Compare commits

..
18 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
22 changed files with 1381 additions and 319 deletions

No files matched your search

Generated
+115 -2
View File
@@ -477,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1100,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1264,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1468,6 +1509,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2744,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2871,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -2971,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3450,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3567,6 +3669,17 @@ dependencies = [
"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 = "synstructure"
version = "0.14.0"
@@ -5228,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5269,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+2
View File
@@ -30,6 +30,7 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
@@ -40,6 +41,7 @@ members = [
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+54 -8
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,6 +26,8 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -124,18 +124,6 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -313,35 +301,4 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+8 -13
View File
@@ -5157,13 +5157,10 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
// The container screen's press state belongs to the screen: a screen
// that takes the pointer over starts with its own state, as
// `AbstractContainerScreen` does, so a press whose release never
// arrived (the pointer left the window) cannot wedge the next
// screen's quick move and clicks.
self.reset_inventory_clicks();
if !capture {
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -6012,14 +6009,12 @@ impl Game<'_> {
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_clicks = Default::default();
self.inv_drag = None;
self.last_inv_click = None;
}
-60
View File
@@ -3211,64 +3211,4 @@ pub(crate) mod tests {
assert_eq!(menu.slot(0), stack("dirt", 1).as_ref());
});
}
/// #1737: a Shift+Right-Click quick move in a chest (a double chest's
/// 54 slots) moves the stack and leaves the menu's state clean, so the
/// next quick move still moves its stack and the inventory still
/// answers.
#[test]
fn a_chest_quick_move_keeps_the_next_one_working() {
with_menu(Kind::Chest(6), |env, menu| {
menu.creative = false;
// The hotbar holds two stacks; the chest is empty.
menu.set_slot(env, 81, stack("cobblestone", 64));
menu.set_slot(env, 82, stack("dirt", 32));
// Shift+Right-Click on the hotbar: the stack goes into the chest.
menu.click(env, 81, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(81), None);
assert_eq!(menu.slot(0).map(|s| s.count), Some(64));
// Nothing is left on the cursor and no quick craft is running.
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
// The next quick move still works.
menu.click(env, 82, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(82), None);
assert_eq!(menu.slot(1).map(|s| s.count), Some(32));
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
});
}
/// #1832: a potion taken from the creative inventory and put in the
/// hotbar keeps its `PotionContents` (the brew), as vanilla's
/// `PotionContents.createItemStack` builds it: the item tab's stack is
/// the real one, and the hotbar slot holds it as it is.
#[test]
fn a_creative_potion_keeps_its_contents_in_the_hotbar() {
let snbt = r#"{components:{"minecraft:potion_contents":{potion:"minecraft:swiftness"}},count:1,id:"minecraft:potion"}"#;
let potion = crate::item_data::parse_stack(snbt).unwrap().unwrap();
assert_eq!(potion.potion(), Some("minecraft:swiftness"));
// The creative screen hands the stack over...
let mut carried = None;
crate::creative::click_display(
&mut carried,
Some(&potion),
|_: &ItemStack| 1,
0,
Input::Pickup,
);
assert_eq!(
carried.as_ref().and_then(|s| s.potion()),
Some("minecraft:swiftness")
);
// ... and the hotbar slot keeps it: it is not the uncraftable
// potion, which is a `minecraft:potion` with no contents at all.
with_menu(Kind::ItemPicker, |env, menu| {
menu.carried = carried;
menu.click(env, 45, 0, Input::Pickup).unwrap();
let in_hand = menu.slot(45).expect("the hotbar slot");
assert_eq!(in_hand.potion(), Some("minecraft:swiftness"));
assert_eq!(&*in_hand.item, "minecraft:potion");
});
}
}
-186
View File
@@ -4043,190 +4043,4 @@ mod tests {
"{walked} steps over {metres} step-metres"
);
}
/// Vanilla sprint is a state, not a held key: `LocalPlayer.aiStep` starts
/// it with the sprint key and stops it only on a vanilla stop condition.
/// Releasing the key while swimming keeps the sprint and its swim speed.
#[test]
fn a_sprint_survives_the_sprint_key_coming_up_while_swimming() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let forward = crate::sprint::Keys {
forward: true,
..Default::default()
};
let sprinting = crate::sprint::Keys {
sprint: true,
..forward
};
// The same swim with the sprint key held, and with the key released
// once the sprint has started: the speed must not change.
let mut swims = Vec::new();
for release in [false, true] {
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
for x in 19..=21 {
for y in 4..=8 {
for z in 19..=31 {
scene.set(x, y, z, water).unwrap();
}
}
}
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
let mut speed = Vec::new();
for tick in 0..12 {
// The key comes up on the fourth tick, after the sprint has
// started and the player swims.
let keys = if release && tick >= 4 {
forward
} else {
sprinting
};
w.controlled_simulation_tick(keys).unwrap();
speed.push(w.player.velocity.z);
}
assert!(
w.player.sprinting,
"the sprint state survives the sprint key in water"
);
assert!(w.player.swimming, "a sprinting player in water swims");
swims.push(speed);
}
assert_eq!(
swims[0], swims[1],
"the swim speed must not drop when the sprint key comes up"
);
}
/// Vanilla: `LivingEntity.jumpFromGround` adds its 0.2 forward impulse
/// while the sprint state holds, so a jump taken after the sprint key
/// comes up is still a sprint jump.
#[test]
fn a_sprint_jump_keeps_its_impulse_when_the_sprint_key_comes_up() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
let forward = crate::sprint::Keys {
forward: true,
..Default::default()
};
let sprinting = crate::sprint::Keys {
sprint: true,
..forward
};
for _ in 0..20 {
w.controlled_simulation_tick(sprinting).unwrap();
}
assert!(w.player.ground.on_ground, "the player settles on the floor");
assert!(w.player.sprinting, "the sprint key starts the sprint");
let running = w.player.velocity.z;
// The sprint key comes up and the player jumps.
w.controlled_simulation_tick(crate::sprint::Keys {
jump: true,
..forward
})
.unwrap();
assert!(
w.player.sprinting,
"the sprint state survives the sprint key in the air"
);
assert!(
w.player.velocity.z > running + 0.05,
"the sprint jump keeps its forward impulse: {:?} vs {running}",
w.player.velocity
);
}
/// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability
/// speed, doubled while the sprint state holds. The sprint key starts the
/// state; releasing it does not change the speed.
#[test]
fn the_flight_speed_follows_the_sprint_state_not_the_sprint_key() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.player.may_fly = true;
w.player.flying = true;
let forward = crate::sprint::Keys {
forward: true,
..Default::default()
};
let sprinting = crate::sprint::Keys {
sprint: true,
..forward
};
assert_eq!(
w.flying_speed().unwrap(),
0.05,
"a flying player without a sprint moves at the ability speed"
);
// The sprint key starts the sprint, and the state doubles the speed.
w.controlled_simulation_tick(sprinting).unwrap();
assert!(w.player.sprinting, "the sprint key starts the sprint");
assert_eq!(
w.flying_speed().unwrap(),
0.1,
"a sprinting player flies at twice the ability speed"
);
// Releasing the sprint key keeps the state, so the speed stays.
w.controlled_simulation_tick(forward).unwrap();
assert!(
w.player.sprinting,
"the sprint state survives the sprint key in the air"
);
assert_eq!(
w.flying_speed().unwrap(),
0.1,
"the flight speed follows the sprint state, not the sprint key"
);
}
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "horizon-mod-lua"
version = "0.1.0"
edition.workspace = true
license.workspace = true
description = "Write Horizon mods in Lua: a Lua interpreter (piccolo) running inside the mod's WebAssembly sandbox"
# One feature per capability, as the mod's horizon-mod.toml lists them: the
# game refuses a mod whose component imports an interface it didn't declare,
# so only the `horizon.<interface>` tables a feature names are compiled in.
[features]
game-read = []
chat = []
hud = []
storage = []
[dependencies]
horizon-mod-sdk = { path = "../horizon-mod-sdk" }
# Pure Rust, so it builds for wasm32-unknown-unknown (C Lua needs setjmp). MIT.
piccolo = "0.3.3"
# piccolo's hash tables (ahash) and math.random (rand) ask getrandom for
# seeds; a mod has no entropy source, so they get a fixed sequence (below).
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom02 = { package = "getrandom", version = "0.2", features = ["custom"] }
getrandom03 = { package = "getrandom", version = "0.3" }
[lints]
workspace = true
+574
View File
@@ -0,0 +1,574 @@
//! Horizon mods in Lua (`docs/mods.md`, "Lua"): a Lua 5.4 interpreter
//! ([piccolo](https://github.com/kyren/piccolo), pure Rust) compiled into the
//! mod's WebAssembly component with the mod's script, so a Lua mod runs in
//! the same sandbox, with the same capabilities and time budgets, as a Rust
//! one. The mod's crate is one line:
//!
//! ```ignore
//! horizon_mod_lua::lua_mod!("main.lua");
//! ```
//!
//! The script registers its commands with `horizon.command(name)` and
//! defines the hooks it wants as globals: `on_tick(tick)`,
//! `on_frame(seconds)`, `on_command(name, args)` (a string returned is the
//! feedback, `error(text)` the red error) and `on_chat(text)`. The
//! interfaces are tables of `horizon`: `log` always, and `game`, `chat`,
//! `hud`, `storage` with this crate's feature of the same capability
//! (`game-read` for `game`), as the mod's `horizon-mod.toml` lists them.
#[cfg(feature = "chat")]
use horizon_mod_sdk::chat;
#[cfg(feature = "game-read")]
use horizon_mod_sdk::game;
#[cfg(feature = "hud")]
use horizon_mod_sdk::hud;
#[cfg(feature = "storage")]
use horizon_mod_sdk::storage;
use horizon_mod_sdk::{Registration, log};
use piccolo::{
Callback, CallbackReturn, Closure, Context, Error, Executor, Lua, StashedExecutor, StaticError,
Table, Value, Variadic,
};
use std::cell::RefCell;
mod stdlib;
/// Seeds for piccolo's hash tables and `math.random`: a mod's sandbox has
/// no entropy source, so they come from a fixed sequence (SplitMix64).
/// `getrandom` 0.3 takes it with `--cfg getrandom_backend="custom"`, which
/// `cargo horizon-mod build` passes.
#[cfg(target_arch = "wasm32")]
mod entropy {
use std::cell::Cell;
std::thread_local! {
static STATE: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
}
fn fill(dest: &mut [u8]) {
for chunk in dest.chunks_mut(8) {
let z = STATE.with(|s| {
let z = s.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
s.set(z);
z
});
let mut z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
}
}
fn fill02(dest: &mut [u8]) -> Result<(), getrandom02::Error> {
fill(dest);
Ok(())
}
getrandom02::register_custom_getrandom!(fill02);
/// getrandom 0.3's custom backend.
///
/// # Safety
/// `dest` points to `len` writable bytes (getrandom's contract).
#[unsafe(no_mangle)]
unsafe extern "Rust" fn __getrandom_v03_custom(
dest: *mut u8,
len: usize,
) -> Result<(), getrandom03::Error> {
// SAFETY: getrandom passes a buffer of `len` bytes it owns.
fill(unsafe { std::slice::from_raw_parts_mut(dest, len) });
Ok(())
}
}
#[doc(hidden)]
pub use horizon_mod_sdk as __sdk;
std::thread_local! {
/// What `horizon.command` registered while the script's chunk ran.
static COMMANDS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
/// A hook's argument.
pub enum Arg {
Integer(i64),
Number(f64),
Text(String),
}
/// A running Lua mod: the interpreter with the script loaded.
pub struct LuaMod {
lua: Lua,
/// One executor, restarted for each call (a new one per call costs a
/// thread's allocation).
executor: StashedExecutor,
}
/// A Lua error as the player reads it: the message alone for `error(text)`.
fn message(e: StaticError) -> String {
match e {
StaticError::Lua(e) => e.to_string(),
StaticError::Runtime(e) => e.to_string(),
}
}
impl LuaMod {
/// Runs the script's chunk; the hooks it defined and the commands it
/// registered make the [`Registration`].
pub fn start(name: &str, source: &str) -> Result<(Self, Registration), String> {
let mut lua = Lua::core();
lua.try_enter(|ctx| {
stdlib::install(ctx)?;
install(ctx)
})
.map_err(message)?;
COMMANDS.with(|c| c.borrow_mut().clear());
let chunk = lua
.try_enter(|ctx| {
let closure = Closure::load(ctx, Some(name), source.as_bytes())?;
Ok(ctx.stash(Executor::start(ctx, closure.into(), ())))
})
.map_err(message)?;
lua.execute::<()>(&chunk).map_err(message)?;
let mut registration = Registration::new();
lua.enter(|ctx| {
let defined = |hook: &'static str| matches!(ctx.get_global(hook), Value::Function(_));
registration.tick = defined("on_tick");
registration.frame = defined("on_frame");
registration.chat = defined("on_chat");
});
registration.commands = COMMANDS.with(|c| std::mem::take(&mut *c.borrow_mut()));
let executor = lua.enter(|ctx| ctx.stash(Executor::new(ctx)));
Ok((LuaMod { lua, executor }, registration))
}
/// Calls the global function `hook` with `args`: what it returned, as
/// text (`None` for nothing or nil), or the error it raised. A hook the
/// script didn't define returns `None`.
pub fn call(&mut self, hook: &'static str, args: Vec<Arg>) -> Result<Option<String>, String> {
let executor = &self.executor;
let found = self
.lua
.try_enter(|ctx| {
let Value::Function(f) = ctx.get_global(hook) else {
return Ok(false);
};
let args: Vec<Value> = args
.into_iter()
.map(|a| match a {
Arg::Integer(i) => Value::Integer(i),
Arg::Number(n) => Value::Number(n),
Arg::Text(s) => ctx.intern(s.as_bytes()).into(),
})
.collect();
ctx.fetch(executor).restart(ctx, f, Variadic(args));
Ok(true)
})
.map_err(message)?;
if !found {
return Ok(None);
}
self.lua.finish(executor);
self.lua
.try_enter(|ctx| {
let value = ctx.fetch(executor).take_result::<Value>(ctx)??;
Ok(match value {
Value::Nil => None,
Value::String(s) => Some(s.to_str_lossy().into_owned()),
other => Some(other.to_string()),
})
})
.map_err(message)
}
/// A hook that returns nothing: an error goes to the mod's log.
pub fn run(&mut self, hook: &'static str, args: Vec<Arg>) {
if let Err(e) = self.call(hook, args) {
log::write(log::Level::Error, &format!("{hook}: {e}"));
}
}
}
/// `fn(args) -> results` as a Lua function.
fn function<'gc>(
ctx: Context<'gc>,
f: impl Fn(Context<'gc>, Vec<Value<'gc>>) -> Result<Vec<Value<'gc>>, Error<'gc>> + 'static,
) -> Callback<'gc> {
Callback::from_fn(&ctx, move |ctx, _, mut stack| {
let args: Vec<Value> = stack.drain(..).collect();
let out = f(ctx, args)?;
stack.extend(out);
Ok(CallbackReturn::Return)
})
}
fn text<'gc>(ctx: Context<'gc>, s: &str) -> Value<'gc> {
ctx.intern(s.as_bytes()).into()
}
/// The argument at `i` as text (numbers convert, as Lua's `tostring`).
fn arg_text(args: &[Value], i: usize, what: &str) -> Result<String, String> {
match args.get(i).copied().unwrap_or(Value::Nil) {
Value::String(s) => Ok(s.to_str_lossy().into_owned()),
Value::Integer(n) => Ok(n.to_string()),
Value::Number(n) => Ok(n.to_string()),
other => Err(format!(
"{what} must be a string, not {}",
other.type_name()
)),
}
}
fn lua_error<'gc>(ctx: Context<'gc>, message: String) -> Error<'gc> {
text(ctx, &message).into()
}
#[cfg(feature = "game-read")]
fn table<'gc>(
ctx: Context<'gc>,
fields: &[(&'static str, Value<'gc>)],
) -> Result<Table<'gc>, Error<'gc>> {
let t = Table::new(&ctx);
for &(k, v) in fields {
t.set(ctx, k, v)?;
}
Ok(t)
}
/// The `horizon` table and `print` (to the mod's log).
fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
let horizon = Table::new(&ctx);
horizon.set(
ctx,
"command",
function(ctx, |ctx, args| {
let name = arg_text(&args, 0, "the command's name").map_err(|e| lua_error(ctx, e))?;
COMMANDS.with(|c| c.borrow_mut().push(name));
Ok(vec![])
}),
)?;
let log_table = Table::new(&ctx);
for (name, level) in [
("debug", log::Level::Debug),
("info", log::Level::Info),
("warn", log::Level::Warn),
("error", log::Level::Error),
] {
log_table.set(
ctx,
name,
function(ctx, move |ctx, args| {
let message = arg_text(&args, 0, "the message").map_err(|e| lua_error(ctx, e))?;
log::write(level, &message);
Ok(vec![])
}),
)?;
}
horizon.set(ctx, "log", log_table)?;
ctx.set_global(
"print",
function(ctx, |_, args| {
let line: Vec<String> = args
.iter()
.map(|v| match v {
Value::String(s) => s.to_str_lossy().into_owned(),
other => other.to_string(),
})
.collect();
log::write(log::Level::Info, &line.join("\t"));
Ok(vec![])
}),
)?;
#[cfg(feature = "game-read")]
{
let game_table = Table::new(&ctx);
game_table.set(
ctx,
"player",
function(ctx, |ctx, _| {
let Some(p) = game::get_player() else {
return Ok(vec![Value::Nil]);
};
let t = table(
ctx,
&[
("x", Value::Number(p.x)),
("y", Value::Number(p.y)),
("z", Value::Number(p.z)),
("yaw", Value::Number(p.yaw.into())),
("pitch", Value::Number(p.pitch.into())),
("health", Value::Number(p.health.into())),
("food", Value::Integer(p.food.into())),
("dimension", text(ctx, &p.dimension)),
],
)?;
Ok(vec![t.into()])
}),
)?;
game_table.set(
ctx,
"clock",
function(ctx, |ctx, _| {
let Some(c) = game::get_clock() else {
return Ok(vec![Value::Nil]);
};
let t = table(
ctx,
&[
("game_time", Value::Integer(c.game_time as i64)),
("day_time", Value::Integer(c.day_time as i64)),
],
)?;
Ok(vec![t.into()])
}),
)?;
horizon.set(ctx, "game", game_table)?;
}
#[cfg(feature = "chat")]
{
let chat_table = Table::new(&ctx);
chat_table.set(
ctx,
"show",
function(ctx, |ctx, args| {
let line = arg_text(&args, 0, "the line").map_err(|e| lua_error(ctx, e))?;
chat::show(&line);
Ok(vec![])
}),
)?;
horizon.set(ctx, "chat", chat_table)?;
}
#[cfg(feature = "hud")]
{
let hud_table = Table::new(&ctx);
hud_table.set(
ctx,
"set_panel",
function(ctx, |ctx, args| {
let corner = match arg_text(&args, 0, "the corner").map_err(|e| lua_error(ctx, e))?.as_str() {
"top-left" => hud::Corner::TopLeft,
"top-right" => hud::Corner::TopRight,
"bottom-left" => hud::Corner::BottomLeft,
"bottom-right" => hud::Corner::BottomRight,
other => {
return Err(lua_error(
ctx,
format!("no corner {other:?}: top-left, top-right, bottom-left or bottom-right"),
));
}
};
let Some(Value::Table(rows)) = args.get(1).copied() else {
return Err(lua_error(ctx, "the lines must be a table".into()));
};
let mut lines = Vec::new();
for i in 1..=rows.length() {
let line = match rows.get(ctx, i) {
Value::Table(row) => hud::Line {
text: arg_text(&[row.get(ctx, "text")], 0, "a line's text")
.map_err(|e| lua_error(ctx, e))?,
color: row.get(ctx, "color").to_integer().unwrap_or(0xFFFFFF) as u32,
},
v => hud::Line {
text: arg_text(&[v], 0, "a line").map_err(|e| lua_error(ctx, e))?,
color: 0xFFFFFF,
},
};
lines.push(line);
}
hud::set_panel(corner, &lines);
Ok(vec![])
}),
)?;
hud_table.set(
ctx,
"clear",
function(ctx, |_, _| {
hud::clear();
Ok(vec![])
}),
)?;
horizon.set(ctx, "hud", hud_table)?;
}
#[cfg(feature = "storage")]
{
let storage_table = Table::new(&ctx);
storage_table.set(
ctx,
"get",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
Ok(vec![match storage::get(&key) {
Some(bytes) => ctx.intern(&bytes).into(),
None => Value::Nil,
}])
}),
)?;
storage_table.set(
ctx,
"set",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
let value = match args.get(1).copied().unwrap_or(Value::Nil) {
Value::String(s) => s.as_bytes().to_vec(),
v => arg_text(&[v], 0, "the value")
.map_err(|e| lua_error(ctx, e))?
.into_bytes(),
};
// Lua's way: true, or nil and why.
Ok(match storage::set(&key, &value) {
Ok(()) => vec![Value::Boolean(true)],
Err(e) => vec![Value::Nil, text(ctx, &e)],
})
}),
)?;
storage_table.set(
ctx,
"delete",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
storage::delete(&key);
Ok(vec![])
}),
)?;
horizon.set(ctx, "storage", storage_table)?;
}
ctx.set_global("horizon", horizon)?;
Ok(())
}
/// A Lua mod: the crate's `src/lib.rs` is `horizon_mod_lua::lua_mod!("main.lua");`,
/// the script's path from the crate's folder.
#[macro_export]
macro_rules! lua_mod {
($script:literal) => {
const _: () = {
// The SDK's export macro names the SDK's crate.
use $crate::__sdk as horizon_mod_sdk;
struct LuaMod($crate::LuaMod);
impl horizon_mod_sdk::Mod for LuaMod {
fn init() -> Result<(Self, horizon_mod_sdk::Registration), String> {
let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $script));
let (m, registration) = $crate::LuaMod::start($script, source)?;
Ok((LuaMod(m), registration))
}
fn tick(&mut self, tick: u64) {
self.0
.run("on_tick", vec![$crate::Arg::Integer(tick as i64)]);
}
fn frame(&mut self, seconds: f32) {
self.0
.run("on_frame", vec![$crate::Arg::Number(seconds.into())]);
}
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
self.0.call(
"on_command",
vec![
$crate::Arg::Text(name.into()),
$crate::Arg::Text(args.into()),
],
)
}
fn chat(&mut self, text: &str) {
self.0.run("on_chat", vec![$crate::Arg::Text(text.into())]);
}
}
horizon_mod_sdk::export_mod!(LuaMod);
};
};
}
#[cfg(test)]
mod tests {
use super::*;
/// Loads `source` and calls its global `f`.
fn run(source: &str) -> Result<Option<String>, String> {
let (mut m, _) = LuaMod::start("test.lua", source)?;
m.call("on_command", vec![])
}
#[test]
fn the_script_makes_the_registration() {
let (_, r) = LuaMod::start(
"test.lua",
r#"horizon.command("where") horizon.command("coords")
function on_tick(t) end
function on_command(name, args) end"#,
)
.unwrap();
assert_eq!(r.commands, ["where", "coords"]);
assert!(r.tick && !r.frame && !r.chat);
}
#[test]
fn a_command_answers_or_errs() {
assert_eq!(
run("function on_command() return 'done' end"),
Ok(Some("done".into()))
);
assert_eq!(run("function on_command() end"), Ok(None));
assert_eq!(
run("function on_command() error('Not in a world') end"),
Err("Not in a world".into())
);
assert!(run("function on_command() return nil + 1 end").is_err());
assert!(LuaMod::start("test.lua", "this is not lua").is_err());
}
#[test]
fn string_format_as_c_prints() {
let f = |args: &str| {
run(&format!(
"function on_command() return string.format({args}) end"
))
};
assert_eq!(
f("'XYZ %.1f / %.1f', 12.5, -3.25"),
Ok(Some("XYZ 12.5 / -3.2".into()))
);
assert_eq!(
f("'Day %d, %02d:%02d', 2, 6, 5"),
Ok(Some("Day 2, 06:05".into()))
);
assert_eq!(
f("'[%5s|%-4d|%x|%X|%+d]', 'ab', 7, 255, 255, 3"),
Ok(Some("[ ab|7 |ff|FF|+3]".into()))
);
assert_eq!(
f("'%g %g %e', 0.0001, 100000, 1234.5"),
Ok(Some("0.0001 100000 1.234500e+03".into()))
);
assert_eq!(f("'%.0f%%', 99.6"), Ok(Some("100%".into())));
assert_eq!(f("'%s %s', 1.0, nil"), Ok(Some("1.0 nil".into())));
assert!(
f("'%d', 1.5")
.unwrap_err()
.contains("no integer representation")
);
}
#[test]
fn table_insert_remove_concat() {
let script = r#"function on_command()
local t = {}
table.insert(t, "b")
table.insert(t, "c")
table.insert(t, 1, "a")
local last = table.remove(t)
return table.concat(t, ",") .. "|" .. last .. "|" .. string.rep("ab", 3, "-")
end"#;
assert_eq!(run(script), Ok(Some("a,b|c|ab-ab-ab".into())));
}
#[test]
fn rep_and_concat_keep_bytes() {
let script = r#"function on_command()
local r = string.rep("\255", 2, "\0")
local c = table.concat({"\254", 7, "\128"}, "\255")
return tostring(r == "\255\0\255" and #r == 3) .. " " .. tostring(c == "\254\255" .. "7\255\128" and #c == 5)
end"#;
assert_eq!(run(script), Ok(Some("true true".into())));
}
}
+337
View File
@@ -0,0 +1,337 @@
//! What piccolo's standard library leaves out that mods reach for:
//! `string.format`, `string.rep`, `table.insert`, `table.remove` and
//! `table.concat`, as Lua 5.4 defines them (the subset of `format` below).
use crate::{function, lua_error, text};
use piccolo::{Context, Error, Table, Value};
/// Adds them to the `string` and `table` tables.
pub fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
let string = match ctx.get_global("string") {
Value::Table(t) => t,
_ => {
let t = Table::new(&ctx);
ctx.set_global("string", t)?;
t
}
};
string.set(
ctx,
"format",
function(ctx, |ctx, args| {
let out = format(&args)
.map_err(|e| lua_error(ctx, format!("bad argument to 'format' ({e})")))?;
Ok(vec![text(ctx, &out)])
}),
)?;
string.set(
ctx,
"rep",
function(ctx, |ctx, args| {
let s = bytes_arg(&args, 0).ok_or_else(|| {
lua_error(ctx, "bad argument #1 to 'rep' (string expected)".into())
})?;
let n = args.get(1).and_then(|v| v.to_integer()).unwrap_or(0).max(0) as usize;
let sep = bytes_arg(&args, 2).unwrap_or_default();
if (s.len() + sep.len()).saturating_mul(n) > 1 << 20 {
return Err(lua_error(ctx, "resulting string too large".into()));
}
Ok(vec![bytes(ctx, &vec![s; n].join(&sep[..]))])
}),
)?;
let table = match ctx.get_global("table") {
Value::Table(t) => t,
_ => {
let t = Table::new(&ctx);
ctx.set_global("table", t)?;
t
}
};
table.set(
ctx,
"insert",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'insert' (table expected)".into(),
));
};
let n = t.length();
match args.len() {
2 => {
t.set(ctx, n + 1, args[1])?;
}
3 => {
let at = args[1]
.to_integer()
.filter(|&i| (1..=n + 1).contains(&i))
.ok_or_else(|| {
lua_error(
ctx,
"bad argument #2 to 'insert' (position out of bounds)".into(),
)
})?;
for i in (at..=n).rev() {
t.set(ctx, i + 1, t.get(ctx, i))?;
}
t.set(ctx, at, args[2])?;
}
_ => {
return Err(lua_error(
ctx,
"wrong number of arguments to 'insert'".into(),
));
}
}
Ok(vec![])
}),
)?;
table.set(
ctx,
"remove",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'remove' (table expected)".into(),
));
};
let n = t.length();
let at = args.get(1).and_then(|v| v.to_integer()).unwrap_or(n);
if n == 0 && args.len() < 2 {
return Ok(vec![Value::Nil]);
}
if !(1..=n + 1).contains(&at) {
return Err(lua_error(
ctx,
"bad argument #2 to 'remove' (position out of bounds)".into(),
));
}
let removed = t.get(ctx, at);
for i in at..n {
t.set(ctx, i, t.get(ctx, i + 1))?;
}
if at <= n {
t.set(ctx, n, Value::Nil)?;
}
Ok(vec![removed])
}),
)?;
table.set(
ctx,
"concat",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'concat' (table expected)".into(),
));
};
let sep = bytes_arg(&args, 1).unwrap_or_default();
let first = args.get(2).and_then(|v| v.to_integer()).unwrap_or(1);
let last = args
.get(3)
.and_then(|v| v.to_integer())
.unwrap_or_else(|| t.length());
let mut parts = Vec::new();
for i in first..=last {
let v = t.get(ctx, i);
parts.push(bytes_arg(&[v], 0).ok_or_else(|| {
lua_error(
ctx,
format!("invalid value (at index {i}) in table for 'concat'"),
)
})?);
}
Ok(vec![bytes(ctx, &parts.join(&sep[..]))])
}),
)?;
Ok(())
}
/// A string or number argument as bytes: Lua strings are byte strings, so `rep` and `concat`
/// keep bytes that are not UTF-8 (binary values from `horizon.storage`, for one) as they are.
fn bytes_arg(args: &[Value], i: usize) -> Option<Vec<u8>> {
match args.get(i).copied()? {
Value::String(s) => Some(s.as_bytes().to_vec()),
_ => string_arg(args, i).map(String::into_bytes),
}
}
fn bytes<'gc>(ctx: Context<'gc>, b: &[u8]) -> Value<'gc> {
Value::String(piccolo::String::from_slice(&ctx, b))
}
/// A string or number argument as text, as Lua coerces them.
fn string_arg(args: &[Value], i: usize) -> Option<String> {
match args.get(i).copied()? {
Value::String(s) => Some(s.to_str_lossy().into_owned()),
Value::Integer(n) => Some(n.to_string()),
Value::Number(n) => Some(number_text(n)),
_ => None,
}
}
/// `tostring` of a float: `1.0`, `0.5`, `1e+100`.
fn number_text(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{n:.1}")
} else {
format!("{n}")
}
}
/// `string.format` for `%d %i %u %c %x %X %o %f %F %e %E %g %G %s %q %%` with
/// flags `-0+ #`, a width and a precision.
fn format(args: &[Value]) -> Result<String, String> {
let template = string_arg(args, 0).ok_or("#1: string expected")?;
let mut out = String::new();
let mut next = 1;
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
if chars.peek() == Some(&'%') {
chars.next();
out.push('%');
continue;
}
let mut flags = String::new();
while let Some(&f) = chars.peek().filter(|f| "-0+ #".contains(**f)) {
flags.push(f);
chars.next();
}
let mut width = String::new();
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
width.push(d);
chars.next();
}
let mut precision = None;
if chars.peek() == Some(&'.') {
chars.next();
let mut p = String::new();
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
p.push(d);
chars.next();
}
precision = Some(p.parse::<usize>().unwrap_or(0));
}
let conversion = chars.next().ok_or("invalid conversion '%' to 'format'")?;
let arg = args.get(next).copied().unwrap_or(Value::Nil);
let what = format!("#{}", next + 1);
next += 1;
let integer = || -> Result<i64, String> {
match arg {
Value::Integer(i) => Ok(i),
Value::Number(n) if n.fract() == 0.0 => Ok(n as i64),
Value::Number(_) => Err(format!("{what}: number has no integer representation")),
_ => Err(format!("{what}: number expected, got {}", arg.type_name())),
}
};
let float = || -> Result<f64, String> {
arg.to_number()
.ok_or_else(|| format!("{what}: number expected, got {}", arg.type_name()))
};
let plus = flags.contains('+');
let body = match conversion {
'd' | 'i' | 'u' => {
let n = integer()?;
if plus && n >= 0 {
format!("+{n}")
} else {
n.to_string()
}
}
'c' => char::from_u32(integer()? as u32).unwrap_or('?').to_string(),
'x' => format!("{:x}", integer()?),
'X' => format!("{:X}", integer()?),
'o' => format!("{:o}", integer()?),
'f' | 'F' => {
let n = float()?;
let s = format!("{n:.*}", precision.unwrap_or(6));
if plus && n >= 0.0 { format!("+{s}") } else { s }
}
'e' | 'E' => {
let s = c_exponent(float()?, precision.unwrap_or(6));
if conversion == 'E' {
s.to_uppercase()
} else {
s
}
}
'g' | 'G' => {
let n = float()?;
let p = precision.unwrap_or(6).max(1);
let exp = if n == 0.0 {
0
} else {
n.abs().log10().floor() as i32
};
let s = if exp < -4 || exp >= p as i32 {
c_exponent(n, p - 1)
} else {
let s = format!("{n:.*}", (p as i32 - 1 - exp).max(0) as usize);
if s.contains('.') && !flags.contains('#') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s
}
};
if conversion == 'G' {
s.to_uppercase()
} else {
s
}
}
's' => {
let s = match arg {
Value::String(s) => s.to_str_lossy().into_owned(),
Value::Number(n) => number_text(n),
Value::Nil => "nil".into(),
other => other.to_string(),
};
match precision {
Some(p) => s.chars().take(p).collect(),
None => s,
}
}
'q' => format!(
"{:?}",
string_arg(&[arg], 0).ok_or(format!("{what}: string expected"))?
),
other => return Err(format!("invalid conversion '%{other}' to 'format'")),
};
let width: usize = width.parse().unwrap_or(0);
let pad = width.saturating_sub(body.chars().count());
if flags.contains('-') {
out.push_str(&body);
out.extend(std::iter::repeat_n(' ', pad));
} else if flags.contains('0') && !matches!(conversion, 's' | 'q' | 'c') {
let (sign, digits) = match body.strip_prefix(['-', '+']) {
Some(rest) => (&body[..1], rest),
None => ("", body.as_str()),
};
out.push_str(sign);
out.extend(std::iter::repeat_n('0', pad));
out.push_str(digits);
} else {
out.extend(std::iter::repeat_n(' ', pad));
out.push_str(&body);
}
}
Ok(out)
}
/// C's `%e`: `1.500000e+02`.
fn c_exponent(n: f64, precision: usize) -> String {
let s = format!("{n:.*e}", precision);
let (mantissa, exp) = s.split_once('e').unwrap_or((&s, "0"));
let exp: i32 = exp.parse().unwrap_or(0);
format!(
"{mantissa}e{}{:02}",
if exp < 0 { '-' } else { '+' },
exp.abs()
)
}
@@ -0,0 +1,16 @@
[package]
name = "coords-hud-lua"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Example Horizon mod in Lua: the coords-hud example, line for line"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-lua = { path = "../../../horizon-mod-lua", features = ["game-read", "hud", "chat"] }
[lints]
workspace = true
@@ -0,0 +1,8 @@
id = "coords_hud_lua"
name = "Coordinates HUD (Lua)"
version = "0.1.0"
description = "The Coordinates HUD example written in Lua: your coordinates, facing and the time of day in a corner of the screen. /where puts them in chat, /coords hides or shows the panel."
authors = ["Horizon"]
api = "0.1"
side = "client"
capabilities = ["game-read", "hud", "chat"]
@@ -0,0 +1,67 @@
-- Example Horizon mod in Lua (docs/mods.md, "Lua"): coordinates, facing and
-- the time of day in the top-left corner, /where to put them in chat and
-- /coords to hide or show the panel. The same mod as examples/coords-hud
-- in Rust, and the same test checks both.
local shown = true
-- The panel last sent, so an unchanged one costs no call.
local last = ""
horizon.command("where")
horizon.command("coords")
-- Direction.fromYRot: yaw 0 faces south, 90 west.
local FACING = { "South (+Z)", "West (-X)", "North (-Z)", "East (+X)" }
local function facing(yaw)
return FACING[(math.floor(yaw / 90 + 0.5) & 3) + 1]
end
-- "Day 3, 14:05": day time 0 is 6:00 on day 1.
local function time_of_day(clock)
local day = clock.day_time // 24000 + 1
local ticks = clock.day_time % 24000
local hours = (ticks // 1000 + 6) % 24
local minutes = ticks % 1000 * 60 // 1000
return string.format("Day %d, %02d:%02d", day, hours, minutes)
end
local function lines(p, clock)
local out = {
string.format("XYZ %.1f / %.1f / %.1f", p.x, p.y, p.z),
"Facing " .. facing(p.yaw),
}
if clock then
table.insert(out, time_of_day(clock))
end
return out
end
function on_tick(tick)
local p = shown and horizon.game.player()
if not p then
if last ~= "" then
horizon.hud.clear()
last = ""
end
return
end
local now = lines(p, horizon.game.clock())
local key = table.concat(now, "\n")
if key ~= last then
horizon.hud.set_panel("top-left", now)
last = key
end
end
function on_command(name, args)
if name == "coords" then
shown = not shown
return "Coordinates panel " .. (shown and "shown" or "hidden")
end
local p = horizon.game.player()
if not p then
error("Not in a world")
end
horizon.chat.show(string.format("%.0f, %.0f, %.0f in %s",
math.floor(p.x), math.floor(p.y), math.floor(p.z), p.dimension))
end
@@ -0,0 +1,2 @@
//! The coords-hud example in Lua: the mod is `main.lua`.
horizon_mod_lua::lua_mod!("main.lua");
+61 -5
View File
@@ -1,6 +1,6 @@
//! Loads real mods built with the SDK: the coords-hud and predator-missile
//! examples and a guest
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
//! Loads real mods built with the SDK: the coords-hud example (in Rust and
//! in Lua), the predator-missile example and a guest that misbehaves on
//! command. Needs `rustup target add wasm32-unknown-unknown`.
use horizon_mods::{
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
ServerPlayer, Shape, Snapshot, Status,
@@ -39,10 +39,17 @@ fn mods_dir() -> &'static Path {
let status = Command::new(env!("CARGO"))
.current_dir(&root)
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
// As `cargo horizon-mod build` does, for the Lua runtime's seeds.
.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
"--cfg getrandom_backend=\"custom\"",
)
.args([
"-p",
"coords-hud",
"-p",
"coords-hud-lua",
"-p",
"predator-missile",
"-p",
"welcome",
@@ -70,6 +77,7 @@ fn mods_dir() -> &'static Path {
};
let mods = [
("coords_hud", example("coords-hud")),
("coords_hud_lua", example("coords-hud-lua")),
("predator_missile", example("predator-missile")),
("welcome", example("welcome")),
("beacons", example("beacons")),
@@ -140,9 +148,19 @@ fn predator_missile_runs_commands() {
assert!(blast.iter().all(|l| l.contains("summon tnt")));
}
/// The same example in Rust and in Lua: the same checks pass for both.
#[test]
fn coords_hud_draws_and_answers() {
let mut mods = load("coords_hud.hmod");
for (hmod, name) in [
("coords_hud.hmod", "Coordinates HUD"),
("coords_hud_lua.hmod", "Coordinates HUD (Lua)"),
] {
coords_hud_checks(hmod, name);
}
}
fn coords_hud_checks(hmod: &str, name: &str) {
let mut mods = load(hmod);
assert_eq!(mods.entries()[0].status, Status::Running);
assert_eq!(mods.commands().collect::<Vec<_>>(), ["where", "coords"]);
mods.set_snapshot(player());
@@ -161,7 +179,7 @@ fn coords_hud_draws_and_answers() {
assert!(mods.command("where"));
let chat = Output::Chat {
name: "Coordinates HUD".into(),
name: name.into(),
text: "12, 64, -4 in minecraft:overworld".into(),
};
assert_eq!(mods.drain(), [chat]);
@@ -174,6 +192,44 @@ fn coords_hud_draws_and_answers() {
};
assert_eq!(mods.drain(), [hidden]);
assert!(!mods.command("gamemode creative"));
// On the title screen /where is an error, `error()` in Lua.
mods.set_snapshot(Snapshot::default());
assert!(mods.command("where"));
let error = Output::Feedback {
text: "Not in a world".into(),
error: true,
};
assert_eq!(mods.drain(), [error], "{hmod}");
}
/// What a tick of the HUD example costs, in Rust and in Lua: the player
/// moves every tick, so each one formats and sends a new panel.
#[test]
fn coords_hud_tick_cost_in_rust_and_lua() {
for hmod in ["coords_hud.hmod", "coords_hud_lua.hmod"] {
let mut mods = load(hmod);
let mut snapshot = player();
// Warm up: the first calls compile and fill caches.
for t in 0..100 {
snapshot.player.as_mut().unwrap().x += 0.1;
mods.set_snapshot(snapshot.clone());
mods.tick(t);
}
let ticks = 2000;
let started = Instant::now();
for t in 0..ticks {
snapshot.player.as_mut().unwrap().x += 0.1;
mods.set_snapshot(snapshot.clone());
mods.tick(t);
}
let per_tick = started.elapsed() / ticks as u32;
assert_eq!(mods.entries()[0].status, Status::Running, "{hmod}");
println!("{hmod}: {per_tick:?} per tick (a new panel each tick)");
assert!(
per_tick.as_micros() < 1000,
"{hmod} took {per_tick:?} per tick"
);
}
}
#[test]
+2
View File
@@ -121,5 +121,7 @@ What real mods need next, in the order modpacks and servers need it:
use-stop, key-down, key-up).
- Experimental `render` (`experimental = ["render"]`): HUD shapes (fill, quad, sprite, item, text), `gui-size`, camera
`shake`, drawn by the game's own renderer.
- SDK: `horizon-mod-lua`, mods written in Lua 5.4 (the interpreter built into the mod, same sandbox and API);
`cargo horizon-mod new --template lua`; example `coords-hud-lua`.
- API 0.2 (content): data-driven blocks and items, tags, `horizon:add_to`, terrain adaptation.
- API 0.1.0: `log`, `game`, `chat`, `hud`, `storage`; `init`, `tick`, `frame`, `command`, `chat`.
+1 -1
View File
@@ -109,7 +109,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
| Script mods: Lua (`horizon-mod-lua`, an interpreter built into the mod; `cargo horizon-mod new --template lua`) | experimental | `horizon-mod-lua` 0.1.0 |
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
| Shader API | not started | |
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
+62 -1
View File
@@ -132,7 +132,8 @@ There is no file, socket, process or raw-input interface, on purpose.
| **wasmtime + component model** | yes: memory, fuel, no imports but ours | yes | WIT → Rust on both sides | 85 ns per empty hook call; 50 ms to compile a 64 KB mod on load | **chosen** |
| wasmtime, core modules with a hand ABI | yes | yes | no: pointers and lengths by hand | a few ns less per call | not worth the fragile ABI |
| wasmi (interpreter) | yes | yes | no components | no compile time, execution ~10-50x slower than cranelift code | kept in mind for a platform without JIT |
| Lua (mlua) or another script | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
| Lua (mlua) or another script, in the game | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
| Lua *inside* the WebAssembly sandbox ([`horizon-mod-lua`](#lua-mods)) | yes: the same as any mod | yes | the same API, as Lua tables | the interpreter in the mod (~240 KB); a HUD tick ~60 µs | **offered** as a way in for Lua writers |
| native `cdylib` plugins | none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players |
| an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research |
@@ -246,6 +247,66 @@ and each of its commands: it must start and never trap or overrun. It checks a
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
key> --signature <file>.minisig`.
### Lua mods
A mod can be written in Lua 5.4 instead of Rust:
`cargo horizon-mod new my-mod --template lua` makes one. The interpreter,
[piccolo](https://github.com/kyren/piccolo) (pure Rust, MIT), is built into
the mod's WebAssembly component with the script by
[`horizon-mod-lua`](../crates/horizon-mod-lua/src/lib.rs). So a Lua mod is an
`.hmod` like any other: the same sandbox, capabilities, time budgets and
checks. Only the toolchain is shared with Rust mods (`cargo horizon-mod build`
compiles the interpreter once, in about 30 s).
```lua
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
```
- **Hooks are global functions the script defines:** `on_tick(tick)`,
`on_frame(seconds)`, `on_command(name, args)` and `on_chat(text)`. The game
calls only those that exist. A command returns its feedback as a string, or
raises `error(text)` for the red error.
- **Commands** are registered with `horizon.command(name)` while the script
loads.
- **The API is a table per interface:**
- `horizon.game.player()` (a table with `x y z yaw pitch health food
dimension`, or nil) and `horizon.game.clock()` (`game_time day_time`).
- `horizon.chat.show(text)`.
- `horizon.hud.set_panel(corner, lines)`, where the corner is
`"top-left"`, ... and the lines are strings or `{text =, color =}`; and
`horizon.hud.clear()`.
- `horizon.storage.get(key)`, `set(key, value)` (`true`, or `nil` and why)
and `delete(key)`.
- `horizon.log.info(text)`, ...; `print` writes to the mod's log.
- **Capabilities are the crate's features too.** The Cargo.toml's
`horizon-mod-lua` features list the same capabilities as
`horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`): only those
tables are built in, and the game refuses a mod whose component uses one
it didn't declare. A capability in the manifest but not in the features
leaves its table out: the script fails with `attempt to index a nil value`.
- **The standard library** is piccolo's: base, `math`, `string` (`len sub
lower upper reverse`), `table` (`pack unpack`) and `coroutine`. The mod
runtime adds `string.format` (`%d %s %f %x %e %g %q`, with flags, width and
precision), `string.rep`, `table.insert`, `table.remove` and `table.concat`.
There is no `io` or `os`, and patterns (`string.find`, `gsub`) aren't there
yet.
- **`math.random`** is seeded from a fixed sequence: the sandbox has no
entropy.
The example [`coords-hud-lua`](../crates/horizon-mod-sdk/examples/coords-hud-lua/main.lua)
is [`coords-hud`](../crates/horizon-mod-sdk/examples/coords-hud/src/lib.rs)
line for line, and the sandbox test runs the same checks on both. With the
player moving every tick (a new panel each one), a tick costs 54–68 µs in Lua
against 22–63 µs in Rust on a busy Mac mini M4 Pro
(`coords_hud_tick_cost_in_rust_and_lua`), under the 0.1 ms the API asks of a
HUD script.
### Testing a mod
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in