mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fbb0cde70 | ||
|
|
6ff335eacf | ||
|
|
b8027d4b19 | ||
|
|
4d98e545eb | ||
|
|
e34cb7c021 | ||
|
|
b66861c721 | ||
|
|
27f63ce50c | ||
|
|
8682fb845d | ||
|
|
189b2c6b3c | ||
|
|
c048292f3f | ||
|
|
c8464c6bff | ||
|
|
b12c89650e | ||
|
|
0a9a7c0d06 | ||
|
|
d104a350cd | ||
|
|
6cd4238930 | ||
|
|
3c1ab736d5 | ||
|
|
a31a435525 | ||
|
|
7650451b4f | ||
|
|
3c22ff0849 | ||
|
|
c0171fa6bc | ||
|
|
707348fcd5 | ||
|
|
8a458496c7 | ||
|
|
3144276935 | ||
|
|
6c995bfa15 | ||
|
|
88b7bfafc0 | ||
|
|
2a0f4e39a8 | ||
|
|
acbcaad743 | ||
|
|
e3e08911ba | ||
|
|
87ff54e702 | ||
|
|
8cc8c6180d | ||
|
|
852e7cd524 | ||
|
|
28ef79ed66 | ||
|
|
f52d053817 | ||
|
|
d64bd7c528 | ||
|
|
45bc45b396 | ||
|
|
6f0c0c13b6 | ||
|
|
b90ab85f44 | ||
|
|
b110608a93 | ||
|
|
382380fc8b | ||
|
|
9e447a7e92 | ||
|
|
4498df8be5 | ||
|
|
57c5f81299 | ||
|
|
ce46917121 | ||
|
|
09d61ec866 | ||
|
|
427b48aa42 | ||
|
|
2b54c8a4ab | ||
|
|
ff26716cd1 | ||
|
|
71126d3e13 | ||
|
|
641ff51089 | ||
|
|
45bd296f14 | ||
|
|
cad817c82d | ||
|
|
a68c2f587d | ||
|
|
d31832eaf4 | ||
|
|
6c3473c6e0 | ||
|
|
e52f0fdcc2 | ||
|
|
3f5a323fd9 | ||
|
|
24ab642e7b | ||
|
|
c58af30ca6 | ||
|
|
6ace65fc18 | ||
|
|
108ced5793 | ||
|
|
a1f1501305 | ||
|
|
cbdbf068bf | ||
|
|
70b832d658 | ||
|
|
c1814283e0 | ||
|
|
4512105e85 | ||
|
|
c3763ff1b3 | ||
|
|
0958cb297a | ||
|
|
a6c8e9070a | ||
|
|
886ecd9b20 | ||
|
|
d113131f7e | ||
|
|
3df60c21c2 | ||
|
|
ab92ab346c | ||
|
|
0bfb011ee2 | ||
|
|
2631eca00b | ||
|
|
7b0192118e | ||
|
|
bb3a289caa | ||
|
|
7dfd4c6280 | ||
|
|
31eecdc271 | ||
|
|
2d088739e5 | ||
|
|
a6548089c4 | ||
|
|
bbc57aa838 | ||
|
|
d143666e46 | ||
|
|
c7432c21e1 | ||
|
|
2c0d9cc30e | ||
|
|
f988ed417a | ||
|
|
21019afc2b | ||
|
|
785e83feeb | ||
|
|
9aa6d7723f | ||
|
|
e50a4c6443 | ||
|
|
bd09f8b03e | ||
|
|
dab13669f5 | ||
|
|
df8b23637e | ||
|
|
390da26cf4 | ||
|
|
1285247230 | ||
|
|
bcd42f50e9 | ||
|
|
c9f554d1c4 | ||
|
|
0f2a497f02 | ||
|
|
319c7fcaee | ||
|
|
eb6c81945a | ||
|
|
d89fb16f60 | ||
|
|
c43f4bdb2b |
No files matched your search
@@ -8,7 +8,7 @@ name: nightly audit
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -31,14 +31,19 @@ jobs:
|
||||
audit:
|
||||
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
|
||||
runs-on: [self-hosted, horizon-game, macOS]
|
||||
timeout-minutes: 150
|
||||
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
- name: 03:07 in Paris
|
||||
id: when
|
||||
env:
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
|
||||
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
|
||||
offset=$(TZ=Europe/Paris date +%z)
|
||||
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
|
||||
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
|
||||
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -66,7 +71,8 @@ jobs:
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
|
||||
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
|
||||
@@ -23,10 +23,10 @@ env:
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
# The Mac mini's release runner (label added 2026-10-26), not the two `horizon-game` runners: behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and releases wait for it. Releases need it green
|
||||
# first, so the two never compete. Not the desktop: a job slowed down because the maintainer came back could time
|
||||
# out, and a failure here bisects and comments on merges.
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
|
||||
@@ -52,7 +52,14 @@ jobs:
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
run: |
|
||||
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
|
||||
# the tool nor the number, so there is nothing to check.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
|
||||
@@ -66,7 +66,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
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
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -156,7 +156,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
@@ -164,8 +164,8 @@ when you touch player-facing gameplay:
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
Generated
+39
@@ -1437,12 +1437,14 @@ dependencies = [
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-shader-api",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmparser",
|
||||
"wasmtime",
|
||||
"wit-component",
|
||||
"wit-parser",
|
||||
@@ -2082,6 +2084,13 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimap"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
@@ -4140,6 +4149,28 @@ dependencies = [
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "254.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "febc59cde513fc8f39b8ffd487893723c555fdd024e2e3dc7d165181e6397ea0"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.254.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7db96351e51a3a1bbe6812f029e1100b4d9a5e02161fb63104bd2271fc847e7e"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -4287,6 +4318,13 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "welcome"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "30.0.1"
|
||||
@@ -4998,6 +5036,7 @@ dependencies = [
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wat",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "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", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/zoom", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "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", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! `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, and import
|
||||
//! no more than its capabilities.
|
||||
//! 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.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
@@ -46,6 +47,7 @@ impl Report {
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"api_level": self.facts.api_level,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
@@ -69,6 +71,8 @@ impl Report {
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if self.kind == Kind::Shader {
|
||||
out += &format!(", shader API {}", f.api);
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
@@ -84,6 +88,9 @@ impl Report {
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
if let Some(level) = f.api_level {
|
||||
out += &format!(" needs mod API level {level} or newer\n");
|
||||
}
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
@@ -113,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -191,40 +199,66 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(str::to_owned).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 {
|
||||
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,
|
||||
}),
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
});
|
||||
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");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
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::Feedback { text, error } => {
|
||||
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}"),
|
||||
@@ -285,7 +319,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(e.contains("horizon-shaders.toml"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
|
||||
@@ -610,12 +610,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let home =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let mut s = Session::new(home.join("nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,33 @@ impl Displayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
costliest
|
||||
.iter()
|
||||
.take(SHOWN)
|
||||
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
|
||||
);
|
||||
if costliest.len() > SHOWN {
|
||||
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
|
||||
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
@@ -337,4 +364,15 @@ mod tests {
|
||||
(1.0, [640, 360])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_pass_lines_list_the_costliest_passes_first() {
|
||||
assert!(shader_pass_lines(&[]).is_empty());
|
||||
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
|
||||
let lines = shader_pass_lines(&passes);
|
||||
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
|
||||
assert_eq!(lines[1], "p9: 9.00 ms");
|
||||
assert_eq!(lines[8], "p2: 2.00 ms");
|
||||
assert_eq!(lines[9], "2 more: 1.00 ms");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use crate::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
|
||||
mod fluids;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
pub health: i32,
|
||||
@@ -165,18 +167,16 @@ impl ItemEntity {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
|
||||
self.pickup_delay -= 1;
|
||||
}
|
||||
let eye = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y + 0.1).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let in_water = facts.get(eye).fluid == Fluid::Water;
|
||||
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
|
||||
let (water_height, lava_height) = self.update_fluids(world, fluids);
|
||||
let in_water = water_height > f64::from(0.1f32);
|
||||
if in_water {
|
||||
// ItemEntity.setUnderwaterMovement
|
||||
self.velocity = Vec3::new(
|
||||
@@ -184,7 +184,7 @@ impl ItemEntity {
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if facts.get(eye).fluid == Fluid::Lava {
|
||||
} else if lava_height > f64::from(0.1f32) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.95,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
@@ -245,6 +245,8 @@ impl ItemEntity {
|
||||
}
|
||||
}
|
||||
self.age += 1;
|
||||
// ItemEntity.tick refreshes immersion and pushes again after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::ItemEntity;
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
entity_fluid::{
|
||||
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
|
||||
FluidSections, FluidTagId,
|
||||
},
|
||||
fluid::{FluidHeightWorld, FluidKind, FluidState},
|
||||
fluid_flow::{FluidFlowWorld, flow_at},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
type Error = std::convert::Infallible;
|
||||
struct ItemFluidWorld<'a> {
|
||||
item: &'a mut ItemEntity,
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = ItemFluidWorld {
|
||||
item: self,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for ItemFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
FluidTags.fluid_in_tag(kind, tag)
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> Result<Vec3, Error> {
|
||||
Ok(self.item.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
|
||||
let v = self.item.velocity;
|
||||
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
|
||||
if delta.is_finite() && sum.is_finite() {
|
||||
self.item.velocity = sum;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Chunk = ();
|
||||
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
|
||||
Ok(Some(
|
||||
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.item.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.item.position.x.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> Result<f64, Error> {
|
||||
Ok(self.item.position.y + f64::from(0.2125f32))
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.item.position.z.floor() as i32
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.camera_height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
|
||||
flow_at(self, f, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
|
||||
}
|
||||
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
Ok(self.fluids.sturdy(state, Direction::Down))
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Block = BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
|
||||
Ok(self.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.blocks_flow(s)
|
||||
}
|
||||
fn is_ice_class(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.ice(s)
|
||||
}
|
||||
fn is_face_sturdy(
|
||||
&mut self,
|
||||
s: BlockStateId,
|
||||
_: BlockPos,
|
||||
d: Direction,
|
||||
) -> Result<bool, Error> {
|
||||
Ok(self.fluids.sturdy(s, d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let item = ItemEntity {
|
||||
id: 1,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
stack: crate::items::ItemStack {
|
||||
item: "minecraft:sugar_cane".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
thrower: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(&world, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(&world, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! Horizon's optional extras (minimap, lighting, inventory tools): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
@@ -6,17 +6,8 @@
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The name the options screen uses while the zoom key waits for a key.
|
||||
pub const ZOOM_KEY: &str = "horizon.zoom";
|
||||
/// The same for the sort key.
|
||||
/// The name the options screen uses while the sort key waits for a key.
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// Zoom levels the default cycles through, and the wheel's limits.
|
||||
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
pub const ZOOM_MIN: f32 = 1.25;
|
||||
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const ZOOM_STEP: f32 = 1.25;
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -184,7 +175,7 @@ impl Extras {
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
|
||||
e.zoom_level = v.clamp(2, 10);
|
||||
}
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
@@ -270,11 +261,10 @@ impl Extras {
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
ZOOM_KEY => self.zoom_key = value,
|
||||
SORT_KEY => self.sort_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
@@ -291,87 +281,12 @@ impl Extras {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom key's state: held or not, and the zoom the wheel has set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Zoom {
|
||||
held: bool,
|
||||
factor: f32,
|
||||
}
|
||||
impl Default for Zoom {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
factor: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Zoom {
|
||||
/// The key went down: start at the default level.
|
||||
pub fn press(&mut self, level: u8) {
|
||||
self.held = true;
|
||||
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.held = false;
|
||||
}
|
||||
/// A press or release of the zoom key. Only a press while `playing` (no
|
||||
/// screen or text field open) zooms; anywhere else the key belongs to
|
||||
/// the screen, and the zoom lets go.
|
||||
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
|
||||
if !pressed || !playing {
|
||||
self.release();
|
||||
} else if !repeat {
|
||||
self.press(level);
|
||||
}
|
||||
}
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
/// Wheel steps while held (positive zooms in); false when not zooming.
|
||||
pub fn scroll(&mut self, steps: f32) -> bool {
|
||||
if !self.held {
|
||||
return false;
|
||||
}
|
||||
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
true
|
||||
}
|
||||
/// The zoom in effect: 1 when the key is up or the spyglass is in use
|
||||
/// (scoping keeps vanilla's spyglass view).
|
||||
pub fn factor(&self, scoping: bool) -> f32 {
|
||||
if self.held && !scoping {
|
||||
self.factor
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla key mappings bound to the zoom key. The key still reaches them
|
||||
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
|
||||
/// says so.
|
||||
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
|
||||
if e.zoom_key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
crate::keybinds::MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|name| keys.get(name) == e.zoom_key)
|
||||
.collect()
|
||||
}
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
}
|
||||
/// The mouse turning scale while zoomed.
|
||||
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
|
||||
if lower_sensitivity && zoom > 1.0 {
|
||||
1.0 / f64::from(zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame times over the last few seconds: frames per second, the minimum
|
||||
/// and the 1% low, recomputed twice a second.
|
||||
pub struct FpsCounter {
|
||||
@@ -463,17 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
MinimapShape,
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -492,19 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
Ok(ZoomLevel),
|
||||
Ok(ZoomSmooth),
|
||||
Ok(ZoomSensitivity),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
Ok(MinimapSize),
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -514,37 +405,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
]
|
||||
}
|
||||
/// "1 block", "2 blocks", "½ block".
|
||||
fn block_count(n: f32) -> String {
|
||||
if n < 1.0 {
|
||||
"½ block".into()
|
||||
} else if n == 1.0 {
|
||||
"1 block".into()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
}
|
||||
}
|
||||
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
|
||||
let i = all.iter().position(|v| *v == current).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
Item::ZoomSmooth => "Smooth zoom",
|
||||
Item::ZoomSensitivity => "Slower turning",
|
||||
Item::MinimapCorner => "Corner",
|
||||
Item::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
Item::MinimapZoom => "Zoom",
|
||||
Item::MinimapRotate => "Turn with the player",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -559,12 +424,10 @@ impl Item {
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
|
||||
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | MouseTweaks | Sort | AutoReconnect
|
||||
| NoAi | AgentControl | DiscordPresence => {
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -576,17 +439,6 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
Item::ZoomSmooth => "Eases in and out like the spyglass.",
|
||||
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
|
||||
Item::MinimapCorner => "Overlays in one corner stack.",
|
||||
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
|
||||
Item::MinimapSize => "How big the map is on screen.",
|
||||
Item::MinimapShape => "Square or round.",
|
||||
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
|
||||
Item::MinimapRotate => "Forward is up; off keeps north up.",
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -617,8 +469,8 @@ impl Item {
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright | Item::Zoom => Some(Cost::None),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -628,11 +480,6 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -646,55 +493,32 @@ impl Item {
|
||||
/// The value of the other items, as their button shows it.
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::MinimapCorner => e.minimap_corner.label().into(),
|
||||
Item::MinimapSize => MINIMAP_SIZES
|
||||
.iter()
|
||||
.find(|s| s.0 == e.minimap_size)
|
||||
.map_or("Medium", |s| s.1)
|
||||
.into(),
|
||||
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
|
||||
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
/// A click (`_back` for the right button: every item left is a switch,
|
||||
/// so both flip it). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, _back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
|
||||
Item::MinimapSize => {
|
||||
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
|
||||
e.minimap_size = next(&sizes, e.minimap_size, back);
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
/// The key name the options screen waits on for a key item.
|
||||
pub fn key_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Item::ZoomKey => Some(ZOOM_KEY),
|
||||
Item::SortKey => Some(SORT_KEY),
|
||||
_ => None,
|
||||
}
|
||||
@@ -752,43 +576,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_zoom_key_is_free_in_vanilla() {
|
||||
let key = Extras::default().zoom_key;
|
||||
assert!(crate::keybinds::key_code(&key).is_some());
|
||||
assert!(
|
||||
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
|
||||
"{key} is a vanilla binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_vanilla_unless_held_and_not_scoping() {
|
||||
let mut z = Zoom::default();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
|
||||
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
|
||||
z.press(4);
|
||||
assert_eq!(z.factor(false), 4.0);
|
||||
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
|
||||
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
|
||||
assert!(z.scroll(1.0));
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
for _ in 0..40 {
|
||||
z.scroll(1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MAX);
|
||||
for _ in 0..80 {
|
||||
z.scroll(-1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MIN);
|
||||
z.release();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
z.press(2);
|
||||
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_target_eases_like_the_spyglass() {
|
||||
fn a_mods_zoom_eases_like_the_spyglass() {
|
||||
// Camera.tickFov halves the distance to the target each tick.
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.tick(None, 1.0);
|
||||
@@ -812,55 +600,6 @@ mod tests {
|
||||
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_key_only_zooms_while_playing() {
|
||||
let mut z = Zoom::default();
|
||||
// In chat, a sign, a text field or any screen: no zoom.
|
||||
z.key(true, false, false, 4);
|
||||
assert!(!z.held());
|
||||
z.key(true, false, true, 4);
|
||||
assert!(z.held());
|
||||
// A screen opening while held lets go (its key presses are the screen's).
|
||||
z.key(true, true, false, 4);
|
||||
assert!(!z.held());
|
||||
// Key repeats do not restart a zoom the wheel changed.
|
||||
z.key(true, false, true, 4);
|
||||
z.scroll(1.0);
|
||||
z.key(true, true, true, 4);
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
z.key(false, false, true, 4);
|
||||
assert!(!z.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_key_conflicts_name_the_vanilla_mappings() {
|
||||
let mut e = Extras::default();
|
||||
let mut keys = crate::keybinds::KeyBinds::default();
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
assert_eq!(
|
||||
zoom_key_conflicts(&e, &keys),
|
||||
[
|
||||
"key.saveToolbarActivator",
|
||||
"key.debug.crash",
|
||||
"key.debug.copyLocation"
|
||||
]
|
||||
);
|
||||
keys.set("key.drop", "key.keyboard.z");
|
||||
e.zoom_key = "key.keyboard.z".into();
|
||||
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
|
||||
e.zoom_key = "key.keyboard.unknown".into();
|
||||
keys.set("key.smoothCamera", "key.keyboard.unknown");
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_scales_with_zoom() {
|
||||
assert_eq!(turn_scale(1.0, true), 1.0);
|
||||
assert_eq!(turn_scale(4.0, false), 1.0);
|
||||
assert_eq!(turn_scale(4.0, true), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_counter_reports_rate_minimum_and_low() {
|
||||
let mut c = FpsCounter::default();
|
||||
@@ -914,20 +653,9 @@ mod tests {
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::ZoomLevel.cycle(&mut e, false);
|
||||
assert_eq!(e.zoom_level, 6);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
assert_eq!(e.zoom_level, 3);
|
||||
Item::MinimapCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.minimap_corner, Corner::TopLeft);
|
||||
Item::Zoom.cycle(&mut e, false);
|
||||
assert_eq!(Item::Zoom.switch(&e), Some(true));
|
||||
assert_eq!(Item::ZoomKey.value(&e), "Z");
|
||||
Item::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
assert_eq!(Item::MinimapSize.value(&e), "Large");
|
||||
Item::Sort.cycle(&mut e, true);
|
||||
assert_eq!(Item::Sort.value(&e), "On");
|
||||
// The minimap's old keys still parse, for the move to its mod.
|
||||
assert_eq!(
|
||||
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
|
||||
1.0
|
||||
|
||||
@@ -131,6 +131,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_chunks;
|
||||
mod remote_mob_data;
|
||||
mod remote_mob_use;
|
||||
mod remote_objects;
|
||||
@@ -558,6 +559,8 @@ struct GameLevel<'w> {
|
||||
/// Where the chunk tickets let blocks tick.
|
||||
tickets: horizon_client::ticket_levels::Levels,
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
/// Players with each chest open (a trapped chest's signal).
|
||||
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -693,6 +696,10 @@ impl LevelAccess for GameLevel<'_> {
|
||||
_state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
// TrappedChestBlock.getSignal: the players with it open, at most 15.
|
||||
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
|
||||
return (*count).clamp(0, 15);
|
||||
}
|
||||
jukebox::jukebox_signals(self.world, self.items, pos).map_or(0, |(signal, _)| signal)
|
||||
}
|
||||
fn biome_name(&self, pos: BlockPos) -> Option<&str> {
|
||||
@@ -1505,7 +1512,7 @@ struct Game<'a> {
|
||||
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
|
||||
/// Recent frame times for the performance overlay.
|
||||
frame_graph: horizon_client::perf_overlay::FrameGraph,
|
||||
/// Horizon's extras (zoom, FPS counter, keystrokes): `game/extras.rs`.
|
||||
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
|
||||
extras: extras::ExtrasState,
|
||||
/// Horizon mods (`game/mods.rs`), none running unless turned on.
|
||||
mods: mods::ModsState,
|
||||
@@ -2203,7 +2210,7 @@ impl Game<'_> {
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
/// `Player.drop`: the statistics and a thrown item entity in front of the player.
|
||||
fn player_drop(&mut self, thrown: ItemStack) {
|
||||
pub(super) fn player_drop(&mut self, thrown: ItemStack) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
@@ -2265,7 +2272,13 @@ impl Game<'_> {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
|
||||
let alive = item.tick(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2474,6 +2487,7 @@ impl Game<'_> {
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
lid_openers: &self.lid_openers,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -4186,6 +4200,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
if self.agent.hold_server {
|
||||
return Ok(());
|
||||
}
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
if self.remote.is_some() {
|
||||
@@ -5593,6 +5610,8 @@ impl Game<'_> {
|
||||
self.craft_size = 2;
|
||||
self.craft_table = None;
|
||||
if let Some(key) = self.container.take() {
|
||||
// A cart's or chest boat's items go back to it.
|
||||
self.close_cart_container(key);
|
||||
self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
@@ -5632,7 +5651,6 @@ impl Game<'_> {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
self.close_ender_chest();
|
||||
self.close_cart_container();
|
||||
}
|
||||
/// `quickMoveStack` of the open plain menu for a shift-click.
|
||||
fn inventory_quick_move(&mut self, slot: usize) -> bool {
|
||||
@@ -6512,7 +6530,11 @@ impl Game<'_> {
|
||||
use horizon_client::menu::Input;
|
||||
let button = i32::from(right);
|
||||
if self.remote.is_some() {
|
||||
self.remote_throw(right, input);
|
||||
if self.screen == Screen::Creative {
|
||||
self.remote_creative_drop(right);
|
||||
} else {
|
||||
self.remote_throw(right, input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = self.carried.clone();
|
||||
@@ -10496,7 +10518,10 @@ impl Game<'_> {
|
||||
menu_blur: self.menu_blur(),
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => horizon_client::spans::time("renderer", || renderer.render(&frame)),
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug);
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
};
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
@@ -11296,7 +11321,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.mod_wheel(d)
|
||||
&& !self.extras_scroll(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
@@ -11707,7 +11731,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
self.extras_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
// key.smoothCamera toggles the cinematic camera.
|
||||
if self.screen == Screen::Playing
|
||||
|
||||
@@ -300,6 +300,8 @@ struct Glide {
|
||||
pub(super) struct Agent {
|
||||
task: Option<Task>,
|
||||
glide: Option<Glide>,
|
||||
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
|
||||
pub hold_server: bool,
|
||||
}
|
||||
|
||||
pub(super) enum Handled {
|
||||
@@ -479,6 +481,8 @@ impl Game<'_> {
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -667,6 +671,7 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(chat) = &self.ui.chat_screen {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
v["menu"] = self.title_value();
|
||||
|
||||
@@ -13,7 +13,10 @@ use horizon_client::{
|
||||
save::number,
|
||||
skins,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
@@ -86,6 +89,16 @@ impl Game<'_> {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
@@ -104,15 +117,48 @@ impl Game<'_> {
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
*count = (*count + if open { 1 } else { -1 }).max(0);
|
||||
let count = *count;
|
||||
if count == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
|
||||
// (`TrappedChestBlock.getSignal`), so the blocks around it and
|
||||
// below it hear of a change.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
|
||||
{
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, p, block);
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
count > 0,
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
let before = *count;
|
||||
*count = (before + if open { 1 } else { -1 }).max(0);
|
||||
let after = *count;
|
||||
if after == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
@@ -173,6 +219,11 @@ impl Game<'_> {
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
let Some(r) = &self.renderer else {
|
||||
// A server has no window, but its gateways still run their
|
||||
// server ticker.
|
||||
if self.remote.is_none() {
|
||||
self.tick_gateway_servers();
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Sorted by position: the order they tick in, and a quick lookup.
|
||||
@@ -332,6 +383,31 @@ impl Game<'_> {
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
|
||||
/// gateways a server keeps a copy of, in block-ticking chunks
|
||||
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
|
||||
/// and every 2400 ticks `triggerCooldown` sends its block event.
|
||||
fn tick_gateway_servers(&mut self) {
|
||||
let levels = self.tickets.levels();
|
||||
let mut positions: Vec<[i32; 3]> = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|p| levels.block_ticking_at(p[0], p[2]))
|
||||
.collect();
|
||||
positions.sort_unstable();
|
||||
for pos in positions {
|
||||
let triggered = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.get_mut(&pos)
|
||||
.is_some_and(EndGateway::portal_tick);
|
||||
if triggered {
|
||||
self.block_entity_event(pos, 1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
|
||||
/// `pos` let the entity in (`triggerCooldown`, with its block event).
|
||||
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -1097,6 +1097,12 @@ impl Game<'_> {
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
// A server's player teleported within their level is placed
|
||||
// there at once: the arrival they were waiting for elsewhere
|
||||
// in it (whose chunks lose their tickets) is abandoned.
|
||||
if let Some(conn) = self.lan_acting {
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
|
||||
@@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"status game, player, streaming and renderer state",
|
||||
"simulation authoritative projectiles in the owner's level",
|
||||
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
|
||||
"stall MILLISECONDS stall the window thread (0..5000), leaving transport alive",
|
||||
"key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
"release release every key and mouse button",
|
||||
"mouse left|right|middle down|up|click",
|
||||
@@ -238,6 +241,47 @@ impl Game<'_> {
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
if millis > 5000 {
|
||||
return Err("stall is limited to 5000 milliseconds".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(millis));
|
||||
ok()
|
||||
}
|
||||
"simulation" => {
|
||||
self.require_world()?;
|
||||
let value = if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(|g| {
|
||||
g.in_owner_level(|g| {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
"server-hold" => {
|
||||
self.require_world()?;
|
||||
let held = match arg(1)? {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("server-hold on|off".into()),
|
||||
};
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
self.agent.hold_server = held;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
@@ -295,13 +339,9 @@ impl Game<'_> {
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// The zoom extra sees the key as the keyboard hook does.
|
||||
// Mods' keys (capability `input`) see the key as the keyboard
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.extras_key(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.extras_key(physical, false, false);
|
||||
}
|
||||
// So do mods' keys (capability `input`).
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.mod_key(physical, false, false);
|
||||
@@ -379,15 +419,19 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
@@ -508,10 +552,23 @@ impl Game<'_> {
|
||||
"mobs" => {
|
||||
self.require_world()?;
|
||||
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let value = server
|
||||
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
}
|
||||
"hurtlog" => {
|
||||
self.require_world()?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
}
|
||||
"resume" => {
|
||||
@@ -612,6 +669,7 @@ impl Game<'_> {
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"mods" => Page::Mods,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
@@ -625,6 +683,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.open_options(self.title.is_some());
|
||||
self.ui.options.show(page);
|
||||
if page == Page::ShaderPacks {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"chat-log" => {
|
||||
|
||||
@@ -312,6 +312,9 @@ impl Game<'_> {
|
||||
/// The `mobs` command: nearby mobs with position, health and running goals.
|
||||
pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
self.control_mobs_at(radius, center)
|
||||
}
|
||||
pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value {
|
||||
let list: Vec<serde_json::Value> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -504,6 +507,17 @@ impl Game<'_> {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.map(|g| (g.conn, g.entity_id as u32))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
@@ -578,6 +592,19 @@ impl Game<'_> {
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Who::Player
|
||||
{
|
||||
*target = Who::Mob(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
|
||||
@@ -415,6 +415,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
|
||||
//! the minimap's tiles and image, and their HUD; clicks and frame times for
|
||||
//! mods (`input.clicks-per-second`, `render.frame-stats`). Each hook does
|
||||
//! nothing while its extra is off and no mod runs.
|
||||
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
hud::{HudBatch, MINIMAP_SPRITE},
|
||||
keybinds,
|
||||
minimap::{self, Layer, Minimap, View},
|
||||
multiplayer,
|
||||
reconnect::{Retry, Tracker},
|
||||
title::Mode,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ExtrasState {
|
||||
zoom: Zoom,
|
||||
fps: FpsCounter,
|
||||
/// Left and right mouse buttons: held, and their clicks.
|
||||
mouse: [bool; 2],
|
||||
@@ -36,34 +34,8 @@ impl Game<'_> {
|
||||
fn extras_now(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
}
|
||||
fn zoom_key(&self) -> Option<KeyCode> {
|
||||
let e = &self.settings.extras;
|
||||
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
|
||||
}
|
||||
/// A key press or release. The zoom key zooms only while playing with
|
||||
/// no text open; the key itself always goes on to the screen and the key
|
||||
/// binds, as a vanilla key mapping's would.
|
||||
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if self.zoom_key() != Some(key) {
|
||||
return;
|
||||
}
|
||||
let playing = self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none();
|
||||
let level = self.settings.extras.zoom_level;
|
||||
self.extras.zoom.key(pressed, repeat, playing, level);
|
||||
}
|
||||
/// Wheel steps while playing; true when zooming took them from the hotbar.
|
||||
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
|
||||
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
|
||||
}
|
||||
/// Mouse buttons while playing, for zoom and the keystroke overlay.
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let e = &self.settings.extras;
|
||||
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
|
||||
self.extras.zoom.key(pressed, false, true, e.zoom_level);
|
||||
}
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
@@ -81,9 +53,6 @@ impl Game<'_> {
|
||||
// Under agent control the window may lack focus without pausing.
|
||||
let focused = self.focused || self.control.is_some();
|
||||
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
|
||||
if !playing || !self.settings.extras.zoom {
|
||||
self.extras.zoom.release();
|
||||
}
|
||||
if !playing {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
@@ -159,25 +128,29 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap's view of the player, if it shows.
|
||||
fn minimap_view(&self) -> Option<View> {
|
||||
let e = &self.settings.extras;
|
||||
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
|
||||
/// in GUI pixels and its view of the player.
|
||||
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
|
||||
let p = self.player.as_ref()?;
|
||||
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
|
||||
shown.then_some(View {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
rotate: e.minimap_rotate,
|
||||
blocks_per_pixel: e.minimap_zoom,
|
||||
round: e.minimap_round,
|
||||
})
|
||||
rotate: m.rotate,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
round: m.round,
|
||||
};
|
||||
Some((m.corner, m.size, view))
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some(view) = self.minimap_view() else {
|
||||
if !self.settings.extras.minimap || self.player.is_none() {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -225,24 +198,12 @@ impl Game<'_> {
|
||||
rgba: self.extras.minimap.image.clone(),
|
||||
});
|
||||
}
|
||||
/// The zoom in effect (1 unless the key is held).
|
||||
fn zoom_factor(&self) -> f32 {
|
||||
self.extras.zoom.factor(self.scoping())
|
||||
}
|
||||
/// The zoom in effect, the extra's or a mod's (`render.set-zoom`),
|
||||
/// whichever is stronger: (factor, smooth, turning scale).
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
let factor = self.zoom_factor();
|
||||
let e = &self.settings.extras;
|
||||
let own = (
|
||||
factor,
|
||||
e.zoom_smooth,
|
||||
turn_scale(factor, e.zoom_sensitivity),
|
||||
);
|
||||
match self.mod_zoom() {
|
||||
Some(z) if z.factor > factor => (z.factor, z.smooth, f64::from(z.turn)),
|
||||
_ => own,
|
||||
}
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
}
|
||||
/// The field-of-view modifier target, divided by a smooth zoom.
|
||||
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
|
||||
@@ -265,16 +226,15 @@ impl Game<'_> {
|
||||
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
|
||||
/// by the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.minimap && !mod_panels {
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some(view) = self.minimap_view()
|
||||
if let Some((map_corner, map_size, view)) = map
|
||||
&& visible(map_corner)
|
||||
&& !self.extras.minimap.image.is_empty()
|
||||
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
|
||||
|
||||
@@ -71,6 +71,18 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
/// `damageAmount * (float)Math.sqrt((5.0 - this.distanceTo(target)) / 5.0)`
|
||||
/// from the rocket at `from` to a target at `to`; `Entity.distanceTo` sums
|
||||
/// the squared offsets in floats.
|
||||
fn blast_damage(damage: f32, from: Vec3, to: Vec3) -> f32 {
|
||||
let (x, y, z) = (
|
||||
(from.x - to.x) as f32,
|
||||
(from.y - to.y) as f32,
|
||||
(from.z - to.z) as f32,
|
||||
);
|
||||
let distance = f64::from(x * x + y * y + z * z).sqrt() as f32;
|
||||
damage * ((5.0 - f64::from(distance)) / 5.0).sqrt() as f32
|
||||
}
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
@@ -251,21 +263,24 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
// `attachedToEntity` takes the full damage, a guest as well; every
|
||||
// other player in range and in sight takes the blast.
|
||||
let boosted = r.guest.filter(|_| r.attached);
|
||||
if r.attached && boosted.is_none() {
|
||||
self.rocket_hurt_player(damage);
|
||||
} else {
|
||||
self.rocket_blast_player(from, damage);
|
||||
}
|
||||
if let Some(conn) = boosted
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Every other player in the level (LAN guests, the players of a
|
||||
// server thread), each swapped in as the player.
|
||||
self.each_guest_hit_except(boosted, |g| g.rocket_blast_player(from, damage));
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
self.mobs
|
||||
@@ -285,7 +300,7 @@ impl Game<'_> {
|
||||
);
|
||||
world.clear_between(from, to, false)
|
||||
});
|
||||
sees.then(|| (m.id, damage * ((5.0 - d2.sqrt()) / 5.0).sqrt() as f32))
|
||||
sees.then(|| (m.id, blast_damage(damage, from, m.position)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -307,6 +322,49 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
|
||||
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
|
||||
fn rocket_blast_player(&mut self, from: Vec3, damage: f32) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let feet = p.position;
|
||||
let d2 = (feet.x - from.x).powi(2) + (feet.y - from.y).powi(2) + (feet.z - from.z).powi(2);
|
||||
if d2 > 25.0 {
|
||||
return;
|
||||
}
|
||||
let height = f64::from(p.dimensions.height);
|
||||
let sees = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
(0..2).any(|step| {
|
||||
let to = Vec3::new(feet.x, feet.y + height * 0.5 * f64::from(step), feet.z);
|
||||
world.clear_between(from, to, false)
|
||||
})
|
||||
};
|
||||
if sees {
|
||||
self.rocket_hurt_player(blast_damage(damage, from, feet));
|
||||
}
|
||||
}
|
||||
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
|
||||
/// wears (`hurtArmor`); creative and spectator players are immune.
|
||||
fn rocket_hurt_player(&mut self, amount: f32) {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
}
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -64,14 +64,14 @@ impl Game<'_> {
|
||||
});
|
||||
self.view.tick(eye, target);
|
||||
}
|
||||
/// The player's `deathTime` while dead or dying.
|
||||
fn death_time(&self) -> Option<i32> {
|
||||
/// The player's `deathTime` while dead or dying: whole ticks counted by
|
||||
/// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in
|
||||
/// `Camera.extractRenderState` rises smoothly across frames.
|
||||
pub(super) fn death_time(&self) -> Option<i32> {
|
||||
let p = self.player.as_ref()?;
|
||||
p.health.dead_or_dying().then(|| {
|
||||
self.ui
|
||||
.died_at
|
||||
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
|
||||
})
|
||||
p.health
|
||||
.dead_or_dying()
|
||||
.then(|| self.ui.death_time.min(i32::MAX as u32) as i32)
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
|
||||
@@ -370,6 +370,12 @@ impl Game<'_> {
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
if self.agent.hold_server {
|
||||
// Keep servicing control requests so the fixture can release the barrier.
|
||||
// Unlike /tick freeze, no player's queued packets are applied here.
|
||||
self.wait_for_next_server_tick(&mut next, true);
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
|
||||
@@ -38,6 +38,9 @@ pub(super) struct GuestPlay {
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
|
||||
/// item a creative player drops from the creative screen, one less each tick.
|
||||
creative_drops: u32,
|
||||
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
|
||||
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
@@ -1086,6 +1089,35 @@ impl Game<'_> {
|
||||
}
|
||||
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
|
||||
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
|
||||
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
|
||||
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
|
||||
S::SetCreativeModeSlot(p) if p.slot < 0 => {
|
||||
if !host.hub.guests[gi].creative() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = self.item_from_network(host, &p.item) else {
|
||||
return;
|
||||
};
|
||||
if stack.count
|
||||
> self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
"Player {} was dropping items too fast in creative mode, ignoring.",
|
||||
host.hub.guests[gi].name
|
||||
);
|
||||
return;
|
||||
}
|
||||
play.creative_drops += 20;
|
||||
self.as_guest(host, conn, |g| g.player_drop(stack));
|
||||
}
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
@@ -1160,6 +1192,10 @@ impl Game<'_> {
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
// A vehicle's menu edits the vehicle's own items.
|
||||
if let Some(Slots::Block(key)) = slots {
|
||||
self.store_cart_items(key);
|
||||
}
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
@@ -1799,12 +1835,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
// LivingEntity.hurt kills at once (die), whether the level
|
||||
// has loaded around the player or not: its respawn request
|
||||
// is taken as soon as it comes.
|
||||
self.guest_die(host, gi, conn);
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
@@ -1883,69 +1924,72 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
self.guest_die(host, gi, conn);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die` for a guest whose health has run out: the death
|
||||
/// message, the loot and experience, the death screen.
|
||||
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops =
|
||||
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2143,6 +2187,9 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
|
||||
@@ -291,6 +291,11 @@ impl Game<'_> {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
|
||||
// so the chunk is saved and comparators read the block again.
|
||||
if changed.iter().any(|&s| s < items.len()) {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
|
||||
@@ -119,7 +119,17 @@ impl Game<'_> {
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
pub(super) fn each_guest_hit(&mut self, f: impl FnMut(&mut Self)) {
|
||||
self.each_guest_hit_except(None, f);
|
||||
}
|
||||
|
||||
/// [`Self::each_guest_hit`] leaving out the guest `except` (the one a
|
||||
/// firework rocket is attached to, which it hurts on its own).
|
||||
pub(super) fn each_guest_hit_except(
|
||||
&mut self,
|
||||
except: Option<u32>,
|
||||
mut f: impl FnMut(&mut Self),
|
||||
) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -127,7 +137,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && Some(g.conn) != except)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn recipe_matches(condition: &Value, id: &str) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Level.setBlock` of one boolean property of the block at `pos`.
|
||||
fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
pub(crate) fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let old = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(mut state) = registry.state(old).cloned() else {
|
||||
|
||||
@@ -1117,22 +1117,35 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// Closing a cart's container copies the items back.
|
||||
pub(super) fn close_cart_container(&mut self) {
|
||||
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
|
||||
/// Closing the cart's container open under `key` copies the items back.
|
||||
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
|
||||
if key.1 != CART_KEY_Y {
|
||||
return;
|
||||
};
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
|
||||
}
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
|
||||
&& c.kind == CartKind::Chest
|
||||
{
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
}
|
||||
/// The menu of a chest or hopper minecart or a chest boat works on the
|
||||
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
|
||||
/// that the menu edits is copied back to the vehicle.
|
||||
pub(super) fn store_cart_items(&mut self, key: (i32, i32, i32)) {
|
||||
if key.1 != CART_KEY_Y {
|
||||
return;
|
||||
}
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let items = items.clone();
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
|
||||
c.items = items;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
if c.kind == CartKind::Chest {
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
|
||||
b.items = items;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ impl Game<'_> {
|
||||
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
|
||||
@@ -38,6 +41,16 @@ pub(super) struct ModZoom {
|
||||
pub(super) turn: f32,
|
||||
}
|
||||
|
||||
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModMinimap {
|
||||
pub(super) corner: horizon_client::extras::Corner,
|
||||
pub(super) size: f32,
|
||||
pub(super) blocks_per_pixel: f32,
|
||||
pub(super) rotate: bool,
|
||||
pub(super) round: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack as `input` shows it; none when it is empty.
|
||||
@@ -147,4 +160,22 @@ impl Game<'_> {
|
||||
turn: z.turn,
|
||||
})
|
||||
}
|
||||
/// The minimap the first mod showing one asks for.
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
use horizon_client::extras::Corner;
|
||||
use horizon_mods::RenderCorner;
|
||||
let m = self.mods.mods.minimap()?;
|
||||
Some(ModMinimap {
|
||||
corner: match m.corner {
|
||||
RenderCorner::TopLeft => Corner::TopLeft,
|
||||
RenderCorner::TopRight => Corner::TopRight,
|
||||
RenderCorner::BottomLeft => Corner::BottomLeft,
|
||||
RenderCorner::BottomRight => Corner::BottomRight,
|
||||
},
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ use {
|
||||
text::{self, Text},
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
horizon_mods::{
|
||||
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
|
||||
},
|
||||
std::time::Instant,
|
||||
winit::{event::MouseButton, keyboard::KeyCode},
|
||||
};
|
||||
@@ -270,7 +272,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
@@ -412,6 +414,7 @@ impl Game<'_> {
|
||||
mappings,
|
||||
cps,
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A game tick.
|
||||
@@ -522,6 +525,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Server mods' lines: this game runs no server mod yet.
|
||||
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
// Written by this client, so the Chat option doesn't hide it.
|
||||
|
||||
@@ -82,6 +82,13 @@ mod with_mods {
|
||||
Ok(()) => {
|
||||
self.settings.extras.moved.push(m.id.to_owned());
|
||||
self.settings.mods.enabled.insert(m.id.to_owned());
|
||||
// The install wrote the key to horizon-mods.txt;
|
||||
// the settings save below must keep it.
|
||||
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
|
||||
for (name, key) in m.keys(&self.settings.extras) {
|
||||
keys.entry(name.to_owned()).or_insert(key);
|
||||
}
|
||||
self.settings.mods.keys.retain(|_, k| !k.is_empty());
|
||||
self.mods.moved.notices.push(Text::literal(m.notice()));
|
||||
installed = true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Horizon's own shader packs in the game (docs/shaders.md), for development until the Shader
|
||||
//! Packs page exists: `HORIZON_SHADER_PACK=<name>` naming one in `shaderpacks/` (a folder with
|
||||
//! `horizon-shaders.toml`, or a `.hshader`) runs it once it passes `horizon_shader_api::check`.
|
||||
//! The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
|
||||
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
|
||||
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs once it
|
||||
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
|
||||
//! pack that fails is refused with its problems in the chat while the last good one keeps
|
||||
//! drawing.
|
||||
|
||||
@@ -21,11 +20,22 @@ const RED: u32 = 0xFF5555;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeShaders {
|
||||
/// The pack's name in `shaderpacks/`, of either kind (`game/shader_packs.rs`); empty: none.
|
||||
pub picked: String,
|
||||
/// The pack's files when it was last read (`fingerprint`).
|
||||
stamp: Option<u64>,
|
||||
looked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl NativeShaders {
|
||||
pub fn picking(name: &str) -> Self {
|
||||
Self {
|
||||
picked: name.to_owned(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Builds the pack at `path` when its files changed since it was last read.
|
||||
pub(super) fn tick_native_shader_pack(&mut self, path: &Path) {
|
||||
|
||||
@@ -270,6 +270,18 @@ impl Game<'_> {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
|
||||
/// passenger), then only the client's `setAsInsidePortal`, which drives
|
||||
/// the confusion effect (`client_portal_effect`).
|
||||
pub(super) fn client_inside_nether_portal(&mut self) {
|
||||
let alive = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying());
|
||||
if alive && self.riding.is_none() {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
|
||||
/// player then). Players wait `players_nether_portal_default_delay`
|
||||
/// ticks, or the creative delay with invulnerable abilities.
|
||||
|
||||
@@ -78,6 +78,10 @@ impl Game<'_> {
|
||||
.unwrap_or_default()
|
||||
.join("options.txt")
|
||||
}
|
||||
/// `shaderpacks/` beside `options.txt`.
|
||||
pub(super) fn shader_packs_dir() -> std::path::PathBuf {
|
||||
Self::options_path().with_file_name(horizon_client::shader_pack_list::DIR)
|
||||
}
|
||||
pub(super) fn gui_size(&self) -> (f32, f32) {
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -230,6 +234,17 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ListShaderPacks => {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
// Saved; the next frame's `shader_pack_inputs` loads the pick.
|
||||
Event::ShaderPack => self.settings.save(&Self::options_path()),
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = Self::shader_packs_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
//! clicks). The player still moves with the local physics, as the vanilla
|
||||
//! client predicts its own movement; the server stays authoritative for
|
||||
//! everything else.
|
||||
use super::remote_chunks::{ChunkDecoder, ChunkFormat, Next};
|
||||
use super::{Dim, Game, Mining, Screen};
|
||||
use horizon_client::first_person::{Hand, SwingAnimation};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
|
||||
chunk::{Chunk, SECTIONS},
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -25,11 +26,7 @@ use horizon_client::{
|
||||
tab_list::SPECTATOR,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
paletted::{PalettedContainer, Strategy},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{ITEM_ENTITY_ITEM, Value},
|
||||
item::{Hashed, Stack},
|
||||
@@ -64,9 +61,6 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
|
||||
remote_text(tag).resolve_legacy()
|
||||
}
|
||||
|
||||
/// A section's sky and block light layers from the wire.
|
||||
type LightLayers = (Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
|
||||
/// Streaming needs a generator; a server world never generates.
|
||||
struct NoTerrain;
|
||||
impl ChunkGenerator for NoTerrain {
|
||||
@@ -104,6 +98,14 @@ fn place_interpolated(m: &mut Mob, at: PosRot) {
|
||||
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
|
||||
}
|
||||
|
||||
/// What the network thread delivered, in order: an event, or a chunk packet
|
||||
/// handed to the decoder (with the format it decodes with), whose column
|
||||
/// comes back from `ChunkDecoder::next`.
|
||||
enum Received {
|
||||
Event(Event),
|
||||
Chunk(Arc<ChunkFormat>),
|
||||
}
|
||||
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
@@ -114,8 +116,11 @@ pub(super) struct Remote {
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
dimension: DimensionType,
|
||||
biome_map: Vec<u16>,
|
||||
biome_bits: u8,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
/// Decodes chunk packets as they arrive, off the frame.
|
||||
decoder: ChunkDecoder,
|
||||
view_distance: i32,
|
||||
sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
@@ -200,7 +205,7 @@ pub(super) struct Remote {
|
||||
chunk_center: (i32, i32),
|
||||
/// Received events a frame left for the next one, in order, and when
|
||||
/// the oldest of them first waited (`poll_remote`).
|
||||
backlog: VecDeque<Event>,
|
||||
backlog: VecDeque<Received>,
|
||||
backlog_since: Option<Instant>,
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
@@ -210,11 +215,12 @@ pub(super) struct Remote {
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
/// the received events for the next frame. A batch of 25 to 60 columns
|
||||
/// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every
|
||||
/// time the player crossed a chunk border at 32 chunks.
|
||||
/// time the player crossed a chunk border at 32 chunks; they decode on the
|
||||
/// chunk decoder's thread now, and the frame only inserts them.
|
||||
const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
|
||||
/// Events that have waited this long are all handled in the next frame,
|
||||
/// whatever their cost: the packets after a slow machine's chunks never
|
||||
/// wait more than a tick.
|
||||
/// whatever inserting their columns costs, up to a column still decoding:
|
||||
/// the packets after a slow machine's chunks don't wait on its frames.
|
||||
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
|
||||
/// The open menu name of a server's horse inventory, then its chest's
|
||||
/// columns (the packet names no `MenuType`).
|
||||
@@ -420,8 +426,12 @@ impl Game<'_> {
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
biome_map: Vec::new(),
|
||||
biome_bits: 7,
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
&DimensionType::vanilla("minecraft:overworld"),
|
||||
Vec::new(),
|
||||
7,
|
||||
)),
|
||||
decoder: ChunkDecoder::new(),
|
||||
view_distance: self.settings.render_distance,
|
||||
sequence: 0,
|
||||
position_sender: Default::default(),
|
||||
@@ -741,25 +751,63 @@ impl Game<'_> {
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`.
|
||||
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
|
||||
/// back what follows it.
|
||||
pub(super) fn poll_remote(&mut self) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
events.extend(r.session.poll());
|
||||
// Chunk packets start decoding now; the frame that reaches one in
|
||||
// order inserts its column.
|
||||
for event in r.session.poll() {
|
||||
events.push_back(match event {
|
||||
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
|
||||
match r.decoder.submit(r.chunk_format.clone(), p) {
|
||||
None => Received::Chunk(r.chunk_format.clone()),
|
||||
Some(p) => Received::Event(Event::Packet(p)),
|
||||
}
|
||||
}
|
||||
event => Received::Event(event),
|
||||
});
|
||||
}
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
while let Some(event) = events.pop_front() {
|
||||
let chunk =
|
||||
matches!(&event, Event::Packet(p) if matches!(**p, C::LevelChunkWithLight(_)));
|
||||
while let Some(item) = events.pop_front() {
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(event);
|
||||
events.push_front(item);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
let event = match item {
|
||||
Received::Event(event) => event,
|
||||
Received::Chunk(format) => {
|
||||
// A column still decoding waits for the next frame, with
|
||||
// what came after it, overdue or not.
|
||||
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
|
||||
break;
|
||||
};
|
||||
let (packet, column) = match next {
|
||||
Next::Ready(decoded) => decoded,
|
||||
Next::Decoding => {
|
||||
events.push_front(Received::Chunk(format));
|
||||
break;
|
||||
}
|
||||
Next::Lost => {
|
||||
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -915,8 +963,8 @@ impl Game<'_> {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.biome_bits = ceil_log2(biomes.len());
|
||||
r.biome_map = biomes;
|
||||
let bits = ceil_log2(biomes.len());
|
||||
r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits));
|
||||
r.registries = registries;
|
||||
}
|
||||
}
|
||||
@@ -927,6 +975,7 @@ impl Game<'_> {
|
||||
};
|
||||
let dimension = r.registries.dimension(spawn.dimension_type);
|
||||
let same = r.dimension.name == dimension.name && keep_world;
|
||||
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
|
||||
r.dimension = dimension;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.loaded_sent = false;
|
||||
@@ -989,145 +1038,10 @@ impl Game<'_> {
|
||||
self.apply_settings();
|
||||
}
|
||||
|
||||
/// LevelChunkSection data into the client's fixed -64..320 column.
|
||||
fn remote_chunk(&self, p: &play::LevelChunkWithLight) -> std::result::Result<Chunk, String> {
|
||||
let r = self.remote.as_ref().ok_or("no connection")?;
|
||||
let sections = p
|
||||
.chunk
|
||||
.sections(r.dimension.sections(), 16, r.biome_bits)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut chunk = Chunk::empty(p.x, p.z);
|
||||
let offset = (r.dimension.min_y - MIN_Y).div_euclid(16);
|
||||
// Biome table index per quart cell of the dimension's own sections.
|
||||
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let target = offset + i as i32;
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = u32::from(
|
||||
r.biome_map
|
||||
.get(s.biomes.get(j) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
biome_cells.push(cells);
|
||||
if !(0..SECTIONS as i32).contains(&target) {
|
||||
continue;
|
||||
}
|
||||
// A global section is as wide as the server's registry: 16 bits
|
||||
// from a vanilla server, wider from a Horizon host with the same
|
||||
// content mods. Read it at that width, then widen it to ours.
|
||||
let ours = block_strategy();
|
||||
let strategy = if ours.wire_has_palette(s.states.bits) {
|
||||
ours
|
||||
} else {
|
||||
Strategy::blocks(s.states.bits)
|
||||
};
|
||||
let palette = s.states.palette.iter().map(|&p| p as u32).collect();
|
||||
let words = s.states.data.iter().map(|&w| w as u64).collect();
|
||||
let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if strategy.global_bits != ours.global_bits {
|
||||
let mut values = [0u32; 4096];
|
||||
blocks.unpack(&mut values);
|
||||
// Ids past our registry (a host with mods we lack) read as air.
|
||||
let limit = 1u64 << ours.global_bits;
|
||||
for v in &mut values {
|
||||
if u64::from(*v) >= limit {
|
||||
*v = 0;
|
||||
}
|
||||
}
|
||||
blocks = PalettedContainer::from_values(ours, &values);
|
||||
}
|
||||
let mut section = Section {
|
||||
blocks,
|
||||
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
|
||||
light: Light::Uniform(0xF0),
|
||||
};
|
||||
section.compact();
|
||||
chunk.sections[target as usize] = Arc::new(section);
|
||||
}
|
||||
// Sections outside the dimension's range take its nearest biomes
|
||||
// (the column's top or bottom cell layer repeated).
|
||||
if !biome_cells.is_empty() {
|
||||
let last = biome_cells.len() - 1;
|
||||
for si in 0..SECTIONS as i32 {
|
||||
let i = si - offset;
|
||||
if (0..sections.len() as i32).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = biome_cells[edge][layer * 16 + j % 16];
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
|
||||
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
|
||||
}
|
||||
}
|
||||
// ClientPacketListener.applyLightData: the column keeps the light
|
||||
// the server sent; nothing is relit.
|
||||
let (layers, above) = self.wire_light(&p.light);
|
||||
let sky = r.dimension.has_skylight;
|
||||
horizon_client::light::light_from_layers(&mut chunk, &layers, above, sky);
|
||||
Ok(chunk)
|
||||
}
|
||||
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
|
||||
/// light packet, by section of the client's column, and the sky light
|
||||
/// above the column's top section (from the light section just above
|
||||
/// it, else open sky).
|
||||
fn wire_light(
|
||||
&self,
|
||||
light: &horizon_protocol::chunk::LightData,
|
||||
) -> (Vec<LightLayers>, [u8; 256]) {
|
||||
let mut layers: Vec<LightLayers> = vec![(None, None); SECTIONS];
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return (layers, [0; 256]);
|
||||
};
|
||||
let sky = r.dimension.has_skylight;
|
||||
let mut above = [if sky { 15 } else { 0 }; 256];
|
||||
// Light section b is the dimension's section b - 1.
|
||||
let first = (r.dimension.min_y - MIN_Y).div_euclid(16) - 1;
|
||||
for (b, layer) in light
|
||||
.layers(r.dimension.sections() + 2)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let si = first + b as i32;
|
||||
if (0..SECTIONS as i32).contains(&si) {
|
||||
layers[si as usize] = layer;
|
||||
} else if si == SECTIONS as i32
|
||||
&& sky
|
||||
&& let Some(l) = &layer.0
|
||||
{
|
||||
for (i, a) in above.iter_mut().enumerate() {
|
||||
*a = (l[i >> 1] >> ((i & 1) * 4)) & 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
(layers, above)
|
||||
}
|
||||
/// `handleLightUpdatePacket`: the sections it names take its light.
|
||||
fn remote_light_update(&mut self, p: &play::LightUpdate) {
|
||||
let Some(sky) = self.remote.as_ref().map(|r| r.dimension.has_skylight) else {
|
||||
return;
|
||||
};
|
||||
if !self.world.chunks.contains_key(&(p.x, p.z)) {
|
||||
return;
|
||||
}
|
||||
let (layers, _) = self.wire_light(&p.light);
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else {
|
||||
return;
|
||||
};
|
||||
let changed =
|
||||
horizon_client::light::update_light_layers(Arc::make_mut(chunk), &layers, sky);
|
||||
Streaming::light_updated(&mut self.world, p.x, p.z, &changed);
|
||||
}
|
||||
/// `ClientChunkCache.Storage.inRange`: within the storage around the
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
self.remote.as_ref().is_none_or(|r| {
|
||||
let range = r.view_distance.max(2) + 3;
|
||||
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
|
||||
@@ -1135,29 +1049,33 @@ impl Game<'_> {
|
||||
}
|
||||
/// The columns a moved or resized storage no longer holds.
|
||||
fn drop_remote_chunks_out_of_range(&mut self) {
|
||||
let gone: Vec<(i32, i32)> = self
|
||||
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
|
||||
.collect();
|
||||
for (x, z) in gone {
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
for &(x, z) in &gone {
|
||||
self.streaming.forget(&mut self.world, x, z);
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
renderer.remove((x, sy, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
// One pass for all of them.
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
|
||||
}
|
||||
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
|
||||
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
|
||||
/// changes nothing.
|
||||
fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
|
||||
pub(super) fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(name) = self
|
||||
.remote
|
||||
@@ -1248,7 +1166,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
|
||||
fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
|
||||
pub(super) fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
match BlockEntity::for_block(&name) {
|
||||
Some(entity) => {
|
||||
@@ -1704,36 +1622,7 @@ impl Game<'_> {
|
||||
})
|
||||
.clamp(-90.0, 90.0);
|
||||
}
|
||||
// ClientChunkCache.replaceWithPacketData: a column outside the
|
||||
// storage around the view centre is ignored.
|
||||
C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => {
|
||||
log::warn!(
|
||||
"Ignoring chunk since it's not in the view range: {}, {}",
|
||||
p.x,
|
||||
p.z
|
||||
);
|
||||
}
|
||||
C::LevelChunkWithLight(p) => match self.remote_chunk(&p) {
|
||||
Ok(chunk) => {
|
||||
let (x, z) = (p.x, p.z);
|
||||
self.streaming.insert_remote(&mut self.world, chunk);
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
for info in &p.chunk.block_entities {
|
||||
let pos = (
|
||||
x * 16 + i32::from(info.packed_xz as u8 >> 4),
|
||||
i32::from(info.y),
|
||||
z * 16 + i32::from(info.packed_xz as u8 & 15),
|
||||
);
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.sync_block_entity(pos, state);
|
||||
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
|
||||
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z),
|
||||
},
|
||||
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
|
||||
C::LightUpdate(p) => self.remote_light_update(&p),
|
||||
C::ForgetLevelChunk(p) => {
|
||||
let [x, z] = p.pos;
|
||||
@@ -2234,6 +2123,7 @@ impl Game<'_> {
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -2250,6 +2140,7 @@ impl Game<'_> {
|
||||
// screen, whatever health was last sent.
|
||||
if me && self.screen != Screen::Dead && self.player.is_some() {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -3288,6 +3179,11 @@ impl Game<'_> {
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
self.sync_player_body();
|
||||
// LivingEntity.tickDeath: the local player counts its deathTime once
|
||||
// per client tick while dead (the camera's death roll and zoom).
|
||||
if self.screen == Screen::Dead {
|
||||
self.ui.death_time = self.ui.death_time.saturating_add(1);
|
||||
}
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3343,6 +3239,12 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
|
||||
}
|
||||
// NetherPortalBlock.entityInside on the client's own level:
|
||||
// the portal overlay and its trigger sound (the server moves
|
||||
// the player).
|
||||
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
|
||||
self.client_inside_nether_portal();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3610,7 +3512,7 @@ impl Game<'_> {
|
||||
// ticks), and only the server removes it.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.tick(world, facts, collision, false);
|
||||
d.tick(world, facts, collision, false, &self.res.motion().fluids);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
@@ -4060,6 +3962,10 @@ impl Game<'_> {
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
} else if let Some(r) = self.remote.as_mut() {
|
||||
// BlockItem.place plays its sound only once the block is set: the
|
||||
// server's resend of the target it refused is no placement.
|
||||
r.placing = None;
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
@@ -4828,6 +4734,34 @@ impl Game<'_> {
|
||||
carried,
|
||||
}));
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
|
||||
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
|
||||
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
|
||||
/// -1, and the hand swings as `Player.drop` predicts.
|
||||
pub(super) fn remote_creative_drop(&mut self, right: bool) {
|
||||
let one = right && !self.creative_inventory_open();
|
||||
let Some(carried) = self.carried.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let thrown = if one && carried.count > 1 {
|
||||
carried.count -= 1;
|
||||
ItemStack {
|
||||
count: 1,
|
||||
..carried.clone()
|
||||
}
|
||||
} else {
|
||||
self.carried.take().expect("carried")
|
||||
};
|
||||
if !self.creative {
|
||||
return;
|
||||
}
|
||||
self.swing_arm();
|
||||
let item = self.to_network(&Some(thrown));
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: -1,
|
||||
item,
|
||||
}));
|
||||
}
|
||||
pub(super) fn remote_close_container(&mut self) {
|
||||
let id = self
|
||||
.remote
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! A server's chunk columns (`ClientboundLevelChunkWithLightPacket`): the
|
||||
//! sections and light of each packet become a client column on a decoder
|
||||
//! thread as soon as the packet arrives, so the frame that handles it in
|
||||
//! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
|
||||
streaming::Streaming,
|
||||
};
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_protocol::{
|
||||
chunk::{LightData, SectionLight},
|
||||
packets::play::{self, Clientbound as C},
|
||||
registry::DimensionType,
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
mpsc::{self, TryRecvError},
|
||||
};
|
||||
|
||||
/// What turning a chunk packet into a client column depends on: the
|
||||
/// dimension's height and sky, and the server's biome registry mapped to
|
||||
/// ours. The connection replaces it when either changes, so a column decoded
|
||||
/// with an older one is decoded again.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ChunkFormat {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
has_skylight: bool,
|
||||
biome_map: Vec<u16>,
|
||||
biome_bits: u8,
|
||||
}
|
||||
|
||||
impl ChunkFormat {
|
||||
pub(super) fn new(dimension: &DimensionType, biome_map: Vec<u16>, biome_bits: u8) -> Self {
|
||||
Self {
|
||||
min_y: dimension.min_y,
|
||||
sections: dimension.sections(),
|
||||
has_skylight: dimension.has_skylight,
|
||||
biome_map,
|
||||
biome_bits,
|
||||
}
|
||||
}
|
||||
pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self {
|
||||
Self::new(dimension, self.biome_map.clone(), self.biome_bits)
|
||||
}
|
||||
|
||||
/// LevelChunkSection data into the client's fixed -64..320 column.
|
||||
fn column(&self, p: &play::LevelChunkWithLight) -> Result<Chunk, String> {
|
||||
let sections = p
|
||||
.chunk
|
||||
.sections(self.sections, 16, self.biome_bits)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut chunk = Chunk::empty(p.x, p.z);
|
||||
let offset = (self.min_y - MIN_Y).div_euclid(16);
|
||||
// Biome table index per quart cell of the dimension's own sections.
|
||||
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let target = offset + i as i32;
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = u32::from(
|
||||
self.biome_map
|
||||
.get(s.biomes.get(j) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
biome_cells.push(cells);
|
||||
if !(0..SECTIONS as i32).contains(&target) {
|
||||
continue;
|
||||
}
|
||||
// A global section is as wide as the server's registry: 16 bits
|
||||
// from a vanilla server, wider from a Horizon host with the same
|
||||
// content mods. Read it at that width, then widen it to ours.
|
||||
let ours = block_strategy();
|
||||
let strategy = if ours.wire_has_palette(s.states.bits) {
|
||||
ours
|
||||
} else {
|
||||
Strategy::blocks(s.states.bits)
|
||||
};
|
||||
let palette = s.states.palette.iter().map(|&p| p as u32).collect();
|
||||
let words = s.states.data.iter().map(|&w| w as u64).collect();
|
||||
let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if strategy.global_bits != ours.global_bits {
|
||||
let mut values = [0u32; 4096];
|
||||
blocks.unpack(&mut values);
|
||||
// Ids past our registry (a host with mods we lack) read as air.
|
||||
let limit = 1u64 << ours.global_bits;
|
||||
for v in &mut values {
|
||||
if u64::from(*v) >= limit {
|
||||
*v = 0;
|
||||
}
|
||||
}
|
||||
blocks = PalettedContainer::from_values(ours, &values);
|
||||
}
|
||||
let mut section = Section {
|
||||
blocks,
|
||||
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
|
||||
light: Light::Uniform(0xF0),
|
||||
};
|
||||
section.compact();
|
||||
chunk.sections[target as usize] = Arc::new(section);
|
||||
}
|
||||
// Sections outside the dimension's range take its nearest biomes
|
||||
// (the column's top or bottom cell layer repeated).
|
||||
if !biome_cells.is_empty() {
|
||||
let last = biome_cells.len() - 1;
|
||||
for si in 0..SECTIONS as i32 {
|
||||
let i = si - offset;
|
||||
if (0..sections.len() as i32).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = biome_cells[edge][layer * 16 + j % 16];
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
|
||||
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
|
||||
}
|
||||
}
|
||||
// ClientPacketListener.applyLightData: the column keeps the light
|
||||
// the server sent; nothing is relit.
|
||||
let (layers, above) = self.light(&p.light);
|
||||
horizon_client::light::light_from_layers(&mut chunk, &layers, above, self.has_skylight);
|
||||
Ok(chunk)
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
|
||||
/// light packet, by section of the client's column, and the sky light
|
||||
/// above the column's top section (from the light section just above
|
||||
/// it, else open sky).
|
||||
pub(super) fn light(&self, light: &LightData) -> (Vec<SectionLight>, [u8; 256]) {
|
||||
let mut layers: Vec<SectionLight> = vec![(None, None); SECTIONS];
|
||||
let sky = self.has_skylight;
|
||||
let mut above = [if sky { 15 } else { 0 }; 256];
|
||||
// Light section b is the dimension's section b - 1.
|
||||
let first = (self.min_y - MIN_Y).div_euclid(16) - 1;
|
||||
for (b, layer) in light.layers(self.sections + 2).into_iter().enumerate() {
|
||||
let si = first + b as i32;
|
||||
if (0..SECTIONS as i32).contains(&si) {
|
||||
layers[si as usize] = layer;
|
||||
} else if si == SECTIONS as i32
|
||||
&& sky
|
||||
&& let Some(l) = &layer.0
|
||||
{
|
||||
for (i, a) in above.iter_mut().enumerate() {
|
||||
*a = (l[i >> 1] >> ((i & 1) * 4)) & 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
(layers, above)
|
||||
}
|
||||
}
|
||||
|
||||
type Decoded = (Box<C>, Result<Chunk, String>);
|
||||
|
||||
/// One thread that decodes chunk packets in the order they arrived; its
|
||||
/// results come back in that order, one per `submit`.
|
||||
pub(super) struct ChunkDecoder {
|
||||
jobs: mpsc::Sender<(Arc<ChunkFormat>, Box<C>)>,
|
||||
done: mpsc::Receiver<Decoded>,
|
||||
}
|
||||
|
||||
impl ChunkDecoder {
|
||||
pub(super) fn new() -> Self {
|
||||
let (jobs, todo) = mpsc::channel::<(Arc<ChunkFormat>, Box<C>)>();
|
||||
let (finished, done) = mpsc::channel();
|
||||
// The thread ends with the connection, when `jobs` is dropped.
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Chunk decoder".into())
|
||||
.spawn(move || {
|
||||
// A frame waits for its columns, as it would for the render
|
||||
// thread's own work.
|
||||
horizon_threads::favour_current();
|
||||
for (format, packet) in todo {
|
||||
let column = match &*packet {
|
||||
C::LevelChunkWithLight(p) => format.column(p),
|
||||
_ => Err("not a chunk packet".into()),
|
||||
};
|
||||
if finished.send((packet, column)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: no chunk decoder thread ({e}); chunks decode in the frame");
|
||||
}
|
||||
Self { jobs, done }
|
||||
}
|
||||
|
||||
/// Start decoding a chunk packet with the format it arrived under.
|
||||
/// Without a decoder thread the packet comes back undecoded.
|
||||
pub(super) fn submit(&self, format: Arc<ChunkFormat>, packet: Box<C>) -> Option<Box<C>> {
|
||||
self.jobs.send((format, packet)).err().map(|e| (e.0).1)
|
||||
}
|
||||
|
||||
/// The oldest submitted packet and its column, unless it is still
|
||||
/// decoding: a frame never waits for the decoder.
|
||||
pub(super) fn next(&self) -> Next {
|
||||
match self.done.try_recv() {
|
||||
Ok(decoded) => Next::Ready(decoded),
|
||||
Err(TryRecvError::Empty) => Next::Decoding,
|
||||
// The decoder thread panicked: its columns are gone.
|
||||
Err(TryRecvError::Disconnected) => Next::Lost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Next {
|
||||
Ready(Decoded),
|
||||
Decoding,
|
||||
Lost,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `handleLevelChunkWithLight`: the column replaces any loaded one, and
|
||||
/// its block entities load their update tags. `decoded` is the column the
|
||||
/// decoder made with `format`; another format since means decode again.
|
||||
pub(super) fn remote_chunk(
|
||||
&mut self,
|
||||
p: &play::LevelChunkWithLight,
|
||||
decoded: Option<(&Arc<ChunkFormat>, Result<Chunk, String>)>,
|
||||
) {
|
||||
// ClientChunkCache.replaceWithPacketData: a column outside the
|
||||
// storage around the view centre is ignored.
|
||||
if !self.remote_chunk_in_range(p.x, p.z) {
|
||||
log::warn!(
|
||||
"Ignoring chunk since it's not in the view range: {}, {}",
|
||||
p.x,
|
||||
p.z
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
return;
|
||||
};
|
||||
let column = match decoded {
|
||||
Some((used, column)) if Arc::ptr_eq(used, &format) => column,
|
||||
_ => format.column(p),
|
||||
};
|
||||
let chunk = match column {
|
||||
Ok(chunk) => chunk,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (x, z) = (p.x, p.z);
|
||||
self.streaming.insert_remote(&mut self.world, chunk);
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
for info in &p.chunk.block_entities {
|
||||
let pos = (
|
||||
x * 16 + i32::from(info.packed_xz as u8 >> 4),
|
||||
i32::from(info.y),
|
||||
z * 16 + i32::from(info.packed_xz as u8 & 15),
|
||||
);
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.sync_block_entity(pos, state);
|
||||
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
|
||||
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleLightUpdatePacket`: the sections it names take its light.
|
||||
pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) {
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
return;
|
||||
};
|
||||
if !self.world.chunks.contains_key(&(p.x, p.z)) {
|
||||
return;
|
||||
}
|
||||
let (layers, _) = format.light(&p.light);
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else {
|
||||
return;
|
||||
};
|
||||
let changed = horizon_client::light::update_light_layers(
|
||||
Arc::make_mut(chunk),
|
||||
&layers,
|
||||
format.has_skylight,
|
||||
);
|
||||
Streaming::light_updated(&mut self.world, p.x, p.z, &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::chunk::{ChunkData, Paletted, Section as Wire, Strategy as Wiring};
|
||||
|
||||
/// A column of `state` with biome 1 everywhere, unlit.
|
||||
fn packet(x: i32, state: i32) -> Box<C> {
|
||||
let section = Wire {
|
||||
non_empty: 4096,
|
||||
fluids: 0,
|
||||
states: Paletted::from_values(&[state; 4096], Wiring::Blocks, 16),
|
||||
biomes: Paletted::from_values(&[1; 64], Wiring::Biomes, 2),
|
||||
};
|
||||
let none = LightData {
|
||||
sky_mask: Vec::new(),
|
||||
block_mask: Vec::new(),
|
||||
empty_sky_mask: Vec::new(),
|
||||
empty_block_mask: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
};
|
||||
Box::new(C::LevelChunkWithLight(play::LevelChunkWithLight {
|
||||
x,
|
||||
z: -3,
|
||||
chunk: ChunkData {
|
||||
heightmaps: Vec::new(),
|
||||
buffer: ChunkData::from_sections(&vec![section; 24]),
|
||||
block_entities: Vec::new(),
|
||||
},
|
||||
light: none,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Columns come back one per packet, in the order the packets went in,
|
||||
/// as the frame would have decoded them itself.
|
||||
#[test]
|
||||
fn the_decoder_returns_columns_in_order() {
|
||||
let overworld = DimensionType::vanilla("minecraft:overworld");
|
||||
let format = Arc::new(ChunkFormat::new(&overworld, vec![0, 7, 9], 2));
|
||||
let decoder = ChunkDecoder::new();
|
||||
for x in 0..20 {
|
||||
assert!(decoder.submit(format.clone(), packet(x, 1 + x)).is_none());
|
||||
}
|
||||
for x in 0..20 {
|
||||
let (packet, column) = loop {
|
||||
match decoder.next() {
|
||||
Next::Ready(decoded) => break decoded,
|
||||
Next::Decoding => std::thread::yield_now(),
|
||||
Next::Lost => panic!("the decoder stopped"),
|
||||
}
|
||||
};
|
||||
let C::LevelChunkWithLight(p) = *packet else {
|
||||
panic!("not a chunk packet");
|
||||
};
|
||||
let (column, direct) = (column.unwrap(), format.column(&p).unwrap());
|
||||
assert_eq!((column.x, column.z), (x, -3));
|
||||
for (cx, y, cz) in [(0, -64, 0), (5, 70, 9), (15, 319, 15)] {
|
||||
assert_eq!(column.get(cx, y, cz), 1 + x as u32);
|
||||
assert_eq!(column.get(cx, y, cz), direct.get(cx, y, cz));
|
||||
// Wire biome 1 is our biome 7.
|
||||
assert_eq!(column.biome(cx, y, cz), 7);
|
||||
}
|
||||
}
|
||||
assert!(matches!(decoder.next(), Next::Decoding));
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,8 @@ impl Game<'_> {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
|
||||
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player)}));
|
||||
}
|
||||
for (kind, p, _) in self.potions.thrown_entities() {
|
||||
out.push(json!({"kind": kind, "position": at(p)}));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world; Horizon's
|
||||
//! own packs go to `game/native_shaders.rs`. The
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
//! Shader packs: the one picked on Options > Horizon Settings > Shader Packs, or
|
||||
//! `HORIZON_SHADER_PACK=<name>` (development; it wins over the pick), runs `shaderpacks/<name>`
|
||||
//! (a folder or a zip beside `options.txt`) over the world; Horizon's own packs go to
|
||||
//! `game/native_shaders.rs`. The programs of the current dimension's folder are loaded when the
|
||||
//! dimension changes (spec 1.2).
|
||||
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
@@ -34,25 +35,34 @@ impl Game<'_> {
|
||||
/// Loads the pack for the current dimension when it changed, and returns this frame's
|
||||
/// inputs to the pack's uniforms.
|
||||
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
|
||||
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
|
||||
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
|
||||
let picked = dev.as_deref().unwrap_or(&self.settings.horizon.shader_pack);
|
||||
if self.native_shaders.picked != picked {
|
||||
// Another pack, or none: drop what ran.
|
||||
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
|
||||
self.pack_ids = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.drop_shader_packs();
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return PackInputs::default();
|
||||
};
|
||||
}
|
||||
let name = picked.to_owned();
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&name);
|
||||
let path = Self::shader_packs_dir().join(&name);
|
||||
if horizon_shader_api::package::is_pack(&path) {
|
||||
// One of Horizon's own packs (game/native_shaders.rs).
|
||||
self.tick_native_shader_pack(&path);
|
||||
} else if let Some(renderer) = &mut self.renderer
|
||||
&& renderer.shader_pack_dimension() != Some(dimension)
|
||||
{
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.as_str();
|
||||
let started = std::time::Instant::now();
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
|
||||
@@ -366,6 +366,16 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ListShaderPacks => {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Game::shader_packs_dir());
|
||||
}
|
||||
Event::ShaderPack => self.save_menu_settings(),
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = Game::shader_packs_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
|
||||
@@ -67,7 +67,9 @@ mod end_sky;
|
||||
mod flat;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_pack;
|
||||
mod native_post;
|
||||
mod native_sky;
|
||||
mod occlusion;
|
||||
mod pack_atlas;
|
||||
pub mod pack_draw;
|
||||
@@ -86,6 +88,7 @@ mod stars;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
|
||||
@@ -501,7 +504,9 @@ pub struct Renderer {
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
native_post: Option<native_post::NativePost>,
|
||||
native: Option<native_pack::NativePack>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
pass_timing: bool,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
pack_entity_rest: Vec<Vertex>,
|
||||
/// Set 1 of the cloud pipeline, for a shader pack's `gbuffers_clouds`.
|
||||
@@ -2412,7 +2417,8 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack_post: None,
|
||||
native_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
pack_weather: None,
|
||||
@@ -2921,35 +2927,40 @@ impl Renderer {
|
||||
});
|
||||
self.pack_post = Some((dimension, post));
|
||||
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
|
||||
self.native_post = None;
|
||||
self.native = None;
|
||||
}
|
||||
report
|
||||
}
|
||||
/// Runs the post passes of one of Horizon's own shader packs over the world from now on
|
||||
/// (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
/// passes' labels; on an error the passes that ran keep running.
|
||||
/// Drops the running shader pack, of either kind.
|
||||
pub fn drop_shader_packs(&mut self) {
|
||||
self.pack_post = None;
|
||||
self.native = None;
|
||||
self.pack_atlas = None;
|
||||
}
|
||||
/// Runs the sky and post stages of one of Horizon's own shader packs from now on (`None`:
|
||||
/// no pack), with its options' values; replaces a `shaders/` pack. Returns the stages'
|
||||
/// labels; on an error the pack that ran keeps running.
|
||||
pub fn set_native_shader_pack(
|
||||
&mut self,
|
||||
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let stages = pack.map(|(report, constants)| {
|
||||
let post: Vec<_> = report
|
||||
.stages
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == horizon_shader_api::StageKind::Post)
|
||||
.collect();
|
||||
(post, constants)
|
||||
});
|
||||
let Some((post, constants)) = stages.filter(|(post, _)| !post.is_empty()) else {
|
||||
self.native_post = None;
|
||||
let Some((report, constants)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let post = native_post::NativePost::new(&self.device, post, &constants, format)?;
|
||||
let labels = post.labels().into_iter().map(str::to_owned).collect();
|
||||
self.pack_post = None;
|
||||
self.pack_atlas = None;
|
||||
self.native_post = Some(post);
|
||||
let (pack, labels) = native_pack::NativePack::new(
|
||||
&self.device,
|
||||
&self.terrain_layout,
|
||||
report,
|
||||
&constants,
|
||||
format,
|
||||
)?;
|
||||
self.native = pack.draws().then_some(pack);
|
||||
if self.native.is_some() {
|
||||
self.pack_post = None;
|
||||
self.pack_atlas = None;
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
|
||||
@@ -2992,6 +3003,34 @@ impl Renderer {
|
||||
.terrain_inputs(self.pack_terrain_flat),
|
||||
)
|
||||
}
|
||||
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
|
||||
/// with `HORIZON_SHADER_PACK_TIMING`.
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on || pack_timing::PassTiming::logging();
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
native: &'a mut Option<native_pack::NativePack>,
|
||||
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
|
||||
match (pack, native) {
|
||||
(Some((_, Some(post))), _) => Some(&mut post.timing),
|
||||
(_, Some(native)) => Some(&mut native.timing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
|
||||
/// they are timed (`set_pass_timing`).
|
||||
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
|
||||
let timer = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
@@ -4015,7 +4054,15 @@ impl Renderer {
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
// One of Horizon's own packs reads the same world target.
|
||||
let native_post = !frame.title && self.native_post.is_some();
|
||||
let native_post = !frame.title && self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native)
|
||||
&& timer.is_some() != self.pass_timing
|
||||
{
|
||||
*timer = self
|
||||
.pass_timing
|
||||
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
@@ -4070,24 +4117,79 @@ impl Renderer {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
|
||||
wgpu::Color::BLACK
|
||||
} else {
|
||||
wgpu::Color {
|
||||
r: f64::from(fog_color[0]),
|
||||
g: f64::from(fog_color[1]),
|
||||
b: f64::from(fog_color[2]),
|
||||
a: 1.0,
|
||||
}
|
||||
};
|
||||
let sky_visible = sky_open(frame);
|
||||
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
|
||||
// pass of its own, so that it has a time of its own.
|
||||
let mut native_sky_pass = false;
|
||||
if !frame.title
|
||||
&& let Some(native) = &mut self.native
|
||||
{
|
||||
let size = self
|
||||
.world_post
|
||||
.size()
|
||||
.filter(|_| scaled.is_some())
|
||||
.unwrap_or([self.config.width, self.config.height]);
|
||||
native.frame.write(&self.queue, frame, size);
|
||||
if let Some(t) = &mut native.timing {
|
||||
t.begin(&self.device, &self.queue);
|
||||
native_sky_pass =
|
||||
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
|
||||
}
|
||||
}
|
||||
if native_sky_pass
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(sky) = &native.sky
|
||||
{
|
||||
if let Some(t) = &mut native.timing {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
t.mark(&self.device, &mut encoder, (world_view, format));
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&sky.label),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(clear),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
timestamp_writes: native.timing.as_mut().and_then(|t| t.writes(&sky.label)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
sky.draw(&mut pass, &native.frame);
|
||||
}
|
||||
{
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(
|
||||
if frame.title || (frame.end_sky && !frame.underwater) {
|
||||
wgpu::Color::BLACK
|
||||
} else {
|
||||
wgpu::Color {
|
||||
r: f64::from(fog_color[0]),
|
||||
g: f64::from(fog_color[1]),
|
||||
b: f64::from(fog_color[2]),
|
||||
a: 1.0,
|
||||
}
|
||||
},
|
||||
),
|
||||
load: if native_sky_pass {
|
||||
wgpu::LoadOp::Load
|
||||
} else {
|
||||
wgpu::LoadOp::Clear(clear)
|
||||
},
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
@@ -4110,11 +4212,19 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
let sky_visible = sky_open(frame);
|
||||
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
|
||||
if !frame.title && frame.skybox && !pack_draws_sky {
|
||||
pass.set_pipeline(&self.sky);
|
||||
pass.draw(0..3, 0..1);
|
||||
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
|
||||
match self
|
||||
.native
|
||||
.as_ref()
|
||||
.and_then(|n| Some((n.sky.as_ref()?, &n.frame)))
|
||||
{
|
||||
Some((sky, native)) => sky.draw(&mut pass, native),
|
||||
None => {
|
||||
pass.set_pipeline(&self.sky);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if frame.title {
|
||||
self.panorama.draw(&mut pass);
|
||||
@@ -4466,17 +4576,17 @@ impl Renderer {
|
||||
self.pack_post = Some((dimension, Some(post)));
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& native_post
|
||||
&& let Some(post) = &mut self.native_post
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(post) = &mut native.post
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
post.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
(color, depth),
|
||||
&view_target,
|
||||
frame,
|
||||
size,
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
@@ -4649,6 +4759,9 @@ impl Renderer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
|
||||
//! read, its sky and post stages, and the timer of their passes.
|
||||
|
||||
use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
pub struct NativePack {
|
||||
pub frame: NativeFrame,
|
||||
pub sky: Option<NativeSky>,
|
||||
pub post: Option<NativePost>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
|
||||
impl NativePack {
|
||||
/// The pack of the checked stages `report` with the option values `constants`, drawing
|
||||
/// onto targets of `format`; `globals` is the terrain's group 0, which the sky reads.
|
||||
/// Also returns its stages' labels, in the order they draw. Errors name the stage wgpu
|
||||
/// refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
globals: &wgpu::BindGroupLayout,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let frame = NativeFrame::new(device);
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
.partition(|s| s.kind == StageKind::Post);
|
||||
let sky = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Sky)
|
||||
.map(|stage| NativeSky::new(device, globals, &frame, stage, constants, format))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, constants, format, &frame))
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
if let Some(post) = &post {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
let pack = Self {
|
||||
frame,
|
||||
sky,
|
||||
post,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
}
|
||||
|
||||
/// Whether the pack draws anything the game runs today.
|
||||
pub fn draws(&self) -> bool {
|
||||
self.sky.is_some() || self.post.is_some()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
//! the pack's options as pipeline constants. A pass reads bind group 3: the frame, the world's
|
||||
//! colour and depth, the previous pass's output and two samplers. Passes before the last write
|
||||
//! half-float targets of the world's size, so values over 1 carry to the next; the last draws
|
||||
//! onto the screen.
|
||||
//! onto the screen. Every stage of a pack reads the same `frame`, written once a frame
|
||||
//! (`NativeFrame`).
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
|
||||
@@ -25,10 +26,89 @@ pub struct NativePost {
|
||||
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
|
||||
/// One bind group per pass, for these world views.
|
||||
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
|
||||
pub struct NativeFrame {
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the stages that read only the frame (the sky).
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
pub group: wgpu::BindGroup,
|
||||
start: std::time::Instant,
|
||||
frames: u32,
|
||||
}
|
||||
|
||||
/// The uniform `frame` at binding 0, read by `visibility`.
|
||||
fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeFrame {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
size: size_of::<FrameInputs>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
Self {
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
start: std::time::Instant::now(),
|
||||
frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
}
|
||||
}
|
||||
|
||||
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
|
||||
pub fn present_constants<'a>(
|
||||
module: &naga::Module,
|
||||
constants: &'a [(String, f64)],
|
||||
) -> Vec<(&'a str, f64)> {
|
||||
constants
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
module
|
||||
.overrides
|
||||
.iter()
|
||||
.any(|(_, o)| o.name.as_ref() == Some(id))
|
||||
})
|
||||
.map(|(id, v)| (id.as_str(), *v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -59,21 +139,13 @@ impl NativePost {
|
||||
stages: Vec<CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
) -> Result<Self, String> {
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
@@ -91,17 +163,7 @@ impl NativePost {
|
||||
let count = stages.len();
|
||||
let mut passes = Vec::with_capacity(count);
|
||||
for (i, stage) in stages.into_iter().enumerate() {
|
||||
let present: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
stage
|
||||
.module
|
||||
.overrides
|
||||
.iter()
|
||||
.any(|(_, o)| o.name.as_ref() == Some(id))
|
||||
})
|
||||
.map(|(id, v)| (id.as_str(), *v))
|
||||
.collect();
|
||||
let present = present_constants(&stage.module, constants);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -141,12 +203,6 @@ impl NativePost {
|
||||
}
|
||||
passes.push((stage.label, pipeline));
|
||||
}
|
||||
let frame = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
size: size_of::<FrameInputs>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
@@ -158,13 +214,12 @@ impl NativePost {
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
frame,
|
||||
frame: frame.buffer.clone(),
|
||||
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
|
||||
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
|
||||
targets: None,
|
||||
groups: None,
|
||||
start: std::time::Instant::now(),
|
||||
frames: 0,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,35 +313,20 @@ impl NativePost {
|
||||
}
|
||||
|
||||
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
|
||||
/// `screen`.
|
||||
/// `screen`, timed by `timing` when it times this frame.
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
frame: &Frame,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
self.draw(device, queue, encoder, world, screen, &inputs, size);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
inputs: &FrameInputs,
|
||||
size: [u32; 2],
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.prepare(device, world, size);
|
||||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(inputs));
|
||||
if let Some(t) = timing.as_deref_mut() {
|
||||
t.mark(device, encoder, (world.0, self.format));
|
||||
}
|
||||
let Some((_, groups)) = &self.groups else {
|
||||
return;
|
||||
};
|
||||
@@ -307,6 +347,7 @@ impl NativePost {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &attachments,
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
@@ -426,8 +467,20 @@ mod tests {
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let mut post =
|
||||
NativePost::new(&device, report.stages, &[("gain".into(), 1.0)], format).unwrap();
|
||||
let frame = NativeFrame::new(&device);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&FrameInputs::default()),
|
||||
);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
|
||||
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -467,15 +520,7 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
let view = screen.create_view(&Default::default());
|
||||
post.draw(
|
||||
&device,
|
||||
&queue,
|
||||
&mut encoder,
|
||||
(&color, &depth),
|
||||
&view,
|
||||
&FrameInputs::default(),
|
||||
[4, 4],
|
||||
);
|
||||
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! The sky stage of Horizon's own shader packs (docs/shaders.md): the pack's `sky` function,
|
||||
//! called for each pixel of the sky disc with vanilla's colour along its view ray. The
|
||||
//! pipeline's shader is the pack's stage (`CheckedStage::source`) followed by a copy of the
|
||||
//! renderer's sky (terrain.wgsl's `Globals`, `linear_fog` and sky section) whose names all
|
||||
//! take a `horizon_sky_` prefix, so none can meet a pack's (the check refuses `horizon_`
|
||||
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
|
||||
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
|
||||
|
||||
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
|
||||
|
||||
use super::native_post::{NativeFrame, present_constants};
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
const PREFIX: &str = "horizon_sky_";
|
||||
|
||||
/// The fragment entry that calls the pack: vanilla's colour in, an opaque colour out.
|
||||
const PACK_ENTRY: &str = "
|
||||
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = horizon_sky_view_ray(in.ndc);
|
||||
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
pub struct NativeSky {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
|
||||
fn vanilla_sky() -> String {
|
||||
let text = [
|
||||
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
|
||||
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
|
||||
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
|
||||
]
|
||||
.concat();
|
||||
prefixed(&text, PREFIX)
|
||||
}
|
||||
|
||||
/// The sky shader for the pack's sky stage `source`.
|
||||
fn shader_source(source: &str) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
|
||||
vanilla_sky()
|
||||
)
|
||||
}
|
||||
|
||||
/// A sky pipeline over `module` whose fragment entry is `fragment`.
|
||||
fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
module: &wgpu::ShaderModule,
|
||||
fragment: &str,
|
||||
constants: &[(&str, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack sky"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some("horizon_sky_vs_sky"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The sky's naga module and the layout of its pipelines: `globals` at group 0, the frame
|
||||
/// at group 3.
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
globals: &wgpu::BindGroupLayout,
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
|
||||
let text = shader_source(&stage.source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(globals);
|
||||
groups[GROUP as usize] = Some(&frame.layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack sky"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
Ok((module, layout))
|
||||
}
|
||||
|
||||
impl NativeSky {
|
||||
/// The pipeline of the pack's sky `stage` with the option values `constants`, drawing
|
||||
/// onto the world's target of `format`; `globals` is the terrain's group 0.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
globals: &wgpu::BindGroupLayout,
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Result<Self, String> {
|
||||
let (module, layout) = build(device, globals, frame, stage)?;
|
||||
let present = present_constants(&module, constants);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let pipeline = pipeline(
|
||||
device,
|
||||
&layout,
|
||||
&module,
|
||||
"horizon_sky_fs_pack",
|
||||
&present,
|
||||
format,
|
||||
);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
}
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
label: stage.label.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws the sky in a pass whose group 0 is the terrain's.
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, frame: &NativeFrame) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(GROUP, &frame.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{Frame as FrameInputs, MANIFEST, Pack, StageKind, check};
|
||||
|
||||
fn stage(sky: &str) -> CheckedStage {
|
||||
let mut manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[sky]\nfile = \"sky.wgsl\"\n"
|
||||
.to_owned();
|
||||
if sky.contains("gain") {
|
||||
manifest += "[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\
|
||||
default = 1.0\nmin = 0.0\nmax = 2.0\n";
|
||||
}
|
||||
let manifest = manifest.as_str();
|
||||
let files = [(MANIFEST, manifest), ("sky.wgsl", sky)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
assert_eq!(report.stages[0].kind, StageKind::Sky);
|
||||
report.stages.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_renderer_s_sky_takes_a_prefix_and_validates_with_a_pack() {
|
||||
let sky = vanilla_sky();
|
||||
for name in [
|
||||
"Globals",
|
||||
"SkyOut",
|
||||
"view_ray",
|
||||
"PI",
|
||||
"sky_along_ray",
|
||||
"linear_fog",
|
||||
"fs_sky",
|
||||
] {
|
||||
assert!(sky.contains(&format!("{PREFIX}{name}")), "{name}");
|
||||
}
|
||||
// A pack may use the renderer's names for its own things.
|
||||
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
|
||||
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
|
||||
let text = shader_source(&stage(own).source);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
|
||||
panic!("{}", e.emit_to_string(&text));
|
||||
});
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::default(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Vanilla's sky and two packs over the same globals: one that returns its input draws
|
||||
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
|
||||
#[test]
|
||||
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let mut globals: super::super::Globals = bytemuck::Zeroable::zeroed();
|
||||
globals.inverse_view = glam::Mat4::IDENTITY.to_cols_array_2d();
|
||||
globals.misc = [0.0, 0.0, 1.0, 1.0];
|
||||
globals.fog_color = [0.2, 0.4, 0.6, 1.0];
|
||||
globals.sky_color = [0.5, 0.7, 1.0, 1.0];
|
||||
globals.fog_ends = [64.0, 0.0, 0.0, 0.0];
|
||||
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: size_of::<super::super::Globals>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&globals_buffer, 0, bytemuck::bytes_of(&globals));
|
||||
let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &globals_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&FrameInputs::default()),
|
||||
);
|
||||
|
||||
let gain = [("gain".to_owned(), 1.0)];
|
||||
let draw = |stage: &CheckedStage, vanilla: bool| -> Vec<u8> {
|
||||
let sky =
|
||||
NativeSky::new(&device, &globals_layout, &frame, stage, &gain, format).unwrap();
|
||||
let pipeline = if vanilla {
|
||||
let (module, layout) = build(&device, &globals_layout, &frame, stage).unwrap();
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
pipeline(&device, &layout, &module, "horizon_sky_fs_sky", &[], format)
|
||||
} else {
|
||||
sky.pipeline.clone()
|
||||
};
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let (color_view, depth_view) = (
|
||||
color.create_view(&Default::default()),
|
||||
depth.create_view(&Default::default()),
|
||||
);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &globals_group, &[]);
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(GROUP, &frame.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
|
||||
rows_per_image: Some(4),
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
(0..4)
|
||||
.flat_map(|row| {
|
||||
let start = row * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
|
||||
bytes[start..start + 16].to_vec()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let same = stage("fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n");
|
||||
let vanilla = draw(&same, true);
|
||||
assert_eq!(draw(&same, false), vanilla);
|
||||
// The top row looks up into the sky disc, the bottom one down into the fog.
|
||||
assert_ne!(vanilla[..4], vanilla[vanilla.len() - 4..]);
|
||||
let inverse = stage(
|
||||
"override gain: f32;\nfn sky(input: SkyInput) -> vec4<f32> { return vec4(vec3(gain) - input.color.rgb, 0.25); }\n",
|
||||
);
|
||||
let inverted = draw(&inverse, false);
|
||||
for (got, want) in inverted.chunks(4).zip(vanilla.chunks(4)) {
|
||||
assert_eq!(got[3], 255);
|
||||
for i in 0..3 {
|
||||
assert!(got[i].abs_diff(255 - want[i]) <= 1, "{got:?} {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,8 +297,8 @@ pub struct PackPost {
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
/// `HORIZON_SHADER_PACK_TIMING`: GPU time of each pass, in the log.
|
||||
timing: Option<super::pack_timing::PassTiming>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
@@ -1430,7 +1430,7 @@ impl PackPost {
|
||||
draws,
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
timing: super::pack_timing::PassTiming::new(device, queue),
|
||||
timing: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
lightmap: world.lightmap,
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
//! Developers' aid: `HORIZON_SHADER_PACK_TIMING=1` times every pass of a shader pack on the
|
||||
//! GPU, one frame in `EVERY`, and logs each pass's median every `REPORT` timed frames. The
|
||||
//! frame after a timed one waits for the GPU before reading the timestamps (Metal resolves
|
||||
//! them without waiting for the passes that write them), so timing slows the game.
|
||||
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
|
||||
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
|
||||
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
|
||||
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
|
||||
//! write them), then read back when mapped. The next frame after that is timed again.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
const EVERY: u32 = 30;
|
||||
const REPORT: usize = 30;
|
||||
/// Passes timed per frame at most (two timestamps each).
|
||||
const CAPACITY: u32 = 64;
|
||||
/// Timed frames each pass's median is taken over.
|
||||
const WINDOW: usize = 15;
|
||||
|
||||
pub(super) struct PassTiming {
|
||||
enum Stage {
|
||||
/// The next frame is timed.
|
||||
Idle,
|
||||
/// The frame being recorded is timed.
|
||||
Recording,
|
||||
/// Submitted; the flag is set once the GPU has finished it.
|
||||
Running(Arc<AtomicBool>),
|
||||
/// Resolved and copied; the flag is set once the copy is mapped.
|
||||
Reading(Arc<AtomicBool>, u64),
|
||||
}
|
||||
|
||||
pub struct PassTiming {
|
||||
queries: wgpu::QuerySet,
|
||||
resolve: wgpu::Buffer,
|
||||
read: wgpu::Buffer,
|
||||
period_ns: f32,
|
||||
frame: u32,
|
||||
/// The passes timed in the frame being recorded or waiting to be read, in order.
|
||||
stage: Stage,
|
||||
/// The passes of the timed frame, in order.
|
||||
names: Vec<String>,
|
||||
timed: bool,
|
||||
samples: Vec<(String, Vec<f32>)>,
|
||||
/// Each pass's last `WINDOW` times, in milliseconds.
|
||||
samples: Vec<(String, VecDeque<f32>)>,
|
||||
/// Log the medians every `REPORT` timed frames.
|
||||
log: bool,
|
||||
/// `mark`'s pipeline, for the target format it was made for.
|
||||
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
reports: usize,
|
||||
}
|
||||
|
||||
impl PassTiming {
|
||||
/// `None` when the device can't time passes.
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
|
||||
if std::env::var_os("HORIZON_SHADER_PACK_TIMING").is_none()
|
||||
|| !device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
|
||||
{
|
||||
if !device.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
|
||||
return None;
|
||||
}
|
||||
let size = u64::from(CAPACITY) * 2 * 8;
|
||||
@@ -52,51 +74,138 @@ impl PassTiming {
|
||||
wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
),
|
||||
period_ns: queue.get_timestamp_period(),
|
||||
frame: 0,
|
||||
stage: Stage::Idle,
|
||||
names: Vec::new(),
|
||||
timed: false,
|
||||
samples: Vec::new(),
|
||||
log: Self::logging(),
|
||||
marker: None,
|
||||
reports: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// At the start of a frame: reads the last timed frame's passes, and decides whether this
|
||||
/// one is timed.
|
||||
/// Whether `HORIZON_SHADER_PACK_TIMING` asks for timing without the F3 overlay.
|
||||
pub fn logging() -> bool {
|
||||
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*LOG.get_or_init(|| std::env::var_os("HORIZON_SHADER_PACK_TIMING").is_some())
|
||||
}
|
||||
|
||||
/// At the start of a frame's passes: moves the last timed frame along, and decides whether
|
||||
/// this one is timed.
|
||||
pub fn begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
if self.timed && !self.names.is_empty() {
|
||||
self.collect(device, queue);
|
||||
match &self.stage {
|
||||
// A timed frame that was never submitted: time this one instead.
|
||||
Stage::Recording => self.stage = Stage::Idle,
|
||||
Stage::Running(done) if done.load(Ordering::Acquire) => {
|
||||
let bytes = self.names.len() as u64 * 16;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.resolve_query_set(
|
||||
&self.queries,
|
||||
0..self.names.len() as u32 * 2,
|
||||
&self.resolve,
|
||||
0,
|
||||
);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes);
|
||||
queue.submit([encoder.finish()]);
|
||||
let mapped = Arc::new(AtomicBool::new(false));
|
||||
let flag = mapped.clone();
|
||||
self.read
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |r| {
|
||||
flag.store(r.is_ok(), Ordering::Release)
|
||||
});
|
||||
self.stage = Stage::Reading(mapped, bytes);
|
||||
}
|
||||
Stage::Reading(mapped, bytes) if mapped.load(Ordering::Acquire) => {
|
||||
let bytes = *bytes;
|
||||
self.collect(bytes);
|
||||
self.stage = Stage::Idle;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.names.clear();
|
||||
self.frame += 1;
|
||||
self.timed = self.frame.is_multiple_of(EVERY);
|
||||
if matches!(self.stage, Stage::Idle) {
|
||||
self.names.clear();
|
||||
self.stage = Stage::Recording;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the frame being recorded is timed.
|
||||
pub fn recording(&self) -> bool {
|
||||
matches!(self.stage, Stage::Recording)
|
||||
}
|
||||
|
||||
/// Timestamp writes for the pass `name`, in a timed frame.
|
||||
pub fn writes(&mut self, name: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
|
||||
let i = self.push(name)?;
|
||||
Some(stamps(&self.queries, i))
|
||||
}
|
||||
|
||||
/// The index of the timed frame's next pass, `name`.
|
||||
fn push(&mut self, name: &str) -> Option<u32> {
|
||||
let i = self.names.len() as u32;
|
||||
if !self.timed || i >= CAPACITY {
|
||||
if !matches!(self.stage, Stage::Recording) || i >= CAPACITY {
|
||||
return None;
|
||||
}
|
||||
self.names.push(name.to_owned());
|
||||
Some(wgpu::RenderPassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: Some(2 * i),
|
||||
end_of_pass_write_index: Some(2 * i + 1),
|
||||
})
|
||||
Some(i)
|
||||
}
|
||||
|
||||
fn collect(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
// The timed frame first, then the resolve, so every timestamp is written.
|
||||
_ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
let count = self.names.len() as u32 * 2;
|
||||
let bytes = u64::from(count) * 8;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.resolve_query_set(&self.queries, 0..count, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes);
|
||||
queue.submit([encoder.finish()]);
|
||||
/// A pass that ends once everything drawn into `target` before it has, in a timed frame:
|
||||
/// the next pass counts from there, not from when the GPU queued it behind them.
|
||||
pub fn mark(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
(target, format): super::timing::Target,
|
||||
) {
|
||||
if self.marker.as_ref().is_none_or(|(f, _)| *f != format) {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack timing mark"),
|
||||
source: wgpu::ShaderSource::Wgsl(super::timing::MARKER.into()),
|
||||
});
|
||||
let pipeline = super::timing::end_pipeline(device, &module, format);
|
||||
self.marker = Some((format, pipeline));
|
||||
}
|
||||
// An unnamed pass: its end counts, it is never listed.
|
||||
let Some(i) = self.push("") else {
|
||||
return;
|
||||
};
|
||||
let (_, pipeline) = self.marker.as_ref().expect("made above");
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack timing mark"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
timestamp_writes: Some(stamps(&self.queries, i)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_scissor_rect(0, 0, 1, 1);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// After the frame is submitted.
|
||||
pub fn submitted(&mut self, queue: &wgpu::Queue) {
|
||||
if !matches!(self.stage, Stage::Recording) {
|
||||
return;
|
||||
}
|
||||
if self.names.is_empty() {
|
||||
self.stage = Stage::Idle;
|
||||
return;
|
||||
}
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let flag = done.clone();
|
||||
queue.on_submitted_work_done(move || flag.store(true, Ordering::Release));
|
||||
self.stage = Stage::Running(done);
|
||||
}
|
||||
|
||||
fn collect(&mut self, bytes: u64) {
|
||||
let slice = self.read.slice(..bytes);
|
||||
slice.map_async(wgpu::MapMode::Read, |_| {});
|
||||
_ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
let Ok(data) = slice.get_mapped_range() else {
|
||||
return;
|
||||
};
|
||||
@@ -108,41 +217,91 @@ impl PassTiming {
|
||||
.collect();
|
||||
drop(data);
|
||||
self.read.unmap();
|
||||
// Passes overlap on tiled GPUs (a pass begins before the one before it ends): a pass
|
||||
// costs the time from the later of its beginning and the previous pass's end.
|
||||
let mut previous_end = 0;
|
||||
for (name, pair) in self.names.iter().zip(stamps.as_chunks::<2>().0) {
|
||||
if pair[0] == 0 || pair[1] < pair[0] {
|
||||
let times = pass_times(&stamps, self.period_ns);
|
||||
for (name, ms) in self.names.iter().zip(times) {
|
||||
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let i = match self.samples.iter().position(|s| s.0 == *name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.samples.push((name.clone(), VecDeque::new()));
|
||||
self.samples.len() - 1
|
||||
}
|
||||
};
|
||||
let window = &mut self.samples[i].1;
|
||||
if window.len() == WINDOW {
|
||||
window.pop_front();
|
||||
}
|
||||
let from = pair[0].max(previous_end).min(pair[1]);
|
||||
previous_end = pair[1];
|
||||
let ms = (pair[1] - from) as f32 * self.period_ns / 1e6;
|
||||
match self.samples.iter_mut().find(|s| s.0 == *name) {
|
||||
Some(s) => s.1.push(ms),
|
||||
None => self.samples.push((name.clone(), vec![ms])),
|
||||
}
|
||||
window.push_back(ms);
|
||||
}
|
||||
self.reports += 1;
|
||||
if self.reports.is_multiple_of(REPORT) {
|
||||
self.report();
|
||||
if self.log && self.reports.is_multiple_of(REPORT) {
|
||||
let medians = self.medians();
|
||||
let total: f32 = medians.iter().map(|m| m.1).sum();
|
||||
let parts: Vec<String> = medians
|
||||
.iter()
|
||||
.map(|(n, ms)| format!("{n} {ms:.3}"))
|
||||
.collect();
|
||||
log::info!(
|
||||
"shader pack GPU time per pass (median ms of the last {WINDOW} timed frames): {} = {total:.3}",
|
||||
parts.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn report(&mut self) {
|
||||
let mut total = 0.0;
|
||||
let mut parts = Vec::new();
|
||||
// A pass that did not run in this window (no entities, the sky out of sight) is left out.
|
||||
for (name, v) in self.samples.iter_mut().filter(|s| !s.1.is_empty()) {
|
||||
v.sort_by(f32::total_cmp);
|
||||
let median = v[v.len() / 2];
|
||||
total += median;
|
||||
parts.push(format!("{name} {median:.3}"));
|
||||
v.clear();
|
||||
}
|
||||
log::info!(
|
||||
"shader pack GPU time per pass (median ms of {REPORT} frames): {} = {total:.3}",
|
||||
parts.join(", ")
|
||||
);
|
||||
/// Each pass's median time in milliseconds over its last timed frames, in the order the
|
||||
/// passes first ran.
|
||||
pub fn medians(&self) -> Vec<(String, f32)> {
|
||||
self.samples
|
||||
.iter()
|
||||
.filter(|s| !s.1.is_empty())
|
||||
.map(|(name, v)| {
|
||||
let mut v: Vec<f32> = v.iter().copied().collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
(name.clone(), v[v.len() / 2])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass `i`'s beginning and end timestamps.
|
||||
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
|
||||
wgpu::RenderPassTimestampWrites {
|
||||
query_set: queries,
|
||||
beginning_of_pass_write_index: Some(2 * i),
|
||||
end_of_pass_write_index: Some(2 * i + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
|
||||
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
|
||||
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
|
||||
fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
|
||||
let mut previous_end = 0;
|
||||
stamps
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|&[begin, end]| {
|
||||
if begin == 0 || end < begin {
|
||||
return None;
|
||||
}
|
||||
let from = begin.max(previous_end).min(end);
|
||||
previous_end = end;
|
||||
Some((end - from) as f32 * period_ns / 1e6)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pass_times;
|
||||
|
||||
#[test]
|
||||
fn overlapping_passes_cost_from_the_previous_end() {
|
||||
// Pass 2 begins before pass 1 ends; pass 3 was never stamped.
|
||||
let stamps = [1_000_000, 3_000_000, 2_000_000, 5_000_000, 0, 0];
|
||||
assert_eq!(pass_times(&stamps, 1.0), [Some(2.0), Some(2.0), None]);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ struct Markers {
|
||||
|
||||
/// The start's one thread; the end's triangle over the whole target (the
|
||||
/// pass's scissor keeps one pixel), shading nothing it writes.
|
||||
const MARKER: &str =
|
||||
pub(super) const MARKER: &str =
|
||||
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -254,7 +254,7 @@ impl GpuTimer {
|
||||
}
|
||||
|
||||
/// The end marker's pipeline for a `format` target, every write masked.
|
||||
fn end_pipeline(
|
||||
pub(super) fn end_pipeline(
|
||||
device: &wgpu::Device,
|
||||
module: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Copies of the renderer's own WGSL that run beside a shader pack's code in one module (the
|
||||
//! sky and material stages of Horizon's packs, docs/shaders.md): every name such a copy
|
||||
//! declares takes a prefix starting with `horizon_`, which the pack check refuses in a pack,
|
||||
//! so no pack name can meet one of Horizon's.
|
||||
|
||||
/// terrain.wgsl, which the copies are taken from.
|
||||
pub const TERRAIN: &str = include_str!("../terrain.wgsl");
|
||||
|
||||
/// The text of `source` from the line starting with `start` up to (not including) the one
|
||||
/// starting with `end`.
|
||||
pub fn section<'a>(source: &'a str, start: &str, end: &str) -> &'a str {
|
||||
let from = source
|
||||
.find(&format!("\n{start}"))
|
||||
.expect("the source has the section")
|
||||
+ 1;
|
||||
let to = from
|
||||
+ source[from..]
|
||||
.find(&format!("\n{end}"))
|
||||
.expect("the source ends the section")
|
||||
+ 1;
|
||||
&source[from..to]
|
||||
}
|
||||
|
||||
/// The names `text` declares at its top level: functions, structs, constants, overrides and
|
||||
/// module variables (bindings).
|
||||
fn declared(text: &str) -> Vec<&str> {
|
||||
let mut names = Vec::new();
|
||||
for line in text.lines() {
|
||||
// Top level only: a function's own `var` and `let` stay as they are.
|
||||
if line.starts_with(char::is_whitespace) {
|
||||
continue;
|
||||
}
|
||||
let mut line = line;
|
||||
while let Some(rest) = line.strip_prefix('@') {
|
||||
// An attribute: `@vertex`, `@group(0)`, `@binding(1)`.
|
||||
let end = rest.find(' ').unwrap_or(rest.len());
|
||||
line = rest[end..].trim_start();
|
||||
}
|
||||
let rest = ["fn ", "struct ", "const ", "override ", "var "]
|
||||
.iter()
|
||||
.find_map(|k| line.strip_prefix(k))
|
||||
.or_else(|| {
|
||||
// `var<uniform> name`, `var<storage, read> name`.
|
||||
let rest = line.strip_prefix("var<")?;
|
||||
Some(rest[rest.find('>')? + 1..].trim_start())
|
||||
});
|
||||
if let Some(rest) = rest {
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
names.push(&rest[..end]);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// `text` with every name it declares at its top level, and every use of one, prefixed by
|
||||
/// `prefix`. Comments are rewritten too, which is harmless.
|
||||
pub fn prefixed(text: &str, prefix: &str) -> String {
|
||||
let names = declared(text);
|
||||
let mut out = String::with_capacity(text.len() * 2);
|
||||
let mut word = String::new();
|
||||
let flush = |word: &mut String, out: &mut String| {
|
||||
if names.contains(&word.as_str()) {
|
||||
out.push_str(prefix);
|
||||
}
|
||||
out.push_str(word);
|
||||
word.clear();
|
||||
};
|
||||
for c in text.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
word.push(c);
|
||||
} else {
|
||||
flush(&mut word, &mut out);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
flush(&mut word, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefixes_top_level_names_and_their_uses_only() {
|
||||
let text = "struct S { a: f32, }\n\
|
||||
@group(0) @binding(0) var<uniform> g: S;\n\
|
||||
@group(1) @binding(2) var<storage, read> list: array<u32>;\n\
|
||||
override N: u32 = 1u;\n\
|
||||
const K: f32 = 2.0;\n\
|
||||
@fragment fn f(x: f32) -> @location(0) vec4<f32> {\n \
|
||||
var a = g.a * K;\n \
|
||||
return vec4(a + f32(list[N]) + x);\n\
|
||||
}\n";
|
||||
let out = prefixed(text, "p_");
|
||||
assert_eq!(
|
||||
out,
|
||||
"struct p_S { a: f32, }\n\
|
||||
@group(0) @binding(0) var<uniform> p_g: p_S;\n\
|
||||
@group(1) @binding(2) var<storage, read> p_list: array<u32>;\n\
|
||||
override p_N: u32 = 1u;\n\
|
||||
const p_K: f32 = 2.0;\n\
|
||||
@fragment fn p_f(x: f32) -> @location(0) vec4<f32> {\n \
|
||||
var a = p_g.a * p_K;\n \
|
||||
return vec4(a + f32(p_list[p_N]) + x);\n\
|
||||
}\n"
|
||||
);
|
||||
// Every top-level declaration of terrain.wgsl is found.
|
||||
let names = declared(TERRAIN);
|
||||
for name in [
|
||||
"Globals",
|
||||
"globals",
|
||||
"sprites_7",
|
||||
"SPRITE_SPLIT",
|
||||
"block_slots",
|
||||
"fs_sky",
|
||||
] {
|
||||
assert!(names.contains(&name), "{name}");
|
||||
}
|
||||
assert!(!names.contains(&"out"));
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,10 @@ pub struct Graphics {
|
||||
pub battery: bool,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
/// ignored.
|
||||
pub shader_pack: String,
|
||||
}
|
||||
impl Default for Graphics {
|
||||
fn default() -> Self {
|
||||
@@ -284,6 +288,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,6 +339,7 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
g.overlay = match value {
|
||||
"fps" => Overlay::Fps,
|
||||
@@ -348,7 +354,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -375,6 +381,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
@@ -471,6 +478,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
|
||||
@@ -2175,17 +2175,24 @@ impl Hub {
|
||||
if g.spectator() || g.sleeping {
|
||||
return (false, false);
|
||||
}
|
||||
let height = if g.fall_flying {
|
||||
0.6
|
||||
// getBoundingBox: the box of the body's pose (swimming and crawling
|
||||
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
|
||||
let (half, height) = if g.fall_flying {
|
||||
(0.3, 0.6)
|
||||
} else if let Some(m) = &g.motion {
|
||||
(
|
||||
f64::from(m.dimensions.width) / 2.0,
|
||||
f64::from(m.dimensions.height),
|
||||
)
|
||||
} else if g.sneaking {
|
||||
1.5
|
||||
(0.3, 1.5)
|
||||
} else {
|
||||
1.8
|
||||
(0.3, 1.8)
|
||||
};
|
||||
let at = |p: [f64; 3]| {
|
||||
Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + height, p[2] + 0.3),
|
||||
Vec3::new(p[0] - half, p[1], p[2] - half),
|
||||
Vec3::new(p[0] + half, p[1] + height, p[2] + half),
|
||||
)
|
||||
};
|
||||
let (old, new) = (at(g.position), at(to));
|
||||
|
||||
@@ -168,6 +168,7 @@ pub mod sculk;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
|
||||
@@ -4,7 +4,13 @@ mod game;
|
||||
|
||||
use horizon_client::{crash, logging};
|
||||
|
||||
/// The mod API level this game serves, kept in the program for the launcher
|
||||
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
|
||||
/// reads it, so the linker keeps it.
|
||||
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
|
||||
|
||||
fn main() {
|
||||
std::hint::black_box(&MOD_API_LEVEL);
|
||||
horizon_client::telemetry::mark_start();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
|
||||
@@ -44,7 +44,7 @@ use std::{
|
||||
|
||||
pub(crate) mod environment;
|
||||
mod fluids;
|
||||
pub use fluids::{FluidResources, LAVA, PlayerFluids, WATER};
|
||||
pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER};
|
||||
|
||||
pub struct MotionResources {
|
||||
pub attributes: Arc<Catalog>,
|
||||
|
||||
@@ -103,10 +103,10 @@ fn is_tagged(kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn fluid_height(&self, tag: FluidTagId) -> f64 {
|
||||
self.fluids.interaction.fluid_height(&Membership, tag)
|
||||
self.fluids.interaction.fluid_height(&FluidTags, tag)
|
||||
}
|
||||
pub fn is_eye_in_fluid(&self, tag: FluidTagId) -> bool {
|
||||
self.fluids.interaction.is_eye_in_fluid(&Membership, tag)
|
||||
self.fluids.interaction.is_eye_in_fluid(&FluidTags, tag)
|
||||
}
|
||||
/// `LocalPlayer.isUnderWater`: `wasUnderwater`, the eye sample `Player.tick`
|
||||
/// takes before the base tick (`Entity.isUnderWater` also needs the body
|
||||
@@ -116,11 +116,12 @@ impl PlayerMotion {
|
||||
}
|
||||
/// `Entity.isInLava`.
|
||||
pub fn is_in_lava(&self) -> bool {
|
||||
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&Membership, LAVA)
|
||||
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&FluidTags, LAVA)
|
||||
}
|
||||
}
|
||||
struct Membership;
|
||||
impl FluidMembership for Membership {
|
||||
/// Which fluids `FluidTags.WATER` and `FluidTags.LAVA` hold.
|
||||
pub struct FluidTags;
|
||||
impl FluidMembership for FluidTags {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
is_tagged(kind, tag)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct Moved {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
pub const MOVED: [Moved; 2] = [
|
||||
pub const MOVED: [Moved; 4] = [
|
||||
Moved {
|
||||
id: "fps_overlay",
|
||||
name: "FPS counter",
|
||||
@@ -34,13 +34,23 @@ pub const MOVED: [Moved; 2] = [
|
||||
id: "keystrokes",
|
||||
name: "Keystrokes",
|
||||
},
|
||||
Moved {
|
||||
id: "zoom",
|
||||
name: "Zoom",
|
||||
},
|
||||
Moved {
|
||||
id: "minimap",
|
||||
name: "Minimap",
|
||||
},
|
||||
];
|
||||
|
||||
impl Moved {
|
||||
fn was_on(&self, e: &Extras) -> bool {
|
||||
match self.id {
|
||||
"fps_overlay" => e.fps,
|
||||
_ => e.keystrokes,
|
||||
"keystrokes" => e.keystrokes,
|
||||
"zoom" => e.zoom,
|
||||
_ => e.minimap,
|
||||
}
|
||||
}
|
||||
/// The extra's settings as the mod keeps them (its storage keys).
|
||||
@@ -51,10 +61,33 @@ impl Moved {
|
||||
("corner", e.fps_corner.name().into()),
|
||||
("lows", flag(e.fps_lows)),
|
||||
],
|
||||
_ => vec![
|
||||
"keystrokes" => vec![
|
||||
("corner", e.keystrokes_corner.name().into()),
|
||||
("cps", flag(e.keystrokes_cps)),
|
||||
],
|
||||
"zoom" => vec![
|
||||
("level", e.zoom_level.to_string()),
|
||||
("smooth", flag(e.zoom_smooth)),
|
||||
("sensitivity", flag(e.zoom_sensitivity)),
|
||||
],
|
||||
_ => vec![
|
||||
("corner", e.minimap_corner.name().into()),
|
||||
("size", e.minimap_size.to_string()),
|
||||
(
|
||||
"shape",
|
||||
if e.minimap_round { "round" } else { "square" }.into(),
|
||||
),
|
||||
("zoom", e.minimap_zoom.to_string()),
|
||||
("rotate", flag(e.minimap_rotate)),
|
||||
],
|
||||
}
|
||||
}
|
||||
/// The extra's key as the mod's key (`horizon-mods.txt`), when the
|
||||
/// player had changed it from the mod's default.
|
||||
pub fn keys(&self, e: &Extras) -> Vec<(&'static str, String)> {
|
||||
match self.id {
|
||||
"zoom" if e.zoom_key != "key.keyboard.z" => vec![("zoom", e.zoom_key.clone())],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The line the player reads once it is installed.
|
||||
@@ -131,21 +164,21 @@ pub fn fetch_index() -> Result<Index, String> {
|
||||
|
||||
/// Installs `m` into the game folder `dir` (where `options.txt` is), for a
|
||||
/// player whose extras were `e`: its newest version for this game that
|
||||
/// wasn't taken down, checked against the index (size, SHA-256, signature,
|
||||
/// manifest), as `horizon-mods/<id>.hmod`, turned on, with the extra's
|
||||
/// settings in its storage unless the mod has its own already.
|
||||
/// wasn't taken down and that this Horizon's mod API level runs, checked
|
||||
/// against the index (size, SHA-256, signature, manifest), as
|
||||
/// `horizon-mods/<id>.hmod`, turned on, with the extra's settings in its
|
||||
/// storage unless the mod has its own already.
|
||||
pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> {
|
||||
let project = index
|
||||
.project(m.id)
|
||||
.ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?;
|
||||
let version = project
|
||||
.versions
|
||||
.iter()
|
||||
.find(|v| {
|
||||
v.game == horizon_core::version::VERSION_ID
|
||||
&& index.revoked(&project.id, &v.file.sha256).is_none()
|
||||
})
|
||||
.ok_or_else(|| format!("{} has no version for this game", m.id))?;
|
||||
let version = index
|
||||
.installable(
|
||||
project,
|
||||
horizon_core::version::VERSION_ID,
|
||||
horizon_mods::api_level::LEVEL,
|
||||
)
|
||||
.ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?;
|
||||
let mods = dir.join(crate::mod_list::DIR);
|
||||
fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?;
|
||||
let target = mods.join(format!("{}.hmod", m.id));
|
||||
@@ -171,6 +204,12 @@ pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), S
|
||||
}
|
||||
let mut list = ModList::load(dir);
|
||||
list.enabled.insert(m.id.to_owned());
|
||||
// A key the player already chose for the mod stays.
|
||||
let keys = list.keys.entry(m.id.to_owned()).or_default();
|
||||
for (name, key) in m.keys(e) {
|
||||
keys.entry(name.to_owned()).or_insert(key);
|
||||
}
|
||||
list.keys.retain(|_, k| !k.is_empty());
|
||||
list.save(dir);
|
||||
Ok(())
|
||||
}
|
||||
@@ -184,7 +223,7 @@ mod tests {
|
||||
fn only_extras_that_were_on_are_installed_and_each_once() {
|
||||
let mut e = Extras::default();
|
||||
assert!(pending(&e).is_empty());
|
||||
assert_eq!(nothing_to_do(&e).len(), 2);
|
||||
assert_eq!(nothing_to_do(&e).len(), 4);
|
||||
e.fps = true;
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.fps_lows = true;
|
||||
@@ -199,7 +238,40 @@ mod tests {
|
||||
);
|
||||
e.moved.push("fps_overlay".into());
|
||||
assert!(pending(&e).is_empty());
|
||||
assert_eq!(nothing_to_do(&e), [&MOVED[1]]);
|
||||
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]);
|
||||
// Zoom: its settings, and its key only when the player changed it.
|
||||
e.zoom = true;
|
||||
e.zoom_level = 8;
|
||||
e.zoom_smooth = false;
|
||||
assert_eq!(pending(&e), [&MOVED[2]]);
|
||||
assert_eq!(
|
||||
MOVED[2].settings(&e),
|
||||
[
|
||||
("level", "8".into()),
|
||||
("smooth", "false".into()),
|
||||
("sensitivity", "true".into())
|
||||
]
|
||||
);
|
||||
assert!(MOVED[2].keys(&e).is_empty());
|
||||
e.zoom_key = "key.mouse.4".into();
|
||||
assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]);
|
||||
// The minimap: its five settings, in the extra's values.
|
||||
e.minimap = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
e.minimap_zoom = 0.5;
|
||||
assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]);
|
||||
assert_eq!(
|
||||
MOVED[3].settings(&e),
|
||||
[
|
||||
("corner", "bottom_left".into()),
|
||||
("size", "128".into()),
|
||||
("shape", "round".into()),
|
||||
("zoom", "0.5".into()),
|
||||
("rotate", "false".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
MOVED[0].notice(),
|
||||
"FPS counter moved to the mod browser: installed for you"
|
||||
|
||||
@@ -863,6 +863,8 @@ pub enum Page {
|
||||
Font,
|
||||
Packs,
|
||||
Mods,
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -893,6 +895,9 @@ enum Act {
|
||||
ResetPadKey(&'static str),
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
|
||||
PickShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
@@ -947,6 +952,11 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
/// A shader pack was picked or turned off: save and apply it.
|
||||
ShaderPack,
|
||||
OpenShaderPackFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
@@ -1092,6 +1102,9 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
@@ -1183,6 +1196,7 @@ impl OptionsUi {
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
@@ -1299,23 +1313,30 @@ impl OptionsUi {
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Controller Settings...", Page::Controller),
|
||||
Some(self.extra_button(s, crate::extras::Item::DiscordPresence)),
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("AI Agent...", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai).then(|| {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
}),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
@@ -1532,6 +1553,26 @@ impl OptionsUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::PickShaderPack(i)), None)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -2017,13 +2058,18 @@ impl OptionsUi {
|
||||
}
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
if page == Page::Mods {
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal("Open Mod Folder"),
|
||||
Act::OpenModFolder,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
@@ -2475,7 +2521,12 @@ impl OptionsUi {
|
||||
} => {
|
||||
events.push(Event::ButtonClick);
|
||||
match act {
|
||||
Act::Push(p) => self.push(p),
|
||||
Act::Push(p) => {
|
||||
if p == Page::ShaderPacks {
|
||||
events.push(Event::ListShaderPacks);
|
||||
}
|
||||
self.push(p)
|
||||
}
|
||||
Act::Done => events.extend(self.back(s)),
|
||||
Act::Cycle(opt) => {
|
||||
opt.cycle(s, right, framebuffer);
|
||||
@@ -2520,6 +2571,17 @@ impl OptionsUi {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::PickShaderPack(i) => {
|
||||
if let Some(pack) = s.shader_packs.get(i) {
|
||||
s.horizon.shader_pack = if s.horizon.shader_pack == pack.file {
|
||||
String::new()
|
||||
} else {
|
||||
pack.file.clone()
|
||||
};
|
||||
events.push(Event::ShaderPack);
|
||||
}
|
||||
}
|
||||
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -3296,26 +3358,29 @@ mod tests {
|
||||
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
|
||||
);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Zoom)),
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
assert!(s.extras.zoom);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::ZoomKey)),
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
|
||||
vec![Event::ButtonClick]
|
||||
);
|
||||
assert_eq!(ui.selected_key, Some(crate::extras::ZOOM_KEY));
|
||||
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyC, None),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(s.extras.zoom_key, "key.keyboard.c");
|
||||
assert_eq!(s.extras.sort_key, "key.keyboard.c");
|
||||
assert_eq!(
|
||||
crate::extras::Extras::parse(&s.extras.serialize()),
|
||||
s.extras
|
||||
);
|
||||
ui.scroll(&s, size, -1000.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
// In a window too short for the page, the wheel scrolls to its end.
|
||||
let short = (480.0, 180.0);
|
||||
ui.scroll(&s, short, -1000.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
|
||||
assert!(content > bottom - top);
|
||||
assert_eq!(ui.scroll, content - (bottom - top));
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
|
||||
@@ -3867,7 +3932,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extras_and_vanilla_bindings_accept_mouse_buttons() {
|
||||
use crate::extras::{Extras, SORT_KEY, ZOOM_KEY};
|
||||
use crate::extras::{Extras, SORT_KEY};
|
||||
use winit::event::MouseButton;
|
||||
for button in [
|
||||
MouseButton::Left,
|
||||
@@ -3876,7 +3941,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, ZOOM_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -3885,7 +3950,6 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
ZOOM_KEY => assert_eq!(s.extras.zoom_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
@@ -4064,6 +4128,7 @@ mod tests {
|
||||
[
|
||||
Page::Graphics,
|
||||
Page::GraphicsAdvanced,
|
||||
Page::ShaderPacks,
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
|
||||
@@ -182,6 +182,9 @@ pub struct Settings {
|
||||
pub extras: crate::extras::Extras,
|
||||
/// The Horizon mods turned on (`horizon-mods.txt`), all off by default.
|
||||
pub mods: crate::mod_list::ModList,
|
||||
/// The packs in `shaderpacks/`, listed when the Shader Packs page opens; the pick is
|
||||
/// `horizon.shader_pack`.
|
||||
pub shader_packs: Vec<crate::shader_pack_list::Entry>,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
@@ -255,6 +258,7 @@ impl Default for Settings {
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
shader_packs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,6 +419,7 @@ impl Settings {
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
shader_packs: Vec::new(),
|
||||
auto_jump: f.bool("autoJump"),
|
||||
language: f.string("lang"),
|
||||
force_unicode: f.bool("forceUnicodeFont"),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The shader packs in the `shaderpacks` folder beside `options.txt`, as Options > Horizon
|
||||
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
|
||||
//! `shaders/` format, side by side.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub const DIR: &str = "shaderpacks";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// A folder with `horizon-shaders.toml`, or a `.hshader`.
|
||||
Horizon,
|
||||
/// A folder with a `shaders` folder, or a `.zip`.
|
||||
Classic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
pub file: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
|
||||
pub fn scan(dir: &Path) -> Vec<Entry> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
|
||||
packs.sort_by(|a, b| {
|
||||
(a.kind == Kind::Classic, a.title.to_lowercase())
|
||||
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
|
||||
});
|
||||
packs
|
||||
}
|
||||
|
||||
fn entry(path: &Path) -> Option<Entry> {
|
||||
let file = path.file_name()?.to_str()?.to_owned();
|
||||
if file.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
if horizon_shader_api::package::is_pack(path) {
|
||||
// Its manifest's name; a pack that can't be read is still listed, under its file
|
||||
// name: picking it says why in the chat.
|
||||
let manifest = if path.is_dir() {
|
||||
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
|
||||
.ok()
|
||||
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
|
||||
} else {
|
||||
horizon_shader_api::Pack::read(path)
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
|
||||
return Some(Entry {
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
});
|
||||
}
|
||||
let zip = path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
|
||||
(zip || path.join("shaders").is_dir()).then(|| Entry {
|
||||
title: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let d = dir.as_path();
|
||||
let manifest = "schema = 1\nid = \"warm\"\nname = \"Warm Light\"\nversion = \"1\"\n\
|
||||
api = \"0.1\"\n[[post]]\nfile = \"p.wgsl\"\n";
|
||||
std::fs::create_dir(d.join("warm")).unwrap();
|
||||
std::fs::write(d.join("warm").join(horizon_shader_api::MANIFEST), manifest).unwrap();
|
||||
std::fs::create_dir_all(d.join("Classic Folder/shaders")).unwrap();
|
||||
std::fs::write(d.join("archive.zip"), b"").unwrap();
|
||||
std::fs::write(d.join("notes.txt"), b"").unwrap();
|
||||
std::fs::create_dir(d.join("empty")).unwrap();
|
||||
std::fs::create_dir_all(d.join(".hidden/shaders")).unwrap();
|
||||
let packs = scan(d);
|
||||
let listed: Vec<(&str, &str, Kind)> = packs
|
||||
.iter()
|
||||
.map(|e| (e.file.as_str(), e.title.as_str(), e.kind))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
[
|
||||
("warm", "Warm Light", Kind::Horizon),
|
||||
("archive.zip", "archive.zip", Kind::Classic),
|
||||
("Classic Folder", "Classic Folder", Kind::Classic),
|
||||
]
|
||||
);
|
||||
assert!(scan(&d.join("missing")).is_empty());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -639,8 +639,12 @@ impl Streaming {
|
||||
self.lit.remove(&(x, z));
|
||||
self.meshed.remove(&(x, z));
|
||||
self.lighting.remove(&(x, z));
|
||||
self.meshing.retain(|k, _| (k.0, k.2) != (x, z));
|
||||
self.waiting.retain(|k| (k.0, k.2) != (x, z));
|
||||
// By key, not by a pass over every pending section: a moved or
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
world.dirty_sections.take_column(x, z);
|
||||
world.chunks.remove(&(x, z))
|
||||
}
|
||||
|
||||
@@ -227,8 +227,9 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = view_ray(in.ndc);
|
||||
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
|
||||
// gfx/native_sky.rs builds that stage from this section).
|
||||
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
var color = globals.fog_color.rgb;
|
||||
let dist = disc_distance(d, 16.0);
|
||||
if dist.x >= 0.0 {
|
||||
@@ -238,7 +239,10 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
if w > 0.0 {
|
||||
color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a);
|
||||
}
|
||||
return vec4(color, 1.0);
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
}
|
||||
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = view_ray(in.ndc);
|
||||
|
||||
Generated
+267
-8
@@ -17,12 +17,24 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -80,6 +92,27 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -95,6 +128,26 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -117,6 +170,21 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
@@ -147,6 +215,12 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -165,7 +239,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -187,7 +261,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -236,6 +310,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -302,11 +382,28 @@ dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
@@ -340,12 +437,27 @@ dependencies = [
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-shader-api",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmparser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -540,6 +652,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -554,6 +668,12 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -634,6 +754,54 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting",
|
||||
"half",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"libm",
|
||||
"log",
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash",
|
||||
"spirv",
|
||||
"thiserror",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga-types"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"rustc-hash",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -699,6 +867,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
@@ -752,6 +926,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -779,7 +959,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -894,6 +1074,15 @@ version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -906,6 +1095,17 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
@@ -931,7 +1131,27 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -968,7 +1188,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1049,6 +1269,12 @@ version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -1111,6 +1337,19 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.254.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -1254,10 +1493,30 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
@@ -1275,7 +1534,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -1315,7 +1574,7 @@ checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -342,11 +342,12 @@ async fn what_the_form_refuses() {
|
||||
);
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
|
||||
// Shader packs aren't open.
|
||||
// A shader pack runs the game's own check: a mod's file isn't one.
|
||||
let mut f = form();
|
||||
f["kind"] = json!("shader");
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
refused(status, &json, "shader packs open");
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
|
||||
refused(status, &json, "horizon-shaders.toml: missing");
|
||||
// An update by someone who isn't an owner.
|
||||
let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n";
|
||||
mock.lock()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[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
|
||||
@@ -0,0 +1,8 @@
|
||||
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"]
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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` counts the blocks the player broke since the server started.
|
||||
use horizon_mod_sdk::{
|
||||
server::{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("online")
|
||||
.command("mined")
|
||||
.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: &str,
|
||||
) -> 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(", "),
|
||||
_ => {
|
||||
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);
|
||||
@@ -0,0 +1,31 @@
|
||||
# /online lists the players and their dimension; /mined counts the acting 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)"
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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 = []
|
||||
@@ -21,7 +21,8 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`).
|
||||
//! `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
|
||||
@@ -173,3 +174,152 @@ macro_rules! export_mod {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 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::{BlockPos, Guest as __Guest, Registration};
|
||||
pub use horizon::mods::players::{self, GameMode, Player};
|
||||
|
||||
/// 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`. `Ok(Some(text))` is their
|
||||
/// feedback, `Err(text)` their error in red.
|
||||
fn command(
|
||||
&mut self,
|
||||
_player: &Player,
|
||||
_name: &str,
|
||||
_args: &str,
|
||||
) -> 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`.
|
||||
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_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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{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,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut out = Err("The mod is not running".to_string());
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -364,6 +364,27 @@ interface render {
|
||||
/// 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>);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
@@ -391,6 +412,84 @@ interface events {
|
||||
chat: func(text: string);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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 registration {
|
||||
/// Commands players may type, without the slash. A name vanilla
|
||||
/// already uses is refused (vanilla's command runs).
|
||||
commands: list<string>,
|
||||
/// 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,
|
||||
}
|
||||
record block-pos {
|
||||
x: s32,
|
||||
y: s32,
|
||||
z: s32,
|
||||
}
|
||||
/// 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.
|
||||
command: func(player: player, name: string, args: string) -> 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;
|
||||
@@ -401,6 +500,16 @@ world horizon-mod {
|
||||
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;
|
||||
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.
|
||||
|
||||
@@ -17,18 +17,27 @@ wasmtime = { version = "48.0.7", default-features = false, features = ["runtime"
|
||||
# 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.
|
||||
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 }
|
||||
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"]
|
||||
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"]
|
||||
index = ["dep:sha2", "dep:minisign-verify", "dep:horizon-shader-api", "dep:wasmparser"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Mod API levels: which of the WIT contract's functions a Horizon has
|
||||
//! (`docs/api/stability.md`). Every function the game offers mods has the
|
||||
//! level it arrived at; a Horizon serves every level up to its [`LEVEL`].
|
||||
//! A mod needs the level of the newest function it imports, so a mod that
|
||||
//! calls a function this Horizon doesn't have is refused with "update
|
||||
//! Horizon" instead of a linking error, and the registry lists each
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 1;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 32] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
("chat.show", 0),
|
||||
("hud.set-panel", 0),
|
||||
("hud.clear", 0),
|
||||
("storage.get", 0),
|
||||
("storage.set", 0),
|
||||
("storage.delete", 0),
|
||||
("command.run", 0),
|
||||
("ui.open-screen", 0),
|
||||
("ui.close-screen", 0),
|
||||
("ui.set-settings", 0),
|
||||
("input.claim-items", 0),
|
||||
("input.register-key", 0),
|
||||
("input.held", 0),
|
||||
("input.poll", 0),
|
||||
("input.claim-wheel", 0),
|
||||
("input.mapping", 0),
|
||||
("input.clicks-per-second", 0),
|
||||
("render.set-hud", 0),
|
||||
("render.gui-size", 0),
|
||||
("render.shake", 0),
|
||||
("render.set-corner", 0),
|
||||
("render.clear-corner", 0),
|
||||
("render.text-width", 0),
|
||||
("render.frame-stats", 0),
|
||||
("render.set-zoom", 0),
|
||||
("render.set-minimap", 1),
|
||||
("players.online", 1),
|
||||
("players.tell", 1),
|
||||
("players.broadcast", 1),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
/// without running it: `horizon-mod-api-level:0001;` (four digits). A
|
||||
/// program from before the mark serves level 0.
|
||||
pub const MARK: [u8; 27] = {
|
||||
let mut mark = *b"horizon-mod-api-level:0000;";
|
||||
let mut level = LEVEL;
|
||||
let mut i = 25;
|
||||
while level > 0 {
|
||||
mark[i] = b'0' + (level % 10) as u8;
|
||||
level /= 10;
|
||||
i -= 1;
|
||||
}
|
||||
mark
|
||||
};
|
||||
|
||||
/// The level a program's bytes carry in their [`MARK`], if any.
|
||||
pub fn read_mark(program: &[u8]) -> Option<u32> {
|
||||
let prefix = &MARK[..22];
|
||||
program
|
||||
.windows(MARK.len())
|
||||
.filter(|w| w.starts_with(prefix) && w[26] == b';')
|
||||
.find_map(|w| std::str::from_utf8(&w[22..26]).ok()?.parse().ok())
|
||||
}
|
||||
|
||||
/// A function's level; None for one this Horizon doesn't have.
|
||||
pub fn level_of(function: &str) -> Option<u32> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == function)
|
||||
.map(|&(_, level)| level)
|
||||
}
|
||||
|
||||
/// The level a mod needs, from the functions it imports (as [`imports`]
|
||||
/// names them): its newest function's. A function this Horizon doesn't
|
||||
/// know comes from a newer one, so it needs more than [`LEVEL`]; that
|
||||
/// function is returned with it, for the message.
|
||||
pub fn needed<'a>(imports: impl IntoIterator<Item = &'a str>) -> (u32, Option<&'a str>) {
|
||||
let mut needed = (0, None);
|
||||
for name in imports {
|
||||
match level_of(name) {
|
||||
Some(level) if level > needed.0 => needed = (level, Some(name)),
|
||||
Some(_) => {}
|
||||
None => return (LEVEL + 1, Some(name)),
|
||||
}
|
||||
}
|
||||
needed
|
||||
}
|
||||
|
||||
/// Why this Horizon can't run a mod that needs `level`, or None when it can.
|
||||
pub fn too_new(level: u32, function: Option<&str>) -> Option<String> {
|
||||
(level > LEVEL).then(|| {
|
||||
let uses = function.map_or(String::new(), |f| format!(" (it uses {f})"));
|
||||
format!("made for a newer Horizon{uses}: update Horizon to run it")
|
||||
})
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
pub fn imports(wasm: &[u8]) -> Result<Vec<String>, String> {
|
||||
use wasmparser::{
|
||||
Parser, Payload, Validator, WasmFeatures, component_types::ComponentEntityType,
|
||||
};
|
||||
let types = Validator::new_with_features(WasmFeatures::all())
|
||||
.validate_all(wasm)
|
||||
.map_err(|e| format!("not a component: {e}"))?;
|
||||
let types = types.as_ref();
|
||||
let mut out = Vec::new();
|
||||
for payload in Parser::new(0).parse_all(wasm) {
|
||||
let Payload::ComponentImportSection(section) = payload.map_err(|e| e.to_string())? else {
|
||||
continue;
|
||||
};
|
||||
for import in section {
|
||||
let name = import.map_err(|e| e.to_string())?.name.name;
|
||||
// `horizon:mods/[email protected]` → `render`.
|
||||
let Some(interface) = name.strip_prefix("horizon:mods/") else {
|
||||
continue;
|
||||
};
|
||||
let interface = interface.split('@').next().unwrap_or(interface);
|
||||
let Some(item) = types.component_item_for_import(name) else {
|
||||
continue;
|
||||
};
|
||||
if let ComponentEntityType::Instance(id) = item.ty {
|
||||
for (function, export) in &types[id].exports {
|
||||
if matches!(export.ty, ComponentEntityType::Func(_)) {
|
||||
out.push(format!("{interface}.{function}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn levels_are_listed_once_and_up_to_this_horizons() {
|
||||
for (i, (name, level)) in FUNCTIONS.iter().enumerate() {
|
||||
assert!(*level <= LEVEL, "{name}");
|
||||
assert!(
|
||||
FUNCTIONS[..i].iter().all(|(other, _)| other != name),
|
||||
"{name} twice"
|
||||
);
|
||||
}
|
||||
assert!(FUNCTIONS.iter().any(|&(_, level)| level == LEVEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mark_carries_the_level() {
|
||||
assert_eq!(read_mark(&MARK), Some(LEVEL));
|
||||
let mut program = b"\0\x7fELF horizon-mod-api-level: horizon-mod-api-level:".to_vec();
|
||||
program.extend_from_slice(&MARK);
|
||||
program.extend_from_slice(b"\0rest");
|
||||
assert_eq!(read_mark(&program), Some(LEVEL));
|
||||
assert_eq!(read_mark(b"horizon-mod-api-level:12;"), None);
|
||||
assert_eq!(read_mark(b"no mark"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_needs_its_newest_function() {
|
||||
assert_eq!(needed(["log.write", "hud.set-panel"]), (0, None));
|
||||
assert_eq!(
|
||||
needed(["log.write", "render.set-minimap", "hud.clear"]),
|
||||
(1, Some("render.set-minimap"))
|
||||
);
|
||||
assert_eq!(
|
||||
needed(["render.set-minimap", "render.set-sky"]),
|
||||
(LEVEL + 1, Some("render.set-sky"))
|
||||
);
|
||||
assert_eq!(too_new(LEVEL, None), None);
|
||||
assert_eq!(
|
||||
too_new(LEVEL + 1, Some("render.set-sky")).as_deref(),
|
||||
Some("made for a newer Horizon (it uses render.set-sky): update Horizon to run it")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,8 @@ pub enum Kind {
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
ResourcePack,
|
||||
/// A Horizon shader pack (the native WGSL format, not Iris or OptiFine
|
||||
/// packs). Listed by the schema; submissions open with its API.
|
||||
/// A Horizon shader pack (`.hshader`, the native WGSL format of
|
||||
/// docs/shaders.md; not the shaders/-folder packs, which stay on Modrinth).
|
||||
Shader,
|
||||
Other(String),
|
||||
}
|
||||
@@ -69,7 +69,8 @@ impl Kind {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack | Kind::Shader => Some("zip"),
|
||||
Kind::ResourcePack => Some("zip"),
|
||||
Kind::Shader => Some(horizon_shader_api::EXTENSION),
|
||||
Kind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -137,6 +138,11 @@ pub struct Version {
|
||||
pub side: Option<Side>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The mod API level a mod's code needs ([`crate::api_level`]), read
|
||||
/// from its file; none for content and other kinds, and in indexes
|
||||
/// built before levels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_level: Option<u32>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// A modpack's mods, as its manifest lists them.
|
||||
@@ -316,6 +322,21 @@ impl Index {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A project's newest version for Minecraft `game` that wasn't taken
|
||||
/// down and that a Horizon serving mod API `level` can run (an unknown
|
||||
/// level counts as runnable: the game still checks when it starts it).
|
||||
pub fn installable<'a>(
|
||||
&self,
|
||||
project: &'a Project,
|
||||
game: &str,
|
||||
level: u32,
|
||||
) -> Option<&'a Version> {
|
||||
project.versions.iter().find(|v| {
|
||||
v.game == game
|
||||
&& v.api_level.is_none_or(|l| l <= level)
|
||||
&& self.revoked(&project.id, &v.file.sha256).is_none()
|
||||
})
|
||||
}
|
||||
pub fn project(&self, id: &str) -> Option<&Project> {
|
||||
self.projects.iter().find(|p| p.id == id)
|
||||
}
|
||||
@@ -448,7 +469,7 @@ pub fn check_file(
|
||||
(None, None) => Trust::Unsigned,
|
||||
_ => return Err("its signature and the project's key don't go together".into()),
|
||||
};
|
||||
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
|
||||
if matches!(project.kind, Kind::Other(_)) {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
@@ -471,6 +492,8 @@ pub struct Facts {
|
||||
pub side: Option<Side>,
|
||||
/// In the manifest's order.
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The mod API level a mod's code needs, from the functions it imports.
|
||||
pub api_level: Option<u32>,
|
||||
/// The Minecraft version, when the file names one (a modpack does).
|
||||
pub game: Option<String>,
|
||||
pub requires: Vec<Requirement>,
|
||||
@@ -487,6 +510,7 @@ impl Facts {
|
||||
|| self.api != version.api
|
||||
|| self.side != version.side
|
||||
|| caps != listed
|
||||
|| version.api_level.is_some() && self.api_level != version.api_level
|
||||
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|
||||
|| self.requires != version.requires;
|
||||
if !differs {
|
||||
@@ -502,6 +526,13 @@ impl Facts {
|
||||
names(&self.capabilities)
|
||||
),
|
||||
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
|
||||
Kind::Shader => format!(
|
||||
"its {} ({} {}, shader API {}) isn't what the index lists",
|
||||
horizon_shader_api::MANIFEST,
|
||||
self.id.as_deref().unwrap_or("?"),
|
||||
self.version.as_deref().unwrap_or("?"),
|
||||
self.api
|
||||
),
|
||||
_ => format!("its pack format {} isn't what the index lists", self.api),
|
||||
})
|
||||
}
|
||||
@@ -509,14 +540,22 @@ impl Facts {
|
||||
|
||||
/// Reads and checks a file as its kind: a mod's manifest and content, a
|
||||
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
|
||||
/// paths. Shader packs and kinds this build doesn't know are refused: their
|
||||
/// checks come with their formats.
|
||||
/// paths, a shader pack's `horizon_shader_api::check` (the game's own). Kinds
|
||||
/// this build doesn't know are refused: their checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
let Package { manifest: m, wasm } = Package::read(path)?;
|
||||
crate::Content::read(path, &m)?;
|
||||
let api_level = match wasm {
|
||||
Some(wasm) => {
|
||||
let functions = crate::api_level::imports(&wasm)?;
|
||||
Some(crate::api_level::needed(functions.iter().map(String::as_str)).0)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Facts {
|
||||
api_level,
|
||||
capabilities: m.capabilities,
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
@@ -540,9 +579,23 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
api: check_resource_pack(path)?,
|
||||
..Facts::default()
|
||||
}),
|
||||
Kind::Shader => Err(
|
||||
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
|
||||
),
|
||||
Kind::Shader => {
|
||||
let pack = horizon_shader_api::Pack::read(path)?;
|
||||
if let Err(problems) = horizon_shader_api::check(&pack) {
|
||||
let lines: Vec<String> = problems.iter().map(ToString::to_string).collect();
|
||||
return Err(format!(
|
||||
"the shader pack fails its check:\n{}",
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
let m = pack.manifest;
|
||||
Ok(Facts {
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
api: m.api,
|
||||
..Facts::default()
|
||||
})
|
||||
}
|
||||
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
|
||||
}
|
||||
}
|
||||
@@ -591,14 +644,11 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// The kinds the registry takes submissions for. Shader packs wait for
|
||||
/// Horizon's own format; other kinds for theirs.
|
||||
/// The kinds the registry takes submissions for; other kinds wait for their
|
||||
/// formats.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
|
||||
Kind::Shader => Err(
|
||||
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
|
||||
),
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,7 @@ fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, St
|
||||
api: facts.api,
|
||||
side: facts.side,
|
||||
capabilities: facts.capabilities,
|
||||
api_level: facts.api_level,
|
||||
game: v.game.clone(),
|
||||
requires: facts.requires,
|
||||
file: File {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! A registry checkout holding the sample index's mod and modpack builds the
|
||||
//! sample index (`tests/index/index-v1.json`) exactly.
|
||||
//! A registry checkout holding the sample index's mod, modpack and shader pack
|
||||
//! builds the sample index (`tests/index/index-v1.json`) exactly.
|
||||
use super::*;
|
||||
|
||||
const HMOD: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
|
||||
const HPACK: &str = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65";
|
||||
const HSHADER: &str = "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02";
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
|
||||
@@ -61,6 +62,23 @@ file = "cozy-1.0.0.hpack"
|
||||
sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
"#;
|
||||
|
||||
const GLOW: &str = r#"id = "glow"
|
||||
kind = "shader"
|
||||
name = "Soft Glow"
|
||||
summary = "A post-processing pass over the world."
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/ludovic111/horizon-mods"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-26"
|
||||
game = "26.3"
|
||||
file = "glow-1.0.0.hshader"
|
||||
sha256 = "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02"
|
||||
"#;
|
||||
|
||||
const REVOKED_TOML: &str = r#"[[revoked]]
|
||||
id = "bad_mod"
|
||||
sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
@@ -82,9 +100,14 @@ impl Scratch {
|
||||
let s = Scratch(dir);
|
||||
s.write("mods/coords_hud.toml", &hud_entry());
|
||||
s.write("mods/cozy.toml", COZY);
|
||||
s.write("mods/glow.toml", GLOW);
|
||||
s.write("revoked.toml", REVOKED_TOML);
|
||||
s.write("icons/coords_hud.png", "png");
|
||||
for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] {
|
||||
for (name, hash) in [
|
||||
("coords_hud-0.1.0.hmod", HMOD),
|
||||
("cozy-1.0.0.hpack", HPACK),
|
||||
("glow-1.0.0.hshader", HSHADER),
|
||||
] {
|
||||
std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap();
|
||||
}
|
||||
s
|
||||
@@ -117,7 +140,7 @@ fn builds_the_sample_index() {
|
||||
let s = Scratch::new("sample");
|
||||
let built = s.build().unwrap();
|
||||
let sample = sample();
|
||||
assert_eq!(built.projects, sample.projects[..2]);
|
||||
assert_eq!(built.projects, sample.projects[..3]);
|
||||
assert_eq!(built.revoked, sample.revoked);
|
||||
assert_eq!(built.generated, sample.generated);
|
||||
// What it writes reads back.
|
||||
@@ -154,7 +177,11 @@ fn lists_every_problem_of_a_registry() {
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}");
|
||||
assert!(has("glow: shader packs open when"), "{errors:?}");
|
||||
// A shader entry is taken now, and checked like the others.
|
||||
assert!(
|
||||
has("glow 1.0.0: the file isn't the one its sha256 names"),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(
|
||||
has("other 0.1.0: the file's id is \"coords_hud\""),
|
||||
"{errors:?}"
|
||||
@@ -219,7 +246,7 @@ fn a_review_protects_published_files() {
|
||||
#[test]
|
||||
fn only_open_kinds_are_taken() {
|
||||
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
|
||||
assert!(open(&Kind::Shader).unwrap_err().contains("Iris"));
|
||||
assert!(open(&Kind::Shader).is_ok());
|
||||
assert!(open(&Kind::Other("world".into())).is_err());
|
||||
let mut submit: Entry = toml::from_str(COZY).unwrap();
|
||||
submit.id = "submit".into();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The sample index (`tests/index/index-v1.json`, the one docs/api/mod-index.md
|
||||
//! shows) and its files: `coords_hud-0.1.0.hmod`, signed with the throwaway
|
||||
//! key `test.pub` by minisign 0.12 (its secret key was deleted), and
|
||||
//! `cozy-1.0.0.hpack`, unsigned.
|
||||
//! key `test.pub` by minisign 0.12 (its secret key was deleted),
|
||||
//! `cozy-1.0.0.hpack`, unsigned, and `glow-1.0.0.hshader`, unsigned
|
||||
//! (`horizon-shader new --template post`, then `horizon-shader pack`).
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -25,8 +26,8 @@ fn reads_the_sample_and_keeps_unknown_kinds() {
|
||||
let index = sample();
|
||||
assert_eq!(index.schema, SCHEMA);
|
||||
let kinds: Vec<&str> = index.projects.iter().map(|p| p.kind.name()).collect();
|
||||
assert_eq!(kinds, ["mod", "modpack", "world"]);
|
||||
assert_eq!(index.projects[2].kind, Kind::Other("world".into()));
|
||||
assert_eq!(kinds, ["mod", "modpack", "shader", "world"]);
|
||||
assert_eq!(index.projects[3].kind, Kind::Other("world".into()));
|
||||
let hud = index.project("coords_hud").unwrap();
|
||||
assert_eq!(
|
||||
hud.versions[0].capabilities,
|
||||
@@ -44,6 +45,8 @@ fn files_pass_against_their_entries() {
|
||||
assert_eq!(check(&index, "coords_hud", &hmod), Ok(Trust::Verified));
|
||||
let hpack = fixtures().join("cozy-1.0.0.hpack");
|
||||
assert_eq!(check(&index, "cozy", &hpack), Ok(Trust::Unsigned));
|
||||
let hshader = fixtures().join("glow-1.0.0.hshader");
|
||||
assert_eq!(check(&index, "glow", &hshader), Ok(Trust::Unsigned));
|
||||
let world = index.project("skyblock_start").unwrap();
|
||||
let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err();
|
||||
assert!(e.contains("bytes"), "{e}");
|
||||
@@ -73,6 +76,15 @@ fn a_changed_file_key_or_manifest_is_refused() {
|
||||
let e = check(&more_caps, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("isn't what the index lists"), "{e}");
|
||||
|
||||
let mut other_level = index.clone();
|
||||
other_level.projects[0].versions[0].api_level = Some(1);
|
||||
let e = check(&other_level, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("isn't what the index lists"), "{e}");
|
||||
// An index built before levels lists none: the file still passes.
|
||||
let mut no_level = index.clone();
|
||||
no_level.projects[0].versions[0].api_level = None;
|
||||
assert_eq!(check(&no_level, "coords_hud", &hmod), Ok(Trust::Verified));
|
||||
|
||||
let mut revoked = index.clone();
|
||||
revoked.revoked.push(Revoked {
|
||||
id: "coords_hud".into(),
|
||||
@@ -163,3 +175,79 @@ fn resource_packs_need_a_pack_mcmeta() {
|
||||
assert!(e.contains("plain path"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_version_this_horizon_runs_is_installed() {
|
||||
let mut index = sample();
|
||||
let mut newer = index.projects[0].versions[0].clone();
|
||||
newer.version = "0.2.0".into();
|
||||
newer.file.sha256 = "2".repeat(64);
|
||||
newer.api_level = Some(crate::api_level::LEVEL + 1);
|
||||
index.projects[0].versions.insert(0, newer);
|
||||
let p = index.project("coords_hud").unwrap();
|
||||
let pick = |level| {
|
||||
index
|
||||
.installable(p, "26.3", level)
|
||||
.map(|v| v.version.as_str())
|
||||
};
|
||||
assert_eq!(pick(crate::api_level::LEVEL), Some("0.1.0"));
|
||||
assert_eq!(pick(crate::api_level::LEVEL + 1), Some("0.2.0"));
|
||||
assert_eq!(index.installable(p, "26.4", crate::api_level::LEVEL), None);
|
||||
// No level listed: runnable as far as the index knows.
|
||||
index.projects[0].versions[0].api_level = None;
|
||||
let p = index.project("coords_hud").unwrap();
|
||||
let v = index.installable(p, "26.3", 0).unwrap();
|
||||
assert_eq!(v.version, "0.2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_packs_pass_the_game_s_own_check() {
|
||||
let manifest = "schema = 1\nid = \"glow\"\nname = \"Glow\"\nversion = \"1.0.0\"\napi = \"0.1\"\n\
|
||||
[[post]]\nfile = \"post.wgsl\"\n";
|
||||
let pack = |post: &str| {
|
||||
let files = [
|
||||
(horizon_shader_api::MANIFEST, manifest),
|
||||
("post.wgsl", post),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
horizon_shader_api::Pack::from_files(files)
|
||||
.unwrap()
|
||||
.package()
|
||||
.unwrap()
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mods-shader-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("glow-1.0.0.hshader");
|
||||
std::fs::write(
|
||||
&good,
|
||||
pack("fn post(input: PostInput) -> vec4<f32> { return input.color; }\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let bad = dir.join("bad.hshader");
|
||||
std::fs::write(
|
||||
&bad,
|
||||
pack("fn post(input: PostInput) -> vec4<f32> { return input.color }\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let facts = inspect(&Kind::Shader, &good);
|
||||
let refused = inspect(&Kind::Shader, &bad).unwrap_err();
|
||||
let not_a_pack = inspect(&Kind::Shader, &fixtures().join("cozy-1.0.0.hpack")).unwrap_err();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let facts = facts.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
facts.id.as_deref(),
|
||||
facts.version.as_deref(),
|
||||
facts.api.as_str()
|
||||
),
|
||||
(Some("glow"), Some("1.0.0"), "0.1")
|
||||
);
|
||||
assert!(refused.contains("post.wgsl:1:"), "{refused}");
|
||||
assert!(
|
||||
not_a_pack.contains(horizon_shader_api::MANIFEST),
|
||||
"{not_a_pack}"
|
||||
);
|
||||
assert_eq!(Kind::Shader.extension(), Some("hshader"));
|
||||
assert!(open(&Kind::Shader).is_ok());
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! Nothing here runs unless a mod is installed and turned on: an empty
|
||||
//! [`Mods`] makes no engine and every hook returns at once.
|
||||
pub mod api_level;
|
||||
pub mod content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
|
||||
@@ -16,7 +16,10 @@ pub const API: &str = "0.1";
|
||||
/// The manifest `api` lines this build loads. 0.2 is 0.1 plus content:
|
||||
/// `assets/` and `data/` in the `.hmod`, with blocks and items declared in
|
||||
/// `data/<id>/horizon/` (docs/modpacks.md). Code still links API 0.1.
|
||||
pub const APIS: [&str; 2] = [API, "0.2"];
|
||||
/// 0.3 is a server mod (experimental): the `horizon-server-mod` world.
|
||||
pub const APIS: [&str; 3] = [API, "0.2", SERVER_API];
|
||||
/// The line of server mods, experimental: `side = "server"`.
|
||||
pub const SERVER_API: &str = "0.3";
|
||||
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
|
||||
/// to those, docs/mods.md) and the game's own.
|
||||
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
|
||||
@@ -63,7 +66,7 @@ pub enum Side {
|
||||
/// Runs in the player's game only (HUD, keys, chat commands).
|
||||
#[default]
|
||||
Client,
|
||||
/// Runs where the world is simulated (rules, blocks); not run yet.
|
||||
/// Runs where the world is simulated (rules, blocks): `api = "0.3"`.
|
||||
Server,
|
||||
/// Both, and the server tells clients which version it expects.
|
||||
Both,
|
||||
@@ -92,9 +95,11 @@ pub enum Capability {
|
||||
/// The use button on items the mod claims, and its own keys
|
||||
/// (interface `input`).
|
||||
Input,
|
||||
/// The players on the server and messages to them (server mods).
|
||||
Players,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 9] = [
|
||||
pub const ALL: [Capability; 10] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
@@ -104,7 +109,12 @@ impl Capability {
|
||||
Capability::Ui,
|
||||
Capability::Render,
|
||||
Capability::Input,
|
||||
Capability::Players,
|
||||
];
|
||||
/// Whether a server mod may have it: the others act on a local player.
|
||||
pub fn on_server(self) -> bool {
|
||||
matches!(self, Capability::Storage | Capability::Players)
|
||||
}
|
||||
/// The WIT interface it grants, in `horizon:mods`.
|
||||
pub fn interface(self) -> &'static str {
|
||||
match self {
|
||||
@@ -117,6 +127,7 @@ impl Capability {
|
||||
Capability::Ui => "ui",
|
||||
Capability::Render => "render",
|
||||
Capability::Input => "input",
|
||||
Capability::Players => "players",
|
||||
}
|
||||
}
|
||||
/// May change in any release: a mod uses it only with
|
||||
@@ -141,6 +152,7 @@ impl Capability {
|
||||
Capability::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"
|
||||
}
|
||||
Capability::Players => "see who is on the server and message them",
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -175,6 +187,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Ui => "ui",
|
||||
Capability::Render => "render",
|
||||
Capability::Input => "input",
|
||||
Capability::Players => "players",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
@@ -205,6 +218,20 @@ impl Manifest {
|
||||
"{MANIFEST}: capability registries needs api = \"0.2\""
|
||||
));
|
||||
}
|
||||
let server = m.side == Side::Server;
|
||||
if server != (m.api == SERVER_API) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: a server mod is side = \"server\" with api = \"{SERVER_API}\", and only it"
|
||||
));
|
||||
}
|
||||
if let Some(c) = m
|
||||
.capabilities
|
||||
.iter()
|
||||
.find(|c| c.on_server() != server && **c != Capability::Storage)
|
||||
{
|
||||
let side = if server { "client" } else { "server" };
|
||||
return Err(format!("{MANIFEST}: capability {c} is for {side} mods"));
|
||||
}
|
||||
if let Some(c) = m
|
||||
.capabilities
|
||||
.iter()
|
||||
@@ -435,12 +462,32 @@ capabilities = ["game-read", "hud"]
|
||||
fn refuses_bad_ids_and_apis() {
|
||||
let bad_id = EXAMPLE.replace("coords_hud", "Coords HUD");
|
||||
assert!(Manifest::parse(&bad_id).unwrap_err().contains("is not"));
|
||||
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.3\"");
|
||||
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.3"));
|
||||
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.4\"");
|
||||
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.4"));
|
||||
let unknown = format!("{EXAMPLE}network = true\n");
|
||||
assert!(Manifest::parse(&unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_mods_are_api_0_3_with_server_capabilities() {
|
||||
let server = |api: &str, side: &str, caps: &str| {
|
||||
Manifest::parse(&format!(
|
||||
"id = \"w\"\nname = \"W\"\nversion = \"1\"\napi = \"{api}\"\nside = \"{side}\"\ncapabilities = [{caps}]\n"
|
||||
))
|
||||
};
|
||||
let m = server("0.3", "server", "\"players\", \"storage\"").unwrap();
|
||||
let imports = ["horizon:mods/[email protected]", "horizon:mods/[email protected]"];
|
||||
assert_eq!(m.check_imports(imports), Ok(()));
|
||||
for (api, side) in [("0.3", "client"), ("0.3", "both"), ("0.1", "server")] {
|
||||
let e = server(api, side, "").unwrap_err();
|
||||
assert!(e.contains("a server mod is side"), "{e}");
|
||||
}
|
||||
let e = server("0.3", "server", "\"hud\"").unwrap_err();
|
||||
assert!(e.contains("capability hud is for client mods"), "{e}");
|
||||
let e = server("0.1", "client", "\"players\"").unwrap_err();
|
||||
assert!(e.contains("capability players is for server mods"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_extra_namespaces() {
|
||||
let content = "id = \"t_and_t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\ncapabilities = [\"registries\"]\n";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Loading and calling mods with wasmtime's component model.
|
||||
use crate::manifest::{Manifest, Package, Side};
|
||||
use server_bindings::{
|
||||
HorizonServerMod, exports::horizon::mods::server_events::Registration as ServerRegistration,
|
||||
horizon::mods::players,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -16,6 +20,17 @@ mod bindings {
|
||||
world: "horizon-mod",
|
||||
});
|
||||
}
|
||||
/// The server mods' world: `log` and `storage` are the client world's.
|
||||
mod server_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../horizon-mod-sdk/wit",
|
||||
world: "horizon-server-mod",
|
||||
with: {
|
||||
"horizon:mods/log": super::bindings::horizon::mods::log,
|
||||
"horizon:mods/storage": super::bindings::horizon::mods::storage,
|
||||
},
|
||||
});
|
||||
}
|
||||
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
|
||||
/// world, so mods built before it (no `ui-events` export) still instantiate
|
||||
/// as `horizon-mod` and simply hear nothing of their widgets.
|
||||
@@ -34,6 +49,12 @@ use bindings::{
|
||||
exports::horizon::mods::events::Registration,
|
||||
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
|
||||
};
|
||||
pub use server_bindings::{
|
||||
exports::horizon::mods::server_events::BlockPos,
|
||||
horizon::mods::players::{GameMode, Player as ServerPlayer},
|
||||
};
|
||||
type ClientEvents = bindings::exports::horizon::mods::events::Guest;
|
||||
type ServerEvents = server_bindings::exports::horizon::mods::server_events::Guest;
|
||||
/// The widget model a `ui` screen or settings page shows (WIT `interface
|
||||
/// ui`), for the game to draw with its menus' own widgets.
|
||||
pub use ui_bindings::horizon::mods::ui::{
|
||||
@@ -52,8 +73,9 @@ use render_bindings::horizon::mods::input;
|
||||
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
|
||||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||||
pub use render_bindings::horizon::mods::render::{
|
||||
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Point as RenderPoint, QuadShape,
|
||||
Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
|
||||
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Minimap as ModMinimap,
|
||||
Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, TextShape,
|
||||
Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
|
||||
@@ -100,6 +122,26 @@ pub struct Snapshot {
|
||||
/// The HUD font's advance per character from U+0000
|
||||
/// (`render.text-width`); empty: 6 each.
|
||||
pub font_widths: Arc<[f32]>,
|
||||
/// The players on the server, for server mods.
|
||||
pub players: Vec<ServerPlayer>,
|
||||
}
|
||||
|
||||
/// Which mods a game starts: the window's game the client mods, the game
|
||||
/// simulating the world the server mods, single player in the frame both.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Runs {
|
||||
Client,
|
||||
Server,
|
||||
Both,
|
||||
}
|
||||
impl Runs {
|
||||
fn starts(self, side: Side) -> bool {
|
||||
match self {
|
||||
Runs::Both => true,
|
||||
Runs::Client => side != Side::Server,
|
||||
Runs::Server => side == Side::Server,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What mods asked the game to show since the last [`Mods::drain`].
|
||||
@@ -114,6 +156,20 @@ pub enum Output {
|
||||
Command { line: String },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// `players.tell`: a line after the mod's name, to the player with this id.
|
||||
Tell {
|
||||
name: String,
|
||||
player: String,
|
||||
text: String,
|
||||
},
|
||||
/// `players.broadcast`: a line after the mod's name, to every player.
|
||||
Broadcast { name: String, text: String },
|
||||
/// A server mod command's feedback, to the player who typed it.
|
||||
Reply {
|
||||
player: String,
|
||||
text: String,
|
||||
error: bool,
|
||||
},
|
||||
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
|
||||
/// [`Mods::screen`].
|
||||
OpenScreen { owner: usize },
|
||||
@@ -135,6 +191,9 @@ pub enum Status {
|
||||
/// Installed and turned off by the player.
|
||||
Off,
|
||||
Running,
|
||||
/// Turned on, and run by another game: a server mod in the window while
|
||||
/// the world runs on its server thread.
|
||||
Elsewhere,
|
||||
/// Could not load or start, or stopped while running.
|
||||
Failed(String),
|
||||
}
|
||||
@@ -164,10 +223,36 @@ impl Entry {
|
||||
|
||||
struct Running {
|
||||
store: Store<State>,
|
||||
exports: HorizonMod,
|
||||
/// The mod's `ui-events` export; none for a mod built before `ui`.
|
||||
/// The mod's `ui-events` export; none for a mod built before `ui`, or a
|
||||
/// server mod.
|
||||
ui: Option<UiHost>,
|
||||
hooks: Registration,
|
||||
hooks: Hooks,
|
||||
}
|
||||
|
||||
/// A running mod's exports and the hooks its `init` asked for.
|
||||
enum Hooks {
|
||||
Client(HorizonMod, Registration),
|
||||
Server(HorizonServerMod, ServerRegistration),
|
||||
}
|
||||
impl Hooks {
|
||||
fn client(&self) -> Option<&Registration> {
|
||||
match self {
|
||||
Hooks::Client(_, r) => Some(r),
|
||||
Hooks::Server(..) => None,
|
||||
}
|
||||
}
|
||||
fn server(&self) -> Option<&ServerRegistration> {
|
||||
match self {
|
||||
Hooks::Server(_, r) => Some(r),
|
||||
Hooks::Client(..) => None,
|
||||
}
|
||||
}
|
||||
fn commands(&self) -> &[String] {
|
||||
match self {
|
||||
Hooks::Client(_, r) => &r.commands,
|
||||
Hooks::Server(_, r) => &r.commands,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What capability `ui` pushed since the hooks last ran.
|
||||
@@ -196,6 +281,8 @@ struct State {
|
||||
corner: Option<CornerPanel>,
|
||||
/// Capability `render`'s zoom, when the mod zooms.
|
||||
zoom: Option<ModZoom>,
|
||||
/// Capability `render`'s minimap, when the mod shows one.
|
||||
minimap: Option<ModMinimap>,
|
||||
input: InputState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
data_dir: PathBuf,
|
||||
@@ -218,13 +305,23 @@ pub struct Mods {
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
/// Reads every mod at `paths` and starts those `enabled` says are on.
|
||||
/// A command that `reserved` (vanilla's commands) or an earlier mod
|
||||
/// already uses is refused.
|
||||
/// Reads every mod at `paths` and starts those `enabled` says are on,
|
||||
/// client and server mods alike. A command that `reserved` (vanilla's
|
||||
/// commands) or an earlier mod already uses is refused.
|
||||
pub fn load(
|
||||
paths: &[PathBuf],
|
||||
enabled: impl Fn(&str) -> bool,
|
||||
reserved: impl Fn(&str) -> bool,
|
||||
) -> Mods {
|
||||
Mods::load_for(Runs::Both, paths, enabled, reserved)
|
||||
}
|
||||
/// [`Mods::load`], starting only the mods `runs` says this game runs; the
|
||||
/// others that are on are [`Status::Elsewhere`].
|
||||
pub fn load_for(
|
||||
runs: Runs,
|
||||
paths: &[PathBuf],
|
||||
enabled: impl Fn(&str) -> bool,
|
||||
reserved: impl Fn(&str) -> bool,
|
||||
) -> Mods {
|
||||
let mut mods = Mods::default();
|
||||
for path in paths {
|
||||
@@ -242,7 +339,9 @@ impl Mods {
|
||||
Ok(package) => {
|
||||
let on = enabled(&package.manifest.id);
|
||||
entry.manifest = Some(package.manifest.clone());
|
||||
if on {
|
||||
if on && !runs.starts(package.manifest.side) {
|
||||
entry.status = Status::Elsewhere;
|
||||
} else if on {
|
||||
// Content is read before the world loads; a mod whose
|
||||
// content is refused stays off, with the reason.
|
||||
let content = crate::Content::read(path, &package.manifest);
|
||||
@@ -273,6 +372,8 @@ impl Mods {
|
||||
let mut linker = Linker::new(&engine);
|
||||
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
players::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
RenderHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
@@ -296,9 +397,6 @@ impl Mods {
|
||||
let Some(wasm) = &package.wasm else {
|
||||
return Ok(None);
|
||||
};
|
||||
if m.side == Side::Server {
|
||||
return Err("server mods do not run yet".into());
|
||||
}
|
||||
// Two running mods with the same id would share one `data_dir`
|
||||
// (capability `storage`): the first loaded keeps the id.
|
||||
if self
|
||||
@@ -308,11 +406,13 @@ impl Mods {
|
||||
{
|
||||
return Err(format!("another running mod already uses id {}", m.id));
|
||||
}
|
||||
let server = m.side == Side::Server;
|
||||
let taken: Vec<String> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.flat_map(|r| r.hooks.commands.iter().cloned())
|
||||
.filter(|r| r.hooks.server().is_some() == server)
|
||||
.flat_map(|r| r.hooks.commands().iter().cloned())
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let (engine, linker) = self.engine()?;
|
||||
@@ -324,6 +424,12 @@ impl Mods {
|
||||
.map(|(name, _)| name.to_string())
|
||||
.collect();
|
||||
m.check_imports(imports.iter().map(String::as_str))?;
|
||||
// A function this Horizon doesn't have: say so, not wasmtime's linking error.
|
||||
let functions = crate::api_level::imports(wasm)?;
|
||||
let (level, function) = crate::api_level::needed(functions.iter().map(String::as_str));
|
||||
if let Some(why) = crate::api_level::too_new(level, function) {
|
||||
return Err(why);
|
||||
}
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
@@ -339,6 +445,7 @@ impl Mods {
|
||||
shapes: Vec::new(),
|
||||
corner: None,
|
||||
zoom: None,
|
||||
minimap: None,
|
||||
input: InputState::default(),
|
||||
data_dir,
|
||||
storage_budget: STORAGE_WRITE_BUDGET,
|
||||
@@ -353,15 +460,29 @@ impl Mods {
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|e| reason(&e))?;
|
||||
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
// `ui-events` is a mod built since `ui`'s; older mods have no export
|
||||
// and stay callable through `events` alone.
|
||||
let ui = UiHost::new(&mut store, &instance).ok();
|
||||
let mut hooks = exports
|
||||
.horizon_mods_events()
|
||||
.call_init(&mut store)
|
||||
.map_err(|e| reason(&e))??;
|
||||
hooks.commands.retain(|c| {
|
||||
let (mut hooks, ui) = if server {
|
||||
let exports = HorizonServerMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
let r = exports
|
||||
.horizon_mods_server_events()
|
||||
.call_init(&mut store)
|
||||
.map_err(|e| reason(&e))??;
|
||||
(Hooks::Server(exports, r), None)
|
||||
} else {
|
||||
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
// `ui-events` is a mod built since `ui`'s; older mods have no export
|
||||
// and stay callable through `events` alone.
|
||||
let ui = UiHost::new(&mut store, &instance).ok();
|
||||
let r = exports
|
||||
.horizon_mods_events()
|
||||
.call_init(&mut store)
|
||||
.map_err(|e| reason(&e))??;
|
||||
(Hooks::Client(exports, r), ui)
|
||||
};
|
||||
let commands = match &mut hooks {
|
||||
Hooks::Client(_, r) => &mut r.commands,
|
||||
Hooks::Server(_, r) => &mut r.commands,
|
||||
};
|
||||
commands.retain(|c| {
|
||||
let free = !reserved(c) && !taken.contains(c);
|
||||
if !free {
|
||||
log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id);
|
||||
@@ -369,18 +490,19 @@ impl Mods {
|
||||
free
|
||||
});
|
||||
self.out.append(&mut store.data_mut().out);
|
||||
Ok(Some(Running {
|
||||
store,
|
||||
exports,
|
||||
ui,
|
||||
hooks,
|
||||
}))
|
||||
Ok(Some(Running { store, ui, hooks }))
|
||||
}
|
||||
|
||||
/// True when at least one mod runs: the game skips its mod work otherwise.
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.entries.iter().any(|e| e.run.is_some())
|
||||
}
|
||||
/// True when a server mod runs: the server skips its mod work otherwise.
|
||||
pub fn runs_server(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.server().is_some()))
|
||||
}
|
||||
pub fn entries(&self) -> &[Entry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -393,7 +515,7 @@ impl Mods {
|
||||
fn each(
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&Registration) -> bool,
|
||||
wants: impl Fn(&Hooks) -> bool,
|
||||
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
|
||||
) {
|
||||
for i in 0..self.entries.len() {
|
||||
@@ -406,6 +528,38 @@ impl Mods {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// [`Mods::each`] over the client mods.
|
||||
fn each_client(
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&Registration) -> bool,
|
||||
mut f: impl FnMut(&ClientEvents, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
) {
|
||||
self.each(
|
||||
fuel,
|
||||
|h| h.client().is_some_and(&wants),
|
||||
|run| match &run.hooks {
|
||||
Hooks::Client(m, _) => f(m.horizon_mods_events(), &mut run.store),
|
||||
Hooks::Server(..) => Ok(()),
|
||||
},
|
||||
);
|
||||
}
|
||||
/// [`Mods::each`] over the server mods.
|
||||
fn each_server(
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&ServerRegistration) -> bool,
|
||||
mut f: impl FnMut(&ServerEvents, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
) {
|
||||
self.each(
|
||||
fuel,
|
||||
|h| h.server().is_some_and(&wants),
|
||||
|run| match &run.hooks {
|
||||
Hooks::Server(m, _) => f(m.horizon_mods_server_events(), &mut run.store),
|
||||
Hooks::Client(..) => Ok(()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One call into mod `i`; a trap or an overrun turns the mod off.
|
||||
fn call<R>(
|
||||
@@ -453,62 +607,39 @@ impl Mods {
|
||||
|
||||
/// A game tick.
|
||||
pub fn tick(&mut self, tick: u64) {
|
||||
self.each(
|
||||
FUEL_TICK,
|
||||
|h| h.tick,
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_tick(&mut run.store, tick)
|
||||
},
|
||||
);
|
||||
self.each_client(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
|
||||
}
|
||||
/// A rendered frame, `seconds` after the last.
|
||||
pub fn frame(&mut self, seconds: f32) {
|
||||
self.each(
|
||||
FUEL_FRAME,
|
||||
|h| h.frame,
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_frame(&mut run.store, seconds)
|
||||
},
|
||||
);
|
||||
self.each_client(FUEL_FRAME, |h| h.frame, |m, s| m.call_frame(s, seconds));
|
||||
}
|
||||
/// A chat line the player received, as plain text.
|
||||
pub fn chat(&mut self, text: &str) {
|
||||
self.each(
|
||||
FUEL_EVENT,
|
||||
|h| h.chat,
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_chat(&mut run.store, text)
|
||||
},
|
||||
);
|
||||
self.each_client(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, text));
|
||||
}
|
||||
/// Whether a running mod owns `/name`.
|
||||
/// Whether a running client mod owns `/name`.
|
||||
pub fn has_command(&self, name: &str) -> bool {
|
||||
self.command_owner(name).is_some()
|
||||
self.command_owner(false, name).is_some()
|
||||
}
|
||||
fn command_owner(&self, name: &str) -> Option<usize> {
|
||||
fn command_owner(&self, server: bool, name: &str) -> Option<usize> {
|
||||
self.entries.iter().position(|e| {
|
||||
e.run
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.hooks.commands.iter().any(|c| c == name))
|
||||
e.run.as_ref().is_some_and(|r| {
|
||||
r.hooks.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name)
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Runs `line` (without its slash) if a mod owns its command; false
|
||||
/// when none does.
|
||||
/// Runs `line` (without its slash) if a client mod owns its command;
|
||||
/// false when none does.
|
||||
pub fn command(&mut self, line: &str) -> bool {
|
||||
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
|
||||
let Some(i) = self.command_owner(name) else {
|
||||
let Some(i) = self.command_owner(false, name) else {
|
||||
return false;
|
||||
};
|
||||
let result = self.call(i, FUEL_EVENT, |run| {
|
||||
run.exports
|
||||
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
|
||||
Hooks::Client(m, _) => m
|
||||
.horizon_mods_events()
|
||||
.call_command(&mut run.store, name, args)
|
||||
.call_command(&mut run.store, name, args),
|
||||
Hooks::Server(..) => Ok(Ok(None)),
|
||||
});
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
|
||||
@@ -517,14 +648,89 @@ impl Mods {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The commands running mods own, for suggestions.
|
||||
/// The commands running client mods own, for suggestions.
|
||||
pub fn commands(&self) -> impl Iterator<Item = &str> {
|
||||
self.owned_commands(false)
|
||||
}
|
||||
fn owned_commands(&self, server: bool) -> impl Iterator<Item = &str> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.flat_map(|r| r.hooks.commands.iter().map(String::as_str))
|
||||
.filter(move |r| r.hooks.server().is_some() == server)
|
||||
.flat_map(|r| r.hooks.commands().iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// A server tick.
|
||||
pub fn server_tick(&mut self, tick: u64) {
|
||||
self.each_server(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
|
||||
}
|
||||
/// `player` joined the server (they are in the snapshot's players).
|
||||
pub fn player_joined(&mut self, player: &ServerPlayer) {
|
||||
self.each_server(
|
||||
FUEL_EVENT,
|
||||
|h| h.joins,
|
||||
|m, s| m.call_player_joined(s, player),
|
||||
);
|
||||
}
|
||||
/// `player` left the server (they are no longer in the snapshot's players).
|
||||
pub fn player_left(&mut self, player: &ServerPlayer) {
|
||||
self.each_server(
|
||||
FUEL_EVENT,
|
||||
|h| h.joins,
|
||||
|m, s| m.call_player_left(s, player),
|
||||
);
|
||||
}
|
||||
/// A chat message `player` sent, as plain text.
|
||||
pub fn player_chat(&mut self, player: &ServerPlayer, text: &str) {
|
||||
self.each_server(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, player, text));
|
||||
}
|
||||
/// `player` broke `state` at `pos`.
|
||||
pub fn block_broken(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
|
||||
self.each_server(
|
||||
FUEL_EVENT,
|
||||
|h| h.blocks,
|
||||
|m, s| m.call_block_broken(s, player, pos, state),
|
||||
);
|
||||
}
|
||||
/// `player` placed `state` at `pos`.
|
||||
pub fn block_placed(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
|
||||
self.each_server(
|
||||
FUEL_EVENT,
|
||||
|h| h.blocks,
|
||||
|m, s| m.call_block_placed(s, player, pos, state),
|
||||
);
|
||||
}
|
||||
/// The commands running server mods own, for the server's command tree.
|
||||
pub fn server_commands(&self) -> impl Iterator<Item = &str> {
|
||||
self.owned_commands(true)
|
||||
}
|
||||
/// Runs `line` (without its slash), typed by `player`, if a server mod
|
||||
/// owns its command; its feedback is an [`Output::Reply`]. False when
|
||||
/// none does.
|
||||
pub fn server_command(&mut self, player: &ServerPlayer, line: &str) -> bool {
|
||||
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
|
||||
let Some(i) = self.command_owner(true, name) else {
|
||||
return false;
|
||||
};
|
||||
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
|
||||
Hooks::Server(m, _) => {
|
||||
m.horizon_mods_server_events()
|
||||
.call_command(&mut run.store, player, name, args)
|
||||
}
|
||||
Hooks::Client(..) => Ok(Ok(None)),
|
||||
});
|
||||
let reply = |text, error| Output::Reply {
|
||||
player: player.id.clone(),
|
||||
text,
|
||||
error,
|
||||
};
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(reply(text, false)),
|
||||
Some(Err(text)) => self.out.push(reply(text, true)),
|
||||
Some(Ok(None)) | None => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The running mods' HUD panels.
|
||||
pub fn panels(&self) -> impl Iterator<Item = &Panel> {
|
||||
self.entries
|
||||
@@ -540,9 +746,11 @@ impl Mods {
|
||||
/// Some running mod has a `frame` hook: the game refreshes the snapshot
|
||||
/// every frame for it.
|
||||
pub fn wants_frames(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.frame))
|
||||
self.entries.iter().any(|e| {
|
||||
e.run
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.hooks.client().is_some_and(|h| h.frame))
|
||||
})
|
||||
}
|
||||
/// Some running mod claimed items (capability `input`).
|
||||
pub fn claims_any(&self) -> bool {
|
||||
@@ -684,6 +892,12 @@ impl Mods {
|
||||
.filter_map(|e| e.run.as_ref()?.store.data().zoom)
|
||||
.max_by(|a, b| a.factor.total_cmp(&b.factor))
|
||||
}
|
||||
/// The minimap the first running mod shows (capability `render`).
|
||||
pub fn minimap(&self) -> Option<ModMinimap> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().minimap)
|
||||
}
|
||||
fn inputs(&self) -> impl Iterator<Item = &InputState> {
|
||||
self.entries
|
||||
.iter()
|
||||
@@ -771,7 +985,7 @@ impl Mods {
|
||||
for i in 0..self.entries.len() {
|
||||
let entry = &self.entries[i];
|
||||
let now = modified(&entry.path);
|
||||
let was_on = entry.status != Status::Off;
|
||||
let was_on = !matches!(entry.status, Status::Off | Status::Elsewhere);
|
||||
if now == entry.modified || !was_on {
|
||||
continue;
|
||||
}
|
||||
@@ -880,20 +1094,51 @@ impl game::Host for State {
|
||||
self.snapshot.clock
|
||||
}
|
||||
}
|
||||
impl chat::Host for State {
|
||||
fn show(&mut self, text: String) {
|
||||
impl State {
|
||||
/// Queues a chat line unless the call already sent [`CHAT_PER_CALL`].
|
||||
fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool {
|
||||
let shown = self
|
||||
.out
|
||||
.iter()
|
||||
.filter(|o| matches!(o, Output::Chat { .. }))
|
||||
.filter(|o| {
|
||||
matches!(
|
||||
o,
|
||||
Output::Chat { .. } | Output::Tell { .. } | Output::Broadcast { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if shown < CHAT_PER_CALL {
|
||||
let name = self.name.clone();
|
||||
self.out.push(Output::Chat {
|
||||
name,
|
||||
text: clip(text),
|
||||
});
|
||||
self.out.push(line(name));
|
||||
}
|
||||
shown < CHAT_PER_CALL
|
||||
}
|
||||
}
|
||||
impl chat::Host for State {
|
||||
fn show(&mut self, text: String) {
|
||||
self.say(|name| Output::Chat {
|
||||
name,
|
||||
text: clip(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
impl players::Host for State {
|
||||
fn online(&mut self) -> Vec<ServerPlayer> {
|
||||
self.snapshot.players.clone()
|
||||
}
|
||||
fn tell(&mut self, player: String, text: String) -> bool {
|
||||
self.snapshot.players.iter().any(|p| p.id == player)
|
||||
&& self.say(|name| Output::Tell {
|
||||
name,
|
||||
player,
|
||||
text: clip(text),
|
||||
})
|
||||
}
|
||||
fn broadcast(&mut self, text: String) {
|
||||
self.say(|name| Output::Broadcast {
|
||||
name,
|
||||
text: clip(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A key a mod registered (capability `input`).
|
||||
@@ -1013,6 +1258,9 @@ const SHAKE_SECONDS: f32 = 10.0;
|
||||
/// or more, and turning never stops.
|
||||
const ZOOM_MAX: f32 = 10.0;
|
||||
const TURN_MIN: f32 = 0.05;
|
||||
/// `set-minimap`'s side in GUI pixels and its blocks per map pixel.
|
||||
const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
|
||||
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
|
||||
|
||||
/// A mod's corner panel: shapes in a `width` × `height` box the game
|
||||
/// places in `corner`, stacked with the other overlays.
|
||||
@@ -1094,6 +1342,14 @@ impl render::Host for State {
|
||||
},
|
||||
});
|
||||
}
|
||||
fn set_minimap(&mut self, minimap: Option<ModMinimap>) {
|
||||
let finite = |v: f32, lo: f32, hi: f32| if v.is_finite() { v.clamp(lo, hi) } else { lo };
|
||||
self.minimap = minimap.map(|m| ModMinimap {
|
||||
size: finite(m.size, MINIMAP_SIZE.0, MINIMAP_SIZE.1),
|
||||
blocks_per_pixel: finite(m.blocks_per_pixel, MINIMAP_SCALE.0, MINIMAP_SCALE.1),
|
||||
..m
|
||||
});
|
||||
}
|
||||
fn frame_stats(&mut self) -> FrameRate {
|
||||
let (fps, min, low) = self.snapshot.frame;
|
||||
FrameRate { fps, min, low }
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
//!
|
||||
//! [`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. [`Script`] is a
|
||||
//! 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::{Clock, Corner, Mods, Output, Panel, Player, Snapshot, Status};
|
||||
use crate::{
|
||||
BlockPos, Clock, Corner, GameMode, Mods, Output, Panel, Player, ServerPlayer, Snapshot, Status,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -20,6 +24,10 @@ pub struct TestGame {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -60,6 +68,8 @@ impl TestGame {
|
||||
game_time: 0,
|
||||
day_time: 0,
|
||||
}),
|
||||
players: Vec::new(),
|
||||
names: Vec::new(),
|
||||
tick: 0,
|
||||
};
|
||||
copy_mod(hmod, ©)?;
|
||||
@@ -87,11 +97,16 @@ impl TestGame {
|
||||
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) {
|
||||
@@ -103,6 +118,7 @@ impl TestGame {
|
||||
}
|
||||
self.snapshot();
|
||||
self.mods.tick(self.tick);
|
||||
self.mods.server_tick(self.tick);
|
||||
}
|
||||
}
|
||||
/// `n` frames, 1/60 s apart.
|
||||
@@ -124,13 +140,76 @@ impl TestGame {
|
||||
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> {
|
||||
self.mods.commands().map(str::to_owned).collect()
|
||||
let server = self.mods.server_commands();
|
||||
self.mods
|
||||
.commands()
|
||||
.chain(server)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
pub fn panel(&self) -> Option<&Panel> {
|
||||
self.mods.panels().next()
|
||||
@@ -166,7 +245,7 @@ fn copy_mod(from: &Path, to: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A test in TOML: where the player starts, then steps run in order.
|
||||
/// 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 {
|
||||
@@ -178,9 +257,11 @@ pub struct Script {
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
/// Fields of the player to change; the others keep their values.
|
||||
/// 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)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct PlayerSet {
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
@@ -190,6 +271,37 @@ pub struct PlayerSet {
|
||||
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)]
|
||||
@@ -200,8 +312,11 @@ pub struct ClockSet {
|
||||
}
|
||||
|
||||
/// One step. Its parts happen in this order: `reload`, `title-screen`,
|
||||
/// `player`, `clock`, `ticks`, `frames`, `chat`, `command`; then `expect` is
|
||||
/// checked against what the mod showed during the step.
|
||||
/// `join`, `player`, `clock`, `ticks`, `frames`, `chat`, `command`, `break`,
|
||||
/// `place`, `leave`; then `expect` is checked against what the mod showed
|
||||
/// 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 {
|
||||
@@ -218,6 +333,14 @@ pub struct Step {
|
||||
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>,
|
||||
#[serde(default)]
|
||||
expect: Expect,
|
||||
}
|
||||
@@ -233,6 +356,10 @@ pub struct Expect {
|
||||
/// 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>,
|
||||
@@ -276,6 +403,17 @@ impl PlayerSet {
|
||||
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 {
|
||||
@@ -304,6 +442,17 @@ impl Step {
|
||||
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());
|
||||
}
|
||||
@@ -318,8 +467,35 @@ impl Step {
|
||||
game.player = None;
|
||||
game.clock = None;
|
||||
}
|
||||
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 {
|
||||
set.apply(game.player.get_or_insert_with(default_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 {
|
||||
@@ -329,10 +505,32 @@ impl Step {
|
||||
}
|
||||
game.tick(self.ticks);
|
||||
game.frames(self.frames);
|
||||
let who = actor.map(|i| game.players[i].clone());
|
||||
if let Some(line) = &self.chat {
|
||||
game.chat(line);
|
||||
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"));
|
||||
}
|
||||
let owned = self.command.as_ref().map(|line| game.command(line));
|
||||
let out = game.drain();
|
||||
let stopped = out.iter().find_map(|o| match o {
|
||||
Output::Stopped { reason, .. } => Some(reason.as_str()),
|
||||
@@ -372,10 +570,38 @@ impl Step {
|
||||
}
|
||||
let feedback = |error: bool| {
|
||||
out.iter().rev().find_map(|o| match o {
|
||||
Output::Feedback { text, error: e } if *e == error => Some(text.clone()),
|
||||
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))?;
|
||||
}
|
||||
|
||||
@@ -302,3 +302,38 @@ fn every_capability_with_an_interface_is_in_the_contract() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -29,6 +29,7 @@
|
||||
"hud",
|
||||
"chat"
|
||||
],
|
||||
"api_level": 0,
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "coords_hud-0.1.0.hmod",
|
||||
@@ -75,6 +76,34 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "glow",
|
||||
"kind": "shader",
|
||||
"name": "Soft Glow",
|
||||
"summary": "A post-processing pass over the world.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon-mods",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"published": "2026-10-26",
|
||||
"api": "0.1",
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "glow-1.0.0.hshader",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/glow-1.0.0/glow-1.0.0.hshader",
|
||||
"size": 689,
|
||||
"sha256": "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skyblock_start",
|
||||
"kind": "world",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! examples and a guest
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
Clock, Corner, HeldItem, Mods, Output, Package, Player, Shape, Snapshot, Status,
|
||||
BlockPos, Clock, Corner, GameMode, HeldItem, Mods, Output, Package, Player, Runs, ServerPlayer,
|
||||
Shape, Snapshot, Status,
|
||||
manifest::componentize,
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
@@ -44,6 +45,8 @@ fn mods_dir() -> &'static Path {
|
||||
"-p",
|
||||
"predator-missile",
|
||||
"-p",
|
||||
"welcome",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-ui-guest",
|
||||
@@ -66,6 +69,7 @@ fn mods_dir() -> &'static Path {
|
||||
let mods = [
|
||||
("coords_hud", example("coords-hud")),
|
||||
("predator_missile", example("predator-missile")),
|
||||
("welcome", example("welcome")),
|
||||
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
|
||||
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
|
||||
];
|
||||
@@ -193,6 +197,65 @@ fn undeclared_capabilities_are_refused() {
|
||||
assert_eq!(mods.entries()[0].status, refused);
|
||||
}
|
||||
|
||||
/// A mod calling a function this Horizon doesn't have is refused with
|
||||
/// "update Horizon", not a linking error; the test mods' levels are read
|
||||
/// from the functions they import.
|
||||
#[test]
|
||||
fn a_mod_for_a_newer_horizon_is_refused() {
|
||||
use horizon_mods::api_level::{LEVEL, imports, needed};
|
||||
use wit_parser::{ManglingAndAbi, Resolve};
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit"))
|
||||
.unwrap()
|
||||
.replace(
|
||||
" set-minimap: func(minimap: option<minimap>);",
|
||||
" set-minimap: func(minimap: option<minimap>);\n set-sky: func(on: bool);",
|
||||
);
|
||||
let mut resolve = Resolve::default();
|
||||
let package = resolve.push_str("horizon.wit", &wit).unwrap();
|
||||
let world = resolve
|
||||
.select_world(&[package], Some("horizon-ui-mod"))
|
||||
.unwrap();
|
||||
let mut module = wit_component::dummy_module(&resolve, world, ManglingAndAbi::Standard32);
|
||||
wit_component::embed_component_metadata(
|
||||
&mut module,
|
||||
&resolve,
|
||||
world,
|
||||
wit_component::StringEncoding::UTF8,
|
||||
)
|
||||
.unwrap();
|
||||
let wasm = wit_component::ComponentEncoder::default()
|
||||
.module(&module)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
let functions = imports(&wasm).unwrap();
|
||||
assert!(functions.iter().any(|f| f == "render.set-sky"));
|
||||
let dir = mods_dir().join("too_new");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = r#"
|
||||
id = "too_new"
|
||||
name = "Too New"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"]
|
||||
experimental = ["ui", "render", "input"]
|
||||
"#;
|
||||
std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap();
|
||||
std::fs::write(dir.join("mod.wasm"), wasm).unwrap();
|
||||
let mods = Mods::load(&[dir], |_| true, |_| false);
|
||||
let refused = "made for a newer Horizon (it uses render.set-sky): update Horizon to run it";
|
||||
assert_eq!(mods.entries()[0].status, Status::Failed(refused.into()));
|
||||
|
||||
let level = |file: &str| {
|
||||
let wasm = Package::read(&mods_dir().join(file)).unwrap().wasm.unwrap();
|
||||
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
|
||||
};
|
||||
assert_eq!(level("coords_hud.hmod"), 0);
|
||||
// The UI guest shows a minimap (`render.set-minimap`).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_commands_win() {
|
||||
let mods = Mods::load(
|
||||
@@ -633,6 +696,32 @@ fn render_zoom_is_clamped_and_the_strongest_counts() {
|
||||
assert_eq!(zoom(&mut mods, "x 0 1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_minimap_is_clamped_and_hidden() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
let map = |mods: &mut Mods, args: &str| {
|
||||
assert!(mods.command(&format!("minimap {args}")));
|
||||
mods.minimap()
|
||||
.map(|m| (m.corner, m.size, m.blocks_per_pixel, m.rotate, m.round))
|
||||
};
|
||||
assert!(mods.minimap().is_none());
|
||||
let corner = horizon_mods::RenderCorner::BottomLeft;
|
||||
assert_eq!(
|
||||
map(&mut mods, "96 2 1 0"),
|
||||
Some((corner, 96.0, 2.0, true, false))
|
||||
);
|
||||
// The side and the scale are clamped; NaN takes the smallest.
|
||||
assert_eq!(
|
||||
map(&mut mods, "999 0.1 0 1"),
|
||||
Some((corner, 256.0, 0.5, false, true))
|
||||
);
|
||||
assert_eq!(
|
||||
map(&mut mods, "x 9 0 0"),
|
||||
Some((corner, 16.0, 4.0, false, false))
|
||||
);
|
||||
assert_eq!(map(&mut mods, "off"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_panels_text_width_and_readings() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
@@ -705,22 +794,23 @@ capabilities = ["registries"]
|
||||
assert!(!mods.is_running());
|
||||
}
|
||||
|
||||
/// `cargo horizon-mod test` on the example: its own `tests/*.toml` pass.
|
||||
/// `cargo horizon-mod test` on the examples: their own `tests/*.toml` pass.
|
||||
#[test]
|
||||
fn the_examples_test_scripts_pass() {
|
||||
let tests =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../horizon-mod-sdk/examples/coords-hud/tests");
|
||||
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../horizon-mod-sdk/examples");
|
||||
let mut ran = 0;
|
||||
for entry in std::fs::read_dir(tests).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let script = Script::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let mut game = TestGame::load(&mods_dir().join("coords_hud.hmod")).unwrap();
|
||||
script
|
||||
.run(&mut game)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
ran += 1;
|
||||
for (example, hmod) in [("coords-hud", "coords_hud"), ("welcome", "welcome")] {
|
||||
for entry in std::fs::read_dir(examples.join(example).join("tests")).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let script = Script::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let mut game = TestGame::load(&mods_dir().join(format!("{hmod}.hmod"))).unwrap();
|
||||
script
|
||||
.run(&mut game)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
ran += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(ran, 2);
|
||||
assert_eq!(ran, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -744,6 +834,13 @@ fn a_test_script_says_which_step_failed_and_why() {
|
||||
Script::parse("[[step]]\ntick = 1").is_err(),
|
||||
"a typo is an error"
|
||||
);
|
||||
|
||||
let mut game = TestGame::load(&mods_dir().join("welcome.hmod")).unwrap();
|
||||
let script = "[[step]]\njoin = \"Alex\"\n\n[[step]]\nas = \"Steve\"\ncommand = \"mined\"";
|
||||
assert_eq!(
|
||||
Script::parse(script).unwrap().run(&mut game).unwrap_err(),
|
||||
"step 2 (/mined): Steve is not on the server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -781,3 +878,74 @@ fn a_test_script_reloads_and_expects_a_stop() {
|
||||
"the scratch copy and its storage are removed"
|
||||
);
|
||||
}
|
||||
|
||||
fn alex() -> ServerPlayer {
|
||||
ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Alex".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:the_nether".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_mod_runs_only_where_the_world_does() {
|
||||
// A fresh folder: what the mod stored in an earlier run is beside it.
|
||||
let dir = mods_dir().join("server");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("welcome.hmod");
|
||||
std::fs::copy(mods_dir().join("welcome.hmod"), &path).unwrap();
|
||||
let paths = std::slice::from_ref(&path);
|
||||
let window = Mods::load_for(Runs::Client, paths, |_| true, |_| false);
|
||||
assert_eq!(window.entries()[0].status, Status::Elsewhere);
|
||||
assert!(!window.is_running());
|
||||
|
||||
let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give");
|
||||
assert!(mods.runs_server());
|
||||
assert_eq!(
|
||||
mods.server_commands().collect::<Vec<_>>(),
|
||||
["online", "mined"]
|
||||
);
|
||||
assert_eq!(mods.commands().count(), 0, "not the window's chat commands");
|
||||
let alex = alex();
|
||||
mods.set_snapshot(Snapshot {
|
||||
players: vec![alex.clone()],
|
||||
..Snapshot::default()
|
||||
});
|
||||
mods.player_joined(&alex);
|
||||
let greeted = [
|
||||
Output::Tell {
|
||||
name: "Welcome".into(),
|
||||
player: alex.id.clone(),
|
||||
text: "Welcome, Alex! 0 other player(s) online.".into(),
|
||||
},
|
||||
Output::Broadcast {
|
||||
name: "Welcome".into(),
|
||||
text: "Alex is here for the first time".into(),
|
||||
},
|
||||
];
|
||||
assert_eq!(mods.drain(), greeted);
|
||||
let stone = BlockPos { x: 1, y: 63, z: 0 };
|
||||
mods.block_broken(&alex, stone, "minecraft:stone");
|
||||
assert!(mods.server_command(&alex, "mined"));
|
||||
assert!(mods.server_command(&alex, "online"));
|
||||
let reply = |text: &str| Output::Reply {
|
||||
player: alex.id.clone(),
|
||||
text: text.into(),
|
||||
error: false,
|
||||
};
|
||||
assert_eq!(
|
||||
mods.drain(),
|
||||
[reply("You broke 1 block(s)"), reply("Alex (the_nether)")]
|
||||
);
|
||||
assert!(!mods.server_command(&alex, "where"));
|
||||
assert!(
|
||||
!mods.command("online"),
|
||||
"a server command isn't the window's"
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ impl Mod for Guest {
|
||||
"input_held",
|
||||
"input_wheel",
|
||||
"zoom",
|
||||
"minimap",
|
||||
"corner",
|
||||
"readings",
|
||||
]
|
||||
@@ -198,6 +199,24 @@ impl Mod for Guest {
|
||||
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);
|
||||
}
|
||||
// Clamped to (1, 10); the two after it shake nothing.
|
||||
"render_shake" => {
|
||||
render::shake(2.0, 99.0);
|
||||
|
||||
@@ -828,9 +828,9 @@ pub fn carry(
|
||||
// Additions to other sets and pools, as files the game merges.
|
||||
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
|
||||
let types = &mut report.worldgen_types;
|
||||
if let Some((kind, mut added)) = modifier.and_then(|v| {
|
||||
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
|
||||
}) {
|
||||
if let Some((kind, mut added)) =
|
||||
modifier.and_then(|v| crate::lithostitched::modifier(&v, loaded, types))
|
||||
{
|
||||
crate::processors::carry(
|
||||
kind,
|
||||
&mut added,
|
||||
|
||||
@@ -157,6 +157,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "modular compat is crate::lithostitched's now (its tests)"]
|
||||
fn modular_compat_runs_its_list_only_with_its_mod() {
|
||||
let mut data = ProcessorData::default();
|
||||
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
|
||||
//! processors.
|
||||
use super::ctov;
|
||||
use crate::processors::{ProcessorData, carry_all, processors, referenced};
|
||||
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
|
||||
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
|
||||
/// `start_checks`). `None` for any other file.
|
||||
pub(crate) fn upgrade(
|
||||
kind: &str,
|
||||
v: &mut Value,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) -> Option<bool> {
|
||||
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
|
||||
return None;
|
||||
}
|
||||
start_checks(v, "lowest", lost, false);
|
||||
if v.as_object_mut()
|
||||
.and_then(|o| o.remove(ENHANCED_TERRAIN))
|
||||
.is_some()
|
||||
{
|
||||
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
|
||||
}
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// A lithostitched worldgen modifier
|
||||
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
|
||||
/// sets or template pools, as a file the game merges into them (the key
|
||||
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
|
||||
/// modifier's conditions are checked against `loaded`, the mods ported
|
||||
/// along: a compat addition for a mod that is not would place that mod's
|
||||
/// blocks, which the game does not have. Other modifier types are left out.
|
||||
/// Both are counted in `report`.
|
||||
pub(crate) fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let t = v["type"].as_str().unwrap_or_default();
|
||||
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
|
||||
let (kind, targets, from, to) = match t {
|
||||
"lithostitched:add_structure_set_entries" => {
|
||||
("structure_set", "structure_sets", "entries", "structures")
|
||||
}
|
||||
"lithostitched:add_template_pool_elements" => {
|
||||
("template_pool", "template_pools", "elements", "elements")
|
||||
}
|
||||
_ => {
|
||||
left(t);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !conditions_hold(v, loaded) {
|
||||
left("conditions");
|
||||
return None;
|
||||
}
|
||||
let targets: Option<Vec<&str>> = match &v[targets] {
|
||||
Value::String(s) => Some(vec![s.as_str()]),
|
||||
Value::Array(a) => a.iter().map(Value::as_str).collect(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
|
||||
else {
|
||||
left("target");
|
||||
return None;
|
||||
};
|
||||
let entries = v[from].as_array().cloned().unwrap_or_default();
|
||||
for target in &targets {
|
||||
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
|
||||
}
|
||||
Some((
|
||||
kind,
|
||||
serde_json::json!({"horizon:add_to": targets, to: entries}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
|
||||
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
|
||||
/// type not known here does not hold.
|
||||
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
|
||||
list.as_array().is_some_and(|a| a.iter().all(test))
|
||||
};
|
||||
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| predicate(q, loaded);
|
||||
let list = &p["predicates"];
|
||||
match p["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("lithostitched:"))
|
||||
{
|
||||
Some("true") => true,
|
||||
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
|
||||
Some("not") => !predicate(&p["predicate"], loaded),
|
||||
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| neoforge(q, loaded);
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:not") => !neoforge(&c["value"], loaded),
|
||||
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| fabric(q, loaded);
|
||||
let mods = || {
|
||||
c["values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(Value::as_str)
|
||||
};
|
||||
match c["condition"].as_str() {
|
||||
Some("fabric:true") => true,
|
||||
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:not") => !fabric(&c["value"], loaded),
|
||||
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
|
||||
let loader = match (
|
||||
v.get("neoforge:conditions"),
|
||||
v.get("fabric:load_conditions"),
|
||||
) {
|
||||
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
|
||||
// One condition or a list of them.
|
||||
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
|
||||
(None, Some(c)) => fabric(c, loaded),
|
||||
(None, None) => true,
|
||||
};
|
||||
litho && loader
|
||||
}
|
||||
|
||||
/// The data form of one of Lithostitched's processors: the processors to put
|
||||
/// in its place, or `None` when it has none (or is not Lithostitched's).
|
||||
pub(crate) fn processor(
|
||||
kind: &str,
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Vec<Value>> {
|
||||
match kind {
|
||||
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:block_swap" => {
|
||||
// The same map; the game's swap keeps the properties both have.
|
||||
p["blocks"].as_object()?;
|
||||
Some(vec![
|
||||
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
|
||||
])
|
||||
}
|
||||
"lithostitched:set_block" => {
|
||||
// A simple or weighted state provider, the block entity left as
|
||||
// it is.
|
||||
let provider = p["state_provider"]["type"].as_str()?;
|
||||
let modifier = p["block_entity_modifier"]["type"].as_str();
|
||||
let plain =
|
||||
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
|
||||
if !plain
|
||||
|| !matches!(
|
||||
provider,
|
||||
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut out = p.clone();
|
||||
out["processor_type"] = "horizon:set_block".into();
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.remove("block_entity_modifier");
|
||||
}
|
||||
Some(vec![out])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The references (Lithostitched's, and CTOV's compat processors) in a
|
||||
/// processor list written in; whether any was.
|
||||
pub(crate) fn expand_references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> (Vec<Value>, bool) {
|
||||
references(list, data, carried, 0)
|
||||
}
|
||||
|
||||
/// A processor list inside a carried processor: an id stays (the game reads
|
||||
/// that file, carried on its own); an inline one is carried here.
|
||||
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
|
||||
let mut list = list.clone();
|
||||
if let Some(ps) = processors(&mut list) {
|
||||
*ps = carry_all(std::mem::take(ps), data, carried).0;
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
|
||||
/// lists, or its set of lists at weight 1. A tag is not carried.
|
||||
fn apply_random(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
let lists = &p["processor_lists"];
|
||||
let weighted = lists.as_array().is_some_and(|a| {
|
||||
a.iter()
|
||||
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
|
||||
});
|
||||
let entries: Vec<(Value, Value)> = if weighted {
|
||||
lists
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|e| (e["weight"].clone(), e["data"].clone()))
|
||||
.collect()
|
||||
} else {
|
||||
match lists {
|
||||
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
|
||||
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
|
||||
vec![(1.into(), lists.clone())]
|
||||
}
|
||||
Value::Array(a)
|
||||
if a.iter()
|
||||
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
|
||||
{
|
||||
a.iter().map(|x| (1.into(), x.clone())).collect()
|
||||
}
|
||||
Value::Object(_) => vec![(1.into(), lists.clone())],
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
let lists: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
|
||||
.collect();
|
||||
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
|
||||
if let Some(mode) = p.get("mode") {
|
||||
out["mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
|
||||
/// every condition in it (true, all_of, any_of, not, random_chance,
|
||||
/// matching_blocks with plain property values); else it stays Java.
|
||||
fn condition(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
fn runs(c: &Value) -> bool {
|
||||
if let Some(all) = c.as_array() {
|
||||
return all.iter().all(runs);
|
||||
}
|
||||
let kind = c["type"].as_str().unwrap_or_default();
|
||||
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
|
||||
"true" | "random_chance" => true,
|
||||
"all_of" | "any_of" => c["conditions"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().all(runs)),
|
||||
"not" => runs(&c["condition"]),
|
||||
"matching_blocks" => c.get("properties").is_none_or(|p| {
|
||||
p.as_object()
|
||||
.is_some_and(|o| o.values().all(Value::is_string))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if !runs(&p["if_true"]) {
|
||||
return None;
|
||||
}
|
||||
// One processor or a list of them.
|
||||
let mut branch = |key: &str| -> Value {
|
||||
let list = match p.get(key) {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
carry_all(list, data, carried).0.into()
|
||||
};
|
||||
let (then, otherwise) = (branch("then"), branch("else"));
|
||||
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
|
||||
"then": then, "else": otherwise});
|
||||
if let Some(mode) = p.get("random_mode") {
|
||||
out["random_mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's references in a list, each replaced by the processors of
|
||||
/// the lists it names, in order (references in them too, to a depth of 8).
|
||||
/// One naming a tag or a list the mod does not have stays, to be dropped.
|
||||
/// Whether any was replaced.
|
||||
fn references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> (Vec<Value>, bool) {
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
let mut changed = false;
|
||||
for p in list {
|
||||
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
|
||||
changed = true;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
continue;
|
||||
}
|
||||
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
|
||||
.then(|| referenced(&p["processor_lists"], data))
|
||||
.flatten();
|
||||
match named {
|
||||
Some(inner) => {
|
||||
*carried.entry("lithostitched:reference".into()).or_default() += 1;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
changed = true;
|
||||
}
|
||||
None => out.push(p),
|
||||
}
|
||||
}
|
||||
(out, changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::legacy_structure_mods::data;
|
||||
use crate::processors::carry;
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_sets_and_pools() {
|
||||
let mut report = Upgrade::default();
|
||||
let only_fd = |m: &str| m == "farmersdelight";
|
||||
let set = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "minecraft:villages",
|
||||
"entries": [{"structure": "x:village", "weight": 4}]});
|
||||
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "structure_set");
|
||||
assert_eq!(
|
||||
added,
|
||||
json!({"horizon:add_to": ["minecraft:villages"],
|
||||
"structures": [{"structure": "x:village", "weight": 4}]})
|
||||
);
|
||||
// A compat addition holds only when its mod is ported along.
|
||||
let pool = |modid: &str| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements",
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
|
||||
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
|
||||
"template_pools": ["x:houses", "y:houses"],
|
||||
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
|
||||
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
|
||||
};
|
||||
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
|
||||
assert_eq!(added["elements"][0]["weight"], 5);
|
||||
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
|
||||
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
|
||||
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"fabric:load_conditions": [{"condition": "fabric:not",
|
||||
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
|
||||
"predicate": {"type": "lithostitched:all_of", "predicates": [
|
||||
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
|
||||
{"type": "lithostitched:true"}]},
|
||||
"structure_sets": "minecraft:villages", "entries": []});
|
||||
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
|
||||
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "#x:sets", "entries": []});
|
||||
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
|
||||
let features = json!({"type": "lithostitched:add_features"});
|
||||
assert!(modifier(&features, &only_fd, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.added_to,
|
||||
BTreeMap::from([
|
||||
("minecraft:villages".into(), 1),
|
||||
("x:houses".into(), 1),
|
||||
("y:houses".into(), 1),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:add_features".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_block_swaps_become_the_games() {
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data(), &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
|
||||
);
|
||||
// A map that is not one stays Java (dropped later).
|
||||
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_references_are_written_in() {
|
||||
let mut data = data();
|
||||
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
|
||||
data.lists
|
||||
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
|
||||
);
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
// House (its own reference to road first, then its swap), then road;
|
||||
// the swap inside is carried too.
|
||||
assert_eq!(p[0], gravity);
|
||||
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
|
||||
assert_eq!(p[2], gravity);
|
||||
// A tag or a list the mod lacks stays, to be dropped.
|
||||
assert_eq!(p[3]["processor_lists"], "#v:roads");
|
||||
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
|
||||
assert_eq!(carried["lithostitched:reference"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_random_and_condition_become_the_games() {
|
||||
let mut data = data();
|
||||
let swap = json!({"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
data.lists
|
||||
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
|
||||
{"processor_type": "v:java"}]}}]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": ["v:green", "v:red"]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": "#v:colours"},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
|
||||
"then": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
let green = json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
// Inline lists are carried; Java left in them is dropped later.
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
|
||||
);
|
||||
assert_eq!(
|
||||
p[1]["processor_lists"],
|
||||
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
|
||||
);
|
||||
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
|
||||
assert_eq!(
|
||||
p[3],
|
||||
json!({"processor_type": "horizon:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
|
||||
);
|
||||
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
|
||||
assert_eq!(carried["lithostitched:apply_random"], 2);
|
||||
assert_eq!(carried["lithostitched:condition"], 1);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@
|
||||
//! The general code reaches them only through the `pub(crate)` entry points
|
||||
//! of these three modules. Nothing in them is used by another mod's
|
||||
//! conversion.
|
||||
#[allow(dead_code)] // its modular compat is `crate::lithostitched`'s now; the rest goes with C1
|
||||
pub(crate) mod ctov;
|
||||
pub(crate) mod lithostitched;
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)] // the old Lithostitched tests used it
|
||||
fn data() -> crate::processors::ProcessorData {
|
||||
let mut d = crate::processors::ProcessorData::default();
|
||||
d.spawners.insert(
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod import;
|
||||
pub mod inventory;
|
||||
pub mod jar;
|
||||
pub(crate) mod legacy_structure_mods;
|
||||
mod lithostitched;
|
||||
pub mod modrinth;
|
||||
pub mod oracle;
|
||||
pub mod pipeline;
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Lithostitched's worldgen, carried as the game reads it (clean room:
|
||||
//! `cleanroom/structures/spec-lithostitched.md`; CTOV's processor:
|
||||
//! `spec-ctov.md` §4).
|
||||
//!
|
||||
//! - `lithostitched:jigsaw` structures become vanilla's jigsaw. Lithostitched
|
||||
//! lays jigsaws out its own way (OPEN-L5): Horizon keeps vanilla's layouts.
|
||||
//! - Its processors (`block_swap`, `reference`, `apply_random`, `condition`,
|
||||
//! `set_block`, `discard_input`) are the game's own types
|
||||
//! (`horizon_world`'s `structure::processor`): they are carried as written,
|
||||
//! the processors nested in them carried in turn. CTOV's
|
||||
//! `ctov:modular_compat` (a reference that runs only when its mod is loaded)
|
||||
//! becomes a `reference` when the mod is ported along, else nothing.
|
||||
//! - `add_template_pool_elements` modifiers become `horizon:add_to` pool files
|
||||
//! (`docs/mods.md`), when their load conditions pass. Lithostitched's own
|
||||
//! pool elements (`limited`, `guaranteed`, `delegating`) place their
|
||||
//! `delegate`; what they add to it is counted as lost.
|
||||
use crate::{processors::ProcessorData, worldgen::Upgrade};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JIGSAW: &str = "lithostitched:jigsaw";
|
||||
/// Vanilla's JigsawStructure.MAX_DEPTH.
|
||||
const MAX_SIZE: i64 = 20;
|
||||
const MODULAR_COMPAT: &str = "ctov:modular_compat";
|
||||
/// The processor types the game reads.
|
||||
const PROCESSORS: [&str; 6] = [
|
||||
"lithostitched:block_swap",
|
||||
"lithostitched:reference",
|
||||
"lithostitched:apply_random",
|
||||
"lithostitched:condition",
|
||||
"lithostitched:set_block",
|
||||
"lithostitched:discard_input",
|
||||
];
|
||||
|
||||
fn count(map: &mut BTreeMap<String, usize>, name: &str) {
|
||||
*map.entry(name.to_owned()).or_default() += 1;
|
||||
}
|
||||
|
||||
/// A `lithostitched:jigsaw` structure as vanilla's jigsaw: `Some(true)`, else
|
||||
/// `None`.
|
||||
pub(crate) fn upgrade(
|
||||
kind: &str,
|
||||
v: &mut Value,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) -> Option<bool> {
|
||||
if kind != "structure" || v["type"] != JIGSAW {
|
||||
return None;
|
||||
}
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
// `size` may be an int provider (sampled per start): its largest value.
|
||||
let size = match &v["size"] {
|
||||
Value::Null => None,
|
||||
Value::Number(n) => n.as_i64(),
|
||||
provider => {
|
||||
count(lost, "lithostitched:jigsaw size provider");
|
||||
["max_inclusive", "value"]
|
||||
.iter()
|
||||
.find_map(|k| provider[k].as_i64())
|
||||
}
|
||||
};
|
||||
if let Some(size) = size {
|
||||
if size > MAX_SIZE {
|
||||
count(lost, "lithostitched:jigsaw size over 20");
|
||||
}
|
||||
v["size"] = size.clamp(0, MAX_SIZE).into();
|
||||
}
|
||||
let o = v.as_object_mut()?;
|
||||
match o.remove("start_projection") {
|
||||
Some(Value::String(heightmap)) if heightmap != "floor" && heightmap != "ceiling" => {
|
||||
o.entry("project_start_to_heightmap")
|
||||
.or_insert(heightmap.into());
|
||||
}
|
||||
Some(_) => count(lost, "lithostitched:jigsaw start_projection"),
|
||||
None => {}
|
||||
}
|
||||
if o.remove("fixed_rotation").is_some_and(|f| f == true) {
|
||||
count(lost, "lithostitched:jigsaw fixed_rotation");
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// Lithostitched's pool elements as the element they wrap; whether `element`
|
||||
/// was one.
|
||||
pub(crate) fn upgrade_pool_element(
|
||||
element: &mut Value,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) -> bool {
|
||||
let Some(kind) = element["element_type"].as_str() else {
|
||||
return false;
|
||||
};
|
||||
match kind {
|
||||
"lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => {
|
||||
count(lost, kind);
|
||||
let delegate = element["delegate"].take();
|
||||
*element = delegate;
|
||||
// A delegate wrapping another (only `delegating` allows it).
|
||||
upgrade_pool_element(element, lost);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids a modifier targets: an id or a list of ids (`None` for tags and
|
||||
/// Lithostitched's object forms).
|
||||
fn ids(v: &Value) -> Option<Vec<String>> {
|
||||
let id = |v: &Value| {
|
||||
v.as_str()
|
||||
.filter(|s| !s.starts_with('#'))
|
||||
.map(str::to_owned)
|
||||
};
|
||||
match v {
|
||||
Value::Array(a) => a.iter().map(id).collect(),
|
||||
other => Some(vec![id(other)?]),
|
||||
}
|
||||
}
|
||||
|
||||
/// NeoForge's load conditions (`neoforge:conditions`): all must pass.
|
||||
fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
fn pass(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |key: &str| {
|
||||
c[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|c| pass(c, loaded))
|
||||
};
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:not") => !pass(&c["value"], loaded),
|
||||
Some("neoforge:and") => all("values"),
|
||||
Some("neoforge:or") => {
|
||||
(c["values"].as_array().into_iter().flatten()).any(|c| pass(c, loaded))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
(v["neoforge:conditions"].as_array().into_iter().flatten()).all(|c| pass(c, loaded))
|
||||
}
|
||||
|
||||
/// A worldgen modifier as a file the game merges: `("template_pool", file)`
|
||||
/// for `add_template_pool_elements`; the others are counted in
|
||||
/// `report.modifiers_left`.
|
||||
pub(crate) fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let kind = v["type"].as_str().unwrap_or("no type");
|
||||
if !conditions_pass(v, loaded) {
|
||||
count(&mut report.modifiers_left, "conditions");
|
||||
return None;
|
||||
}
|
||||
if kind != "lithostitched:add_template_pool_elements" {
|
||||
count(&mut report.modifiers_left, kind);
|
||||
return None;
|
||||
}
|
||||
let (Some(targets), Some(elements)) = (ids(&v["template_pools"]), v["elements"].as_array())
|
||||
else {
|
||||
count(&mut report.modifiers_left, "target");
|
||||
return None;
|
||||
};
|
||||
for target in &targets {
|
||||
*report.added_to.entry(target.clone()).or_default() += elements.len();
|
||||
}
|
||||
Some((
|
||||
"template_pool",
|
||||
json!({"horizon:add_to": targets, "elements": elements}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether the game reads a Lithostitched processor as it is: one of its
|
||||
/// types, with no processor list named by tag (the game has no processor
|
||||
/// list tags) and a state provider it runs.
|
||||
pub(crate) fn game_reads(p: &Value) -> bool {
|
||||
let Some(kind) = p["processor_type"]
|
||||
.as_str()
|
||||
.filter(|t| PROCESSORS.contains(t))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let tag = |v: &Value| v.as_str().is_some_and(|s| s.starts_with('#'));
|
||||
match kind {
|
||||
"lithostitched:reference" => match &p["processor_lists"] {
|
||||
Value::Array(a) => !a.iter().any(tag),
|
||||
other => other.is_string() && !tag(other),
|
||||
},
|
||||
"lithostitched:apply_random" => p["processor_lists"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().all(|e| !tag(e) && !tag(&e["data"]))),
|
||||
"lithostitched:set_block" => matches!(
|
||||
p["state_provider"]["type"].as_str(),
|
||||
Some("minecraft:simple_state_provider" | "minecraft:weighted_state_provider")
|
||||
),
|
||||
"lithostitched:block_swap" => p["blocks"].is_object(),
|
||||
"lithostitched:condition" => !p["if_true"].is_null(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The processor lists inside a Lithostitched processor, for the general
|
||||
/// code that walks lists (`then`, `else`, `apply_random`'s inline data).
|
||||
pub(crate) fn inner_lists(p: &mut Value) -> Vec<&mut Value> {
|
||||
match p["processor_type"].as_str() {
|
||||
Some("lithostitched:apply_random") => (p["processor_lists"].as_array_mut().into_iter())
|
||||
.flatten()
|
||||
.filter_map(|e| e.get_mut("data").filter(|d| !d.is_string()))
|
||||
.collect(),
|
||||
Some("lithostitched:condition") => (p.as_object_mut().into_iter())
|
||||
.flat_map(|o| o.iter_mut())
|
||||
.filter(|(k, _)| *k == "then" || *k == "else")
|
||||
.map(|(_, v)| v)
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lithostitched processor with the processors nested in it carried
|
||||
/// (`Some`, when one of them changed); `None` for any other processor.
|
||||
pub(crate) fn processor(
|
||||
kind: &str,
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Vec<Value>> {
|
||||
if !PROCESSORS.contains(&kind) {
|
||||
return None;
|
||||
}
|
||||
let mut out = p.clone();
|
||||
let mut changed = false;
|
||||
for list in inner_lists(&mut out) {
|
||||
// One processor alone is a list of one.
|
||||
if list.get("processor_type").is_some() {
|
||||
*list = Value::Array(vec![list.take()]);
|
||||
changed = true;
|
||||
}
|
||||
if let Some(processors) = crate::processors::processors(list) {
|
||||
let (inner, inner_changed) =
|
||||
crate::processors::carry_all(std::mem::take(processors), data, carried);
|
||||
*processors = inner;
|
||||
changed |= inner_changed;
|
||||
}
|
||||
}
|
||||
changed.then(|| vec![out])
|
||||
}
|
||||
|
||||
/// The list with CTOV's modular compat processors written as the game's
|
||||
/// `reference` (its mod is ported along) or taken out; whether any was.
|
||||
pub(crate) fn expand_references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> (Vec<Value>, bool) {
|
||||
let mut changed = false;
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for p in list {
|
||||
if p["processor_type"] != MODULAR_COMPAT {
|
||||
out.push(p);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
count(carried, MODULAR_COMPAT);
|
||||
let loaded = p["modid"]
|
||||
.as_str()
|
||||
.is_some_and(|m| data.compat_loaded.contains(m));
|
||||
if loaded {
|
||||
out.push(json!({"processor_type": "lithostitched:reference",
|
||||
"processor_lists": p["reference"].clone()}));
|
||||
}
|
||||
}
|
||||
(out, changed)
|
||||
}
|
||||
|
||||
/// Notes which mods CTOV's modular compat processors name are `loaded`.
|
||||
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
|
||||
fn visit(v: &Value, out: &mut Vec<String>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT)
|
||||
&& let Some(m) = o.get("modid").and_then(Value::as_str)
|
||||
{
|
||||
out.push(m.to_owned());
|
||||
}
|
||||
o.values().for_each(|v| visit(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| visit(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut named = Vec::new();
|
||||
data.lists.values().for_each(|l| visit(l, &mut named));
|
||||
data.compat_loaded
|
||||
.extend(named.into_iter().filter(|m| loaded(m)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worldgen::upgrade as upgrade_file;
|
||||
|
||||
#[test]
|
||||
fn jigsaws_become_vanillas() {
|
||||
let mut lost = BTreeMap::new();
|
||||
let mut s = json!({"type": JIGSAW, "size": {"type": "uniform", "min_inclusive": 3,
|
||||
"max_inclusive": 40}, "start_projection": "WORLD_SURFACE_WG", "fixed_rotation": true});
|
||||
assert_eq!(upgrade("structure", &mut s, &mut lost), Some(true));
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({"type": "minecraft:jigsaw", "size": 20,
|
||||
"project_start_to_heightmap": "WORLD_SURFACE_WG"})
|
||||
);
|
||||
assert_eq!(lost.len(), 3);
|
||||
assert_eq!(
|
||||
upgrade(
|
||||
"structure",
|
||||
&mut json!({"type": "minecraft:jigsaw"}),
|
||||
&mut lost
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_elements_place_their_delegate() {
|
||||
let mut lost = BTreeMap::new();
|
||||
let delegate = json!({"element_type": "minecraft:single_pool_element", "location": "a:b",
|
||||
"projection": "rigid", "processors": "minecraft:empty"});
|
||||
let mut e =
|
||||
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate});
|
||||
assert!(upgrade_pool_element(&mut e, &mut lost));
|
||||
assert_eq!(e, delegate);
|
||||
assert_eq!(lost["lithostitched:limited"], 1);
|
||||
assert!(!upgrade_pool_element(&mut e, &mut lost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_pools_when_their_mods_are_loaded() {
|
||||
let mut report = Upgrade::default();
|
||||
let loaded = |m: &str| m == "waystones";
|
||||
let add = |modid: &str, pools: Value| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements", "template_pools": pools,
|
||||
"elements": [{"weight": 2, "element": {"element_type": "minecraft:empty_pool_element"}}],
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}]})
|
||||
};
|
||||
let (kind, file) = modifier(
|
||||
&add("waystones", json!("ctov:village/plains/house")),
|
||||
&loaded,
|
||||
&mut report,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(file["horizon:add_to"], json!(["ctov:village/plains/house"]));
|
||||
assert_eq!(file["elements"][0]["weight"], 2);
|
||||
assert_eq!(report.added_to["ctov:village/plains/house"], 1);
|
||||
assert!(modifier(&add("sawmill", json!("a:b")), &loaded, &mut report).is_none());
|
||||
assert!(modifier(&add("waystones", json!("#a:pools")), &loaded, &mut report).is_none());
|
||||
let aliases = json!({"type": "lithostitched:set_pool_aliases"});
|
||||
assert!(modifier(&aliases, &loaded, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:set_pool_aliases".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// A CTOV list (spec-ctov §4): references, a condition with a single
|
||||
/// processor and a nested condition, a random list holding the compat
|
||||
/// hook, and processors the game cannot read.
|
||||
#[test]
|
||||
fn processors_stay_lithostitcheds_with_their_insides_carried() {
|
||||
let hook = json!({"processor_type": MODULAR_COMPAT, "modid": "natures_spirit",
|
||||
"reference": "ctov:village/mesa/house_wisteria"});
|
||||
let list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["ctov:village/mesa/house"]},
|
||||
{"processor_type": "lithostitched:condition", "random_mode": "per_block",
|
||||
"if_true": {"type": "lithostitched:matching_blocks", "blocks": "minecraft:water",
|
||||
"match_type": "location"},
|
||||
"then": {"processor_type": "lithostitched:set_block", "state_provider": {
|
||||
"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:spruce_planks"}}},
|
||||
"else": [{"processor_type": "somemod:java_processor"}]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 1, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [hook]}}]},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "#ctov:palettes"},
|
||||
{"processor_type": "lithostitched:set_block", "state_provider": {
|
||||
"type": "minecraft:noise_provider"}}]});
|
||||
for (loaded, wisteria) in [
|
||||
(false, json!([])),
|
||||
(
|
||||
true,
|
||||
json!([{"processor_type":
|
||||
"lithostitched:reference", "processor_lists": "ctov:village/mesa/house_wisteria"}]),
|
||||
),
|
||||
] {
|
||||
let mut data = ProcessorData::default();
|
||||
data.lists
|
||||
.insert("ctov:village/mesa/house".into(), list.clone());
|
||||
mark_loaded(&mut data, &|m| loaded && m == "natures_spirit");
|
||||
let mut v = list.clone();
|
||||
let mut report = Upgrade::default();
|
||||
crate::processors::carry(
|
||||
"processor_list",
|
||||
&mut v,
|
||||
&data,
|
||||
&mut report.processors_carried,
|
||||
);
|
||||
upgrade_file("processor_list", &mut v, &mut report, &BTreeMap::new());
|
||||
let p = &v["processors"];
|
||||
assert_eq!(p.as_array().unwrap().len(), 3, "{p}");
|
||||
assert_eq!(p[0], list["processors"][0]);
|
||||
// One processor is a list of one; the Java one is taken out.
|
||||
assert_eq!(
|
||||
p[1]["then"][0]["state_provider"]["state"]["id"],
|
||||
"minecraft:spruce_planks"
|
||||
);
|
||||
assert_eq!(p[1]["else"], json!([]));
|
||||
assert_eq!(p[2]["processor_lists"][0]["data"], "minecraft:empty");
|
||||
assert_eq!(p[2]["processor_lists"][1]["data"]["processors"], wisteria);
|
||||
assert_eq!(report.processors_carried[MODULAR_COMPAT], 1);
|
||||
assert_eq!(
|
||||
report.processors_dropped,
|
||||
BTreeMap::from([
|
||||
("lithostitched:reference".into(), 1),
|
||||
("lithostitched:set_block".into(), 1),
|
||||
("somemod:java_processor".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,13 @@
|
||||
//! - Repurposed Structures' mineshaft sky view becomes the game's
|
||||
//! `horizon:mineshaft_sky_view`.
|
||||
//!
|
||||
//! The processors of YUNG's mods, Lithostitched and Choice Theorem's Overhauled
|
||||
//! Village are carried by `legacy_structure_mods`, called from [`carry`].
|
||||
//! Lithostitched's processors and Choice Theorem's Overhauled Village's
|
||||
//! compat hook are carried by `crate::lithostitched`, called from [`carry`].
|
||||
//!
|
||||
//! What is not here is taken out of its list later and counted
|
||||
//! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an
|
||||
//! `equipment` map, not 1.21.1's `HandItems` and `ArmorItems`).
|
||||
use crate::legacy_structure_mods::{ctov, lithostitched};
|
||||
use crate::lithostitched;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -42,7 +42,7 @@ pub struct ProcessorData {
|
||||
impl ProcessorData {
|
||||
/// Notes which mods its compat processors name are `loaded`.
|
||||
pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) {
|
||||
ctov::mark_loaded(self, loaded);
|
||||
lithostitched::mark_loaded(self, loaded);
|
||||
}
|
||||
|
||||
pub fn read<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> Self {
|
||||
@@ -177,8 +177,7 @@ fn carry_at(
|
||||
let mut out = Vec::with_capacity(expanded.len());
|
||||
for p in expanded {
|
||||
let kind = p["processor_type"].as_str().unwrap_or_default().to_owned();
|
||||
// Lithostitched's processors are carried apart from this code
|
||||
// (`legacy_structure_mods`).
|
||||
// Lithostitched's processors stay theirs, with what they hold carried.
|
||||
let replacement = lithostitched::processor(&kind, &p, data, carried).or_else(|| {
|
||||
match pillar_type(&kind) {
|
||||
Some(to) => Some(vec![pillar(&p, to, data, carried, depth)]),
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
//! closely.
|
||||
//!
|
||||
//! YUNG's API's files are carried as written ([`crate::yung`]).
|
||||
//! Lithostitched's and CTOV's worldgen files are converted by
|
||||
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
|
||||
//! processors.
|
||||
use crate::{legacy_structure_mods::lithostitched, yung};
|
||||
//! Lithostitched's jigsaws, elements, modifiers and processors are carried by
|
||||
//! [`crate::lithostitched`]; the entries CTOV adds to vanilla's structure sets
|
||||
//! by `legacy_structure_mods`, called from [`crate::carry`].
|
||||
use crate::{lithostitched, yung};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -191,28 +191,19 @@ fn drop_java_processors(list: &mut Value, dropped: &mut BTreeMap<String, usize>)
|
||||
let before = processors.len();
|
||||
processors.retain(|p| {
|
||||
let t = p["processor_type"].as_str().unwrap_or_default();
|
||||
let java = !t.starts_with("minecraft:") && !t.starts_with("horizon:");
|
||||
let java = !t.starts_with("minecraft:")
|
||||
&& !t.starts_with("horizon:")
|
||||
&& !lithostitched::game_reads(p);
|
||||
if java {
|
||||
*dropped.entry(t.to_owned()).or_default() += 1;
|
||||
}
|
||||
!java
|
||||
});
|
||||
let mut changed = processors.len() != before;
|
||||
// The inline lists inside the game's apply_random and condition.
|
||||
// The inline lists inside Lithostitched's apply_random and condition.
|
||||
for p in processors.iter_mut() {
|
||||
let inner: Vec<&mut Value> = match p["processor_type"].as_str() {
|
||||
Some("horizon:apply_random") => p["processor_lists"]
|
||||
.as_array_mut()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| &mut e["data"])
|
||||
.collect(),
|
||||
Some("horizon:condition") => {
|
||||
let o = p.as_object_mut().into_iter().flat_map(|o| o.iter_mut());
|
||||
o.filter(|(k, _)| *k == "then" || *k == "else")
|
||||
.map(|(_, v)| v)
|
||||
.collect()
|
||||
}
|
||||
Some(t) if t.starts_with("lithostitched:") => lithostitched::inner_lists(p),
|
||||
Some("horizon:pillar" | "horizon:bottom_pillar") => {
|
||||
p.get_mut("pillar_processor_list").into_iter().collect()
|
||||
}
|
||||
@@ -398,7 +389,9 @@ fn upgrade_types(
|
||||
}
|
||||
}
|
||||
for element in elements {
|
||||
if yung::upgrade_pool_element(element, lost) {
|
||||
if yung::upgrade_pool_element(element, lost)
|
||||
|| lithostitched::upgrade_pool_element(element, lost)
|
||||
{
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct CheckedStage {
|
||||
pub label: String,
|
||||
pub module: naga::Module,
|
||||
pub info: ModuleInfo,
|
||||
/// The stage's WGSL without the entry points (the prelude, the shared
|
||||
/// files, the file), for a renderer that calls the stage function from
|
||||
/// its own shader (the sky).
|
||||
pub source: String,
|
||||
pub expressions: usize,
|
||||
pub texture_reads: usize,
|
||||
}
|
||||
@@ -169,6 +173,7 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedSt
|
||||
label: file.to_owned(),
|
||||
module,
|
||||
info,
|
||||
source: own.text,
|
||||
expressions,
|
||||
texture_reads,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ use horizon_core::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
mod lithostitched;
|
||||
mod litho;
|
||||
mod pillars;
|
||||
mod sea_level;
|
||||
|
||||
@@ -238,6 +238,23 @@ impl Modifier {
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Modifier {
|
||||
/// RuleBlockEntityModifier.apply: the block entity the output gets.
|
||||
fn apply(&self, nbt: Option<&Compound>, random: &mut impl ProcessorRandom) -> Option<Compound> {
|
||||
match self {
|
||||
Self::Passthrough => nbt.cloned(),
|
||||
Self::Clear => Some(Compound::new()),
|
||||
Self::AppendLoot(table) => {
|
||||
let mut nbt = nbt.cloned().unwrap_or_default();
|
||||
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
nbt.insert("LootTableSeed".into(), Tag::Long(random.long()));
|
||||
Some(nbt)
|
||||
}
|
||||
Self::AppendStatic(data) => Some(edited(nbt, &[], data)),
|
||||
Self::Edit { remove, data } => Some(edited(nbt, remove, data)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A block entity with `remove`'s keys taken out and `data` merged in
|
||||
/// (append_static is the one with nothing to remove).
|
||||
fn edited(nbt: Option<&Compound>, remove: &[String], data: &Compound) -> Compound {
|
||||
@@ -292,17 +309,21 @@ pub enum Processor {
|
||||
SpawnerMobs(Box<SpawnerMobs>),
|
||||
Pick(Box<Pick>),
|
||||
CountCap(Box<CountCap>),
|
||||
/// `horizon:block_swap` (Lithostitched's `block_swap`): blocks by name
|
||||
/// become others, keeping the properties both have.
|
||||
/// `lithostitched:block_swap`: blocks by name become others, keeping
|
||||
/// the properties both have (a block the game lacks is left alone).
|
||||
BlockSwap(std::collections::BTreeMap<String, String>),
|
||||
/// `horizon:apply_random` (Lithostitched's `apply_random`): a processor
|
||||
/// list drawn by weight runs on the block.
|
||||
ApplyRandom(Box<lithostitched::ApplyRandom>),
|
||||
/// `horizon:condition` (Lithostitched's `condition`): `then` or `else`
|
||||
/// `lithostitched:apply_random`: a processor list drawn by weight runs
|
||||
/// on the block.
|
||||
ApplyRandom(Box<litho::ApplyRandom>),
|
||||
/// `lithostitched:reference`: the named lists run on the block in turn.
|
||||
Reference(Vec<ProcessorList>),
|
||||
/// `lithostitched:discard_input`: the world's block stays.
|
||||
DiscardInput,
|
||||
/// `lithostitched:condition`: `then` or `else`
|
||||
/// runs on the block, by a test of it.
|
||||
Condition(Box<lithostitched::Conditional>),
|
||||
/// `horizon:set_block` (Lithostitched's `set_block`).
|
||||
SetBlock(Box<lithostitched::SetBlock>),
|
||||
Condition(Box<litho::Conditional>),
|
||||
/// `lithostitched:set_block`: the block from a state provider.
|
||||
SetBlock(Box<litho::SetBlock>),
|
||||
/// `horizon:pillar` (Repurposed Structures' and Moog's pillars): a
|
||||
/// trigger block starts a column down to the ground.
|
||||
Pillar(Box<pillars::Pillar>),
|
||||
@@ -786,7 +807,7 @@ impl Processor {
|
||||
})?))
|
||||
}
|
||||
"horizon:pick" => Self::Pick(Box::new(Pick::decode(value, state)?)),
|
||||
"horizon:block_swap" => Self::BlockSwap(
|
||||
"lithostitched:block_swap" => Self::BlockSwap(
|
||||
value["blocks"]
|
||||
.as_object()
|
||||
.ok_or(DecodeError::Field("blocks"))?
|
||||
@@ -801,12 +822,16 @@ impl Processor {
|
||||
.collect::<Result<_, DecodeError>>()?,
|
||||
),
|
||||
"horizon:count_cap" => Self::CountCap(Box::new(CountCap::decode(value, state)?)),
|
||||
"horizon:apply_random" => Self::ApplyRandom(Box::new(
|
||||
lithostitched::ApplyRandom::decode(value, resources, bindings)?,
|
||||
"lithostitched:apply_random" => Self::ApplyRandom(Box::new(
|
||||
litho::ApplyRandom::decode(value, resources, bindings)?,
|
||||
)),
|
||||
"horizon:set_block" => {
|
||||
Self::SetBlock(Box::new(lithostitched::SetBlock::decode(value, state)?))
|
||||
"lithostitched:set_block" => {
|
||||
Self::SetBlock(Box::new(litho::SetBlock::decode(value, state)?))
|
||||
}
|
||||
"lithostitched:reference" => {
|
||||
Self::Reference(litho::reference(value, resources, bindings)?)
|
||||
}
|
||||
"lithostitched:discard_input" => Self::DiscardInput,
|
||||
"horizon:pillar" => Self::Pillar(Box::new(pillars::Pillar::decode(
|
||||
value, resources, bindings, state,
|
||||
)?)),
|
||||
@@ -834,7 +859,7 @@ impl Processor {
|
||||
"horizon:keep_world_block" => {
|
||||
Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?))
|
||||
}
|
||||
"horizon:condition" => Self::Condition(Box::new(lithostitched::Conditional::decode(
|
||||
"lithostitched:condition" => Self::Condition(Box::new(litho::Conditional::decode(
|
||||
value, resources, bindings,
|
||||
)?)),
|
||||
_ => return Err(DecodeError::Field("structure processor type")),
|
||||
@@ -1115,24 +1140,10 @@ fn process_block<W: PlacementContext>(
|
||||
if !position_ok {
|
||||
continue;
|
||||
}
|
||||
let nbt = match &rule.modifier {
|
||||
Modifier::Passthrough => info.nbt.clone(),
|
||||
Modifier::Clear => Some(Compound::new()),
|
||||
Modifier::AppendLoot(table) => {
|
||||
let mut nbt = info.nbt.clone().unwrap_or_default();
|
||||
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
nbt.insert("LootTableSeed".into(), Tag::Long(random.next_long()));
|
||||
Some(nbt)
|
||||
}
|
||||
Modifier::AppendStatic(data) => Some(edited(info.nbt.as_ref(), &[], data)),
|
||||
Modifier::Edit { remove, data } => {
|
||||
Some(edited(info.nbt.as_ref(), remove, data))
|
||||
}
|
||||
};
|
||||
return Ok(Some(BlockInfo {
|
||||
pos: info.pos,
|
||||
state: rule.output,
|
||||
nbt,
|
||||
nbt: rule.modifier.apply(info.nbt.as_ref(), &mut random),
|
||||
}));
|
||||
}
|
||||
Some(info)
|
||||
@@ -1165,8 +1176,7 @@ fn process_block<W: PlacementContext>(
|
||||
false => Some(info),
|
||||
},
|
||||
Processor::ApplyRandom(apply) => {
|
||||
let seed = world.world_seed()?;
|
||||
let mut random = apply.random.create(seed, position, reference, info.pos);
|
||||
let mut random = apply.draws.random(world.world_seed()?, position, info.pos);
|
||||
match apply.pick(&mut random) {
|
||||
Some(list) => {
|
||||
return process_list(
|
||||
@@ -1176,21 +1186,34 @@ fn process_block<W: PlacementContext>(
|
||||
None => Some(info),
|
||||
}
|
||||
}
|
||||
Processor::Reference(lists) => {
|
||||
let mut current = info;
|
||||
for list in lists {
|
||||
match process_list(
|
||||
list, world, resources, position, reference, relative, current, settings,
|
||||
)? {
|
||||
Some(next) => current = next,
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
Processor::DiscardInput => None,
|
||||
Processor::SetBlock(set) => {
|
||||
let seed = world.world_seed()?;
|
||||
let state = set.state(seed, position, reference, info.pos);
|
||||
let state = match set.preserves() {
|
||||
let mut random = set.draws.random(world.world_seed()?, position, info.pos);
|
||||
let state = set.state(&mut random);
|
||||
let state = match set.preserve {
|
||||
true => registry
|
||||
.state(state)
|
||||
.and_then(|s| with_properties_of(registry, &s.name, info.state))
|
||||
.unwrap_or(state),
|
||||
false => state,
|
||||
};
|
||||
Some(BlockInfo { state, ..info })
|
||||
let nbt = set.modifier.apply(info.nbt.as_ref(), &mut random);
|
||||
Some(BlockInfo { state, nbt, ..info })
|
||||
}
|
||||
Processor::Condition(c) => {
|
||||
let seed = world.world_seed()?;
|
||||
let mut random = c.random.create(seed, position, reference, info.pos);
|
||||
let mut random = c.draws.random(world.world_seed()?, position, info.pos);
|
||||
let location = world.block_state(info.pos)?;
|
||||
let passed = c
|
||||
.condition
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
//! Lithostitched's processors (spec-lithostitched §5), read as its data
|
||||
//! writes them: `lithostitched:block_swap` (in `processor.rs`), `reference`,
|
||||
//! `apply_random`, `condition`, `set_block` and `discard_input`.
|
||||
//!
|
||||
//! The draws of `random_chance`, `apply_random` and `set_block` follow
|
||||
//! Lithostitched's `random_mode` (per block or per piece), but its seeding is
|
||||
//! not known (OPEN-L6): a positional random of the world seed, the block's or
|
||||
//! the piece's position and the processor itself, so the shares match while
|
||||
//! the blocks chosen do not.
|
||||
use super::{BlockStateId, DecodeError, Modifier, ProcessorList, ProcessorRandom};
|
||||
use crate::worldgen::{block_feature::FeatureBlockBindings, structure::StructureResources};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, position_seed},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// `random_mode` (`condition`, `set_block`) or `mode` (`apply_random`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Draws {
|
||||
per_piece: bool,
|
||||
/// The processor's own salt, so two processors on a block draw apart.
|
||||
salt: i64,
|
||||
}
|
||||
impl Draws {
|
||||
fn decode(mode: Option<&Value>, processor: &Value) -> Result<Self, DecodeError> {
|
||||
let per_piece = match mode.and_then(Value::as_str) {
|
||||
None | Some("per_block") => false,
|
||||
Some("per_piece") => true,
|
||||
_ => return Err(DecodeError::Field("random_mode")),
|
||||
};
|
||||
// FNV-1a over the processor's JSON: the same in every run.
|
||||
let salt = (processor.to_string().bytes()).fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
|
||||
(h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
|
||||
}) as i64;
|
||||
Ok(Self { per_piece, salt })
|
||||
}
|
||||
/// The random for the block at `pos` of the piece at `piece`.
|
||||
pub(super) fn random(self, world_seed: i64, piece: [i32; 3], pos: [i32; 3]) -> LegacyRandom {
|
||||
let [x, y, z] = if self.per_piece { piece } else { pos };
|
||||
LegacyRandom::new(world_seed ^ self.salt ^ position_seed(BlockPos::new(x, y, z)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A processor or a list of them (`then`, `else`, `apply_random`'s data):
|
||||
/// a list holder (`ProcessorList::decode`), or one processor object.
|
||||
fn processors(
|
||||
value: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<ProcessorList, DecodeError> {
|
||||
let one = value.get("processor_type").is_some();
|
||||
let holder = if one {
|
||||
Value::Array(vec![value.clone()])
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
ProcessorList::decode(&holder.to_string(), resources, bindings)
|
||||
}
|
||||
|
||||
/// `reference`: the named lists, run in turn on the block.
|
||||
pub(super) fn reference(
|
||||
value: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Vec<ProcessorList>, DecodeError> {
|
||||
let names = match &value["processor_lists"] {
|
||||
Value::Array(a) => a.clone(),
|
||||
other => vec![other.clone()],
|
||||
};
|
||||
(names.iter())
|
||||
.map(|name| match name.as_str() {
|
||||
Some(n) if n.starts_with('#') => Err(DecodeError::Field("processor list tag")),
|
||||
Some(_) => ProcessorList::decode(&name.to_string(), resources, bindings),
|
||||
None => Err(DecodeError::Field("processor_lists")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `apply_random`: one list drawn by weight runs on the block.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ApplyRandom {
|
||||
pub(super) draws: Draws,
|
||||
lists: Vec<(i32, ProcessorList)>,
|
||||
total: i32,
|
||||
}
|
||||
impl ApplyRandom {
|
||||
pub(super) fn decode(
|
||||
value: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let entries = value["processor_lists"]
|
||||
.as_array()
|
||||
.ok_or(DecodeError::Field("apply_random processor_lists"))?;
|
||||
let lists = (entries.iter())
|
||||
.map(|e| match e.get("data") {
|
||||
Some(data) => {
|
||||
let weight = e["weight"]
|
||||
.as_i64()
|
||||
.unwrap_or(1)
|
||||
.clamp(0, i64::from(i32::MAX));
|
||||
Ok((weight as i32, processors(data, resources, bindings)?))
|
||||
}
|
||||
None => Ok((1, processors(e, resources, bindings)?)),
|
||||
})
|
||||
.collect::<Result<Vec<_>, DecodeError>>()?;
|
||||
let total = lists
|
||||
.iter()
|
||||
.map(|(w, _)| *w)
|
||||
.fold(0i32, i32::saturating_add);
|
||||
Ok(Self {
|
||||
draws: Draws::decode(value.get("mode"), value)?,
|
||||
lists,
|
||||
total,
|
||||
})
|
||||
}
|
||||
/// The list drawn: one bounded draw over the total weight.
|
||||
pub(super) fn pick(&self, random: &mut impl ProcessorRandom) -> Option<&ProcessorList> {
|
||||
if self.total <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut at = random.int(self.total);
|
||||
for (weight, list) in &self.lists {
|
||||
at -= weight;
|
||||
if at < 0 {
|
||||
return Some(list);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Lithostitched's processor conditions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum Condition {
|
||||
True,
|
||||
AllOf(Vec<Condition>),
|
||||
AnyOf(Vec<Condition>),
|
||||
Not(Box<Condition>),
|
||||
Chance(f32),
|
||||
/// `matching_blocks`: the template's block (`input`) or the world's
|
||||
/// (`location`) is one of `blocks`, with `properties`.
|
||||
Matching {
|
||||
blocks: BTreeSet<String>,
|
||||
properties: Vec<(String, String)>,
|
||||
location: bool,
|
||||
},
|
||||
}
|
||||
impl Condition {
|
||||
/// One condition, or a list of them that must all pass (`if_true`).
|
||||
pub(super) fn decode(
|
||||
value: &Value,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let list = |v: &Value| -> Result<Vec<Self>, DecodeError> {
|
||||
(v.as_array().ok_or(DecodeError::Field("conditions"))?.iter())
|
||||
.map(|c| Self::decode(c, bindings))
|
||||
.collect()
|
||||
};
|
||||
if value.is_array() {
|
||||
return Ok(Self::AllOf(list(value)?));
|
||||
}
|
||||
let kind = value["type"].as_str().unwrap_or_default();
|
||||
Ok(match crate::worldgen::tags::identifier(kind)?.as_str() {
|
||||
"lithostitched:true" => Self::True,
|
||||
"lithostitched:all_of" => Self::AllOf(list(&value["conditions"])?),
|
||||
"lithostitched:any_of" => Self::AnyOf(list(&value["conditions"])?),
|
||||
"lithostitched:not" => {
|
||||
Self::Not(Box::new(Self::decode(&value["condition"], bindings)?))
|
||||
}
|
||||
"lithostitched:random_chance" => Self::Chance(
|
||||
value["chance"]
|
||||
.as_f64()
|
||||
.ok_or(DecodeError::Field("random_chance chance"))? as f32,
|
||||
),
|
||||
"lithostitched:matching_blocks" => Self::Matching {
|
||||
blocks: blocks(&value["blocks"], bindings)?,
|
||||
properties: (value
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.into_iter())
|
||||
.flatten()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
v.as_str().map_or_else(|| v.to_string(), str::to_owned),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
location: match value.get("match_type").and_then(Value::as_str) {
|
||||
None | Some("input") => false,
|
||||
Some("location") => true,
|
||||
_ => return Err(DecodeError::Field("matching_blocks match_type")),
|
||||
},
|
||||
},
|
||||
_ => return Err(DecodeError::Field("lithostitched condition type")),
|
||||
})
|
||||
}
|
||||
pub(super) fn test(
|
||||
&self,
|
||||
input: BlockStateId,
|
||||
location: BlockStateId,
|
||||
registry: &crate::registry::BlockRegistry,
|
||||
random: &mut impl ProcessorRandom,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::True => true,
|
||||
Self::AllOf(all) => all
|
||||
.iter()
|
||||
.all(|c| c.test(input, location, registry, random)),
|
||||
Self::AnyOf(any) => any
|
||||
.iter()
|
||||
.any(|c| c.test(input, location, registry, random)),
|
||||
Self::Not(c) => !c.test(input, location, registry, random),
|
||||
Self::Chance(chance) => random.float() < *chance,
|
||||
Self::Matching {
|
||||
blocks,
|
||||
properties,
|
||||
location: at_location,
|
||||
} => {
|
||||
let Some(state) = registry.state(if *at_location { location } else { input })
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
blocks.contains(&state.name)
|
||||
&& (properties.iter()).all(|(k, v)| state.properties.get(k) == Some(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A block set (an id, a list, `#tag`) by block name. Blocks the game does
|
||||
/// not have are left out: no template block can be one.
|
||||
fn blocks(
|
||||
value: &Value,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<BTreeSet<String>, DecodeError> {
|
||||
let names: Vec<&str> = match value {
|
||||
Value::String(s) => vec![s.as_str()],
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
|
||||
_ => return Err(DecodeError::Field("matching_blocks blocks")),
|
||||
};
|
||||
let registry = bindings.registry();
|
||||
let mut out = BTreeSet::new();
|
||||
for name in names {
|
||||
match name.strip_prefix('#') {
|
||||
Some(tag) => {
|
||||
let tag = crate::worldgen::tags::identifier(tag)?;
|
||||
let states = bindings.tags.get(&tag).into_iter().flat_map(|s| s.iter());
|
||||
out.extend(states.filter_map(|s| Some(registry.state(*s)?.name.clone())));
|
||||
}
|
||||
None => {
|
||||
out.insert(crate::worldgen::tags::identifier(name)?.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `condition`: `then` when the conditions pass, else `else` (none: the
|
||||
/// block as it is).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Conditional {
|
||||
pub(super) draws: Draws,
|
||||
pub(super) condition: Condition,
|
||||
pub(super) then: ProcessorList,
|
||||
pub(super) otherwise: ProcessorList,
|
||||
}
|
||||
impl Conditional {
|
||||
pub(super) fn decode(
|
||||
value: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let branch = |key| match value.get(key) {
|
||||
Some(v) => processors(v, resources, bindings),
|
||||
None => Ok(ProcessorList(Vec::new())),
|
||||
};
|
||||
Ok(Self {
|
||||
draws: Draws::decode(value.get("random_mode"), value)?,
|
||||
condition: Condition::decode(&value["if_true"], bindings)?,
|
||||
then: branch("then")?,
|
||||
otherwise: branch("else")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_block`: the block from a state provider (simple or weighted).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SetBlock {
|
||||
pub(super) draws: Draws,
|
||||
states: Vec<(i32, BlockStateId)>,
|
||||
total: i32,
|
||||
/// `preserve_state` (true by default): the template block's properties
|
||||
/// the new block has too are kept.
|
||||
pub(super) preserve: bool,
|
||||
pub(super) modifier: Modifier,
|
||||
}
|
||||
impl SetBlock {
|
||||
pub(super) fn decode(
|
||||
value: &Value,
|
||||
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let provider = &value["state_provider"];
|
||||
let kind = provider["type"].as_str().unwrap_or_default();
|
||||
let states = match crate::worldgen::tags::identifier(kind)?.as_str() {
|
||||
"minecraft:simple_state_provider" => vec![(1, state(&provider["state"])?)],
|
||||
"minecraft:weighted_state_provider" => (provider["entries"].as_array().into_iter())
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
Ok((
|
||||
e["weight"].as_i64().unwrap_or(1).clamp(0, 1 << 30) as i32,
|
||||
state(&e["data"])?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, DecodeError>>()?,
|
||||
_ => return Err(DecodeError::Field("set_block state_provider")),
|
||||
};
|
||||
let total = states.iter().map(|(w, _)| *w).sum();
|
||||
if total <= 0 {
|
||||
return Err(DecodeError::Field("set_block state_provider"));
|
||||
}
|
||||
Ok(Self {
|
||||
draws: Draws::decode(value.get("random_mode"), value)?,
|
||||
states,
|
||||
total,
|
||||
preserve: value
|
||||
.get("preserve_state")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
modifier: Modifier::decode(value.get("block_entity_modifier"))?,
|
||||
})
|
||||
}
|
||||
/// The provider's state: WeightedList.getRandom, one bounded draw.
|
||||
pub(super) fn state(&self, random: &mut impl ProcessorRandom) -> BlockStateId {
|
||||
let mut at = random.int(self.total);
|
||||
for (weight, state) in &self.states {
|
||||
at -= weight;
|
||||
if at < 0 {
|
||||
return *state;
|
||||
}
|
||||
}
|
||||
self.states[0].1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{BlockInfo, PlaceSettings, ProcessorList, process_list};
|
||||
use crate::{
|
||||
heightmap::HeightmapType,
|
||||
nbt::{JavaString, Tag},
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
worldgen::{
|
||||
anchor::WorldGenerationContext,
|
||||
block_feature::FeatureBlockBindings,
|
||||
placement::{PlacementContext, PlacementQueryError},
|
||||
structure::{
|
||||
Rotation, StateTransforms, StructureResources, template::parse_state_string,
|
||||
},
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Every combination of `properties`' values, the first one the default.
|
||||
fn states(name: &str, properties: &[(&str, &[&str])]) -> Vec<Value> {
|
||||
let mut out = vec![serde_json::Map::new()];
|
||||
for (key, values) in properties {
|
||||
out = (out.iter())
|
||||
.flat_map(|o| {
|
||||
values.iter().map(move |v| {
|
||||
let mut o = o.clone();
|
||||
o.insert((*key).into(), (*v).into());
|
||||
o
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
(out.into_iter())
|
||||
.map(|p| json!([name, name.ends_with("air"), false, "", {"id": name, "properties": p}]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A registry of the blocks CTOV's lists and the probe pack use.
|
||||
fn registry() -> &'static BlockRegistry {
|
||||
let ages: Vec<String> = (0..8).map(|a| a.to_string()).collect();
|
||||
let ages: Vec<&str> = ages.iter().map(String::as_str).collect();
|
||||
let levels: Vec<String> = (0..16).map(|a| a.to_string()).collect();
|
||||
let levels: Vec<&str> = levels.iter().map(String::as_str).collect();
|
||||
let water = [("level", &levels[..])];
|
||||
let stairs: &[(&str, &[&str])] =
|
||||
&[("facing", &["north", "east"]), ("half", &["bottom", "top"])];
|
||||
let blocks: Vec<(&str, &[(&str, &[&str])])> = vec![
|
||||
("minecraft:air", &[]),
|
||||
("minecraft:cave_air", &[]),
|
||||
("minecraft:void_air", &[]),
|
||||
("minecraft:lava", &water),
|
||||
("minecraft:cobblestone", &[]),
|
||||
("minecraft:blackstone", &[]),
|
||||
("minecraft:deepslate", &[]),
|
||||
("minecraft:water", &water),
|
||||
("minecraft:spruce_planks", &[]),
|
||||
("minecraft:chest", &[]),
|
||||
("minecraft:oak_log", &[("axis", &["y", "x"])]),
|
||||
("minecraft:stripped_cherry_log", &[("axis", &["y", "x"])]),
|
||||
("minecraft:oak_stairs", stairs),
|
||||
("minecraft:warped_stairs", stairs),
|
||||
];
|
||||
let wheat = [("age", &ages[..])];
|
||||
let beetroots = [("age", &ages[..4])];
|
||||
let mut rows = Vec::new();
|
||||
let mut defaults = BTreeMap::new();
|
||||
let crops: [(&str, &[(&str, &[&str])]); 3] = [
|
||||
("minecraft:wheat", &wheat),
|
||||
("minecraft:carrots", &wheat),
|
||||
("minecraft:beetroots", &beetroots),
|
||||
];
|
||||
for (name, properties) in blocks.into_iter().chain(crops) {
|
||||
defaults.insert(name, rows.len());
|
||||
rows.extend(states(name, properties));
|
||||
}
|
||||
let facts = json!({"states": rows, "defaults": defaults});
|
||||
let registry = BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap();
|
||||
Box::leak(Box::new(registry))
|
||||
}
|
||||
|
||||
/// The world a piece is placed in: one block everywhere.
|
||||
struct World {
|
||||
seed: i64,
|
||||
block: BlockStateId,
|
||||
}
|
||||
impl PlacementContext for World {
|
||||
fn world_seed(&mut self) -> Result<i64, PlacementQueryError> {
|
||||
Ok(self.seed)
|
||||
}
|
||||
fn block_state(&mut self, _: [i32; 3]) -> Result<BlockStateId, PlacementQueryError> {
|
||||
Ok(self.block)
|
||||
}
|
||||
fn generation(&self) -> WorldGenerationContext {
|
||||
WorldGenerationContext::new(-64, 384, 63)
|
||||
}
|
||||
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
|
||||
Ok(-64)
|
||||
}
|
||||
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
|
||||
Err(PlacementQueryError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
struct Lists {
|
||||
registry: &'static BlockRegistry,
|
||||
resources: StructureResources,
|
||||
}
|
||||
impl Lists {
|
||||
fn new(lists: &[(&str, Value)]) -> Self {
|
||||
let registry = registry();
|
||||
let lists = (lists.iter())
|
||||
.map(|(id, list)| ((*id).to_owned(), list.to_string()))
|
||||
.collect();
|
||||
let resources = StructureResources::new(
|
||||
registry,
|
||||
StateTransforms::default(),
|
||||
std::path::Path::new("/nonexistent"),
|
||||
lists,
|
||||
&BTreeMap::new(),
|
||||
);
|
||||
Self {
|
||||
registry,
|
||||
resources,
|
||||
}
|
||||
}
|
||||
fn id(&self, state: &str) -> BlockStateId {
|
||||
parse_state_string(self.registry, state).unwrap()
|
||||
}
|
||||
fn decode(&self, processors: Value) -> Result<ProcessorList, crate::worldgen::DecodeError> {
|
||||
let bindings = FeatureBlockBindings::new(self.registry, BTreeSet::new()).unwrap();
|
||||
ProcessorList::decode(&processors.to_string(), &self.resources, &bindings)
|
||||
}
|
||||
/// The block the list makes of template block `state` at `pos` of the
|
||||
/// piece at `piece`, in a world of `world`: None when it is not placed.
|
||||
fn run(
|
||||
&self,
|
||||
list: &ProcessorList,
|
||||
world: (i64, &str),
|
||||
piece: [i32; 3],
|
||||
pos: [i32; 3],
|
||||
state: &str,
|
||||
) -> Option<BlockInfo> {
|
||||
let mut world = World {
|
||||
seed: world.0,
|
||||
block: self.id(world.1),
|
||||
};
|
||||
let info = BlockInfo {
|
||||
pos,
|
||||
state: self.id(state),
|
||||
nbt: None,
|
||||
};
|
||||
let mut settings = PlaceSettings::new(Rotation::None);
|
||||
process_list(
|
||||
list,
|
||||
&mut world,
|
||||
&self.resources,
|
||||
piece,
|
||||
piece,
|
||||
[0; 3],
|
||||
info,
|
||||
&mut settings,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn name(&self, info: Option<BlockInfo>) -> String {
|
||||
match info {
|
||||
None => "none".into(),
|
||||
Some(info) => {
|
||||
let s = self.registry.state(info.state).unwrap();
|
||||
let props: Vec<String> = s
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
match props.is_empty() {
|
||||
true => s.name.clone(),
|
||||
false => format!("{}[{}]", s.name, props.join(",")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `name(run(...))` for one template block in an air world of seed 0.
|
||||
fn out(&self, list: &ProcessorList, state: &str) -> String {
|
||||
self.name(self.run(list, (0, "minecraft:air"), [0; 3], [0; 3], state))
|
||||
}
|
||||
}
|
||||
|
||||
fn swap(blocks: Value) -> Value {
|
||||
json!({"processor_type": "lithostitched:block_swap", "blocks": blocks})
|
||||
}
|
||||
fn set_block(provider: Value) -> Value {
|
||||
json!({"processor_type": "lithostitched:set_block", "state_provider": provider})
|
||||
}
|
||||
fn simple(name: &str) -> Value {
|
||||
json!({"type": "minecraft:simple_state_provider", "state": {"id": name}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_swap_keeps_the_properties_the_new_block_can_have() {
|
||||
let t = Lists::new(&[]);
|
||||
let list = t
|
||||
.decode(json!([swap(json!({
|
||||
"minecraft:wheat": "minecraft:carrots",
|
||||
"oak_stairs": "minecraft:warped_stairs",
|
||||
"minecraft:cobblestone": "natures_spirit:missing"}))]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
t.out(&list, "minecraft:wheat[age=7]"),
|
||||
"minecraft:carrots[age=7]"
|
||||
);
|
||||
assert_eq!(
|
||||
t.out(&list, "minecraft:oak_stairs[facing=east,half=top]"),
|
||||
"minecraft:warped_stairs[facing=east,half=top]"
|
||||
);
|
||||
// A block the game lacks leaves the template's; others pass.
|
||||
assert_eq!(
|
||||
t.out(&list, "minecraft:cobblestone"),
|
||||
"minecraft:cobblestone"
|
||||
);
|
||||
assert_eq!(
|
||||
t.out(&list, "minecraft:oak_log[axis=x]"),
|
||||
"minecraft:oak_log[axis=x]"
|
||||
);
|
||||
// Beetroots' age stops at 3: ripe wheat gives the default (spec-ctov §4).
|
||||
let beet = t
|
||||
.decode(json!([swap(
|
||||
json!({"minecraft:wheat": "minecraft:beetroots"})
|
||||
)]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
t.out(&beet, "minecraft:wheat[age=7]"),
|
||||
"minecraft:beetroots[age=0]"
|
||||
);
|
||||
assert_eq!(
|
||||
t.out(&beet, "minecraft:wheat[age=2]"),
|
||||
"minecraft:beetroots[age=2]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_run_the_named_lists_in_turn() {
|
||||
let t = Lists::new(&[
|
||||
(
|
||||
"t:logs",
|
||||
json!({"processors": [swap(json!({"minecraft:oak_log": "minecraft:stripped_cherry_log"}))]}),
|
||||
),
|
||||
(
|
||||
"t:stone",
|
||||
json!([swap(
|
||||
json!({"minecraft:stripped_cherry_log": "minecraft:cobblestone"})
|
||||
)]),
|
||||
),
|
||||
(
|
||||
"t:discard",
|
||||
json!([{"processor_type": "lithostitched:discard_input"}]),
|
||||
),
|
||||
]);
|
||||
let reference = |lists: Value| json!([{"processor_type": "lithostitched:reference", "processor_lists": lists}]);
|
||||
let one = t.decode(reference(json!("t:logs"))).unwrap();
|
||||
assert_eq!(
|
||||
t.out(&one, "minecraft:oak_log[axis=x]"),
|
||||
"minecraft:stripped_cherry_log[axis=x]"
|
||||
);
|
||||
let both = t.decode(reference(json!(["t:logs", "t:stone"]))).unwrap();
|
||||
assert_eq!(t.out(&both, "minecraft:oak_log"), "minecraft:cobblestone");
|
||||
let reversed = t.decode(reference(json!(["t:stone", "t:logs"]))).unwrap();
|
||||
assert_eq!(
|
||||
t.out(&reversed, "minecraft:oak_log"),
|
||||
"minecraft:stripped_cherry_log[axis=y]"
|
||||
);
|
||||
// discard_input: the template block is not placed, and the lists after it do not run.
|
||||
let discard = t.decode(reference(json!(["t:discard", "t:logs"]))).unwrap();
|
||||
assert_eq!(t.out(&discard, "minecraft:oak_log"), "none");
|
||||
// The game has no processor list tags; unknown lists are errors.
|
||||
assert!(t.decode(reference(json!(["#t:palettes"]))).is_err());
|
||||
assert!(t.decode(reference(json!("t:missing"))).is_err());
|
||||
}
|
||||
|
||||
/// The probe pack's nested condition (spec-lithostitched §7.3): template
|
||||
/// cobblestone becomes blackstone or deepslate, half each.
|
||||
#[test]
|
||||
fn conditions_run_then_or_else() {
|
||||
let t = Lists::new(&[]);
|
||||
let inner = json!({"processor_type": "lithostitched:condition", "random_mode": "per_block",
|
||||
"if_true": {"type": "lithostitched:random_chance", "chance": 0.5},
|
||||
"then": set_block(simple("minecraft:blackstone")),
|
||||
"else": [set_block(simple("minecraft:deepslate"))]});
|
||||
let list = t
|
||||
.decode(json!([{"processor_type": "lithostitched:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": [inner]}]))
|
||||
.unwrap();
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for i in 0..4000 {
|
||||
let out = t.run(
|
||||
&list,
|
||||
(1234, "minecraft:air"),
|
||||
[0; 3],
|
||||
[i % 64, i / 64, 7],
|
||||
"minecraft:cobblestone",
|
||||
);
|
||||
*counts.entry(t.name(out)).or_default() += 1;
|
||||
}
|
||||
assert_eq!(counts.len(), 2, "{counts:?}");
|
||||
assert!(
|
||||
(1850..2150).contains(&counts["minecraft:blackstone"]),
|
||||
"{counts:?}"
|
||||
);
|
||||
// Not cobblestone, and no `else`: the block as it is.
|
||||
assert_eq!(
|
||||
t.out(&list, "minecraft:oak_log"),
|
||||
"minecraft:oak_log[axis=y]"
|
||||
);
|
||||
|
||||
// `location` tests the world's block, with properties on the input.
|
||||
let water = t
|
||||
.decode(
|
||||
json!([{"processor_type": "lithostitched:condition", "random_mode": "per_piece",
|
||||
"if_true": {"type": "lithostitched:matching_blocks", "blocks": ["minecraft:water"],
|
||||
"match_type": "location"},
|
||||
"then": set_block(simple("minecraft:spruce_planks"))}]),
|
||||
)
|
||||
.unwrap();
|
||||
let at = |world| {
|
||||
t.name(t.run(
|
||||
&water,
|
||||
(0, world),
|
||||
[0; 3],
|
||||
[1, 2, 3],
|
||||
"minecraft:cobblestone",
|
||||
))
|
||||
};
|
||||
assert_eq!(at("minecraft:water"), "minecraft:spruce_planks");
|
||||
assert_eq!(at("minecraft:air"), "minecraft:cobblestone");
|
||||
let ripe = t
|
||||
.decode(json!([{"processor_type": "lithostitched:condition",
|
||||
"if_true": {"type": "lithostitched:all_of", "conditions": [
|
||||
{"type": "lithostitched:true"},
|
||||
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:wheat", "properties": {"age": "7"}},
|
||||
{"type": "lithostitched:not", "condition": {"type": "lithostitched:any_of", "conditions": [
|
||||
{"type": "lithostitched:random_chance", "chance": 0.0},
|
||||
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:water", "match_type": "location"}]}}]},
|
||||
"then": swap(json!({"minecraft:wheat": "minecraft:carrots"}))}]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
t.out(&ripe, "minecraft:wheat[age=7]"),
|
||||
"minecraft:carrots[age=7]"
|
||||
);
|
||||
assert_eq!(
|
||||
t.out(&ripe, "minecraft:wheat[age=6]"),
|
||||
"minecraft:wheat[age=6]"
|
||||
);
|
||||
let bad = json!([{"processor_type": "lithostitched:condition", "random_mode": "per_chunk",
|
||||
"if_true": {"type": "lithostitched:true"}}]);
|
||||
assert!(t.decode(bad).is_err());
|
||||
}
|
||||
|
||||
/// `apply_random` (spec-lithostitched §7.3: crimson 1 : birch 3, one
|
||||
/// choice per piece): here blackstone 1 : deepslate 3.
|
||||
#[test]
|
||||
fn apply_random_draws_per_piece_or_per_block() {
|
||||
let t = Lists::new(&[(
|
||||
"t:deepslate",
|
||||
json!([swap(
|
||||
json!({"minecraft:cobblestone": "minecraft:deepslate"})
|
||||
)]),
|
||||
)]);
|
||||
let random = |mode: &str| {
|
||||
t.decode(json!([{"processor_type": "lithostitched:apply_random", "mode": mode,
|
||||
"processor_lists": [
|
||||
{"weight": 1, "data": [swap(json!({"minecraft:cobblestone": "minecraft:blackstone"}))]},
|
||||
{"weight": 3, "data": "t:deepslate"}]}]))
|
||||
.unwrap()
|
||||
};
|
||||
let (per_piece, per_block) = (random("per_piece"), random("per_block"));
|
||||
let outcomes = |list: &ProcessorList, seed: i64, piece: [i32; 3]| -> Vec<String> {
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
let pos = [piece[0] + i, piece[1], piece[2] + 3 * i];
|
||||
t.name(t.run(
|
||||
list,
|
||||
(seed, "minecraft:air"),
|
||||
piece,
|
||||
pos,
|
||||
"minecraft:cobblestone",
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut blackstone_pieces = 0;
|
||||
for p in 0..400 {
|
||||
let piece = [16 * p, 70, -40 * p];
|
||||
let blocks = outcomes(&per_piece, 1234, piece);
|
||||
assert!(blocks.iter().all(|b| *b == blocks[0]), "{blocks:?}");
|
||||
blackstone_pieces += u32::from(blocks[0] == "minecraft:blackstone");
|
||||
// The same piece and seed give the same blocks.
|
||||
assert_eq!(outcomes(&per_piece, 1234, piece), blocks);
|
||||
}
|
||||
assert!(
|
||||
(70..130).contains(&blackstone_pieces),
|
||||
"{blackstone_pieces}"
|
||||
);
|
||||
// Per block, one piece mixes both.
|
||||
let mixed: BTreeSet<String> = (0..10)
|
||||
.flat_map(|p| outcomes(&per_block, 1234, [p, 0, 0]))
|
||||
.collect();
|
||||
assert_eq!(mixed.len(), 2, "{mixed:?}");
|
||||
// A plain list: every entry weight 1. An entry list holding nothing is a no-op.
|
||||
let plain = t
|
||||
.decode(
|
||||
json!([{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": ["t:deepslate", []]}]),
|
||||
)
|
||||
.unwrap();
|
||||
let seen: BTreeSet<String> = (0..10)
|
||||
.flat_map(|p| outcomes(&plain, 9, [p, 0, 0]))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
BTreeSet::from(["minecraft:cobblestone".into(), "minecraft:deepslate".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_block_runs_its_provider() {
|
||||
let t = Lists::new(&[]);
|
||||
let stairs = "minecraft:oak_stairs[facing=east,half=top]";
|
||||
let warped = |preserve: Value| {
|
||||
let mut p = set_block(simple("minecraft:warped_stairs"));
|
||||
if !preserve.is_null() {
|
||||
p["preserve_state"] = preserve;
|
||||
}
|
||||
t.decode(json!([p])).unwrap()
|
||||
};
|
||||
// preserve_state (true by default) keeps the properties both blocks have.
|
||||
assert_eq!(
|
||||
t.out(&warped(Value::Null), stairs),
|
||||
"minecraft:warped_stairs[facing=east,half=top]"
|
||||
);
|
||||
assert_eq!(
|
||||
t.out(&warped(json!(false)), stairs),
|
||||
"minecraft:warped_stairs[facing=north,half=bottom]"
|
||||
);
|
||||
|
||||
let weighted = t
|
||||
.decode(json!([set_block(
|
||||
json!({"type": "minecraft:weighted_state_provider", "entries": [
|
||||
{"weight": 1, "data": {"id": "minecraft:blackstone"}},
|
||||
{"weight": 3, "data": {"id": "minecraft:deepslate"}}]})
|
||||
)]))
|
||||
.unwrap();
|
||||
let blackstone = (0..2000)
|
||||
.filter(|i| {
|
||||
let out = t.run(
|
||||
&weighted,
|
||||
(42, "minecraft:air"),
|
||||
[0; 3],
|
||||
[*i, 0, -*i],
|
||||
"minecraft:cobblestone",
|
||||
);
|
||||
t.name(out) == "minecraft:blackstone"
|
||||
})
|
||||
.count();
|
||||
assert!((420..580).contains(&blackstone), "{blackstone}");
|
||||
|
||||
// block_entity_modifier gives the output its block entity.
|
||||
let mut loot = set_block(simple("minecraft:chest"));
|
||||
loot["block_entity_modifier"] =
|
||||
json!({"type": "minecraft:append_loot", "loot_table": "t:chests/house"});
|
||||
let out = t
|
||||
.run(
|
||||
&t.decode(json!([loot])).unwrap(),
|
||||
(0, "minecraft:air"),
|
||||
[0; 3],
|
||||
[0; 3],
|
||||
"minecraft:air",
|
||||
)
|
||||
.unwrap();
|
||||
let table = out
|
||||
.nbt
|
||||
.as_ref()
|
||||
.and_then(|n| n.get(&JavaString::from("LootTable")));
|
||||
assert_eq!(table, Some(&Tag::String("t:chests/house".into())));
|
||||
|
||||
// Providers the game does not run are refused.
|
||||
assert!(
|
||||
t.decode(json!([set_block(
|
||||
json!({"type": "minecraft:noise_provider"})
|
||||
)]))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
//! Lithostitched's processors that pick processors, carried as data
|
||||
//! (`horizon:apply_random` and `horizon:condition`): one runs a processor
|
||||
//! list drawn by weight, the other one list or another by a test of the
|
||||
//! block. Both draw from Lithostitched's RandomSettings: a random seeded from
|
||||
//! the world seed and a name, at the block, the piece or the structure.
|
||||
//! `horizon:set_block` is its `set_block`, which the conditions mostly run.
|
||||
use super::{Processor, ProcessorList};
|
||||
use crate::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
worldgen::{DecodeError, block_feature::FeatureBlockBindings, structure::StructureResources},
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, PositionalRandom, RandomSource},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
/// RandomSettings: where the random is taken, and the name salting it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RandomSettings {
|
||||
mode: Mode,
|
||||
/// ResourceLocation.hashCode of the name (`lithostitched:default`).
|
||||
salt: i32,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Mode {
|
||||
Block,
|
||||
Piece,
|
||||
Structure,
|
||||
}
|
||||
impl RandomSettings {
|
||||
/// A mode alone, or `{"mode": ..., "name": ...}`; per block when unset.
|
||||
fn decode(v: Option<&Value>) -> Result<Self, DecodeError> {
|
||||
let mode = Mode::decode;
|
||||
let (mode, name) = match v {
|
||||
None => (Mode::Block, "lithostitched:default"),
|
||||
Some(Value::String(m)) => (mode(Some(m))?, "lithostitched:default"),
|
||||
Some(o) => (
|
||||
mode(o["mode"].as_str())?,
|
||||
o["name"]
|
||||
.as_str()
|
||||
.ok_or(DecodeError::Field("random name"))?,
|
||||
),
|
||||
};
|
||||
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
Ok(Self {
|
||||
mode,
|
||||
salt: java_hash(namespace)
|
||||
.wrapping_mul(31)
|
||||
.wrapping_add(java_hash(path)),
|
||||
})
|
||||
}
|
||||
/// RandomSource.create(seed + hash).forkPositional().at(the chosen
|
||||
/// position): the piece's position, the structure's pivot or the block.
|
||||
pub(super) fn create(
|
||||
&self,
|
||||
seed: i64,
|
||||
piece: [i32; 3],
|
||||
pivot: [i32; 3],
|
||||
block: [i32; 3],
|
||||
) -> LegacyRandom {
|
||||
let [x, y, z] = self.mode.select(piece, pivot, block);
|
||||
LegacyRandom::new(seed.wrapping_add(i64::from(self.salt)))
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(x, y, z))
|
||||
}
|
||||
}
|
||||
impl Mode {
|
||||
fn decode(m: Option<&str>) -> Result<Self, DecodeError> {
|
||||
match m {
|
||||
None | Some("per_block") => Ok(Self::Block),
|
||||
Some("per_piece") => Ok(Self::Piece),
|
||||
Some("per_structure") => Ok(Self::Structure),
|
||||
_ => Err(DecodeError::Field("random mode")),
|
||||
}
|
||||
}
|
||||
fn select(self, piece: [i32; 3], pivot: [i32; 3], block: [i32; 3]) -> [i32; 3] {
|
||||
match self {
|
||||
Self::Block => block,
|
||||
Self::Piece => piece,
|
||||
Self::Structure => pivot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SetBlockProcessor with a simple or weighted state provider and no block
|
||||
/// entity change: the block becomes the state, keeping the properties it
|
||||
/// shares with the old one unless `preserve_state` is off.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SetBlock {
|
||||
/// The states and their weights; one state alone is a simple provider,
|
||||
/// which draws nothing.
|
||||
states: Vec<(BlockStateId, i32)>,
|
||||
weighted: bool,
|
||||
preserve: bool,
|
||||
mode: Mode,
|
||||
}
|
||||
impl SetBlock {
|
||||
pub(super) fn decode(
|
||||
v: &Value,
|
||||
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let provider = &v["state_provider"];
|
||||
let (states, weighted) = match provider["type"].as_str() {
|
||||
Some("minecraft:simple_state_provider") => {
|
||||
(vec![(state(&provider["state"])?, 1)], false)
|
||||
}
|
||||
Some("minecraft:weighted_state_provider") => (
|
||||
provider["entries"]
|
||||
.as_array()
|
||||
.ok_or(DecodeError::Field("set_block entries"))?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let weight = e["weight"]
|
||||
.as_i64()
|
||||
.ok_or(DecodeError::Field("set_block weight"))?;
|
||||
Ok((state(&e["data"])?, weight as i32))
|
||||
})
|
||||
.collect::<Result<_, DecodeError>>()?,
|
||||
true,
|
||||
),
|
||||
_ => return Err(DecodeError::Field("set_block state_provider")),
|
||||
};
|
||||
if states.is_empty() {
|
||||
return Err(DecodeError::Field("set_block entries"));
|
||||
}
|
||||
Ok(Self {
|
||||
states,
|
||||
weighted,
|
||||
preserve: v["preserve_state"].as_bool().unwrap_or(true),
|
||||
mode: Mode::decode(v["random_mode"].as_str())?,
|
||||
})
|
||||
}
|
||||
/// The state to set, before `preserve`: drawn with
|
||||
/// RandomSource.create(seed).forkPositional().at(the chosen position).
|
||||
pub(super) fn state(
|
||||
&self,
|
||||
seed: i64,
|
||||
piece: [i32; 3],
|
||||
pivot: [i32; 3],
|
||||
block: [i32; 3],
|
||||
) -> BlockStateId {
|
||||
if !self.weighted {
|
||||
return self.states[0].0;
|
||||
}
|
||||
let [x, y, z] = self.mode.select(piece, pivot, block);
|
||||
let mut random = LegacyRandom::new(seed)
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(x, y, z));
|
||||
let total: i32 = self.states.iter().map(|s| s.1).sum();
|
||||
let mut value = random.next_int_bound(total.max(1)).unwrap_or(0);
|
||||
for (state, weight) in &self.states {
|
||||
if value < *weight {
|
||||
return *state;
|
||||
}
|
||||
value -= weight;
|
||||
}
|
||||
self.states[0].0
|
||||
}
|
||||
pub(super) fn preserves(&self) -> bool {
|
||||
self.preserve
|
||||
}
|
||||
}
|
||||
|
||||
/// String.hashCode over UTF-16 units.
|
||||
fn java_hash(s: &str) -> i32 {
|
||||
s.encode_utf16()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
|
||||
}
|
||||
|
||||
fn list(
|
||||
v: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<ProcessorList, DecodeError> {
|
||||
ProcessorList::decode(&v.to_string(), resources, bindings)
|
||||
}
|
||||
|
||||
/// ApplyRandomStructureProcessor: a weighted list of processor lists, one
|
||||
/// drawn per block with `nextInt(total)`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ApplyRandom {
|
||||
pub(super) random: RandomSettings,
|
||||
lists: Vec<(ProcessorList, i32)>,
|
||||
total: i32,
|
||||
}
|
||||
impl ApplyRandom {
|
||||
pub(super) fn decode(
|
||||
v: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
let lists = v["processor_lists"]
|
||||
.as_array()
|
||||
.ok_or(DecodeError::Field("apply_random processor_lists"))?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let weight = e["weight"]
|
||||
.as_i64()
|
||||
.ok_or(DecodeError::Field("apply_random weight"))?;
|
||||
Ok((list(&e["data"], resources, bindings)?, weight as i32))
|
||||
})
|
||||
.collect::<Result<Vec<_>, DecodeError>>()?;
|
||||
Ok(Self {
|
||||
random: RandomSettings::decode(v.get("mode"))?,
|
||||
total: lists.iter().map(|l| l.1).sum(),
|
||||
lists,
|
||||
})
|
||||
}
|
||||
/// WeightedList.getRandom: none when the weights add up to 0.
|
||||
pub(super) fn pick(&self, random: &mut impl RandomSource) -> Option<&ProcessorList> {
|
||||
if self.total <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut value = random.next_int_bound(self.total).unwrap_or(0);
|
||||
for (list, weight) in &self.lists {
|
||||
if value < *weight {
|
||||
return Some(list);
|
||||
}
|
||||
value -= weight;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// ConditionProcessor: `then` when the condition holds for the block, else
|
||||
/// `else`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Conditional {
|
||||
pub(super) random: RandomSettings,
|
||||
pub(super) condition: Condition,
|
||||
pub(super) then: ProcessorList,
|
||||
pub(super) otherwise: ProcessorList,
|
||||
}
|
||||
impl Conditional {
|
||||
pub(super) fn decode(
|
||||
v: &Value,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
) -> Result<Self, DecodeError> {
|
||||
// A processor alone or a list of them.
|
||||
let processors = |key: &str| -> Result<ProcessorList, DecodeError> {
|
||||
match v.get(key) {
|
||||
None => Ok(ProcessorList::default()),
|
||||
Some(Value::Object(p)) if p.contains_key("processor_type") => Ok(ProcessorList(
|
||||
vec![Processor::decode(&v[key], resources, bindings)?],
|
||||
)),
|
||||
Some(other) => list(other, resources, bindings),
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
random: RandomSettings::decode(v.get("random_mode"))?,
|
||||
condition: Condition::decode(&v["if_true"], bindings)?,
|
||||
then: processors("then")?,
|
||||
otherwise: processors("else")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Lithostitched's processor conditions the game runs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum Condition {
|
||||
True,
|
||||
AllOf(Vec<Condition>),
|
||||
AnyOf(Vec<Condition>),
|
||||
Not(Box<Condition>),
|
||||
RandomChance(f32),
|
||||
/// The block placed (`input`) or the one in the world (`location`) is
|
||||
/// one of these, with these property values.
|
||||
MatchingBlocks {
|
||||
blocks: Arc<BTreeSet<BlockStateId>>,
|
||||
properties: Vec<(String, String)>,
|
||||
location: bool,
|
||||
},
|
||||
}
|
||||
impl Condition {
|
||||
fn decode(v: &Value, bindings: &FeatureBlockBindings<'_>) -> Result<Self, DecodeError> {
|
||||
let all = |a: &Value| -> Result<Vec<Condition>, DecodeError> {
|
||||
a.as_array()
|
||||
.ok_or(DecodeError::Field("processor conditions"))?
|
||||
.iter()
|
||||
.map(|c| Self::decode(c, bindings))
|
||||
.collect()
|
||||
};
|
||||
if v.is_array() {
|
||||
return Ok(Self::AllOf(all(v)?));
|
||||
}
|
||||
let kind = v["type"].as_str().unwrap_or_default();
|
||||
Ok(match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
|
||||
"true" => Self::True,
|
||||
"all_of" => Self::AllOf(all(&v["conditions"])?),
|
||||
"any_of" => Self::AnyOf(all(&v["conditions"])?),
|
||||
"not" => Self::Not(Box::new(Self::decode(&v["condition"], bindings)?)),
|
||||
"random_chance" => Self::RandomChance(super::float(v, "chance")?),
|
||||
"matching_blocks" => Self::MatchingBlocks {
|
||||
blocks: super::holder_set(&v["blocks"], bindings)?,
|
||||
properties: match v.get("properties") {
|
||||
None => Vec::new(),
|
||||
Some(p) => p
|
||||
.as_object()
|
||||
.ok_or(DecodeError::Field("matching_blocks properties"))?
|
||||
.iter()
|
||||
.map(|(k, x)| {
|
||||
let x = x
|
||||
.as_str()
|
||||
.ok_or(DecodeError::Field("matching_blocks property"))?;
|
||||
Ok((k.clone(), x.to_owned()))
|
||||
})
|
||||
.collect::<Result<_, DecodeError>>()?,
|
||||
},
|
||||
location: match v["match_type"].as_str() {
|
||||
None | Some("input") => false,
|
||||
Some("location") => true,
|
||||
_ => return Err(DecodeError::Field("matching_blocks match_type")),
|
||||
},
|
||||
},
|
||||
_ => return Err(DecodeError::Field("processor condition type")),
|
||||
})
|
||||
}
|
||||
/// ProcessorCondition.test with the block placed and the one there.
|
||||
pub(super) fn test(
|
||||
&self,
|
||||
input: BlockStateId,
|
||||
location: BlockStateId,
|
||||
registry: &BlockRegistry,
|
||||
random: &mut impl RandomSource,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::True => true,
|
||||
Self::AllOf(all) => all
|
||||
.iter()
|
||||
.all(|c| c.test(input, location, registry, random)),
|
||||
Self::AnyOf(any) => any
|
||||
.iter()
|
||||
.any(|c| c.test(input, location, registry, random)),
|
||||
Self::Not(c) => !c.test(input, location, registry, random),
|
||||
Self::RandomChance(chance) => random.next_float() < *chance,
|
||||
Self::MatchingBlocks {
|
||||
blocks,
|
||||
properties,
|
||||
location: at_location,
|
||||
} => {
|
||||
let state = if *at_location { location } else { input };
|
||||
blocks.contains(&state)
|
||||
&& properties.iter().all(|(k, x)| {
|
||||
registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get(k))
|
||||
.is_some_and(|v| v == x)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn random_settings_salt_and_position() {
|
||||
// ResourceLocation.hashCode: 31 * namespace + path.
|
||||
let default = RandomSettings::decode(Some(&"per_piece".into())).unwrap();
|
||||
assert_eq!(
|
||||
default.salt,
|
||||
java_hash("lithostitched")
|
||||
.wrapping_mul(31)
|
||||
.wrapping_add(java_hash("default"))
|
||||
);
|
||||
assert_eq!(java_hash("default"), 1544803905);
|
||||
let (seed, piece, pivot, block) = (12345, [16, 64, -32], [8, 70, 8], [17, 65, -30]);
|
||||
let mut ours = default.create(seed, piece, pivot, block);
|
||||
let mut theirs = LegacyRandom::new(seed + i64::from(default.salt))
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(16, 64, -32));
|
||||
assert_eq!(ours.next_long(), theirs.next_long());
|
||||
let named: Value = serde_json::json!({"mode": "per_structure", "name": "v:roof"});
|
||||
let named = RandomSettings::decode(Some(&named)).unwrap();
|
||||
assert_eq!(named.mode, Mode::Structure);
|
||||
assert_eq!(
|
||||
named.salt,
|
||||
java_hash("v")
|
||||
.wrapping_mul(31)
|
||||
.wrapping_add(java_hash("roof"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_random_draws_by_weight() {
|
||||
let apply = ApplyRandom {
|
||||
random: RandomSettings {
|
||||
mode: Mode::Block,
|
||||
salt: 0,
|
||||
},
|
||||
lists: vec![
|
||||
(ProcessorList(vec![Processor::Nop]), 3),
|
||||
(ProcessorList::default(), 1),
|
||||
],
|
||||
total: 4,
|
||||
};
|
||||
let mut ours = LegacyRandom::new(9);
|
||||
let mut theirs = LegacyRandom::new(9);
|
||||
for _ in 0..32 {
|
||||
let draw = theirs.next_int_bound(4).unwrap();
|
||||
let picked = apply.pick(&mut ours).unwrap();
|
||||
assert_eq!(picked.0.len(), usize::from(draw < 3));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_block_draws_from_the_unsalted_seed() {
|
||||
let (a, b) = (BlockStateId(1), BlockStateId(2));
|
||||
let set = SetBlock::decode(
|
||||
&serde_json::json!({"state_provider": {"type": "minecraft:weighted_state_provider",
|
||||
"entries": [{"weight": 1, "data": 1}, {"weight": 2, "data": 2}]},
|
||||
"random_mode": "per_piece"}),
|
||||
|v| Ok(BlockStateId(v.as_u64().unwrap() as u32)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(set.preserves());
|
||||
for x in 0..16 {
|
||||
let mut java = LegacyRandom::new(77)
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(x, 5, 3));
|
||||
let want = if java.next_int_bound(3).unwrap() < 1 {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
};
|
||||
assert_eq!(set.state(77, [x, 5, 3], [0; 3], [9, 9, 9]), want);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -1,7 +1,7 @@
|
||||
# The registry index and `.hpack`
|
||||
|
||||
Horizon's registry lists what is made for Horizon and reviewed: mods (`.hmod`), modpacks (`.hpack`), resource packs
|
||||
and, once their format exists, Horizon shader packs. Submissions are pull requests in
|
||||
and Horizon shader packs (`.hshader`, [shaders.md](../shaders.md)). Submissions are pull requests in
|
||||
[ludovic111/horizon-mods](https://github.com/ludovic111/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
|
||||
@@ -21,7 +21,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
"license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...",
|
||||
"versions": [{
|
||||
"version": "0.1.0", "published": "2026-10-24", "changelog": "",
|
||||
"api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "game": "26.3",
|
||||
"api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "api_level": 0, "game": "26.3",
|
||||
"file": { "name": "coords_hud-0.1.0.hmod", "url": "https://github.com/ludovic111/horizon-mods/releases/download/...",
|
||||
"size": 438, "sha256": "e113...", "signature": "untrusted comment: ...\n...\ntrusted comment: ...\n...\n" }
|
||||
}]
|
||||
@@ -43,6 +43,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
| `versions` | Newest first, at least one. |
|
||||
| `api` | A mod's API line (its manifest's `api`); 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. |
|
||||
| `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. |
|
||||
@@ -57,9 +58,11 @@ changing what one means, makes schema 2.
|
||||
|
||||
1. Its size and SHA-256 are the ones the entry lists, and it isn't revoked.
|
||||
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) and its
|
||||
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,
|
||||
mods); a resource pack's `pack.mcmeta`.
|
||||
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.
|
||||
@@ -99,7 +102,7 @@ own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mo
|
||||
|
||||
```toml
|
||||
id = "coords_hud" # the file's name, and the manifest's id
|
||||
kind = "mod" # mod | modpack | resourcepack; shader once its format exists
|
||||
kind = "mod" # mod | modpack | resourcepack | shader
|
||||
name = "Coordinates HUD"
|
||||
summary = "One line, at most 160 characters."
|
||||
description = """Markdown.""" # optional
|
||||
@@ -156,10 +159,14 @@ 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` is in the schema and stays closed until
|
||||
Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile
|
||||
the pack in a headless Horizon. A new kind gets its own check before it opens.
|
||||
Kinds open to submissions: `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
|
||||
opens.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-26: the launcher installs `shader` files into `shaderpacks/`; the sample index lists one (`glow`).
|
||||
- 2026-10-25: `shader` opens, checked by `horizon_shader_api::check`; its files are `.hshader`.
|
||||
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 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`.
|
||||
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
|
||||
|
||||
@@ -16,12 +16,22 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
|
||||
| `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 `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`
|
||||
- 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.
|
||||
@@ -390,6 +400,16 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `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. |
|
||||
|
||||
| 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. |
|
||||
@@ -400,6 +420,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `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. |
|
||||
|
||||
## `events`
|
||||
|
||||
@@ -423,3 +444,76 @@ What the game calls. Only the hooks `init` asks for are ever called.
|
||||
| `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. |
|
||||
|
||||
## `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. |
|
||||
|
||||
## `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 `registration`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `commands` | `list<string>` | Commands players may type, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
|
||||
| `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`. |
|
||||
|
||||
### record `block-pos`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `x` | `s32` | |
|
||||
| `y` | `s32` | |
|
||||
| `z` | `s32` | |
|
||||
|
||||
| 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) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. |
|
||||
| `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. |
|
||||
+16
-11
@@ -13,14 +13,14 @@ the player before the mod is turned on, and checked against the component's real
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Interfaces the game offers | 5: `log`, `game`, `chat`, `hud`, `storage` |
|
||||
| Functions in them | 9 |
|
||||
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat` |
|
||||
| Capabilities | 5: `game-read`, `chat`, `hud`, `storage`, `registries` |
|
||||
| Manifest `api` lines served | 2: `0.1` (code), `0.2` (content) |
|
||||
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers |
|
||||
| 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` (5 builders), `export_server_mod!` |
|
||||
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
|
||||
| Example mods | `coords-hud` (code), `ruby-content` (content) |
|
||||
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
|
||||
|
||||
## Guide
|
||||
|
||||
@@ -49,6 +49,8 @@ call, 4 MiB of storage, fuel per call).
|
||||
- `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.
|
||||
|
||||
@@ -78,15 +80,16 @@ scripting runtime in the game.
|
||||
## 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 come as their own line, `api = "0.3"`, experimental first.
|
||||
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:
|
||||
|
||||
- **Server mods** (`api = "0.3"`): the same component running on the server thread and the dedicated server, with world events (block
|
||||
broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed
|
||||
arguments.
|
||||
- **Server mods in the game** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): the
|
||||
contract, runtime and SDK are here; next the server thread, LAN host and dedicated server start them and call
|
||||
their hooks, then entity events, game rules and server commands with typed arguments.
|
||||
- **World and blocks**: read blocks, block states and biomes in loaded chunks; change them through the server's block
|
||||
log, so clients and the parity harnesses see vanilla's updates.
|
||||
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
|
||||
@@ -102,6 +105,8 @@ What real mods need next, in the order modpacks and servers need it:
|
||||
|
||||
## Changelog
|
||||
|
||||
- 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).
|
||||
|
||||
+3
-2
@@ -91,11 +91,12 @@ pack exist ([stability.md](stability.md)).
|
||||
|
||||
## Not there yet
|
||||
|
||||
The stages and everything that runs a pack (the format above is read and checked). The order: the renderer hooks shared with the format loader, then the material and post
|
||||
stages with options and hot reload, then the overlay, lighting, sky and shadow stages.
|
||||
The material stage in the game (the sky and post stages run, with hot reload), option values other than the defaults,
|
||||
then the overlay, lighting and shadow stages.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-26: the sky stage in the game: the pack's `sky` gets vanilla's colour along each ray.
|
||||
- 2026-10-26: the post stage in the game (`HORIZON_SHADER_PACK`, hot reload with the last good pack kept).
|
||||
- 2026-10-25: `horizon-shader` (`new`, `check`, `pack`), three templates, the example pack and the guide.
|
||||
- 2026-10-25: the check (naga validation, signatures, options, budgets, SPIR-V/Metal/HLSL translation).
|
||||
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user