mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
78
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 | ||
|
|
427b48aa42 | ||
|
|
71126d3e13 | ||
|
|
cad817c82d | ||
|
|
a68c2f587d | ||
|
|
6c3473c6e0 | ||
|
|
108ced5793 | ||
|
|
a1f1501305 | ||
|
|
cbdbf068bf | ||
|
|
70b832d658 | ||
|
|
3df60c21c2 | ||
|
|
ab92ab346c | ||
|
|
0bfb011ee2 | ||
|
|
2631eca00b | ||
|
|
bb3a289caa | ||
|
|
7dfd4c6280 | ||
|
|
31eecdc271 | ||
|
|
2d088739e5 | ||
|
|
a6548089c4 | ||
|
|
bbc57aa838 | ||
|
|
d143666e46 | ||
|
|
c7432c21e1 | ||
|
|
2c0d9cc30e | ||
|
|
f988ed417a | ||
|
|
21019afc2b | ||
|
|
785e83feeb | ||
|
|
9aa6d7723f | ||
|
|
e50a4c6443 | ||
|
|
bd09f8b03e | ||
|
|
df8b23637e | ||
|
|
390da26cf4 | ||
|
|
1285247230 | ||
|
|
bcd42f50e9 | ||
|
|
c9f554d1c4 | ||
|
|
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -378,12 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
MinimapShape,
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -402,13 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
Ok(MinimapSize),
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -418,32 +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::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",
|
||||
@@ -458,10 +424,9 @@ impl Item {
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -474,12 +439,6 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
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."
|
||||
}
|
||||
@@ -511,7 +470,7 @@ impl Item {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -521,8 +480,6 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
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,
|
||||
@@ -537,38 +494,22 @@ impl Item {
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
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 sort 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::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,
|
||||
@@ -712,12 +653,9 @@ mod tests {
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::MinimapCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.minimap_corner, Corner::TopLeft);
|
||||
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;
|
||||
@@ -2271,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.
|
||||
@@ -4193,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() {
|
||||
@@ -5600,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",
|
||||
@@ -5639,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -219,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.
|
||||
@@ -378,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]))?;
|
||||
@@ -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]) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the minimap's tiles and
|
||||
//! image, and its HUD; a mod's zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! 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};
|
||||
@@ -128,44 +128,29 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap that shows, the extra's or else a mod's
|
||||
/// (`render.set-minimap`): its corner, its side in GUI pixels and its
|
||||
/// view of the player.
|
||||
/// 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()?;
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let e = &self.settings.extras;
|
||||
let (corner, size, rotate, blocks_per_pixel, round) = if e.minimap {
|
||||
let size = f32::from(e.minimap_size);
|
||||
(
|
||||
e.minimap_corner,
|
||||
size,
|
||||
e.minimap_rotate,
|
||||
e.minimap_zoom,
|
||||
e.minimap_round,
|
||||
)
|
||||
} else {
|
||||
let m = self.mod_minimap()?;
|
||||
(m.corner, m.size, m.rotate, m.blocks_per_pixel, m.round)
|
||||
};
|
||||
let m = self.mod_minimap()?;
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
rotate,
|
||||
blocks_per_pixel,
|
||||
round,
|
||||
rotate: m.rotate,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
round: m.round,
|
||||
};
|
||||
Some((corner, size, view))
|
||||
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_shown() else {
|
||||
let wanted = self.settings.extras.minimap || self.mod_minimap().is_some();
|
||||
if !wanted || self.player.is_none() {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1192,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) => {
|
||||
@@ -1831,6 +1835,10 @@ 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();
|
||||
@@ -1916,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2176,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(());
|
||||
};
|
||||
@@ -3616,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 {
|
||||
@@ -4066,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);
|
||||
|
||||
@@ -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,7 @@ 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.
|
||||
@@ -2414,7 +2417,7 @@ 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,
|
||||
@@ -2924,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
|
||||
@@ -3003,20 +3011,20 @@ impl Renderer {
|
||||
/// 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_post::NativePost>,
|
||||
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(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_post) {
|
||||
let timer = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(post)) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
@@ -4046,8 +4054,8 @@ 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();
|
||||
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native_post)
|
||||
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
|
||||
@@ -4109,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,
|
||||
},
|
||||
})];
|
||||
@@ -4149,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);
|
||||
@@ -4505,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);
|
||||
@@ -4688,7 +4759,7 @@ 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_post) {
|
||||
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 {
|
||||
|
||||
@@ -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,12 +26,87 @@ 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>)>,
|
||||
start: std::time::Instant,
|
||||
frames: u32,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -63,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),
|
||||
@@ -95,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),
|
||||
@@ -145,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),
|
||||
@@ -162,15 +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,
|
||||
timing: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,37 +313,18 @@ 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) = &mut self.timing {
|
||||
t.begin(device, queue);
|
||||
if let Some(t) = timing.as_deref_mut() {
|
||||
t.mark(device, encoder, (world.0, self.format));
|
||||
}
|
||||
let Some((_, groups)) = &self.groups else {
|
||||
@@ -317,7 +347,7 @@ impl NativePost {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &attachments,
|
||||
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(label)),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
@@ -437,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;
|
||||
@@ -478,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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,11 @@ impl PassTiming {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
@@ -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; 3] = [
|
||||
pub const MOVED: [Moved; 4] = [
|
||||
Moved {
|
||||
id: "fps_overlay",
|
||||
name: "FPS counter",
|
||||
@@ -38,6 +38,10 @@ pub const MOVED: [Moved; 3] = [
|
||||
id: "zoom",
|
||||
name: "Zoom",
|
||||
},
|
||||
Moved {
|
||||
id: "minimap",
|
||||
name: "Minimap",
|
||||
},
|
||||
];
|
||||
|
||||
impl Moved {
|
||||
@@ -45,7 +49,8 @@ impl Moved {
|
||||
match self.id {
|
||||
"fps_overlay" => e.fps,
|
||||
"keystrokes" => e.keystrokes,
|
||||
_ => e.zoom,
|
||||
"zoom" => e.zoom,
|
||||
_ => e.minimap,
|
||||
}
|
||||
}
|
||||
/// The extra's settings as the mod keeps them (its storage keys).
|
||||
@@ -60,11 +65,21 @@ impl Moved {
|
||||
("corner", e.keystrokes_corner.name().into()),
|
||||
("cps", flag(e.keystrokes_cps)),
|
||||
],
|
||||
_ => vec![
|
||||
"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
|
||||
@@ -149,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));
|
||||
@@ -208,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(), 3);
|
||||
assert_eq!(nothing_to_do(&e).len(), 4);
|
||||
e.fps = true;
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.fps_lows = true;
|
||||
@@ -223,7 +238,7 @@ mod tests {
|
||||
);
|
||||
e.moved.push("fps_overlay".into());
|
||||
assert!(pending(&e).is_empty());
|
||||
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2]]);
|
||||
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;
|
||||
@@ -240,6 +255,23 @@ mod tests {
|
||||
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 => {}
|
||||
@@ -3315,7 +3377,7 @@ mod tests {
|
||||
s.extras
|
||||
);
|
||||
// In a window too short for the page, the wheel scrolls to its end.
|
||||
let short = (480.0, 300.0);
|
||||
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);
|
||||
@@ -4066,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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -412,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;
|
||||
@@ -422,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::{
|
||||
@@ -101,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`].
|
||||
@@ -115,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 },
|
||||
@@ -136,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),
|
||||
}
|
||||
@@ -165,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.
|
||||
@@ -221,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 {
|
||||
@@ -245,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);
|
||||
@@ -276,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)
|
||||
@@ -299,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
|
||||
@@ -311,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()?;
|
||||
@@ -327,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("."))
|
||||
@@ -357,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);
|
||||
@@ -373,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
|
||||
}
|
||||
@@ -397,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() {
|
||||
@@ -410,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>(
|
||||
@@ -457,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 }),
|
||||
@@ -521,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
|
||||
@@ -544,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 {
|
||||
@@ -781,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;
|
||||
}
|
||||
@@ -890,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`).
|
||||
|
||||
@@ -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(
|
||||
@@ -731,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]
|
||||
@@ -770,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]
|
||||
@@ -807,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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -434,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).
|
||||
|
||||
+17
-1
@@ -34,6 +34,22 @@ Not breaking: a new function, interface, capability, command, endpoint, tool or
|
||||
argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier
|
||||
don't import it); faster, or fixing behaviour to what the reference already says.
|
||||
|
||||
### Mod API levels
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
- the game refuses a mod that needs a function it doesn't have, with "made for a newer Horizon (it uses
|
||||
render.set-minimap): update Horizon to run it", instead of a linking error;
|
||||
- the registry's CI writes each mod version's level into the index (`api_level`), and installers take the newest
|
||||
version the running game can run.
|
||||
|
||||
A pull request that adds a function lists it in `api_level.rs` at the next level and raises `LEVEL`; the
|
||||
`api_reference` test fails until it does.
|
||||
|
||||
## Stability levels
|
||||
|
||||
Every part of an API has one level, shown in its reference and its surface page.
|
||||
@@ -92,7 +108,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Mod API: `render` (HUD shapes, camera shake and zoom; drawn by the game, no mod shaders) | experimental | manifest `experimental = ["render"]` |
|
||||
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
|
||||
| Server mods: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` |
|
||||
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
|
||||
| Shader API | not started | |
|
||||
|
||||
@@ -51,7 +51,7 @@ file selects ~103.
|
||||
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
|
||||
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
|
||||
| 36 | Audio and music (38) | `L/audio.rs`, `L/sound_engine.rs`, `L/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e --server-thread` (portals, levels, distance, lava) |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
|
||||
| 38 | Command parsing (34) | `L/command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`) | command_parse (corpus `tools/command_parse_corpus.py`) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `tools/perf_bench.py`, `L/telemetry.rs`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
|
||||
+14
-19
@@ -1,6 +1,6 @@
|
||||
# Horizon extras
|
||||
|
||||
Optional conveniences built into the client: fullbright, dynamic lights, a minimap, auto-reconnect,
|
||||
Optional conveniences built into the client: fullbright, dynamic lights, auto-reconnect,
|
||||
Mouse Tweaks-style inventory controls and inventory sorting. **Every one is off by default**: a fresh
|
||||
install looks and behaves exactly like vanilla 26.3, and turning an extra off
|
||||
again restores vanilla's behaviour completely.
|
||||
@@ -12,8 +12,8 @@ again restores vanilla's behaviour completely.
|
||||
| Settings, parsing and saving | `crates/horizon-client/src/extras.rs` |
|
||||
| FPS and click counters (for mods) | `extras.rs` (pure logic, unit tested) |
|
||||
| Dynamic lights: item glow, nearest sources | `crates/horizon-client/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) |
|
||||
| HUD drawing (minimap frame, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
|
||||
| Minimap colours, tiles and image | `crates/horizon-client/src/minimap.rs` |
|
||||
| HUD drawing (a mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
|
||||
| Minimap colours, tiles and image (for `render.set-minimap`) | `crates/horizon-client/src/minimap.rs` |
|
||||
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` |
|
||||
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` |
|
||||
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
|
||||
@@ -62,7 +62,7 @@ in Controls, and adding one would put a non-vanilla mapping into `options.txt`.
|
||||
|
||||
### Moved to the mod browser
|
||||
|
||||
The **FPS counter**, **Keystrokes** and **Zoom** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)),
|
||||
The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)),
|
||||
with the same look and options. On the first start after the update, a player who had one on gets the mod installed
|
||||
from Horizon's registry and turned on, with their corner and options carried over (Zoom's key too, into
|
||||
`horizon-mods.txt`, when it wasn't Z), and reads one line in the chat ("Zoom moved to the mod browser: installed for
|
||||
@@ -71,19 +71,14 @@ SHA-256, signature, manifest); if it fails (offline), the next start tries again
|
||||
keys and lists what was done in `movedToMods`. Code: `crates/horizon-client/src/moved_extras.rs`,
|
||||
`game/moved_extras.rs`.
|
||||
|
||||
### Minimap (`minimap`, default off)
|
||||
- A corner map (top right by default), square or round, small/medium/large,
|
||||
zoom 0.5 to 4 blocks per pixel, rotating with the player or north up, with the
|
||||
player arrow (vanilla's map decoration sprite).
|
||||
- Colours are vanilla's `MapColor` per block, as map items use, with the map
|
||||
item's height shading and water-depth dithering.
|
||||
- Underground, in the Nether and under any roof, it maps the floor around the
|
||||
player's height instead of the top block, so caves read sensibly.
|
||||
- Cost: one cached 16x16 tile per loaded chunk (packed map colour and height).
|
||||
Tiles are filled nearest first and refreshed when blocks change, within a time
|
||||
budget of 0.3 ms a frame; composing the 128x128 image is a few thousand
|
||||
lookups, done only when the view moved or a tile changed, and uploaded into a
|
||||
dedicated atlas slot. With the minimap off nothing runs.
|
||||
The game still draws the minimap, for the mod (`render.set-minimap`, [mods.md](mods.md)):
|
||||
- a corner map (top right by default), square or round, small/medium/large, zoom 0.5 to 4 blocks per pixel, rotating
|
||||
with the player or north up, with the player arrow (vanilla's map decoration sprite);
|
||||
- vanilla's `MapColor` per block, as map items use, with the map item's height shading and water-depth dithering;
|
||||
- underground, in the Nether and under any roof, the floor around the player's height instead of the top block;
|
||||
- one cached 16x16 tile per loaded chunk (packed map colour and height), filled nearest first and refreshed when
|
||||
blocks change within 0.3 ms a frame; the 128x128 image is recomposed only when the view moved or a tile changed, and
|
||||
uploaded into a dedicated atlas slot. With no mod showing a map, nothing runs.
|
||||
|
||||
### Auto-reconnect (`autoReconnect`, default off)
|
||||
- On the "Connection lost" screen after a multiplayer connection drops, a
|
||||
@@ -140,11 +135,11 @@ and the container screen tests are unchanged).
|
||||
the world, like vanilla's HUD. Extras in the top left corner give way to the
|
||||
debug screen (F3), which shows the FPS anyway.
|
||||
- Corners stack: extras placed in the same corner stack downward (top corners)
|
||||
or upward (bottom corners) in the order minimap, then mods' panels. The top
|
||||
or upward (bottom corners) in the order a mod's minimap, then mods' panels. The top
|
||||
right corner starts below the status effect icons.
|
||||
|
||||
## Perf budget
|
||||
|
||||
Off: a flag check per frame per extra (no allocations, no world access).
|
||||
On: the minimap stays under 0.3 ms of CPU
|
||||
A mod's minimap stays under 0.3 ms of CPU
|
||||
per frame (tile budget) plus a 64 KiB texture upload when its image changes.
|
||||
@@ -94,7 +94,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `respawn` | `respawn` | Respawn from the death screen | none |
|
||||
| `pause` | `pause` | Open the pause menu | none |
|
||||
| `resume` | `resume` | Leave the pause menu | none |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
|
||||
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
|
||||
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
|
||||
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |
|
||||
|
||||
@@ -321,7 +321,7 @@ Search for packs Horizon loads.
|
||||
horizon-launcher mods search [TEXT]...
|
||||
|
||||
- `TEXT`
|
||||
- `--kind` (mod, resource, modpack)
|
||||
- `--kind` (mod, resource, modpack, shader)
|
||||
- `--everything`: Also what was made for other versions or runs only partly, each marked
|
||||
- `--source` (modrinth, curseforge, horizon)
|
||||
|
||||
|
||||
+10
-6
@@ -1,7 +1,7 @@
|
||||
# The launcher's mod browser
|
||||
|
||||
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks, resource packs and shader
|
||||
packs, see how well each one runs before installing it, and install it with its checksum checked. This is item 16 of the roadmap and issue #577. The code is in
|
||||
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks and resource packs, and Horizon's
|
||||
registry for Horizon's own mods, modpacks, resource packs and shader packs; see how well each one runs before installing it, and install it with its checksum checked. This is item 16 of the roadmap and issue #577. The code is in
|
||||
`launcher/src/browser.rs` and `launcher/src/browser/`.
|
||||
|
||||
## What it offers
|
||||
@@ -16,6 +16,7 @@ yet (Modrinth has no `horizon` loader).
|
||||
| Java mods (Fabric, NeoForge, Forge, Quilt) | ported into `horizon-mods/<mod id>/` and turned on | Checked from its jar before install: **Works**, **Partly** or **Doesn't run** ([below](#java-mods)) |
|
||||
| Resource packs | `resourcepacks/` beside `options.txt` | **Works** when made for 26.3 ([resource-packs.md](resource-packs.md) lists what Horizon reads). Older packs are **Partly**: they load with vanilla's compatibility warning |
|
||||
| Modpacks (`.mrpack`, CurseForge zip) | the Modpacks page's import | **Partly**: Horizon runs the mods it can port, from the player's own downloads ([modpacks.md](modpacks.md)) |
|
||||
| Shader packs (`.hshader`, Horizon's registry only) | `shaderpacks/` beside `options.txt` | **Works** when made for 26.3: checked by the game's own check before install ([shaders.md](shaders.md)). The `shaders/`-format packs on Modrinth and CurseForge are not offered |
|
||||
|
||||
By default a search shows only what runs: Java mods made for 26.3, resource packs made for 26.3, and every modpack. **Show everything** lists the rest of the kind too, each marked with how well it runs; what doesn't run is
|
||||
never offered for install.
|
||||
@@ -74,7 +75,8 @@ start); the hedge places and its recipe crafts 4.
|
||||
|
||||
## The Mods page
|
||||
|
||||
The launcher's **Mods** page has the three kinds as tabs (Mods, Modpacks, Resource packs), a search field (Enter searches), **Show everything** and
|
||||
The launcher's **Mods** page has the four kinds as tabs (Mods, Modpacks, Resource packs, Shader packs; the last
|
||||
searches Horizon's registry), a search field (Enter searches), **Show everything** and
|
||||
the source: Modrinth, with **CurseForge (coming soon)** beside it, disabled. Each result is an index row: its summary, author,
|
||||
downloads, source and licence, how well it runs (and why), **Page** (the project's page on its source) and
|
||||
**Install** (**Import** for a modpack). The Modpacks tab lists the imported packs first, with their coverage
|
||||
@@ -125,7 +127,8 @@ same name the player put there is never overwritten.
|
||||
|
||||
- **Install** picks the newest version that runs, then its required dependencies, checked before any download: a
|
||||
dependency that isn't a pack Horizon loads (a Java mod) stops the install. Dependencies go in first. A resource
|
||||
pack still has to be turned on in the game's Resource Packs screen, as in vanilla.
|
||||
pack still has to be turned on in the game's Resource Packs screen, as in vanilla, and a shader pack picked in
|
||||
the game's Shader Packs page.
|
||||
- **Update** installs the newer version and removes the old file.
|
||||
- **Remove** deletes the file and its entry. Packs installed as dependencies stay.
|
||||
- **A modpack** is downloaded (checked the same way) into `ports/downloads/`, imported, and ported straight away
|
||||
@@ -149,7 +152,7 @@ horizon-launcher mods remove faithful-32x
|
||||
|
||||
## Sources
|
||||
|
||||
**Horizon's registry.** Mods (`.hmod`) and resource packs made for Horizon, reviewed as pull requests in
|
||||
**Horizon's registry.** Mods (`.hmod`), modpacks (`.hpack`), resource packs and shader packs (`.hshader`) made for Horizon, reviewed as pull requests in
|
||||
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) ([api/mod-index.md](api/mod-index.md)). The
|
||||
launcher reads its `index.json` (`browser/horizon.rs`; `HORIZON_MOD_INDEX=<file or https address>` reads a test
|
||||
registry instead, whose files may then be on disk) at most every ten minutes, and keeps the last copy in
|
||||
@@ -164,7 +167,8 @@ checked pack, `pack.json` and `home/`, the profile's game folder, where each of
|
||||
like any registry mod and turned on or off as the pack says; its overrides (settings, resource packs, a mod's saved
|
||||
settings) are copied in on the first install only, so the player's changes survive updates. A pack whose mod was taken
|
||||
down installs nothing. It is listed on the Modpacks tab with **Play** and **Remove**, and plays like an imported pack
|
||||
(`modpacks::apply_profile`). Shader packs are listed by the index but not installed by this launcher yet.
|
||||
(`modpacks::apply_profile`). A shader pack goes to `shaderpacks/<file>` once `check_file` has run the game's own
|
||||
check on it (`horizon_shader_api::check`); a taken-down one is named, for the player to remove.
|
||||
|
||||
```sh
|
||||
horizon-launcher mods search --source horizon --kind mod coords # with whether it is signed and what it may do
|
||||
|
||||
+16
-15
@@ -504,21 +504,22 @@ Two cases the oracle fills in:
|
||||
mod. Better Dungeons' small dungeons keep one chest and each other one with a fifth's
|
||||
chance, two at most (the game's `horizon:count_cap`, counted per placement as the mod's
|
||||
DungeonContext is): 12 chests in five small dungeons at seed 1234 become 6.
|
||||
Lithostitched's `block_swap` (137 lists, all in ChoiceTheorem's Overhauled Village) is the
|
||||
game's `horizon:block_swap`: the same map, with Block.withPropertiesOf's properties kept;
|
||||
a target block the game does not have (most of them are Nature's Spirit's and Croptopia's)
|
||||
leaves the block as it is, as the mod does. Lithostitched's `reference` (CTOV builds most
|
||||
of its lists from others) has the referenced lists' processors written in its place; its
|
||||
`apply_random`, `condition` and `set_block` are the game's `horizon:apply_random`,
|
||||
`horizon:condition` and `horizon:set_block`, with Lithostitched's random (the world seed
|
||||
plus the name's hash, at the block, piece or structure). In a CTOV plains village at seed
|
||||
1234 the farms now grow carrots, potatoes and beetroot besides wheat, and 1,655
|
||||
cobblestone blocks become paths, grass, mossy cobblestone and planks. No Lithostitched processor is
|
||||
dropped now (130 before). CTOV's compat hooks for other mods inside them (its
|
||||
`modular_compat`, 142 copies) run a list of their own only when the mod they name is
|
||||
loaded: carry writes that list in when the mod is ported along and takes the hook out (a
|
||||
no-op, as in the mod) when it is not. In ATM10 all 142 name Nature's Spirit, which the
|
||||
pack does not have.
|
||||
Lithostitched's processors (all in ChoiceTheorem's Overhauled Village in ATM10) are the
|
||||
game's own types, read as the mod writes them. `block_swap` keeps the properties the new
|
||||
block can have: a farm's ripe wheat becomes ripe carrots, but beetroot, whose age stops at
|
||||
3, keeps its default; a target block the game does not have (most are Nature's Spirit's and
|
||||
Croptopia's) leaves the block as it is. `reference` runs the lists it names, `condition`
|
||||
tests the template's block or the world's, `apply_random` and `set_block` draw per block or
|
||||
per piece as the data says. Lithostitched's own random is not known yet, so the shares of a
|
||||
random choice are the Java pack's but the blocks chosen differ. Lists named by tag and state
|
||||
providers other than simple and weighted ones are dropped and counted (ATM10 has none).
|
||||
CTOV's compat hooks for other mods (its `modular_compat`, 30 of them in 17 lists) become a
|
||||
`reference` when the mod they name is ported along, and are taken out (a no-op, as in the
|
||||
mod) when it is not. In ATM10 they all name Nature's Spirit, which the pack does not have.
|
||||
In Java, Lithostitched also lays out every jigsaw structure its own way (villages,
|
||||
outposts, trial chambers; CTOV's villages are `lithostitched:jigsaw`, carried as vanilla's
|
||||
jigsaw). Horizon keeps vanilla's layouts, so with Lithostitched in a pack, structure layouts
|
||||
differ from the Java pack's for the same seed (their positions do not).
|
||||
The processors that build outside their template are carried too: Repurposed's and
|
||||
Moog's pillar processors become the game's `horizon:pillar` (a column from a placeholder
|
||||
block down to the ground, with the processor list they run on each of its blocks written
|
||||
|
||||
+54
-3
@@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
@@ -120,7 +120,7 @@ loading. Ordered by what the first native ports need:
|
||||
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
|
||||
| `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints |
|
||||
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
|
||||
| `server` | world events (block break/place, entity spawn/death, player join), game rules, server commands | runs where the world is simulated |
|
||||
| `server` | world events (entity spawn/death), game rules, world read/write, typed server commands | after [server mods 0.3](#server-mods-api-03-experimental)'s players, chat, blocks and commands |
|
||||
| `net` | a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
|
||||
|
||||
There is no file, socket, process or raw-input interface, on purpose.
|
||||
@@ -251,7 +251,8 @@ key> --signature <file>.minisig`.
|
||||
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
|
||||
the sandbox the game uses, with no game and no window: the test plays the
|
||||
game's part (the player and the clock the mod reads, ticks, frames, chat lines,
|
||||
commands) and checks what the mod showed. A script fails at its first wrong
|
||||
commands; for a server mod, players joining, chatting, typing commands,
|
||||
breaking and placing blocks and leaving) and checks what the mod showed. A script fails at its first wrong
|
||||
step, with what it expected and what it got; the command fails if any script
|
||||
does. `--debug` builds without optimizing, `--hmod <file>` tests a `.hmod`
|
||||
already built, and a name runs only the scripts whose path holds it. Each
|
||||
@@ -288,6 +289,16 @@ A step's parts happen in this order:
|
||||
| `ticks = n`, `frames = n` | that many ticks (the game's tick counter and the clock move on), then frames 1/60 s apart |
|
||||
| `chat = "<Steve> hi"` | a chat line the player receives |
|
||||
| `command = "where north"` | the player types `/where north`; a command the mod doesn't own fails the step |
|
||||
| `break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }`, `place = { ... }` | server mods: the player breaks or places that block |
|
||||
| `leave = "Alex"` | server mods: that player leaves |
|
||||
|
||||
A server mod starts with nobody on: `join = "Alex"` (after `reload` and `title-screen`) brings a
|
||||
player in at the defaults, in survival, with the id
|
||||
`00000000-0000-4000-8000-000000000001` for the first name that joined, `...2`
|
||||
for the second, the same id when a name joins again. `player`, `chat`,
|
||||
`command`, `break` and `place` are then that player's doing: the one named by
|
||||
`as = "Steve"`, or the first one on. `player` takes `game-mode` there, and the
|
||||
script's own `[player]` and `[clock]` are a client mod's.
|
||||
|
||||
Then `expect` checks what the mod did during the step; a key left out is not
|
||||
checked:
|
||||
@@ -298,6 +309,7 @@ checked:
|
||||
| `chat = ["..."]` | every chat line it showed, in order (`[]`: none) |
|
||||
| `feedback = "..."`, `error = "..."` | the command's answer, shown plain or in red |
|
||||
| `panel = ["..."]`, `corner = "top-left"` | its HUD panel's lines (`[]`: no panel) and corner |
|
||||
| `told = ["Alex: Welcome!"]`, `broadcast = ["..."]` | server mods: every `players.tell` (as `name: text`) and `players.broadcast`, in order |
|
||||
| `stopped = "panic"` | the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test |
|
||||
|
||||
Unknown keys are errors, so a typo never passes silently. The same runner is
|
||||
@@ -452,6 +464,38 @@ none.
|
||||
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
|
||||
```
|
||||
|
||||
### Server mods (API 0.3, experimental)
|
||||
|
||||
A server mod runs where the world is simulated: on the dedicated server, on a
|
||||
LAN host and on single player's own server, never in a player's window. Its
|
||||
manifest says `side = "server"` and `api = "0.3"` (and only a server mod may);
|
||||
its component targets the WIT world `horizon-server-mod` instead of
|
||||
`horizon-mod`. Experimental: it may change in any release
|
||||
([stability](api/stability.md)).
|
||||
|
||||
| Interface | Capability | Functions |
|
||||
|---|---|---|
|
||||
| `log` | always | as for client mods |
|
||||
| `storage` | `storage` | as for client mods, in the server's `horizon-mods/` |
|
||||
| `players` | `players` | `online()`: id, name, position, dimension, game mode, health of each player on; `tell(id, text)`, `broadcast(text)`: system messages after the mod's name, 8 per call |
|
||||
|
||||
| Hook (export `server-events`) | When |
|
||||
|---|---|
|
||||
| `init() -> result<registration, string>` | once, after loading |
|
||||
| `tick(tick)` | each server tick |
|
||||
| `player-joined(player)`, `player-left(player)` | `registration.joins` |
|
||||
| `chat(player, text)` | `registration.chat`: each chat message a player sends |
|
||||
| `command(player, name, args)` | `/name args` typed by a player, for a command it registered; `ok(some)` is that player's feedback, `err` their red error |
|
||||
| `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` |
|
||||
|
||||
Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for
|
||||
client mods; `players` is for server mods. In Rust: `impl
|
||||
horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
|
||||
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs). The
|
||||
runtime, the registry's `check` and the SDK run them today; the game starts
|
||||
them once its server code calls these hooks (the next step), and until then a
|
||||
server mod that is turned on shows as run elsewhere.
|
||||
|
||||
## Publish your mod
|
||||
|
||||
Horizon's registry lists mods, modpacks (`.hpack`) and resource packs made for Horizon, each reviewed before it is
|
||||
@@ -490,6 +534,7 @@ so the same rustc and `Cargo.lock` give the same file.
|
||||
| FPS Counter | `fps_overlay` | Extras > FPS counter | `corner`: `top_left` (default), `top_right`, `bottom_left`, `bottom_right`; `lows`: `false` (default), `true` |
|
||||
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
|
||||
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
|
||||
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
|
||||
|
||||
## Versions and compatibility
|
||||
|
||||
@@ -500,6 +545,12 @@ so the same rustc and `Cargo.lock` give the same file.
|
||||
release cycle, so existing mods keep working while authors move.
|
||||
- The manifest's `api` names the line it needs (`0.1`); the game refuses a
|
||||
line it does not serve with a clear message.
|
||||
- Within a line, each function has an API level, the one it arrived at
|
||||
([stability](api/stability.md#mod-api-levels)). A mod needs the level of
|
||||
the newest function it calls, read from its file: an older Horizon refuses
|
||||
it with "made for a newer Horizon: update Horizon", and the registry lists
|
||||
the level so installers take a version the game can run.
|
||||
`cargo horizon-mod check` prints it ("needs mod API level 1 or newer").
|
||||
- Horizon follows Minecraft releases; the mod API does not have to. Vanilla
|
||||
concepts mods see (dimensions, block and item ids) are the game's own ids,
|
||||
so a version port changes the data, not the API.
|
||||
|
||||
@@ -111,3 +111,53 @@ and shrieker. They supplement the deterministic component comparisons.
|
||||
## Reading vanilla
|
||||
|
||||
Besides the Vineflower sources in `.local/minecraft/<version>/sources/`, `tools/rea_vanilla.py` queries the shipped bytecode through REA (callers through lambdas, overloads, a second decompiler); see [decompilation status](decompilation-status.md#querying-the-shipped-bytecode-with-rea).
|
||||
|
||||
## Player-facing integration and deterministic fixtures
|
||||
|
||||
`python3 tools/lan_e2e.py` runs every LAN check with an integrated server thread,
|
||||
the same path as normal single player. `python3 tools/test_friends.py --client
|
||||
target/fast/horizon-client --relay target/fast/horizon-relay --baseline` covers
|
||||
plain LAN, direct invitations, a local relay and ended invitations in that mode.
|
||||
Both accept `--in-frame` for comparison and retain `--server-thread` for existing
|
||||
callers. Run Linux games under `xvfb-run -a` when no display is available. Each
|
||||
process gets scratch game, launcher, XDG data and config homes; telemetry, audio
|
||||
and launcher self-update are disabled.
|
||||
|
||||
`lan_integration` and `friends_integration` register these suites with `parity.py`.
|
||||
`friends_integration` (under a minute) is selected like any harness when the
|
||||
drivers or game implementation change. `lan_integration` is the whole LAN suite
|
||||
(~40 minutes on a loaded runner, and single checks flake under load), so
|
||||
`tools/parity_hosts.json` lists it under `named_only`: no automatic selection
|
||||
(`--changed`, `--failed`, `--flake-check`, CI, parity-main) picks it; it runs
|
||||
when named and in the nightly audit (`--all`). LAN writes `results.json`
|
||||
with the host mode and each result beside its screenshots and logs. A skipped
|
||||
case is recorded explicitly and does not count as passing coverage.
|
||||
|
||||
The game-binary fixture audit keeps these complementary direct rule tests:
|
||||
|
||||
- `command_harness.rs`: command feedback, styled spans and result callbacks,
|
||||
held player position, explicit ticks and portal completion matching
|
||||
`VanillaCommandExecOracle`. LAN `commands`, `feedback`, `styles`, `levels` and
|
||||
`portals` cover command input, packet feedback and travel across the boundary.
|
||||
- `dispense_fixture.rs`: seeded synthetic worlds, block/event traces, item
|
||||
use/mining and combat hooks matching `VanillaDispenserOracle`. These isolate
|
||||
rule results and RNG order; LAN machines, crafting, clicks, fireball, mining
|
||||
and combat exercise real input and server handling separately. The new
|
||||
`dispenser_input_host` / `dispenser_input_guest` checks press a button and
|
||||
verify authoritative inventory consumption and exactly one drop on both clients.
|
||||
- `sculk_fixture.rs`: scripted events and exact per-tick sculk/warden traces
|
||||
matching `VanillaSculkOracle`; introducing wall-clock network ticks would
|
||||
destroy that deterministic oracle contract. `sculk_input_host` and
|
||||
`sculk_input_guest` separately exercise real footsteps reaching a sensor;
|
||||
failures remain visible rather than changing the oracle trace.
|
||||
- `net_harness.rs`: already a real network client of a dedicated server; keep
|
||||
its handshake, world loading, chat and command packet assertions.
|
||||
|
||||
Library probes, `lan_bot`'s protocol hub fixture, generation/save tests and
|
||||
streaming/mesh tests remain fast deterministic coverage. They do not become
|
||||
full game launches. Setup and authoritative reads use the owning server;
|
||||
interactions stay on the client. `server-hold on|off` provides a prediction-test
|
||||
barrier between server ticks, including player packet handling, while transport
|
||||
threads and control calls remain responsive. `stall MILLISECONDS` stalls only
|
||||
the window to test recovery from a hung frame without process signals interrupting
|
||||
socket reads. Neither mechanism changes normal gameplay.
|
||||
+14
-11
@@ -13,8 +13,9 @@ Horizon's own format.
|
||||
| Authoring tool | `horizon-shader` (same crate) | `new`, `check`, `pack` |
|
||||
| Templates | `crates/horizon-shader-api/templates/` | post, sky, material |
|
||||
| Example pack | `crates/horizon-shader-api/packs/golden_hour` | a warm sky and two post passes |
|
||||
| In the game: the post stage | `HORIZON_SHADER_PACK=<name>` (for development, until the Shader Packs page) | post passes, hot reload |
|
||||
| In the game: sky and material; the launcher and the registry | | not yet |
|
||||
| In the game: the sky and post stages | Options > Horizon Settings > Shader Packs | sky, post passes, hot reload |
|
||||
| The registry and the launcher | `shader` kind ([api/mod-index.md](api/mod-index.md)); the Mods page's Shader packs tab | checked, then installed into `shaderpacks/` |
|
||||
| In the game: the material stage | | not yet |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -46,12 +47,14 @@ my_pack 0.1.0 (shader API 0.1): FAILED
|
||||
|
||||
## Trying a pack in the game
|
||||
|
||||
Until the Shader Packs page lists them, put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt`
|
||||
and start the game with `HORIZON_SHADER_PACK=<its folder or file name>`. The game checks the pack first, and refuses
|
||||
one that fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
|
||||
Put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt` (Open Pack Folder opens it) and pick
|
||||
it on Options > Horizon Settings > Shader Packs, which lists Horizon's packs, marked (Horizon), beside packs in the
|
||||
`shaders/` format; one runs at a time, and clicking it again turns it off. `HORIZON_SHADER_PACK=<its folder or file
|
||||
name>` runs a pack whatever the page says, for development. The game checks the pack first, and refuses one that
|
||||
fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
|
||||
and the pack is checked and rebuilt in place ("Shader pack ... reloaded"); if the new version fails, the chat says
|
||||
where and the last good version keeps drawing. The post passes run today; the sky and material stages are checked
|
||||
but not drawn yet.
|
||||
where and the last good version keeps drawing. The sky and post stages run today; the material stage is checked but
|
||||
not drawn yet.
|
||||
|
||||
## A pack
|
||||
|
||||
@@ -86,7 +89,7 @@ by Horizon: a pack changes how things look, not how they are drawn.
|
||||
|
||||
| Stage | Your function | What it does |
|
||||
| --- | --- | --- |
|
||||
| `[sky]` | `fn sky(input: SkyInput) -> vec4<f32>` | the sky's colour along each view ray; the sun, moon, stars and clouds are drawn over it |
|
||||
| `[sky]` | `fn sky(input: SkyInput) -> vec4<f32>` | the sky's colour along each view ray, opaque (alpha is ignored), drawn where vanilla draws its sky dome (the Overworld's sky; not the Nether's or the End's); the sun, moon, stars and clouds are drawn over it |
|
||||
| `[material]` | `fn material(input: MaterialInput) -> Surface` | the lit colour of each point of a block's surface, before the alpha test and fog |
|
||||
| `[[post]]` | `fn post(input: PostInput) -> vec4<f32>` | a full-screen pass over the world's picture (up to 8, in order), under the hand and HUD |
|
||||
|
||||
@@ -129,9 +132,9 @@ one keeps drawing), the launcher before it installs one, and the registry's CI b
|
||||
|
||||
`[budget] gpu_ms` is the GPU time your pack aims to add to a frame. To compare, open F3 in the game: while it is
|
||||
open, each pass is timed on the GPU and the overlay shows the pack's total and its costliest passes, by their `name`
|
||||
(the median of the last 15 timed frames). Timing waits for nothing, so it does not slow the game.
|
||||
(the median of the last 15 timed frames). Timing waits for nothing, so it does not slow the game. In a timed frame the sky
|
||||
stage draws in a pass of its own (it shares the world's pass otherwise), so its time includes clearing the picture.
|
||||
|
||||
## Next
|
||||
|
||||
Options > Shader packs listing these packs, then the material and sky stages, and the registry's `shader` kind with
|
||||
installs from the launcher.
|
||||
The material stage in the game.
|
||||
Generated
+111
-7
@@ -565,7 +565,16 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -574,6 +583,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
@@ -611,7 +626,7 @@ dependencies = [
|
||||
"ash-window",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"codespan-reporting",
|
||||
"codespan-reporting 0.12.0",
|
||||
"glow",
|
||||
"gpu-alloc",
|
||||
"gpu-alloc-ash",
|
||||
@@ -621,7 +636,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"mint",
|
||||
"naga",
|
||||
"naga 25.0.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
@@ -1037,6 +1052,15 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[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 = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -2295,7 +2319,7 @@ dependencies = [
|
||||
"log",
|
||||
"lyon",
|
||||
"metal",
|
||||
"naga",
|
||||
"naga 25.0.1",
|
||||
"num_cpus",
|
||||
"objc",
|
||||
"oo7",
|
||||
@@ -2560,6 +2584,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2700,12 +2729,14 @@ dependencies = [
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-shader-api",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
"wasmparser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -2742,6 +2773,19 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga 30.0.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -3543,10 +3587,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-set 0.8.0",
|
||||
"bitflags 2.13.2",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting",
|
||||
"codespan-reporting 0.12.0",
|
||||
"half",
|
||||
"hashbrown 0.15.5",
|
||||
"hexf-parse",
|
||||
@@ -3555,12 +3599,50 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"spirv 0.3.0+sdk-1.3.268.0",
|
||||
"strum 0.26.3",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting 0.13.1",
|
||||
"half",
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"libm",
|
||||
"log",
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv 0.4.0+sdk-1.4.341.0",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga-types"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
@@ -5405,6 +5487,15 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[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 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -6516,6 +6607,19 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.254.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"semver",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user