mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
19
Commits
No files matched your search
@@ -51,7 +51,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
@@ -71,11 +70,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
@@ -71,7 +71,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -91,9 +91,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
|
||||
Generated
+5
-815
File diff suppressed because it is too large.
Load diff
-13
@@ -23,26 +23,13 @@ members = [
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
|
||||
+9
-9
@@ -144,9 +144,9 @@ Better tools for mod makers: templates for each kind of mod, a test run with no
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
`cargo horizon-mod` makes content mods (`new`, `build`, `run`, `check`; [#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/mods.md](docs/mods.md)). Native mods start from horizon-native-mod-template, and `native.py check` in horizon-mods applies them on a chosen Horizon and builds them together, with a weekly check of every official mod on the newest Nightly ([docs/native-mods.md](docs/native-mods.md)). The WebAssembly SDK and its test runner were removed. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and a CI check that fails when a stable reference entry is removed without a new major version ([docs/api/stability.md](docs/api/stability.md)).
|
||||
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -390,7 +390,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -400,7 +400,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -726,7 +726,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -780,11 +780,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -806,7 +806,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -816,7 +816,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
//! when given a key, and for a `.hmod` that it is content only.
|
||||
use horizon_mods::{
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -53,10 +37,6 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,18 +80,8 @@ impl Report {
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
if self.kind == Kind::Mod {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -153,145 +123,26 @@ pub fn check(
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
|
||||
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
|
||||
fn content_only(path: &Path) -> Result<(), String> {
|
||||
match Package::read(path)?.wasm {
|
||||
Some(_) => Err(
|
||||
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
|
||||
.into(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -316,7 +167,7 @@ mod tests {
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
@@ -324,7 +175,7 @@ mod tests {
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-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).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
Some("build") => build().map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
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> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,31 +73,9 @@ 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") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
let files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".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")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."), release)
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
));
|
||||
};
|
||||
}
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
let dir = root.join("target/horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
let bytes = Package::write_files(&manifest_text, None, &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
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);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let path = build()?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
println!("Starting {game} with {}", path.display());
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
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()));
|
||||
@@ -397,7 +242,7 @@ mod tests {
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
@@ -409,17 +254,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
// Code is native now.
|
||||
std::fs::write(path.join("Cargo.toml"), "").unwrap();
|
||||
assert!(build_in(&path).unwrap_err().contains("native"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK 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"]
|
||||
@@ -1,19 +0,0 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -1,14 +0,0 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Hook points for native mods (docs/native-mods.md, "Hook points"): empty
|
||||
//! Hook points for native mods (docs/native-mods.md, "Mods on the hook points"): empty
|
||||
//! functions the game calls at fixed places, for horizon-mixins bundles to
|
||||
//! hook with HEAD. Hooks that answer take an out parameter (`claimed`,
|
||||
//! `zoom`…) so several mods can hook the same point. A native mod keeps its
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
description = "Write Horizon mods in Rust: WebAssembly components for the horizon:mod world (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = { version = "0.60.0", default-features = false, features = ["macros", "realloc"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "beacons"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon server mod: /beacon lights a pillar beside you, /ground says what you stand on"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
id = "beacons"
|
||||
name = "Beacons"
|
||||
version = "0.1.0"
|
||||
description = "/beacon [height] lights a pillar of glowstone beside you; /ground [pos] says what you are standing on (or what is at pos), and in which biome."
|
||||
authors = ["Horizon"]
|
||||
api = "0.3"
|
||||
side = "server"
|
||||
capabilities = ["players", "world"]
|
||||
@@ -1,103 +0,0 @@
|
||||
//! Example Horizon server mod (`docs/mods.md`, "Server mods") with
|
||||
//! capability `world` and typed commands: `/beacon [height]` lights a
|
||||
//! pillar of glowstone two blocks east of the player, `/ground [pos]` reads
|
||||
//! the block under their feet (or at `pos`) and its biome.
|
||||
use horizon_mod_sdk::server::{
|
||||
Args, Argument, BlockChange, Player, Registration, ServerMod, players, world,
|
||||
};
|
||||
|
||||
/// How tall `/beacon` builds when no height is given.
|
||||
const HEIGHT: i32 = 4;
|
||||
|
||||
struct Beacons;
|
||||
|
||||
/// The block the player stands in: their feet, floored.
|
||||
fn feet(player: &Player) -> (i32, i32, i32) {
|
||||
(
|
||||
player.x.floor() as i32,
|
||||
player.y.floor() as i32,
|
||||
player.z.floor() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// The pillar beside `player`: `height` blocks of glowstone, from their feet up.
|
||||
fn pillar(player: &Player, height: i32) -> Vec<BlockChange> {
|
||||
let (x, y, z) = feet(player);
|
||||
(0..height)
|
||||
.map(|dy| BlockChange {
|
||||
x: x + 2,
|
||||
y: y + dy,
|
||||
z,
|
||||
state: "minecraft:glowstone".into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ServerMod for Beacons {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((
|
||||
Beacons,
|
||||
Registration::new()
|
||||
.command_with(
|
||||
"beacon",
|
||||
[Argument::integer("height", Some(1), Some(64)).optional()],
|
||||
)
|
||||
.command_with("ground", [Argument::block_pos("pos").optional()]),
|
||||
))
|
||||
}
|
||||
|
||||
fn command(
|
||||
&mut self,
|
||||
player: &Player,
|
||||
name: &str,
|
||||
args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
let (x, y, z) = feet(player);
|
||||
if name == "beacon" {
|
||||
let height = args.integer("height").unwrap_or(HEIGHT);
|
||||
world::set_blocks(&player.dimension, &pillar(player, height))?;
|
||||
players::broadcast(&format!(
|
||||
"{} lit a beacon at {} {y} {z}",
|
||||
player.name,
|
||||
x + 2
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
let (bx, by, bz) = match args.block_pos("pos") {
|
||||
Some(p) => (p.x, p.y, p.z),
|
||||
None => (x, y - 1, z),
|
||||
};
|
||||
let block =
|
||||
world::get_block(&player.dimension, bx, by, bz).ok_or("That block isn't loaded")?;
|
||||
let biome = world::get_biome(&player.dimension, bx, by, bz).unwrap_or_default();
|
||||
if args.block_pos("pos").is_some() {
|
||||
return Ok(Some(format!("{bx} {by} {bz} is {block} in {biome}")));
|
||||
}
|
||||
Ok(Some(format!("You are standing on {block} in {biome}")))
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_server_mod!(Beacons);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_pillar_stands_two_blocks_east() {
|
||||
let p = Player {
|
||||
id: String::new(),
|
||||
name: "Alex".into(),
|
||||
x: -0.5,
|
||||
y: 64.0,
|
||||
z: 3.2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: horizon_mod_sdk::server::GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let blocks = pillar(&p, HEIGHT);
|
||||
assert_eq!(blocks.len(), HEIGHT as usize);
|
||||
assert_eq!((blocks[0].x, blocks[0].y, blocks[0].z), (1, 64, 3));
|
||||
assert_eq!(blocks[3].y, 67);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
# /ground [pos] reads the block under the player (or at pos) and the biome; /beacon [height] sets glowstone two to the east.
|
||||
|
||||
[[step]]
|
||||
join = "Alex"
|
||||
blocks = [{ x = 0, y = 63, z = 0, state = "minecraft:grass_block[snowy=false]" }]
|
||||
command = "ground"
|
||||
expect.feedback = "You are standing on minecraft:grass_block[snowy=false] in minecraft:plains"
|
||||
|
||||
[[step]]
|
||||
command = "beacon"
|
||||
expect.broadcast = ["Alex lit a beacon at 2 64 0"]
|
||||
expect.blocks = [
|
||||
{ x = 2, y = 64, z = 0, state = "minecraft:glowstone" },
|
||||
{ x = 2, y = 67, z = 0, state = "minecraft:glowstone" },
|
||||
{ x = 2, y = 68, z = 0, state = "minecraft:air" },
|
||||
]
|
||||
|
||||
# Anywhere the script set nothing is air.
|
||||
[[step]]
|
||||
player = { x = 10.5, z = 10.5 }
|
||||
command = "ground"
|
||||
expect.feedback = "You are standing on minecraft:air in minecraft:plains"
|
||||
|
||||
# Typed arguments: a height, a position relative to the player.
|
||||
[[step]]
|
||||
player = { x = 5.5, y = 64.0, z = 5.5 }
|
||||
command = "beacon 2"
|
||||
expect.blocks = [
|
||||
{ x = 7, y = 65, z = 5, state = "minecraft:glowstone" },
|
||||
{ x = 7, y = 66, z = 5, state = "minecraft:air" },
|
||||
]
|
||||
|
||||
[[step]]
|
||||
command = "beacon 65"
|
||||
expect.error = "Integer must not be more than 64, found 65"
|
||||
|
||||
[[step]]
|
||||
command = "ground ~2 ~1 ~"
|
||||
expect.feedback = "7 65 5 is minecraft:glowstone in minecraft:plains"
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "coords-hud"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod: coordinates, facing and the time of day on the HUD, and /where"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
id = "coords_hud"
|
||||
name = "Coordinates HUD"
|
||||
version = "0.1.0"
|
||||
description = "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"]
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Example Horizon mod (`docs/mods.md`): 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.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, chat,
|
||||
game::{self, Clock, Player},
|
||||
hud::{self, Corner, Line},
|
||||
};
|
||||
|
||||
struct CoordsHud {
|
||||
shown: bool,
|
||||
/// The panel last sent, so an unchanged one costs no call.
|
||||
last: Vec<String>,
|
||||
}
|
||||
|
||||
/// Direction.fromYRot: yaw 0 faces south, 90 west.
|
||||
fn facing(yaw: f32) -> &'static str {
|
||||
let i = (yaw / 90.0 + 0.5).floor() as i32 & 3;
|
||||
["South (+Z)", "West (-X)", "North (-Z)", "East (+X)"][i as usize]
|
||||
}
|
||||
|
||||
/// `Day 3, 14:05`: day time 0 is 6:00 on day 1.
|
||||
fn time_of_day(clock: &Clock) -> String {
|
||||
let day = clock.day_time / 24000 + 1;
|
||||
let ticks = clock.day_time % 24000;
|
||||
let hours = (ticks / 1000 + 6) % 24;
|
||||
let minutes = ticks % 1000 * 60 / 1000;
|
||||
format!("Day {day}, {hours:02}:{minutes:02}")
|
||||
}
|
||||
|
||||
fn lines(p: &Player, clock: Option<&Clock>) -> Vec<String> {
|
||||
let mut lines = vec![
|
||||
format!("XYZ {:.1} / {:.1} / {:.1}", p.x, p.y, p.z),
|
||||
format!("Facing {}", facing(p.yaw)),
|
||||
];
|
||||
lines.extend(clock.map(time_of_day));
|
||||
lines
|
||||
}
|
||||
|
||||
impl Mod for CoordsHud {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = Registration::new()
|
||||
.command("where")
|
||||
.command("coords")
|
||||
.on_tick();
|
||||
let state = CoordsHud {
|
||||
shown: true,
|
||||
last: Vec::new(),
|
||||
};
|
||||
Ok((state, hooks))
|
||||
}
|
||||
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
let Some(p) = game::get_player().filter(|_| self.shown) else {
|
||||
if !self.last.is_empty() {
|
||||
hud::clear();
|
||||
self.last.clear();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let now = lines(&p, game::get_clock().as_ref());
|
||||
if now != self.last {
|
||||
let panel: Vec<Line> = now
|
||||
.iter()
|
||||
.map(|text| Line {
|
||||
text: text.clone(),
|
||||
color: 0xFFFFFF,
|
||||
})
|
||||
.collect();
|
||||
hud::set_panel(Corner::TopLeft, &panel);
|
||||
self.last = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
"coords" => {
|
||||
self.shown = !self.shown;
|
||||
let state = if self.shown { "shown" } else { "hidden" };
|
||||
Ok(Some(format!("Coordinates panel {state}")))
|
||||
}
|
||||
_ => {
|
||||
let p = game::get_player().ok_or("Not in a world")?;
|
||||
chat::show(&format!(
|
||||
"{:.0}, {:.0}, {:.0} in {}",
|
||||
p.x.floor(),
|
||||
p.y.floor(),
|
||||
p.z.floor(),
|
||||
p.dimension
|
||||
));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(CoordsHud);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn facing_follows_vanilla_yaw() {
|
||||
assert_eq!(facing(0.0), "South (+Z)");
|
||||
assert_eq!(facing(90.0), "West (-X)");
|
||||
assert_eq!(facing(-180.0), "North (-Z)");
|
||||
assert_eq!(facing(-90.0), "East (+X)");
|
||||
assert_eq!(facing(314.0), "East (+X)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_time_reads_as_a_clock() {
|
||||
let at = |day_time| {
|
||||
time_of_day(&Clock {
|
||||
game_time: 0,
|
||||
day_time,
|
||||
})
|
||||
};
|
||||
assert_eq!(at(0), "Day 1, 06:00");
|
||||
assert_eq!(at(18000), "Day 1, 00:00");
|
||||
assert_eq!(at(24000 * 2 + 8083), "Day 3, 14:04");
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
# The panel follows the player and the clock; /coords hides and shows it.
|
||||
|
||||
[player]
|
||||
x = 12.5
|
||||
z = -3.25
|
||||
yaw = 180.0
|
||||
|
||||
[clock]
|
||||
day-time = 29999
|
||||
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.panel = ["XYZ 12.5 / 64.0 / -3.2", "Facing North (-Z)", "Day 2, 12:00"]
|
||||
expect.corner = "top-left"
|
||||
|
||||
[[step]]
|
||||
player = { x = -100.0, yaw = 90.0 }
|
||||
ticks = 1
|
||||
expect.panel = ["XYZ -100.0 / 64.0 / -3.2", "Facing West (-X)", "Day 2, 12:00"]
|
||||
|
||||
[[step]]
|
||||
command = "coords"
|
||||
expect.feedback = "Coordinates panel hidden"
|
||||
|
||||
# A step's ticks come before its command: the panel goes at the next tick.
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.panel = []
|
||||
|
||||
[[step]]
|
||||
command = "coords"
|
||||
expect.feedback = "Coordinates panel shown"
|
||||
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.panel = ["XYZ -100.0 / 64.0 / -3.2", "Facing West (-X)", "Day 2, 12:00"]
|
||||
@@ -1,17 +0,0 @@
|
||||
# /where puts the position in chat; on the title screen it answers with an error.
|
||||
|
||||
[player]
|
||||
x = 12.5
|
||||
y = 70.2
|
||||
z = -3.25
|
||||
dimension = "minecraft:the_nether"
|
||||
|
||||
[[step]]
|
||||
command = "where"
|
||||
expect.chat = ["12, 70, -4 in minecraft:the_nether"]
|
||||
|
||||
[[step]]
|
||||
title-screen = true
|
||||
command = "where"
|
||||
expect.chat = []
|
||||
expect.error = "Not in a world"
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod: a missile you launch with /missile and steer with the mouse"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
id = "predator_missile"
|
||||
name = "Predator Missile"
|
||||
version = "0.1.0"
|
||||
description = "/missile launches a guided missile 60 blocks above you. Steer it with the mouse; it explodes on whatever it hits. Needs cheats (it runs vanilla commands as you)."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["game-read", "hud", "chat", "command"]
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Example Horizon mod (`docs/mods.md`): `/missile` turns you into a guided
|
||||
//! missile 60 blocks up. You fly along your own look, so the mouse steers;
|
||||
//! on a block or a mob a stack of primed TNT goes off, and you are put back
|
||||
//! where you launched. Built from vanilla commands (capability `command`),
|
||||
//! so it needs cheats.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
command::run,
|
||||
game::{self, Player},
|
||||
hud::{self, Corner, Line},
|
||||
};
|
||||
|
||||
/// Blocks per tick (a fired arrow does about 3).
|
||||
const SPEED: f64 = 1.5;
|
||||
const ALTITUDE: i32 = 60;
|
||||
/// Ticks of flight before the missile detonates on its own.
|
||||
const FUEL: u32 = 30 * 20;
|
||||
/// Ticks to watch the blast before going back.
|
||||
const AFTERMATH: u32 = 40;
|
||||
/// Primed TNT per blast: the stack's explosions add up.
|
||||
const WARHEAD: usize = 4;
|
||||
/// What the impact checks print; the mod hears it through the chat hook.
|
||||
const IMPACT: &str = "Predator: impact";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Flying { from: Player, ticks: u32 },
|
||||
Aftermath { from: Player, ticks: u32 },
|
||||
}
|
||||
|
||||
struct Predator {
|
||||
state: State,
|
||||
}
|
||||
|
||||
fn launch() {
|
||||
run("gamerule send_command_feedback false");
|
||||
run("execute if entity @s[gamemode=creative] run tag @s add pm_creative");
|
||||
run("gamemode spectator");
|
||||
run(&format!("tp @s ~ ~{ALTITUDE} ~ ~ 60"));
|
||||
run("effect give @s night_vision 60 0 true");
|
||||
// The thermal camera: every mob outlined.
|
||||
run("effect give @e[type=!player,distance=..256] glowing 60 0 true");
|
||||
}
|
||||
|
||||
/// One tick of flight: stop at the surface (the next step would enter a
|
||||
/// block or a mob), else move one step along the look.
|
||||
fn fly() {
|
||||
run(&format!(
|
||||
"execute at @s unless block ^ ^ ^{SPEED} #minecraft:air run tellraw @s \"{IMPACT}\""
|
||||
));
|
||||
run(&format!(
|
||||
"execute at @s positioned ^ ^ ^{SPEED} if entity @e[type=!player,distance=..2] run tellraw @s \"{IMPACT}\""
|
||||
));
|
||||
run(&format!(
|
||||
"execute at @s if block ^ ^ ^{SPEED} #minecraft:air run tp @s ^ ^ ^{SPEED}"
|
||||
));
|
||||
}
|
||||
|
||||
fn detonate() {
|
||||
for _ in 0..WARHEAD {
|
||||
run("execute at @s run summon tnt ^ ^ ^1 {fuse:0s}");
|
||||
}
|
||||
}
|
||||
|
||||
fn land(from: &Player) {
|
||||
run(&format!(
|
||||
"tp @s {} {} {} {} {}",
|
||||
from.x, from.y, from.z, from.yaw, from.pitch
|
||||
));
|
||||
run("execute if entity @s[tag=pm_creative] run gamemode creative");
|
||||
run("execute unless entity @s[tag=pm_creative] run gamemode survival");
|
||||
run("execute if entity @s[tag=pm_creative] run tag @s remove pm_creative");
|
||||
run("effect clear @s night_vision");
|
||||
// shortcut: assumes feedback was on before launch, as in a new world.
|
||||
run("gamerule send_command_feedback true");
|
||||
}
|
||||
|
||||
fn panel(p: &Player, ticks: u32) -> Vec<Line> {
|
||||
let green = 0x7CFF7C;
|
||||
let left = (FUEL - ticks.min(FUEL)) / 20;
|
||||
[
|
||||
"PREDATOR".to_string(),
|
||||
format!("ALT {:.0}", p.y),
|
||||
format!("FUEL {left}s"),
|
||||
"Mouse to steer, /missile to detonate".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|text| Line { text, color: green })
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Predator {
|
||||
fn impact(&mut self) {
|
||||
if let State::Flying { from, .. } = &self.state {
|
||||
detonate();
|
||||
self.state = State::Aftermath {
|
||||
from: from.clone(),
|
||||
ticks: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mod for Predator {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = Registration::new().command("missile").on_tick().on_chat();
|
||||
Ok((Predator { state: State::Idle }, hooks))
|
||||
}
|
||||
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
match &mut self.state {
|
||||
State::Idle => {}
|
||||
State::Flying { ticks, .. } => {
|
||||
*ticks += 1;
|
||||
let ticks = *ticks;
|
||||
if ticks >= FUEL {
|
||||
return self.impact();
|
||||
}
|
||||
// The first tick waits for the launch teleport.
|
||||
if ticks > 1 {
|
||||
fly();
|
||||
}
|
||||
if let Some(p) = game::get_player() {
|
||||
hud::set_panel(Corner::TopLeft, &panel(&p, ticks));
|
||||
}
|
||||
}
|
||||
State::Aftermath { from, ticks } => {
|
||||
*ticks += 1;
|
||||
if *ticks >= AFTERMATH {
|
||||
land(from);
|
||||
hud::clear();
|
||||
self.state = State::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
if text.contains(IMPACT) {
|
||||
self.impact();
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
match self.state {
|
||||
State::Idle => {
|
||||
let from = game::get_player().ok_or("Not in a world")?;
|
||||
launch();
|
||||
self.state = State::Flying { from, ticks: 0 };
|
||||
Ok(Some("Missile away".into()))
|
||||
}
|
||||
State::Flying { .. } => {
|
||||
self.impact();
|
||||
Ok(None)
|
||||
}
|
||||
State::Aftermath { .. } => Err("Wait for the blast to clear".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Predator);
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "ruby:block/ruby_block"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "ruby:item/ruby"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "ruby:block/ruby_block"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"block.ruby.ruby_block": "Block of Ruby",
|
||||
"item.ruby.ruby": "Ruby"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "ruby:block/ruby_block"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "ruby:item/ruby"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 149 B |
Binary file not shown.
|
Before Width: | Height: | Size: 149 B |
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"values": [
|
||||
"ruby:ruby_block"
|
||||
]
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"values": [
|
||||
"ruby:ruby_block"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"template": "minecraft:iron_block",
|
||||
"properties": {
|
||||
"hardness": 4.0,
|
||||
"light": 3,
|
||||
"sounds": "minecraft:amethyst_block",
|
||||
"map_color": 28
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"template": "minecraft:diamond",
|
||||
"rarity": "uncommon"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"condition": {
|
||||
"type": "minecraft:survives_explosion"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "ruby:ruby_block"
|
||||
}
|
||||
],
|
||||
"rolls": 1
|
||||
}
|
||||
],
|
||||
"random_sequence": "ruby:blocks/ruby_block"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "building",
|
||||
"key": {
|
||||
"#": "ruby:ruby"
|
||||
},
|
||||
"pattern": [
|
||||
"###",
|
||||
"###",
|
||||
"###"
|
||||
],
|
||||
"result": {
|
||||
"id": "ruby:ruby_block"
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"ingredients": [
|
||||
"ruby:ruby_block"
|
||||
],
|
||||
"result": {
|
||||
"count": 9,
|
||||
"id": "ruby:ruby"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# A content-only Horizon mod (API 0.2, docs/modpacks.md): no code, a block
|
||||
# and an item declared from vanilla templates, with their recipes, loot,
|
||||
# tags, models, textures and names. Try it: horizon-client --dev-mod <this folder>
|
||||
id = "ruby"
|
||||
name = "Ruby"
|
||||
version = "0.1.0"
|
||||
description = "A ruby gem and a block of ruby"
|
||||
authors = ["Horizon"]
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
capabilities = ["registries"]
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "welcome"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon server mod: greets players, announces newcomers, /online and /mined"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
id = "welcome"
|
||||
name = "Welcome"
|
||||
version = "0.1.0"
|
||||
description = "Greets each player who joins, announces a newcomer to everyone, /online lists who is on, /mined counts the blocks you broke."
|
||||
authors = ["Horizon"]
|
||||
api = "0.3"
|
||||
side = "server"
|
||||
capabilities = ["players", "storage"]
|
||||
@@ -1,91 +0,0 @@
|
||||
//! Example Horizon server mod (`docs/mods.md`, "Server mods"): greets each
|
||||
//! player who joins, announces a newcomer to everyone (the players it has
|
||||
//! seen are kept in `storage`), `/online` lists who is on and where, and
|
||||
//! `/mined [player]` counts the blocks a player broke since the server
|
||||
//! started (typed arguments: the chat box completes the player's name).
|
||||
use horizon_mod_sdk::{
|
||||
server::{Args, Argument, BlockPos, Player, Registration, ServerMod, players},
|
||||
storage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Welcome {
|
||||
/// Ids of every player seen, one per line in storage key `seen`.
|
||||
seen: Vec<String>,
|
||||
mined: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl ServerMod for Welcome {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let seen = storage::get("seen")
|
||||
.map(|b| {
|
||||
String::from_utf8_lossy(&b)
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let hooks = Registration::new()
|
||||
.command_with("online", [])
|
||||
.command_with("mined", [Argument::player("player").optional()])
|
||||
.on_joins()
|
||||
.on_blocks();
|
||||
Ok((
|
||||
Welcome {
|
||||
seen,
|
||||
mined: HashMap::new(),
|
||||
},
|
||||
hooks,
|
||||
))
|
||||
}
|
||||
|
||||
fn player_joined(&mut self, player: &Player) {
|
||||
let others = players::online().len() - 1;
|
||||
players::tell(
|
||||
&player.id,
|
||||
&format!("Welcome, {}! {others} other player(s) online.", player.name),
|
||||
);
|
||||
if !self.seen.contains(&player.id) {
|
||||
self.seen.push(player.id.clone());
|
||||
let _ = storage::set("seen", self.seen.join("\n").as_bytes());
|
||||
players::broadcast(&format!("{} is here for the first time", player.name));
|
||||
}
|
||||
}
|
||||
|
||||
fn command(
|
||||
&mut self,
|
||||
player: &Player,
|
||||
name: &str,
|
||||
args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(Some(match name {
|
||||
"online" => players::online()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"{} ({})",
|
||||
p.name,
|
||||
p.dimension.trim_start_matches("minecraft:")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
_ => match args.player("player") {
|
||||
Some(other) if other.id != player.id => {
|
||||
let n = self.mined.get(&other.id).copied().unwrap_or(0);
|
||||
format!("{} broke {n} block(s)", other.name)
|
||||
}
|
||||
_ => {
|
||||
let n = self.mined.get(&player.id).copied().unwrap_or(0);
|
||||
format!("You broke {n} block(s)")
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn block_broken(&mut self, player: &Player, _pos: BlockPos, _state: &str) {
|
||||
*self.mined.entry(player.id.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_server_mod!(Welcome);
|
||||
@@ -1,46 +0,0 @@
|
||||
# /online lists the players and their dimension; /mined [player] counts a player's blocks.
|
||||
|
||||
[[step]]
|
||||
join = "Alex"
|
||||
expect.commands = ["online", "mined"]
|
||||
|
||||
[[step]]
|
||||
join = "Steve"
|
||||
as = "Steve"
|
||||
player = { dimension = "minecraft:the_nether" }
|
||||
command = "online"
|
||||
expect.feedback = "Alex (overworld), Steve (the_nether)"
|
||||
|
||||
[[step]]
|
||||
as = "Alex"
|
||||
break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }
|
||||
|
||||
[[step]]
|
||||
as = "Alex"
|
||||
break = { x = 2, y = 63, z = 0, state = "minecraft:dirt" }
|
||||
|
||||
# A step's command comes before its break and place.
|
||||
[[step]]
|
||||
as = "Alex"
|
||||
command = "mined"
|
||||
expect.feedback = "You broke 2 block(s)"
|
||||
|
||||
[[step]]
|
||||
as = "Steve"
|
||||
command = "mined"
|
||||
expect.feedback = "You broke 0 block(s)"
|
||||
|
||||
[[step]]
|
||||
as = "Steve"
|
||||
command = "mined alex"
|
||||
expect.feedback = "Alex broke 2 block(s)"
|
||||
|
||||
# The server parses the arguments before the mod: vanilla's errors.
|
||||
[[step]]
|
||||
as = "Steve"
|
||||
command = "mined Herobrine"
|
||||
expect.error = "No player was found"
|
||||
|
||||
[[step]]
|
||||
command = "online now"
|
||||
expect.error = "Incorrect argument for command"
|
||||
@@ -1,21 +0,0 @@
|
||||
# Joins are greeted; a newcomer is announced once, even after a reload (storage).
|
||||
|
||||
[[step]]
|
||||
join = "Alex"
|
||||
expect.told = ["Alex: Welcome, Alex! 0 other player(s) online."]
|
||||
expect.broadcast = ["Alex is here for the first time"]
|
||||
|
||||
[[step]]
|
||||
join = "Steve"
|
||||
expect.told = ["Steve: Welcome, Steve! 1 other player(s) online."]
|
||||
expect.broadcast = ["Steve is here for the first time"]
|
||||
|
||||
[[step]]
|
||||
leave = "Alex"
|
||||
expect.told = []
|
||||
|
||||
[[step]]
|
||||
reload = true
|
||||
join = "Alex"
|
||||
expect.told = ["Alex: Welcome, Alex! 1 other player(s) online."]
|
||||
expect.broadcast = []
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Write Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! A mod is a type implementing [`Mod`], exported with [`export_mod!`] from
|
||||
//! a `cdylib` crate built for `wasm32-unknown-unknown`;
|
||||
//! `cargo horizon-mod build` turns it into the component the game loads.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
//!
|
||||
//! struct Hello;
|
||||
//! impl Mod for Hello {
|
||||
//! fn init() -> Result<(Self, Registration), String> {
|
||||
//! Ok((Hello, Registration::new().command("hello")))
|
||||
//! }
|
||||
//! fn command(&mut self, _name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
//! chat::show("Hello from a mod!");
|
||||
//! Ok(None)
|
||||
//! }
|
||||
//! }
|
||||
//! horizon_mod_sdk::export_mod!(Hello);
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`;
|
||||
//! a server mod `storage` and `players`). Server mods: [`server`].
|
||||
//!
|
||||
//! Widgets (capability `ui`): a mod shows a screen it owns with
|
||||
//! [`ui::open_screen`] and a page in Options > Mods with
|
||||
//! [`ui::set_settings`]; what the player does with them comes back as
|
||||
//! [`Mod::ui_select`], [`Mod::ui_closed`] and [`Mod::ui_settings_opened`].
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "horizon-ui-mod",
|
||||
pub_export_macro: true,
|
||||
export_macro_name: "__export_horizon_mod",
|
||||
default_bindings_module: "horizon_mod_sdk",
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
|
||||
pub use horizon::mods::{
|
||||
browser, chat, command, game, hud, input, log, recipes, render, storage, ui,
|
||||
};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
pub trait Mod: Sized + 'static {
|
||||
/// Once, after loading: the mod's state and the hooks it wants. An error
|
||||
/// keeps the mod off and is shown to the player.
|
||||
fn init() -> Result<(Self, Registration), String>;
|
||||
/// Every game tick, when [`Registration::tick`] is set.
|
||||
fn tick(&mut self, _tick: u64) {}
|
||||
/// Every rendered frame, when [`Registration::frame`] is set.
|
||||
fn frame(&mut self, _seconds: f32) {}
|
||||
/// `/name args` for a command the mod registered. `Ok(Some(text))` is
|
||||
/// shown as feedback, `Err(text)` in red.
|
||||
fn command(&mut self, _name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
Err("This command does nothing".into())
|
||||
}
|
||||
/// A chat line the player received, when [`Registration::chat`] is set.
|
||||
fn chat(&mut self, _text: &str) {}
|
||||
/// A row of one of the mod's `list` widgets was picked (capability
|
||||
/// `ui`): the widget's `id` and the row.
|
||||
fn ui_select(&mut self, _id: u32, _row: u32) {}
|
||||
/// The player left the mod's screen (Esc or Done; capability `ui`).
|
||||
fn ui_closed(&mut self) {}
|
||||
/// The player opened the mod's settings page (capability `ui`): call
|
||||
/// [`ui::set_settings`] for fresh widgets.
|
||||
fn ui_settings_opened(&mut self) {}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
/// No commands and no hooks.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
commands: Vec::new(),
|
||||
tick: false,
|
||||
frame: false,
|
||||
chat: false,
|
||||
}
|
||||
}
|
||||
/// Adds `/name`.
|
||||
pub fn command(mut self, name: &str) -> Self {
|
||||
self.commands.push(name.into());
|
||||
self
|
||||
}
|
||||
pub fn on_tick(mut self) -> Self {
|
||||
self.tick = true;
|
||||
self
|
||||
}
|
||||
pub fn on_frame(mut self) -> Self {
|
||||
self.frame = true;
|
||||
self
|
||||
}
|
||||
pub fn on_chat(mut self) -> Self {
|
||||
self.chat = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Default for Registration {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `log::write` at info level.
|
||||
pub fn info(message: &str) {
|
||||
log::write(log::Level::Info, message);
|
||||
}
|
||||
/// `log::write` at warn level.
|
||||
pub fn warn(message: &str) {
|
||||
log::write(log::Level::Warn, message);
|
||||
}
|
||||
|
||||
/// Exports a [`Mod`] as the component's `events` interface. The state lives
|
||||
/// in a thread local: a mod instance is single-threaded.
|
||||
#[macro_export]
|
||||
macro_rules! export_mod {
|
||||
($t:ty) => {
|
||||
const _: () = {
|
||||
use ::std::cell::RefCell;
|
||||
::std::thread_local! {
|
||||
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
|
||||
}
|
||||
fn with(f: impl FnOnce(&mut $t)) {
|
||||
STATE.with(|s| {
|
||||
if let Some(m) = s.borrow_mut().as_mut() {
|
||||
f(m)
|
||||
}
|
||||
})
|
||||
}
|
||||
struct Export;
|
||||
impl $crate::__Guest for Export {
|
||||
fn init() -> Result<$crate::Registration, String> {
|
||||
let (m, registration) = <$t as $crate::Mod>::init()?;
|
||||
STATE.with(|s| *s.borrow_mut() = Some(m));
|
||||
Ok(registration)
|
||||
}
|
||||
fn tick(tick: u64) {
|
||||
with(|m| $crate::Mod::tick(m, tick))
|
||||
}
|
||||
fn frame(seconds: f32) {
|
||||
with(|m| $crate::Mod::frame(m, seconds))
|
||||
}
|
||||
fn command(name: String, args: String) -> Result<Option<String>, String> {
|
||||
let mut out = Err("The mod is not running".to_string());
|
||||
with(|m| out = $crate::Mod::command(m, &name, &args));
|
||||
out
|
||||
}
|
||||
fn chat(text: String) {
|
||||
with(|m| $crate::Mod::chat(m, &text))
|
||||
}
|
||||
}
|
||||
impl $crate::__UiEventsGuest for Export {
|
||||
fn select(id: u32, row: u32) {
|
||||
with(|m| $crate::Mod::ui_select(m, id, row))
|
||||
}
|
||||
fn closed() {
|
||||
with(|m| $crate::Mod::ui_closed(m))
|
||||
}
|
||||
fn settings_opened() {
|
||||
with(|m| $crate::Mod::ui_settings_opened(m))
|
||||
}
|
||||
}
|
||||
// Only WebAssembly has the component's exports: a host build (cargo test or
|
||||
// clippy on the workspace) checks the mod without them, which macOS
|
||||
// couldn't link (its exported-symbols list cuts names at the `#`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
$crate::__export_horizon_mod!(Export);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = (
|
||||
<Export as $crate::__Guest>::init,
|
||||
<Export as $crate::__UiEventsGuest>::select,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Server mods (manifest `side = "server"`, `api = "0.3"`, experimental): a
|
||||
/// type implementing [`ServerMod`](server::ServerMod), exported with
|
||||
/// [`export_server_mod!`]. It runs where the world is simulated, and reads
|
||||
/// and messages the players with capability `players`.
|
||||
pub mod server {
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "horizon-server-mod",
|
||||
pub_export_macro: true,
|
||||
export_macro_name: "__export_horizon_server_mod",
|
||||
default_bindings_module: "horizon_mod_sdk::server",
|
||||
with: {
|
||||
"horizon:mods/[email protected]": crate::log,
|
||||
"horizon:mods/[email protected]": crate::storage,
|
||||
},
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::server_events::{
|
||||
Argument, ArgumentKind, ArgumentValue, BlockPos, Guest as __Guest, Registration,
|
||||
ServerCommand,
|
||||
};
|
||||
pub use horizon::mods::players::{self, GameMode, Player};
|
||||
pub use horizon::mods::world::{self, BlockChange};
|
||||
|
||||
/// The server's calls into a mod. Only the hooks [`Registration`] asks
|
||||
/// for are called; the others may stay as their defaults.
|
||||
pub trait ServerMod: Sized + 'static {
|
||||
/// Once, after loading: the mod's state and the hooks it wants. An
|
||||
/// error keeps the mod off and is logged.
|
||||
fn init() -> Result<(Self, Registration), String>;
|
||||
/// Every server tick, when [`Registration::tick`] is set.
|
||||
fn tick(&mut self, _tick: u64) {}
|
||||
/// When [`Registration::joins`] is set.
|
||||
fn player_joined(&mut self, _player: &Player) {}
|
||||
fn player_left(&mut self, _player: &Player) {}
|
||||
/// A chat message a player sent, when [`Registration::chat`] is set.
|
||||
fn chat(&mut self, _player: &Player, _text: &str) {}
|
||||
/// `/name args` typed by `player`, parsed as the command's
|
||||
/// arguments say ([`Registration::command_with`]). `Ok(Some(text))`
|
||||
/// is their feedback, `Err(text)` their error in red.
|
||||
fn command(
|
||||
&mut self,
|
||||
_player: &Player,
|
||||
_name: &str,
|
||||
_args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
Err("This command does nothing".into())
|
||||
}
|
||||
/// When [`Registration::blocks`] is set: `state` is what was there.
|
||||
fn block_broken(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
|
||||
fn block_placed(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
/// No commands and no hooks.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
commands: Vec::new(),
|
||||
tick: false,
|
||||
joins: false,
|
||||
chat: false,
|
||||
blocks: false,
|
||||
}
|
||||
}
|
||||
/// Adds `/name`, taking free text ([`Args::text`]): one optional
|
||||
/// `text` argument named `args`.
|
||||
pub fn command(self, name: &str) -> Self {
|
||||
self.command_with(name, [Argument::text("args").optional()])
|
||||
}
|
||||
/// Adds `/name` with typed arguments (none: nothing after the name):
|
||||
/// players' chat boxes complete and check them, and the server
|
||||
/// parses them into [`Args`].
|
||||
pub fn command_with(
|
||||
mut self,
|
||||
name: &str,
|
||||
arguments: impl IntoIterator<Item = Argument>,
|
||||
) -> Self {
|
||||
self.commands.push(ServerCommand {
|
||||
name: name.into(),
|
||||
arguments: arguments.into_iter().collect(),
|
||||
});
|
||||
self
|
||||
}
|
||||
pub fn on_tick(mut self) -> Self {
|
||||
self.tick = true;
|
||||
self
|
||||
}
|
||||
pub fn on_joins(mut self) -> Self {
|
||||
self.joins = true;
|
||||
self
|
||||
}
|
||||
pub fn on_chat(mut self) -> Self {
|
||||
self.chat = true;
|
||||
self
|
||||
}
|
||||
pub fn on_blocks(mut self) -> Self {
|
||||
self.blocks = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Default for Registration {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
fn of(name: &str, kind: ArgumentKind) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
kind,
|
||||
optional: false,
|
||||
}
|
||||
}
|
||||
/// One word: letters, digits and `_ - . +`.
|
||||
pub fn word(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Word)
|
||||
}
|
||||
/// The rest of the line: only last.
|
||||
pub fn text(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Text)
|
||||
}
|
||||
/// A whole number, at least `min` and at most `max` when given.
|
||||
pub fn integer(name: &str, min: Option<i32>, max: Option<i32>) -> Self {
|
||||
Self::of(name, ArgumentKind::Integer((min, max)))
|
||||
}
|
||||
pub fn number(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Number)
|
||||
}
|
||||
pub fn boolean(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Boolean)
|
||||
}
|
||||
/// One online player: a name, `@s` or `@p`.
|
||||
pub fn player(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Player)
|
||||
}
|
||||
/// `x y z`, each one also `~` or `~n`.
|
||||
pub fn block_pos(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::BlockPos)
|
||||
}
|
||||
/// May be left out; optional arguments come after the required ones.
|
||||
pub fn optional(mut self) -> Self {
|
||||
self.optional = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's arguments: the line as typed, and the values the server
|
||||
/// parsed from it, by argument name.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Args {
|
||||
text: String,
|
||||
values: Vec<(String, ArgumentValue)>,
|
||||
}
|
||||
impl Args {
|
||||
pub fn new(text: String, values: Vec<(String, ArgumentValue)>) -> Self {
|
||||
Self { text, values }
|
||||
}
|
||||
/// The line after the command's name, as typed.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
/// The value of argument `name`; `None` when it was left out.
|
||||
pub fn get(&self, name: &str) -> Option<&ArgumentValue> {
|
||||
self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v)
|
||||
}
|
||||
pub fn integer(&self, name: &str) -> Option<i32> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Integer(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn number(&self, name: &str) -> Option<f64> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A `word` or `text` argument.
|
||||
pub fn string(&self, name: &str) -> Option<&str> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Word(s) | ArgumentValue::Text(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn boolean(&self, name: &str) -> Option<bool> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn player(&self, name: &str) -> Option<&Player> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Player(p) => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn block_pos(&self, name: &str) -> Option<BlockPos> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::BlockPos(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports a [`ServerMod`](server::ServerMod) as the component's
|
||||
/// `server-events` interface, as [`export_mod!`] does for a client mod.
|
||||
#[macro_export]
|
||||
macro_rules! export_server_mod {
|
||||
($t:ty) => {
|
||||
const _: () = {
|
||||
use ::std::cell::RefCell;
|
||||
use $crate::server::{Args, ArgumentValue, BlockPos, Player, Registration, ServerMod};
|
||||
::std::thread_local! {
|
||||
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
|
||||
}
|
||||
fn with(f: impl FnOnce(&mut $t)) {
|
||||
STATE.with(|s| {
|
||||
if let Some(m) = s.borrow_mut().as_mut() {
|
||||
f(m)
|
||||
}
|
||||
})
|
||||
}
|
||||
struct Export;
|
||||
impl $crate::server::__Guest for Export {
|
||||
fn init() -> Result<Registration, String> {
|
||||
let (m, registration) = <$t as ServerMod>::init()?;
|
||||
STATE.with(|s| *s.borrow_mut() = Some(m));
|
||||
Ok(registration)
|
||||
}
|
||||
fn tick(tick: u64) {
|
||||
with(|m| m.tick(tick))
|
||||
}
|
||||
fn player_joined(player: Player) {
|
||||
with(|m| m.player_joined(&player))
|
||||
}
|
||||
fn player_left(player: Player) {
|
||||
with(|m| m.player_left(&player))
|
||||
}
|
||||
fn chat(player: Player, text: String) {
|
||||
with(|m| m.chat(&player, &text))
|
||||
}
|
||||
fn command(
|
||||
player: Player,
|
||||
name: String,
|
||||
args: String,
|
||||
values: Vec<(String, ArgumentValue)>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut out = Err("The mod is not running".to_string());
|
||||
let args = Args::new(args, values);
|
||||
with(|m| out = m.command(&player, &name, &args));
|
||||
out
|
||||
}
|
||||
fn block_broken(player: Player, pos: BlockPos, state: String) {
|
||||
with(|m| m.block_broken(&player, pos, &state))
|
||||
}
|
||||
fn block_placed(player: Player, pos: BlockPos, state: String) {
|
||||
with(|m| m.block_placed(&player, pos, &state))
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
$crate::server::__export_horizon_server_mod!(Export);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = <Export as $crate::server::__Guest>::init;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,840 +0,0 @@
|
||||
/// The contract between Horizon and its mods (docs/mods.md).
|
||||
///
|
||||
/// A mod is a WebAssembly component targeting the `mod` world. Each imported
|
||||
/// interface but `log` is a capability: the mod's manifest lists the ones it
|
||||
/// uses, the player sees them before turning the mod on, and the game refuses
|
||||
/// a component that imports one its manifest does not declare.
|
||||
package horizon:[email protected];
|
||||
|
||||
/// The mod's log, in the game's log under the mod's id. Always granted.
|
||||
interface log {
|
||||
enum level {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
/// One line in the game's log as `[mod <id>] message`, at that level.
|
||||
write: func(level: level, message: string);
|
||||
}
|
||||
|
||||
/// Capability `game-read`: the local player, the world clock and the block
|
||||
/// the player looks at.
|
||||
interface game {
|
||||
record player {
|
||||
/// Feet position, in blocks.
|
||||
x: f64,
|
||||
y: f64,
|
||||
z: f64,
|
||||
/// Degrees, as F3 shows them.
|
||||
yaw: f32,
|
||||
/// Degrees: -90 looks straight up, 90 straight down.
|
||||
pitch: f32,
|
||||
/// Hit points as vanilla counts them: 20 is full health without effects.
|
||||
health: f32,
|
||||
/// 0 to 20, as the hunger bar.
|
||||
food: u32,
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, ...
|
||||
dimension: string,
|
||||
}
|
||||
record clock {
|
||||
/// Ticks since the world was created.
|
||||
game-time: u64,
|
||||
/// 24000 ticks per day; 0 is 6:00 on the first day.
|
||||
day-time: u64,
|
||||
}
|
||||
/// None on the title screen.
|
||||
get-player: func() -> option<player>;
|
||||
/// None on the title screen.
|
||||
get-clock: func() -> option<clock>;
|
||||
/// A block along the player's view.
|
||||
record hit {
|
||||
/// The block hit.
|
||||
block-x: s32,
|
||||
block-y: s32,
|
||||
block-z: s32,
|
||||
/// The point on its face where the view meets it.
|
||||
x: f64,
|
||||
y: f64,
|
||||
z: f64,
|
||||
/// `minecraft:stone`, ...
|
||||
block: string,
|
||||
}
|
||||
/// The first block along the player's view (fluids pass), up to
|
||||
/// `distance` blocks (at most 1024; past the loaded chunks, across the
|
||||
/// distant terrain when LOD Distance is on), as of this tick. None when
|
||||
/// nothing is hit, and on the title screen.
|
||||
raycast: func(distance: f32) -> option<hit>;
|
||||
}
|
||||
|
||||
/// Capability `chat`: messages only the local player sees.
|
||||
interface chat {
|
||||
/// Shown in chat after the mod's name, like a system message.
|
||||
show: func(text: string);
|
||||
}
|
||||
|
||||
/// Capability `hud`: one panel of text lines per mod, drawn by the game
|
||||
/// every frame until the mod changes or clears it (no call per frame).
|
||||
interface hud {
|
||||
enum corner {
|
||||
top-left,
|
||||
top-right,
|
||||
bottom-left,
|
||||
bottom-right,
|
||||
}
|
||||
record line {
|
||||
/// Plain text, up to 256 characters.
|
||||
text: string,
|
||||
/// 0xRRGGBB.
|
||||
color: u32,
|
||||
}
|
||||
/// Replaces the mod's panel: up to 16 lines in that corner of the screen.
|
||||
set-panel: func(corner: corner, lines: list<line>);
|
||||
/// Removes the mod's panel.
|
||||
clear: func();
|
||||
}
|
||||
|
||||
/// Capability `storage`: a per-mod folder of small key/value files, quota'd
|
||||
/// (docs/mods.md). Keys are `[a-z0-9_.-]{1,64}`, one path segment.
|
||||
interface storage {
|
||||
/// None if `key` has never been `set`, or the key is not well-formed.
|
||||
get: func(key: string) -> option<list<u8>>;
|
||||
/// Errs on a malformed key or over quota (bytes for the whole mod).
|
||||
set: func(key: string, value: list<u8>) -> result<_, string>;
|
||||
/// No error if `key` was already absent.
|
||||
delete: func(key: string);
|
||||
}
|
||||
|
||||
/// Capability `command`: vanilla commands run as the player, with the
|
||||
/// player's permissions, after the hook call returns (8 per call). A mod's
|
||||
/// own commands are not run this way.
|
||||
interface command {
|
||||
/// `line` without its slash; its feedback goes to the chat as if typed.
|
||||
run: func(line: string);
|
||||
/// `lines` (each without its slash, at most 256 a call) as `run` does,
|
||||
/// but with nothing in the chat: no feedback and no failure. In single
|
||||
/// player and on Horizon's servers, each runs exactly as the player
|
||||
/// typing it would (the same rights), in order, 64 a tick, and the
|
||||
/// server logs one line a batch; one that carries a chat message (`say`,
|
||||
/// `msg`) is refused. On any other server they go as chat commands, and
|
||||
/// there their feedback shows: a mod running many should say so.
|
||||
run-quiet: func(lines: list<string>);
|
||||
}
|
||||
|
||||
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
|
||||
/// a screen the mod owns and a settings page in Options > Mods. Layout and
|
||||
/// drawing are the game's: a mod says what its widgets hold, never where
|
||||
/// they sit or how they look, and the game clips everything it is handed.
|
||||
interface ui {
|
||||
/// A line of text.
|
||||
record text-widget {
|
||||
/// The mod's own name for the widget; `ui-events` calls echo it.
|
||||
id: u32,
|
||||
/// Up to 256 characters.
|
||||
text: string,
|
||||
/// 0xRRGGBB; 0 is the menus' white.
|
||||
color: u32,
|
||||
}
|
||||
/// An item's icon and a name beside it, as recipe viewers show them.
|
||||
record item-widget {
|
||||
/// The mod's own name for the widget.
|
||||
id: u32,
|
||||
/// `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?".
|
||||
item: string,
|
||||
/// The name shown beside the icon (empty: the item's own).
|
||||
name: string,
|
||||
}
|
||||
/// Rows the player picks one of, as an options page lists choices: a
|
||||
/// pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
record list-widget {
|
||||
/// The mod's own name for the widget; `ui-events::select` echoes it.
|
||||
id: u32,
|
||||
/// The rows' texts, at most 512, each up to 256 characters.
|
||||
rows: list<string>,
|
||||
/// The row to mark as the current choice, if any.
|
||||
selected: option<u32>,
|
||||
}
|
||||
/// A thing on a mod's screen or settings page.
|
||||
/// A button as the options pages have them (200 × 20): a click is
|
||||
/// `ui-events::select(id, 0)`. Mods cycle its label for a choice
|
||||
/// ("Corner: Top Left").
|
||||
record button-widget {
|
||||
/// The mod's own name for the widget.
|
||||
id: u32,
|
||||
/// Up to 256 characters.
|
||||
label: string,
|
||||
}
|
||||
variant widget {
|
||||
/// Text the player reads.
|
||||
text(text-widget),
|
||||
/// A button.
|
||||
button(button-widget),
|
||||
/// An item's icon and name.
|
||||
item(item-widget),
|
||||
/// Rows the player picks from.
|
||||
%list(list-widget),
|
||||
}
|
||||
/// A screen the mod owns: a title over widgets stacked down the middle,
|
||||
/// the Done button the game adds, and Esc to leave it.
|
||||
record screen {
|
||||
/// Its title, up to 256 characters.
|
||||
title: string,
|
||||
/// Its widgets, in order, at most 64.
|
||||
widgets: list<widget>,
|
||||
}
|
||||
/// Show `screen` over the game; a second call while it is open replaces
|
||||
/// it. Only the last call of one hook counts.
|
||||
open-screen: func(screen: screen);
|
||||
/// Close the mod's screen when it is open (the mod hears no `closed`
|
||||
/// for this: it was its own doing).
|
||||
close-screen: func();
|
||||
/// The mod's page in Options > Mods, reached from a settings button on
|
||||
/// its row: the widgets it shows, an empty list for no page. The player
|
||||
/// opening the page is `ui-events::settings-opened`: push fresh widgets
|
||||
/// then; later calls update the page while it shows.
|
||||
set-settings: func(widgets: list<widget>);
|
||||
}
|
||||
|
||||
/// What the game calls on a mod that shows widgets (capability `ui`):
|
||||
/// what the player did with them.
|
||||
interface ui-events {
|
||||
/// A row of one of the mod's `list` widgets was picked: the widget's
|
||||
/// `id` and the row, on the screen or the settings page that showed it.
|
||||
select: func(id: u32, row: u32);
|
||||
/// The player left the mod's screen (Esc or Done); not for a screen the
|
||||
/// mod closed itself or one that went away with the mod.
|
||||
closed: func();
|
||||
/// The player opened the mod's settings page in Options > Mods: call
|
||||
/// `ui::set-settings` now to fill it with fresh widgets.
|
||||
settings-opened: func();
|
||||
}
|
||||
|
||||
/// Capability `input` (experimental: the manifest needs
|
||||
/// `experimental = ["input"]`): the use button on items the mod claims, and
|
||||
/// the mod's own keys. Events wait for `poll`, at most 256.
|
||||
interface input {
|
||||
/// The main hand's stack.
|
||||
record held-item {
|
||||
/// `minecraft:spyglass`, `<mod>:<path>`.
|
||||
id: string,
|
||||
count: u32,
|
||||
/// The hotbar slot, 0 to 8.
|
||||
slot: u32,
|
||||
}
|
||||
record item-use {
|
||||
item: held-item,
|
||||
/// Game ticks the button was held (0 on `use-start`).
|
||||
ticks: u32,
|
||||
}
|
||||
variant event {
|
||||
/// The use button went down with a claimed item in the main hand.
|
||||
/// The game does nothing else with the click: no placing, no eating,
|
||||
/// no swing.
|
||||
use-start(item-use),
|
||||
/// It came up again, or the item left the hand, or a screen opened.
|
||||
use-stop(item-use),
|
||||
/// A key `register-key` named went down or up while playing (no
|
||||
/// screen open). The game's own mapping on that key still runs. A
|
||||
/// screen opening lets every held key up.
|
||||
key-down(string),
|
||||
key-up(string),
|
||||
/// Mouse wheel steps while the mod holds the wheel (`claim-wheel`):
|
||||
/// positive away from the player, as the hotbar reads them.
|
||||
wheel(f32),
|
||||
}
|
||||
/// The items whose use the mod handles instead of the game, by id (at
|
||||
/// most 64). Replaces the previous list.
|
||||
claim-items: func(ids: list<string>);
|
||||
/// A key of the mod's: `name` comes back in `key-down` and `key-up`,
|
||||
/// `label` says what it does, `key` is the key as options.txt writes
|
||||
/// it (`key.keyboard.r`), the default: the player can bind another
|
||||
/// one on the mod's page in Options > Mods, and that one wins. At most
|
||||
/// 16; an unknown key is an error.
|
||||
register-key: func(name: string, label: string, key: string) -> result<_, string>;
|
||||
/// The main hand's stack, as of this tick; none when it is empty.
|
||||
held: func() -> option<held-item>;
|
||||
/// The events since the last call, oldest first.
|
||||
poll: func() -> list<event>;
|
||||
/// Takes the mouse wheel while playing (true) or gives it back: while
|
||||
/// taken, its steps are the mod's `wheel` events and the hotbar doesn't
|
||||
/// move. The game gives it back when the mod stops.
|
||||
claim-wheel: func(on: bool);
|
||||
/// A mapping as the player has bound it.
|
||||
record mapping-state {
|
||||
/// The bound key's name as Controls shows it: `W`, `Space`, `LMB`.
|
||||
label: string,
|
||||
/// Held down now (as of this frame for a mod with a `frame` hook,
|
||||
/// else this tick).
|
||||
down: bool,
|
||||
}
|
||||
/// A movement or click mapping as the player has bound it: `key.forward`,
|
||||
/// `key.left`, `key.back`, `key.right`, `key.jump`, `key.sneak`,
|
||||
/// `key.sprint`, `key.attack` or `key.use`. None for another name.
|
||||
mapping: func(name: string) -> option<mapping-state>;
|
||||
/// Clicks of the attack and use buttons in the last second.
|
||||
clicks-per-second: func() -> tuple<u32, u32>;
|
||||
}
|
||||
|
||||
/// Capability `render` (experimental: the manifest needs
|
||||
/// `experimental = ["render"]`): shapes the game draws for the mod with its
|
||||
/// own renderer, kept until the mod replaces them (no call per frame). A mod
|
||||
/// never supplies shader code.
|
||||
interface render {
|
||||
/// A corner of the screen, as `hud`'s panels use them.
|
||||
enum corner {
|
||||
top-left,
|
||||
top-right,
|
||||
bottom-left,
|
||||
bottom-right,
|
||||
}
|
||||
/// A rectangle in GUI pixels: the top left corner and the size.
|
||||
record rect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
}
|
||||
/// A point in GUI pixels from the screen's top left.
|
||||
record point {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
record fill-shape {
|
||||
rect: rect,
|
||||
/// 0xAARRGGBB.
|
||||
color: u32,
|
||||
}
|
||||
/// Any four-cornered shape (a wheel's segment, an arrow): the corners
|
||||
/// in order round it.
|
||||
record quad-shape {
|
||||
corners: tuple<point, point, point, point>,
|
||||
/// 0xAARRGGBB.
|
||||
color: u32,
|
||||
}
|
||||
record sprite-shape {
|
||||
/// One of the game's GUI sprites, as `gui/sprites/` names them
|
||||
/// (`hud/heart/full`, `widget/button`), the resource packs' versions
|
||||
/// included, drawn over `rect` as vanilla draws it: nine-sliced when
|
||||
/// its `gui.scaling` says so (`tooltip/frame`, `widget/button`: the
|
||||
/// borders keep their size), stretched otherwise. An unknown one
|
||||
/// draws nothing.
|
||||
sprite: string,
|
||||
rect: rect,
|
||||
/// A tint, 0xAARRGGBB: 0xFFFFFFFF draws it as it is.
|
||||
color: u32,
|
||||
}
|
||||
record item-shape {
|
||||
/// `minecraft:diamond`, `<mod>:<path>`; an unknown one draws nothing.
|
||||
item: string,
|
||||
/// The icon's top left.
|
||||
at: point,
|
||||
/// 1.0 is the hotbar's 16 × 16.
|
||||
scale: f32,
|
||||
}
|
||||
record text-shape {
|
||||
/// Up to 256 characters, one line.
|
||||
text: string,
|
||||
at: point,
|
||||
/// 0xRRGGBB, as `hud`'s lines.
|
||||
color: u32,
|
||||
/// Centred on `at.x` instead of starting there.
|
||||
centered: bool,
|
||||
shadow: bool,
|
||||
}
|
||||
variant shape {
|
||||
fill(fill-shape),
|
||||
quad(quad-shape),
|
||||
sprite(sprite-shape),
|
||||
item(item-shape),
|
||||
text(text-shape),
|
||||
}
|
||||
/// Replaces the mod's HUD shapes: up to 1024, drawn every frame in
|
||||
/// order over the HUD (after the hotbar, under chat and screens), hidden
|
||||
/// with the HUD (F1). An empty list removes them.
|
||||
set-hud: func(shapes: list<shape>);
|
||||
/// The screen's size in GUI pixels (width, height), as of this tick.
|
||||
gui-size: func() -> tuple<f32, f32>;
|
||||
/// Shakes the camera: a jolt of `strength` (0 to 1, 1 the hardest) that
|
||||
/// fades out over `seconds` (at most 10). Jolts add up to 1. The view
|
||||
/// turns, the player doesn't, and the player's Distortion Effects option
|
||||
/// scales it (0 turns it off).
|
||||
shake: func(strength: f32, seconds: f32);
|
||||
/// Replaces the mod's corner panel: shapes in GUI pixels from the top
|
||||
/// left of a `width` × `height` box that the game places in `corner`,
|
||||
/// stacked with the game's overlays and the other mods' panels (under
|
||||
/// the status effects at the top right), as `hud`'s panels are.
|
||||
set-corner: func(corner: corner, width: f32, height: f32, shapes: list<shape>);
|
||||
/// Removes the mod's corner panel.
|
||||
clear-corner: func();
|
||||
/// The width of one line of text in GUI pixels, in the HUD's font: exact
|
||||
/// for Latin letters (U+0000 to U+024F), 6 per other character.
|
||||
text-width: func(text: string) -> f32;
|
||||
record frame-rate {
|
||||
/// Frames drawn in the last second.
|
||||
fps: u32,
|
||||
/// The slowest frame's rate, and the 1% low (the 99th percentile
|
||||
/// frame time's rate), over the last 5 seconds.
|
||||
min: u32,
|
||||
low: u32,
|
||||
}
|
||||
/// The game's frame counter, recomputed twice a second.
|
||||
frame-stats: func() -> frame-rate;
|
||||
record zoom {
|
||||
/// The field of view is divided by it: 1 is none, at most 10.
|
||||
factor: f32,
|
||||
/// Ease in and out through the game's field-of-view easing, as the
|
||||
/// spyglass does, instead of at once.
|
||||
smooth: bool,
|
||||
/// Mouse turning is multiplied by it while zoomed: 1 keeps the
|
||||
/// player's sensitivity, at least 0.05.
|
||||
turn: f32,
|
||||
}
|
||||
/// Zooms the camera until the next call (`factor` 1 ends it), only
|
||||
/// while playing: a screen opening or the spyglass's own view (which
|
||||
/// wins) leave the camera vanilla's. With several mods, the strongest
|
||||
/// zoom counts.
|
||||
set-zoom: func(zoom: zoom);
|
||||
record minimap {
|
||||
corner: corner,
|
||||
/// Its side in GUI pixels, 16 to 256.
|
||||
size: f32,
|
||||
/// World blocks per map pixel, 0.5 to 4; the map is 128 × 128 pixels.
|
||||
blocks-per-pixel: f32,
|
||||
/// The map turns with the player, who faces up, and an N on its rim
|
||||
/// marks north. Otherwise north is up and the player's arrow turns.
|
||||
rotate: bool,
|
||||
/// Round, the corners left clear; otherwise square.
|
||||
round: bool,
|
||||
}
|
||||
/// Shows a map of the world around the player in `corner`, stacked with
|
||||
/// the game's overlays and the mods' panels, until the next call (none
|
||||
/// hides it). It looks like a map item: vanilla's map colours with their
|
||||
/// height shading and water depth, and the player's arrow. Underground,
|
||||
/// under a roof and in the Nether it maps the floor around the player's
|
||||
/// height. The game keeps the map's tiles and refreshes them within its
|
||||
/// own frame budget; hidden, they are dropped. One map shows at a time:
|
||||
/// the first loaded mod's.
|
||||
set-minimap: func(minimap: option<minimap>);
|
||||
record far-terrain {
|
||||
/// How far the distant terrain reaches, in chunks: 64 to 1024. On
|
||||
/// integrated graphics other than Apple's the game caps it lower.
|
||||
distance: u32,
|
||||
/// Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms
|
||||
/// less GPU time per frame on integrated graphics.
|
||||
reduced: bool,
|
||||
}
|
||||
/// Draws the world beyond the render distance as distant terrain, out to
|
||||
/// `distance` chunks, until the next call (none ends it). The game builds
|
||||
/// it on its own workers, from the chunks already loaded or saved and,
|
||||
/// where nobody has been, from the world's generator; the simulation
|
||||
/// distance doesn't change. Only worlds whose generator the game has
|
||||
/// (single player and a LAN world you host) show it. The player's own LOD
|
||||
/// Distance setting, when on, wins. One far terrain shows at a time: the
|
||||
/// first loaded mod's.
|
||||
set-far-terrain: func(far-terrain: option<far-terrain>);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
interface events {
|
||||
record registration {
|
||||
/// Chat commands, without the slash. A name vanilla already uses is
|
||||
/// refused (vanilla's command runs).
|
||||
commands: list<string>,
|
||||
/// Call `tick` every game tick (20 per second while playing).
|
||||
tick: bool,
|
||||
/// Call `frame` every rendered frame. Budget: 0.1 ms.
|
||||
frame: bool,
|
||||
/// Call `chat` for each chat line the player receives.
|
||||
chat: bool,
|
||||
}
|
||||
/// Once, after loading. An error keeps the mod off and is shown.
|
||||
init: func() -> result<registration, string>;
|
||||
/// Each game tick while in a world, with the game's tick counter.
|
||||
tick: func(tick: u64);
|
||||
/// Each rendered frame, with the seconds since the last one.
|
||||
frame: func(seconds: f32);
|
||||
/// `/name args`: ok(some(text)) is shown as feedback, err(text) in red.
|
||||
command: func(name: string, args: string) -> result<option<string>, string>;
|
||||
/// Each chat line the player receives, as plain text.
|
||||
chat: func(text: string);
|
||||
}
|
||||
|
||||
/// Capability `recipes` (experimental): the recipes and items the game
|
||||
/// knows, for recipe viewers. In single player and on Horizon's servers
|
||||
/// they are the world's; on any other server they are vanilla 26.3's (a
|
||||
/// client is sent no others), while `known` follows that server's book.
|
||||
interface recipes {
|
||||
/// An item of `items`, by its index there, with a count and the
|
||||
/// components it has beyond the item's own, in vanilla's item syntax
|
||||
/// (`[potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
record stack {
|
||||
item: u32,
|
||||
count: u32,
|
||||
components: string,
|
||||
}
|
||||
/// The station a recipe is made at.
|
||||
enum kind {
|
||||
crafting-shaped,
|
||||
crafting-shapeless,
|
||||
smelting,
|
||||
blasting,
|
||||
smoking,
|
||||
campfire,
|
||||
stonecutting,
|
||||
smithing,
|
||||
brewing,
|
||||
}
|
||||
record recipe {
|
||||
/// `minecraft:oak_planks`.
|
||||
id: string,
|
||||
kind: kind,
|
||||
/// The recipe book's tab (`crafting_building_blocks`,
|
||||
/// `furnace_food`), or "".
|
||||
category: string,
|
||||
/// Recipes the book shows on one button share a group, or "".
|
||||
group: string,
|
||||
/// A shaped recipe's grid: `slots` holds its cells row by row.
|
||||
/// 0 for the other kinds.
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Each slot's stacks, any one of which fits; an empty list is an
|
||||
/// empty cell. Cooking and stonecutting: the input. Smithing: the
|
||||
/// template, the base, the addition. Brewing: the bottle, the
|
||||
/// reagent.
|
||||
slots: list<list<stack>>,
|
||||
/// What it makes; several when that varies (a trim on any armour).
|
||||
results: list<stack>,
|
||||
/// Cooking: game ticks and experience; 0 for the other kinds.
|
||||
cooking-time: u32,
|
||||
experience: f32,
|
||||
/// The recipe book's id for it (one per way the book shows it), as
|
||||
/// `known` lists them; -1 for the kinds the book doesn't show
|
||||
/// (campfire, stonecutting, smithing, brewing).
|
||||
book-id: s32,
|
||||
}
|
||||
record item {
|
||||
/// `minecraft:diamond`, `<mod>:<path>`.
|
||||
id: string,
|
||||
/// In the player's language.
|
||||
name: string,
|
||||
/// Its item tags, without the `#`.
|
||||
tags: list<string>,
|
||||
}
|
||||
/// Every item, in the registry's order: `stack.item` indexes this list.
|
||||
items: func() -> list<item>;
|
||||
/// Every recipe: the book's in its order, then campfire, stonecutting,
|
||||
/// smithing and brewing. They don't change while a world is open.
|
||||
all: func() -> list<recipe>;
|
||||
/// The `book-id`s of the recipes the player's recipe book holds now.
|
||||
known: func() -> list<s32>;
|
||||
/// Places a recipe in the open screen's grid as a click in the recipe
|
||||
/// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or
|
||||
/// a crafting table. `all` places as many as the items allow, as with
|
||||
/// Shift. Refused while no such screen is open, for a recipe the
|
||||
/// player's book doesn't hold and for one that can't be placed; one
|
||||
/// missing items shows as a ghost in the grid, as in the book.
|
||||
place: func(book-id: s32, all: bool) -> result<_, string>;
|
||||
/// Creative mode only: gives the player the stack, as taking it from
|
||||
/// the creative inventory does (`ServerboundSetCreativeModeSlot`): the
|
||||
/// selected slot first, then the hotbar, then the inventory. The count
|
||||
/// is cut to one stack of the item (64, 16 or 1).
|
||||
give: func(stack: stack) -> result<_, string>;
|
||||
}
|
||||
|
||||
/// Capability `ui` (experimental): a recipe viewer's panel beside the
|
||||
/// game's inventory and container screens (not the creative inventory), on
|
||||
/// the right of the screen. The game draws it with its own widgets and
|
||||
/// slots, pages it and keeps its search box; the mod says what it holds.
|
||||
/// The first loaded mod with a panel shows.
|
||||
interface browser {
|
||||
/// An item to show: its id, a count (1 draws none) and its components
|
||||
/// beyond the item's own, in vanilla's item syntax, or "".
|
||||
record stack {
|
||||
item: string,
|
||||
count: u32,
|
||||
components: string,
|
||||
}
|
||||
/// A click on an item of the panel, by its index in `set-items`'s list.
|
||||
record click {
|
||||
index: u32,
|
||||
right: bool,
|
||||
}
|
||||
/// A key the mod registered (`input.register-key`) went down while the
|
||||
/// pointer was on an item: a slot of the screen's, or the panel's.
|
||||
record item-key {
|
||||
key: string,
|
||||
stack: stack,
|
||||
}
|
||||
/// A click on a slot of a recipe the panel shows (`set-recipes`).
|
||||
record slot-click {
|
||||
/// The recipe's index in `set-recipes`'s list and the slot's in
|
||||
/// it; the results come after the slots.
|
||||
recipe: u32,
|
||||
slot: u32,
|
||||
/// The stack the slot showed at that moment.
|
||||
stack: stack,
|
||||
right: bool,
|
||||
}
|
||||
variant event {
|
||||
clicked(click),
|
||||
/// The search box's text, each time the player changes it.
|
||||
search(string),
|
||||
key(item-key),
|
||||
slot-clicked(slot-click),
|
||||
/// The action button of the recipe at this index in
|
||||
/// `set-recipes`'s list.
|
||||
action(u32),
|
||||
/// The player turned the recipes' page (`set-recipes`): the index
|
||||
/// of the first recipe now shown.
|
||||
page(u32),
|
||||
/// The back button of `set-recipes`.
|
||||
back,
|
||||
/// The player picked the tab at this index of `set-recipes`' tabs.
|
||||
tab(u32),
|
||||
}
|
||||
/// How the game lays out a recipe of `set-recipes`.
|
||||
enum layout {
|
||||
/// A crafting grid of `width` x `height` slots, an arrow and the
|
||||
/// result.
|
||||
grid,
|
||||
/// One input over a flame, an arrow and the result (furnaces,
|
||||
/// smokers, campfires).
|
||||
cooking,
|
||||
/// One input, an arrow and the result (the stonecutter).
|
||||
single,
|
||||
/// Template, base and addition, an arrow and the result.
|
||||
smithing,
|
||||
/// A potion and an ingredient, an arrow and the result.
|
||||
brewing,
|
||||
}
|
||||
/// A recipe to show. Each slot, and the result, cycles through its
|
||||
/// stacks, one a second; an empty slot shows nothing.
|
||||
record recipe-view {
|
||||
layout: layout,
|
||||
/// The grid's size (`grid` only), at most 3 x 3.
|
||||
width: u32,
|
||||
height: u32,
|
||||
slots: list<list<stack>>,
|
||||
results: list<stack>,
|
||||
/// A short line under the recipe (a cooking time), or "".
|
||||
note: string,
|
||||
/// The action button beside it: none, enabled, or shown greyed.
|
||||
action: option<bool>,
|
||||
}
|
||||
/// The panel's items, at most 8192 (an empty list hides the panel).
|
||||
set-items: func(items: list<stack>);
|
||||
/// Shows these recipes in the panel instead of its items, as many as
|
||||
/// fit a page, under `title` and a back button, with a row of `tabs`
|
||||
/// (each an icon; `tab` is the selected one) between them; an empty
|
||||
/// list of recipes shows the items again. At most 256 recipes and 16
|
||||
/// tabs.
|
||||
set-recipes: func(title: string, tabs: list<stack>, tab: u32, recipes: list<recipe-view>);
|
||||
/// What the player did on the panel since the last poll, at most 256
|
||||
/// kept.
|
||||
poll: func() -> list<event>;
|
||||
}
|
||||
|
||||
/// Capability `players`, server mods only (manifest `api = "0.3"`,
|
||||
/// experimental): the players on the server, and messages to them.
|
||||
interface players {
|
||||
enum game-mode {
|
||||
survival,
|
||||
creative,
|
||||
adventure,
|
||||
spectator,
|
||||
}
|
||||
record player {
|
||||
/// The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
|
||||
id: string,
|
||||
name: string,
|
||||
/// Feet position, in blocks.
|
||||
x: f64,
|
||||
y: f64,
|
||||
z: f64,
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, ...
|
||||
dimension: string,
|
||||
game-mode: game-mode,
|
||||
/// Hit points as vanilla counts them: 20 is full health without effects.
|
||||
health: f32,
|
||||
}
|
||||
/// The players online, in the order they joined.
|
||||
online: func() -> list<player>;
|
||||
/// A system message to one player, after the mod's name; false when no
|
||||
/// player with that id is online. Up to 8 messages per call.
|
||||
tell: func(player: string, text: string) -> bool;
|
||||
/// A system message to every player and the server's log, after the
|
||||
/// mod's name. Counts toward the same 8 per call.
|
||||
broadcast: func(text: string);
|
||||
}
|
||||
|
||||
/// Capability `world`, server mods only (manifest `api = "0.3"`,
|
||||
/// experimental): the blocks of the server's loaded chunks, and changes to
|
||||
/// them through the server's own block updates.
|
||||
interface %world {
|
||||
record block-change {
|
||||
x: s32,
|
||||
y: s32,
|
||||
z: s32,
|
||||
/// `minecraft:oak_log[axis=y]`; properties left out take the block's
|
||||
/// defaults, `minecraft:stone` alone is the default state.
|
||||
state: string,
|
||||
}
|
||||
/// The block state at a position in a dimension (`minecraft:overworld`,
|
||||
/// ...), as `minecraft:oak_log[axis=y]`, as of this call: none where the
|
||||
/// server has no chunk loaded there.
|
||||
get-block: func(dimension: string, x: s32, y: s32, z: s32) -> option<string>;
|
||||
/// The biome at a position (`minecraft:plains`, ...), as the server
|
||||
/// stores it (one per 4 x 4 x 4 cell): none where no chunk is loaded.
|
||||
get-biome: func(dimension: string, x: s32, y: s32, z: s32) -> option<string>;
|
||||
/// Sets blocks as `/setblock` does: the old block entity removed,
|
||||
/// neighbours and shapes updated, light, and every player told. Every
|
||||
/// state is checked now (an error queues nothing); the changes are made
|
||||
/// in order after this call returns, before that dimension's next tick;
|
||||
/// one in a chunk not loaded then is skipped. At most 4096 per call.
|
||||
set-blocks: func(dimension: string, blocks: list<block-change>) -> result<_, string>;
|
||||
}
|
||||
|
||||
/// What the server calls in a server mod (manifest `api = "0.3"`,
|
||||
/// experimental). Only the hooks `init` asks for are ever called. The player
|
||||
/// in each event is as the server saw them when it happened.
|
||||
interface server-events {
|
||||
use players.{player};
|
||||
|
||||
record block-pos {
|
||||
x: s32,
|
||||
y: s32,
|
||||
z: s32,
|
||||
}
|
||||
/// What an argument of a command takes. The server sends each command
|
||||
/// and its arguments to players' games, whose chat box completes and
|
||||
/// colours them as vanilla's; the server parses them before the mod's
|
||||
/// `command` hook gets their values.
|
||||
variant argument-kind {
|
||||
/// One word: letters, digits and `_ - . +`.
|
||||
word,
|
||||
/// The rest of the line, spaces included: only last.
|
||||
text,
|
||||
/// A whole number, within the bounds given.
|
||||
integer(tuple<option<s32>, option<s32>>),
|
||||
/// A number with or without decimals.
|
||||
number,
|
||||
/// `true` or `false`.
|
||||
boolean,
|
||||
/// One online player: a name, or `@s` or `@p` (whoever typed it,
|
||||
/// as vanilla picks for a player).
|
||||
player,
|
||||
/// A block position: three whole numbers, each one also `~` or
|
||||
/// `~n`, relative to whoever typed it.
|
||||
block-pos,
|
||||
}
|
||||
record argument {
|
||||
/// Shown in the chat box's hint (`<height>`) and the key of its value.
|
||||
name: string,
|
||||
kind: argument-kind,
|
||||
/// Optional arguments come after every required one.
|
||||
optional: bool,
|
||||
}
|
||||
record server-command {
|
||||
/// Without the slash. A name vanilla already uses is refused
|
||||
/// (vanilla's command runs).
|
||||
name: string,
|
||||
/// In order; none: the command takes nothing after its name. Free
|
||||
/// text is one optional `text` argument.
|
||||
arguments: list<argument>,
|
||||
}
|
||||
/// An argument's value, parsed as its kind says.
|
||||
variant argument-value {
|
||||
word(string),
|
||||
text(string),
|
||||
integer(s32),
|
||||
number(f64),
|
||||
boolean(bool),
|
||||
player(player),
|
||||
block-pos(block-pos),
|
||||
}
|
||||
record registration {
|
||||
/// Commands players may type.
|
||||
commands: list<server-command>,
|
||||
/// Call `tick` every server tick (20 per second).
|
||||
tick: bool,
|
||||
/// Call `player-joined` and `player-left`.
|
||||
joins: bool,
|
||||
/// Call `chat` for each chat message a player sends.
|
||||
chat: bool,
|
||||
/// Call `block-broken` and `block-placed`.
|
||||
blocks: bool,
|
||||
}
|
||||
/// Once, after loading. An error keeps the mod off and is logged.
|
||||
init: func() -> result<registration, string>;
|
||||
/// Each server tick, with the server's tick counter.
|
||||
tick: func(tick: u64);
|
||||
/// A player joined the server; they are in `players.online` already.
|
||||
player-joined: func(player: player);
|
||||
/// A player left the server; they are no longer in `players.online`.
|
||||
player-left: func(player: player);
|
||||
/// A chat message a player sent, as plain text.
|
||||
chat: func(player: player, text: string);
|
||||
/// `/name args` typed by `player`: ok(some(text)) is their feedback,
|
||||
/// err(text) their error in red. `values` holds each argument given, by
|
||||
/// name, in order (an optional one left out isn't there); a line that
|
||||
/// doesn't parse never reaches the mod: the player gets vanilla's error.
|
||||
command: func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> result<option<string>, string>;
|
||||
/// A player broke the block at `pos` in their dimension: `state` is what
|
||||
/// was there, as `minecraft:oak_log[axis=y]`.
|
||||
block-broken: func(player: player, pos: block-pos, state: string);
|
||||
/// A player placed `state` at `pos` in their dimension.
|
||||
block-placed: func(player: player, pos: block-pos, state: string);
|
||||
}
|
||||
|
||||
/// A client mod: runs in the player's game.
|
||||
world horizon-mod {
|
||||
import log;
|
||||
import game;
|
||||
import chat;
|
||||
import hud;
|
||||
import storage;
|
||||
import command;
|
||||
export events;
|
||||
}
|
||||
|
||||
/// A server mod (manifest `side = "server"`, `api = "0.3"`, experimental):
|
||||
/// runs where the world is simulated, on the dedicated server, a LAN host
|
||||
/// and single player's own server.
|
||||
world horizon-server-mod {
|
||||
import log;
|
||||
import storage;
|
||||
import players;
|
||||
import %world;
|
||||
export server-events;
|
||||
}
|
||||
|
||||
/// A mod with widgets (capability `ui`): the SDK builds every mod against
|
||||
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
|
||||
/// still load and simply hear nothing of their widgets.
|
||||
world horizon-ui-mod {
|
||||
include horizon-mod;
|
||||
import ui;
|
||||
import render;
|
||||
import input;
|
||||
import recipes;
|
||||
import browser;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
/// The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
world ui-host {
|
||||
import ui;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
/// The game's side of capability `render` (imports only).
|
||||
world render-host {
|
||||
import render;
|
||||
import input;
|
||||
}
|
||||
|
||||
/// The game's side of capability `recipes` (imports only).
|
||||
world recipes-host {
|
||||
import recipes;
|
||||
import browser;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
description = "Horizon's mod runtime: sandboxed WebAssembly components (docs/mods.md)"
|
||||
description = "Horizon's mod files: content mods, modpacks and the registry index (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -12,12 +12,7 @@ serde_json = "1"
|
||||
toml = "1.1.6"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
log = "0.4"
|
||||
# The 48 LTS line: security fixes until August 2028, on the same wasm-tools (0.254) as wit-component and the SDK's wit-bindgen.
|
||||
wasmtime = { version = "48.0.7", default-features = false, features = ["runtime", "cranelift", "component-model", "std"], optional = true }
|
||||
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
|
||||
wit-component = { version = "0.254", optional = true }
|
||||
wit-parser = { version = "0.254", optional = true }
|
||||
# The functions a mod imports, for its API level (src/api_level.rs): the game and the registry index.
|
||||
# The functions a published WebAssembly mod imports, for its API level (src/api_level.rs): the registry index.
|
||||
wasmparser = { version = "0.254", optional = true }
|
||||
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
@@ -25,29 +20,9 @@ minisign-verify = { version = "0.3", optional = true }
|
||||
# A shader pack's check (kind `shader`): the one the game runs before it loads a pack.
|
||||
horizon-shader-api = { path = "../horizon-shader-api", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# A component made for a newer Horizon, from the WIT alone (tests/sandbox.rs).
|
||||
wit-component = { version = "0.254", features = ["dummy-module"] }
|
||||
wit-parser = "0.254"
|
||||
|
||||
[features]
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
# content and packaging. Off by default so builds that don't run mods (the
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime", "dep:wasmparser"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
|
||||
index = ["dep:sha2", "dep:minisign-verify", "dep:horizon-shader-api", "dep:wasmparser"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
name = "sandbox"
|
||||
required-features = ["runtime", "pack"]
|
||||
|
||||
# docs/api/mods-reference.md from the WIT contract (HORIZON_WRITE_DOCS=1 rewrites it).
|
||||
[[test]]
|
||||
name = "api_reference"
|
||||
required-features = ["pack"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -120,7 +120,7 @@ pub fn too_new(level: u32, function: Option<&str>) -> Option<String> {
|
||||
|
||||
/// The functions a mod's component imports from Horizon (`horizon:mods/*`),
|
||||
/// as `interface.function`, in the component's order.
|
||||
#[cfg(any(feature = "runtime", feature = "index"))]
|
||||
#[cfg(feature = "index")]
|
||||
pub fn imports(wasm: &[u8]) -> Result<Vec<String>, String> {
|
||||
use wasmparser::{
|
||||
Parser, Payload, Validator, WasmFeatures, component_types::ComponentEntityType,
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
//! Server mods' typed commands: an argument line parsed as the command's
|
||||
//! arguments say, the way vanilla's Brigadier parsers read them, with
|
||||
//! vanilla's error messages. The game sends the same arguments to players'
|
||||
//! command trees, so their chat box checks them before the server does.
|
||||
use crate::runtime::{Argument, ArgumentKind, ArgumentValue, BlockPos, ServerPlayer};
|
||||
|
||||
/// The values of `args`, typed by `player`, for `arguments` (in order;
|
||||
/// optional ones left out aren't there), or the error the player reads.
|
||||
pub fn parse(
|
||||
arguments: &[Argument],
|
||||
args: &str,
|
||||
player: &ServerPlayer,
|
||||
online: &[ServerPlayer],
|
||||
) -> Result<Vec<(String, ArgumentValue)>, String> {
|
||||
let mut values = Vec::new();
|
||||
let mut rest = args;
|
||||
for (i, argument) in arguments.iter().enumerate() {
|
||||
if i > 0 && !rest.is_empty() {
|
||||
rest = rest.strip_prefix(' ').ok_or(
|
||||
"Expected whitespace to end one argument, but found trailing data".to_string(),
|
||||
)?;
|
||||
}
|
||||
if rest.is_empty() {
|
||||
if argument.optional {
|
||||
break;
|
||||
}
|
||||
return Err("Unknown or incomplete command, see below for error".into());
|
||||
}
|
||||
let (value, after) = one(&argument.kind, rest, player, online)?;
|
||||
values.push((argument.name.clone(), value));
|
||||
rest = after;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return Err("Incorrect argument for command".into());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// StringReader.isAllowedInUnquotedString.
|
||||
fn unquoted(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+')
|
||||
}
|
||||
|
||||
/// The unquoted string at the start of `s`, and what follows it.
|
||||
fn word(s: &str) -> (&str, &str) {
|
||||
let end = s.find(|c: char| !unquoted(c)).unwrap_or(s.len());
|
||||
s.split_at(end)
|
||||
}
|
||||
|
||||
/// One argument from the start of `s`, and what follows it.
|
||||
fn one<'a>(
|
||||
kind: &ArgumentKind,
|
||||
s: &'a str,
|
||||
player: &ServerPlayer,
|
||||
online: &[ServerPlayer],
|
||||
) -> Result<(ArgumentValue, &'a str), String> {
|
||||
Ok(match kind {
|
||||
ArgumentKind::Word => {
|
||||
let (w, rest) = word(s);
|
||||
if w.is_empty() {
|
||||
return Err("Expected string".into());
|
||||
}
|
||||
(ArgumentValue::Word(w.into()), rest)
|
||||
}
|
||||
ArgumentKind::Text => (ArgumentValue::Text(s.into()), ""),
|
||||
ArgumentKind::Integer((min, max)) => {
|
||||
let (n, rest) = integer(s)?;
|
||||
if let Some(min) = min.filter(|m| n < *m) {
|
||||
return Err(format!("Integer must not be less than {min}, found {n}"));
|
||||
}
|
||||
if let Some(max) = max.filter(|m| n > *m) {
|
||||
return Err(format!("Integer must not be more than {max}, found {n}"));
|
||||
}
|
||||
(ArgumentValue::Integer(n), rest)
|
||||
}
|
||||
ArgumentKind::Number => {
|
||||
let (n, rest) = double(s)?;
|
||||
(ArgumentValue::Number(n), rest)
|
||||
}
|
||||
ArgumentKind::Boolean => {
|
||||
let (w, rest) = word(s);
|
||||
let b = match w {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
"" => return Err("Expected bool".into()),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid boolean, expected 'true' or 'false' but found '{w}'"
|
||||
));
|
||||
}
|
||||
};
|
||||
(ArgumentValue::Boolean(b), rest)
|
||||
}
|
||||
ArgumentKind::Player => {
|
||||
if let Some(selector) = s.strip_prefix('@') {
|
||||
let (kind, rest) = word(selector);
|
||||
return match kind {
|
||||
// A player's @p is themselves (the nearest to where they are).
|
||||
"s" | "p" => Ok((ArgumentValue::Player(player.clone()), rest)),
|
||||
"a" | "e" | "r" | "n" => {
|
||||
Err("Only one player is allowed, but the provided selector allows more than one".into())
|
||||
}
|
||||
_ => Err(format!("Unknown selector type '@{kind}'")),
|
||||
};
|
||||
}
|
||||
let (name, rest) = word(s);
|
||||
if name.is_empty() {
|
||||
return Err("Invalid name or UUID".into());
|
||||
}
|
||||
let found = online
|
||||
.iter()
|
||||
.find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
.ok_or("No player was found")?;
|
||||
(ArgumentValue::Player(found.clone()), rest)
|
||||
}
|
||||
ArgumentKind::BlockPos => {
|
||||
let (x, rest) = coordinate(s, player.x)?;
|
||||
let rest = rest
|
||||
.strip_prefix(' ')
|
||||
.ok_or("Incomplete (expected 3 coordinates)")?;
|
||||
let (y, rest) = coordinate(rest, player.y)?;
|
||||
let rest = rest
|
||||
.strip_prefix(' ')
|
||||
.ok_or("Incomplete (expected 3 coordinates)")?;
|
||||
let (z, rest) = coordinate(rest, player.z)?;
|
||||
(ArgumentValue::BlockPos(BlockPos { x, y, z }), rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// StringReader.readInt.
|
||||
fn integer(s: &str) -> Result<(i32, &str), String> {
|
||||
let end = s
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
|
||||
.unwrap_or(s.len());
|
||||
let (n, rest) = s.split_at(end);
|
||||
if n.is_empty() {
|
||||
return Err("Expected integer".into());
|
||||
}
|
||||
let n = n.parse().map_err(|_| format!("Invalid integer '{n}'"))?;
|
||||
Ok((n, rest))
|
||||
}
|
||||
|
||||
/// StringReader.readDouble.
|
||||
fn double(s: &str) -> Result<(f64, &str), String> {
|
||||
let end = s
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
|
||||
.unwrap_or(s.len());
|
||||
let (n, rest) = s.split_at(end);
|
||||
if n.is_empty() {
|
||||
return Err("Expected double".into());
|
||||
}
|
||||
let n = n.parse().map_err(|_| format!("Invalid double '{n}'"))?;
|
||||
Ok((n, rest))
|
||||
}
|
||||
|
||||
/// WorldCoordinate.parseInt, then BlockPos.containing: `~` and `~n` from
|
||||
/// `base`, a whole number as it is.
|
||||
fn coordinate(s: &str, base: f64) -> Result<(i32, &str), String> {
|
||||
if s.starts_with('^') {
|
||||
return Err(
|
||||
"Cannot mix world & local coordinates (everything must either use ^ or not)".into(),
|
||||
);
|
||||
}
|
||||
if let Some(offset) = s.strip_prefix('~') {
|
||||
if offset.is_empty() || offset.starts_with(' ') {
|
||||
return Ok((base.floor() as i32, offset));
|
||||
}
|
||||
let (n, rest) = double(offset)?;
|
||||
return Ok(((base + n).floor() as i32, rest));
|
||||
}
|
||||
integer(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::GameMode;
|
||||
|
||||
fn player(name: &str) -> ServerPlayer {
|
||||
ServerPlayer {
|
||||
id: format!("id-{name}"),
|
||||
name: name.into(),
|
||||
x: 10.7,
|
||||
y: 64.0,
|
||||
z: -3.2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
}
|
||||
}
|
||||
fn arg(name: &str, kind: ArgumentKind, optional: bool) -> Argument {
|
||||
Argument {
|
||||
name: name.into(),
|
||||
kind,
|
||||
optional,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_in_order_and_optional_ones_left_out() {
|
||||
let alex = player("Alex");
|
||||
let online = [alex.clone(), player("Steve")];
|
||||
let args = [
|
||||
arg("who", ArgumentKind::Player, false),
|
||||
arg("height", ArgumentKind::Integer((Some(1), Some(64))), true),
|
||||
arg("note", ArgumentKind::Text, true),
|
||||
];
|
||||
let v = parse(&args, "steve 5 hello there", &alex, &online).unwrap();
|
||||
assert_eq!(v[0].0, "who");
|
||||
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Steve"));
|
||||
assert_eq!(v[1], ("height".into(), ArgumentValue::Integer(5)));
|
||||
assert_eq!(
|
||||
v[2],
|
||||
("note".into(), ArgumentValue::Text("hello there".into()))
|
||||
);
|
||||
let v = parse(&args, "@s", &alex, &online).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Alex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_errors() {
|
||||
let alex = player("Alex");
|
||||
let online = [alex.clone()];
|
||||
let height = [arg(
|
||||
"height",
|
||||
ArgumentKind::Integer((Some(1), Some(64))),
|
||||
false,
|
||||
)];
|
||||
let err = |a: &[Argument], s| parse(a, s, &alex, &online).unwrap_err();
|
||||
assert_eq!(
|
||||
err(&height, "0"),
|
||||
"Integer must not be less than 1, found 0"
|
||||
);
|
||||
assert_eq!(
|
||||
err(&height, "65"),
|
||||
"Integer must not be more than 64, found 65"
|
||||
);
|
||||
assert_eq!(err(&height, "x"), "Expected integer");
|
||||
assert_eq!(err(&height, "1.5"), "Invalid integer '1.5'");
|
||||
assert_eq!(
|
||||
err(&height, ""),
|
||||
"Unknown or incomplete command, see below for error"
|
||||
);
|
||||
assert_eq!(err(&height, "3 4"), "Incorrect argument for command");
|
||||
let who = [arg("who", ArgumentKind::Player, false)];
|
||||
assert_eq!(err(&who, "Herobrine"), "No player was found");
|
||||
assert_eq!(err(&who, "@q"), "Unknown selector type '@q'");
|
||||
let flag = [arg("on", ArgumentKind::Boolean, false)];
|
||||
assert_eq!(
|
||||
err(&flag, "yes"),
|
||||
"Invalid boolean, expected 'true' or 'false' but found 'yes'"
|
||||
);
|
||||
let pos = [arg("at", ArgumentKind::BlockPos, false)];
|
||||
assert_eq!(err(&pos, "1 2"), "Incomplete (expected 3 coordinates)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_positions_relative_to_the_player() {
|
||||
let alex = player("Alex");
|
||||
let pos = [arg("at", ArgumentKind::BlockPos, false)];
|
||||
let v = parse(&pos, "~ ~1 ~-0.5", &alex, &[]).unwrap();
|
||||
assert_eq!(
|
||||
v[0].1,
|
||||
ArgumentValue::BlockPos(BlockPos {
|
||||
x: 10,
|
||||
y: 65,
|
||||
z: -4
|
||||
})
|
||||
);
|
||||
let v = parse(&pos, "-5 70 ~", &alex, &[]).unwrap();
|
||||
assert_eq!(
|
||||
v[0].1,
|
||||
ArgumentValue::BlockPos(BlockPos {
|
||||
x: -5,
|
||||
y: 70,
|
||||
z: -4
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_is_an_optional_text_argument() {
|
||||
let alex = player("Alex");
|
||||
let free = [arg("args", ArgumentKind::Text, true)];
|
||||
assert_eq!(parse(&free, "", &alex, &[]), Ok(Vec::new()));
|
||||
assert_eq!(
|
||||
parse(&free, "anything at all", &alex, &[]),
|
||||
Ok(vec![(
|
||||
"args".into(),
|
||||
ArgumentValue::Text("anything at all".into())
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&[], "more", &alex, &[]),
|
||||
Err("Incorrect argument for command".into())
|
||||
);
|
||||
assert_eq!(parse(&[], "", &alex, &[]), Ok(Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,12 @@
|
||||
//! Horizon's mods (`docs/mods.md`): WebAssembly components written against
|
||||
//! `crates/horizon-mod-sdk/wit/horizon.wit`, each in its own sandbox with
|
||||
//! no file, network or clock access, a memory cap and a time budget per
|
||||
//! call. A mod that traps or overruns is turned off; the game goes on.
|
||||
//!
|
||||
//! Nothing here runs unless a mod is installed and turned on: an empty
|
||||
//! [`Mods`] makes no engine and every hook returns at once.
|
||||
//! Horizon's mod files (`docs/mods.md`): content mods (`.hmod`: blocks, items, data and
|
||||
//! assets, no code), modpacks and, with `index`, the mod registry's index. Code mods are
|
||||
//! native (docs/native-mods.md).
|
||||
pub mod api_level;
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod commands;
|
||||
pub mod content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
pub mod manifest;
|
||||
pub mod modpack;
|
||||
#[cfg(feature = "runtime")]
|
||||
mod runtime;
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod testing;
|
||||
|
||||
pub use content::Content;
|
||||
pub use manifest::{Capability, Manifest, Package, Side, find};
|
||||
#[cfg(feature = "runtime")]
|
||||
pub use runtime::*;
|
||||
@@ -406,37 +406,6 @@ pub fn find(dir: &Path) -> Vec<PathBuf> {
|
||||
found
|
||||
}
|
||||
|
||||
/// Turns a `wasm32-unknown-unknown` cdylib built with the SDK into the
|
||||
/// component the game loads.
|
||||
#[cfg(feature = "pack")]
|
||||
pub fn componentize(core: &[u8]) -> Result<Vec<u8>, String> {
|
||||
wit_component::ComponentEncoder::default()
|
||||
.module(core)
|
||||
.and_then(|e| e.validate(true).encode())
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
/// The names a component imports: `horizon:mods/[email protected]`, ...
|
||||
#[cfg(feature = "pack")]
|
||||
pub fn component_imports(component: &[u8]) -> Result<Vec<String>, String> {
|
||||
use wit_parser::{WorldItem, WorldKey};
|
||||
let wit_component::DecodedWasm::Component(resolve, world) =
|
||||
wit_component::decode(component).map_err(|e| format!("{e:#}"))?
|
||||
else {
|
||||
return Err("a WIT package, not a component".into());
|
||||
};
|
||||
let names = resolve.worlds[world]
|
||||
.imports
|
||||
.iter()
|
||||
.map(|(key, item)| match (key, item) {
|
||||
(WorldKey::Interface(id), _) | (_, WorldItem::Interface { id, .. }) => resolve
|
||||
.id_of(*id)
|
||||
.unwrap_or_else(|| resolve.name_world_key(key)),
|
||||
_ => resolve.name_world_key(key),
|
||||
});
|
||||
Ok(names.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,728 +0,0 @@
|
||||
//! A mod run with no game: `cargo horizon-mod test` (docs/mods.md, "Testing a mod").
|
||||
//!
|
||||
//! [`TestGame`] loads one `.hmod` from a scratch copy (its `storage` lives and
|
||||
//! dies with the run) into the same sandbox the game uses, and plays the game's
|
||||
//! part: the snapshot, ticks, frames, chat lines and commands, and for a
|
||||
//! server mod the players joining, leaving, chatting and breaking or placing
|
||||
//! blocks. [`Script`] is a
|
||||
//! test written in TOML, `tests/<name>.toml` in the mod's folder: steps, each
|
||||
//! saying what happens and what the mod must have shown by then.
|
||||
use crate::{
|
||||
BlockPos, Clock, Corner, GameMode, Mods, Output, Panel, Player, ServerPlayer, Snapshot, Status,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
/// One mod in the sandbox, driven by a test.
|
||||
pub struct TestGame {
|
||||
mods: Mods,
|
||||
dir: PathBuf,
|
||||
hmod: PathBuf,
|
||||
/// What the mod reads with `game-read`; ticks move the clock as the game does.
|
||||
pub player: Option<Player>,
|
||||
pub clock: Option<Clock>,
|
||||
/// The players on the server, for a server mod, in join order.
|
||||
pub players: Vec<ServerPlayer>,
|
||||
/// Every name that joined, for their id.
|
||||
names: Vec<String>,
|
||||
tick: u64,
|
||||
/// Capability `world`'s Overworld: the blocks a script set or the mod
|
||||
/// changed; every other block, and the other dimensions, are air. Every
|
||||
/// biome is the dimension's plainest: plains, nether wastes, the end.
|
||||
pub world: std::collections::BTreeMap<(i32, i32, i32), String>,
|
||||
}
|
||||
|
||||
/// The world a script's mod reads: [`TestGame::world`], copied for a step.
|
||||
struct ScriptWorld(std::collections::BTreeMap<(i32, i32, i32), String>);
|
||||
|
||||
impl crate::WorldView for ScriptWorld {
|
||||
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
|
||||
let here = (dimension == "minecraft:overworld")
|
||||
.then(|| self.0.get(&(x, y, z)).cloned())
|
||||
.flatten();
|
||||
Some(here.unwrap_or_else(|| "minecraft:air".into()))
|
||||
}
|
||||
fn biome(&self, dimension: &str, _x: i32, _y: i32, _z: i32) -> Option<String> {
|
||||
let biome = match dimension {
|
||||
"minecraft:overworld" => "minecraft:plains",
|
||||
"minecraft:the_nether" => "minecraft:nether_wastes",
|
||||
"minecraft:the_end" => "minecraft:the_end",
|
||||
_ => return None,
|
||||
};
|
||||
Some(biome.into())
|
||||
}
|
||||
fn has_dimension(&self, dimension: &str) -> bool {
|
||||
matches!(
|
||||
dimension,
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
|
||||
)
|
||||
}
|
||||
fn check_state(&self, state: &str) -> Result<(), String> {
|
||||
// No registry here: the shape only (the game checks the names).
|
||||
let name = state.split('[').next().unwrap_or_default();
|
||||
let plain = |t: &str| {
|
||||
!t.is_empty()
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-/".contains(&b))
|
||||
};
|
||||
let ok = match name.split_once(':') {
|
||||
Some((ns, path)) => plain(ns) && plain(path),
|
||||
None => plain(name),
|
||||
};
|
||||
let closed = !state.contains('[') || state.ends_with(']');
|
||||
(ok && closed)
|
||||
.then_some(())
|
||||
.ok_or_else(|| format!("{state} is not a block state"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a script starts: standing at 0.5 64 0.5 in the Overworld, facing
|
||||
/// south, full health and food, at the first tick of a new world's first morning.
|
||||
pub fn default_player() -> Player {
|
||||
Player {
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl TestGame {
|
||||
/// Starts the mod at `hmod`, as the game would with it turned on. Errs
|
||||
/// when it doesn't start, with the game's reason.
|
||||
pub fn load(hmod: &Path) -> Result<TestGame, String> {
|
||||
static RUN: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-mod-test-{}-{}",
|
||||
std::process::id(),
|
||||
RUN.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let copy = dir.join(hmod.file_name().unwrap_or("mod.hmod".as_ref()));
|
||||
let mut game = TestGame {
|
||||
mods: Mods::default(),
|
||||
hmod: copy.clone(),
|
||||
dir,
|
||||
player: Some(default_player()),
|
||||
clock: Some(Clock {
|
||||
game_time: 0,
|
||||
day_time: 0,
|
||||
}),
|
||||
players: Vec::new(),
|
||||
names: Vec::new(),
|
||||
tick: 0,
|
||||
world: Default::default(),
|
||||
};
|
||||
copy_mod(hmod, ©)?;
|
||||
game.start()?;
|
||||
Ok(game)
|
||||
}
|
||||
|
||||
fn start(&mut self) -> Result<(), String> {
|
||||
self.mods = Mods::load(std::slice::from_ref(&self.hmod), |_| true, |_| false);
|
||||
match &self.mods.entries()[0].status {
|
||||
Status::Failed(reason) => Err(format!("the mod didn't start: {reason}")),
|
||||
_ if !self.mods.is_running() => Err("the mod has no code to run".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the mod and starts it again, as a rebuild does in the game: its
|
||||
/// memory starts over, its `storage` stays.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
self.mods = Mods::default();
|
||||
self.start()
|
||||
}
|
||||
|
||||
fn snapshot(&mut self) {
|
||||
self.mods.set_snapshot(Snapshot {
|
||||
player: self.player.clone(),
|
||||
clock: self.clock,
|
||||
players: self.players.clone(),
|
||||
// The GUI of a 1280 x 720 window at scale 3, as the trial run's.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
});
|
||||
}
|
||||
/// Whether the mod is a server mod.
|
||||
pub fn server(&self) -> bool {
|
||||
self.mods.runs_server()
|
||||
}
|
||||
|
||||
/// `n` game ticks; each moves the clock one tick on, as the game does.
|
||||
pub fn tick(&mut self, n: u64) {
|
||||
for _ in 0..n {
|
||||
self.tick += 1;
|
||||
if let Some(clock) = &mut self.clock {
|
||||
clock.game_time += 1;
|
||||
clock.day_time += 1;
|
||||
}
|
||||
self.snapshot();
|
||||
self.mods.tick(self.tick);
|
||||
self.mods.server_tick(self.tick);
|
||||
}
|
||||
}
|
||||
/// `n` frames, 1/60 s apart.
|
||||
pub fn frames(&mut self, n: u64) {
|
||||
self.snapshot();
|
||||
for _ in 0..n {
|
||||
self.mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
/// A chat line the player receives.
|
||||
pub fn chat(&mut self, line: &str) {
|
||||
self.snapshot();
|
||||
self.mods.chat(line);
|
||||
}
|
||||
/// `/line` typed by the player (without the slash); false when the mod
|
||||
/// owns no such command.
|
||||
pub fn command(&mut self, line: &str) -> bool {
|
||||
self.snapshot();
|
||||
self.mods.command(line)
|
||||
}
|
||||
|
||||
/// A player named `name` joins the server, standing where a script starts
|
||||
/// in survival. Their id is `00000000-0000-4000-8000-000000000001` for
|
||||
/// the first name that joined, `...0002` for the second, and so on.
|
||||
pub fn join(&mut self, name: &str) -> ServerPlayer {
|
||||
let n = match self.names.iter().position(|n| n == name) {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
self.names.push(name.to_owned());
|
||||
self.names.len()
|
||||
}
|
||||
};
|
||||
let p = default_player();
|
||||
let player = ServerPlayer {
|
||||
id: format!("00000000-0000-4000-8000-{n:012x}"),
|
||||
name: name.to_owned(),
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
z: p.z,
|
||||
dimension: p.dimension,
|
||||
game_mode: GameMode::Survival,
|
||||
health: p.health,
|
||||
};
|
||||
self.players.push(player.clone());
|
||||
self.snapshot();
|
||||
self.mods.player_joined(&player);
|
||||
player
|
||||
}
|
||||
/// The player named `name` leaves; false when no such player is on.
|
||||
pub fn leave(&mut self, name: &str) -> bool {
|
||||
let Some(i) = self.players.iter().position(|p| p.name == name) else {
|
||||
return false;
|
||||
};
|
||||
let player = self.players.remove(i);
|
||||
self.snapshot();
|
||||
self.mods.player_left(&player);
|
||||
true
|
||||
}
|
||||
/// A chat message `player` sends, for a server mod.
|
||||
pub fn player_chat(&mut self, player: &ServerPlayer, line: &str) {
|
||||
self.snapshot();
|
||||
self.mods.player_chat(player, line);
|
||||
}
|
||||
/// `/line` typed by `player`, for a server mod; false when the mod owns
|
||||
/// no such command.
|
||||
pub fn player_command(&mut self, player: &ServerPlayer, line: &str) -> bool {
|
||||
self.snapshot();
|
||||
self.mods.server_command(player, line)
|
||||
}
|
||||
/// `player` breaks (or places) `state` at `pos`.
|
||||
pub fn block(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str, placed: bool) {
|
||||
self.snapshot();
|
||||
if placed {
|
||||
self.mods.block_placed(player, pos, state);
|
||||
} else {
|
||||
self.mods.block_broken(player, pos, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scratch copy the mod runs from; its `storage` is beside it.
|
||||
pub fn hmod(&self) -> &Path {
|
||||
&self.hmod
|
||||
}
|
||||
/// The commands the mod registered.
|
||||
pub fn commands(&self) -> Vec<String> {
|
||||
let server = self.mods.server_commands().map(|c| c.name.as_str());
|
||||
self.mods
|
||||
.commands()
|
||||
.chain(server)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
pub fn panel(&self) -> Option<&Panel> {
|
||||
self.mods.panels().next()
|
||||
}
|
||||
pub fn status(&self) -> &Status {
|
||||
&self.mods.entries()[0].status
|
||||
}
|
||||
/// What the mod showed since the last call.
|
||||
pub fn drain(&mut self) -> Vec<Output> {
|
||||
self.mods.drain()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestGame {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.hmod` file, or a mod folder (`horizon-mod.toml` + `mod.wasm`).
|
||||
fn copy_mod(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{}: {e}", from.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for entry in std::fs::read_dir(from).map_err(err)? {
|
||||
let entry = entry.map_err(err)?;
|
||||
if entry.file_type().map_err(err)?.is_file() {
|
||||
std::fs::copy(entry.path(), to.join(entry.file_name())).map_err(err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A test in TOML: where a client mod's player starts, then steps run in order.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct Script {
|
||||
#[serde(default)]
|
||||
pub player: PlayerSet,
|
||||
#[serde(default)]
|
||||
pub clock: ClockSet,
|
||||
#[serde(default, rename = "step")]
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
/// Fields of the player to change; the others keep their values. A server
|
||||
/// mod's players have no `yaw`, `pitch` or `food`, and a client mod's player
|
||||
/// no `game-mode`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct PlayerSet {
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
z: Option<f64>,
|
||||
yaw: Option<f32>,
|
||||
pitch: Option<f32>,
|
||||
health: Option<f32>,
|
||||
food: Option<u32>,
|
||||
dimension: Option<String>,
|
||||
game_mode: Option<Mode>,
|
||||
}
|
||||
|
||||
/// `players.game-mode` as a script spells it.
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Mode {
|
||||
Survival,
|
||||
Creative,
|
||||
Adventure,
|
||||
Spectator,
|
||||
}
|
||||
impl From<Mode> for GameMode {
|
||||
fn from(m: Mode) -> GameMode {
|
||||
match m {
|
||||
Mode::Survival => GameMode::Survival,
|
||||
Mode::Creative => GameMode::Creative,
|
||||
Mode::Adventure => GameMode::Adventure,
|
||||
Mode::Spectator => GameMode::Spectator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A block a player breaks or places.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BlockSet {
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct ClockSet {
|
||||
game_time: Option<u64>,
|
||||
day_time: Option<u64>,
|
||||
}
|
||||
|
||||
/// One step. Its parts happen in this order: `reload`, `title-screen`,
|
||||
/// `blocks`, `join`, `player`, `clock`, `ticks`, `frames`, `chat`, `command`,
|
||||
/// `break`, `place`, `leave`; then the blocks the mod set are made, and
|
||||
/// `expect` is checked against what the mod showed and changed during the
|
||||
/// step. For a server mod, `player`, `chat`, `command`, `break`
|
||||
/// and `place` are the doing of the player named by `as`, or of the first
|
||||
/// player on.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct Step {
|
||||
#[serde(default)]
|
||||
reload: bool,
|
||||
/// Leave the world: the mod reads no player and no clock until a step sets them.
|
||||
#[serde(default)]
|
||||
title_screen: bool,
|
||||
player: Option<PlayerSet>,
|
||||
clock: Option<ClockSet>,
|
||||
#[serde(default)]
|
||||
ticks: u64,
|
||||
#[serde(default)]
|
||||
frames: u64,
|
||||
chat: Option<String>,
|
||||
command: Option<String>,
|
||||
/// Server mods: a player joins, leaves, breaks or places a block.
|
||||
join: Option<String>,
|
||||
leave: Option<String>,
|
||||
#[serde(rename = "as")]
|
||||
actor: Option<String>,
|
||||
#[serde(rename = "break")]
|
||||
broken: Option<BlockSet>,
|
||||
place: Option<BlockSet>,
|
||||
/// Blocks of the Overworld before the rest of the step (capability
|
||||
/// `world`); every other block is air.
|
||||
#[serde(default)]
|
||||
blocks: Vec<BlockSet>,
|
||||
#[serde(default)]
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
/// What a step checks; a part left out is not checked.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct Expect {
|
||||
/// The commands the mod owns.
|
||||
commands: Option<Vec<String>>,
|
||||
/// Every chat line shown during the step, in order.
|
||||
chat: Option<Vec<String>>,
|
||||
/// The command's feedback, and its red error.
|
||||
feedback: Option<String>,
|
||||
error: Option<String>,
|
||||
/// Server mods: every `players.tell` as `"Name: text"`, and every
|
||||
/// `players.broadcast`, in order.
|
||||
told: Option<Vec<String>>,
|
||||
broadcast: Option<Vec<String>>,
|
||||
/// The panel's lines (`[]`: no panel) and its corner.
|
||||
panel: Option<Vec<String>>,
|
||||
corner: Option<String>,
|
||||
/// Blocks of the Overworld after the step, the mod's changes made
|
||||
/// (capability `world`).
|
||||
blocks: Option<Vec<BlockSet>>,
|
||||
/// The mod is turned off during this step, for a reason holding this text.
|
||||
/// Without it, a mod that stops fails the test.
|
||||
stopped: Option<String>,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
pub fn parse(text: &str) -> Result<Script, String> {
|
||||
toml::from_str(text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Runs every step on `game`; the error names the first step that failed.
|
||||
pub fn run(&self, game: &mut TestGame) -> Result<(), String> {
|
||||
if let Some(p) = &mut game.player {
|
||||
self.player.apply(p);
|
||||
}
|
||||
if let Some(c) = &mut game.clock {
|
||||
self.clock.apply(c);
|
||||
}
|
||||
for (i, step) in self.steps.iter().enumerate() {
|
||||
step.run(game)
|
||||
.map_err(|e| format!("step {} ({}): {e}", i + 1, step.describe()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerSet {
|
||||
fn apply(&self, p: &mut Player) {
|
||||
let set = |to: &mut f64, v: Option<f64>| *to = v.unwrap_or(*to);
|
||||
set(&mut p.x, self.x);
|
||||
set(&mut p.y, self.y);
|
||||
set(&mut p.z, self.z);
|
||||
p.yaw = self.yaw.unwrap_or(p.yaw);
|
||||
p.pitch = self.pitch.unwrap_or(p.pitch);
|
||||
p.health = self.health.unwrap_or(p.health);
|
||||
p.food = self.food.unwrap_or(p.food);
|
||||
if let Some(d) = &self.dimension {
|
||||
p.dimension.clone_from(d);
|
||||
}
|
||||
}
|
||||
fn apply_server(&self, p: &mut ServerPlayer) {
|
||||
let set = |to: &mut f64, v: Option<f64>| *to = v.unwrap_or(*to);
|
||||
set(&mut p.x, self.x);
|
||||
set(&mut p.y, self.y);
|
||||
set(&mut p.z, self.z);
|
||||
p.health = self.health.unwrap_or(p.health);
|
||||
p.game_mode = self.game_mode.map_or(p.game_mode, GameMode::from);
|
||||
if let Some(d) = &self.dimension {
|
||||
p.dimension.clone_from(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSet {
|
||||
fn apply(&self, c: &mut Clock) {
|
||||
c.game_time = self.game_time.unwrap_or(c.game_time);
|
||||
c.day_time = self.day_time.unwrap_or(c.day_time);
|
||||
}
|
||||
}
|
||||
|
||||
impl Step {
|
||||
fn describe(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if self.reload {
|
||||
parts.push("reload".to_string());
|
||||
}
|
||||
let count = |n: u64, what: &str| format!("{n} {what}{}", if n == 1 { "" } else { "s" });
|
||||
if self.ticks > 0 {
|
||||
parts.push(count(self.ticks, "tick"));
|
||||
}
|
||||
if self.frames > 0 {
|
||||
parts.push(count(self.frames, "frame"));
|
||||
}
|
||||
if let Some(line) = &self.chat {
|
||||
parts.push(format!("chat {line:?}"));
|
||||
}
|
||||
if let Some(line) = &self.command {
|
||||
parts.push(format!("/{line}"));
|
||||
}
|
||||
if let Some(name) = &self.join {
|
||||
parts.insert(0, format!("{name} joins"));
|
||||
}
|
||||
for (what, b) in [("breaks", &self.broken), ("places", &self.place)] {
|
||||
if let Some(b) = b {
|
||||
parts.push(format!("{what} {} at {} {} {}", b.state, b.x, b.y, b.z));
|
||||
}
|
||||
}
|
||||
if let Some(name) = &self.leave {
|
||||
parts.push(format!("{name} leaves"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push("checks".into());
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
fn run(&self, game: &mut TestGame) -> Result<(), String> {
|
||||
if self.reload {
|
||||
game.reload()?;
|
||||
}
|
||||
if self.title_screen {
|
||||
game.player = None;
|
||||
game.clock = None;
|
||||
}
|
||||
for b in &self.blocks {
|
||||
game.world.insert((b.x, b.y, b.z), b.state.clone());
|
||||
}
|
||||
game.mods
|
||||
.lend_world(Box::new(ScriptWorld(game.world.clone())));
|
||||
if let Some(name) = &self.join {
|
||||
game.join(name);
|
||||
}
|
||||
let server = game.server();
|
||||
let actor = |game: &TestGame| match &self.actor {
|
||||
Some(name) => game
|
||||
.players
|
||||
.iter()
|
||||
.position(|p| p.name == *name)
|
||||
.ok_or_else(|| format!("{name} is not on the server")),
|
||||
None => (!game.players.is_empty())
|
||||
.then_some(0)
|
||||
.ok_or_else(|| "no player is on the server: add join = \"Name\"".to_string()),
|
||||
};
|
||||
let acts = self.player.is_some()
|
||||
|| self.chat.is_some()
|
||||
|| self.command.is_some()
|
||||
|| self.broken.is_some()
|
||||
|| self.place.is_some();
|
||||
let actor = if server && acts {
|
||||
Some(actor(game)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(set) = &self.player {
|
||||
match actor {
|
||||
Some(i) => set.apply_server(&mut game.players[i]),
|
||||
None => set.apply(game.player.get_or_insert_with(default_player)),
|
||||
}
|
||||
}
|
||||
if let Some(set) = &self.clock {
|
||||
set.apply(game.clock.get_or_insert(Clock {
|
||||
game_time: 0,
|
||||
day_time: 0,
|
||||
}));
|
||||
}
|
||||
game.tick(self.ticks);
|
||||
game.frames(self.frames);
|
||||
let who = actor.map(|i| game.players[i].clone());
|
||||
if let Some(line) = &self.chat {
|
||||
match &who {
|
||||
Some(p) => game.player_chat(p, line),
|
||||
None => game.chat(line),
|
||||
}
|
||||
}
|
||||
let owned = self.command.as_ref().map(|line| match &who {
|
||||
Some(p) => game.player_command(p, line),
|
||||
None => game.command(line),
|
||||
});
|
||||
for (b, placed) in [(&self.broken, false), (&self.place, true)] {
|
||||
if let (Some(b), Some(p)) = (b, &who) {
|
||||
let pos = BlockPos {
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
z: b.z,
|
||||
};
|
||||
game.block(p, pos, &b.state, placed);
|
||||
}
|
||||
}
|
||||
if let Some(name) = &self.leave
|
||||
&& !game.leave(name)
|
||||
{
|
||||
return Err(format!("{name} is not on the server"));
|
||||
}
|
||||
game.mods.take_world();
|
||||
let out = game.drain();
|
||||
for o in &out {
|
||||
if let Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} = o
|
||||
&& dimension == "minecraft:overworld"
|
||||
{
|
||||
for b in blocks {
|
||||
game.world.insert((b.x, b.y, b.z), b.state.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let stopped = out.iter().find_map(|o| match o {
|
||||
Output::Stopped { reason, .. } => Some(reason.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
let e = &self.expect;
|
||||
match (stopped, &e.stopped) {
|
||||
(Some(reason), Some(want)) if !reason.contains(want.as_str()) => {
|
||||
return Err(format!("the mod stopped ({reason}), expected {want:?}"));
|
||||
}
|
||||
(Some(_), Some(_)) => return Ok(()),
|
||||
(Some(reason), None) => return Err(format!("the mod stopped: {reason}")),
|
||||
(None, Some(want)) => {
|
||||
return Err(format!(
|
||||
"the mod kept running, expected it to stop ({want:?})"
|
||||
));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
if owned == Some(false) {
|
||||
let line = self.command.as_deref().unwrap_or_default();
|
||||
let name = line.split(' ').next().unwrap_or_default();
|
||||
return Err(format!("the mod owns no command /{name}"));
|
||||
}
|
||||
if let Some(want) = &e.commands {
|
||||
same("commands", want, &game.commands())?;
|
||||
}
|
||||
if let Some(want) = &e.chat {
|
||||
let got: Vec<String> = out
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::Chat { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
same("chat", want, &got)?;
|
||||
}
|
||||
let feedback = |error: bool| {
|
||||
out.iter().rev().find_map(|o| match o {
|
||||
Output::Feedback { text, error: e } | Output::Reply { text, error: e, .. }
|
||||
if *e == error =>
|
||||
{
|
||||
Some(text.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
if let Some(want) = &e.told {
|
||||
let name = |id: &str| {
|
||||
let p = game.players.iter().find(|p| p.id == id);
|
||||
p.map_or(id.to_owned(), |p| p.name.clone())
|
||||
};
|
||||
let got: Vec<String> = out
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::Tell { player, text, .. } => Some(format!("{}: {text}", name(player))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
same("told", want, &got)?;
|
||||
}
|
||||
if let Some(want) = &e.broadcast {
|
||||
let got: Vec<String> = out
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::Broadcast { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
same("broadcast", want, &got)?;
|
||||
}
|
||||
if let Some(want) = &e.feedback {
|
||||
same("feedback", &Some(want.clone()), &feedback(false))?;
|
||||
}
|
||||
if let Some(want) = &e.error {
|
||||
same("error", &Some(want.clone()), &feedback(true))?;
|
||||
}
|
||||
if let Some(want) = &e.panel {
|
||||
let got: Vec<String> = game
|
||||
.panel()
|
||||
.map(|p| p.lines.iter().map(|l| l.text.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
same("panel", want, &got)?;
|
||||
}
|
||||
if let Some(want) = &e.blocks {
|
||||
for b in want {
|
||||
let got = game.world.get(&(b.x, b.y, b.z));
|
||||
let got = got.map_or("minecraft:air", String::as_str);
|
||||
if got != b.state {
|
||||
return Err(format!(
|
||||
"block at {} {} {}: expected {}, got {got}",
|
||||
b.x, b.y, b.z, b.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(want) = &e.corner {
|
||||
let got = game.panel().map(|p| corner_name(p.corner).to_string());
|
||||
same("corner", &Some(want.clone()), &got)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn corner_name(c: Corner) -> &'static str {
|
||||
match c {
|
||||
Corner::TopLeft => "top-left",
|
||||
Corner::TopRight => "top-right",
|
||||
Corner::BottomLeft => "bottom-left",
|
||||
Corner::BottomRight => "bottom-right",
|
||||
}
|
||||
}
|
||||
|
||||
fn same<T: PartialEq + std::fmt::Debug>(what: &str, want: &T, got: &T) -> Result<(), String> {
|
||||
if want == got {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"{what}\n expected: {want:?}\n got: {got:?}"
|
||||
))
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
//! docs/api/mods-reference.md, generated from the WIT contract and the manifest's capabilities, and a test that
|
||||
//! fails when it is out of date. Update it with:
|
||||
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference`.
|
||||
use horizon_mods::manifest::{APIS, Capability};
|
||||
use std::{fmt::Write, path::Path};
|
||||
use wit_parser::{Docs, Function, Handle, Resolve, Type, TypeDefKind, WorldItem};
|
||||
|
||||
const WIT: &str = "crates/horizon-mod-sdk/wit/horizon.wit";
|
||||
const DOC: &str = "docs/api/mods-reference.md";
|
||||
|
||||
fn root() -> &'static Path {
|
||||
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
|
||||
}
|
||||
|
||||
/// A doc comment on one line, for a table cell.
|
||||
fn cell(docs: &Docs) -> String {
|
||||
let text = docs.contents.as_deref().unwrap_or("").trim();
|
||||
text.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.replace('|', "\\|")
|
||||
}
|
||||
|
||||
/// A type as WIT spells it.
|
||||
fn ty(resolve: &Resolve, t: &Type) -> String {
|
||||
let id = match t {
|
||||
Type::Bool => return "bool".into(),
|
||||
Type::U8 => return "u8".into(),
|
||||
Type::U16 => return "u16".into(),
|
||||
Type::U32 => return "u32".into(),
|
||||
Type::U64 => return "u64".into(),
|
||||
Type::S8 => return "s8".into(),
|
||||
Type::S16 => return "s16".into(),
|
||||
Type::S32 => return "s32".into(),
|
||||
Type::S64 => return "s64".into(),
|
||||
Type::F32 => return "f32".into(),
|
||||
Type::F64 => return "f64".into(),
|
||||
Type::Char => return "char".into(),
|
||||
Type::String => return "string".into(),
|
||||
Type::ErrorContext => return "error-context".into(),
|
||||
Type::Id(id) => *id,
|
||||
};
|
||||
match &resolve.types[id].name {
|
||||
Some(name) => name.clone(),
|
||||
None => definition(resolve, &resolve.types[id].kind),
|
||||
}
|
||||
}
|
||||
|
||||
/// What an anonymous type, or the right-hand side of a `type` alias, spells.
|
||||
fn definition(resolve: &Resolve, kind: &TypeDefKind) -> String {
|
||||
let of = |t: &Option<Type>| t.as_ref().map_or("_".into(), |t| ty(resolve, t));
|
||||
match kind {
|
||||
TypeDefKind::Option(t) => format!("option<{}>", ty(resolve, t)),
|
||||
TypeDefKind::List(t) => format!("list<{}>", ty(resolve, t)),
|
||||
TypeDefKind::FixedLengthList(t, n) => format!("list<{}, {n}>", ty(resolve, t)),
|
||||
TypeDefKind::Result(r) => match (&r.ok, &r.err) {
|
||||
(None, None) => "result".into(),
|
||||
(ok, None) => format!("result<{}>", of(ok)),
|
||||
(ok, err) => format!("result<{}, {}>", of(ok), of(err)),
|
||||
},
|
||||
TypeDefKind::Tuple(t) => {
|
||||
let items: Vec<_> = t.types.iter().map(|t| ty(resolve, t)).collect();
|
||||
format!("tuple<{}>", items.join(", "))
|
||||
}
|
||||
TypeDefKind::Future(t) => t
|
||||
.as_ref()
|
||||
.map_or("future".into(), |t| format!("future<{}>", ty(resolve, t))),
|
||||
TypeDefKind::Stream(t) => t
|
||||
.as_ref()
|
||||
.map_or("stream".into(), |t| format!("stream<{}>", ty(resolve, t))),
|
||||
TypeDefKind::Handle(Handle::Own(t)) => ty(resolve, &Type::Id(*t)),
|
||||
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(resolve, &Type::Id(*t))),
|
||||
TypeDefKind::Type(t) => ty(resolve, t),
|
||||
other => panic!(
|
||||
"{WIT}: an anonymous {} the reference can't spell",
|
||||
other.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(resolve: &Resolve, f: &Function) -> String {
|
||||
let params: Vec<_> = f
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| format!("{}: {}", p.name, ty(resolve, &p.ty)))
|
||||
.collect();
|
||||
let result = f
|
||||
.result
|
||||
.as_ref()
|
||||
.map_or(String::new(), |t| format!(" -> {}", ty(resolve, t)));
|
||||
format!("func({}){result}", params.join(", "))
|
||||
}
|
||||
|
||||
/// Who may use an interface: the capability that grants it, or what it is.
|
||||
fn access(interface: &str, exported: bool) -> String {
|
||||
if exported {
|
||||
return "exported by the mod: the game calls it".into();
|
||||
}
|
||||
if interface == "log" {
|
||||
return "always granted".into();
|
||||
}
|
||||
match Capability::ALL.iter().find(|c| c.interface() == interface) {
|
||||
Some(c) => format!("capability `{c}`"),
|
||||
None => "not granted by any capability".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown() -> String {
|
||||
let mut resolve = Resolve::new();
|
||||
let package = resolve
|
||||
.push_file(root().join(WIT))
|
||||
.expect("the WIT contract parses");
|
||||
let package = &resolve.packages[package];
|
||||
let name = &package.name;
|
||||
let mut out = String::new();
|
||||
let w = &mut out;
|
||||
writeln!(w, "<!-- Generated from {WIT} and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->").unwrap();
|
||||
writeln!(w, "# Mod API reference\n").unwrap();
|
||||
writeln!(
|
||||
w,
|
||||
"The contract between Horizon and its mods: the WIT package `{name}`, every interface, type, function and hook in it, \
|
||||
and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: \
|
||||
[stability.md](stability.md). Manifest `api` lines this game serves: {}.\n",
|
||||
APIS.map(|a| format!("`{a}`")).join(", ")
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
writeln!(w, "## Capabilities\n").unwrap();
|
||||
writeln!(
|
||||
w,
|
||||
"| In `capabilities` | Grants | The player reads \"May …\" |\n| --- | --- | --- |"
|
||||
)
|
||||
.unwrap();
|
||||
for c in Capability::ALL {
|
||||
let grants = if package.interfaces.contains_key(c.interface()) {
|
||||
format!("[`{0}`](#{0})", c.interface())
|
||||
} else {
|
||||
"data files (`assets/`, `data/`), no interface".into()
|
||||
};
|
||||
writeln!(w, "| `{c}` | {grants} | {} |", c.describe()).unwrap();
|
||||
}
|
||||
|
||||
let mut exported = Vec::new();
|
||||
for (world_name, &world) in &package.worlds {
|
||||
let world = &resolve.worlds[world];
|
||||
let prefix = format!("{}:{}/", name.namespace, name.name);
|
||||
let names = |keys: Vec<&wit_parser::WorldKey>| {
|
||||
keys.into_iter()
|
||||
.map(|key| {
|
||||
let full = resolve.name_world_key(key);
|
||||
let short = full.trim_start_matches(&prefix);
|
||||
format!("`{}`", short.split('@').next().unwrap_or(short))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
writeln!(w, "\n## World `{world_name}`\n").unwrap();
|
||||
if world.docs.contents.is_some() {
|
||||
writeln!(w, "{}\n", cell(&world.docs)).unwrap();
|
||||
}
|
||||
writeln!(
|
||||
w,
|
||||
"- Imports (what the game offers): {}",
|
||||
names(world.imports.keys().collect())
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
w,
|
||||
"- Exports (what the mod provides): {}",
|
||||
names(world.exports.keys().collect())
|
||||
)
|
||||
.unwrap();
|
||||
for item in world.exports.values() {
|
||||
if let WorldItem::Interface { id, .. } = item {
|
||||
exported.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (interface_name, &id) in &package.interfaces {
|
||||
let interface = &resolve.interfaces[id];
|
||||
writeln!(w, "\n## `{interface_name}`\n").unwrap();
|
||||
writeln!(
|
||||
w,
|
||||
"Access: {}.\n",
|
||||
access(interface_name, exported.contains(&id))
|
||||
)
|
||||
.unwrap();
|
||||
if interface.docs.contents.is_some() {
|
||||
writeln!(w, "{}\n", cell(&interface.docs)).unwrap();
|
||||
}
|
||||
for (type_name, &type_id) in &interface.types {
|
||||
let def = &resolve.types[type_id];
|
||||
writeln!(w, "### {} `{type_name}`\n", def.kind.as_str()).unwrap();
|
||||
if def.docs.contents.is_some() {
|
||||
writeln!(w, "{}\n", cell(&def.docs)).unwrap();
|
||||
}
|
||||
match &def.kind {
|
||||
TypeDefKind::Record(r) => {
|
||||
writeln!(w, "| Field | Type | |\n| --- | --- | --- |").unwrap();
|
||||
for f in &r.fields {
|
||||
writeln!(
|
||||
w,
|
||||
"| `{}` | `{}` | {} |",
|
||||
f.name,
|
||||
ty(&resolve, &f.ty),
|
||||
cell(&f.docs)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
TypeDefKind::Enum(e) => {
|
||||
writeln!(w, "| Case | |\n| --- | --- |").unwrap();
|
||||
for c in &e.cases {
|
||||
writeln!(w, "| `{}` | {} |", c.name, cell(&c.docs)).unwrap();
|
||||
}
|
||||
}
|
||||
TypeDefKind::Variant(v) => {
|
||||
writeln!(w, "| Case | Payload | |\n| --- | --- | --- |").unwrap();
|
||||
for c in &v.cases {
|
||||
let payload =
|
||||
c.ty.as_ref()
|
||||
.map_or(String::new(), |t| format!("`{}`", ty(&resolve, t)));
|
||||
writeln!(w, "| `{}` | {payload} | {} |", c.name, cell(&c.docs)).unwrap();
|
||||
}
|
||||
}
|
||||
TypeDefKind::Flags(f) => {
|
||||
writeln!(w, "| Flag | |\n| --- | --- |").unwrap();
|
||||
for flag in &f.flags {
|
||||
writeln!(w, "| `{}` | {} |", flag.name, cell(&flag.docs)).unwrap();
|
||||
}
|
||||
}
|
||||
TypeDefKind::Resource => {
|
||||
writeln!(w, "A resource: the game owns it, the mod holds a handle.").unwrap()
|
||||
}
|
||||
kind => writeln!(w, "`type {type_name} = {}`", definition(&resolve, kind)).unwrap(),
|
||||
}
|
||||
writeln!(w).unwrap();
|
||||
}
|
||||
if !interface.functions.is_empty() {
|
||||
writeln!(
|
||||
w,
|
||||
"| Function | Signature | What it does |\n| --- | --- | --- |"
|
||||
)
|
||||
.unwrap();
|
||||
for (fn_name, f) in &interface.functions {
|
||||
writeln!(
|
||||
w,
|
||||
"| `{fn_name}` | `{}` | {} |",
|
||||
signature(&resolve, f),
|
||||
cell(&f.docs)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mod_api_reference_is_up_to_date() {
|
||||
let path = root().join(DOC);
|
||||
let want = markdown();
|
||||
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
|
||||
std::fs::write(&path, &want).unwrap();
|
||||
}
|
||||
let have = std::fs::read_to_string(&path)
|
||||
.unwrap_or_default()
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_function_is_documented() {
|
||||
let mut resolve = Resolve::new();
|
||||
resolve.push_file(root().join(WIT)).unwrap();
|
||||
for (_, interface) in resolve.interfaces.iter() {
|
||||
for (name, f) in &interface.functions {
|
||||
assert!(
|
||||
f.docs.contents.is_some(),
|
||||
"{WIT}: `{name}` needs a doc comment: the reference shows it to mod authors"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_capability_with_an_interface_is_in_the_contract() {
|
||||
let mut resolve = Resolve::new();
|
||||
let package = resolve.push_file(root().join(WIT)).unwrap();
|
||||
let interfaces = &resolve.packages[package].interfaces;
|
||||
for c in Capability::ALL {
|
||||
if c != Capability::Registries {
|
||||
assert!(
|
||||
interfaces.contains_key(c.interface()),
|
||||
"capability {c} grants `{}`, missing from {WIT}",
|
||||
c.interface()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every function the game offers (an interface some world imports and none
|
||||
/// exports) has an API level, and the levels list nothing else.
|
||||
#[test]
|
||||
fn every_function_has_an_api_level() {
|
||||
let mut resolve = Resolve::default();
|
||||
let package = resolve.push_file(root().join(WIT)).unwrap();
|
||||
let package = &resolve.packages[package];
|
||||
let exported: Vec<_> = package
|
||||
.worlds
|
||||
.values()
|
||||
.flat_map(|&w| resolve.worlds[w].exports.values())
|
||||
.filter_map(|item| match item {
|
||||
WorldItem::Interface { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut offered = Vec::new();
|
||||
for (name, &id) in &package.interfaces {
|
||||
if !exported.contains(&id) {
|
||||
for function in resolve.interfaces[id].functions.keys() {
|
||||
offered.push(format!("{name}.{function}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in &offered {
|
||||
assert!(
|
||||
horizon_mods::api_level::level_of(f).is_some(),
|
||||
"{f} has no level in crates/horizon-mods/src/api_level.rs: add it at the next level"
|
||||
);
|
||||
}
|
||||
for (f, _) in horizon_mods::api_level::FUNCTIONS {
|
||||
assert!(offered.iter().any(|o| o == f), "{f} isn't in the WIT");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-mods-test-guest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "A mod that misbehaves on command, for horizon-mods' sandbox tests"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,63 +0,0 @@
|
||||
//! A mod for the runtime's tests: `/spin` loops forever, `/panic` panics,
|
||||
//! `/grow` asks for a gigabyte, `/hello` chats, `/save` and `/load` exercise
|
||||
//! capability `storage` on a fixed key, `/save_key <key> <text>` exercises
|
||||
//! it on an arbitrary key (for the quota and key-count limits); `tick` and
|
||||
//! `frame` do nothing, to measure the cost of a call.
|
||||
use horizon_mod_sdk::{Mod, Registration, chat, storage};
|
||||
|
||||
struct Guest {
|
||||
ticks: u64,
|
||||
}
|
||||
|
||||
impl Mod for Guest {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = ["spin", "panic", "grow", "hello", "save", "load", "save_key"]
|
||||
.into_iter()
|
||||
.fold(Registration::new(), Registration::command)
|
||||
.on_tick()
|
||||
.on_frame();
|
||||
Ok((Guest { ticks: 0 }, hooks))
|
||||
}
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
self.ticks += 1;
|
||||
}
|
||||
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
"spin" => {
|
||||
let mut n = 0u64;
|
||||
while std::hint::black_box(n) != u64::MAX {
|
||||
n = n.wrapping_add(2);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
"panic" => panic!("asked to"),
|
||||
"grow" => {
|
||||
let big = std::hint::black_box(vec![1u8; 1 << 30]);
|
||||
Ok(Some(format!("{}", big.len())))
|
||||
}
|
||||
"save" => {
|
||||
storage::set("note", args.as_bytes())?;
|
||||
Ok(Some("saved".into()))
|
||||
}
|
||||
"load" => {
|
||||
let text =
|
||||
storage::get("note").map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
|
||||
chat::show(text.as_deref().unwrap_or("(empty)"));
|
||||
Ok(None)
|
||||
}
|
||||
"save_key" => {
|
||||
let (key, text) = args
|
||||
.split_once(' ')
|
||||
.ok_or("Usage: /save_key <key> <text>")?;
|
||||
storage::set(key, text.as_bytes())?;
|
||||
Ok(Some("saved".into()))
|
||||
}
|
||||
_ => {
|
||||
chat::show(&format!("hello {args} after {} ticks", self.ticks));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Guest);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "A mod that shows widgets, for horizon-mods' sandbox tests"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,512 +0,0 @@
|
||||
//! A mod for the runtime's `ui` tests: `/ui_screen` opens a screen with one
|
||||
//! of each widget, `/ui_settings` sets a settings page, `/ui_close` closes
|
||||
//! the screen; the `ui-events` calls answer in chat so the host sees them.
|
||||
//! `/render_hud`, `/render_clear` and `/render_shake` test `render`;
|
||||
//! `/input_claim`, `/input_poll`, `/input_held` and `/input_wheel` test
|
||||
//! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`;
|
||||
//! `/corner` and `/readings` the calls the official extras mods use;
|
||||
//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs
|
||||
//! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes`
|
||||
//! counts what `recipes` reads; `/recipes ID` describes that recipe.
|
||||
//! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N
|
||||
//! items, `/browser` alone empties it) and registers the keys `recipe` (R)
|
||||
//! and `uses` (U); `/browser_poll` says what the player did on it;
|
||||
//! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes`
|
||||
//! alone: the items again). `/recipe_place ID [all]` places that recipe
|
||||
//! with `recipes.place`; `/recipe_give ITEM [COUNT]` gives with
|
||||
//! `recipes.give`.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, browser, chat, command, game,
|
||||
input::{self, Event},
|
||||
recipes,
|
||||
render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape},
|
||||
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
|
||||
};
|
||||
|
||||
/// One of each shape, the text too long by 44 characters.
|
||||
fn shapes() -> Vec<Shape> {
|
||||
let (w, h) = render::gui_size();
|
||||
let p = |x, y| Point { x, y };
|
||||
vec![
|
||||
Shape::Fill(FillShape {
|
||||
rect: Rect {
|
||||
x: 4.0,
|
||||
y: 4.0,
|
||||
w: 60.0,
|
||||
h: 20.0,
|
||||
},
|
||||
color: 0x80FF0000,
|
||||
}),
|
||||
Shape::Quad(QuadShape {
|
||||
corners: (
|
||||
p(w / 2.0, 10.0),
|
||||
p(w / 2.0 + 20.0, 30.0),
|
||||
p(w / 2.0, 50.0),
|
||||
p(w / 2.0 - 20.0, 30.0),
|
||||
),
|
||||
color: 0xC000FF00,
|
||||
}),
|
||||
Shape::Sprite(SpriteShape {
|
||||
sprite: "hud/heart/full".into(),
|
||||
rect: Rect {
|
||||
x: 70.0,
|
||||
y: 4.0,
|
||||
w: 18.0,
|
||||
h: 18.0,
|
||||
},
|
||||
color: 0xFFFFFFFF,
|
||||
}),
|
||||
Shape::Item(ItemShape {
|
||||
item: "minecraft:diamond".into(),
|
||||
at: p(92.0, 4.0),
|
||||
scale: 2.0,
|
||||
}),
|
||||
Shape::Text(TextShape {
|
||||
text: format!("gui {w}x{h} {}", "x".repeat(300)),
|
||||
at: p(w / 2.0, h / 2.0 - 30.0),
|
||||
color: 0xFFFF55,
|
||||
centered: true,
|
||||
shadow: true,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
struct Guest {
|
||||
/// `/recipe_place ID [all] later`: placed on the first tick a crafting
|
||||
/// screen is open.
|
||||
pending: Option<(i32, bool, String)>,
|
||||
}
|
||||
|
||||
fn widgets() -> Vec<Widget> {
|
||||
vec![
|
||||
Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "Pick a row".into(),
|
||||
color: 0,
|
||||
}),
|
||||
Widget::Item(ItemWidget {
|
||||
id: 2,
|
||||
item: "minecraft:diamond".into(),
|
||||
name: String::new(),
|
||||
}),
|
||||
Widget::List(ListWidget {
|
||||
id: 7,
|
||||
rows: vec!["first".into(), "second".into(), "third".into()],
|
||||
selected: Some(1),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
impl Mod for Guest {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = [
|
||||
"ui_screen",
|
||||
"ui_settings",
|
||||
"ui_close",
|
||||
"render_hud",
|
||||
"render_clear",
|
||||
"render_shake",
|
||||
"input_claim",
|
||||
"input_poll",
|
||||
"input_held",
|
||||
"input_wheel",
|
||||
"zoom",
|
||||
"minimap",
|
||||
"far_terrain",
|
||||
"raycast",
|
||||
"quiet",
|
||||
"quiet_many",
|
||||
"recipes",
|
||||
"browser",
|
||||
"browser_poll",
|
||||
"browser_recipes",
|
||||
"recipe_place",
|
||||
"recipe_give",
|
||||
"corner",
|
||||
"readings",
|
||||
]
|
||||
.into_iter()
|
||||
.fold(Registration::new(), Registration::command)
|
||||
.on_tick();
|
||||
Ok((Guest { pending: None }, hooks))
|
||||
}
|
||||
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
"ui_screen" => ui::open_screen(&Screen {
|
||||
title: "Guest's screen".into(),
|
||||
widgets: widgets(),
|
||||
}),
|
||||
"ui_settings" => ui::set_settings(&widgets()),
|
||||
"ui_close" => ui::close_screen(),
|
||||
"render_hud" => render::set_hud(&shapes()),
|
||||
"render_clear" => render::set_hud(&[]),
|
||||
// An FPS-counter-like box: as wide as its text plus 4.
|
||||
"corner" => {
|
||||
let text = "60 fps";
|
||||
let w = render::text_width(text) + 4.0;
|
||||
render::set_corner(
|
||||
render::Corner::TopRight,
|
||||
w,
|
||||
12.0,
|
||||
&[
|
||||
Shape::Fill(FillShape {
|
||||
rect: Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w,
|
||||
h: 12.0,
|
||||
},
|
||||
color: 0x78000000,
|
||||
}),
|
||||
Shape::Text(TextShape {
|
||||
text: text.into(),
|
||||
at: Point { x: 2.0, y: 2.0 },
|
||||
color: 0xFFFFFF,
|
||||
centered: false,
|
||||
shadow: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
ui::set_settings(&[Widget::Button(ui::ButtonWidget {
|
||||
id: 3,
|
||||
label: "Corner: Top Right".into(),
|
||||
})]);
|
||||
}
|
||||
"readings" => {
|
||||
let f = render::frame_stats();
|
||||
let fwd = input::mapping("key.forward")
|
||||
.map_or("none".into(), |m| format!("{} {}", m.label, m.down));
|
||||
let (l, r) = input::clicks_per_second();
|
||||
let none = input::mapping("key.inventory").is_none();
|
||||
chat::show(&format!(
|
||||
"{} {} {}; forward {fwd}; cps {l} {r}; other {none}",
|
||||
f.fps, f.min, f.low
|
||||
));
|
||||
}
|
||||
"input_claim" => {
|
||||
input::claim_items(&["minecraft:stick".into()]);
|
||||
input::register_key("wheel", "Open the wheel", "key.keyboard.r")?;
|
||||
// Not a key: refused.
|
||||
let bad = input::register_key("bad", "Bad", "R").unwrap_err();
|
||||
chat::show(&format!("claimed; {bad}"));
|
||||
}
|
||||
"input_poll" => {
|
||||
for event in input::poll() {
|
||||
chat::show(&match event {
|
||||
Event::UseStart(u) => format!("use-start {} {}", u.item.id, u.ticks),
|
||||
Event::UseStop(u) => format!("use-stop {} {}", u.item.id, u.ticks),
|
||||
Event::KeyDown(k) => format!("key-down {k}"),
|
||||
Event::KeyUp(k) => format!("key-up {k}"),
|
||||
Event::Wheel(steps) => format!("wheel {steps}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
"input_held" => {
|
||||
let held = input::held().map_or("nothing".into(), |h| {
|
||||
format!("{} x{} in {}", h.id, h.count, h.slot)
|
||||
});
|
||||
chat::show(&format!("holding {held}"));
|
||||
}
|
||||
"input_wheel" => input::claim_wheel(args != "off"),
|
||||
"zoom" => {
|
||||
let mut words = args.split(' ');
|
||||
let mut number = || {
|
||||
words
|
||||
.next()
|
||||
.and_then(|w| w.parse().ok())
|
||||
.unwrap_or(f32::NAN)
|
||||
};
|
||||
let factor = number();
|
||||
let smooth = number() == 1.0;
|
||||
let turn = number();
|
||||
render::set_zoom(render::Zoom {
|
||||
factor,
|
||||
smooth,
|
||||
turn,
|
||||
});
|
||||
}
|
||||
// `minimap off`, or `minimap SIZE BLOCKS-PER-PIXEL ROTATE ROUND`.
|
||||
"minimap" => {
|
||||
let words: Vec<&str> = args.split(' ').collect();
|
||||
let number = |i: usize| {
|
||||
words
|
||||
.get(i)
|
||||
.and_then(|w| w.parse().ok())
|
||||
.unwrap_or(f32::NAN)
|
||||
};
|
||||
let map = (args != "off").then(|| render::Minimap {
|
||||
corner: render::Corner::BottomLeft,
|
||||
size: number(0),
|
||||
blocks_per_pixel: number(1),
|
||||
rotate: words.get(2) == Some(&"1"),
|
||||
round: words.get(3) == Some(&"1"),
|
||||
});
|
||||
render::set_minimap(map);
|
||||
}
|
||||
// `far_terrain off`, or `far_terrain DISTANCE REDUCED`.
|
||||
"far_terrain" => {
|
||||
let words: Vec<&str> = args.split(' ').collect();
|
||||
let far = (args != "off").then(|| render::FarTerrain {
|
||||
distance: words.first().and_then(|w| w.parse().ok()).unwrap_or(0),
|
||||
reduced: words.get(1) == Some(&"1"),
|
||||
});
|
||||
render::set_far_terrain(far);
|
||||
}
|
||||
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
|
||||
"quiet_many" => {
|
||||
let n: usize = args.parse().unwrap_or(0);
|
||||
command::run_quiet(
|
||||
&(0..n)
|
||||
.map(|i| format!("setblock {i} 300 0 stone"))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
"raycast" => {
|
||||
let distance = args.parse().unwrap_or(f32::NAN);
|
||||
chat::show(&match game::raycast(distance) {
|
||||
Some(h) => format!(
|
||||
"hit {} at {} {} {} ({:.1} {:.1} {:.1})",
|
||||
h.block, h.block_x, h.block_y, h.block_z, h.x, h.y, h.z
|
||||
),
|
||||
None => "no hit".into(),
|
||||
});
|
||||
}
|
||||
"recipes" => {
|
||||
let items = recipes::items();
|
||||
let all = recipes::all();
|
||||
let known = recipes::known();
|
||||
let name = |s: &recipes::Stack| {
|
||||
let id = items.get(s.item as usize).map_or("?", |i| i.id.as_str());
|
||||
format!("{} {id}{}", s.count, s.components)
|
||||
};
|
||||
chat::show(&match all.iter().find(|r| r.id == args) {
|
||||
Some(r) => format!(
|
||||
"{} {:?} {}x{} {}t slots {}, the first {} -> {} known {}",
|
||||
r.id,
|
||||
r.kind,
|
||||
r.width,
|
||||
r.height,
|
||||
r.cooking_time,
|
||||
r.slots.len(),
|
||||
r.slots
|
||||
.first()
|
||||
.and_then(|s| s.first())
|
||||
.map_or("-".into(), name),
|
||||
r.results.first().map_or("-".into(), name),
|
||||
known.contains(&r.book_id)
|
||||
),
|
||||
None => format!(
|
||||
"{} items, {} recipes, {} known, {} brewing",
|
||||
items.len(),
|
||||
all.len(),
|
||||
known.len(),
|
||||
all.iter()
|
||||
.filter(|r| r.kind == recipes::Kind::Brewing)
|
||||
.count()
|
||||
),
|
||||
});
|
||||
}
|
||||
"browser" => {
|
||||
let stack = |item: &str| {
|
||||
let (item, components) = match item.find('[') {
|
||||
Some(at) => (&item[..at], &item[at..]),
|
||||
None => (item, ""),
|
||||
};
|
||||
browser::Stack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: components.into(),
|
||||
}
|
||||
};
|
||||
let items: Vec<browser::Stack> = match args.parse::<usize>() {
|
||||
Ok(n) => recipes::items()
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(n)
|
||||
.map(|i| stack(&i.id))
|
||||
.collect(),
|
||||
Err(_) => args.split_whitespace().map(stack).collect(),
|
||||
};
|
||||
browser::set_items(&items);
|
||||
input::register_key("recipe", "Show recipes", "key.keyboard.r")?;
|
||||
input::register_key("uses", "Show uses", "key.keyboard.u")?;
|
||||
chat::show(&format!("panel of {}", items.len()));
|
||||
}
|
||||
"browser_recipes" => {
|
||||
let items = recipes::items();
|
||||
let id = |i: u32| {
|
||||
items
|
||||
.get(i as usize)
|
||||
.map_or(String::new(), |x| x.id.clone())
|
||||
};
|
||||
let stack = |s: &recipes::Stack| browser::Stack {
|
||||
item: id(s.item),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
};
|
||||
let views: Vec<browser::RecipeView> = recipes::all()
|
||||
.iter()
|
||||
.filter(|r| !args.is_empty() && r.results.iter().any(|s| id(s.item) == args))
|
||||
.map(|r| {
|
||||
use recipes::Kind;
|
||||
let crafting =
|
||||
matches!(r.kind, Kind::CraftingShaped | Kind::CraftingShapeless);
|
||||
let shapeless = (r.slots.len() as u32).clamp(1, 3);
|
||||
browser::RecipeView {
|
||||
layout: match r.kind {
|
||||
Kind::CraftingShaped | Kind::CraftingShapeless => {
|
||||
browser::Layout::Grid
|
||||
}
|
||||
Kind::Stonecutting => browser::Layout::Single,
|
||||
Kind::Smithing => browser::Layout::Smithing,
|
||||
Kind::Brewing => browser::Layout::Brewing,
|
||||
_ => browser::Layout::Cooking,
|
||||
},
|
||||
width: if r.width == 0 { shapeless } else { r.width },
|
||||
height: if r.height == 0 {
|
||||
(r.slots.len() as u32).div_ceil(3).max(1)
|
||||
} else {
|
||||
r.height
|
||||
},
|
||||
slots: r
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.iter().map(stack).collect())
|
||||
.collect(),
|
||||
results: r.results.iter().map(stack).collect(),
|
||||
note: if r.cooking_time > 0 {
|
||||
format!("{} ticks", r.cooking_time)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
action: crafting.then_some(r.kind == Kind::CraftingShaped),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A tab per layout shown, the first selected.
|
||||
let mut tabs: Vec<browser::Stack> = Vec::new();
|
||||
for v in &views {
|
||||
let icon = match v.layout {
|
||||
browser::Layout::Grid => "minecraft:crafting_table",
|
||||
browser::Layout::Cooking => "minecraft:furnace",
|
||||
browser::Layout::Single => "minecraft:stonecutter",
|
||||
browser::Layout::Smithing => "minecraft:smithing_table",
|
||||
browser::Layout::Brewing => "minecraft:brewing_stand",
|
||||
};
|
||||
if !tabs.iter().any(|t| t.item == icon) {
|
||||
tabs.push(browser::Stack {
|
||||
item: icon.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
browser::set_recipes(&format!("Recipes for {args}"), &tabs, 0, &views);
|
||||
chat::show(&format!("recipes shown: {}", views.len()));
|
||||
}
|
||||
"recipe_place" => {
|
||||
let mut words = args.split_whitespace();
|
||||
let id = words.next().unwrap_or_default();
|
||||
let rest: Vec<&str> = words.collect();
|
||||
let all = rest.contains(&"all");
|
||||
let book = recipes::all()
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| r.book_id);
|
||||
if let Some(b) = book.filter(|_| rest.contains(&"later")) {
|
||||
self.pending = Some((b, all, id.to_owned()));
|
||||
chat::show(&format!("place {id}: waiting"));
|
||||
return Ok(None);
|
||||
}
|
||||
chat::show(&match book.map(|b| recipes::place(b, all)) {
|
||||
Some(Ok(())) => format!("place {id}: ok"),
|
||||
Some(Err(e)) => format!("place {id}: {e}"),
|
||||
None => format!("place {id}: no such recipe"),
|
||||
});
|
||||
}
|
||||
"recipe_give" => {
|
||||
let mut words = args.split_whitespace();
|
||||
let id = words.next().unwrap_or_default();
|
||||
let count = words.next().and_then(|c| c.parse().ok()).unwrap_or(1);
|
||||
let item = recipes::items().iter().position(|i| i.id == id);
|
||||
let result = item.map(|i| {
|
||||
recipes::give(&recipes::Stack {
|
||||
item: i as u32,
|
||||
count,
|
||||
components: String::new(),
|
||||
})
|
||||
});
|
||||
chat::show(&match result {
|
||||
Some(Ok(())) => format!("give {id}: ok"),
|
||||
Some(Err(e)) => format!("give {id}: {e}"),
|
||||
None => format!("give {id}: no such item"),
|
||||
});
|
||||
}
|
||||
"browser_poll" => {
|
||||
let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components);
|
||||
// A hook shows at most 8 chat lines: the count, then the first 7.
|
||||
let events = browser::poll();
|
||||
chat::show(&format!("{} events", events.len()));
|
||||
for event in events.into_iter().take(7) {
|
||||
chat::show(&match event {
|
||||
browser::Event::Clicked(c) => {
|
||||
format!(
|
||||
"clicked {} {}",
|
||||
c.index,
|
||||
if c.right { "right" } else { "left" }
|
||||
)
|
||||
}
|
||||
browser::Event::Search(text) => format!("search {text:?}"),
|
||||
browser::Event::Key(k) => format!("key {} on {}", k.key, stack(&k.stack)),
|
||||
browser::Event::SlotClicked(c) => {
|
||||
format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack))
|
||||
}
|
||||
browser::Event::Action(i) => format!("action {i}"),
|
||||
browser::Event::Page(first) => format!("page {first}"),
|
||||
browser::Event::Back => "back".into(),
|
||||
browser::Event::Tab(i) => format!("tab {i}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Clamped to (1, 10); the two after it shake nothing.
|
||||
"render_shake" => {
|
||||
render::shake(2.0, 99.0);
|
||||
render::shake(0.0, 1.0);
|
||||
render::shake(f32::NAN, 1.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
if let Some((book, all, id)) = self.pending.take() {
|
||||
match recipes::place(book, all) {
|
||||
Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)),
|
||||
Ok(()) => chat::show(&format!("place {id}: ok")),
|
||||
Err(e) => chat::show(&format!("place {id}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, row: u32) {
|
||||
chat::show(&format!("selected {id}:{row}"));
|
||||
}
|
||||
fn ui_closed(&mut self) {
|
||||
chat::show("closed");
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
ui::set_settings(&[
|
||||
Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "fresh".into(),
|
||||
color: 0x33CC33,
|
||||
}),
|
||||
Widget::Button(ui::ButtonWidget {
|
||||
id: 3,
|
||||
label: "Corner: Top Right".into(),
|
||||
}),
|
||||
]);
|
||||
chat::show("settings opened");
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Guest);
|
||||
+1
-1
@@ -14,7 +14,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it.
|
||||
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
|
||||
- [Shader packs](shaders.md): Horizon's own WGSL shader packs, how to make and check one (`horizon-shader`).
|
||||
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods.
|
||||
- [Mods](mods.md): content mods (data) and native mods (Rust), how to make, install and publish them. [Native mods](native-mods.md): how the launcher builds them.
|
||||
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
|
||||
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
|
||||
- [Versioning](versioning.md): how releases are numbered from 0.2 (`v0.2.x`, `[email protected]`), how installs from before 0.2 get there, and the steps for the day 0.2 ships.
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ section has one page per surface, plus the rules every surface follows:
|
||||
|
||||
| Surface | For | API today | Stability | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Mods](mods.md) | mod authors | WIT `horizon:[email protected]`, manifest `api` 0.1 (code) and 0.2 (content) | preview | [mods-reference.md](mods-reference.md) (generated) |
|
||||
| [Mods](../mods.md) | mod authors | content mods: manifest `api` 0.2; native mods: a horizon-mixins bundle ([native-mods.md](../native-mods.md)) | preview (content), experimental (native) | [mods.md](../mods.md) |
|
||||
| [Shaders](shaders.md) | shader authors | none yet: packs were removed on 2026-10-24 | planned | none |
|
||||
| [Agent control](agent-control.md) | agents, scripts, tests | `horizon-ctl`, its MCP server, the game's control socket | preview | [horizon-ctl-commands.md](../horizon-ctl-commands.md) (generated) |
|
||||
| [Servers](server.md) | hosts, panels, bots | `horizon-server` commands, `server.toml`, control API `/api/v1` | stable | [server-reference.md](server-reference.md) (generated) |
|
||||
@@ -18,13 +18,14 @@ section has one page per surface, plus the rules every surface follows:
|
||||
|
||||
- **From the people who use them.** Each API starts from what a mod author, a pack author, a server host or an agent
|
||||
needs to do, written down as tasks ("a host backs up the world from a script"), and from how Horizon is built:
|
||||
Rust, wgpu, WebAssembly components and a server thread that runs every world. A function exists because a task
|
||||
Rust, wgpu and a server thread that runs every world. A function exists because a task
|
||||
needs it.
|
||||
- **Our own design.** Horizon's APIs are not modelled on any other project's. Contributors may know other mod
|
||||
loaders, renderers and server platforms exist, but their source and API references are never read, copied or
|
||||
paraphrased here, and our names, shapes and docs are not taken from theirs. Vanilla Minecraft 26.3 is the reference for game
|
||||
concepts: ids, commands, `server.properties`, data packs and resource packs.
|
||||
- **Safe by default.** Mods run in a sandbox and can only use the capabilities their manifest declares; the control
|
||||
- **Safe by default.** A content mod is data and runs no code; a native mod runs with the player's rights, so it is
|
||||
off by default, behind a warning and reviewed before the registry lists it; the control
|
||||
sockets listen on this computer only, behind a token; nothing here is on by default where it would change vanilla's
|
||||
look or behaviour.
|
||||
- **Free when unused.** An API nobody uses costs nothing per tick or per frame: no engine, no thread, no allocation.
|
||||
@@ -37,7 +38,6 @@ A reference page is written by code from the source it describes, and a test fai
|
||||
|
||||
| Page | Generated from | Update it with |
|
||||
| --- | --- | --- |
|
||||
| [mods-reference.md](mods-reference.md) | `crates/horizon-mod-sdk/wit/horizon.wit`, the manifest's capabilities | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference` |
|
||||
| [server-reference.md](server-reference.md) | `horizon-server`'s help, `config::TEMPLATE`, `api::ROUTES` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference` |
|
||||
| [horizon-ctl-commands.md](../horizon-ctl-commands.md) | the command table in `crates/horizon-client/src/bin/horizon-ctl/commands.rs` | `horizon-ctl help --markdown > docs/horizon-ctl-commands.md` |
|
||||
| [launcher-commands.md](../launcher-commands.md) | `launcher/src/cli.rs` | `HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference` |
|
||||
|
||||
@@ -10,7 +10,8 @@ and worlds. Each entry names its version field, its documentation and its level
|
||||
| Benchmark report | the benchmark's output, the local log, uploads | `"format": "horizon-benchmark-report"`, `"version": 1` | stable | [benchmark-report.md](../benchmark-report.md) |
|
||||
| Telemetry batch | `POST /v1/batch` from the launcher | `"schema": 1` | stable | [telemetry.md](../telemetry.md) |
|
||||
| Telemetry consent | `telemetry/consent.json` in the game folder | `"version": 2` | stable | [telemetry.md](../telemetry.md) |
|
||||
| Mod package | `.hmod`: a zip with `horizon-mod.toml`, `mod.wasm`, `assets/`, `data/` | the manifest's `api` | preview | [mods.md](../mods.md#the-manifest), [mods-reference.md](mods-reference.md) |
|
||||
| Content mod package | `.hmod`: a zip with `horizon-mod.toml`, `assets/`, `data/` | the manifest's `api` | preview | [mods.md](../mods.md#the-manifest), [mod-index.md](mod-index.md) |
|
||||
| Native mod package | `.hnmod`: a zip with `horizon-native.json` and the bundle's `.rs` and `.toml` files | `schema` | experimental | [native-mods.md](../native-mods.md) |
|
||||
| Mods turned on | `horizon-mods.txt` | none | preview | [mods.md](../mods.md#playing-with-mods) |
|
||||
| Horizon settings | `horizon-graphics.txt`, `horizon-extras.txt` (`key:value`, as `options.txt`) | none: unknown keys fall back to defaults | preview | [graphics.md](../graphics.md), [extras.md](../extras.md#options-storage) |
|
||||
| Server settings | `server.toml` | its keys | stable | [server-reference.md](server-reference.md) |
|
||||
|
||||
+14
-12
@@ -1,7 +1,8 @@
|
||||
# The registry index and `.hpack`
|
||||
|
||||
Horizon's registry lists what is made for Horizon and reviewed: mods (`.hmod`), modpacks (`.hpack`), resource packs
|
||||
and Horizon shader packs (`.hshader`, [shaders.md](../shaders.md)). Submissions are pull requests in
|
||||
Horizon's registry lists what is made for Horizon and reviewed: content mods (`.hmod`), native mods (`.hnmod`,
|
||||
[native-mods.md](../native-mods.md)), modpacks (`.hpack`), resource packs and Horizon shader packs (`.hshader`,
|
||||
[shaders.md](../shaders.md)). Submissions are pull requests in
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods); merging one publishes it. The launcher and the
|
||||
website read one file, `index.json`, which that repository's CI builds. The Rust types and every check below are
|
||||
`horizon_mods::index` and `horizon_mods::modpack` (feature `index`); the sample is
|
||||
@@ -34,7 +35,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
| --- | --- |
|
||||
| `schema` | 1. A reader refuses a higher number with "update Horizon" rather than misread it. |
|
||||
| `projects[].id` | `[a-z0-9_]{1,64}`, unique; a mod's is its manifest's `id`. |
|
||||
| `kind` | `mod`, `modpack`, `resourcepack`, `shader` (Horizon's own shader packs, not Iris or OptiFine packs). Other kinds (data packs, worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
|
||||
| `kind` | `mod` (content), `native-mod`, `modpack`, `resourcepack`, `shader` (Horizon's own shader packs, not Iris or OptiFine packs). Other kinds (data packs, worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
|
||||
| `summary`, `description` | One line for lists; Markdown for the project's page. |
|
||||
| `owners` | GitHub logins allowed to publish updates. |
|
||||
| `license`, `source` | An SPDX expression and an `https://` address of the source code: both required. |
|
||||
@@ -42,14 +43,16 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
| `key` | The author's [minisign](https://jedisct1.github.io/minisign/) public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. |
|
||||
| `versions` | Newest first, at least one. |
|
||||
| `downloads` | How many times its files were downloaded, every version together: GitHub's count of each release asset, summed by the publish job, which runs every hour. Absent until a file is downloaded. Read-only: never in an entry. |
|
||||
| `api` | A mod's API line (its manifest's `api`); a modpack's manifest format (`1`). |
|
||||
| `api` | A mod's API line (its manifest's `api`); a native mod's pinned revisions (`native-1/<horizon>/<mixins>`); a modpack's manifest format (`1`). |
|
||||
| `side`, `capabilities` | A mod's, read from its manifest by CI, never typed by hand. |
|
||||
| `api_level` | The mod API level a mod's code needs ([stability.md](stability.md#mod-api-levels)): its newest imported function's, read from the file by CI. Absent for content-only mods and in indexes built before levels. Installers take the newest version whose level the running game serves. |
|
||||
| `api_level` | For a WebAssembly mod published before they were removed: the mod API level its code needs, read from the file by CI. Absent for content mods and native mods. The launcher doesn't install such a mod alone (it "needs a native port"); it stays in the index for the modpacks that require it. |
|
||||
| `game` | The Minecraft version it is for. |
|
||||
| `requires` | A modpack's mods: `{ id, version, sha256 }`, each a version in this index with that hash. |
|
||||
| `file` | A plain name with the kind's extension (`.hmod`, `.hpack`, `.zip`), an `https://` URL, the size (at most 64 MiB), the lowercase hex SHA-256, and the `.minisig` text when the project has a key. |
|
||||
| `file` | A plain name with the kind's extension (`.hmod`, `.hnmod`, `.hpack`, `.zip`, `.hshader`), an `https://` URL, the size (at most 64 MiB), the lowercase hex SHA-256, and the `.minisig` text when the project has a key. |
|
||||
| `revoked` | Files taken down: one file by `sha256`, or every version of `id` without it. The launcher refuses to install them and turns installed copies off with the reason. |
|
||||
|
||||
The sample's `coords_hud` is a WebAssembly mod from before they were removed, which is why it has an `api_level`.
|
||||
|
||||
Readers ignore fields they don't know, so an optional field can be added to schema 1. Renaming or removing a field, or
|
||||
changing what one means, makes schema 2.
|
||||
|
||||
@@ -61,13 +64,12 @@ changing what one means, makes schema 2.
|
||||
2. With a project key: its signature verifies with that key (minisign's prehashed and legacy signatures both).
|
||||
3. What the file says matches the entry: a mod's `horizon-mod.toml` (id, version, API, side, capabilities), its API
|
||||
level when the entry lists one, and its
|
||||
content's fence ([mods.md](../mods.md#content-mods-api-02)); a modpack's manifest (id, version, format, game,
|
||||
content's fence ([mods.md](../mods.md#content-mods-api-02)); a native mod's `horizon-native.json` and
|
||||
`horizon-mixin.toml` (id, version, the pinned Horizon and horizon-mixins revisions); a modpack's manifest (id, version, format, game,
|
||||
mods); a resource pack's `pack.mcmeta`; a shader pack's `horizon_shader_api::check` and manifest (id, version,
|
||||
shader API).
|
||||
4. A kind this build can't install is refused with "this Horizon can't install a … yet".
|
||||
|
||||
The game still checks a mod's component imports against its capabilities when it starts it, as for any mod.
|
||||
|
||||
## `.hpack`
|
||||
|
||||
A zip holding `horizon-modpack.toml` and, optionally, `overrides/`. The mods are not inside: the launcher downloads
|
||||
@@ -91,7 +93,7 @@ on = true # optional, default true: turned on in the profile's
|
||||
|
||||
Unknown keys are refused. `overrides/` may hold only `options.txt`, `horizon-graphics.txt`, `horizon-extras.txt`,
|
||||
`resourcepacks/<name>.zip` and `horizon-mods/.horizon-mod-data/<a listed mod>/...` (a mod's own saved settings,
|
||||
capability `storage`), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
|
||||
a native mod's saved settings), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
|
||||
them into the profile's game folder.
|
||||
|
||||
## The registry repository
|
||||
@@ -139,7 +141,7 @@ Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above), its signature with `--key` and `--signature`, and for a mod a trial run in the sandbox (40 ticks, 120 frames, a chat line, each command). |
|
||||
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above) and its signature with `--key` and `--signature`. It refuses an `.hmod` with WebAssembly code, and runs no mod's code: a native mod's source is read by the reviewers. |
|
||||
| `cargo horizon-mod registry check <base> <head> --files <dir>` | `check` on each file `head` lists and `base` doesn't: a pull request's new files. |
|
||||
| `cargo horizon-mod registry list <checkout>` | `<sha256> <id> <version> <file>` per version: the files to download. |
|
||||
| `cargo horizon-mod registry build <checkout> --files <dir> --out index.json` | Reads every entry and its file (`<dir>/<sha256>`), lists every problem, and writes the index with URLs `<release><id>-<version>/<file>` (`--release`, ending in `/`, defaults to the repository's releases). |
|
||||
@@ -160,7 +162,7 @@ How the repository runs (its workflows are in `.github/workflows/`):
|
||||
- **Taking a file down** is a pull request adding it to `revoked.toml`. Removing an entry outright (a test, a project
|
||||
nobody installed) fails `registry review` on purpose and needs an administrator's merge.
|
||||
|
||||
Kinds open to submissions: `mod`, `modpack`, `resourcepack`, `shader`. A shader pack (`.hshader`) passes
|
||||
Kinds open to submissions: `mod` (content only), `native-mod`, `modpack`, `resourcepack`, `shader`. A shader pack (`.hshader`) passes
|
||||
`horizon_shader_api::check`, the check the game runs before it loads one (`horizon-shader check`, [shaders.md](../shaders.md)):
|
||||
every stage validated by naga and translated for Vulkan, Metal and DirectX 12; its manifest's id, version and shader
|
||||
API line are its facts. Iris and OptiFine packs are for the Modrinth browser. A new kind gets its own check before it
|
||||
|
||||
@@ -1,770 +0,0 @@
|
||||
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
|
||||
# Mod API reference
|
||||
|
||||
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`, `0.3`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| In `capabilities` | Grants | The player reads "May …" |
|
||||
| --- | --- | --- |
|
||||
| `game-read` | [`game`](#game) | see your position, health and the time |
|
||||
| `chat` | [`chat`](#chat) | show you chat messages |
|
||||
| `hud` | [`hud`](#hud) | draw text on your screen |
|
||||
| `storage` | [`storage`](#storage) | save its own settings to disk |
|
||||
| `command` | [`command`](#command) | run commands as you, with your permissions |
|
||||
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
|
||||
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
|
||||
| `render` | [`render`](#render) | draw on your screen, shake and zoom the camera |
|
||||
| `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys |
|
||||
| `players` | [`players`](#players) | see who is on the server and message them |
|
||||
| `world` | [`world`](#world) | read and change the world's blocks |
|
||||
| `recipes` | [`recipes`](#recipes) | read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
A client mod: runs in the player's game.
|
||||
|
||||
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Exports (what the mod provides): `events`
|
||||
|
||||
## World `horizon-server-mod`
|
||||
|
||||
A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs where the world is simulated, on the dedicated server, a LAN host and single player's own server.
|
||||
|
||||
- Imports (what the game offers): `log`, `storage`, `players`, `world`
|
||||
- Exports (what the mod provides): `server-events`
|
||||
|
||||
## World `horizon-ui-mod`
|
||||
|
||||
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
|
||||
|
||||
- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `browser`, `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Exports (what the mod provides): `ui-events`, `events`
|
||||
|
||||
## World `ui-host`
|
||||
|
||||
The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
|
||||
- Imports (what the game offers): `ui`
|
||||
- Exports (what the mod provides): `ui-events`
|
||||
|
||||
## World `render-host`
|
||||
|
||||
The game's side of capability `render` (imports only).
|
||||
|
||||
- Imports (what the game offers): `render`, `input`
|
||||
- Exports (what the mod provides):
|
||||
|
||||
## World `recipes-host`
|
||||
|
||||
The game's side of capability `recipes` (imports only).
|
||||
|
||||
- Imports (what the game offers): `recipes`, `browser`
|
||||
- Exports (what the mod provides):
|
||||
|
||||
## `log`
|
||||
|
||||
Access: always granted.
|
||||
|
||||
The mod's log, in the game's log under the mod's id. Always granted.
|
||||
|
||||
### enum `level`
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `debug` | |
|
||||
| `info` | |
|
||||
| `warn` | |
|
||||
| `error` | |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `write` | `func(level: level, message: string)` | One line in the game's log as `[mod <id>] message`, at that level. |
|
||||
|
||||
## `game`
|
||||
|
||||
Access: capability `game-read`.
|
||||
|
||||
Capability `game-read`: the local player, the world clock and the block the player looks at.
|
||||
|
||||
### record `player`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `f64` | Feet position, in blocks. |
|
||||
| `y` | `f64` | |
|
||||
| `z` | `f64` | |
|
||||
| `yaw` | `f32` | Degrees, as F3 shows them. |
|
||||
| `pitch` | `f32` | Degrees: -90 looks straight up, 90 straight down. |
|
||||
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
|
||||
| `food` | `u32` | 0 to 20, as the hunger bar. |
|
||||
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
|
||||
|
||||
### record `clock`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `game-time` | `u64` | Ticks since the world was created. |
|
||||
| `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. |
|
||||
|
||||
### record `hit`
|
||||
|
||||
A block along the player's view.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `block-x` | `s32` | The block hit. |
|
||||
| `block-y` | `s32` | |
|
||||
| `block-z` | `s32` | |
|
||||
| `x` | `f64` | The point on its face where the view meets it. |
|
||||
| `y` | `f64` | |
|
||||
| `z` | `f64` | |
|
||||
| `block` | `string` | `minecraft:stone`, ... |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `get-player` | `func() -> option<player>` | None on the title screen. |
|
||||
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
|
||||
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
|
||||
|
||||
## `chat`
|
||||
|
||||
Access: capability `chat`.
|
||||
|
||||
Capability `chat`: messages only the local player sees.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `show` | `func(text: string)` | Shown in chat after the mod's name, like a system message. |
|
||||
|
||||
## `hud`
|
||||
|
||||
Access: capability `hud`.
|
||||
|
||||
Capability `hud`: one panel of text lines per mod, drawn by the game every frame until the mod changes or clears it (no call per frame).
|
||||
|
||||
### enum `corner`
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `top-left` | |
|
||||
| `top-right` | |
|
||||
| `bottom-left` | |
|
||||
| `bottom-right` | |
|
||||
|
||||
### record `line`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `text` | `string` | Plain text, up to 256 characters. |
|
||||
| `color` | `u32` | 0xRRGGBB. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-panel` | `func(corner: corner, lines: list<line>)` | Replaces the mod's panel: up to 16 lines in that corner of the screen. |
|
||||
| `clear` | `func()` | Removes the mod's panel. |
|
||||
|
||||
## `storage`
|
||||
|
||||
Access: capability `storage`.
|
||||
|
||||
Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/mods.md). Keys are `[a-z0-9_.-]{1,64}`, one path segment.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `get` | `func(key: string) -> option<list<u8>>` | None if `key` has never been `set`, or the key is not well-formed. |
|
||||
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
|
||||
| `delete` | `func(key: string)` | No error if `key` was already absent. |
|
||||
|
||||
## `command`
|
||||
|
||||
Access: capability `command`.
|
||||
|
||||
Capability `command`: vanilla commands run as the player, with the player's permissions, after the hook call returns (8 per call). A mod's own commands are not run this way.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. |
|
||||
| `run-quiet` | `func(lines: list<string>)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. |
|
||||
|
||||
## `ui`
|
||||
|
||||
Access: capability `ui`.
|
||||
|
||||
Capability `ui`: widgets the game draws with its own menus' widgets, for a screen the mod owns and a settings page in Options > Mods. Layout and drawing are the game's: a mod says what its widgets hold, never where they sit or how they look, and the game clips everything it is handed.
|
||||
|
||||
### record `text-widget`
|
||||
|
||||
A line of text.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events` calls echo it. |
|
||||
| `text` | `string` | Up to 256 characters. |
|
||||
| `color` | `u32` | 0xRRGGBB; 0 is the menus' white. |
|
||||
|
||||
### record `item-widget`
|
||||
|
||||
An item's icon and a name beside it, as recipe viewers show them.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget. |
|
||||
| `item` | `string` | `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?". |
|
||||
| `name` | `string` | The name shown beside the icon (empty: the item's own). |
|
||||
|
||||
### record `list-widget`
|
||||
|
||||
Rows the player picks one of, as an options page lists choices: a pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events::select` echoes it. |
|
||||
| `rows` | `list<string>` | The rows' texts, at most 512, each up to 256 characters. |
|
||||
| `selected` | `option<u32>` | The row to mark as the current choice, if any. |
|
||||
|
||||
### record `button-widget`
|
||||
|
||||
A thing on a mod's screen or settings page. A button as the options pages have them (200 × 20): a click is `ui-events::select(id, 0)`. Mods cycle its label for a choice ("Corner: Top Left").
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget. |
|
||||
| `label` | `string` | Up to 256 characters. |
|
||||
|
||||
### variant `widget`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `text` | `text-widget` | Text the player reads. |
|
||||
| `button` | `button-widget` | A button. |
|
||||
| `item` | `item-widget` | An item's icon and name. |
|
||||
| `list` | `list-widget` | Rows the player picks from. |
|
||||
|
||||
### record `screen`
|
||||
|
||||
A screen the mod owns: a title over widgets stacked down the middle, the Done button the game adds, and Esc to leave it.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `title` | `string` | Its title, up to 256 characters. |
|
||||
| `widgets` | `list<widget>` | Its widgets, in order, at most 64. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `open-screen` | `func(screen: screen)` | Show `screen` over the game; a second call while it is open replaces it. Only the last call of one hook counts. |
|
||||
| `close-screen` | `func()` | Close the mod's screen when it is open (the mod hears no `closed` for this: it was its own doing). |
|
||||
| `set-settings` | `func(widgets: list<widget>)` | The mod's page in Options > Mods, reached from a settings button on its row: the widgets it shows, an empty list for no page. The player opening the page is `ui-events::settings-opened`: push fresh widgets then; later calls update the page while it shows. |
|
||||
|
||||
## `ui-events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
What the game calls on a mod that shows widgets (capability `ui`): what the player did with them.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `select` | `func(id: u32, row: u32)` | A row of one of the mod's `list` widgets was picked: the widget's `id` and the row, on the screen or the settings page that showed it. |
|
||||
| `closed` | `func()` | The player left the mod's screen (Esc or Done); not for a screen the mod closed itself or one that went away with the mod. |
|
||||
| `settings-opened` | `func()` | The player opened the mod's settings page in Options > Mods: call `ui::set-settings` now to fill it with fresh widgets. |
|
||||
|
||||
## `input`
|
||||
|
||||
Access: capability `input`.
|
||||
|
||||
Capability `input` (experimental: the manifest needs `experimental = ["input"]`): the use button on items the mod claims, and the mod's own keys. Events wait for `poll`, at most 256.
|
||||
|
||||
### record `held-item`
|
||||
|
||||
The main hand's stack.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | `minecraft:spyglass`, `<mod>:<path>`. |
|
||||
| `count` | `u32` | |
|
||||
| `slot` | `u32` | The hotbar slot, 0 to 8. |
|
||||
|
||||
### record `item-use`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `held-item` | |
|
||||
| `ticks` | `u32` | Game ticks the button was held (0 on `use-start`). |
|
||||
|
||||
### variant `event`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `use-start` | `item-use` | The use button went down with a claimed item in the main hand. The game does nothing else with the click: no placing, no eating, no swing. |
|
||||
| `use-stop` | `item-use` | It came up again, or the item left the hand, or a screen opened. |
|
||||
| `key-down` | `string` | A key `register-key` named went down or up while playing (no screen open). The game's own mapping on that key still runs. A screen opening lets every held key up. |
|
||||
| `key-up` | `string` | |
|
||||
| `wheel` | `f32` | Mouse wheel steps while the mod holds the wheel (`claim-wheel`): positive away from the player, as the hotbar reads them. |
|
||||
|
||||
### record `mapping-state`
|
||||
|
||||
A mapping as the player has bound it.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `label` | `string` | The bound key's name as Controls shows it: `W`, `Space`, `LMB`. |
|
||||
| `down` | `bool` | Held down now (as of this frame for a mod with a `frame` hook, else this tick). |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `claim-items` | `func(ids: list<string>)` | The items whose use the mod handles instead of the game, by id (at most 64). Replaces the previous list. |
|
||||
| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`), the default: the player can bind another one on the mod's page in Options > Mods, and that one wins. At most 16; an unknown key is an error. |
|
||||
| `held` | `func() -> option<held-item>` | The main hand's stack, as of this tick; none when it is empty. |
|
||||
| `poll` | `func() -> list<event>` | The events since the last call, oldest first. |
|
||||
| `claim-wheel` | `func(on: bool)` | Takes the mouse wheel while playing (true) or gives it back: while taken, its steps are the mod's `wheel` events and the hotbar doesn't move. The game gives it back when the mod stops. |
|
||||
| `mapping` | `func(name: string) -> option<mapping-state>` | A movement or click mapping as the player has bound it: `key.forward`, `key.left`, `key.back`, `key.right`, `key.jump`, `key.sneak`, `key.sprint`, `key.attack` or `key.use`. None for another name. |
|
||||
| `clicks-per-second` | `func() -> tuple<u32, u32>` | Clicks of the attack and use buttons in the last second. |
|
||||
|
||||
## `render`
|
||||
|
||||
Access: capability `render`.
|
||||
|
||||
Capability `render` (experimental: the manifest needs `experimental = ["render"]`): shapes the game draws for the mod with its own renderer, kept until the mod replaces them (no call per frame). A mod never supplies shader code.
|
||||
|
||||
### enum `corner`
|
||||
|
||||
A corner of the screen, as `hud`'s panels use them.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `top-left` | |
|
||||
| `top-right` | |
|
||||
| `bottom-left` | |
|
||||
| `bottom-right` | |
|
||||
|
||||
### record `rect`
|
||||
|
||||
A rectangle in GUI pixels: the top left corner and the size.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `f32` | |
|
||||
| `y` | `f32` | |
|
||||
| `w` | `f32` | |
|
||||
| `h` | `f32` | |
|
||||
|
||||
### record `point`
|
||||
|
||||
A point in GUI pixels from the screen's top left.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `f32` | |
|
||||
| `y` | `f32` | |
|
||||
|
||||
### record `fill-shape`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `rect` | `rect` | |
|
||||
| `color` | `u32` | 0xAARRGGBB. |
|
||||
|
||||
### record `quad-shape`
|
||||
|
||||
Any four-cornered shape (a wheel's segment, an arrow): the corners in order round it.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `corners` | `tuple<point, point, point, point>` | |
|
||||
| `color` | `u32` | 0xAARRGGBB. |
|
||||
|
||||
### record `sprite-shape`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `sprite` | `string` | One of the game's GUI sprites, as `gui/sprites/` names them (`hud/heart/full`, `widget/button`), the resource packs' versions included, drawn over `rect` as vanilla draws it: nine-sliced when its `gui.scaling` says so (`tooltip/frame`, `widget/button`: the borders keep their size), stretched otherwise. An unknown one draws nothing. |
|
||||
| `rect` | `rect` | |
|
||||
| `color` | `u32` | A tint, 0xAARRGGBB: 0xFFFFFFFF draws it as it is. |
|
||||
|
||||
### record `item-shape`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `string` | `minecraft:diamond`, `<mod>:<path>`; an unknown one draws nothing. |
|
||||
| `at` | `point` | The icon's top left. |
|
||||
| `scale` | `f32` | 1.0 is the hotbar's 16 × 16. |
|
||||
|
||||
### record `text-shape`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `text` | `string` | Up to 256 characters, one line. |
|
||||
| `at` | `point` | |
|
||||
| `color` | `u32` | 0xRRGGBB, as `hud`'s lines. |
|
||||
| `centered` | `bool` | Centred on `at.x` instead of starting there. |
|
||||
| `shadow` | `bool` | |
|
||||
|
||||
### variant `shape`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `fill` | `fill-shape` | |
|
||||
| `quad` | `quad-shape` | |
|
||||
| `sprite` | `sprite-shape` | |
|
||||
| `item` | `item-shape` | |
|
||||
| `text` | `text-shape` | |
|
||||
|
||||
### record `frame-rate`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `fps` | `u32` | Frames drawn in the last second. |
|
||||
| `min` | `u32` | The slowest frame's rate, and the 1% low (the 99th percentile frame time's rate), over the last 5 seconds. |
|
||||
| `low` | `u32` | |
|
||||
|
||||
### record `zoom`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `factor` | `f32` | The field of view is divided by it: 1 is none, at most 10. |
|
||||
| `smooth` | `bool` | Ease in and out through the game's field-of-view easing, as the spyglass does, instead of at once. |
|
||||
| `turn` | `f32` | Mouse turning is multiplied by it while zoomed: 1 keeps the player's sensitivity, at least 0.05. |
|
||||
|
||||
### record `minimap`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `corner` | `corner` | |
|
||||
| `size` | `f32` | Its side in GUI pixels, 16 to 256. |
|
||||
| `blocks-per-pixel` | `f32` | World blocks per map pixel, 0.5 to 4; the map is 128 × 128 pixels. |
|
||||
| `rotate` | `bool` | The map turns with the player, who faces up, and an N on its rim marks north. Otherwise north is up and the player's arrow turns. |
|
||||
| `round` | `bool` | Round, the corners left clear; otherwise square. |
|
||||
|
||||
### record `far-terrain`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
|
||||
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||||
| `gui-size` | `func() -> tuple<f32, f32>` | The screen's size in GUI pixels (width, height), as of this tick. |
|
||||
| `shake` | `func(strength: f32, seconds: f32)` | Shakes the camera: a jolt of `strength` (0 to 1, 1 the hardest) that fades out over `seconds` (at most 10). Jolts add up to 1. The view turns, the player doesn't, and the player's Distortion Effects option scales it (0 turns it off). |
|
||||
| `set-corner` | `func(corner: corner, width: f32, height: f32, shapes: list<shape>)` | Replaces the mod's corner panel: shapes in GUI pixels from the top left of a `width` × `height` box that the game places in `corner`, stacked with the game's overlays and the other mods' panels (under the status effects at the top right), as `hud`'s panels are. |
|
||||
| `clear-corner` | `func()` | Removes the mod's corner panel. |
|
||||
| `text-width` | `func(text: string) -> f32` | The width of one line of text in GUI pixels, in the HUD's font: exact for Latin letters (U+0000 to U+024F), 6 per other character. |
|
||||
| `frame-stats` | `func() -> frame-rate` | The game's frame counter, recomputed twice a second. |
|
||||
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
|
||||
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
|
||||
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
|
||||
|
||||
## `events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
What the game calls. Only the hooks `init` asks for are ever called.
|
||||
|
||||
### record `registration`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `commands` | `list<string>` | Chat commands, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
|
||||
| `tick` | `bool` | Call `tick` every game tick (20 per second while playing). |
|
||||
| `frame` | `bool` | Call `frame` every rendered frame. Budget: 0.1 ms. |
|
||||
| `chat` | `bool` | Call `chat` for each chat line the player receives. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is shown. |
|
||||
| `tick` | `func(tick: u64)` | Each game tick while in a world, with the game's tick counter. |
|
||||
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
|
||||
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
|
||||
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
|
||||
|
||||
## `recipes`
|
||||
|
||||
Access: capability `recipes`.
|
||||
|
||||
Capability `recipes` (experimental): the recipes and items the game knows, for recipe viewers. In single player and on Horizon's servers they are the world's; on any other server they are vanilla 26.3's (a client is sent no others), while `known` follows that server's book.
|
||||
|
||||
### record `stack`
|
||||
|
||||
An item of `items`, by its index there, with a count and the components it has beyond the item's own, in vanilla's item syntax (`[potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `u32` | |
|
||||
| `count` | `u32` | |
|
||||
| `components` | `string` | |
|
||||
|
||||
### enum `kind`
|
||||
|
||||
The station a recipe is made at.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `crafting-shaped` | |
|
||||
| `crafting-shapeless` | |
|
||||
| `smelting` | |
|
||||
| `blasting` | |
|
||||
| `smoking` | |
|
||||
| `campfire` | |
|
||||
| `stonecutting` | |
|
||||
| `smithing` | |
|
||||
| `brewing` | |
|
||||
|
||||
### record `recipe`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | `minecraft:oak_planks`. |
|
||||
| `kind` | `kind` | |
|
||||
| `category` | `string` | The recipe book's tab (`crafting_building_blocks`, `furnace_food`), or "". |
|
||||
| `group` | `string` | Recipes the book shows on one button share a group, or "". |
|
||||
| `width` | `u32` | A shaped recipe's grid: `slots` holds its cells row by row. 0 for the other kinds. |
|
||||
| `height` | `u32` | |
|
||||
| `slots` | `list<list<stack>>` | Each slot's stacks, any one of which fits; an empty list is an empty cell. Cooking and stonecutting: the input. Smithing: the template, the base, the addition. Brewing: the bottle, the reagent. |
|
||||
| `results` | `list<stack>` | What it makes; several when that varies (a trim on any armour). |
|
||||
| `cooking-time` | `u32` | Cooking: game ticks and experience; 0 for the other kinds. |
|
||||
| `experience` | `f32` | |
|
||||
| `book-id` | `s32` | The recipe book's id for it (one per way the book shows it), as `known` lists them; -1 for the kinds the book doesn't show (campfire, stonecutting, smithing, brewing). |
|
||||
|
||||
### record `item`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | `minecraft:diamond`, `<mod>:<path>`. |
|
||||
| `name` | `string` | In the player's language. |
|
||||
| `tags` | `list<string>` | Its item tags, without the `#`. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `items` | `func() -> list<item>` | Every item, in the registry's order: `stack.item` indexes this list. |
|
||||
| `all` | `func() -> list<recipe>` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. |
|
||||
| `known` | `func() -> list<s32>` | The `book-id`s of the recipes the player's recipe book holds now. |
|
||||
| `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. |
|
||||
| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. The count is cut to one stack of the item (64, 16 or 1). |
|
||||
|
||||
## `browser`
|
||||
|
||||
Access: not granted by any capability.
|
||||
|
||||
Capability `ui` (experimental): a recipe viewer's panel beside the game's inventory and container screens (not the creative inventory), on the right of the screen. The game draws it with its own widgets and slots, pages it and keeps its search box; the mod says what it holds. The first loaded mod with a panel shows.
|
||||
|
||||
### record `stack`
|
||||
|
||||
An item to show: its id, a count (1 draws none) and its components beyond the item's own, in vanilla's item syntax, or "".
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `string` | |
|
||||
| `count` | `u32` | |
|
||||
| `components` | `string` | |
|
||||
|
||||
### record `click`
|
||||
|
||||
A click on an item of the panel, by its index in `set-items`'s list.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `index` | `u32` | |
|
||||
| `right` | `bool` | |
|
||||
|
||||
### record `item-key`
|
||||
|
||||
A key the mod registered (`input.register-key`) went down while the pointer was on an item: a slot of the screen's, or the panel's.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `key` | `string` | |
|
||||
| `stack` | `stack` | |
|
||||
|
||||
### record `slot-click`
|
||||
|
||||
A click on a slot of a recipe the panel shows (`set-recipes`).
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `recipe` | `u32` | The recipe's index in `set-recipes`'s list and the slot's in it; the results come after the slots. |
|
||||
| `slot` | `u32` | |
|
||||
| `stack` | `stack` | The stack the slot showed at that moment. |
|
||||
| `right` | `bool` | |
|
||||
|
||||
### variant `event`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `clicked` | `click` | |
|
||||
| `search` | `string` | The search box's text, each time the player changes it. |
|
||||
| `key` | `item-key` | |
|
||||
| `slot-clicked` | `slot-click` | |
|
||||
| `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. |
|
||||
| `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. |
|
||||
| `back` | | The back button of `set-recipes`. |
|
||||
| `tab` | `u32` | The player picked the tab at this index of `set-recipes`' tabs. |
|
||||
|
||||
### enum `layout`
|
||||
|
||||
How the game lays out a recipe of `set-recipes`.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `grid` | A crafting grid of `width` x `height` slots, an arrow and the result. |
|
||||
| `cooking` | One input over a flame, an arrow and the result (furnaces, smokers, campfires). |
|
||||
| `single` | One input, an arrow and the result (the stonecutter). |
|
||||
| `smithing` | Template, base and addition, an arrow and the result. |
|
||||
| `brewing` | A potion and an ingredient, an arrow and the result. |
|
||||
|
||||
### record `recipe-view`
|
||||
|
||||
A recipe to show. Each slot, and the result, cycles through its stacks, one a second; an empty slot shows nothing.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `layout` | `layout` | |
|
||||
| `width` | `u32` | The grid's size (`grid` only), at most 3 x 3. |
|
||||
| `height` | `u32` | |
|
||||
| `slots` | `list<list<stack>>` | |
|
||||
| `results` | `list<stack>` | |
|
||||
| `note` | `string` | A short line under the recipe (a cooking time), or "". |
|
||||
| `action` | `option<bool>` | The action button beside it: none, enabled, or shown greyed. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-items` | `func(items: list<stack>)` | The panel's items, at most 8192 (an empty list hides the panel). |
|
||||
| `set-recipes` | `func(title: string, tabs: list<stack>, tab: u32, recipes: list<recipe-view>)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button, with a row of `tabs` (each an icon; `tab` is the selected one) between them; an empty list of recipes shows the items again. At most 256 recipes and 16 tabs. |
|
||||
| `poll` | `func() -> list<event>` | What the player did on the panel since the last poll, at most 256 kept. |
|
||||
|
||||
## `players`
|
||||
|
||||
Access: capability `players`.
|
||||
|
||||
Capability `players`, server mods only (manifest `api = "0.3"`, experimental): the players on the server, and messages to them.
|
||||
|
||||
### enum `game-mode`
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `survival` | |
|
||||
| `creative` | |
|
||||
| `adventure` | |
|
||||
| `spectator` | |
|
||||
|
||||
### record `player`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. |
|
||||
| `name` | `string` | |
|
||||
| `x` | `f64` | Feet position, in blocks. |
|
||||
| `y` | `f64` | |
|
||||
| `z` | `f64` | |
|
||||
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
|
||||
| `game-mode` | `game-mode` | |
|
||||
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `online` | `func() -> list<player>` | The players online, in the order they joined. |
|
||||
| `tell` | `func(player: string, text: string) -> bool` | A system message to one player, after the mod's name; false when no player with that id is online. Up to 8 messages per call. |
|
||||
| `broadcast` | `func(text: string)` | A system message to every player and the server's log, after the mod's name. Counts toward the same 8 per call. |
|
||||
|
||||
## `world`
|
||||
|
||||
Access: capability `world`.
|
||||
|
||||
Capability `world`, server mods only (manifest `api = "0.3"`, experimental): the blocks of the server's loaded chunks, and changes to them through the server's own block updates.
|
||||
|
||||
### record `block-change`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `s32` | |
|
||||
| `y` | `s32` | |
|
||||
| `z` | `s32` | |
|
||||
| `state` | `string` | `minecraft:oak_log[axis=y]`; properties left out take the block's defaults, `minecraft:stone` alone is the default state. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `get-block` | `func(dimension: string, x: s32, y: s32, z: s32) -> option<string>` | The block state at a position in a dimension (`minecraft:overworld`, ...), as `minecraft:oak_log[axis=y]`, as of this call: none where the server has no chunk loaded there. |
|
||||
| `get-biome` | `func(dimension: string, x: s32, y: s32, z: s32) -> option<string>` | The biome at a position (`minecraft:plains`, ...), as the server stores it (one per 4 x 4 x 4 cell): none where no chunk is loaded. |
|
||||
| `set-blocks` | `func(dimension: string, blocks: list<block-change>) -> result<_, string>` | Sets blocks as `/setblock` does: the old block entity removed, neighbours and shapes updated, light, and every player told. Every state is checked now (an error queues nothing); the changes are made in order after this call returns, before that dimension's next tick; one in a chunk not loaded then is skipped. At most 4096 per call. |
|
||||
|
||||
## `server-events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
What the server calls in a server mod (manifest `api = "0.3"`, experimental). Only the hooks `init` asks for are ever called. The player in each event is as the server saw them when it happened.
|
||||
|
||||
### type `player`
|
||||
|
||||
`type player = player`
|
||||
|
||||
### record `block-pos`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `s32` | |
|
||||
| `y` | `s32` | |
|
||||
| `z` | `s32` | |
|
||||
|
||||
### variant `argument-kind`
|
||||
|
||||
What an argument of a command takes. The server sends each command and its arguments to players' games, whose chat box completes and colours them as vanilla's; the server parses them before the mod's `command` hook gets their values.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `word` | | One word: letters, digits and `_ - . +`. |
|
||||
| `text` | | The rest of the line, spaces included: only last. |
|
||||
| `integer` | `tuple<option<s32>, option<s32>>` | A whole number, within the bounds given. |
|
||||
| `number` | | A number with or without decimals. |
|
||||
| `boolean` | | `true` or `false`. |
|
||||
| `player` | | One online player: a name, or `@s` or `@p` (whoever typed it, as vanilla picks for a player). |
|
||||
| `block-pos` | | A block position: three whole numbers, each one also `~` or `~n`, relative to whoever typed it. |
|
||||
|
||||
### record `argument`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `name` | `string` | Shown in the chat box's hint (`<height>`) and the key of its value. |
|
||||
| `kind` | `argument-kind` | |
|
||||
| `optional` | `bool` | Optional arguments come after every required one. |
|
||||
|
||||
### record `server-command`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `name` | `string` | Without the slash. A name vanilla already uses is refused (vanilla's command runs). |
|
||||
| `arguments` | `list<argument>` | In order; none: the command takes nothing after its name. Free text is one optional `text` argument. |
|
||||
|
||||
### variant `argument-value`
|
||||
|
||||
An argument's value, parsed as its kind says.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `word` | `string` | |
|
||||
| `text` | `string` | |
|
||||
| `integer` | `s32` | |
|
||||
| `number` | `f64` | |
|
||||
| `boolean` | `bool` | |
|
||||
| `player` | `player` | |
|
||||
| `block-pos` | `block-pos` | |
|
||||
|
||||
### record `registration`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `commands` | `list<server-command>` | Commands players may type. |
|
||||
| `tick` | `bool` | Call `tick` every server tick (20 per second). |
|
||||
| `joins` | `bool` | Call `player-joined` and `player-left`. |
|
||||
| `chat` | `bool` | Call `chat` for each chat message a player sends. |
|
||||
| `blocks` | `bool` | Call `block-broken` and `block-placed`. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is logged. |
|
||||
| `tick` | `func(tick: u64)` | Each server tick, with the server's tick counter. |
|
||||
| `player-joined` | `func(player: player)` | A player joined the server; they are in `players.online` already. |
|
||||
| `player-left` | `func(player: player)` | A player left the server; they are no longer in `players.online`. |
|
||||
| `chat` | `func(player: player, text: string)` | A chat message a player sent, as plain text. |
|
||||
| `command` | `func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. `values` holds each argument given, by name, in order (an optional one left out isn't there); a line that doesn't parse never reaches the mod: the player gets vanilla's error. |
|
||||
| `block-broken` | `func(player: player, pos: block-pos, state: string)` | A player broke the block at `pos` in their dimension: `state` is what was there, as `minecraft:oak_log[axis=y]`. |
|
||||
| `block-placed` | `func(player: player, pos: block-pos, state: string)` | A player placed `state` at `pos` in their dimension. |
|
||||
@@ -1,125 +0,0 @@
|
||||
# Mod API
|
||||
|
||||
Mods are Rust crates compiled to sandboxed WebAssembly components. Client mods run in the player's game; server mods
|
||||
(experimental) run wherever a world is simulated: single player, a LAN host, the dedicated server. Design and the runtime: [mods.md](../mods.md).
|
||||
|
||||
## Overview
|
||||
|
||||
A mod today can read the local player and the world clock, show chat lines only that player sees, draw a text panel
|
||||
on the HUD, keep small files of its own, register chat commands, and run on every tick, frame or chat line it asks
|
||||
for. A content mod adds blocks and items from JSON in its `.hmod`, copying vanilla templates, plus tags, recipes,
|
||||
loot, models, textures and structures in its own namespaces. Every capability is declared in the manifest, shown to
|
||||
the player before the mod is turned on, and checked against the component's real imports.
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Interfaces the game offers | 6: `log`, `game`, `chat`, `hud`, `storage`; `players` for server mods |
|
||||
| Functions in them | 12 |
|
||||
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat`; a server mod 8: `init`, `tick`, `player-joined`, `player-left`, `chat`, `command`, `block-broken`, `block-placed` |
|
||||
| Capabilities | 6: `game-read`, `chat`, `hud`, `storage`, `registries`, `players` |
|
||||
| Manifest `api` lines served | 3: `0.1` (code), `0.2` (content), `0.3` (server mods, experimental) |
|
||||
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (6 builders), `server::Argument`, `server::Args`, `export_server_mod!` |
|
||||
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
|
||||
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
|
||||
|
||||
## Guide
|
||||
|
||||
The step-by-step guide, from installing the tools to publishing on the registry, is
|
||||
[Make a mod](https://playhorizon.dev/docs/make-a-mod) on the website. In short:
|
||||
|
||||
1. `rustup target add wasm32-unknown-unknown`, `cargo install --git https://github.com/horizon-rust/horizon
|
||||
cargo-horizon-mod`, then `cargo horizon-mod new my-mod` ([Making a mod](../mods.md#making-a-mod)).
|
||||
2. Implement `Mod` for your type, list the capabilities you import in `horizon-mod.toml`
|
||||
([The manifest](../mods.md#the-manifest)).
|
||||
3. `cargo horizon-mod run` builds the `.hmod` and starts the game with it as a dev mod; after an edit,
|
||||
`cargo horizon-mod build` and the running game reloads it.
|
||||
4. `cargo horizon-mod test` runs the mod's `tests/*.toml` scripts in the sandbox, with no game: ticks, commands,
|
||||
chat lines and the player's position in, chat, feedback and the HUD panel checked
|
||||
([Testing a mod](../mods.md#testing-a-mod)).
|
||||
5. Content mods: [Content mods (API 0.2)](../mods.md#content-mods-api-02).
|
||||
|
||||
## Reference
|
||||
|
||||
[mods-reference.md](mods-reference.md), generated from `crates/horizon-mod-sdk/wit/horizon.wit`: every capability,
|
||||
interface, type, function and hook, with the limits the guide lists (16 HUD lines of 256 characters, 8 chat lines per
|
||||
call, 4 MiB of storage, fuel per call).
|
||||
|
||||
## Examples
|
||||
|
||||
- `crates/horizon-mod-sdk/examples/coords-hud`: coordinates, facing and time on the HUD; `/where`, `/coords`; storage.
|
||||
- `crates/horizon-mod-sdk/examples/ruby-content`: no code; a gem and a block of ruby from vanilla templates, with
|
||||
their recipes, loot, tags, models, textures and names.
|
||||
- `crates/horizon-mod-sdk/examples/welcome`: a server mod; greets players who join, announces newcomers (kept in
|
||||
storage), `/online` and `/mined`.
|
||||
- `crates/horizon-mods/tests/guest`: a mod that misbehaves on command (spins, panics, grows its memory), for the
|
||||
sandbox tests.
|
||||
|
||||
## Ways in
|
||||
|
||||
One API (the WIT world) and one sandbox (WebAssembly), whatever the mod is written in. Every way in produces a
|
||||
component for the same world, checked against the same capabilities and given the same limits; there is no second
|
||||
scripting runtime in the game.
|
||||
|
||||
| Way in | For | Status | Template and example |
|
||||
| --- | --- | --- | --- |
|
||||
| Rust | most mods; the fastest | first-class: the SDK, `cargo horizon-mod` | `cargo horizon-mod new`, `coords-hud` |
|
||||
| Data only | blocks and items with no code | first-class: content mods (API 0.2) | `cargo horizon-mod new --template content`, `ruby-content` |
|
||||
| Lua scripts | people who don't want a compiler | planned: a script mod whose code is a `.lua` file, run by Horizon's own interpreter mod | planned, with the same example as Rust |
|
||||
| C, C++, Zig | authors of those languages | to evaluate: bindings generated from the WIT, built without WASI | one each if it passes |
|
||||
| Go (TinyGo), AssemblyScript, JavaScript / TypeScript | authors of those languages | to evaluate: each needs its toolchain to emit a component without WASI imports, or an interpreter mod as for Lua | one each if it passes |
|
||||
|
||||
- **Scripts run inside the sandbox.** The Lua interpreter is itself a WebAssembly mod, built in Rust with the SDK and
|
||||
shipped by Horizon. A script mod's manifest names the interpreter and its own capabilities; the game links the
|
||||
interpreter with only those, so a script can do exactly what a compiled mod with the same manifest can.
|
||||
- **Hot reload.** The game keeps the interpreter compiled and loads the script into a fresh instance when the file
|
||||
changes, without restarting the game; what it saved with `storage` survives, as for dev mods.
|
||||
- **Measured, not guessed.** Each way in is listed with its measured cost on the same tick-heavy example (time and
|
||||
fuel per tick, memory, load time) against Rust, and a language is supported only with a template, an example and a
|
||||
test that builds and loads it in CI.
|
||||
|
||||
## Stability
|
||||
|
||||
Preview ([stability.md](stability.md)): API 0.1 and content 0.2 are served together; a mod built for 0.1.0 keeps
|
||||
loading on every 0.1.x game. Server mods are their own line, `api = "0.3"`, experimental: they may change in any
|
||||
release.
|
||||
|
||||
## Not there yet
|
||||
|
||||
What real mods need next, in the order modpacks and servers need it:
|
||||
|
||||
- **More for server mods** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): they run
|
||||
on the server thread, a LAN host and the dedicated server today, with joins, chat, typed commands in players'
|
||||
command trees, players' block breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock`
|
||||
does); next entity events, game rules, entity, item and block-state arguments.
|
||||
- **World and blocks**: blocks, block states and biomes are there for server mods (`world`); next block entities,
|
||||
and `world` for client mods (raycasts, single player and LAN hosts).
|
||||
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
|
||||
- **Entities**: query and change entities near a position; spawn from a mod's own entity types (data first).
|
||||
- **Block entities and menus**: ticking machines with slots and their screens.
|
||||
- **Screens and UI**: widgets built from vanilla's, a screen the mod owns, a settings page under Options > Mods.
|
||||
- **Networking**: a channel between a mod's two halves, client and server, typed by the mod's own WIT.
|
||||
- **Config**: typed settings the game shows and stores for the mod, on the server too.
|
||||
- **Keys and the crosshair target**: keybinds in Controls; what the player is looking at.
|
||||
- **Script mods and other languages**: see [Ways in](#ways-in).
|
||||
- **SDK**: templates per kind of mod, typed wrappers in the SDK for every interface. Dev mods already reload when
|
||||
rebuilt, and `cargo horizon-mod test` runs a mod headless.
|
||||
|
||||
## Changelog
|
||||
|
||||
- API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word,
|
||||
text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server;
|
||||
`command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`.
|
||||
- API 0.3: capability `world` (`get-block`, `get-biome`, `set-blocks`); example `beacons`; test scripts' `blocks` and
|
||||
`expect.blocks`.
|
||||
- Server mods run in the game: single player (server thread and in-frame), LAN hosts, the dedicated server.
|
||||
- API 0.3 (server mods, experimental): world `horizon-server-mod`, capability `players`, the `server-events`
|
||||
hooks; SDK `server::ServerMod`; example `welcome`.
|
||||
- SDK: `cargo horizon-mod test` and `horizon_mods::testing`, test scripts in TOML; the template has one.
|
||||
- Experimental: `ui` button widget; `render.set-corner`, `text-width`, `frame-stats`; `input.mapping`,
|
||||
`clicks-per-second` (what the official FPS, keystrokes and zoom mods need).
|
||||
- Experimental `input` (`experimental = ["input"]`): `claim-items`, `register-key`, `held`, `poll` (use-start,
|
||||
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.
|
||||
- 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`.
|
||||
+2
-2
@@ -49,8 +49,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
- **Metrics**: a plain-text metrics endpoint any monitoring tool can scrape (TPS, MSPT percentiles, players, chunks,
|
||||
entities, RAM, CPU), from the samples `status` already takes.
|
||||
- **Backups**: delete and download one; restore a single dimension.
|
||||
- **Server mods**: list, turn on and off, and their settings, once the mod API runs on the server
|
||||
([mods.md](mods.md)).
|
||||
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
|
||||
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
|
||||
- **An MCP server** for the same endpoints, so a host's agent manages the server as it manages the game.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
|
||||
|
||||
+14
-47
@@ -16,7 +16,8 @@ promise, how something is deprecated and removed, and the tests that keep old mo
|
||||
|
||||
| API | The version | A user names the line they need |
|
||||
| --- | --- | --- |
|
||||
| Mods | the WIT package (`horizon:[email protected]`) | `api = "0.1"` in `horizon-mod.toml` |
|
||||
| Content mods | the manifest's `api` line | `api = "0.2"` in `horizon-mod.toml` |
|
||||
| Native mods | the Horizon and horizon-mixins revisions the bundle pins | `horizon_revision` in `horizon-mixin.toml` |
|
||||
| Agent control | the control protocol's version (planned: a `hello` reply and MCP `serverInfo`) | `horizon-ctl --api 1` (planned) |
|
||||
| Server control API | the path (`/api/v1/`) | the path |
|
||||
| `server.toml` | the file's keys; a key is never renamed in place | nothing: old files keep loading |
|
||||
@@ -26,29 +27,17 @@ promise, how something is deprecated and removed, and the tests that keep old mo
|
||||
## What counts as a breaking change
|
||||
|
||||
Breaking: removing or renaming anything; changing a type, a default, a unit or a meaning; making something optional
|
||||
required; refusing input that was accepted; any change to a WIT type a function uses, a new `enum` or `variant` case
|
||||
included, in either direction (the runtime links a component only when each imported function's types match exactly:
|
||||
a mod built before the change no longer loads); a JSON field that changes type.
|
||||
required; refusing input that was accepted; a JSON field that changes type.
|
||||
|
||||
Not breaking: a new function, interface, capability, command, endpoint, tool or `server.toml` key; a new optional
|
||||
argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier
|
||||
don't import it); faster, or fixing behaviour to what the reference already says.
|
||||
argument or JSON field (readers must ignore fields they don't know); faster, or fixing behaviour to what the reference already says.
|
||||
|
||||
### Mod API levels
|
||||
### Mod API levels (WebAssembly mods, removed)
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`, level 7 `render.set-far-terrain`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
- the game refuses a mod that needs a function it doesn't have, with "made for a newer Horizon (it uses
|
||||
render.set-minimap): update Horizon to run it", instead of a linking error;
|
||||
- the registry's CI writes each mod version's level into the index (`api_level`), and installers take the newest
|
||||
version the running game can run.
|
||||
|
||||
A pull request that adds a function lists it in `api_level.rs` at the next level and raises `LEVEL`; the
|
||||
`api_reference` test fails until it does.
|
||||
While WebAssembly mods ran, every function the game offered them had an **API level**, the one it arrived at, read
|
||||
from the mod's file (`crates/horizon-mods/src/api_level.rs`, levels 0 to 7). Nothing new is added to it. The registry
|
||||
index still records each old version's level (`api_level`, [mod-index.md](mod-index.md)), for the modpacks that
|
||||
require those mods.
|
||||
|
||||
## Stability levels
|
||||
|
||||
@@ -72,7 +61,7 @@ Every part of an API has one level, shown in its reference and its surface page.
|
||||
## Deprecation and removal
|
||||
|
||||
1. The pull request that deprecates marks it where it is defined (a `/// Deprecated: use X; goes in 0.3.` doc comment,
|
||||
WIT's `@deprecated`, a `deprecated` field in a tool's schema) and adds the replacement in the same release.
|
||||
a `deprecated` field in a tool's schema) and adds the replacement in the same release.
|
||||
2. The reference shows it as deprecated, and the surface page's changelog says what replaces it and when it goes.
|
||||
3. Using it logs one warning per mod, session or client, naming the replacement; it keeps working.
|
||||
4. It is removed only after the window, and only at the next line (preview) or major (stable).
|
||||
@@ -85,7 +74,8 @@ Every preview and stable API has tests that run old users against the current co
|
||||
|
||||
| API | The test |
|
||||
| --- | --- |
|
||||
| Mods | a test mod per served line, built from source against that line's WIT and SDK, loaded by today's runtime with expected output (`crates/horizon-mods/tests/`); a line is served as long as its test mod passes |
|
||||
| Content mods | the content template builds and checks with `cargo horizon-mod`, and `mod_content`'s tests load content mods; a line is served as long as they pass |
|
||||
| Native mods | hook points are only ever added, and a test keeps each one hookable (`tests::hook_points_stay_hookable`, `game/native_hooks.rs`) |
|
||||
| Agent control | a transcript per protocol version: commands and the reply keys each must have |
|
||||
| Server | `api::ROUTES` against `route()` (generated reference); a `server.toml` from each release line parses to the same settings; golden replies per endpoint |
|
||||
| Launcher | the generated reference; golden `--json` output per command |
|
||||
@@ -103,14 +93,9 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
|
||||
| Part | Level | Version |
|
||||
| --- | --- | --- |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `command`, `events` | preview | 0.1.0 |
|
||||
| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` |
|
||||
| Mod API: `render` (HUD shapes, camera shake and zoom; drawn by the game, no mod shaders) | experimental | manifest `experimental = ["render"]` |
|
||||
| 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 | |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
|
||||
| Native mods (horizon-mixins bundles, [native-mods.md](../native-mods.md)) | experimental | the pinned revisions |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`, `check`) | preview | none yet (unversioned) |
|
||||
| Shader API | not started | |
|
||||
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
|
||||
| The game's own control-socket commands outside `horizon-ctl`'s table (test hooks) | experimental | |
|
||||
@@ -121,21 +106,3 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Telemetry batch and consent | stable | `"schema": 1`, consent `2` |
|
||||
| `horizon-graphics.txt`, `horizon-extras.txt`, `horizon-mods.txt` | preview | none |
|
||||
| `SHA256SUMS` and `SHA256SUMS.sig` | stable | |
|
||||
|
||||
### The experimental mod APIs' choices
|
||||
|
||||
- **What a mod may take from the game.** `input.claim-wheel` is the one input a mod consumes: while it holds the
|
||||
wheel, the steps are its `wheel` events and the hotbar doesn't move. `claim-items` takes the use button only on the
|
||||
items it names. A mod's keys (`register-key`) never consume: vanilla's mapping on the same key still runs. Whether
|
||||
keys may consume too is decided before `input` becomes preview.
|
||||
- **`input`'s `wheel` event** is a new case in a variant the game sends, so it breaks mods built before it (allowed
|
||||
because `input` is experimental): an `input` mod that calls `poll` needs a rebuild against the new WIT.
|
||||
- **`render.set-minimap`** draws a map the game makes, not one a mod composes: reading the blocks around the player
|
||||
for a map image costs too much through hook calls, so the game keeps the tiles (vanilla's map colours) and the mod
|
||||
picks the corner, side, scale and shape. One map shows at a time, the first loaded mod's. It is a new function and a
|
||||
new type, so mods built before it keep loading.
|
||||
- **`render.set-far-terrain`** turns on the game's own distant terrain, not one a mod builds: sampling the generator
|
||||
and meshing thousands of far tiles runs on the game's workers within its frame and upload budgets, which no hook
|
||||
call could match. The mod picks the distance (64 to 1024 chunks, capped lower on integrated graphics as LOD Distance
|
||||
is) and Far Detail's Reduced. The player's own LOD Distance, when on, wins; one far terrain shows at a time, the
|
||||
first loaded mod's.
|
||||
+11
-13
@@ -10,10 +10,10 @@ again restores vanilla's behaviour completely.
|
||||
| Part | Code |
|
||||
|---|---|
|
||||
| Settings, parsing and saving | `crates/horizon-client-render/src/extras.rs` |
|
||||
| FPS and click counters (for mods) | `extras.rs` (pure logic, unit tested) |
|
||||
| FPS and click counters (for native mods) | `extras.rs` (pure logic, unit tested) |
|
||||
| Dynamic lights: item glow, nearest sources | `crates/horizon-client-render/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) |
|
||||
| HUD drawing (a mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
|
||||
| Minimap colours, tiles and image (for `render.set-minimap`) | `crates/horizon-client-render/src/minimap.rs` |
|
||||
| HUD drawing (a native mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
|
||||
| Minimap colours, tiles and image (for the Minimap native mod) | `crates/horizon-client-render/src/minimap.rs` |
|
||||
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` |
|
||||
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` |
|
||||
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
|
||||
@@ -60,18 +60,16 @@ in Controls, and adding one would put a non-vanilla mapping into `options.txt`.
|
||||
- Off: nothing is gathered and the shaders' loop runs zero times (the count is 0), so it costs
|
||||
nothing measurable (numbers in the pull request, #814).
|
||||
|
||||
### Moved to the mod browser
|
||||
### Moved to native mods
|
||||
|
||||
The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)),
|
||||
with the same look and options. On the first start after the update, a player who had one on gets the mod installed
|
||||
from Horizon's registry and turned on, with their corner and options carried over (Zoom's key too, into
|
||||
`horizon-mods.txt`, when it wasn't Z), and reads one line in the chat ("Zoom moved to the mod browser: installed for
|
||||
you"). The install is checked like the launcher's (size,
|
||||
SHA-256, signature, manifest); if it fails (offline), the next start tries again. `horizon-extras.txt` keeps the old
|
||||
keys and lists what was done in `movedToMods`. Code: `crates/horizon-client/src/moved_extras.rs`,
|
||||
`game/moved_extras.rs`.
|
||||
The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official native mods now (in
|
||||
horizon-rust/horizon-mods under `native/<name>/`, [mods.md](mods.md#horizons-official-mods)), with the same look and
|
||||
options. Players install them from the launcher's Mods page, and the launcher builds them into the game
|
||||
([native-mods.md](native-mods.md)). They were WebAssembly mods for a while; those don't run any more. The game keeps
|
||||
the hook points they use (`crates/horizon-client/src/game/native_hooks.rs`), and `horizon-extras.txt` keeps the old
|
||||
keys and `movedToMods`.
|
||||
|
||||
The game still draws the minimap, for the mod (`render.set-minimap`, [mods.md](mods.md)):
|
||||
The game still draws the minimap, for the native mod (`native_minimap` in `native_hooks.rs`, [mods.md](mods.md)):
|
||||
- a corner map (top right by default), square or round, small/medium/large, zoom 0.5 to 4 blocks per pixel, rotating
|
||||
with the player or north up, with the player arrow (vanilla's map decoration sprite);
|
||||
- vanilla's `MapColor` per block, as map items use, with the map item's height shading and water-depth dithering;
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Coverage and detail improve progressively; a large selected radius does not mean
|
||||
|
||||
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
|
||||
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. The official Far View mod (`mods/official/far-view`, [mods.md](mods.md)) turns the same distant terrain on from the mod browser, with its own distance and detail; the setting, when on, wins. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. Far Detail (Full or Reduced) sets how finely the distant terrain is drawn (Reduced draws about a quarter of the far quads). Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
|
||||
Anti-aliasing cycles Off, FXAA and TAA (`fxaa` and `taa` in `horizon-graphics.txt`). TAA jitters the world's projection by a sub-pixel offset each frame (Halton 2, 3 over 8 frames) and blends the frame into a history reprojected through the depth buffer, clamped to the frame's 3×3 neighbourhood. Its motion vectors come from the camera alone, so mobs and other moving things rely on the clamp and can trail slightly. It runs at the resolution scale, before the hand and HUD, and is off under a shader pack, which does its own.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Not every line is read by a person. Instead, every change is checked against the
|
||||
- **Built from the public code.** Releases are built by GitHub Actions, the build service of GitHub, from the public source. Linux and Windows are built on GitHub's machines, and macOS on Ludovic's Mac mini, which runs GitHub Actions jobs. Every build log is [public](https://github.com/horizon-rust/horizon/actions/workflows/release.yml).
|
||||
- **Signed checksums.** Every release has a `SHA256SUMS` file, a fingerprint of each download, and `SHA256SUMS.sig`, a signature of that list made with Horizon's release key, which only release builds from main can use. The launcher and `horizon-server update` check both before they install anything, and refuse a file that does not match. The very first server install with `install.sh` checks the fingerprint but not the signature.
|
||||
- **Not yet signed by Apple or Microsoft.** The macOS app is not notarized by Apple, because it is not signed with a paid Apple developer account, so macOS asks you to confirm the first time ([Install](https://playhorizon.dev/docs/install#download-the-launcher) shows how). The Windows programs are not code-signed either, so Windows may warn you too.
|
||||
- **Mods run in a sandbox.** Horizon mods are WebAssembly programs with no access to your files, the network or other programs. A mod lists what it needs, you see that list before you turn it on, and it can do nothing else. See [Mods](mods.md).
|
||||
- **Mods are off by default, and reviewed.** A content mod is data and runs no code. A native mod is Rust code with your rights, not sandboxed: the launcher builds it only after a warning and your OK, in a game of its own, and the registry lists one only after a maintainer has read its source. See [Mods](mods.md) and [Native mods](native-mods.md).
|
||||
- **Nothing collected unless you say yes.** Data collection is off until you turn it on, with four separate switches. The one exception is an anonymous "I'm playing" ping for the launcher's player count, which holds nothing about you. See [Opt-in data collection](telemetry.md).
|
||||
|
||||
## What it doesn't contain
|
||||
|
||||
+11
-7
@@ -8,8 +8,8 @@ registry for Horizon's own mods, modpacks, resource packs and shader packs; see
|
||||
|
||||
Horizon doesn't run Java code ([mods.md](mods.md)). It ports what a Java mod
|
||||
carries as data (blocks, items, recipes, loot tables, tags, worldgen, textures, names) with `horizon-port-pack`, and
|
||||
says before install which of the mod's features that leaves out. Horizon `.hmod` mods aren't on Modrinth or CurseForge
|
||||
yet (Modrinth has no `horizon` loader).
|
||||
says before install which of the mod's features that leaves out. Horizon's own mods (content mods, `.hmod`, and native
|
||||
mods, `.hnmod`) aren't on Modrinth or CurseForge yet (Modrinth has no `horizon` loader).
|
||||
|
||||
| Kind | Goes to | How well it runs |
|
||||
|---|---|---|
|
||||
@@ -160,14 +160,15 @@ horizon-launcher mods remove faithful-32x
|
||||
|
||||
## Sources
|
||||
|
||||
**Horizon's registry.** Mods (`.hmod`), modpacks (`.hpack`), resource packs and shader packs (`.hshader`) made for Horizon, reviewed as pull requests in
|
||||
**Horizon's registry.** Content mods (`.hmod`), native mods (`.hnmod`), modpacks (`.hpack`), resource packs and shader packs (`.hshader`) made for Horizon, reviewed as pull requests in
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods) ([api/mod-index.md](api/mod-index.md)). The
|
||||
launcher reads its `index.json` (`browser/horizon.rs`; `HORIZON_MOD_INDEX=<file or https address>` reads a test
|
||||
registry instead, whose files may then be on disk) at most every ten minutes, and keeps the last copy in
|
||||
`mod-browser/horizon-index.json`. Before a result is installed it shows whether its files are signed and, for a mod,
|
||||
what it may do, in the words of the game's Options > Mods. A download goes to `mod-browser/downloads/` and is moved
|
||||
into place only after `horizon_mods::index::check_file` passes (size, SHA-256, the author's signature when the project
|
||||
has a key, the file's own manifest against the index). A mod becomes `horizon-mods/<id>.hmod`, turned on;
|
||||
has a key, the file's own manifest against the index). A content mod becomes `horizon-mods/<id>.hmod`, turned on; a native mod becomes
|
||||
`horizon-native-mods/<id>.hnmod`, and the launcher builds it into the game ([native-mods.md](native-mods.md));
|
||||
`mods disable` and `mods enable` switch it in `horizon-mods.txt`. When the index takes a file down, the launcher turns
|
||||
it off the next time it reads the index or starts the game, says why, and refuses to install it or turn it back on.
|
||||
A Horizon modpack (`.hpack`) installs as its own profile (`browser/horizon/pack.rs`): `horizon-packs/<id>/` holds the
|
||||
@@ -177,11 +178,14 @@ settings) are copied in on the first install only, so the player's changes survi
|
||||
down installs nothing. It is listed on the Modpacks tab with **Play** and **Remove**, and plays like an imported pack
|
||||
(`modpacks::apply_profile`). A shader pack goes to `shaderpacks/<file>` once `check_file` has run the game's own
|
||||
check on it (`horizon_shader_api::check`); a taken-down one is named, for the player to remove.
|
||||
A registry `.hmod` that has WebAssembly code (any capability besides `registries`) "needs a native port": Horizon doesn't
|
||||
run it any more. The browser neither lists nor installs it alone. A modpack that requires one still installs, and the
|
||||
launcher lists the mod as not running, with that reason, and leaves out its code.
|
||||
|
||||
```sh
|
||||
horizon-launcher mods search --source horizon --kind mod coords # with whether it is signed and what it may do
|
||||
horizon-launcher mods install --source horizon coords_hud
|
||||
horizon-launcher mods disable coords_hud
|
||||
horizon-launcher mods search --source horizon --kind mod <words> # with whether it is signed and what it may do
|
||||
horizon-launcher mods install --source horizon <id>
|
||||
horizon-launcher mods disable <id>
|
||||
horizon-launcher mods install --source horizon cozy # a modpack: its own profile
|
||||
horizon-launcher modpack play cozy
|
||||
```
|
||||
|
||||
+25
-24
@@ -7,7 +7,7 @@ pipeline, not a list of hand ports. It takes a pack as players download it and p
|
||||
Horizon mods. The pipeline is AI agents in dependency order, each port checked against the
|
||||
original mod running in a dev-only Java instance.
|
||||
|
||||
This page is the design. It builds on [mods.md](mods.md) (the mod API, WASM in wasmtime, and
|
||||
This page is the design. It builds on [mods.md](mods.md) (content mods, native mods, and
|
||||
why Java jars cannot run in Horizon, so we port instead). The ATM10 inventory behind the numbers below is
|
||||
[modpacks/atm10-mods.csv](modpacks/atm10-mods.csv).
|
||||
|
||||
@@ -25,8 +25,8 @@ why Java jars cannot run in Horizon, so we port instead). The ATM10 inventory be
|
||||
│
|
||||
├─ carry-over (mechanical, no agent): assets/ + data/ upgraded to 26.3 formats,
|
||||
│ block and item registrations from the oracle's facts export → <mod>.hmod
|
||||
├─ port (one agent per mod, tiers logic and up): Rust → WASM against the Horizon
|
||||
│ mod API, iterating until its harness passes → <mod>.hmod
|
||||
├─ port (one agent per mod, tiers logic and up): a Rust native mod (a horizon-mixins
|
||||
│ bundle), iterating until its harness passes → <mod>.hnmod
|
||||
├─ harness per mod: the Java mod in a dev-only NeoForge/Fabric oracle vs the port
|
||||
▼
|
||||
coverage report: ported · partial · not yet · not needed, per mod and per class
|
||||
@@ -56,7 +56,7 @@ was downloaded.
|
||||
| Class | What it is | Port? | ATM10 |
|
||||
|---|---|---|---:|
|
||||
| **content** | adds or changes gameplay: blocks, items, machines, mobs, worldgen, magic | yes: carry-over or agent | **287** |
|
||||
| **library** | APIs and code shared by other mods (Architectury, Curios, GeckoLib, Kotlin for Forge, Balm...) | no: maps to the Horizon API or the SDK | **80** |
|
||||
| **library** | APIs and code shared by other mods (Architectury, Curios, GeckoLib, Kotlin for Forge, Balm...) | no: maps to Horizon's own code or a native mod | **80** |
|
||||
| **integration** | glue between two other mods (JEI plugins, AE2 ↔ Mekanism, Create add-on wiring) | after both sides are ported | **44** |
|
||||
| **client-ui** | client-only screens, HUDs, tooltips, maps, recipe viewers | yes: client API, or built in | **32** |
|
||||
| **other** | pack tooling and engine features: KubeJS, Almost Unified, shader mods, CTM, crash/version tools | no: folded into the pipeline or the engine | **25** |
|
||||
@@ -153,12 +153,15 @@ derivative work. These rules are part of the pipeline, not advice to users:
|
||||
This is engineering policy, not legal advice. Before anything is published, Ludovic decides,
|
||||
with advice if needed.
|
||||
|
||||
## The mod API: what packs need, against API 0.1
|
||||
## What packs need, and what runs today
|
||||
|
||||
API 0.1 is client-side only: `log`, `game` (player and clock), `chat`, `hud`, plus tick,
|
||||
frame, chat and command hooks. Server mods do not run yet. What a content pack needs:
|
||||
Content mods (`.hmod`, API 0.2) carry data: blocks, items, and everything in the 26.3 data-pack and resource-pack
|
||||
formats ([mods.md](mods.md#content-mods-api-02)). Behaviour that needs code is not a content-mod feature: it is
|
||||
written as a native mod ([native-mods.md](native-mods.md)), one per ported mod. The "Planned in" column keeps the
|
||||
order of the original plan. The rows planned for 0.3 and later were designed as WebAssembly interfaces, which Horizon
|
||||
dropped: their "Shape" column is history, and a native mod hooks the game's code instead. What a content pack needs:
|
||||
|
||||
| Feature | API 0.1 | Needed by (ATM10 content+integration, by tier) | Planned in | Shape |
|
||||
| Feature | Content mod today | Needed by (ATM10 content+integration, by tier) | Planned in | Shape |
|
||||
|---|---|---|---|---|
|
||||
| Block registry: ids, states, properties, shapes, sounds, light | — | data and up (all 331) | **0.2** | declared in `data/<mod>/horizon/block/*.json`, each from a vanilla *template* block (below) |
|
||||
| Item registry, block items | — | data and up | **0.2** | `data/<mod>/horizon/item/*.json`, from a vanilla template item |
|
||||
@@ -172,22 +175,20 @@ frame, chat and command hooks. Server mods do not run yet. What a content pack n
|
||||
| Capabilities: energy (FE), item, fluid handlers on blocks and items | — | machines and up | **0.4** | `capabilities` interface; FE as the common unit, others as mod-defined resources |
|
||||
| Data attachments on entities, chunks, levels | — | machines and up | **0.4** | `storage` per entity/chunk/level |
|
||||
| Accessory slots (what Curios does) | — | Curios users (6 require it, more use it optionally) | **0.4** | built-in slots API |
|
||||
| Mod networking between its client and server halves | — | machines and up | **0.4** | `net` interface (already planned in mods.md) |
|
||||
| Mod networking between its client and server halves | — | machines and up | **0.4** | `net` interface |
|
||||
| Custom recipe types: machine recipes with their own JSON | — | machines and up | **0.4** | recipe-type registry: the mod declares its JSON schema and a display layout for the recipe viewer |
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable |
|
||||
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
|
||||
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
|
||||
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
|
||||
| Client interfaces: `keys`, `target`, `inventory-read`, `ui`, `render`, `world-read` | planned | client tier (38) | 0.2–0.4 | as in [mods.md](mods.md#planned-interfaces) |
|
||||
| Client interfaces: `keys`, `target`, `inventory-read`, `ui`, `render`, `world-read` | — | client tier (38) | native mods | hook points in the game, as the official native mods use ([extras.md](extras.md)) |
|
||||
|
||||
Two rules from mods.md stay: **the sandbox** (no files, sockets or processes; memory cap; fuel
|
||||
per call; a crashing mod stops alone) and **free when unused** (no mods means vanilla code
|
||||
paths and the vanilla resource root, so the parity harnesses test the same game).
|
||||
One rule stays: **free when unused** (no mods means vanilla code paths and the vanilla resource root, so the parity
|
||||
harnesses test the same game).
|
||||
|
||||
### Registries 0.2: data-driven blocks and items
|
||||
|
||||
A `.hmod` may now carry `assets/` and `data/` beside (or instead of) `mod.wasm`. A content-only
|
||||
mod needs no code:
|
||||
A content mod (`.hmod`) carries `assets/` and `data/` and no code:
|
||||
|
||||
```
|
||||
ruby.hmod
|
||||
@@ -713,14 +714,14 @@ For tiers logic, machines and deep, in dependency order, as many agents at once
|
||||
budget allows:
|
||||
|
||||
- **Input:** the inventory record, the carried-over data, the oracle's exports and traces, the
|
||||
harness, the Horizon mod API docs, the ports of its dependencies, and the mod's public
|
||||
harness, [native-mods.md](native-mods.md), the ports of its dependencies, and the mod's public
|
||||
documentation. Never its code: the agent doesn't decompile, read or quote it.
|
||||
- **Output:** a Rust crate in `mods/<id>/port/` built with `cargo horizon-mod build`, and its
|
||||
`.hmod`.
|
||||
- **Output:** a native mod, a horizon-mixins bundle started from
|
||||
[horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template), and its `.hnmod`.
|
||||
- **Loop:** write → build → run the harness → read the differences → fix, until the harness
|
||||
passes or the agent reports what blocks it. Usually that is an API gap, which becomes an
|
||||
issue against the mod API ("needs a scheduled-tick hook"), the way version ports turn
|
||||
differences into work items.
|
||||
passes or the agent reports what blocks it. Usually that is a place in the game's code the
|
||||
mixins can't reach yet, which becomes an issue ("needs a scheduled-tick hook point"), the way version ports
|
||||
turn differences into work items.
|
||||
- **Rules:** the agent writes the port from observed behaviour, the harness and the mod's
|
||||
own data files only; ports follow the mod's licence ([Licences](#licences)).
|
||||
Players without an AI agent get the carry-over tiers. A port the licence lets us publish can
|
||||
@@ -1002,8 +1003,8 @@ In scope (each is a PR or a small stack):
|
||||
Out of scope for that date, and next in order: the rest of worldgen carry-over (mods'
|
||||
structures, sets, pools and features generate now, see step 4; mod biomes, noise and density
|
||||
functions, and the structure types written in Java such as YUNG's and Formations', do not yet),
|
||||
behaviour callbacks and server events (0.3), block entities, menus, capabilities and net
|
||||
(0.4), the first agent-ported logic-tier mod as the pilot of step 5, then machines. Porting all
|
||||
behaviour callbacks and server events, block entities, menus, capabilities and net, as
|
||||
native mods, the first agent-ported logic-tier mod as the pilot of step 5, then machines. Porting all
|
||||
of ATM10 is a campaign. Each step publishes its coverage number; we make no claim beyond what
|
||||
the report shows.
|
||||
|
||||
@@ -1038,7 +1039,7 @@ ATM10 (the launcher's `--dev-mod` carries), seeds 1234 and 2.
|
||||
What is missing, ranked by how much a player notices:
|
||||
|
||||
1. **Code mods.** 333 of ATM10's 496 mods (machines, magic, mobs, tools, storage): the pack's
|
||||
gameplay. They need behaviour APIs (0.3 and later) and a port per mod.
|
||||
gameplay. They need a native port per mod.
|
||||
2. **The 12 mods only on CurseForge**, which the carry has not fetched.
|
||||
3. **Plainer structures.** The 605 dropped processors are what makes structures look aged,
|
||||
overgrown or fitted to their biome in Java: Formations' biome swaps and block ageing (its
|
||||
|
||||
+116
-590
@@ -1,389 +1,77 @@
|
||||
# Horizon mods
|
||||
|
||||
Horizon gets its own mod system, written in Rust and run in a sandbox, plus
|
||||
two ways to bring over what Java players use: native Horizon ports of popular
|
||||
mods and the data-driven parts of packs and mods. Fabric and NeoForge jars do
|
||||
**not** run as they are: their code calls and patches Minecraft's Java classes, and
|
||||
Horizon has none. What carries over is the data and assets inside a jar
|
||||
([modpacks.md](modpacks.md)).
|
||||
Two kinds of mod run in Horizon:
|
||||
|
||||
Mods are **off and absent by default**. A fresh install has no `horizon-mods`
|
||||
folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| Kind | File | What it is | Where it runs |
|
||||
|---|---|---|---|
|
||||
| **Content mod** | `.hmod` | Data and assets: blocks, items, recipes, loot, tags, models, textures, structures. No code. | Read when the game starts |
|
||||
| **Native mod** | `.hnmod` | A [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle: Rust that changes the game's own source before it is compiled. | In a game the launcher builds for the player ([native-mods.md](native-mods.md)) |
|
||||
|
||||
| Part | Where | State |
|
||||
|---|---|---|
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; Far View (`far-view`), distant terrain; and Recipes, a recipe viewer |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, modpacks |
|
||||
| Registry index (`index.json`, `.hpack`), signatures | `horizon_mods::index`, [api/mod-index.md](api/mod-index.md) | schema 1; launcher and website next |
|
||||
| Server mods, registries | this page, [Roadmap](#roadmap) | planned |
|
||||
Fabric and NeoForge jars do **not** run as they are: their code calls and patches Minecraft's Java classes, and
|
||||
Horizon has none. What carries over is the data and assets inside a jar ([modpacks.md](modpacks.md)).
|
||||
|
||||
## Design goals
|
||||
|
||||
1. **Safe to install.** A mod is untrusted code. It runs in a WebAssembly
|
||||
sandbox with no file, network, process or clock access, a memory cap and a
|
||||
time budget per call. It can only do what its capabilities grant, and the
|
||||
player sees those before turning it on.
|
||||
2. **One file for every platform.** The same `.hmod` runs on Windows, macOS
|
||||
and Linux, x86-64 and ARM: authors build once, players never meet "wrong
|
||||
build for my OS".
|
||||
3. **Free when unused.** No mod on: no engine, no thread, no compile, one
|
||||
branch per hook. Measured below.
|
||||
4. **Vanilla stays vanilla.** Mods add; they cannot patch the game's code the
|
||||
way Mixins do. What they may change is a list of hooks the game calls at
|
||||
fixed points, so the parity harnesses keep testing the same game.
|
||||
5. **Stable for authors.** The contract is a versioned WIT package; a mod
|
||||
built for API 0.1 keeps loading on later 0.1.x games.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
mod crate (Rust) Horizon
|
||||
─────────────── ───────
|
||||
impl Mod for MyMod { ... } horizon-mods::Mods
|
||||
export_mod!(MyMod) ├─ Engine (cranelift), made on first mod
|
||||
│ cargo horizon-mod build ├─ one Store per mod: 64 MiB, fuel per call
|
||||
▼ ├─ Linker: log, game, chat, hud (WIT imports)
|
||||
my_mod.hmod = zip { └─ calls the events export: init, tick,
|
||||
horizon-mod.toml, frame, command, chat
|
||||
mod.wasm (a WebAssembly component)
|
||||
}
|
||||
```
|
||||
|
||||
- **WebAssembly components.** Mods compile to `wasm32-unknown-unknown`;
|
||||
`cargo horizon-mod build` wraps the module into a
|
||||
[component](https://component-model.bytecodealliance.org/) for the
|
||||
`horizon:mods/horizon-mod` world. The WIT file is the whole API: host
|
||||
functions the mod imports (`log`, `game`, `chat`, `hud`) and the `events`
|
||||
interface it exports. No WASI: the component cannot even name a file or a
|
||||
socket.
|
||||
- **Capabilities are imports.** Every imported interface except `log` is a
|
||||
capability. The manifest declares them; the game reads the component's
|
||||
actual imports and refuses a mod whose code imports one its manifest does not
|
||||
declare (`uses capability chat, missing from horizon-mod.toml`). So the list
|
||||
the player reads is the truth, not a promise.
|
||||
- **Hooks on request.** `init` returns a `registration`: the commands the mod
|
||||
owns and which of `tick`, `frame` and `chat` it wants. The game calls only
|
||||
those. A mod that wants nothing per frame costs nothing per frame.
|
||||
- **Retained HUD.** A mod sets its panel (corner and coloured lines) when it
|
||||
changes; the game draws it every frame with the extras' overlay code. Most
|
||||
HUD mods need no frame hook at all.
|
||||
- **Snapshots, not callbacks into the world.** Once per tick (and before a
|
||||
command) the game copies what mods may read (player position, look, health,
|
||||
food, dimension, world clock) into a snapshot shared by all mods. Mods never
|
||||
hold references into the game, so they cannot stall or corrupt it.
|
||||
|
||||
### What a mod can do (API 0.1)
|
||||
|
||||
| Interface | Capability | Functions |
|
||||
|---|---|---|
|
||||
| `log` | always | `write(level, message)`, in the game log as `[mod <id>]` |
|
||||
| `game` | `game-read` | `get-player()`: x y z, yaw, pitch, health, food, dimension; `get-clock()`: game time, day time; `raycast(distance)`: the first block along the view (fluids pass), up to 1024 blocks and across the distant terrain, with the point hit and the block's id |
|
||||
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); `set-far-terrain(far-terrain)`: the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
|
||||
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
|
||||
|
||||
| Hook (export) | When |
|
||||
|---|---|
|
||||
| `init() -> result<registration, string>` | once, after loading; an error keeps the mod off and is shown |
|
||||
| `tick(tick)` | each game tick while in a world (20 per second) |
|
||||
| `frame(seconds)` | each rendered frame |
|
||||
| `command(name, args) -> result<option<string>, string>` | `/name args` typed in chat, for a command the mod registered |
|
||||
| `chat(text)` | each chat line the player receives, as plain text |
|
||||
|
||||
Commands: vanilla's command names win. A mod asking for `/give` does not get
|
||||
it (logged), and two mods cannot share a name (the first one loaded keeps it).
|
||||
The chat box colours and Tab-completes a mod's commands like vanilla's, for
|
||||
every player whatever their permission level, and takes any text after the
|
||||
name (the mod reads it as `args`; API 0.1 has no way to declare argument types).
|
||||
|
||||
### Planned interfaces
|
||||
|
||||
Each is a new capability, added in a 0.x minor version so older mods keep
|
||||
loading. Ordered by what the first native ports need:
|
||||
|
||||
| Interface | For | Notes |
|
||||
|---|---|---|
|
||||
| `keys` | keybinds | registered binds appear in Controls; press/release events |
|
||||
| `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips |
|
||||
| `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers |
|
||||
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
|
||||
| `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints |
|
||||
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
|
||||
| `server` | world events (entity spawn/death), game rules, world read/write, typed server commands | after [server mods 0.3](#server-mods-api-03-experimental)'s players, chat, blocks and commands |
|
||||
| `net` | a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
|
||||
|
||||
There is no file, socket, process or raw-input interface, on purpose.
|
||||
|
||||
## Choosing the runtime
|
||||
|
||||
| Option | Sandbox | One file for all OSes | Typed API | Cost | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **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 |
|
||||
| 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 |
|
||||
|
||||
wasmtime 48 is a long-term-support line (security fixes until August 2028)
|
||||
and needs Rust 1.95. It reads WebAssembly with the same wasm-tools release
|
||||
(0.254) as the `wit-component` that packs mods and the SDK's `wit-bindgen`, so
|
||||
the build carries one copy of them. Only its `runtime`, `cranelift`,
|
||||
`component-model` and `std` features are on: no WASI, no async, no GC
|
||||
proposal, no profiling. Its cranelift backend generates native code for x86-64
|
||||
and ARM64 on Windows, macOS and Linux, which is why one `.hmod` serves them
|
||||
all.
|
||||
|
||||
The game links it only with its `mods` feature: every release build turns it
|
||||
on (`release.yml`), and a build from source needs
|
||||
`cargo build --release -p horizon-client --features mods` to run code mods.
|
||||
Without it (the tests and probes) wasmtime and cranelift aren't built, a
|
||||
quarter of a clean build; content mods still load, and the game logs why the
|
||||
code mods don't start.
|
||||
|
||||
### Performance
|
||||
|
||||
Measured on the Mac mini (M4 Pro), release build, `cargo test -p
|
||||
horizon-mods --features runtime,pack --release --test sandbox -- --ignored --nocapture`:
|
||||
|
||||
| | per call |
|
||||
|---|---|
|
||||
| tick with no mod loaded | 1.2 ns (one check) |
|
||||
| empty `tick` hook (call into the sandbox and back, fuel on) | 85.5 ns |
|
||||
| empty `frame` hook | 87.5 ns |
|
||||
| coords-hud's `tick` (reads the player and clock, formats 3 lines, compares) | 2.3 µs |
|
||||
| loading coords-hud (read, compile with cranelift, instantiate, `init`) | 50 ms, once |
|
||||
|
||||
In the game, same world and view, `horizon-ctl bench 10` three times each
|
||||
(4 other sessions shared the machine, so the tails are noisy; the median is
|
||||
the stable figure):
|
||||
|
||||
| | median frame | mean FPS |
|
||||
|---|---|---|
|
||||
| no `horizon-mods` folder | 2.62-2.71 ms | 149-163 |
|
||||
| coords-hud on | 2.72-2.78 ms | 151-254 |
|
||||
|
||||
At 20 ticks per second coords-hud costs 46 µs per second of game time: well
|
||||
under 0.01 ms per frame. The frame-time medians differ by less than the
|
||||
run-to-run spread.
|
||||
|
||||
**Budgets.** Each call gets fuel (about one unit per WebAssembly instruction):
|
||||
2 billion for `init`, 50 million for `tick`, 10 million for `frame`, 100
|
||||
million for a command or chat line. A runaway loop ends in a fraction of a
|
||||
second (the sandbox test's endless `/spin` is cut well within its 5 s limit)
|
||||
instead of hanging the game. The target budgets a mod should stay under are
|
||||
tighter: **1 ms per tick and 0.1 ms per frame**. The runtime keeps each mod's
|
||||
average call time (`Entry::cost`); the Mods page will show it, and a mod over
|
||||
budget for long will be flagged, then paused.
|
||||
|
||||
### Crash isolation
|
||||
|
||||
A trap (panic, out-of-bounds access, `unreachable`), running out of fuel or
|
||||
hitting the 64 MiB memory cap stops **that mod only**. The game says so in
|
||||
chat (`Mod Test Guest stopped: it took too long (an endless loop?)`), marks it
|
||||
on the Mods page and carries on; the other mods keep running. Host functions
|
||||
clip what a mod hands back (chat lines per call, HUD lines and lengths). Tests:
|
||||
`crates/horizon-mods/tests/sandbox.rs` (an endless loop, a panic and a 1 GiB
|
||||
allocation, next to a healthy mod; `cargo test -p horizon-mods --features
|
||||
runtime,pack`).
|
||||
Mods are **off by default**. A fresh install has no `horizon-mods` folder and plays exactly like vanilla 26.3.
|
||||
|
||||
## Playing with mods
|
||||
|
||||
1. Put `.hmod` files (or mod folders holding `horizon-mod.toml` and
|
||||
`mod.wasm`) in `horizon-mods/` beside `options.txt` (in the launcher's game
|
||||
folder).
|
||||
2. Options > **Mods** lists them with what each may do ("May see your
|
||||
position, health and the time, draw text on your screen, ...") and a
|
||||
switch. They start off; the choice is kept in `horizon-mods.txt`
|
||||
(`mod.<id>:true`).
|
||||
3. A mod that failed to load or stopped shows why, in red, on its row.
|
||||
1. Install a mod from the launcher's Mods page ([mod-browser.md](mod-browser.md)), or put its files by hand in the
|
||||
game folder (the one holding `options.txt`): a content mod's `.hmod` (or a folder holding `horizon-mod.toml`) in
|
||||
`horizon-mods/`. Native mods go in `horizon-native-mods/`, and the launcher builds them
|
||||
([native-mods.md](native-mods.md)).
|
||||
2. Options > **Mods** lists them with what each may do ("May add blocks and items to your worlds") and a switch.
|
||||
They start off. The choice is kept in `horizon-mods.txt` (`mod.<id>:true`).
|
||||
3. A content mod's blocks and items load once, when the game starts: restart the game after a switch.
|
||||
4. A mod that can't load shows why, in red, on its row.
|
||||
|
||||
Two turned-on mods never share an `id` while running (it is also capability
|
||||
`storage`'s directory): the one `find` lists first starts, the other stays
|
||||
off with the reason on its row.
|
||||
Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with
|
||||
the reason in the log.
|
||||
|
||||
Benchmarks (`--benchmark`) never load mods.
|
||||
|
||||
## Making a mod
|
||||
## Making a content mod
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
|
||||
cargo horizon-mod new my-mod # Cargo.toml, horizon-mod.toml, src/lib.rs, tests/hello.toml
|
||||
# (--template content: a content mod, no code)
|
||||
cargo horizon-mod new my-mod # a content mod: manifest, one block with its model, loot and recipe
|
||||
cd my-mod
|
||||
cargo horizon-mod run # build, then start the game with it
|
||||
cargo horizon-mod build # again after an edit: the running game reloads it
|
||||
cargo horizon-mod test # build, then run tests/*.toml with no game
|
||||
cargo horizon-mod run # build my-mod.hmod, then start the game with it turned on
|
||||
cargo horizon-mod build # only build: target/horizon-mod/my_mod.hmod
|
||||
cargo horizon-mod check target/horizon-mod/my_mod.hmod # the registry's checks; --json for JSON
|
||||
```
|
||||
|
||||
`run` starts `horizon-client` (on `PATH`, or `--game PATH`, or
|
||||
`HORIZON_CLIENT`) with `--dev-mod target/horizon-mod/<id>.hmod`. A dev mod is
|
||||
always on, and the game checks its file once a second: rebuild and it restarts
|
||||
with "Mods reloaded" in chat. Its in-memory state starts over; whatever it
|
||||
saved with capability `storage` survives the reload.
|
||||
|
||||
Before you submit it to the registry, run what its CI runs:
|
||||
|
||||
```sh
|
||||
cargo horizon-mod check target/horizon-mod/my_mod.hmod # --json for the report as JSON
|
||||
```
|
||||
|
||||
It reads the manifest and content, refuses imports the capabilities don't
|
||||
cover, then plays the mod in the sandbox for 40 ticks, 120 frames, a chat line
|
||||
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`.
|
||||
|
||||
### Testing a mod
|
||||
|
||||
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
|
||||
the sandbox the game uses, with no game and no window: the test plays the
|
||||
game's part (the player and the clock the mod reads, ticks, frames, chat lines,
|
||||
commands; for a server mod, players joining, chatting, typing commands,
|
||||
breaking and placing blocks and leaving) and checks what the mod showed. A script fails at its first wrong
|
||||
step, with what it expected and what it got; the command fails if any script
|
||||
does. `--debug` builds without optimizing, `--hmod <file>` tests a `.hmod`
|
||||
already built, and a name runs only the scripts whose path holds it. Each
|
||||
script gets a scratch copy of the mod, so its `storage` starts empty and is
|
||||
removed afterwards.
|
||||
|
||||
```toml
|
||||
# tests/panel.toml (examples/coords-hud)
|
||||
[player] # where the script starts; unset fields keep the defaults
|
||||
x = 12.5 # (0.5 64 0.5, yaw 0, 20 health and food, the Overworld)
|
||||
z = -3.25
|
||||
yaw = 180.0
|
||||
|
||||
[clock] # day-time 0 is 6:00 on day 1; each tick adds one
|
||||
day-time = 29999
|
||||
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.panel = ["XYZ 12.5 / 64.0 / -3.2", "Facing North (-Z)", "Day 2, 12:00"]
|
||||
expect.corner = "top-left"
|
||||
|
||||
[[step]]
|
||||
command = "coords"
|
||||
expect.feedback = "Coordinates panel hidden"
|
||||
```
|
||||
|
||||
A step's parts happen in this order:
|
||||
|
||||
| Key | Does |
|
||||
| --- | --- |
|
||||
| `reload = true` | stops the mod and starts it again, as a rebuild does in the game: its memory starts over, its `storage` stays |
|
||||
| `title-screen = true` | no player and no clock until a step sets them |
|
||||
| `blocks = [{ x = 0, y = 63, z = 0, state = "minecraft:stone" }]` | server mods with `world`: those blocks of the Overworld, before the rest of the step (every other block is air, every biome the dimension's plainest: plains, nether wastes, the end) |
|
||||
| `player = { x = 3.0, ... }`, `clock = { day-time = 0 }` | changes those fields (`x y z yaw pitch health food dimension`, `game-time day-time`) |
|
||||
| `ticks = n`, `frames = n` | that many ticks (the game's tick counter and the clock move on), then frames 1/60 s apart |
|
||||
| `chat = "<Steve> hi"` | a chat line the player receives |
|
||||
| `command = "where north"` | the player types `/where north`; a command the mod doesn't own fails the step |
|
||||
| `break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }`, `place = { ... }` | server mods: the player breaks or places that block |
|
||||
| `leave = "Alex"` | server mods: that player leaves |
|
||||
|
||||
A server mod starts with nobody on: `join = "Alex"` (after `reload` and `title-screen`) brings a
|
||||
player in at the defaults, in survival, with the id
|
||||
`00000000-0000-4000-8000-000000000001` for the first name that joined, `...2`
|
||||
for the second, the same id when a name joins again. `player`, `chat`,
|
||||
`command`, `break` and `place` are then that player's doing: the one named by
|
||||
`as = "Steve"`, or the first one on. `player` takes `game-mode` there, and the
|
||||
script's own `[player]` and `[clock]` are a client mod's.
|
||||
|
||||
Then `expect` checks what the mod did during the step; a key left out is not
|
||||
checked:
|
||||
|
||||
| `expect.` | Checks |
|
||||
| --- | --- |
|
||||
| `commands = ["where"]` | the commands the mod owns |
|
||||
| `chat = ["..."]` | every chat line it showed, in order (`[]`: none) |
|
||||
| `feedback = "..."`, `error = "..."` | the command's answer, shown plain or in red |
|
||||
| `panel = ["..."]`, `corner = "top-left"` | its HUD panel's lines (`[]`: no panel) and corner |
|
||||
| `told = ["Alex: Welcome!"]`, `broadcast = ["..."]` | server mods: every `players.tell` (as `name: text`) and `players.broadcast`, in order |
|
||||
| `blocks = [{ x = 2, y = 64, z = 0, state = "minecraft:glowstone" }]` | server mods with `world`: those blocks of the Overworld after the step, the mod's `set-blocks` made (the script checks a state's shape; the game checks its name) |
|
||||
| `stopped = "panic"` | the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test |
|
||||
|
||||
Unknown keys are errors, so a typo never passes silently. The same runner is
|
||||
`horizon_mods::testing` for Rust code that drives a mod (`TestGame`,
|
||||
`Script`): Horizon's own tests run the examples' scripts with it. Scripts test
|
||||
any mod, whatever it was written in.
|
||||
|
||||
### Template layout
|
||||
|
||||
```
|
||||
my-mod/
|
||||
├── Cargo.toml # cdylib, horizon-mod-sdk, release: opt-level "s", LTO
|
||||
├── horizon-mod.toml # the manifest
|
||||
├── src/lib.rs # impl Mod, export_mod!
|
||||
└── tests/hello.toml # cargo horizon-mod test
|
||||
```
|
||||
|
||||
A mod is a type implementing `horizon_mod_sdk::Mod`; its state lives in the
|
||||
type:
|
||||
|
||||
```rust
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct Hello { greeted: u32 }
|
||||
|
||||
impl Mod for Hello {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((Hello { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
horizon_mod_sdk::export_mod!(Hello);
|
||||
```
|
||||
|
||||
Hooks not asked for keep their default bodies. The full example is
|
||||
[`coords-hud`](../crates/horizon-mod-sdk/examples/coords-hud/src/lib.rs). Logic
|
||||
that does not touch the API (formatting, maths) is ordinary Rust and unit-tests
|
||||
on the host with `cargo test`.
|
||||
- `new <path> [--name "Name"]` writes the template. It makes content mods only: any other `--template` is refused.
|
||||
- `build` packs `horizon-mod.toml`, `assets/` and `data/` into `target/horizon-mod/<id>.hmod`, with the same checks
|
||||
the game makes when it reads the content. It refuses a folder with a `Cargo.toml` (code is native now) and a
|
||||
manifest without capability `registries`.
|
||||
- `run` builds, then starts `horizon-client` (on `PATH`, or `--game PATH`, or `HORIZON_CLIENT`) with
|
||||
`--dev-mod target/horizon-mod/<id>.hmod`, always on. Arguments after `--` go to the game. The game reads content once
|
||||
at start, so rebuild and run again after an edit.
|
||||
- `check <file>` runs what the registry runs: the file's size and SHA-256, its manifest and content, and a signature
|
||||
with `--key <public key> --signature <file>.minisig`. It reads `.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`
|
||||
and `.hshader` files, and refuses an `.hmod` with WebAssembly code.
|
||||
- `registry build|review|check|list` is the registry repository's CI ([api/mod-index.md](api/mod-index.md)).
|
||||
|
||||
### The manifest
|
||||
|
||||
```toml
|
||||
id = "coords_hud" # [a-z0-9_]{1,64}: settings key, log name
|
||||
name = "Coordinates HUD"
|
||||
id = "my_mod" # [a-z0-9_]{1,64}: settings key, log name
|
||||
name = "My mod"
|
||||
version = "0.1.0"
|
||||
description = "..."
|
||||
authors = ["..."]
|
||||
api = "0.1" # the API the SDK targets
|
||||
side = "client" # client | server | both
|
||||
capabilities = ["game-read", "hud", "chat"]
|
||||
experimental = [] # experimental capabilities it uses, e.g. ["ui"]
|
||||
api = "0.2" # content mods
|
||||
side = "both"
|
||||
capabilities = ["registries"]
|
||||
namespaces = [] # optional: more namespaces the mod's files use (below)
|
||||
```
|
||||
|
||||
Unknown keys are refused, so a typo is an error, not a silently ignored
|
||||
permission. An experimental capability (today `ui`, `render` and `input`) may change in any release
|
||||
([stability](api/stability.md)): a mod uses it only by naming it in
|
||||
`experimental` as well, so nobody depends on it by accident.
|
||||
Unknown keys are refused, so a typo is an error, not a silently ignored permission.
|
||||
|
||||
### Content mods (API 0.2)
|
||||
|
||||
A mod with `api = "0.2"` and capability `registries` may carry `assets/` and
|
||||
`data/` in the `.hmod`, and needs no `mod.wasm` if it has no code
|
||||
(`cargo horizon-mod new --template content` starts one; `build` in a folder
|
||||
with no `Cargo.toml` packs the manifest, `assets/` and `data/`, and `check`
|
||||
reads it without a trial run). It adds
|
||||
blocks and items by declaring them in `data/<id>/horizon/block/*.json` and
|
||||
`data/<id>/horizon/item/*.json`, each copying a vanilla template
|
||||
([modpacks.md](modpacks.md#registries-02-data-driven-blocks-and-items)):
|
||||
A content mod has capability `registries` and carries `assets/` and `data/` in the `.hmod`. It adds blocks and items
|
||||
by declaring them in `data/<id>/horizon/block/*.json` and `data/<id>/horizon/item/*.json`, each copying a vanilla
|
||||
template ([modpacks.md](modpacks.md#registries-02-data-driven-blocks-and-items)):
|
||||
|
||||
```json
|
||||
{ "template": "minecraft:iron_block",
|
||||
@@ -418,9 +106,8 @@ The fence: files go in the mod's own namespaces (`assets/<id>/`,
|
||||
share (`data/c/tags/`).
|
||||
Templates and `sounds` name vanilla blocks; unknown keys are refused.
|
||||
|
||||
Some Java mods ship their files under other names than their mod id (Towns
|
||||
and Towers, mod id `t_and_t`, under `kaisyn` and `towns_and_towers`). A
|
||||
content mod lists those in its manifest:
|
||||
Some Java mods ship their files under other names than their mod id (Towns and Towers, mod id `t_and_t`, under
|
||||
`kaisyn` and `towns_and_towers`). A content mod lists those in its manifest:
|
||||
|
||||
```toml
|
||||
id = "t_and_t"
|
||||
@@ -429,165 +116,56 @@ capabilities = ["registries"]
|
||||
namespaces = ["kaisyn", "towns_and_towers"]
|
||||
```
|
||||
|
||||
A listed namespace is the mod's as its id is: its `assets/` and `data/`
|
||||
files load, and the game reads its tags, recipes, names and loot. Blocks and
|
||||
items are still declared under `data/<id>/horizon/` only. `minecraft`, `c`
|
||||
and `horizon` cannot be listed. Two mods that are on may not own the same
|
||||
namespace (one's id or listed namespace being the other's): the game keeps
|
||||
the first in load order and leaves the other's content out, with
|
||||
"mods A and B both use the namespace N; turn one of them off" in the log. Paths
|
||||
use vanilla's resource characters, at most 65,536 files, 16 MiB each and
|
||||
512 MiB in all. A mod whose content is refused stays off, with the reason on
|
||||
its row. The game reads this content into its block and item tables
|
||||
(`horizon_client::mod_content`, docs/modpacks.md). A tag entry naming an id
|
||||
nobody registers (a block a port left out, another mod's item) is dropped,
|
||||
so the rest of the tag still loads; vanilla would drop the whole tag.
|
||||
A listed namespace is the mod's as its id is: its `assets/` and `data/` files load, and the game reads its tags,
|
||||
recipes, names and loot. Blocks and items are still declared under `data/<id>/horizon/` only. `minecraft`, `c` and
|
||||
`horizon` cannot be listed. Two mods that are on may not own the same namespace (one's id or listed namespace being
|
||||
the other's): the game keeps the first in load order and leaves the other's content out, with "mods A and B both use
|
||||
the namespace N; turn one of them off" in the log. Paths use vanilla's resource characters, at most 65,536 files, 16
|
||||
MiB each and 512 MiB in all. A mod whose content is refused is left out, with the reason in the log. The game reads
|
||||
this content into its block and item tables (`horizon_client::mod_content`, [modpacks.md](modpacks.md)). A tag entry
|
||||
naming an id nobody registers (a block a port left out, another mod's item) is dropped, so the rest of the tag still
|
||||
loads; vanilla would drop the whole tag.
|
||||
|
||||
Tags merge as a data pack's do. Every mod's file for one tag (vanilla's
|
||||
`data/minecraft/tags/`, the convention `data/c/tags/`, or another mod's) adds
|
||||
its values after vanilla's and the mods' before it, in load order. A file with
|
||||
`"replace": true` first drops what came before it, vanilla's entries and the
|
||||
earlier mods', and the mods after it still add theirs, as a higher data pack
|
||||
does. Since the result depends on load order, the game says so in the log:
|
||||
"mod towns replaces data/minecraft/tags/worldgen/biome/has_structure/village_plains.json:
|
||||
vanilla's entries and those of adds are left out, ...". Tags are the only
|
||||
`minecraft` files a mod may hold: its other content stays in its own
|
||||
Tags merge as a data pack's do. Every mod's file for one tag (vanilla's `data/minecraft/tags/`, the convention
|
||||
`data/c/tags/`, or another mod's) adds its values after vanilla's and the mods' before it, in load order. A file with
|
||||
`"replace": true` first drops what came before it, vanilla's entries and the earlier mods', and the mods after it
|
||||
still add theirs, as a higher data pack does. Since the result depends on load order, the game says so in the log:
|
||||
"mod towns replaces data/minecraft/tags/worldgen/biome/has_structure/village_plains.json: vanilla's entries and those
|
||||
of adds are left out, ...". Tags are the only `minecraft` files a mod may hold: its other content stays in its own
|
||||
namespaces.
|
||||
|
||||
A mod adds to structure sets and template pools that are not its own
|
||||
(vanilla's villages, another mod's houses) with a set or pool file in its own
|
||||
namespace holding `horizon:add_to`, a key vanilla data never has. Its entries
|
||||
are appended to each set or pool named, after every mod's own files, in mod
|
||||
and file order; the file is no set or pool itself. A name nobody defines is
|
||||
skipped.
|
||||
A mod adds to structure sets and template pools that are not its own (vanilla's villages, another mod's houses) with
|
||||
a set or pool file in its own namespace holding `horizon:add_to`, a key vanilla data never has. Its entries are
|
||||
appended to each set or pool named, after every mod's own files, in mod and file order; the file is no set or pool
|
||||
itself. A name nobody defines is skipped.
|
||||
|
||||
```json
|
||||
{ "horizon:add_to": "minecraft:villages",
|
||||
"structures": [ { "structure": "mymod:village_beach", "weight": 10 } ] }
|
||||
```
|
||||
|
||||
A pool addition holds `"elements"` as a pool does. This is what lithostitched's
|
||||
`add_structure_set_entries` and `add_template_pool_elements` modifiers do on
|
||||
NeoForge and Fabric, and `horizon-port-pack carry` writes them from those.
|
||||
A pool addition holds `"elements"` as a pool does. `horizon-port-pack carry` writes these from the Lithostitched
|
||||
modifiers `add_structure_set_entries` and `add_template_pool_elements`.
|
||||
|
||||
A structure (and any of its pool elements, in place of the structure's) may
|
||||
hold `horizon:enhanced_terrain_adaptation`: MoogsStructureLib's terrain
|
||||
kernel, which carves or buries terrain around the
|
||||
pieces on top of vanilla's `terrain_adaptation`. `kernel_size` and
|
||||
`kernel_distance` shape the kernel; `top` and `bottom` (`carve`, `bury` or
|
||||
`none`) act above and below a piece's floor; `bottom_offset`, `padding`
|
||||
(`x`, `top`, `bottom`, `z`) and MoogsStructureLib's `band` (`bottom`, `top`,
|
||||
`piece_heights`) move the box it measures from. On a structure,
|
||||
`rigid_junctions` adds rigid pieces' junctions as well and
|
||||
`inflate` grows the start's box by the kernel radius. `kernel_size` 0 is
|
||||
none.
|
||||
A structure (and any of its pool elements, in place of the structure's) may hold
|
||||
`horizon:enhanced_terrain_adaptation`: MoogsStructureLib's terrain kernel, which carves or buries terrain around the
|
||||
pieces on top of vanilla's `terrain_adaptation`. `kernel_size` and `kernel_distance` shape the kernel; `top` and
|
||||
`bottom` (`carve`, `bury` or `none`) act above and below a piece's floor; `bottom_offset`, `padding` (`x`, `top`,
|
||||
`bottom`, `z`) and MoogsStructureLib's `band` (`bottom`, `top`, `piece_heights`) move the box it measures from. On a
|
||||
structure, `rigid_junctions` adds rigid pieces' junctions as well and `inflate` grows the start's box by the kernel
|
||||
radius. `kernel_size` 0 is none.
|
||||
|
||||
```json
|
||||
{ "horizon:enhanced_terrain_adaptation": { "kernel_size": 24, "kernel_distance": 24,
|
||||
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
|
||||
```
|
||||
|
||||
### Server mods (API 0.3, experimental)
|
||||
## Native mods
|
||||
|
||||
A server mod runs where the world is simulated: on the dedicated server, on a
|
||||
LAN host and on single player's own server, never in a player's window. Its
|
||||
manifest says `side = "server"` and `api = "0.3"` (and only a server mod may);
|
||||
its component targets the WIT world `horizon-server-mod` instead of
|
||||
`horizon-mod`. Experimental: it may change in any release
|
||||
([stability](api/stability.md)).
|
||||
|
||||
| Interface | Capability | Functions |
|
||||
|---|---|---|
|
||||
| `log` | always | as for client mods |
|
||||
| `storage` | `storage` | as for client mods, in the server's `horizon-mods/` |
|
||||
| `players` | `players` | `online()`: id, name, position, dimension, game mode, health of each player on; `tell(id, text)`, `broadcast(text)`: system messages after the mod's name, 8 per call |
|
||||
| `world` | `world` | `get-block(dimension, x, y, z)`: the state in a loaded chunk, `minecraft:oak_log[axis=y]`; `get-biome(dimension, x, y, z)`: `minecraft:plains`, ...; `set-blocks(dimension, changes)`: up to 4096 per call, checked at once, made as `/setblock` makes them (neighbours, light, block entities, every player told) after the call, before that dimension's next tick |
|
||||
|
||||
| Hook (export `server-events`) | When |
|
||||
|---|---|
|
||||
| `init() -> result<registration, string>` | once, after loading |
|
||||
| `tick(tick)` | each server tick |
|
||||
| `player-joined(player)`, `player-left(player)` | `registration.joins` |
|
||||
| `chat(player, text)` | `registration.chat`: each chat message a player sends |
|
||||
| `command(player, name, args, values)` | `/name args` typed by a player, for a command it registered, with its arguments' values (below); `ok(some)` is that player's feedback, `err` their red error |
|
||||
| `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` |
|
||||
|
||||
Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for
|
||||
client mods; `players` is for server mods. In Rust: `impl
|
||||
horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
|
||||
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs), and with
|
||||
`world`, [`beacons`](../crates/horizon-mod-sdk/examples/beacons/src/lib.rs).
|
||||
|
||||
**Typed commands.** Each command in `registration.commands` lists its
|
||||
arguments, in order: a name and a kind, required or optional (optional ones
|
||||
last). The server adds them to the command tree every player receives, after
|
||||
vanilla's commands, so a player's chat box completes and colours them as it
|
||||
does vanilla's (player names, coordinates, bounds); when a player sends one,
|
||||
the server parses the line the way vanilla's parsers do and gives the mod the
|
||||
values, by argument name, or answers with vanilla's error (`Integer must not
|
||||
be more than 64, found 65`, `No player was found`) without calling the mod.
|
||||
|
||||
| Kind | Takes | Value | Chat box parser |
|
||||
|---|---|---|---|
|
||||
| `word` | one word: letters, digits, `_ - . +` | `word(string)` | `brigadier:string` (word) |
|
||||
| `text` | the rest of the line, only last | `text(string)` | `brigadier:string` (greedy) |
|
||||
| `integer(min, max)` | a whole number within the bounds given | `integer(s32)` | `brigadier:integer` |
|
||||
| `number` | a number | `number(f64)` | `brigadier:double` |
|
||||
| `boolean` | `true` or `false` | `boolean(bool)` | `brigadier:bool` |
|
||||
| `player` | an online player's name, `@s` or `@p` (whoever typed it) | `player(player)` | `minecraft:entity` (one player) |
|
||||
| `block-pos` | `x y z`, each a whole number, `~` or `~n` (from whoever typed it) | `block-pos(block-pos)` | `minecraft:block_pos` |
|
||||
|
||||
A command with no arguments takes nothing after its name. In Rust,
|
||||
`Registration::command_with("beacon", [Argument::integer("height", Some(1),
|
||||
Some(64)).optional()])`, read with `args.integer("height")`; `command(name)`
|
||||
alone takes free text, an optional `text` argument named `args`
|
||||
(`args.text()` is the line as typed either way).
|
||||
|
||||
**Capability `world`.** Reads are of the server's loaded chunks as they are
|
||||
during the call: the game lends a server mod's call the levels' chunk maps
|
||||
(moved, not copied, and only while a running mod has `world`), so a read
|
||||
costs a map lookup and no copy of the world. A dimension with no chunk loaded
|
||||
at a position reads as none. Writes go through the same code as `/setblock`,
|
||||
so block updates, light, block entities and every client (LAN guests,
|
||||
dedicated server players) see them as vanilla's; the parity harnesses test
|
||||
that code. Changes for a dimension whose level isn't ticking wait for it
|
||||
(in-frame single player ticks only the player's), up to 65,536 blocks; a
|
||||
change in a chunk that isn't loaded when it is made is skipped, as `/setblock`
|
||||
refuses one.
|
||||
|
||||
**Where they run.** The game that simulates the world starts the server mods
|
||||
that are on, and only that one:
|
||||
|
||||
| Mode | Runs server mods | Players they see |
|
||||
|---|---|---|
|
||||
| Single player (default) | the server thread | the window's player, a connection like any other |
|
||||
| Single player `--in-frame` (deprecated, will be removed) | the window's own game, beside its client mods | the window's player |
|
||||
| Open to LAN, Play with Friends | the host's server thread (or its window, in-frame) | the host and every guest |
|
||||
| Dedicated server (`--dedicated`, `horizon-server`) | the server | every connected player |
|
||||
|
||||
A window that is a client of a server (single player's window, a LAN guest,
|
||||
any multiplayer server) never runs them; its Mods page lists a server mod with
|
||||
the others, and its switch takes effect the next time a world opens. The mods
|
||||
live in `horizon-mods/` of the game folder, or of the server's folder on a
|
||||
dedicated server, and are turned on as client mods are: Options > Mods, or
|
||||
`mod.<id>:true` in `horizon-mods.txt`. `--dev-mod <file>` runs one always
|
||||
on and reloads it when its file changes, on a server too.
|
||||
|
||||
**When hooks run.** At the end of each server tick the game tells the server
|
||||
mods who joined and who left since the last one, then what players said,
|
||||
broke and placed, in order, then calls `tick`. A command a player types
|
||||
reaches its mod at once, whatever the player's permission level, and its
|
||||
answer goes to that player. `tell` reaches one player, the window's own
|
||||
chat for the in-frame player and a system message for a connection;
|
||||
`broadcast` reaches every player and the server's log. With no server mod
|
||||
on, each of these points costs one branch (measured in the PR that added
|
||||
them).
|
||||
|
||||
**Not yet.** Block events are a player's own breaking and placing, not
|
||||
explosions, pistons or other mods. Command arguments don't take entities,
|
||||
items or block states yet, and `^` coordinates; in-frame single player's chat
|
||||
box lists server mods' commands by name only (the server still parses their
|
||||
arguments).
|
||||
Behaviour that needs code (a new key, a HUD, a tool that changes how mining works) is a native mod. It is a bundle of
|
||||
a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions of the game. Start from
|
||||
[horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template). How the launcher builds
|
||||
one, what it pins and why it is not sandboxed: [native-mods.md](native-mods.md).
|
||||
|
||||
## Publish your mod
|
||||
|
||||
@@ -597,14 +175,14 @@ published. Players find them at [playhorizon.dev/mods](https://playhorizon.dev/m
|
||||
|
||||
1. Run `cargo horizon-mod check` on the file (above): the registry runs the same checks.
|
||||
2. Put the source somewhere public, under a licence that lets players run it: reviewers read it, and the file must be
|
||||
built from it.
|
||||
built from it. A native mod's `.hnmod` is its source, and a maintainer reads it before it is listed.
|
||||
3. Open [playhorizon.dev/mods/submit](https://playhorizon.dev/mods/submit), sign in with GitHub (it reads only your
|
||||
public profile) and fill in the page: the file, its name and summary, licence, source address, and optionally a
|
||||
minisign public key with the file's `.minisig`.
|
||||
4. The form checks the file again and opens a pull request in
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods) that adds `mods/<id>.toml`, with you as its
|
||||
owner. That repository's checks run the file in the sandbox and post their report on the pull request; answer the
|
||||
reviewers there.
|
||||
owner. That repository's checks run `cargo horizon-mod check` on the file and post their report on the pull
|
||||
request; answer the reviewers there.
|
||||
5. A reviewer merges it: within minutes the file is a release of horizon-mods, and the project is on the site and in
|
||||
the launcher.
|
||||
|
||||
@@ -616,97 +194,45 @@ An update is the same form with a new version; only the project's `owners` can p
|
||||
has one. A published file never changes. Without the form, open the pull request by hand (the repository's README
|
||||
says how). The formats: [api/mod-index.md](api/mod-index.md).
|
||||
|
||||
### Horizon's official mods
|
||||
### Integrity and signing
|
||||
|
||||
The Horizon extras that are mods now, and Horizon's own recipe viewer, live in `mods/official/<name>/`, one crate each in the game's workspace, so
|
||||
`cargo clippy --workspace` and pr_ready cover them. They use only the public API, like any other mod, with the same
|
||||
options on their settings page as the extra had and the same look. Each is published by a maintainer: build it on
|
||||
`main` with the toolchain CI uses (`cd mods/official/<name> && cargo horizon-mod build`), then open a pull request in
|
||||
horizon-mods with its `mods/<id>.toml` (owners `ludovic111`, `source` pointing at the folder) and the file's
|
||||
SHA-256. The reviewer builds it the same way and merges only when the hashes match: the `.wasm` holds no local paths,
|
||||
so the same rustc and `Cargo.lock` give the same file.
|
||||
- **Integrity.** Every download is checked against the SHA-256 the index lists before the file reaches the game
|
||||
folder.
|
||||
- **Signing.** Authors sign files with an Ed25519 key (minisign format: a `.minisig` beside the file); the index pins
|
||||
each project's public key, so an update signed by another key is refused. Unsigned local files stay allowed, with an
|
||||
"unsigned" mark.
|
||||
- **Takedowns.** A file the registry takes down (`revoked.toml`) is refused by the launcher and turned off where
|
||||
installed.
|
||||
|
||||
| Mod | Id | Replaces | Settings (storage key: values) |
|
||||
|---|---|---|---|
|
||||
| FPS Counter | `fps_overlay` | Extras > FPS counter | `corner`: `top_left` (default), `top_right`, `bottom_left`, `bottom_right`; `lows`: `false` (default), `true` |
|
||||
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
|
||||
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
|
||||
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
|
||||
| Far View | `far_view` | Graphics > LOD Distance and Far Detail | `distance` (chunks): `64`, `128`, `256` (default), `512`, `1024`; `detail`: `full` (default), `reduced`. The game builds and draws the distant terrain (`render.set-far-terrain`); the player's LOD Distance, when on, wins |
|
||||
| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book |
|
||||
## Horizon's official mods
|
||||
|
||||
## Versions and compatibility
|
||||
Horizon's own mods are native. They live in
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods) under `native/<name>/`:
|
||||
|
||||
- The API is the WIT package `horizon:[email protected]`. New functions and
|
||||
interfaces arrive in patch versions (0.1.1, ...); a game serving 0.1.N loads
|
||||
mods built for 0.1.0 to 0.1.N. A breaking change makes `0.2` (and later
|
||||
`1.0`); the game then links both versions side by side for at least one
|
||||
release cycle, so existing mods keep working while authors move.
|
||||
- The manifest's `api` names the line it needs (`0.1`); the game refuses a
|
||||
line it does not serve with a clear message.
|
||||
- Within a line, each function has an API level, the one it arrived at
|
||||
([stability](api/stability.md#mod-api-levels)). A mod needs the level of
|
||||
the newest function it calls, read from its file: an older Horizon refuses
|
||||
it with "made for a newer Horizon: update Horizon", and the registry lists
|
||||
the level so installers take a version the game can run.
|
||||
`cargo horizon-mod check` prints it ("needs mod API level 1 or newer").
|
||||
- Horizon follows Minecraft releases; the mod API does not have to. Vanilla
|
||||
concepts mods see (dimensions, block and item ids) are the game's own ids,
|
||||
so a version port changes the data, not the API.
|
||||
| Mod | Folder | Replaces |
|
||||
|---|---|---|
|
||||
| FPS Counter | `native/fps-overlay` | Extras > FPS counter |
|
||||
| Keystrokes | `native/keystrokes` | Extras > Keystrokes |
|
||||
| Zoom | `native/zoom` | Extras > Zoom |
|
||||
| Minimap | `native/minimap` | Extras > Minimap |
|
||||
| Recipes | `native/recipes` | The Recipes WebAssembly mod (item panel and recipe pages, on hook points version 2) |
|
||||
| Coordinates HUD | `native/coords-hud` | The WebAssembly SDK's example mod (position and facing, `/where`, `/coords`) |
|
||||
|
||||
## Multiplayer
|
||||
The game keeps the parts that must be fast, such as drawing the minimap's map, and a native mod picks the options
|
||||
([extras.md](extras.md)). Far View is not a mod any more: its distance and detail are Graphics > LOD Distance and Far
|
||||
Detail ([graphics.md](graphics.md)).
|
||||
|
||||
- **Client mods** (`side = "client"`, all of 0.1) run in the player's game,
|
||||
in single player and on any server, vanilla or Horizon. They see only what
|
||||
the client already knows (the snapshot), like F3 does, and act only on the
|
||||
local screen and chat. A server cannot see them.
|
||||
- **Server mods** (`side = "server"`, API 0.3) run where the world is
|
||||
simulated: single player's server thread (or the window, in-frame), a LAN
|
||||
host and the dedicated server ([Server mods](#server-mods-api-03-experimental)).
|
||||
They use only vanilla messages, so any client, vanilla or Horizon, can join.
|
||||
- **Both** (`side = "both"`): a server mod with a client half (new blocks,
|
||||
their textures, a custom screen). On join the Horizon server announces its
|
||||
mods (id, version, SHA-256) on a `horizon:mods` custom payload channel; a
|
||||
Horizon client with matching client halves enables them for that session, or
|
||||
offers to download them from the same source the launcher uses. Vanilla
|
||||
clients are refused only if the server says its mods need a client.
|
||||
- Registries (new blocks and items) are server-owned and synced like vanilla's
|
||||
registry sync, so ids match on both ends.
|
||||
## WebAssembly code mods (removed)
|
||||
|
||||
## Distribution
|
||||
Horizon used to run mods written in Rust and compiled to sandboxed WebAssembly (`.hmod` files with a `mod.wasm`).
|
||||
That runtime, its SDK, the `horizon:mods` interface and the example mods are gone.
|
||||
|
||||
- **The file.** `.hmod`: a zip holding `horizon-mod.toml`, `mod.wasm` and,
|
||||
later, `assets/` and `data/`. `mod.wasm` is capped at 32 MiB.
|
||||
- **Integrity.** Every download is checked against the SHA-256 the index
|
||||
lists before the file reaches `horizon-mods/`.
|
||||
- **Signing.** Authors sign `.hmod` files with an Ed25519 key (minisign
|
||||
format: a `.hmod.minisig` beside it); the index pins each project's public
|
||||
key, so an update signed by another key is refused. Unsigned local files
|
||||
stay allowed, with an "unsigned" mark on the Mods page.
|
||||
- **Where mods come from.** Horizon's registry ([Publish your mod](#publish-your-mod)): reviewed pull requests in
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods), files as its release assets, and an
|
||||
`index.json` on GitHub Pages that the launcher and the website read. A file the registry takes down
|
||||
(`revoked.toml`) is refused by the launcher and turned off where installed.
|
||||
- **The launcher.** A Mods tab per installation: browse and search the index,
|
||||
install, update, turn on and off (it writes the same `horizon-mods.txt`),
|
||||
see capabilities before installing. The launcher's CLI and MCP tools gain
|
||||
`mods list|search|install|remove|enable|disable`, so agents can set up a
|
||||
modded game the same way players do.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Design (this page) and the Java research.
|
||||
2. SDK, example mod, runtime, `cargo horizon-mod`, the game's Mods page: API 0.1.
|
||||
3. `storage` (done), `keys`, `target`, `inventory-read`, `ui`: enough for the
|
||||
first native ports (zoom presets, AppleSkin-style food HUD, Jade-style
|
||||
tooltips).
|
||||
4. Launcher Mods tab, the index, signatures; the Mods page shows cost and
|
||||
update state.
|
||||
5. `recipes`, `world-read`, `render`: recipe viewer and schematics ports.
|
||||
6. Server mods, `registries`, `net`, data-driven content from `.hmod`
|
||||
`data/` and `assets/`, multiplayer sync.
|
||||
|
||||
Modpacks ([modpacks.md](modpacks.md)) move data-driven `registries` first:
|
||||
API 0.2 is content-only `.hmod`s (blocks, items, recipes, loot, models and
|
||||
textures), then server hooks (0.3) and block entities, menus, capabilities and
|
||||
`net` (0.4), in the order the ATM10 inventory needs them.
|
||||
- **A player** who has such a mod sees it in Options > Mods as "Doesn't run any more: needs a native port", and reads
|
||||
one notice in the chat. The mod's files are not deleted.
|
||||
- **The launcher** hides these mods from search and browse, and can't install one alone. A modpack that requires one
|
||||
still installs, with the mod listed as not running and its code left out.
|
||||
- **An author** ports the mod to a native mod: copy
|
||||
[horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template), look at the official
|
||||
ones in horizon-mods, and read [native-mods.md](native-mods.md). The registry keeps the old versions for the
|
||||
modpacks that need them.
|
||||
- **A content mod is not affected.** Only an `.hmod` with a capability besides `registries` needs a port.
|
||||
+1
-1
@@ -30,7 +30,7 @@ on any Horizon whose version is the one it needs or a later one. Its `horizon` i
|
||||
pin, else on the installed version's commit (the game the player plays), after checking that commit's
|
||||
`NATIVE_HOOKS_API` (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the
|
||||
mod was tested on. In its own copy of the mod it sets `horizon_revision` to the commit it builds on. Horizon's
|
||||
official mods (Zoom, FPS counter, Keystrokes, Minimap, Recipes) are made this way, so any of them build together
|
||||
own native mods (Zoom, FPS counter, Keystrokes, Minimap; horizon-rust/horizon-mods, `native/<name>/`) are made this way, so any of them build together
|
||||
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
|
||||
|
||||
API 2 adds the item panel beside container screens and its recipe pages (`native_panel_items`,
|
||||
|
||||
@@ -327,6 +327,7 @@ fn version_of(
|
||||
blocked: index
|
||||
.revoked(&p.id, &v.file.sha256)
|
||||
.map(|r| format!("taken down: {}", r.reason))
|
||||
.or_else(|| needs_port(v).then(|| NEEDS_PORT.to_string()))
|
||||
.or_else(|| needs_newer(v, level).then(|| NEEDS_NEWER.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -358,6 +359,8 @@ fn search(index: &Index, query: &Query) -> Vec<Project> {
|
||||
.iter()
|
||||
.filter(|p| kind_of(&p.kind) == Some(query.kind) && matches(p, &query.text))
|
||||
.filter(|p| query.everything || p.versions.iter().any(|v| v.game == GAME))
|
||||
// A WebAssembly mod stays in the index for the modpacks that need it, not in the browser.
|
||||
.filter(|p| !p.versions.iter().filter(|v| v.game == GAME).all(needs_port))
|
||||
.filter_map(project_of)
|
||||
.collect();
|
||||
found.sort_by_key(|p| std::cmp::Reverse(p.downloads));
|
||||
@@ -554,6 +557,9 @@ fn install_from(
|
||||
.project(&project.id)
|
||||
.ok_or_else(|| format!("{} isn't in Horizon's registry.", project.title))?;
|
||||
let v = newest(index, p, level)?;
|
||||
if needs_port(v) {
|
||||
return Err(format!("{} {NEEDS_PORT}.", p.name));
|
||||
}
|
||||
install_version(index, local, p, v, home, progress).map(|e| vec![e])
|
||||
}
|
||||
|
||||
@@ -582,6 +588,17 @@ fn newest<'a>(
|
||||
}
|
||||
|
||||
const NEEDS_NEWER: &str = "needs a newer Horizon than the game you play";
|
||||
const NEEDS_PORT: &str = "needs a native port: Horizon doesn't run WebAssembly mods any more";
|
||||
|
||||
/// Whether version `v` is a WebAssembly mod with code: a `.hmod` that may do more than add
|
||||
/// content. The registry keeps it for the modpacks that need it; a modpack installs it with its
|
||||
/// code left out (the game doesn't run it), and the browser neither lists nor installs it.
|
||||
pub fn needs_port(v: &index::Version) -> bool {
|
||||
v.file.name.ends_with(".hmod")
|
||||
&& v.capabilities
|
||||
.iter()
|
||||
.any(|c| *c != horizon_mods::Capability::Registries)
|
||||
}
|
||||
|
||||
/// Whether version `v` calls mod API functions a game serving `level` doesn't have.
|
||||
fn needs_newer(v: &index::Version, level: Option<u32>) -> bool {
|
||||
@@ -676,7 +693,10 @@ fn install_version(
|
||||
page: project.page.clone(),
|
||||
dependency: false,
|
||||
verdict: Some(trust_label(trust).into()),
|
||||
skipped: Vec::new(),
|
||||
skipped: match needs_port(v) {
|
||||
true => vec![format!("its code ({NEEDS_PORT})")],
|
||||
false => Vec::new(),
|
||||
},
|
||||
namespaces: Vec::new(),
|
||||
};
|
||||
list.entries.retain(|e| !ours(e));
|
||||
|
||||
@@ -33,11 +33,19 @@ fn hud(index: &Index) -> Project {
|
||||
project_of(index.project("coords_hud").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Installs the sample's WebAssembly mod as a modpack does: the browser won't, it needs a native
|
||||
/// port.
|
||||
fn put(index: &Index, local: bool, home: &Path) -> Result<Vec<installed::Entry>, String> {
|
||||
let p = index.project("coords_hud").unwrap();
|
||||
install_version(index, local, p, &p.versions[0], home, &|_, _| {}).map(|e| vec![e])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_registry_entries_are_mods_that_say_they_run_native_code() {
|
||||
assert_eq!(kind_of(&index::Kind::NativeMod), Some(Kind::Mod));
|
||||
let mut index = sample();
|
||||
index.projects[0].kind = index::Kind::NativeMod;
|
||||
index.projects[0].versions[0].file.name = "coords_hud-0.1.0.hnmod".into();
|
||||
let found = search(&index, &Query::new("", Kind::Mod));
|
||||
assert!(found.iter().any(|p| p.id == index.projects[0].id));
|
||||
let entry = |file: &str| {
|
||||
@@ -75,20 +83,23 @@ fn searches_by_kind_and_words() {
|
||||
.map(|p| p.id)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(found("", Kind::Mod), ["coords_hud"]);
|
||||
assert_eq!(found("coordinates HUD", Kind::Mod), ["coords_hud"]);
|
||||
// The sample's only mod is a WebAssembly one: it stays in the index, not in the browser.
|
||||
assert!(found("", Kind::Mod).is_empty());
|
||||
assert!(found("coordinates HUD", Kind::Mod).is_empty());
|
||||
assert!(found("zoom", Kind::Mod).is_empty());
|
||||
// Modpacks under Modpacks; a kind this launcher can't install yet (a world) never.
|
||||
assert_eq!(found("", Kind::Modpack), ["cozy"]);
|
||||
assert!(found("skyblock", Kind::ResourcePack).is_empty());
|
||||
assert_eq!(found("", Kind::Shader), ["glow"]);
|
||||
// The index's download counts, most downloaded first.
|
||||
// The index's download counts, most downloaded first (of native mods: the browser lists them).
|
||||
let mut counted = index.clone();
|
||||
counted.projects[0].kind = index::Kind::NativeMod;
|
||||
counted.projects[0].versions[0].file.name = "coords_hud-0.1.0.hnmod".into();
|
||||
counted.projects.push(index::Project {
|
||||
id: "zoom".into(),
|
||||
name: "Zoom".into(),
|
||||
downloads: 12_345,
|
||||
..index.project("coords_hud").unwrap().clone()
|
||||
..counted.projects[0].clone()
|
||||
});
|
||||
counted.projects[0].downloads = 80;
|
||||
let first = search(&counted, &Query::new("", Kind::Mod));
|
||||
@@ -104,8 +115,11 @@ fn installs_checked_turns_on_and_removes() {
|
||||
let index = sample();
|
||||
let home = scratch("install");
|
||||
let p = hud(&index);
|
||||
let done = install_from(&index, true, None, &p, &home, &|_, _| {}).unwrap();
|
||||
let e = install_from(&index, true, None, &p, &home, &|_, _| {}).unwrap_err();
|
||||
assert_eq!(e, format!("Coordinates HUD {NEEDS_PORT}."));
|
||||
let done = put(&index, true, &home).unwrap();
|
||||
assert_eq!(done[0].file, "horizon-mods/coords_hud.hmod");
|
||||
assert_eq!(done[0].skipped, [format!("its code ({NEEDS_PORT})")]);
|
||||
assert_eq!(done[0].verdict.as_deref(), Some("Verified"));
|
||||
assert!(done[0].checksum.starts_with("SHA-256:e1135345"));
|
||||
assert!(home.join("horizon-mods/coords_hud.hmod").is_file());
|
||||
@@ -118,7 +132,7 @@ fn installs_checked_turns_on_and_removes() {
|
||||
0
|
||||
);
|
||||
// Installed already: nothing to do.
|
||||
let again = install_from(&index, true, None, &p, &home, &|_, _| {}).unwrap();
|
||||
let again = put(&index, true, &home).unwrap();
|
||||
assert_eq!(again, done);
|
||||
|
||||
let mut revoked = index.clone();
|
||||
@@ -148,7 +162,7 @@ fn a_mod_that_turned_native_keeps_its_hmod_until_removed() {
|
||||
assert!(!stays(true, "horizon-native-mods/coords_hud.hnmod"));
|
||||
let index = sample();
|
||||
let home = scratch("turned-native");
|
||||
install_from(&index, true, None, &hud(&index), &home, &|_, _| {}).unwrap();
|
||||
put(&index, true, &home).unwrap();
|
||||
// As install_version leaves it after the native update.
|
||||
let mut list = installed::Installed::load(&home);
|
||||
list.entries[0].file = "horizon-native-mods/coords_hud.hnmod".into();
|
||||
@@ -199,21 +213,20 @@ fn installs_a_shader_pack_into_shaderpacks() {
|
||||
#[test]
|
||||
fn refuses_a_changed_file_a_taken_id_and_local_files_from_the_real_registry() {
|
||||
let index = sample();
|
||||
let p = hud(&index);
|
||||
|
||||
let home = scratch("tampered");
|
||||
let mut tampered = index.clone();
|
||||
let cozy = tampered.projects[1].versions[0].file.url.clone();
|
||||
tampered.projects[0].versions[0].file.url = cozy;
|
||||
let e = install_from(&tampered, true, None, &p, &home, &|_, _| {}).unwrap_err();
|
||||
let e = put(&tampered, true, &home).unwrap_err();
|
||||
assert!(e.contains("larger than its source says"), "{e}");
|
||||
assert!(!home.join("horizon-mods").exists());
|
||||
|
||||
let e = install_from(&index, false, None, &p, &home, &|_, _| {}).unwrap_err();
|
||||
let e = put(&index, false, &home).unwrap_err();
|
||||
assert!(e.contains("file servers"), "{e}");
|
||||
|
||||
fs::create_dir_all(home.join("horizon-mods/coords_hud")).unwrap();
|
||||
let e = install_from(&index, true, None, &p, &home, &|_, _| {}).unwrap_err();
|
||||
let e = put(&index, true, &home).unwrap_err();
|
||||
assert!(e.contains("already in horizon-mods"), "{e}");
|
||||
|
||||
let mut taken_down = index.clone();
|
||||
@@ -223,7 +236,7 @@ fn refuses_a_changed_file_a_taken_id_and_local_files_from_the_real_registry() {
|
||||
reason: "Test.".into(),
|
||||
date: String::new(),
|
||||
});
|
||||
let e = install_from(&taken_down, true, None, &p, &home, &|_, _| {}).unwrap_err();
|
||||
let e = put(&taken_down, true, &home).unwrap_err();
|
||||
assert_eq!(e, "Coordinates HUD was taken down: Test.");
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
@@ -236,13 +249,23 @@ fn registry_versions_run_when_made_for_this_game() {
|
||||
assert_eq!(support(Kind::Mod, &v(GAME), &horizon), Support::Works);
|
||||
assert!(!support(Kind::Mod, &v("26.2"), &horizon).installable());
|
||||
let index = sample();
|
||||
let p = index.project("coords_hud").unwrap();
|
||||
let versions: Vec<Version> = p
|
||||
.versions
|
||||
.iter()
|
||||
.map(|x| version_of(&index, p, x, None))
|
||||
.collect();
|
||||
assert_eq!(pick(Kind::Mod, &versions, false).unwrap().number, "0.1.0");
|
||||
let versions = |id: &str| -> Vec<Version> {
|
||||
let p = index.project(id).unwrap();
|
||||
p.versions
|
||||
.iter()
|
||||
.map(|x| version_of(&index, p, x, None))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
pick(Kind::Modpack, &versions("cozy"), false)
|
||||
.unwrap()
|
||||
.number,
|
||||
"1.0.0"
|
||||
);
|
||||
// A WebAssembly mod is never picked: it needs a native port.
|
||||
let hud = versions("coords_hud");
|
||||
assert_eq!(hud[0].blocked.as_deref(), Some(NEEDS_PORT));
|
||||
assert!(pick(Kind::Mod, &hud, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -277,9 +300,9 @@ fn versions_for_a_newer_horizon_are_not_installed() {
|
||||
);
|
||||
assert!(newest(&index, p, Some(1)).is_ok());
|
||||
assert!(newest(&index, p, None).is_ok());
|
||||
// In the browser, a WebAssembly mod needs a native port first.
|
||||
let v = version_of(&index, p, &p.versions[0], Some(0));
|
||||
assert_eq!(v.blocked.as_deref(), Some(NEEDS_NEWER));
|
||||
assert_eq!(version_of(&index, p, &p.versions[0], Some(1)).blocked, None);
|
||||
assert_eq!(v.blocked.as_deref(), Some(NEEDS_PORT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "far-view"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official far view: distant terrain past the render distance."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "far_view"
|
||||
name = "Far View"
|
||||
version = "1.0.0"
|
||||
description = "See much farther: the world past your render distance drawn as simplified distant terrain, out to 1024 chunks, without loading or simulating more chunks."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["render", "ui", "storage"]
|
||||
experimental = ["render", "ui"]
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Horizon's official far view: the world past the render distance drawn as
|
||||
//! distant terrain (`render.set-far-terrain`; the game builds the tiles on
|
||||
//! its own workers). Its settings page picks how far it reaches and the far
|
||||
//! tiles' detail, kept in the mod's storage (`distance`, `detail`). The
|
||||
//! player's own LOD Distance setting, when on, wins over the mod.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
render::{self, FarTerrain},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
/// Distances in chunks, as LOD Distance offers them.
|
||||
const DISTANCES: [u32; 5] = [64, 128, 256, 512, 1024];
|
||||
/// Settings page buttons.
|
||||
const DISTANCE_BUTTON: u32 = 1;
|
||||
const DETAIL_BUTTON: u32 = 2;
|
||||
|
||||
/// An index into [`DISTANCES`] and Far Detail's Reduced.
|
||||
struct FarView {
|
||||
distance: usize,
|
||||
reduced: bool,
|
||||
}
|
||||
|
||||
impl FarView {
|
||||
fn show(&self) {
|
||||
render::set_far_terrain(Some(FarTerrain {
|
||||
distance: DISTANCES[self.distance],
|
||||
reduced: self.reduced,
|
||||
}));
|
||||
}
|
||||
fn page(&self) {
|
||||
let button = |id, label: String| Widget::Button(ButtonWidget { id, label });
|
||||
ui::set_settings(&[
|
||||
button(
|
||||
DISTANCE_BUTTON,
|
||||
format!("Distance: {} chunks", DISTANCES[self.distance]),
|
||||
),
|
||||
button(
|
||||
DETAIL_BUTTON,
|
||||
format!(
|
||||
"Far detail: {}",
|
||||
if self.reduced { "Reduced" } else { "Full" }
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
fn save(&self) {
|
||||
let _ = storage::set("distance", DISTANCES[self.distance].to_string().as_bytes());
|
||||
let _ = storage::set("detail", if self.reduced { b"reduced" } else { b"full" });
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
storage::get(key)
|
||||
.and_then(|v| String::from_utf8(v).ok())
|
||||
.map(|v| v.trim().to_owned())
|
||||
}
|
||||
|
||||
impl Mod for FarView {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
// 256 chunks and full detail, as the docs suggest; an unknown value
|
||||
// keeps the default.
|
||||
let far = FarView {
|
||||
distance: stored("distance")
|
||||
.and_then(|v| DISTANCES.iter().position(|d| d.to_string() == v))
|
||||
.unwrap_or(2),
|
||||
reduced: stored("detail").as_deref() == Some("reduced"),
|
||||
};
|
||||
far.show();
|
||||
far.page();
|
||||
Ok((far, Registration::new()))
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
match id {
|
||||
DISTANCE_BUTTON => self.distance = (self.distance + 1) % DISTANCES.len(),
|
||||
DETAIL_BUTTON => self.reduced = !self.reduced,
|
||||
_ => return,
|
||||
}
|
||||
self.save();
|
||||
self.show();
|
||||
self.page();
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(FarView);
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "fps-overlay"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official FPS counter: frames per second in a corner, with the minimum and the 1% low."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "fps_overlay"
|
||||
name = "FPS Counter"
|
||||
version = "1.0.0"
|
||||
description = "Frames per second in a corner of the screen, with the slowest frame and the 1% low if you like. Horizon's own, formerly an extra."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["render", "ui", "storage"]
|
||||
experimental = ["render", "ui"]
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Horizon's official FPS counter, formerly the built-in extra: the game's
|
||||
//! frame counter (frames in the last second, the slowest frame and the 1%
|
||||
//! low of the last 5 seconds) in a box in a corner, drawn as the extra drew
|
||||
//! it. Its settings page has the extra's two options; they are kept in the
|
||||
//! mod's storage (`corner`, `lows`), where the game writes a player's old
|
||||
//! extra settings when it installs the mod for them.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
render::{self, Corner, FillShape, Point, Rect, Shape, TextShape},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
/// The extra's box: translucent black, white text with a shadow.
|
||||
const BACKGROUND: u32 = 0x7800_0000;
|
||||
const CORNERS: [(Corner, &str, &str); 4] = [
|
||||
(Corner::TopLeft, "top_left", "Top left"),
|
||||
(Corner::TopRight, "top_right", "Top right"),
|
||||
(Corner::BottomLeft, "bottom_left", "Bottom left"),
|
||||
(Corner::BottomRight, "bottom_right", "Bottom right"),
|
||||
];
|
||||
/// Settings page buttons.
|
||||
const CORNER_BUTTON: u32 = 1;
|
||||
const LOWS_BUTTON: u32 = 2;
|
||||
|
||||
struct Fps {
|
||||
/// Index in `CORNERS`.
|
||||
corner: usize,
|
||||
lows: bool,
|
||||
/// What the box shows now, to redraw only on a change.
|
||||
shown: String,
|
||||
}
|
||||
|
||||
impl Fps {
|
||||
fn text(&self, f: render::FrameRate) -> String {
|
||||
if self.lows {
|
||||
format!("{} fps min {} 1% {}", f.fps, f.min, f.low)
|
||||
} else {
|
||||
format!("{} fps", f.fps)
|
||||
}
|
||||
}
|
||||
fn draw(&mut self) {
|
||||
let text = self.text(render::frame_stats());
|
||||
if text == self.shown {
|
||||
return;
|
||||
}
|
||||
let w = render::text_width(&text) + 4.0;
|
||||
render::set_corner(
|
||||
CORNERS[self.corner].0,
|
||||
w,
|
||||
12.0,
|
||||
&[
|
||||
Shape::Fill(FillShape {
|
||||
rect: Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w,
|
||||
h: 12.0,
|
||||
},
|
||||
color: BACKGROUND,
|
||||
}),
|
||||
Shape::Text(TextShape {
|
||||
text: text.clone(),
|
||||
at: Point { x: 2.0, y: 2.0 },
|
||||
color: 0xFF_FFFF,
|
||||
centered: false,
|
||||
shadow: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
self.shown = text;
|
||||
}
|
||||
/// The settings page: the screen's title is the mod's name already.
|
||||
fn page(&self) {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
ui::set_settings(&[
|
||||
Widget::Button(ButtonWidget {
|
||||
id: CORNER_BUTTON,
|
||||
label: format!("Corner: {}", CORNERS[self.corner].2),
|
||||
}),
|
||||
Widget::Button(ButtonWidget {
|
||||
id: LOWS_BUTTON,
|
||||
label: format!("Minimum and 1% low: {}", on(self.lows)),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
fn save(&self) {
|
||||
let _ = storage::set("corner", CORNERS[self.corner].1.as_bytes());
|
||||
let _ = storage::set("lows", if self.lows { b"true" } else { b"false" });
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
storage::get(key).and_then(|v| String::from_utf8(v).ok())
|
||||
}
|
||||
|
||||
impl Mod for Fps {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
// The extra's defaults: top left, no lows.
|
||||
let corner = stored("corner")
|
||||
.and_then(|c| CORNERS.iter().position(|k| k.1 == c.trim()))
|
||||
.unwrap_or(0);
|
||||
let lows = stored("lows").is_some_and(|v| v.trim() == "true");
|
||||
let fps = Fps {
|
||||
corner,
|
||||
lows,
|
||||
shown: String::new(),
|
||||
};
|
||||
fps.page();
|
||||
Ok((fps, Registration::new().on_tick()))
|
||||
}
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
self.draw();
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
match id {
|
||||
CORNER_BUTTON => self.corner = (self.corner + 1) % CORNERS.len(),
|
||||
LOWS_BUTTON => self.lows = !self.lows,
|
||||
_ => return,
|
||||
}
|
||||
self.save();
|
||||
self.page();
|
||||
self.shown.clear();
|
||||
self.draw();
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Fps);
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "keystrokes"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official keystrokes overlay: movement keys, mouse buttons and clicks per second."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "keystrokes"
|
||||
name = "Keystrokes"
|
||||
version = "1.0.0"
|
||||
description = "Your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second. Horizon's own, formerly an extra."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["render", "input", "ui", "storage"]
|
||||
experimental = ["render", "input", "ui"]
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Horizon's official keystrokes overlay, formerly the built-in extra:
|
||||
//! forward, left, back and right as the player bound them, the two mouse
|
||||
//! buttons (with clicks per second) and the space bar, lit while held,
|
||||
//! drawn as the extra drew them. It redraws in the frame hook only when
|
||||
//! something changed. Settings (`corner`, `cps`) live in the mod's storage.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, input,
|
||||
render::{self, Corner, FillShape, Rect, Shape, TextShape},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
const BACKGROUND: u32 = 0x7800_0000;
|
||||
/// A held key: light fill, dark text without a shadow.
|
||||
const HELD: u32 = 0xB0FF_FFFF;
|
||||
const INK_HELD: u32 = 0x14_1414;
|
||||
const INK: u32 = 0xFF_FFFF;
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
const CORNERS: [(Corner, &str, &str); 4] = [
|
||||
(Corner::TopLeft, "top_left", "Top left"),
|
||||
(Corner::TopRight, "top_right", "Top right"),
|
||||
(Corner::BottomLeft, "bottom_left", "Bottom left"),
|
||||
(Corner::BottomRight, "bottom_right", "Bottom right"),
|
||||
];
|
||||
const CORNER_BUTTON: u32 = 1;
|
||||
const CPS_BUTTON: u32 = 2;
|
||||
const MOVES: [&str; 4] = ["key.forward", "key.left", "key.back", "key.right"];
|
||||
|
||||
/// What the overlay shows; a change redraws it.
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
struct View {
|
||||
moves: [(bool, String); 4],
|
||||
jump: bool,
|
||||
attack: bool,
|
||||
use_: bool,
|
||||
cps: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
struct Keystrokes {
|
||||
corner: usize,
|
||||
cps: bool,
|
||||
shown: Option<View>,
|
||||
}
|
||||
|
||||
fn down(name: &str) -> (bool, String) {
|
||||
input::mapping(name).map_or((false, String::new()), |m| (m.down, m.label))
|
||||
}
|
||||
|
||||
/// The extra's key box: a fill and its lines centred, 9 pixels apart.
|
||||
fn key_box(out: &mut Vec<Shape>, [x, y, w, h]: [f32; 4], held: bool, lines: &[&str]) {
|
||||
let (fill, ink) = if held {
|
||||
(HELD, INK_HELD)
|
||||
} else {
|
||||
(BACKGROUND, INK)
|
||||
};
|
||||
out.push(Shape::Fill(FillShape {
|
||||
rect: Rect { x, y, w, h },
|
||||
color: fill,
|
||||
}));
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let tw = render::text_width(line);
|
||||
out.push(Shape::Text(TextShape {
|
||||
text: (*line).into(),
|
||||
at: render::Point {
|
||||
x: (x + (w - tw) / 2.0).round(),
|
||||
y: (y + (h - total) / 2.0).round() + i as f32 * 9.0,
|
||||
},
|
||||
color: ink,
|
||||
centered: false,
|
||||
shadow: !held,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Keystrokes {
|
||||
fn view(&self) -> View {
|
||||
let (l, r) = input::clicks_per_second();
|
||||
View {
|
||||
moves: MOVES.map(down),
|
||||
jump: down("key.jump").0,
|
||||
attack: down("key.attack").0,
|
||||
use_: down("key.use").0,
|
||||
cps: self.cps.then_some((l, r)),
|
||||
}
|
||||
}
|
||||
fn draw(&mut self) {
|
||||
let v = self.view();
|
||||
if self.shown.as_ref() == Some(&v) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0);
|
||||
let step = KEY + GAP;
|
||||
let mut out = Vec::with_capacity(24);
|
||||
let [forward, left, back, right] = &v.moves;
|
||||
key_box(&mut out, [step, 0.0, KEY, KEY], forward.0, &[&forward.1]);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
&mut out,
|
||||
[i as f32 * step, step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = 2.0 * step;
|
||||
for (i, (held, label, cps)) in [
|
||||
(v.attack, "LMB", v.cps.map(|c| c.0)),
|
||||
(v.use_, "RMB", v.cps.map(|c| c.1)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(&mut out, [i as f32 * (mw + GAP), my, mw, KEY], held, &lines);
|
||||
}
|
||||
// The space bar: a line in a short key.
|
||||
let sy = my + step;
|
||||
key_box(&mut out, [0.0, sy, w, 10.0], v.jump, &[]);
|
||||
out.push(Shape::Fill(FillShape {
|
||||
rect: Rect {
|
||||
x: w / 2.0 - 12.0,
|
||||
y: sy + 4.0,
|
||||
w: 24.0,
|
||||
h: 2.0,
|
||||
},
|
||||
color: 0xFF00_0000 | if v.jump { INK_HELD } else { INK },
|
||||
}));
|
||||
render::set_corner(CORNERS[self.corner].0, w, h, &out);
|
||||
self.shown = Some(v);
|
||||
}
|
||||
/// The settings page: the screen's title is the mod's name already.
|
||||
fn page(&self) {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
ui::set_settings(&[
|
||||
Widget::Button(ButtonWidget {
|
||||
id: CORNER_BUTTON,
|
||||
label: format!("Corner: {}", CORNERS[self.corner].2),
|
||||
}),
|
||||
Widget::Button(ButtonWidget {
|
||||
id: CPS_BUTTON,
|
||||
label: format!("Clicks per second: {}", on(self.cps)),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
fn save(&self) {
|
||||
let _ = storage::set("corner", CORNERS[self.corner].1.as_bytes());
|
||||
let _ = storage::set("cps", if self.cps { b"true" } else { b"false" });
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
storage::get(key).and_then(|v| String::from_utf8(v).ok())
|
||||
}
|
||||
|
||||
impl Mod for Keystrokes {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
// The extra's defaults: top left, with clicks per second.
|
||||
let corner = stored("corner")
|
||||
.and_then(|c| CORNERS.iter().position(|k| k.1 == c.trim()))
|
||||
.unwrap_or(0);
|
||||
let cps = stored("cps").is_none_or(|v| v.trim() != "false");
|
||||
let k = Keystrokes {
|
||||
corner,
|
||||
cps,
|
||||
shown: None,
|
||||
};
|
||||
k.page();
|
||||
Ok((k, Registration::new().on_frame()))
|
||||
}
|
||||
fn frame(&mut self, _seconds: f32) {
|
||||
self.draw();
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
match id {
|
||||
CORNER_BUTTON => self.corner = (self.corner + 1) % CORNERS.len(),
|
||||
CPS_BUTTON => self.cps = !self.cps,
|
||||
_ => return,
|
||||
}
|
||||
self.save();
|
||||
self.page();
|
||||
self.shown = None;
|
||||
self.draw();
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Keystrokes);
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "minimap"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official minimap: a map of the terrain around you in a corner."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "minimap"
|
||||
name = "Minimap"
|
||||
version = "1.0.0"
|
||||
description = "A map of the terrain around you in a corner, in map colours, square or round, north up or turning with you. Horizon's own, formerly an extra."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["render", "ui", "storage"]
|
||||
experimental = ["render", "ui"]
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Horizon's official minimap, formerly the built-in extra: a map of the
|
||||
//! terrain around the player in a corner (`render.set-minimap`; the game
|
||||
//! keeps its tiles). Its settings page has the extra's five options, kept in
|
||||
//! the mod's storage (`corner`, `size`, `shape`, `zoom`, `rotate`) with the
|
||||
//! extra's values, where the game writes a player's old extra settings when
|
||||
//! it installs the mod for them.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
render::{self, Corner, Minimap as MapView},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
/// (corner, stored value, label), in the extra's order.
|
||||
const CORNERS: [(Corner, &str, &str); 4] = [
|
||||
(Corner::TopLeft, "top_left", "Top left"),
|
||||
(Corner::TopRight, "top_right", "Top right"),
|
||||
(Corner::BottomLeft, "bottom_left", "Bottom left"),
|
||||
(Corner::BottomRight, "bottom_right", "Bottom right"),
|
||||
];
|
||||
/// (side in GUI pixels, label).
|
||||
const SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
/// Blocks per map pixel.
|
||||
const ZOOMS: [f32; 4] = [0.5, 1.0, 2.0, 4.0];
|
||||
/// Settings page buttons.
|
||||
const CORNER_BUTTON: u32 = 1;
|
||||
const SIZE_BUTTON: u32 = 2;
|
||||
const SHAPE_BUTTON: u32 = 3;
|
||||
const ZOOM_BUTTON: u32 = 4;
|
||||
const ROTATE_BUTTON: u32 = 5;
|
||||
|
||||
/// Indexes into the tables above, and the two switches.
|
||||
struct Minimap {
|
||||
corner: usize,
|
||||
size: usize,
|
||||
round: bool,
|
||||
zoom: usize,
|
||||
rotate: bool,
|
||||
}
|
||||
|
||||
impl Minimap {
|
||||
fn show(&self) {
|
||||
render::set_minimap(Some(MapView {
|
||||
corner: CORNERS[self.corner].0,
|
||||
size: f32::from(SIZES[self.size].0),
|
||||
blocks_per_pixel: ZOOMS[self.zoom],
|
||||
rotate: self.rotate,
|
||||
round: self.round,
|
||||
}));
|
||||
}
|
||||
fn page(&self) {
|
||||
let button = |id, label: String| Widget::Button(ButtonWidget { id, label });
|
||||
let n = ZOOMS[self.zoom];
|
||||
let blocks = if n < 1.0 {
|
||||
"½ block".to_owned()
|
||||
} else if n == 1.0 {
|
||||
"1 block".to_owned()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
};
|
||||
ui::set_settings(&[
|
||||
button(CORNER_BUTTON, format!("Corner: {}", CORNERS[self.corner].2)),
|
||||
button(SIZE_BUTTON, format!("Size: {}", SIZES[self.size].1)),
|
||||
button(
|
||||
SHAPE_BUTTON,
|
||||
format!("Shape: {}", if self.round { "Round" } else { "Square" }),
|
||||
),
|
||||
button(ZOOM_BUTTON, format!("Zoom: {blocks} per pixel")),
|
||||
button(
|
||||
ROTATE_BUTTON,
|
||||
format!(
|
||||
"Turn with the player: {}",
|
||||
if self.rotate { "ON" } else { "OFF" }
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
fn save(&self) {
|
||||
let _ = storage::set("corner", CORNERS[self.corner].1.as_bytes());
|
||||
let _ = storage::set("size", SIZES[self.size].0.to_string().as_bytes());
|
||||
let _ = storage::set("shape", if self.round { b"round" } else { b"square" });
|
||||
let _ = storage::set("zoom", ZOOMS[self.zoom].to_string().as_bytes());
|
||||
let _ = storage::set("rotate", if self.rotate { b"true" } else { b"false" });
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
storage::get(key)
|
||||
.and_then(|v| String::from_utf8(v).ok())
|
||||
.map(|v| v.trim().to_owned())
|
||||
}
|
||||
|
||||
impl Mod for Minimap {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
// The extra's defaults: top right, medium, square, 1 block per
|
||||
// pixel, north up. An unknown value keeps the default.
|
||||
let find = |key: &str, values: &[String], default: usize| {
|
||||
stored(key)
|
||||
.and_then(|v| values.iter().position(|s| *s == v))
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let corners: Vec<String> = CORNERS.iter().map(|c| c.1.to_owned()).collect();
|
||||
let sizes: Vec<String> = SIZES.iter().map(|s| s.0.to_string()).collect();
|
||||
let zooms: Vec<String> = ZOOMS.iter().map(f32::to_string).collect();
|
||||
let map = Minimap {
|
||||
corner: find("corner", &corners, 1),
|
||||
size: find("size", &sizes, 1),
|
||||
round: stored("shape").as_deref() == Some("round"),
|
||||
zoom: find("zoom", &zooms, 1),
|
||||
rotate: stored("rotate").as_deref() == Some("true"),
|
||||
};
|
||||
map.show();
|
||||
map.page();
|
||||
Ok((map, Registration::new()))
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
let next = |i: usize, n: usize| (i + 1) % n;
|
||||
match id {
|
||||
CORNER_BUTTON => self.corner = next(self.corner, CORNERS.len()),
|
||||
SIZE_BUTTON => self.size = next(self.size, SIZES.len()),
|
||||
SHAPE_BUTTON => self.round = !self.round,
|
||||
ZOOM_BUTTON => self.zoom = next(self.zoom, ZOOMS.len()),
|
||||
ROTATE_BUTTON => self.rotate = !self.rotate,
|
||||
_ => return,
|
||||
}
|
||||
self.save();
|
||||
self.show();
|
||||
self.page();
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Minimap);
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "recipes"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official recipe viewer: every item beside the inventory, its recipes and uses."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "recipes"
|
||||
name = "Recipes"
|
||||
version = "1.0.0"
|
||||
description = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses (both rebindable in Controls), and + fills the crafting grid with a recipe you know. In creative mode a click can give you the item (Mod settings). On servers that are not Horizon, it shows this game's own 26.3 recipes; what you know and what + can fill follow the server's recipe book."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["recipes", "ui", "input", "game-read", "chat", "storage"]
|
||||
experimental = ["recipes", "ui", "input"]
|
||||
@@ -1,364 +0,0 @@
|
||||
//! Horizon's official recipe viewer. Every item sits in the item panel the
|
||||
//! game draws beside the inventory and container screens (`browser`), with
|
||||
//! a search box. R over an item, the panel's or any slot's, shows its
|
||||
//! recipes, U its uses (`input.register-key`, so both can be rebound in
|
||||
//! Controls); a click does the same, left for recipes and right for uses.
|
||||
//! A recipe page has a tab per station, and + beside a crafting recipe the
|
||||
//! player knows fills the grid as the recipe book does (`recipes.place`).
|
||||
//! In creative mode, with "Click gives items" on, a click on a panel item
|
||||
//! gives one, a right click a stack (`recipes.give`).
|
||||
//!
|
||||
//! On a server that is not Horizon the recipes are the game's own 26.3
|
||||
//! ones; `known` and `place` follow the server's recipe book.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, browser, chat, game, input, recipes,
|
||||
recipes::Kind,
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
const RECIPE_KEY: &str = "recipe";
|
||||
const USES_KEY: &str = "uses";
|
||||
const GIVE_BUTTON: u32 = 1;
|
||||
/// What `set-recipes` takes at most.
|
||||
const MAX_RECIPES: usize = 256;
|
||||
const MAX_STACKS: usize = 64;
|
||||
/// The stations, in the order of their tabs, with each one's icon.
|
||||
const STATIONS: [&str; 8] = [
|
||||
"minecraft:crafting_table",
|
||||
"minecraft:furnace",
|
||||
"minecraft:blast_furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:campfire",
|
||||
"minecraft:stonecutter",
|
||||
"minecraft:smithing_table",
|
||||
"minecraft:brewing_stand",
|
||||
];
|
||||
|
||||
fn station(kind: Kind) -> usize {
|
||||
match kind {
|
||||
Kind::CraftingShaped | Kind::CraftingShapeless => 0,
|
||||
Kind::Smelting => 1,
|
||||
Kind::Blasting => 2,
|
||||
Kind::Smoking => 3,
|
||||
Kind::Campfire => 4,
|
||||
Kind::Stonecutting => 5,
|
||||
Kind::Smithing => 6,
|
||||
Kind::Brewing => 7,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(item: &str) -> browser::Stack {
|
||||
browser::Stack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A recipe page: an item's recipes or its uses, on one station's tab.
|
||||
#[derive(Clone)]
|
||||
struct View {
|
||||
item: browser::Stack,
|
||||
uses: bool,
|
||||
tab: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recipes {
|
||||
items: Vec<recipes::Item>,
|
||||
all: Vec<recipes::Recipe>,
|
||||
/// The panel's items now: indexes into `items`, after the search.
|
||||
listed: Vec<usize>,
|
||||
search: String,
|
||||
/// The page shown, then the ones Back returns to.
|
||||
history: Vec<View>,
|
||||
/// The book id behind each recipe the page shows (-1: none).
|
||||
shown: Vec<i32>,
|
||||
give: bool,
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
fn id(&self, s: &recipes::Stack) -> &str {
|
||||
self.items
|
||||
.get(s.item as usize)
|
||||
.map_or("minecraft:air", |i| &i.id)
|
||||
}
|
||||
fn convert(&self, s: &recipes::Stack) -> browser::Stack {
|
||||
browser::Stack {
|
||||
item: self.id(s).into(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
/// The same item, and the same components when `want` names some.
|
||||
fn matches(&self, s: &recipes::Stack, want: &browser::Stack) -> bool {
|
||||
self.id(s) == want.item && (want.components.is_empty() || s.components == want.components)
|
||||
}
|
||||
fn name<'a>(&'a self, item: &'a str) -> &'a str {
|
||||
self.items
|
||||
.iter()
|
||||
.find(|i| i.id == item)
|
||||
.map_or(item, |i| &i.name)
|
||||
}
|
||||
|
||||
/// Loads the tables once a world is open, and drops them when it closes.
|
||||
fn load(&mut self) {
|
||||
let in_world = game::get_player().is_some();
|
||||
if in_world && self.items.is_empty() {
|
||||
self.items = recipes::items();
|
||||
self.all = recipes::all();
|
||||
self.list();
|
||||
} else if !in_world && !self.items.is_empty() {
|
||||
browser::set_recipes("", &[], 0, &[]);
|
||||
browser::set_items(&[]);
|
||||
*self = Recipes {
|
||||
give: self.give,
|
||||
..Recipes::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the panel with the items the search keeps: every word must be
|
||||
/// in the name or the id; `@minecraft` keeps a namespace, `#logs` a tag.
|
||||
fn list(&mut self) {
|
||||
let words: Vec<String> = self
|
||||
.search
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let keeps = |item: &recipes::Item| {
|
||||
words.iter().all(|w| {
|
||||
if let Some(ns) = w.strip_prefix('@') {
|
||||
item.id.split(':').next().is_some_and(|n| n.starts_with(ns))
|
||||
} else if let Some(tag) = w.strip_prefix('#') {
|
||||
item.tags.iter().any(|t| t.contains(tag))
|
||||
} else {
|
||||
item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str())
|
||||
}
|
||||
})
|
||||
};
|
||||
self.listed = (self.items.iter().enumerate())
|
||||
.filter(|(_, i)| i.id != "minecraft:air" && keeps(i))
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
let stacks: Vec<_> = self
|
||||
.listed
|
||||
.iter()
|
||||
.map(|&n| stack(&self.items[n].id))
|
||||
.collect();
|
||||
browser::set_items(&stacks);
|
||||
}
|
||||
|
||||
/// The recipes a view lists, all stations together.
|
||||
fn found(&self, view: &View) -> Vec<&recipes::Recipe> {
|
||||
(self.all.iter())
|
||||
.filter(|r| {
|
||||
if view.uses {
|
||||
r.slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| self.matches(s, &view.item))
|
||||
} else {
|
||||
r.results.iter().any(|s| self.matches(s, &view.item))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens a page, pushing it on the history; nothing when it is empty.
|
||||
fn open(&mut self, item: browser::Stack, uses: bool) {
|
||||
let view = View { item, uses, tab: 0 };
|
||||
if self.found(&view).is_empty() {
|
||||
return;
|
||||
}
|
||||
self.history.push(view);
|
||||
self.show();
|
||||
}
|
||||
|
||||
/// Sends the page on top of the history, or the items when there is none.
|
||||
fn show(&mut self) {
|
||||
let Some(mut view) = self.history.last().cloned() else {
|
||||
self.shown.clear();
|
||||
browser::set_recipes("", &[], 0, &[]);
|
||||
return;
|
||||
};
|
||||
let found = self.found(&view);
|
||||
let stations: Vec<usize> = (0..STATIONS.len())
|
||||
.filter(|&s| found.iter().any(|r| station(r.kind) == s))
|
||||
.collect();
|
||||
view.tab = view.tab.min(stations.len().saturating_sub(1));
|
||||
let on: Vec<&recipes::Recipe> = (found.iter().copied())
|
||||
.filter(|r| stations.get(view.tab) == Some(&station(r.kind)))
|
||||
.take(MAX_RECIPES)
|
||||
.collect();
|
||||
let known = recipes::known();
|
||||
let views: Vec<browser::RecipeView> = on.iter().map(|r| self.view(r, &known)).collect();
|
||||
let mut title = format!(
|
||||
"{}: {}",
|
||||
if view.uses { "Uses" } else { "Recipes" },
|
||||
self.name(&view.item.item)
|
||||
);
|
||||
let total = found
|
||||
.iter()
|
||||
.filter(|r| stations.get(view.tab) == Some(&station(r.kind)))
|
||||
.count();
|
||||
if total > on.len() {
|
||||
title.push_str(&format!(" ({} of {total})", on.len()));
|
||||
}
|
||||
let tabs: Vec<_> = stations.iter().map(|&s| stack(STATIONS[s])).collect();
|
||||
browser::set_recipes(&title, &tabs, view.tab as u32, &views);
|
||||
self.shown = on.iter().map(|r| r.book_id).collect();
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = view.tab;
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, r: &recipes::Recipe, known: &[i32]) -> browser::RecipeView {
|
||||
let mut slots: Vec<Vec<browser::Stack>> = (r.slots.iter())
|
||||
.map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect())
|
||||
.collect();
|
||||
let results = r
|
||||
.results
|
||||
.iter()
|
||||
.take(MAX_STACKS)
|
||||
.map(|s| self.convert(s))
|
||||
.collect();
|
||||
let (layout, width, height, note) = match r.kind {
|
||||
Kind::CraftingShaped => (browser::Layout::Grid, r.width, r.height, String::new()),
|
||||
Kind::CraftingShapeless => {
|
||||
let n = slots.len() as u32;
|
||||
let width = match n {
|
||||
0 | 1 => 1,
|
||||
2..=4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let height = n.div_ceil(width).max(1);
|
||||
slots.resize((width * height) as usize, Vec::new());
|
||||
(browser::Layout::Grid, width, height, "Shapeless".into())
|
||||
}
|
||||
Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => {
|
||||
let seconds = r.cooking_time as f32 / 20.0;
|
||||
let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0);
|
||||
(browser::Layout::Cooking, 1, 1, note)
|
||||
}
|
||||
Kind::Stonecutting => (browser::Layout::Single, 1, 1, String::new()),
|
||||
Kind::Smithing => (browser::Layout::Smithing, 3, 1, String::new()),
|
||||
Kind::Brewing => (browser::Layout::Brewing, 2, 1, String::new()),
|
||||
};
|
||||
// + only for what the recipe book shows; greyed until the book has it.
|
||||
let action = (r.book_id >= 0).then(|| known.contains(&r.book_id));
|
||||
browser::RecipeView {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
slots,
|
||||
results,
|
||||
note,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, event: browser::Event) {
|
||||
match event {
|
||||
browser::Event::Search(text) => {
|
||||
self.search = text;
|
||||
self.list();
|
||||
}
|
||||
browser::Event::Clicked(c) => {
|
||||
let Some(&n) = self.listed.get(c.index as usize) else {
|
||||
return;
|
||||
};
|
||||
let item = self.items[n].id.clone();
|
||||
if self.give {
|
||||
let count = if c.right { 99 } else { 1 };
|
||||
let gift = recipes::Stack {
|
||||
item: n as u32,
|
||||
count,
|
||||
components: String::new(),
|
||||
};
|
||||
if recipes::give(&gift).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.open(stack(&item), c.right);
|
||||
}
|
||||
browser::Event::Key(k) if k.key == RECIPE_KEY || k.key == USES_KEY => {
|
||||
let item = browser::Stack {
|
||||
count: 1,
|
||||
..k.stack
|
||||
};
|
||||
self.open(item, k.key == USES_KEY);
|
||||
}
|
||||
browser::Event::SlotClicked(c) => {
|
||||
let item = browser::Stack {
|
||||
count: 1,
|
||||
..c.stack
|
||||
};
|
||||
self.open(item, c.right);
|
||||
}
|
||||
browser::Event::Tab(i) => {
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = i as usize;
|
||||
}
|
||||
self.show();
|
||||
}
|
||||
browser::Event::Action(i) => {
|
||||
let book = self.shown.get(i as usize).copied();
|
||||
if let Some(Err(e)) = book.map(|b| recipes::place(b, false)) {
|
||||
chat::show(&e);
|
||||
}
|
||||
}
|
||||
browser::Event::Back => {
|
||||
self.history.pop();
|
||||
self.show();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn page(&self) {
|
||||
ui::set_settings(&[Widget::Button(ButtonWidget {
|
||||
id: GIVE_BUTTON,
|
||||
label: format!(
|
||||
"Click gives items (creative): {}",
|
||||
if self.give { "ON" } else { "OFF" }
|
||||
),
|
||||
})]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Mod for Recipes {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
input::register_key(RECIPE_KEY, "Show recipes", "key.keyboard.r")?;
|
||||
input::register_key(USES_KEY, "Show uses", "key.keyboard.u")?;
|
||||
let give = storage::get("give").is_some_and(|v| v == b"true");
|
||||
let me = Recipes {
|
||||
give,
|
||||
..Recipes::default()
|
||||
};
|
||||
me.page();
|
||||
Ok((me, Registration::new().on_frame()))
|
||||
}
|
||||
fn frame(&mut self, _seconds: f32) {
|
||||
self.load();
|
||||
// The keys matter only over an item, which `browser` reports.
|
||||
let _ = input::poll();
|
||||
for event in browser::poll() {
|
||||
self.handle(event);
|
||||
}
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
if id == GIVE_BUTTON {
|
||||
self.give = !self.give;
|
||||
let _ = storage::set("give", if self.give { b"true" } else { b"false" });
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Recipes);
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "zoom"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official zoom: hold a key to zoom, the wheel changes it while held."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
id = "zoom"
|
||||
name = "Zoom"
|
||||
version = "1.0.0"
|
||||
description = "Hold a key to zoom in; the mouse wheel changes the zoom while you hold it. Horizon's own, formerly an extra."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["render", "input", "ui", "storage"]
|
||||
experimental = ["render", "input", "ui"]
|
||||
@@ -1,141 +0,0 @@
|
||||
//! Horizon's official zoom, formerly the built-in extra: hold the key (Z
|
||||
//! by default) to zoom in, and turn the mouse wheel while holding it to zoom
|
||||
//! further in or out. Its settings page has the extra's four options: the
|
||||
//! game lists the key there first and keeps the player's choice
|
||||
//! (`horizon-mods.txt`); the rest is kept in the mod's storage (`level`,
|
||||
//! `smooth`, `sensitivity`),
|
||||
//! where the game writes a player's old extra settings when it installs the
|
||||
//! mod for them.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
input::{self, Event},
|
||||
render::{self, Zoom as ZoomView},
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
/// The key's name for `register-key`.
|
||||
const KEY: &str = "zoom";
|
||||
/// The zooms the starting zoom cycles through, and the wheel's limits.
|
||||
const LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
const MIN: f32 = 1.25;
|
||||
const MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const STEP: f32 = 1.25;
|
||||
/// Settings page buttons, under the key's row.
|
||||
const LEVEL_BUTTON: u32 = 2;
|
||||
const SMOOTH_BUTTON: u32 = 3;
|
||||
const SENSITIVITY_BUTTON: u32 = 4;
|
||||
|
||||
struct Zoom {
|
||||
/// The zoom the key starts at, one of `LEVELS`.
|
||||
level: u8,
|
||||
/// Ease in and out like the spyglass.
|
||||
smooth: bool,
|
||||
/// Turn slower the more you zoom in.
|
||||
sensitivity: bool,
|
||||
/// The zoom while the key is held; None when it is up.
|
||||
held: Option<f32>,
|
||||
}
|
||||
|
||||
impl Zoom {
|
||||
/// Tells the game the zoom in effect.
|
||||
fn show(&self) {
|
||||
let factor = self.held.unwrap_or(1.0);
|
||||
render::set_zoom(ZoomView {
|
||||
factor,
|
||||
smooth: self.smooth,
|
||||
turn: if self.sensitivity { 1.0 / factor } else { 1.0 },
|
||||
});
|
||||
}
|
||||
fn page(&self) {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
ui::set_settings(&[
|
||||
Widget::Button(ButtonWidget {
|
||||
id: LEVEL_BUTTON,
|
||||
label: format!("Starting zoom: {}x", self.level),
|
||||
}),
|
||||
Widget::Button(ButtonWidget {
|
||||
id: SMOOTH_BUTTON,
|
||||
label: format!("Smooth zoom: {}", on(self.smooth)),
|
||||
}),
|
||||
Widget::Button(ButtonWidget {
|
||||
id: SENSITIVITY_BUTTON,
|
||||
label: format!("Slower turning: {}", on(self.sensitivity)),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
fn save(&self) {
|
||||
let flag = |b: bool| if b { &b"true"[..] } else { &b"false"[..] };
|
||||
let _ = storage::set("level", self.level.to_string().as_bytes());
|
||||
let _ = storage::set("smooth", flag(self.smooth));
|
||||
let _ = storage::set("sensitivity", flag(self.sensitivity));
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
storage::get(key).and_then(|v| String::from_utf8(v).ok())
|
||||
}
|
||||
|
||||
impl Mod for Zoom {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
input::register_key(KEY, "Zoom key", "key.keyboard.z")?;
|
||||
// The extra's defaults: 4x, smooth, slower turning.
|
||||
let level = stored("level")
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
.map_or(4, |v| v.clamp(2, MAX as u8));
|
||||
let flag = |key: &str| stored(key).is_none_or(|v| v.trim() != "false");
|
||||
let zoom = Zoom {
|
||||
level,
|
||||
smooth: flag("smooth"),
|
||||
sensitivity: flag("sensitivity"),
|
||||
held: None,
|
||||
};
|
||||
zoom.page();
|
||||
Ok((zoom, Registration::new().on_frame()))
|
||||
}
|
||||
fn frame(&mut self, _seconds: f32) {
|
||||
let mut changed = false;
|
||||
for event in input::poll() {
|
||||
match event {
|
||||
Event::KeyDown(k) if k == KEY => {
|
||||
self.held = Some(f32::from(self.level).clamp(MIN, MAX));
|
||||
// While held, the wheel zooms instead of moving the hotbar.
|
||||
input::claim_wheel(true);
|
||||
}
|
||||
Event::KeyUp(k) if k == KEY => {
|
||||
self.held = None;
|
||||
input::claim_wheel(false);
|
||||
}
|
||||
Event::Wheel(steps) => {
|
||||
let Some(z) = &mut self.held else { continue };
|
||||
*z = (*z * STEP.powf(steps)).clamp(MIN, MAX);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.show();
|
||||
}
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
match id {
|
||||
LEVEL_BUTTON => {
|
||||
// As the extra cycled: an unknown level counts as the first.
|
||||
let i = LEVELS.iter().position(|&l| l == self.level).unwrap_or(0);
|
||||
self.level = LEVELS[(i + 1) % LEVELS.len()];
|
||||
}
|
||||
SMOOTH_BUTTON => self.smooth = !self.smooth,
|
||||
SENSITIVITY_BUTTON => self.sensitivity = !self.sensitivity,
|
||||
_ => return,
|
||||
}
|
||||
self.save();
|
||||
self.page();
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Zoom);
|
||||
+16
-11
@@ -373,10 +373,14 @@
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.",
|
||||
"dev": "Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.",
|
||||
"dev": "`cargo horizon-mod` makes content mods (`new`, `build`, `run`, `check`; #1859, #1810, docs/mods.md). Native mods start from horizon-native-mod-template, and `native.py check` in horizon-mods applies them on a chosen Horizon and builds them together, with a weekly check of every official mod on the newest Nightly (docs/native-mods.md). The WebAssembly SDK and its test runner were removed. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and a CI check that fails when a stable reference entry is removed without a new major version (docs/api/stability.md).",
|
||||
"links": [
|
||||
"#1859",
|
||||
"#1810",
|
||||
"#3388",
|
||||
"docs/native-mods.md"
|
||||
"docs/mods.md",
|
||||
"docs/native-mods.md",
|
||||
"docs/api/stability.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -523,7 +527,7 @@
|
||||
"status": "next",
|
||||
"area": "modpacks",
|
||||
"plain": "When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.",
|
||||
"dev": "Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run (docs/modpacks.md, docs/mod-browser.md).",
|
||||
"dev": "Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run (docs/modpacks.md, docs/mod-browser.md).",
|
||||
"links": [
|
||||
"docs/modpacks.md",
|
||||
"docs/mod-browser.md"
|
||||
@@ -535,10 +539,11 @@
|
||||
"status": "next",
|
||||
"area": "modpacks",
|
||||
"plain": "Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.",
|
||||
"dev": "333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see 'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md).",
|
||||
"dev": "333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now (docs/native-mods.md): each port is a horizon-mixins bundle. The path isn't started yet (docs/modpacks.md).",
|
||||
"links": [
|
||||
"#1854",
|
||||
"docs/modpacks.md"
|
||||
"docs/modpacks.md",
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -848,7 +853,7 @@
|
||||
"status": "later",
|
||||
"area": "modpacks",
|
||||
"plain": "Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.",
|
||||
"dev": "Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.",
|
||||
"dev": "Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.",
|
||||
"links": [
|
||||
"#527"
|
||||
]
|
||||
@@ -892,8 +897,8 @@
|
||||
"title": "Orbital Laser on the normal game",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.",
|
||||
"dev": "A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.",
|
||||
"plain": "The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.",
|
||||
"dev": "A native port (a horizon-mixins bundle, docs/native-mods.md) of https://github.com/Stink-O/orbital-laser (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.",
|
||||
"links": [
|
||||
"https://github.com/Stink-O/orbital-laser"
|
||||
]
|
||||
@@ -916,7 +921,7 @@
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.",
|
||||
"dev": "Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.",
|
||||
"dev": "Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod.",
|
||||
"links": [
|
||||
"#805"
|
||||
]
|
||||
@@ -927,9 +932,9 @@
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.",
|
||||
"dev": "Needs server mods, mod networking and an audio interface in the mod API before anything can be built (docs/api/mods.md).",
|
||||
"dev": "A native mod (docs/native-mods.md). It needs a way to carry voice between players and an audio path in the game before anything can be built.",
|
||||
"links": [
|
||||
"docs/api/mods.md"
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+17
-20
@@ -298,8 +298,8 @@ title = "Tools for people who make mods"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next."""
|
||||
dev = """Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods."""
|
||||
links = ["#3388", "docs/native-mods.md"]
|
||||
dev = """`cargo horizon-mod` makes content mods (`new`, `build`, `run`, `check`; #1859, #1810, docs/mods.md). Native mods start from horizon-native-mod-template, and `native.py check` in horizon-mods applies them on a chosen Horizon and builds them together, with a weekly check of every official mod on the newest Nightly (docs/native-mods.md). The WebAssembly SDK and its test runner were removed. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and a CI check that fails when a stable reference entry is removed without a new major version (docs/api/stability.md)."""
|
||||
links = ["#1859", "#1810", "#3388", "docs/mods.md", "docs/native-mods.md", "docs/api/stability.md"]
|
||||
|
||||
[[item]]
|
||||
id = "pregen"
|
||||
@@ -452,8 +452,8 @@ area = "modpacks"
|
||||
plain = """When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be \
|
||||
converted, so you don't have to wait for the rewrite."""
|
||||
dev = """Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item \
|
||||
definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API \
|
||||
allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. \
|
||||
definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native \
|
||||
mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. \
|
||||
The browser says what works before the install. Java code itself does not run (docs/modpacks.md, \
|
||||
docs/mod-browser.md)."""
|
||||
links = ["docs/modpacks.md", "docs/mod-browser.md"]
|
||||
@@ -465,11 +465,11 @@ status = "next"
|
||||
area = "modpacks"
|
||||
plain = """Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are \
|
||||
most of what a big modpack is made of."""
|
||||
dev = """333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block \
|
||||
dev = """333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the gaps (ticking block \
|
||||
entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the \
|
||||
recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see \
|
||||
'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md)."""
|
||||
links = ["#1854", "docs/modpacks.md"]
|
||||
recommended path. Code mods are native mods now (docs/native-mods.md): each port is a horizon-mixins bundle. \
|
||||
The path isn't started yet (docs/modpacks.md)."""
|
||||
links = ["#1854", "docs/modpacks.md", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "datapack-browser"
|
||||
@@ -804,8 +804,7 @@ area = "modpacks"
|
||||
plain = """Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not \
|
||||
started: the Convert button and native rewrites come first."""
|
||||
dev = """Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own \
|
||||
classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. \
|
||||
Whatever is chosen has to run inside the same sandbox and capability checks as native mods."""
|
||||
classes. Option to look at again: a bridge for simple Fabric mods."""
|
||||
links = ["#527"]
|
||||
|
||||
[[item]]
|
||||
@@ -845,11 +844,10 @@ id = "orbital-laser"
|
||||
title = "Orbital Laser on the normal game"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the \
|
||||
registry as the showcase of the mod API."""
|
||||
dev = """A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world \
|
||||
capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its \
|
||||
author's fork. Tested in the game, and listed in the registry."""
|
||||
plain = """The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and \
|
||||
listed in the registry."""
|
||||
dev = """A native port (a horizon-mixins bundle, docs/native-mods.md) of https://github.com/Stink-O/orbital-laser \
|
||||
(MIT), which today runs through its author's fork. Tested in the game, and listed in the registry."""
|
||||
links = ["https://github.com/Stink-O/orbital-laser"]
|
||||
|
||||
[[item]]
|
||||
@@ -868,8 +866,7 @@ status = "later"
|
||||
area = "mods"
|
||||
plain = """Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the \
|
||||
base game."""
|
||||
dev = """Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. \
|
||||
It needs server mods, entities and rendering first."""
|
||||
dev = """Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod."""
|
||||
links = ["#805"]
|
||||
|
||||
[[item]]
|
||||
@@ -878,9 +875,9 @@ title = "Proximity voice chat as a native mod"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod."""
|
||||
dev = """Needs server mods, mod networking and an audio interface in the mod API before anything can be built \
|
||||
(docs/api/mods.md)."""
|
||||
links = ["docs/api/mods.md"]
|
||||
dev = """A native mod (docs/native-mods.md). It needs a way to carry voice between players and an audio path in the \
|
||||
game before anything can be built."""
|
||||
links = ["docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "light-default-pack"
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user