mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
80
Commits
No files matched your search
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
|
||||
@@ -40,6 +40,15 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
|
||||
## Only original code
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
Run the self-check and paste the block it prints into your pull request:
|
||||
|
||||
Generated
+58
-2
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1315,7 +1315,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1331,8 +1331,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1378,6 +1380,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1431,6 +1434,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1654,6 +1664,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1898,6 +1921,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3138,6 +3167,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spake2"
|
||||
version = "0.4.0"
|
||||
@@ -4294,6 +4333,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4426,6 +4471,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "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/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "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/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
|
||||
@@ -245,13 +245,19 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
hardware,
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
@@ -136,7 +136,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
|
||||
@@ -13,6 +13,7 @@ mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boat_passengers;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -4413,7 +4414,7 @@ impl Game<'_> {
|
||||
self.resolve_arrivals();
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan());
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
@@ -9090,21 +9091,11 @@ impl Game<'_> {
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let remesh = (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
s.horizon.cull_leaves,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.cull_leaves,
|
||||
);
|
||||
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
|
||||
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.cull_leaves = self.settings.horizon.cull_leaves;
|
||||
if remesh {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
|
||||
@@ -472,6 +472,8 @@ impl Game<'_> {
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -320,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -401,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -477,6 +529,19 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
@@ -540,13 +605,14 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -556,9 +622,18 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -569,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -656,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -711,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -728,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -758,6 +830,7 @@ impl Game<'_> {
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
|
||||
@@ -82,8 +82,10 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Screens with a text box take every key press first
|
||||
// (EditBox.keyPressed); releases still reach KeyboardHandler.
|
||||
// Vanilla types into a focused EditBox through charTyped, which no
|
||||
// debug key can swallow. Here the character comes with the key press,
|
||||
// so a screen with a focused text box takes every press first;
|
||||
// releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
|
||||
@@ -91,7 +93,11 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing();
|
||||
|| self.station_typing()
|
||||
|| self
|
||||
.title
|
||||
.as_mut()
|
||||
.is_some_and(|t| t.focused_field().is_some());
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
|
||||
@@ -834,8 +834,11 @@ impl Game<'_> {
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
pub(super) fn tick_lan(&mut self) {
|
||||
/// Run the hub for this tick and apply what the guests did. While the
|
||||
/// single player game is `paused` only the connections run
|
||||
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
|
||||
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
|
||||
pub(super) fn tick_lan(&mut self, paused: bool) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -1000,7 +1003,9 @@ impl Game<'_> {
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan_living_flags(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
|
||||
@@ -1277,10 +1277,24 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let eye = {
|
||||
let g = &host.hub.guests[gi];
|
||||
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
|
||||
};
|
||||
let object = || {
|
||||
let (lo, hi, origin) = self.object_target(id)?;
|
||||
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
let Some(l) = location else {
|
||||
// A boat's mobs sit over its middle: aim low at its near
|
||||
// side, so the ray meets the boat before them (the server
|
||||
// attacks the entity it is sent).
|
||||
let carries = u32::try_from(id).is_ok_and(|v| {
|
||||
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
|
||||
});
|
||||
if carries {
|
||||
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
|
||||
return Some([near(0), lo[1] + 0.05, near(2)]);
|
||||
}
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
@@ -1311,7 +1325,6 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
|
||||
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +265,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +346,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +391,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +509,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +587,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -650,25 +673,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +860,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -411,6 +415,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -1382,7 +1387,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1461,6 +1466,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1472,9 +1494,6 @@ impl Game<'_> {
|
||||
steps: 3,
|
||||
},
|
||||
);
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
@@ -1807,6 +1826,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
@@ -2125,17 +2146,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2201,9 +2234,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2933,7 +2971,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2944,7 +2984,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2952,6 +2992,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
|
||||
return;
|
||||
@@ -3149,6 +3206,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3176,6 +3236,7 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
@@ -3219,7 +3280,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3259,9 +3320,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3320,11 +3382,51 @@ impl Game<'_> {
|
||||
za *= f64::from(0.05_f32);
|
||||
push = Vec3::new(push.x - xa, push.y, push.z - za);
|
||||
}
|
||||
// AbstractBoat.push(player) from the boat's own tick (its box grown
|
||||
// by 0.2 sideways) and from the player's pushEntities (its box).
|
||||
let player_team = teams.of(Who::Player);
|
||||
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
|
||||
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
|
||||
let bb = b.bounds();
|
||||
let times = usize::from(
|
||||
bounds.intersects(bb.inflate(area))
|
||||
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
|
||||
) + usize::from(
|
||||
bounds.intersects(bb)
|
||||
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
|
||||
);
|
||||
if times > 0
|
||||
&& super::boat_passengers::boat_pushes(bb, bounds, false)
|
||||
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
|
||||
{
|
||||
push = push.add_vector(v.scale(times as f64));
|
||||
}
|
||||
}
|
||||
push
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3427,20 +3529,12 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
}
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// 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.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
&& l.steps > 0
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (l.target[0] - d.position.x) / n,
|
||||
d.position.y + (l.target[1] - d.position.y) / n,
|
||||
d.position.z + (l.target[2] - d.position.z) / n,
|
||||
);
|
||||
l.steps -= 1;
|
||||
}
|
||||
d.tick(world, facts, collision, false);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -185,11 +185,17 @@ impl Game<'_> {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
f.previous = f.position;
|
||||
f.age += 1;
|
||||
f.position = step(f.id, f.position);
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
|
||||
// Block.updateEntityMovementAfterFallOn, then the air drag.
|
||||
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
|
||||
@@ -88,8 +88,10 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
@@ -129,17 +131,20 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
let seat = b.position;
|
||||
self.boats[i] = b;
|
||||
Some(seat)
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
if let Some(v) = seat {
|
||||
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
|
||||
p.position = v;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -245,7 +245,6 @@ pub(super) fn make_streaming(
|
||||
streaming.biome_blend = settings.biome_blend;
|
||||
streaming.smooth_lighting = settings.smooth_lighting;
|
||||
streaming.cutout_leaves = settings.cutout_leaves;
|
||||
streaming.cull_leaves = settings.horizon.cull_leaves;
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.sky_light = dim != Dim::Nether;
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
|
||||
@@ -227,8 +227,6 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
pub cull_leaves: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
@@ -262,7 +260,6 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
@@ -308,7 +305,6 @@ impl Graphics {
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
@@ -343,12 +339,11 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\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:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
@@ -448,7 +443,6 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
@@ -475,7 +469,7 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ pub enum H {
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
@@ -138,11 +137,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::CullLeaves,
|
||||
None,
|
||||
"Hides shared leaf and mangrove root faces; foliage can look less dense.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -343,7 +337,6 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
@@ -418,7 +411,6 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
@@ -455,7 +447,6 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CullLeaves) => g.cull_leaves,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
@@ -1003,48 +994,6 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_leaves_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::CullLeaves);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: Off");
|
||||
for enabled in [false, true] {
|
||||
s.horizon.cull_leaves = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.cull_leaves, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
point,
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PlayerHealth {
|
||||
self.hurt_animation_time -= 1;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
|
||||
/// tilt and red flash restart.
|
||||
pub fn animate_hurt(&mut self) {
|
||||
self.hurt.hurt_duration = 10;
|
||||
self.hurt.hurt_time = 10;
|
||||
self.hurt_animation_time = 10;
|
||||
}
|
||||
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
|
||||
/// health starts the hurt animation.
|
||||
pub fn flash_health_change(&mut self, health: f32) {
|
||||
let damage = self.health() - health;
|
||||
if damage > 0.0 {
|
||||
self.hurt.last_hurt = damage;
|
||||
self.animate_hurt();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn hurt(
|
||||
@@ -274,6 +290,9 @@ impl PlayerMotion {
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
) -> Result<BlockOutcome> {
|
||||
if self.client_side {
|
||||
return Ok(BlockOutcome::default());
|
||||
}
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(source, None, None, None)
|
||||
|
||||
@@ -55,6 +55,9 @@ impl PlayerMotion {
|
||||
Ok(())
|
||||
}
|
||||
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
|
||||
if self.client_side {
|
||||
return Ok(());
|
||||
}
|
||||
let h = &mut self.health;
|
||||
let mut world = TickWorld {
|
||||
resources,
|
||||
|
||||
@@ -230,8 +230,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod particle_tests;
|
||||
#[cfg(test)]
|
||||
mod visibility_tests;
|
||||
@@ -277,7 +275,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
mangrove_roots: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -440,7 +437,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
mangrove_roots: s.has_class("MangroveRootsBlock"),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -692,8 +688,6 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Cull shared leaf and mangrove-root faces, including transparent leaves.
|
||||
pub cull_leaves: bool,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -773,12 +767,9 @@ impl Mesher<'_> {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// Adapted from CullLeaves (MIT); see THIRD_PARTY_NOTICES.md.
|
||||
let shared_foliage = (state.leaves
|
||||
&& other.leaves
|
||||
&& (!self.cutout_leaves || self.cull_leaves))
|
||||
|| (self.cull_leaves && state.mangrove_roots && other.mangrove_roots);
|
||||
visible[d as usize] = !shared_foliage
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
use super::*;
|
||||
use horizon_assets::model::Direction as ModelDirection;
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_removes_only_internal_leaf_and_root_faces() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let oak = state("minecraft:oak_leaves");
|
||||
let birch = state("minecraft:birch_leaves");
|
||||
let roots = state("minecraft:mangrove_roots");
|
||||
let stone = state("minecraft:stone");
|
||||
let mesh = |blocks: &[([i32; 3], u32)], cull_leaves, cutout_leaves| {
|
||||
let mut center = Chunk::empty(0, 0);
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for &([x, y, z], state) in blocks {
|
||||
let chunk = if x < 16 { &mut center } else { &mut east };
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&east);
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves,
|
||||
cull_leaves,
|
||||
}
|
||||
.tesselate(®ion, (0 - MIN_Y) / 16)
|
||||
.solid
|
||||
};
|
||||
|
||||
// Native leaves can contain unculled interior geometry as well as their
|
||||
// outer faces. Select the touching cull group from the actual baked model,
|
||||
// preserving every other quad and its full vertex attributes.
|
||||
let without_face = |at: [i32; 3], id, direction| {
|
||||
let vertices = mesh(&[(at, id)], false, true);
|
||||
let models = render
|
||||
.model_catalog
|
||||
.collect(BlockStateId(id), BlockPos::new(at[0], at[1], at[2]))
|
||||
.unwrap();
|
||||
let faces: Vec<_> = models.iter().flat_map(|(_, model)| &model.faces).collect();
|
||||
assert_eq!(vertices.len(), faces.len() * 4);
|
||||
let retained: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(faces)
|
||||
.filter(|(_, face)| face.quad.cull != Some(direction))
|
||||
.flat_map(|(quad, _)| quad.iter().copied())
|
||||
.collect();
|
||||
assert!(retained.len() < vertices.len(), "fixture has a cull group");
|
||||
retained
|
||||
};
|
||||
|
||||
// Same-species leaves, mixed leaves and roots hide only their touching
|
||||
// model groups. Disabling the option restores the isolated models.
|
||||
for (a, b) in [(oak, oak), (oak, birch), (roots, roots)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
off.len(),
|
||||
mesh(&[blocks[0]], false, true).len() + mesh(&[blocks[1]], false, true).len()
|
||||
);
|
||||
let mut retained = without_face(blocks[0].0, a, ModelDirection::East);
|
||||
retained.extend(without_face(blocks[1].0, b, ModelDirection::West));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&retained)
|
||||
);
|
||||
}
|
||||
|
||||
// Roots do not join leaves into a single culling group. Ordinary opaque
|
||||
// neighbors also keep their existing occlusion behavior.
|
||||
for (a, b) in [(oak, roots), (roots, oak), (oak, stone)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&off)
|
||||
);
|
||||
}
|
||||
|
||||
// Opaque leaves already hide internal leaf faces independently of this
|
||||
// option, and a leaf beside air retains its complete baked model.
|
||||
let pair = [([7, 7, 7], oak), ([8, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&pair, false, false).len(),
|
||||
mesh(&pair, true, false).len()
|
||||
);
|
||||
let isolated = [([7, 7, 7], oak)];
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, false, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, true, true))
|
||||
);
|
||||
|
||||
// The padding read must also remove the center chunk's internal face
|
||||
// against a different leaf species in the neighboring chunk.
|
||||
let boundary = [([15, 7, 7], oak), ([16, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&boundary, false, true).len(),
|
||||
mesh(&[boundary[0]], false, true).len()
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&boundary, true, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&without_face(boundary[0].0, oak, ModelDirection::East))
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
mangrove_roots: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -736,9 +736,13 @@ pub struct Extra {
|
||||
/// The sniffer's and the ghastling's brain.
|
||||
pub animal_brain: Option<Box<animal_brain::Brain>>,
|
||||
pub axolotl: axolotl::Animation,
|
||||
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
|
||||
/// The mob or boat this one rides, and the mob riding it (`getVehicle`,
|
||||
/// `getFirstPassenger`).
|
||||
pub mount: Option<u32>,
|
||||
pub rider: Option<u32>,
|
||||
/// `Entity.boardingCooldown`: ticks after leaving a boat before a boat
|
||||
/// can pick this mob up again.
|
||||
pub boarding_cooldown: i32,
|
||||
/// A chicken carrying a zombie (`IsChickenJockey`).
|
||||
pub jockey: bool,
|
||||
/// A skeleton horse waiting to spring its trap, and how long it has waited.
|
||||
|
||||
@@ -96,7 +96,8 @@ fn xp_reward(m: &Mob) -> i32 {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn is_animal(s: Species) -> bool {
|
||||
/// The mobs that extend `Animal`.
|
||||
pub fn is_animal(s: Species) -> bool {
|
||||
use Species as S;
|
||||
matches!(
|
||||
s,
|
||||
|
||||
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// A client's copy of its own player (`Level.isClientSide`): only the
|
||||
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
|
||||
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
|
||||
pub client_side: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -465,6 +469,7 @@ impl PlayerMotion {
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
client_side: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -2860,6 +2865,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A client's own fall (multiplayer, a LAN guest, single player on its
|
||||
/// server thread) plays its landing but never hurts it: only the server
|
||||
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
|
||||
/// leaves the screen still.
|
||||
#[test]
|
||||
fn a_clients_own_fall_does_not_hurt_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for client_side in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
|
||||
player.client_side = client_side;
|
||||
player.fall_distance = 30.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
|
||||
assert!(w.player.ground.on_ground);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
// LivingEntity.causeFallDamage's sounds play on both sides.
|
||||
assert!(w.player.events.iter().any(|e| matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::BigFall,
|
||||
..
|
||||
})
|
||||
)));
|
||||
let hurt = w.player.health.health() < 20.0;
|
||||
assert_eq!(hurt, !client_side);
|
||||
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
|
||||
// The server's health then starts the client's hurt animation.
|
||||
if client_side {
|
||||
w.player.health.flash_health_change(20.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 0);
|
||||
w.player.health.flash_health_change(12.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -147,7 +147,12 @@ impl Session {
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Send a play packet; silently dropped once the connection is gone.
|
||||
/// Send a play packet; silently dropped once the connection is gone,
|
||||
/// and while the server has the connection back in configuration (a
|
||||
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
|
||||
/// the level before it acknowledges, so no play packet follows the
|
||||
/// acknowledgement; one that did would reach the server as a
|
||||
/// configuration packet, and Velocity drops such a client.
|
||||
pub fn send(&self, packet: play::Serverbound) {
|
||||
if self.debug
|
||||
&& !matches!(
|
||||
@@ -159,7 +164,11 @@ impl Session {
|
||||
{
|
||||
eprintln!("[net] -> {packet:?}");
|
||||
}
|
||||
if let Some(w) = self.writer.lock().unwrap().as_mut()
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
if self.configuring.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&packet.encode()).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
@@ -296,8 +305,13 @@ fn run(
|
||||
target
|
||||
}
|
||||
};
|
||||
let c =
|
||||
Connection::connect(&host, port, Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
let c = if address.starts_with(FRIEND) {
|
||||
// The friends relay supplies a concrete endpoint, not a Minecraft hostname.
|
||||
Connection::connect(&host, port, Duration::from_secs(10))
|
||||
} else {
|
||||
Connection::connect_server(&host, port, Duration::from_secs(10))
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
(host, port, c)
|
||||
};
|
||||
*shared_counters.lock().unwrap() = Some(c.reader.counters.clone());
|
||||
@@ -596,7 +610,7 @@ pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128), String> {
|
||||
use horizon_protocol::packets::status;
|
||||
let (host, port) = parse_address(address).ok_or("Invalid address")?;
|
||||
let mut c = Connection::connect(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
let mut c = Connection::connect_server(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
c.reader
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -681,6 +695,60 @@ mod tests {
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_packets_wait_out_a_reconfiguration() {
|
||||
let (client, server) = horizon_protocol::connection::Pipe::pair();
|
||||
let (mut reader, writer) = Connection::local(client).split();
|
||||
let (send, events) = channel();
|
||||
let session = Session {
|
||||
events,
|
||||
writer: Arc::new(Mutex::new(Some(writer))),
|
||||
configuring: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
address: "local".into(),
|
||||
name: "Player".into(),
|
||||
counters: Arc::new(Mutex::new(None)),
|
||||
debug: false,
|
||||
};
|
||||
let (thread_writer, thread_configuring) =
|
||||
(session.writer.clone(), session.configuring.clone());
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let tick_end = play::Serverbound::ClientTickEnd(Empty);
|
||||
server
|
||||
.send(&play::Clientbound::StartConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
play::Serverbound::decode(id, &body).unwrap(),
|
||||
play::Serverbound::ConfigurationAcknowledged(Empty)
|
||||
);
|
||||
// The game thread still ticks the old level: nothing of it goes out.
|
||||
session.send(tick_end.clone());
|
||||
server
|
||||
.send(&cfg::Clientbound::FinishConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
cfg::Serverbound::decode(id, &body).unwrap(),
|
||||
cfg::Serverbound::FinishConfiguration(Empty)
|
||||
);
|
||||
// Back in play, the game's packets flow again.
|
||||
session.send(tick_end.clone());
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(play::Serverbound::decode(id, &body).unwrap(), tick_end);
|
||||
drop(session);
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
|
||||
let (send, events) = channel();
|
||||
|
||||
@@ -153,7 +153,6 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
cull_leaves: bool,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -258,7 +257,6 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
pub cull_leaves: bool,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -500,7 +498,6 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
cull_leaves: settings.cull_leaves,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -556,7 +553,6 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -1177,7 +1173,6 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
cull_leaves: self.cull_leaves,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -215,7 +215,6 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
@@ -148,10 +148,11 @@ impl PerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.add_volume(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
self.add_volume::<true>(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
}
|
||||
|
||||
fn add_volume(
|
||||
/// `BATCH` = false evaluates one Y at a time: the reference the batched loop is tested against.
|
||||
fn add_volume<const BATCH: bool>(
|
||||
&self,
|
||||
buffer: &mut [f32],
|
||||
volume: DensityVolume,
|
||||
@@ -207,7 +208,10 @@ impl PerlinNoise {
|
||||
let mut previous_y = i32::MIN;
|
||||
let mut xz_dots = [0.0_f32; 8];
|
||||
let mut y_gradients = [0.0_f32; 8];
|
||||
for (sample, out) in ys.iter().zip(&mut buffer[index..index + size_y]) {
|
||||
let column = &mut buffer[index..index + size_y];
|
||||
let mut y = 0;
|
||||
while y < size_y {
|
||||
let sample = &ys[y];
|
||||
if previous_y != sample.cell {
|
||||
let gradients = self.gradients_at(p, q, sample.cell, cell_z);
|
||||
for i in 0..8 {
|
||||
@@ -217,11 +221,31 @@ impl PerlinNoise {
|
||||
}
|
||||
previous_y = sample.cell;
|
||||
}
|
||||
// Four Ys in one cell share the corners: side by side, LLVM computes them
|
||||
// as vectors. Cell boundaries and the column's tail go one at a time.
|
||||
if BATCH
|
||||
&& y + 4 <= size_y
|
||||
&& ys[y + 1..y + 4].iter().all(|s| s.cell == sample.cell)
|
||||
{
|
||||
add_four(
|
||||
&mut column[y..y + 4],
|
||||
&ys[y..y + 4],
|
||||
xz_dots,
|
||||
y_gradients,
|
||||
fade_x,
|
||||
fade_z,
|
||||
amplitude,
|
||||
);
|
||||
y += 4;
|
||||
continue;
|
||||
}
|
||||
let out = &mut column[y];
|
||||
let dy = sample.offset;
|
||||
let values = std::array::from_fn(|i| {
|
||||
xz_dots[i] + y_gradients[i] * (dy - ((i >> 1) & 1) as f32)
|
||||
});
|
||||
*out += amplitude * lerp3([fade_x, sample.fade, fade_z], values);
|
||||
y += 1;
|
||||
}
|
||||
index += size_y;
|
||||
}
|
||||
@@ -293,7 +317,7 @@ impl SmearedPerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.base.add_volume(
|
||||
self.base.add_volume::<true>(
|
||||
buffer,
|
||||
volume,
|
||||
xz_scale,
|
||||
@@ -312,3 +336,97 @@ fn compute_fudge_y(input_y: f64, relative_y: f64, scale: f64) -> f64 {
|
||||
};
|
||||
f64::from(floor_i32(limited_y / scale + f64::from(1e-7_f32))) * scale
|
||||
}
|
||||
|
||||
/// Adds four Y samples of one cell, laid out lane by lane so LLVM vectorizes them (no intrinsics,
|
||||
/// no target feature). Each lane does the one-at-a-time loop's operations in the same order.
|
||||
#[inline]
|
||||
fn add_four(
|
||||
out: &mut [f32],
|
||||
ys: &[YSample],
|
||||
dots: [f32; 8],
|
||||
gradients: [f32; 8],
|
||||
fade_x: f32,
|
||||
fade_z: f32,
|
||||
amplitude: f32,
|
||||
) {
|
||||
let dy: [f32; 4] = std::array::from_fn(|lane| ys[lane].offset);
|
||||
let fy: [f32; 4] = std::array::from_fn(|lane| ys[lane].fade);
|
||||
let values: [[f32; 4]; 8] = std::array::from_fn(|i| {
|
||||
std::array::from_fn(|lane| dots[i] + gradients[i] * (dy[lane] - ((i >> 1) & 1) as f32))
|
||||
});
|
||||
for lane in 0..4 {
|
||||
out[lane] += amplitude * lerp3([fade_x, fy[lane], fade_z], values.map(|v| v[lane]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn batched_volume_is_bit_identical() {
|
||||
let scales = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.001,
|
||||
-0.001,
|
||||
0.125,
|
||||
-0.5,
|
||||
1.0,
|
||||
4.0,
|
||||
1e100,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NAN,
|
||||
];
|
||||
let amplitudes = [
|
||||
0.0,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NAN,
|
||||
f32::from_bits(1),
|
||||
];
|
||||
for seed in [0, -1, 1234, i64::MIN] {
|
||||
let noise = PerlinNoise::new(&mut LegacyRandom::new(seed));
|
||||
for height in [1, 2, 3, 4, 5, 7, 8, 9, 31, 64, 65, 97] {
|
||||
for (case, &scale) in scales.iter().enumerate() {
|
||||
let minimum = [
|
||||
[-17, -64, 49],
|
||||
[i32::MAX, i32::MIN, -16777216],
|
||||
[16777216, 33554431, -33554432],
|
||||
][case % 3];
|
||||
let volume = DensityVolume::new(
|
||||
[2, height, 2],
|
||||
minimum,
|
||||
[4, if case % 3 == 0 { i32::MAX } else { 1 }, 16],
|
||||
)
|
||||
.unwrap();
|
||||
for fudge in [None, Some(0.125), Some(-0.25), Some(0.0), Some(f64::NAN)] {
|
||||
let initial = [0.0, -0.0, 1.5, f32::from_bits(1)];
|
||||
let mut a: Vec<_> =
|
||||
(0..volume.size() + 3).map(|i| initial[i % 4]).collect();
|
||||
let mut b = a.clone();
|
||||
let amplitude = amplitudes[case % amplitudes.len()];
|
||||
noise
|
||||
.add_volume::<false>(&mut a, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
noise
|
||||
.add_volume::<true>(&mut b, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
for (i, (a, b)) in a.iter().zip(&b).enumerate() {
|
||||
assert_eq!(
|
||||
a.to_bits(),
|
||||
b.to_bits(),
|
||||
"seed={seed} height={height} scale={scale} fudge={fudge:?} index={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ wasmtime = { version = "36.0.18", default-features = false, features = ["runtime
|
||||
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
|
||||
wit-component = { version = "0.239", optional = true }
|
||||
wit-parser = { version = "0.239", 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 }
|
||||
|
||||
[features]
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
@@ -24,6 +27,8 @@ wit-parser = { version = "0.239", optional = true }
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime"]
|
||||
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"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The mod registry's index, `index.json` (docs/api/mod-index.md): every
|
||||
//! reviewed mod and modpack, its versions, the file of each with its size,
|
||||
//! SHA-256 and minisign signature, and the files taken down. The launcher
|
||||
//! reads it to browse and install, the website to show it, and the
|
||||
//! registry's CI writes it.
|
||||
//!
|
||||
//! [`check_file`] is what a file must pass before it is installed: its size
|
||||
//! and hash are the ones the index lists, its signature verifies against the
|
||||
//! project's pinned key, and what its own manifest says matches the entry.
|
||||
use crate::{
|
||||
Capability, Package, Side,
|
||||
modpack::{self, Modpack},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::Read, path::Path};
|
||||
|
||||
/// The index format this build reads and writes. A reader refuses a newer
|
||||
/// one rather than misread it.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
/// The biggest file the registry takes (a `.hmod` with content, a `.hpack`).
|
||||
pub const MAX_FILE: u64 = 64 << 20;
|
||||
/// Where files may be downloaded from: the registry's release assets.
|
||||
pub const FILE_HOST: &str = "https://github.com/ludovic111/horizon-mods/releases/download/";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Index {
|
||||
pub schema: u32,
|
||||
/// When CI built it, RFC 3339.
|
||||
#[serde(default)]
|
||||
pub generated: String,
|
||||
#[serde(default)]
|
||||
pub projects: Vec<Project>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revoked>,
|
||||
}
|
||||
|
||||
/// What a project is. New kinds (data packs, worlds, themes) can be added
|
||||
/// without a new schema: a reader keeps a kind it doesn't know as
|
||||
/// [`Kind::Other`] and doesn't offer it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
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.
|
||||
Shader,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
Kind::Other(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of its files; None for a kind this build doesn't know.
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack | Kind::Shader => Some("zip"),
|
||||
Kind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
_ => Kind::Other(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Kind> for String {
|
||||
fn from(kind: Kind) -> String {
|
||||
kind.name().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// The manifest's id, `[a-z0-9_]{1,64}`: unique across the index.
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
/// One line for lists.
|
||||
pub summary: String,
|
||||
/// Markdown, for the project's page.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The GitHub logins allowed to publish its updates.
|
||||
#[serde(default)]
|
||||
pub owners: Vec<String>,
|
||||
/// An SPDX expression.
|
||||
pub license: String,
|
||||
/// Where its source code is, `https://`.
|
||||
pub source: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// The author's minisign public key: every version must be signed with
|
||||
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Newest first.
|
||||
pub versions: Vec<Version>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
pub version: String,
|
||||
/// The day it was published, `YYYY-MM-DD`.
|
||||
#[serde(default)]
|
||||
pub published: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub changelog: String,
|
||||
/// A mod's API line (`0.1`), from its manifest; a modpack's format (`1`).
|
||||
pub api: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub side: Option<Side>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// A modpack's mods, as its manifest lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub requires: Vec<Requirement>,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Requirement {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct File {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
/// Lowercase hex.
|
||||
pub sha256: String,
|
||||
/// The `.minisig` file's text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// A file taken down: by hash, or every version of `id` when `sha256` is
|
||||
/// None. The launcher refuses to install it and turns installed copies off.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Revoked {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
/// How far a checked file is trusted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Trust {
|
||||
/// Signed with the project's pinned key.
|
||||
Verified,
|
||||
/// The project has no key: only the hash pins it.
|
||||
Unsigned,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses and checks an index (see [`Index::check`]).
|
||||
pub fn parse(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, false)
|
||||
}
|
||||
|
||||
/// [`Index::parse`] for a local test registry, whose files may be
|
||||
/// `file://` paths (trying a registry out before it is published).
|
||||
pub fn parse_local(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, true)
|
||||
}
|
||||
|
||||
fn parse_with(text: &str, local_files: bool) -> Result<Index, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
schema: u32,
|
||||
}
|
||||
let head: Head = serde_json::from_str(text).map_err(|e| format!("not a mod index: {e}"))?;
|
||||
if head.schema > SCHEMA {
|
||||
return Err(format!(
|
||||
"the mod index is schema {}; this build reads {SCHEMA}: update Horizon",
|
||||
head.schema
|
||||
));
|
||||
}
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("mod index: {e}"))?;
|
||||
index.check_with(local_files)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Every rule a reader relies on: unique valid ids, at least one
|
||||
/// version each, plain file names with the kind's extension, `https`
|
||||
/// URLs, hex hashes, keys and signatures that decode, and each
|
||||
/// modpack's mods present in the index with the same hash.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.check_with(false)
|
||||
}
|
||||
|
||||
fn check_with(&self, local_files: bool) -> Result<(), String> {
|
||||
for (i, p) in self.projects.iter().enumerate() {
|
||||
let at = |e: String| format!("{}: {e}", p.id);
|
||||
if !valid_id(&p.id) {
|
||||
return Err(format!("project id {:?} is not [a-z0-9_]{{1,64}}", p.id));
|
||||
}
|
||||
if self.projects[..i].iter().any(|q| q.id == p.id) {
|
||||
return Err(at("listed twice".into()));
|
||||
}
|
||||
if p.name.trim().is_empty() || p.license.trim().is_empty() {
|
||||
return Err(at("needs a name and a licence".into()));
|
||||
}
|
||||
https(&p.source).map_err(at)?;
|
||||
if let Some(icon) = &p.icon {
|
||||
https(icon).map_err(at)?;
|
||||
}
|
||||
if let Some(key) = &p.key {
|
||||
minisign_verify::PublicKey::from_base64(key.trim())
|
||||
.map_err(|e| at(format!("key: {e}")))?;
|
||||
}
|
||||
if p.versions.is_empty() {
|
||||
return Err(at("has no version".into()));
|
||||
}
|
||||
for (j, v) in p.versions.iter().enumerate() {
|
||||
let at = |e: String| format!("{} {}: {e}", p.id, v.version);
|
||||
if v.version.trim().is_empty()
|
||||
|| p.versions[..j].iter().any(|w| w.version == v.version)
|
||||
{
|
||||
return Err(at("empty or repeated version".into()));
|
||||
}
|
||||
let f = &v.file;
|
||||
let extension_ok = p
|
||||
.kind
|
||||
.extension()
|
||||
.is_none_or(|e| f.name.ends_with(&format!(".{e}")));
|
||||
if !plain_name(&f.name) || !extension_ok {
|
||||
return Err(at(format!(
|
||||
"{:?} is not a plain file name for a {}",
|
||||
f.name,
|
||||
p.kind.name()
|
||||
)));
|
||||
}
|
||||
if !(local_files && f.url.starts_with("file:///")) {
|
||||
https(&f.url).map_err(at)?;
|
||||
}
|
||||
hex64(&f.sha256).map_err(at)?;
|
||||
if f.size == 0 || f.size > MAX_FILE {
|
||||
return Err(at(format!(
|
||||
"size {} is not 1 byte to {} MiB",
|
||||
f.size,
|
||||
MAX_FILE >> 20
|
||||
)));
|
||||
}
|
||||
match (&p.key, &f.signature) {
|
||||
(Some(_), None) => {
|
||||
return Err(at("unsigned, but the project has a key".into()));
|
||||
}
|
||||
(None, Some(_)) => return Err(at("signed, but the project has no key".into())),
|
||||
(Some(_), Some(sig)) => {
|
||||
minisign_verify::Signature::decode(sig)
|
||||
.map_err(|e| at(format!("signature: {e}")))?;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for r in &v.requires {
|
||||
let found = self
|
||||
.project(&r.id)
|
||||
.and_then(|q| q.versions.iter().find(|w| w.version == r.version));
|
||||
match found {
|
||||
Some(w) if w.file.sha256 == r.sha256 => {}
|
||||
Some(_) => {
|
||||
return Err(at(format!(
|
||||
"{} {}: another file than the index's",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(at(format!(
|
||||
"needs {} {}, which is not in the index",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in &self.revoked {
|
||||
if let Some(h) = &r.sha256 {
|
||||
hex64(h).map_err(|e| format!("revoked {}: {e}", r.id))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project(&self, id: &str) -> Option<&Project> {
|
||||
self.projects.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Why the file of `id` with this hash was taken down, if it was.
|
||||
pub fn revoked(&self, id: &str, sha256: &str) -> Option<&Revoked> {
|
||||
self.revoked.iter().find(|r| {
|
||||
r.id == id
|
||||
&& r.sha256
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.eq_ignore_ascii_case(sha256))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9_]{1,64}`, the manifests' id rule.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
fn https(url: &str) -> Result<(), String> {
|
||||
match url.strip_prefix("https://") {
|
||||
Some(rest) if !rest.is_empty() && !url.chars().any(char::is_whitespace) => Ok(()),
|
||||
_ => Err(format!("{url:?} is not an https:// address")),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex64(h: &str) -> Result<(), String> {
|
||||
if h.len() == 64
|
||||
&& h.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{h:?} is not a lowercase hex SHA-256"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One plain file name: no folder, no hidden file, nothing a file system
|
||||
/// reads as something else.
|
||||
fn plain_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 128
|
||||
&& !name.starts_with('.')
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
||||
}
|
||||
|
||||
/// The SHA-256 of a file, lowercase hex.
|
||||
pub fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Checks a minisign signature (`.minisig` text) of `path` against `key`.
|
||||
pub fn verify_signature(key: &str, signature: &str, path: &Path) -> Result<(), String> {
|
||||
let key =
|
||||
minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("key: {e}"))?;
|
||||
let sig =
|
||||
minisign_verify::Signature::decode(signature).map_err(|e| format!("signature: {e}"))?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut verifier = key
|
||||
.verify_stream(&sig)
|
||||
.map_err(|e| format!("signature: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
verifier.update(&buf[..n]);
|
||||
}
|
||||
verifier
|
||||
.finalize()
|
||||
.map_err(|_| "the signature doesn't match the project's key".to_string())
|
||||
}
|
||||
|
||||
/// Checks the file at `path` against its entry before it is installed:
|
||||
/// size, SHA-256, revocation, signature, and that its manifest says what the
|
||||
/// index says (id, version, API, side, capabilities; a modpack's mods).
|
||||
pub fn check_file(
|
||||
index: &Index,
|
||||
project: &Project,
|
||||
version: &Version,
|
||||
path: &Path,
|
||||
) -> Result<Trust, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size != version.file.size {
|
||||
return Err(format!(
|
||||
"{} bytes, the index lists {}",
|
||||
size, version.file.size
|
||||
));
|
||||
}
|
||||
let hash = sha256_file(path)?;
|
||||
if hash != version.file.sha256 {
|
||||
return Err("its SHA-256 isn't the one the index lists: nothing was installed".into());
|
||||
}
|
||||
if let Some(r) = index.revoked(&project.id, &hash) {
|
||||
return Err(format!("{} was taken down: {}", project.name, r.reason));
|
||||
}
|
||||
let trust = match (&project.key, &version.file.signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
verify_signature(key, sig, path)?;
|
||||
Trust::Verified
|
||||
}
|
||||
(None, None) => Trust::Unsigned,
|
||||
_ => return Err("its signature and the project's key don't go together".into()),
|
||||
};
|
||||
match project.kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
crate::Content::read(path, &m)?;
|
||||
let mut caps = m.capabilities.clone();
|
||||
caps.sort();
|
||||
let mut listed = version.capabilities.clone();
|
||||
listed.sort();
|
||||
let differs = m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.api != version.api
|
||||
|| Some(m.side) != version.side
|
||||
|| caps != listed;
|
||||
if differs {
|
||||
return Err(format!(
|
||||
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
|
||||
m.id,
|
||||
m.version,
|
||||
m.api,
|
||||
m.side,
|
||||
names(&caps)
|
||||
));
|
||||
}
|
||||
}
|
||||
Kind::ResourcePack => check_resource_pack(path)?,
|
||||
Kind::Shader | Kind::Other(_) => {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
));
|
||||
}
|
||||
Kind::Modpack => {
|
||||
let pack = Modpack::read(path)?;
|
||||
let m = &pack.manifest;
|
||||
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
|
||||
if m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.format.to_string() != version.api
|
||||
|| m.game != version.game
|
||||
|| mods != version.requires
|
||||
{
|
||||
return Err(format!(
|
||||
"its {} isn't what the index lists",
|
||||
modpack::MANIFEST
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(trust)
|
||||
}
|
||||
|
||||
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
|
||||
/// object, and only plain paths.
|
||||
fn check_resource_pack(path: &Path) -> Result<(), String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
for i in 0..zip.len() {
|
||||
let name = zip
|
||||
.by_index_raw(i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.name()
|
||||
.to_owned();
|
||||
if name.starts_with('/') || name.split('/').any(|part| part == "..") || name.contains('\\')
|
||||
{
|
||||
return Err(format!("{name:?} is not a plain path"));
|
||||
}
|
||||
}
|
||||
let mut meta = String::new();
|
||||
zip.by_name("pack.mcmeta")
|
||||
.map_err(|_| "pack.mcmeta: missing".to_string())?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut meta)
|
||||
.map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
if !json["pack"].is_object() {
|
||||
return Err("pack.mcmeta: no \"pack\" object".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn names(caps: &[Capability]) -> String {
|
||||
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! 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.
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const OTHER_KEY: &str = "RWSe8ZD+cTOAqNd/jnXTmDtoKp0c0HxkGMrBBWcWQeEkPmcHB+XkaE9S";
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
|
||||
}
|
||||
|
||||
fn sample() -> Index {
|
||||
Index::parse(&std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn check(index: &Index, id: &str, file: &Path) -> Result<Trust, String> {
|
||||
let p = index.project(id).unwrap();
|
||||
check_file(index, p, &p.versions[0], file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
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()));
|
||||
let hud = index.project("coords_hud").unwrap();
|
||||
assert_eq!(
|
||||
hud.versions[0].capabilities,
|
||||
[Capability::GameRead, Capability::Hud, Capability::Chat]
|
||||
);
|
||||
// What CI writes reads back the same.
|
||||
let again = Index::parse(&serde_json::to_string(&index).unwrap()).unwrap();
|
||||
assert_eq!(again, index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_pass_against_their_entries() {
|
||||
let index = sample();
|
||||
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
|
||||
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 world = index.project("skyblock_start").unwrap();
|
||||
let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err();
|
||||
assert!(e.contains("bytes"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_file_key_or_manifest_is_refused() {
|
||||
let index = sample();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-index-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
|
||||
let mut bytes = std::fs::read(&hmod).unwrap();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 1;
|
||||
let tampered = dir.join("coords_hud-0.1.0.hmod");
|
||||
std::fs::write(&tampered, &bytes).unwrap();
|
||||
let e = check(&index, "coords_hud", &tampered).unwrap_err();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
|
||||
let mut other_key = index.clone();
|
||||
other_key.projects[0].key = Some(OTHER_KEY.into());
|
||||
let e = check(&other_key, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("key"), "{e}");
|
||||
|
||||
let mut more_caps = index.clone();
|
||||
more_caps.projects[0].versions[0].capabilities.pop();
|
||||
let e = check(&more_caps, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("isn't what the index lists"), "{e}");
|
||||
|
||||
let mut revoked = index.clone();
|
||||
revoked.revoked.push(Revoked {
|
||||
id: "coords_hud".into(),
|
||||
sha256: None,
|
||||
reason: "Test.".into(),
|
||||
date: String::new(),
|
||||
});
|
||||
let e = check(&revoked, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("taken down: Test."), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocations_match_by_hash_or_by_id() {
|
||||
let index = sample();
|
||||
let ones = "1".repeat(64);
|
||||
assert!(index.revoked("bad_mod", &ones).is_some());
|
||||
assert!(index.revoked("bad_mod", &"2".repeat(64)).is_none());
|
||||
assert!(index.revoked("coords_hud", &ones).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_broken_indexes() {
|
||||
let text = std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap();
|
||||
let newer = text.replacen("\"schema\": 1", "\"schema\": 2", 1);
|
||||
assert!(Index::parse(&newer).unwrap_err().contains("schema 2"));
|
||||
let broken = |edit: &dyn Fn(&mut Index)| {
|
||||
let mut index = sample();
|
||||
edit(&mut index);
|
||||
index.check().unwrap_err()
|
||||
};
|
||||
let e = broken(&|i| {
|
||||
let twin = i.projects[0].clone();
|
||||
i.projects.push(twin)
|
||||
});
|
||||
assert!(e.contains("listed twice"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.url = "http://example.org/x.hmod".into());
|
||||
assert!(e.contains("https"), "{e}");
|
||||
// A local test registry may list files on disk; the published one may not.
|
||||
let local = text.replace(
|
||||
"https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/",
|
||||
"file:///tmp/",
|
||||
);
|
||||
assert!(Index::parse(&local).unwrap_err().contains("https"));
|
||||
assert!(Index::parse_local(&local).is_ok());
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.name = "../coords_hud.hmod".into());
|
||||
assert!(e.contains("plain file name"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.name = "coords_hud.zip".into());
|
||||
assert!(e.contains("plain file name"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.signature = None);
|
||||
assert!(e.contains("unsigned"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].key = Some("not a key".into()));
|
||||
assert!(e.contains("key"), "{e}");
|
||||
let e = broken(&|i| i.projects[1].versions[0].requires[0].version = "9.9".into());
|
||||
assert!(e.contains("not in the index"), "{e}");
|
||||
let e = broken(&|i| i.projects[1].versions[0].requires[0].sha256 = "2".repeat(64));
|
||||
assert!(e.contains("another file"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].id = "Coords".into());
|
||||
assert!(e.contains("is not"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_packs_need_a_pack_mcmeta() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-index-rp-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pack = |files: &[(&str, &str)]| {
|
||||
let path = dir.join("look.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
for (name, text) in files {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
};
|
||||
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
|
||||
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
|
||||
let e = check_resource_pack(&pack(&[("assets/x.png", "")])).unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", "{}")])).unwrap_err();
|
||||
assert!(e.contains("\"pack\""), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", meta), ("../x", "")])).unwrap_err();
|
||||
assert!(e.contains("plain path"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
//! 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 content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
pub mod manifest;
|
||||
pub mod modpack;
|
||||
#[cfg(feature = "runtime")]
|
||||
mod runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt,
|
||||
io::Read,
|
||||
@@ -20,6 +20,9 @@ pub const APIS: [&str; 2] = [API, "0.2"];
|
||||
/// 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"];
|
||||
/// A mod's data (capability `storage`), in `horizon-mods/` beside the mods
|
||||
/// (`find` never mistakes it for a mod: it has no manifest).
|
||||
pub const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
/// The biggest `mod.wasm` a mod may ship.
|
||||
const MAX_WASM: u64 = 32 << 20;
|
||||
|
||||
@@ -48,7 +51,7 @@ pub struct Manifest {
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Side {
|
||||
/// Runs in the player's game only (HUD, keys, chat commands).
|
||||
@@ -62,7 +65,7 @@ pub enum Side {
|
||||
|
||||
/// What a mod may do beyond writing its log. Each is a WIT interface the
|
||||
/// component imports; the player sees them before turning the mod on.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Capability {
|
||||
GameRead,
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! `.hpack`, a Horizon modpack (docs/api/mod-index.md): registry mods
|
||||
//! played together as a profile. A zip holding `horizon-modpack.toml`, which
|
||||
//! names each mod by id, version and SHA-256 (the files themselves come from
|
||||
//! the registry), and optionally `overrides/`: settings and resource packs
|
||||
//! copied into the profile's game folder.
|
||||
use crate::manifest::STORAGE_DIR;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// The manifest's file name in a `.hpack`.
|
||||
pub const MANIFEST: &str = "horizon-modpack.toml";
|
||||
/// The manifest formats this build reads.
|
||||
pub const FORMAT: u32 = 1;
|
||||
const OVERRIDES: &str = "overrides/";
|
||||
/// Bytes per override file and in all, unpacked.
|
||||
const MAX_OVERRIDE: u64 = 64 << 20;
|
||||
const MAX_OVERRIDES: u64 = 256 << 20;
|
||||
/// The settings files an override may replace.
|
||||
const SETTINGS: [&str; 3] = ["options.txt", "horizon-graphics.txt", "horizon-extras.txt"];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format: u32,
|
||||
/// `[a-z0-9_]{1,64}`: its profile's folder.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<PackMod>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackMod {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
/// Turned on in the profile (`horizon-mods.txt`).
|
||||
#[serde(default = "on")]
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
fn on() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "index")]
|
||||
impl PackMod {
|
||||
pub fn requirement(&self) -> crate::index::Requirement {
|
||||
crate::index::Requirement {
|
||||
id: self.id.clone(),
|
||||
version: self.version.clone(),
|
||||
sha256: self.sha256.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let m: Manifest = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
if m.format != FORMAT {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: format {}; this Horizon reads {FORMAT}",
|
||||
m.format
|
||||
));
|
||||
}
|
||||
let id_ok = |id: &str| {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
};
|
||||
if !id_ok(&m.id) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: id {:?} is not [a-z0-9_]{{1,64}}",
|
||||
m.id
|
||||
));
|
||||
}
|
||||
for (i, pm) in m.mods.iter().enumerate() {
|
||||
let hex = pm.sha256.len() == 64
|
||||
&& pm
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
|
||||
if !id_ok(&pm.id) || !hex || pm.version.is_empty() {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: mod {:?} needs an id, a version and a lowercase hex sha256",
|
||||
pm.id
|
||||
));
|
||||
}
|
||||
if m.mods[..i].iter().any(|other| other.id == pm.id) {
|
||||
return Err(format!("{MANIFEST}: mod {} is listed twice", pm.id));
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.hpack` as read from disk: its manifest and its override files, by
|
||||
/// path under `overrides/`.
|
||||
#[derive(Debug)]
|
||||
pub struct Modpack {
|
||||
pub manifest: Manifest,
|
||||
pub overrides: Vec<String>,
|
||||
}
|
||||
|
||||
impl Modpack {
|
||||
pub fn read(path: &Path) -> Result<Modpack, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut text = String::new();
|
||||
zip.by_name(MANIFEST)
|
||||
.map_err(|_| format!("{MANIFEST}: missing"))?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
let manifest = Manifest::parse(&text)?;
|
||||
let (mut overrides, mut total) = (Vec::new(), 0u64);
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name();
|
||||
if name == MANIFEST || entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = name.strip_prefix(OVERRIDES) else {
|
||||
return Err(format!(
|
||||
"{name}: a modpack holds only {MANIFEST} and overrides/"
|
||||
));
|
||||
};
|
||||
check_override(rest, &manifest)?;
|
||||
if entry.size() > MAX_OVERRIDE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_OVERRIDE >> 20));
|
||||
}
|
||||
total += entry.size();
|
||||
overrides.push(rest.to_owned());
|
||||
}
|
||||
if total > MAX_OVERRIDES {
|
||||
return Err(format!("overrides are over {} MiB", MAX_OVERRIDES >> 20));
|
||||
}
|
||||
overrides.sort();
|
||||
Ok(Modpack {
|
||||
manifest,
|
||||
overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the overrides of the pack at `path` into the game folder
|
||||
/// `home`, each checked again.
|
||||
pub fn extract_overrides(path: &Path, home: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let pack = Modpack::read(path)?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut written = Vec::new();
|
||||
for name in &pack.overrides {
|
||||
let mut entry = zip
|
||||
.by_name(&format!("{OVERRIDES}{name}"))
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
let target = home.join(name);
|
||||
if let Some(dir) = target.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
let mut out =
|
||||
std::fs::File::create(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
std::io::copy(&mut (&mut entry).take(MAX_OVERRIDE), &mut out)
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
written.push(target);
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
/// An override's path under `overrides/`: a settings file, a resource pack
|
||||
/// zip, or the stored settings of one of the pack's mods. Plain parts only.
|
||||
fn check_override(path: &str, manifest: &Manifest) -> Result<(), String> {
|
||||
let bad = || {
|
||||
format!(
|
||||
"overrides/{path}: only {}, resourcepacks/<name>.zip and horizon-mods/{STORAGE_DIR}/<a listed mod>/ may be overridden",
|
||||
SETTINGS.join(", ")
|
||||
)
|
||||
};
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let plain = |p: &&str| {
|
||||
!p.is_empty()
|
||||
&& !p.starts_with('.')
|
||||
&& p.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+ ".contains(&b))
|
||||
};
|
||||
let ok = match parts.as_slice() {
|
||||
[file] => SETTINGS.contains(file),
|
||||
["resourcepacks", name] => plain(name) && name.ends_with(".zip"),
|
||||
["horizon-mods", dir, id, rest @ ..] => {
|
||||
*dir == STORAGE_DIR
|
||||
&& manifest.mods.iter().any(|m| m.id == *id)
|
||||
&& !rest.is_empty()
|
||||
&& rest.iter().all(plain)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok { Ok(()) } else { Err(bad()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const PACK: &str = r#"
|
||||
format = 1
|
||||
id = "cozy"
|
||||
name = "Cozy"
|
||||
version = "1.0.0"
|
||||
game = "26.3"
|
||||
[[mods]]
|
||||
id = "coords_hud"
|
||||
version = "0.1.0"
|
||||
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
"#;
|
||||
|
||||
fn write_pack(dir: &Path, manifest: &str, files: &[(&str, &[u8])]) -> PathBuf {
|
||||
let path = dir.join("cozy.hpack");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(manifest.as_bytes()).unwrap();
|
||||
for (name, bytes) in files {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-hpack-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_pack_and_writes_its_overrides() {
|
||||
let dir = scratch("read");
|
||||
let storage = format!("overrides/horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt");
|
||||
let path = write_pack(
|
||||
&dir,
|
||||
PACK,
|
||||
&[
|
||||
("overrides/options.txt", b"renderDistance:8\n"),
|
||||
("overrides/resourcepacks/Cozy Look.zip", b"zip"),
|
||||
(&storage, b"top-left"),
|
||||
],
|
||||
);
|
||||
let pack = Modpack::read(&path).unwrap();
|
||||
assert_eq!(pack.manifest.mods[0].id, "coords_hud");
|
||||
assert!(pack.manifest.mods[0].on);
|
||||
assert_eq!(pack.overrides.len(), 3);
|
||||
let home = dir.join("home");
|
||||
Modpack::extract_overrides(&path, &home).unwrap();
|
||||
let options = std::fs::read_to_string(home.join("options.txt")).unwrap();
|
||||
assert_eq!(options, "renderDistance:8\n");
|
||||
let corner = home.join(format!("horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt"));
|
||||
assert!(corner.is_file());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_a_pack_may_not_hold() {
|
||||
let dir = scratch("refuse");
|
||||
for name in [
|
||||
"mods/evil.jar",
|
||||
"overrides/../../.bashrc",
|
||||
"overrides/horizon-mods/evil.hmod",
|
||||
"overrides/resourcepacks/../options.txt",
|
||||
"overrides/servers.dat",
|
||||
"overrides/horizon-mods/.horizon-mod-data/other_mod/x",
|
||||
"overrides/resourcepacks/.hidden.zip",
|
||||
] {
|
||||
let path = write_pack(&dir, PACK, &[(name, b"x")]);
|
||||
assert!(Modpack::read(&path).is_err(), "{name} was accepted");
|
||||
}
|
||||
let twice = format!("{PACK}{}", &PACK[PACK.find("[[mods]]").unwrap()..]);
|
||||
assert!(Manifest::parse(&twice).unwrap_err().contains("twice"));
|
||||
let newer = PACK.replace("format = 1", "format = 2");
|
||||
assert!(Manifest::parse(&newer).unwrap_err().contains("format 2"));
|
||||
let short = PACK.replace("0000000000000000", "00");
|
||||
assert!(Manifest::parse(&short).is_err());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,6 @@ const STORAGE_MAX_KEYS: usize = 64;
|
||||
/// a cheap-in-fuel loop replacing one key with large payloads needs its own
|
||||
/// cap, separate from the on-disk quota it would otherwise stay under.
|
||||
const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
|
||||
/// A mod's data, beside its `horizon-mods` folder (`find`'s `path.parent()`
|
||||
/// never has a manifest there, so `find` never mistakes it for a mod).
|
||||
const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
|
||||
/// What mods may read of the game, copied once per tick by the game.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -237,7 +234,7 @@ impl Mods {
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(STORAGE_DIR)
|
||||
.join(crate::manifest::STORAGE_DIR)
|
||||
.join(&m.id);
|
||||
let state = State {
|
||||
name: m.name.clone(),
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=
|
||||
trusted comment: coords_hud 0.1.0
|
||||
IESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==
|
||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-10-24T18:00:00Z",
|
||||
"projects": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"kind": "mod",
|
||||
"name": "Coordinates HUD",
|
||||
"summary": "Your coordinates, facing and the time of day in a corner of the screen.",
|
||||
"description": "`/where` puts them in chat, `/coords` hides or shows the panel.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"icon": "https://ludovic111.github.io/horizon-mods/icons/coords_hud.png",
|
||||
"key": "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "0.1",
|
||||
"side": "client",
|
||||
"capabilities": [
|
||||
"game-read",
|
||||
"hud",
|
||||
"chat"
|
||||
],
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "coords_hud-0.1.0.hmod",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/coords_hud-0.1.0.hmod",
|
||||
"size": 438,
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53",
|
||||
"signature": "untrusted comment: signature from minisign secret key\nRUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=\ntrusted comment: coords_hud 0.1.0\nIESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cozy",
|
||||
"kind": "modpack",
|
||||
"name": "Cozy",
|
||||
"summary": "Coordinates and a calm render distance.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon-mods",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"requires": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"version": "0.1.0",
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"
|
||||
}
|
||||
],
|
||||
"file": {
|
||||
"name": "cozy-1.0.0.hpack",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/cozy-1.0.0/cozy-1.0.0.hpack",
|
||||
"size": 471,
|
||||
"sha256": "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skyblock_start",
|
||||
"kind": "world",
|
||||
"name": "Skyblock start",
|
||||
"summary": "A kind this reader doesn't know yet: kept, not offered.",
|
||||
"license": "CC0-1.0",
|
||||
"source": "https://example.org/skyblock",
|
||||
"future_field": true,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "skyblock.hworld",
|
||||
"url": "https://example.org/skyblock.hworld",
|
||||
"size": 1,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"revoked": [
|
||||
{
|
||||
"id": "bad_mod",
|
||||
"sha256": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"reason": "Sent chat messages it didn't declare.",
|
||||
"date": "2026-10-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
untrusted comment: minisign public key 705764FC9BE1B6D5
|
||||
RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb
|
||||
@@ -814,7 +814,7 @@ pub fn carry(
|
||||
if kind == "structure" {
|
||||
let path = rel.trim_start_matches("worldgen/structure/");
|
||||
let structure = format!("{ns}:{}", path.trim_end_matches(".json"));
|
||||
changed |= crate::worldgen::disables(&structure, &mut v);
|
||||
changed |= crate::legacy_structure_mods::yung::disables(&structure, &mut v);
|
||||
changed |=
|
||||
crate::worldgen::add_piece_counts(&structure, &mut v, &piece_counts);
|
||||
}
|
||||
@@ -829,9 +829,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::worldgen::modifier(&v, loaded, types))
|
||||
{
|
||||
if let Some((kind, mut added)) = modifier.and_then(|v| {
|
||||
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
|
||||
}) {
|
||||
crate::processors::carry(
|
||||
kind,
|
||||
&mut added,
|
||||
@@ -910,7 +910,7 @@ pub fn carry(
|
||||
}
|
||||
// Entries the mod adds to other structure sets in Java code, for the
|
||||
// structures it has.
|
||||
for (file, set, entries) in crate::worldgen::code_set_entries(id) {
|
||||
for (file, set, entries) in crate::legacy_structure_mods::ctov::code_set_entries(id) {
|
||||
let entries: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.filter(|(s, _)| {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Choice Theorem's Overhauled Village: the entries it adds to other
|
||||
//! structure sets in Java code, and its `modular_compat` processor.
|
||||
use crate::processors::{ProcessorData, referenced};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub(crate) fn code_set_entries(
|
||||
mod_id: &str,
|
||||
) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choice Theorem's Overhauled Village's processor that runs the list
|
||||
/// `reference` when mod `modid` is loaded (ModularCompatProcessor).
|
||||
const MODULAR_COMPAT: &str = "ctov:modular_compat";
|
||||
|
||||
/// Notes which mods its compat processors name are `loaded`.
|
||||
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
|
||||
fn walk(v: &Value, out: &mut BTreeSet<String>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) {
|
||||
if let Some(id) = o.get("modid").and_then(Value::as_str) {
|
||||
out.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
o.values().for_each(|v| walk(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| walk(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut named = BTreeSet::new();
|
||||
data.lists.values().for_each(|l| walk(l, &mut named));
|
||||
data.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect();
|
||||
}
|
||||
|
||||
/// A `ctov:modular_compat` processor in a list being carried, at `depth`
|
||||
/// (see [`super::lithostitched`]'s references): counted in `carried`, and
|
||||
/// the processors of the list it names when its mod is loaded, else none.
|
||||
/// `None` for any other processor, or past the depth limit.
|
||||
pub(super) fn modular_compat(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> Option<Vec<Value>> {
|
||||
if p["processor_type"] != MODULAR_COMPAT || depth >= 8 {
|
||||
return None;
|
||||
}
|
||||
*carried.entry(MODULAR_COMPAT.into()).or_default() += 1;
|
||||
let loaded = p["modid"]
|
||||
.as_str()
|
||||
.is_some_and(|m| data.compat_loaded.contains(m));
|
||||
Some(
|
||||
loaded
|
||||
.then(|| referenced(&p["reference"], data))
|
||||
.flatten()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::processors::carry;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modular_compat_runs_its_list_only_with_its_mod() {
|
||||
let mut data = ProcessorData::default();
|
||||
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
|
||||
data.lists
|
||||
.insert("v:house_ns".into(), json!({"processors": [swap.clone()]}));
|
||||
let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"});
|
||||
let gravity = json!({"processor_type": "minecraft:gravity"});
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}),
|
||||
);
|
||||
data.mark_loaded(&|m| m == "ns");
|
||||
assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()]));
|
||||
let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut v, &data, &mut carried));
|
||||
assert_eq!(v, json!({"processors": [swap, gravity]}));
|
||||
assert_eq!(carried["ctov:modular_compat"], 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The conversion of three families of structure mods' data, kept apart from
|
||||
//! the general conversion code (`worldgen`, `processors`, `carry`):
|
||||
//!
|
||||
//! - [`yung`]: YUNG's API and the mods built on it (Better Dungeons, Desert
|
||||
//! Temples, Jungle Temples, Witch Huts, Ocean Monuments, Strongholds,
|
||||
//! Fortresses, Mineshafts), written under `horizon:yungsapi` and the other
|
||||
//! keys `horizon_world::worldgen::structure::yung` reads.
|
||||
//! - [`lithostitched`]: Lithostitched's jigsaw, worldgen modifiers and
|
||||
//! processors.
|
||||
//! - [`ctov`]: Choice Theorem's Overhauled Village's code-registered set
|
||||
//! entries and its modular compat processor.
|
||||
//!
|
||||
//! 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.
|
||||
pub(crate) mod ctov;
|
||||
pub(crate) mod lithostitched;
|
||||
pub(crate) mod yung;
|
||||
|
||||
#[cfg(test)]
|
||||
fn data() -> crate::processors::ProcessorData {
|
||||
let mut d = crate::processors::ProcessorData::default();
|
||||
d.spawners.insert(
|
||||
"repurposed_structures:dungeons/badlands".into(),
|
||||
vec![
|
||||
("minecraft:husk".into(), 10.),
|
||||
("minecraft:cave_spider".into(), 3.),
|
||||
],
|
||||
);
|
||||
d.replace_vanilla.insert(("mvs".into(), "village".into()));
|
||||
d
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ pub mod fetch;
|
||||
pub mod import;
|
||||
pub mod inventory;
|
||||
pub mod jar;
|
||||
pub(crate) mod legacy_structure_mods;
|
||||
pub mod modrinth;
|
||||
pub mod oracle;
|
||||
pub mod pipeline;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,11 @@
|
||||
//! by name. Each rewrite is counted in the carry report, and every type left
|
||||
//! as it is is listed, so the coverage report can say what generates and how
|
||||
//! closely.
|
||||
//!
|
||||
//! YUNG's, 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};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -21,8 +26,7 @@ use std::{
|
||||
};
|
||||
|
||||
/// Structure types that are vanilla's jigsaw with extra start rules.
|
||||
const JIGSAWS: [&str; 3] = [
|
||||
"lithostitched:jigsaw",
|
||||
const JIGSAWS: [&str; 2] = [
|
||||
"moogs_structures:moogs_structures_generic_jigsaw_structure",
|
||||
"repurposed_structures:generic_jigsaw_structure",
|
||||
];
|
||||
@@ -57,35 +61,6 @@ const SPREADS: [&str; 2] = [
|
||||
"moogs_structures:advanced_random_spread",
|
||||
"repurposed_structures:advanced_random_spread",
|
||||
];
|
||||
/// YUNG's placements: vanilla's random spread with an enhanced exclusion
|
||||
/// zone (several sets), plus, for the temples, no start near an ocean or a
|
||||
/// river, and for strongholds, starts only in odd rings around the origin.
|
||||
const YUNG_SPREADS: [&str; 4] = [
|
||||
"yungsapi:enhanced_random_spread",
|
||||
"betterdeserttemples:desert_temple",
|
||||
"betterjungletemples:jungle_temple",
|
||||
"betterstrongholds:stronghold",
|
||||
];
|
||||
/// YUNG's structures and the vanilla structure type each one's mod turns
|
||||
/// off (its DisableVanilla*Mixin, on by default in each mod's config).
|
||||
const YUNG_REPLACES: [(&str, &str); 7] = [
|
||||
(
|
||||
"betterdeserttemples:desert_temple",
|
||||
"minecraft:desert_pyramid",
|
||||
),
|
||||
(
|
||||
"betterjungletemples:jungle_temple",
|
||||
"minecraft:jungle_temple",
|
||||
),
|
||||
("betterwitchhuts:witch_hut", "minecraft:swamp_hut"),
|
||||
(
|
||||
"betteroceanmonuments:ocean_monument",
|
||||
"minecraft:ocean_monument",
|
||||
),
|
||||
("betterstrongholds:stronghold", "minecraft:stronghold"),
|
||||
("betterfortresses:fortress", "minecraft:fortress"),
|
||||
("bettermineshafts:mineshaft_", "minecraft:mineshaft"),
|
||||
];
|
||||
/// Mo' Structures' placement (`mostructures:type`) takes its spacing and
|
||||
/// separation from the mod's config, not its files: the defaults it writes
|
||||
/// (MoStructuresConfig), by config key, as (spacing, separation).
|
||||
@@ -130,8 +105,8 @@ const LAYOUT_RULES: [&str; 4] = [
|
||||
];
|
||||
/// The libraries' terrain kernel field, the game's
|
||||
/// `horizon:enhanced_terrain_adaptation` (docs/mods.md).
|
||||
const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
pub(crate) const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
pub(crate) const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
/// Fields of older versions the mods' codecs no longer read: their own
|
||||
/// structures ignore them, and so does carry. Repurposed's jungle fortress
|
||||
/// and End stronghold still write `vertical_distance_from_start_piece`.
|
||||
@@ -181,195 +156,6 @@ pub struct Upgrade {
|
||||
/// `data/<id>/worldgen/<kind>/horizon_added/<name>.json`.
|
||||
pub const ADDED: &str = "horizon_added";
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub fn code_set_entries(mod_id: &str) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites one `data/<ns>/worldgen/<kind>/...` file in place; whether it
|
||||
/// changed.
|
||||
/// `set_tags` are the mod's structure set tags, expanded
|
||||
@@ -474,15 +260,16 @@ fn upgrade_types(
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
) -> bool {
|
||||
let lost = &mut report.rules_lost;
|
||||
// YUNG's and Lithostitched's types are converted apart from this code.
|
||||
if let Some(changed) =
|
||||
yung::upgrade(kind, v, set_tags, lost).or_else(|| lithostitched::upgrade(kind, v, lost))
|
||||
{
|
||||
report.mapped += usize::from(changed);
|
||||
return changed;
|
||||
}
|
||||
let mut unsupported = |t: &str| *report.unsupported.entry(t.to_owned()).or_default() += 1;
|
||||
let changed = match kind {
|
||||
"structure" => match v["type"].as_str() {
|
||||
// Better Dungeons' small Nether dungeon is YUNG's jigsaw behind a
|
||||
// config switch that ships on.
|
||||
Some("yungsapi:yung_jigsaw" | "betterdungeons:small_nether_dungeon") => {
|
||||
yung_structure(v, lost);
|
||||
true
|
||||
}
|
||||
Some(t)
|
||||
if JIGSAWS.contains(&t)
|
||||
|| NETHER_JIGSAWS.contains(&t)
|
||||
@@ -591,11 +378,6 @@ fn upgrade_types(
|
||||
true
|
||||
}
|
||||
Some("mostructures:type") => mo_placement(&mut v["placement"], lost),
|
||||
Some(t) if YUNG_SPREADS.contains(&t) => {
|
||||
let t = t.to_owned();
|
||||
yung_placement(&t, &mut v["placement"], set_tags, lost);
|
||||
true
|
||||
}
|
||||
Some(t) if !t.starts_with("minecraft:") => {
|
||||
unsupported(t);
|
||||
false
|
||||
@@ -615,6 +397,10 @@ fn upgrade_types(
|
||||
}
|
||||
}
|
||||
for element in elements {
|
||||
if yung::upgrade_pool_element(element, lost) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
let Some(t) = element["element_type"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
@@ -629,9 +415,6 @@ fn upgrade_types(
|
||||
}
|
||||
element["element_type"] = (*to).into();
|
||||
changed = true;
|
||||
} else if t == "yungsapi:yung_single_element" {
|
||||
yung_element(element, lost);
|
||||
changed = true;
|
||||
} else if t == "formations:single_pool_element" {
|
||||
// Vanilla's element with its own ground level (1 when
|
||||
// missing, as its codec reads it).
|
||||
@@ -653,167 +436,10 @@ fn upgrade_types(
|
||||
changed
|
||||
}
|
||||
|
||||
/// YUNG's API's jigsaw as vanilla's, laid out by its own assembler in the
|
||||
/// game (`horizon:yungsapi`, `horizon_world::worldgen::structure::yung`):
|
||||
/// the heights pieces stay within move under the key, its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`. A start offset in
|
||||
/// the chunk is lost.
|
||||
fn yung_structure(v: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert("type".into(), "minecraft:jigsaw".into());
|
||||
let mut rules = serde_json::Map::new();
|
||||
for key in ["min_y", "max_y"] {
|
||||
if let Some(y) = o.remove(key) {
|
||||
rules.insert(key.into(), y);
|
||||
}
|
||||
}
|
||||
// YungJigsawStructure's adaptation (NONE by default): every piece's
|
||||
// junctions, and the start's box grown by its radius.
|
||||
let own = o.remove(ENHANCED_TERRAIN);
|
||||
let mut kernel = yung_terrain(own.as_ref().unwrap_or(&Value::Null), lost);
|
||||
kernel["rigid_junctions"] = true.into();
|
||||
kernel["inflate"] = true.into();
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), kernel);
|
||||
for key in ["x_offset_in_chunk", "z_offset_in_chunk"] {
|
||||
if o.remove(key).is_some_and(|n| n != 0) {
|
||||
*lost.entry(key.into()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// YUNG's single pool element as vanilla's, its rules under
|
||||
/// `horizon:yungsapi`: name and `max_count`, depth range, priority,
|
||||
/// `ignore_bounds`, its condition, dead-end pool and modifiers; its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`.
|
||||
fn yung_element(element: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = element.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert(
|
||||
"element_type".into(),
|
||||
"minecraft:single_pool_element".into(),
|
||||
);
|
||||
let mut rules = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("name", "name"),
|
||||
("max_count", "max_count"),
|
||||
("min_required_depth", "min_depth"),
|
||||
("max_possible_depth", "max_depth"),
|
||||
("is_priority", "priority"),
|
||||
("ignore_bounds", "ignore_bounds"),
|
||||
] {
|
||||
if let Some(x) = o.remove(from) {
|
||||
rules.insert(to.into(), x);
|
||||
}
|
||||
}
|
||||
if let Some(c) = o.remove("condition") {
|
||||
rules.insert("condition".into(), yung_condition(&c, lost));
|
||||
}
|
||||
// A piece's own adaptation, NONE included, replaces its structure's.
|
||||
if let Some(a) = o.remove(ENHANCED_TERRAIN) {
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), yung_terrain(&a, lost));
|
||||
}
|
||||
if let Some(pool) = o.remove("deadend_pool").filter(Value::is_string) {
|
||||
rules.insert("deadend_pool".into(), pool);
|
||||
}
|
||||
let modifiers: Vec<Value> = o
|
||||
.remove("modifiers")
|
||||
.and_then(|m| m.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|m| yung_modifier(m, lost))
|
||||
.collect();
|
||||
if !modifiers.is_empty() {
|
||||
rules.insert("modifiers".into(), modifiers.into());
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// One of YUNG's structure modifiers in the game's terms: its condition and
|
||||
/// its transform and delay_generation actions, on the piece itself (`self`,
|
||||
/// the API's only target). Another target loses the modifier, another action
|
||||
/// just that action.
|
||||
fn yung_modifier(m: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let target = m["target_selector"]["type"].as_str().unwrap_or_default();
|
||||
if target != "yungsapi:self" {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier target {target}"))
|
||||
.or_default() += 1;
|
||||
return None;
|
||||
}
|
||||
let mut actions = Vec::new();
|
||||
for a in m["actions"].as_array().into_iter().flatten() {
|
||||
let int = |k: &str| a[k].as_i64().unwrap_or(0);
|
||||
match a["type"].as_str().unwrap_or_default() {
|
||||
"yungsapi:transform" => actions.push(serde_json::json!({"type": "transform",
|
||||
"output": a["output"].clone(),
|
||||
"offset": [int("x_offset"), int("y_offset"), int("z_offset")]})),
|
||||
"yungsapi:delay_generation" => {
|
||||
actions.push(serde_json::json!({"type": "delay_generation"}))
|
||||
}
|
||||
other => {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier action {other}"))
|
||||
.or_default() += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
let condition = match m.get("condition") {
|
||||
Some(c) => yung_condition(c, lost),
|
||||
None => serde_json::json!({"type": "true"}),
|
||||
};
|
||||
Some(serde_json::json!({"condition": condition, "actions": actions}))
|
||||
}
|
||||
|
||||
/// YUNG's API's terrain adaptation as the game's kernel: its two presets
|
||||
/// spelled out, NONE (also the default) as size 0. An aquifer override
|
||||
/// (none of ATM10's) is lost.
|
||||
fn yung_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let kernel = |size: i32, distance: i32, top: &str, bottom: &str| {
|
||||
serde_json::json!({"kernel_size": size, "kernel_distance": distance,
|
||||
"top": top, "bottom": bottom})
|
||||
};
|
||||
match a["type"].as_str() {
|
||||
Some("yungsapi:carved_top_no_beard_large") => kernel(24, 16, "carve", "none"),
|
||||
Some("yungsapi:carved_top_no_beard_small") => kernel(12, 6, "carve", "none"),
|
||||
Some("yungsapi:custom") => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for key in [
|
||||
"kernel_size",
|
||||
"kernel_distance",
|
||||
"top",
|
||||
"bottom",
|
||||
"bottom_offset",
|
||||
"padding",
|
||||
] {
|
||||
if let Some(x) = a.get(key) {
|
||||
out.insert(key.into(), x.clone());
|
||||
}
|
||||
}
|
||||
if a.get("aquifer_override")
|
||||
.is_some_and(|x| x["type"] != "yungsapi:none")
|
||||
{
|
||||
*lost.entry("yungsapi aquifer_override".into()).or_default() += 1;
|
||||
}
|
||||
moogs_terrain(&out.into(), lost).unwrap_or_else(|| kernel(0, 0, "none", "none"))
|
||||
}
|
||||
None | Some("yungsapi:none") => kernel(0, 0, "none", "none"),
|
||||
Some(other) => {
|
||||
*lost
|
||||
.entry(format!("yungsapi terrain adaptation {other}"))
|
||||
.or_default() += 1;
|
||||
kernel(0, 0, "none", "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MoogsStructureLib's terrain adaptation: already the game's shape (with
|
||||
/// its band), when it has the fields the game needs (else it is lost, as
|
||||
/// the library's codec would refuse it).
|
||||
fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
pub(crate) fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let ok = ["kernel_size", "kernel_distance"]
|
||||
.iter()
|
||||
.all(|k| a[k].is_u64())
|
||||
@@ -824,58 +450,15 @@ fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value>
|
||||
ok.then(|| a.clone())
|
||||
}
|
||||
|
||||
/// One of YUNG's structure conditions in the game's terms. `mod_loaded`
|
||||
/// holds for Minecraft alone: a mod that needs code is not ported, and the
|
||||
/// pieces it guards would place its blocks. A condition the game does not
|
||||
/// have does not hold, and is counted.
|
||||
fn yung_condition(c: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let t = c["type"].as_str().unwrap_or_default();
|
||||
let list = |lost: &mut BTreeMap<String, usize>| -> Value {
|
||||
let all = c["conditions"].as_array().into_iter().flatten();
|
||||
all.map(|x| yung_condition(x, lost))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
};
|
||||
let opt = |k: &str| c.get(k).cloned().unwrap_or(Value::Null);
|
||||
match t.trim_start_matches("yungsapi:") {
|
||||
"always_true" => serde_json::json!({"type": "true"}),
|
||||
"not" => {
|
||||
serde_json::json!({"type": "not", "condition": yung_condition(&c["condition"], lost)})
|
||||
}
|
||||
"all_of" => serde_json::json!({"type": "all_of", "conditions": list(lost)}),
|
||||
"any_of" => serde_json::json!({"type": "any_of", "conditions": list(lost)}),
|
||||
"depth" => serde_json::json!({"type": "depth",
|
||||
"min_depth": opt("min_required_depth"), "max_depth": opt("max_possible_depth")}),
|
||||
"altitude" => serde_json::json!({"type": "altitude",
|
||||
"bottom": opt("bottom_cutoff_y"), "top": opt("top_cutoff_y")}),
|
||||
"random_chance" => serde_json::json!({"type": "random_chance", "chance": opt("chance")}),
|
||||
"rotation" => serde_json::json!({"type": "rotation", "rotations": opt("rotations")}),
|
||||
"piece_in_horizontal_direction" => {
|
||||
// "yungsapi:all" (or no list) matches any piece.
|
||||
let pieces: Vec<Value> = c["pieces"].as_array().cloned().unwrap_or_default();
|
||||
let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all");
|
||||
serde_json::json!({"type": "piece_in_horizontal_direction",
|
||||
"pieces": if any { Vec::new() } else { pieces },
|
||||
"range": opt("range"), "rotation": opt("rotation")})
|
||||
}
|
||||
// The game reads "yungsapi:all" (or no list) as any piece.
|
||||
"piece_in_range" => serde_json::json!({"type": "piece_in_range",
|
||||
"pieces": c.get("pieces").cloned().unwrap_or_else(|| Vec::<Value>::new().into()),
|
||||
"horizontal_range": opt("horizontal_range"), "above_range": opt("above_range"),
|
||||
"below_range": opt("below_range")}),
|
||||
"mod_loaded" if c["modid"] == "minecraft" => serde_json::json!({"type": "true"}),
|
||||
"mod_loaded" => serde_json::json!({"type": "false"}),
|
||||
_ => {
|
||||
*lost.entry(format!("yungsapi condition {t}")).or_default() += 1;
|
||||
serde_json::json!({"type": "false"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a Repurposed Structures or Moog's jigsaw's start rules under
|
||||
/// `horizon:start_checks`, and counts the layout rules it loses. `liquid` is
|
||||
/// the block its no-start-in-liquid rule reads.
|
||||
fn start_checks(v: &mut Value, liquid: &str, lost: &mut BTreeMap<String, usize>, limited: bool) {
|
||||
pub(crate) fn start_checks(
|
||||
v: &mut Value,
|
||||
liquid: &str,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
limited: bool,
|
||||
) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -1043,7 +626,7 @@ fn moogs_spacing(p: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
|
||||
/// An advanced random spread's minimum distance from the world origin and
|
||||
/// its super exclusion zone (sets named by a tag) under `horizon:` keys.
|
||||
fn placement_rules(
|
||||
pub(crate) fn placement_rules(
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
@@ -1115,65 +698,6 @@ fn mo_placement(p: &mut Value, lost: &mut BTreeMap<String, usize>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One of YUNG's placements as vanilla's random spread with `horizon:` keys:
|
||||
/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples'
|
||||
/// ocean and river search (findBiomeHorizontal around the chunk's middle
|
||||
/// block at y 120, 48 blocks, every 2 quarts) as `horizon:avoid_biomes`; the
|
||||
/// strongholds' rings as `horizon:ring_sections`.
|
||||
fn yung_placement(
|
||||
t: &str,
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) {
|
||||
placement_rules(p, set_tags, lost);
|
||||
p["type"] = "minecraft:random_spread".into();
|
||||
let Some(o) = p.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
match t {
|
||||
"betterdeserttemples:desert_temple" | "betterjungletemples:jungle_temple" => {
|
||||
o.insert(
|
||||
"horizon:avoid_biomes".into(),
|
||||
serde_json::json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120}),
|
||||
);
|
||||
}
|
||||
"betterstrongholds:stronghold" => {
|
||||
let mut rings = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("chunk_distance_to_first_ring", "distance_to_first_ring"),
|
||||
("ring_chunk_thickness", "thickness"),
|
||||
("max_ring_section", "max_section"),
|
||||
] {
|
||||
if let Some(n) = o.remove(from) {
|
||||
rings.insert(to.into(), n);
|
||||
}
|
||||
}
|
||||
o.insert("horizon:ring_sections".into(), rings.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a YUNG's structure that the game can start (its type is vanilla's
|
||||
/// after the upgrade) with the vanilla type its mod turns off, under
|
||||
/// `horizon:disables`. One the game cannot start leaves the vanilla
|
||||
/// structure in place, so the world keeps one of the two.
|
||||
pub fn disables(id: &str, v: &mut Value) -> bool {
|
||||
let Some((_, vanilla)) = YUNG_REPLACES.iter().find(|(s, _)| id.starts_with(s)) else {
|
||||
return false;
|
||||
};
|
||||
if !v["type"]
|
||||
.as_str()
|
||||
.is_some_and(|t| t.starts_with("minecraft:"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
v["horizon:disables"] = serde_json::json!([vanilla]);
|
||||
true
|
||||
}
|
||||
|
||||
/// The mod's structure set tags (`data/<ns>/tags/worldgen/structure_set/`),
|
||||
/// by tag id, with the tags they include expanded. Optional entries
|
||||
/// (`{"id": ..., "required": false}`) count as their id.
|
||||
@@ -1590,94 +1114,6 @@ mod tests {
|
||||
assert_eq!(report.rules_lost["super_exclusion_zone"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_jigsaws_keep_their_rules() {
|
||||
let mut report = Upgrade::default();
|
||||
let mut s = json!({"type": "yungsapi:yung_jigsaw", "start_pool": "y:start", "size": 20,
|
||||
"max_y": 60, "x_offset_in_chunk": 0, "max_distance_from_center": 80,
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:custom", "kernel_size": 24,
|
||||
"kernel_distance": 24, "top": "carve", "bottom": "none"}});
|
||||
assert!(upgrade("structure", &mut s, &mut report, &NO_TAGS));
|
||||
assert_eq!(s["type"], "minecraft:jigsaw");
|
||||
assert_eq!(s["horizon:yungsapi"], json!({"max_y": 60}));
|
||||
assert_eq!(
|
||||
s["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 24, "kernel_distance": 24, "top": "carve", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
for gone in ["max_y", "x_offset_in_chunk", "enhanced_terrain_adaptation"] {
|
||||
assert!(s.get(gone).is_none(), "{gone}");
|
||||
}
|
||||
// Better Dungeons' small Nether dungeon is the same jigsaw.
|
||||
let mut nether = json!({"type": "betterdungeons:small_nether_dungeon",
|
||||
"start_pool": "betterdungeons:small_nether_dungeon", "size": 10,
|
||||
"start_jigsaw_name": "betterdungeons:small_nether_dungeon_anchor",
|
||||
"max_distance_from_center": 80, "liquid_settings": "ignore_waterlogging"});
|
||||
assert!(upgrade("structure", &mut nether, &mut report, &NO_TAGS));
|
||||
assert_eq!(nether["type"], "minecraft:jigsaw");
|
||||
assert_eq!(nether["horizon:yungsapi"], json!({}));
|
||||
assert_eq!(
|
||||
nether["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 0, "kernel_distance": 0, "top": "none", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
assert_eq!(
|
||||
nether["start_jigsaw_name"],
|
||||
"betterdungeons:small_nether_dungeon_anchor"
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
let mut pool = json!({"elements": [{"weight": 3, "element": {
|
||||
"element_type": "yungsapi:yung_single_element", "location": "y:room",
|
||||
"processors": "minecraft:empty", "projection": "rigid",
|
||||
"name": "room", "max_count": 2, "min_required_depth": 1, "is_priority": true,
|
||||
"ignore_bounds": false, "deadend_pool": "y:end",
|
||||
"modifiers": [
|
||||
{"condition": {"type": "yungsapi:piece_in_range", "pieces": ["y:pillar"],
|
||||
"above_range": 40},
|
||||
"target_selector": {"type": "yungsapi:self"},
|
||||
"actions": [{"type": "yungsapi:transform", "output": ["y:covered"], "x_offset": -1},
|
||||
{"type": "yungsapi:delay_generation"}, {"type": "yungsapi:spin"}]},
|
||||
{"condition": {"type": "yungsapi:always_true"},
|
||||
"target_selector": {"type": "yungsapi:all"}, "actions": []}],
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:carved_top_no_beard_small"},
|
||||
"condition": {"type": "yungsapi:all_of", "conditions": [
|
||||
{"type": "yungsapi:piece_in_horizontal_direction", "pieces": ["yungsapi:all"],
|
||||
"range": 5, "rotation": "none"},
|
||||
{"type": "yungsapi:not", "condition": {"type": "yungsapi:mod_loaded", "modid": "create"}},
|
||||
{"type": "yungsapi:biome", "biomes": []}]}}}]});
|
||||
assert!(upgrade("template_pool", &mut pool, &mut report, &NO_TAGS));
|
||||
let e = &pool["elements"][0]["element"];
|
||||
assert_eq!(e["element_type"], "minecraft:single_pool_element");
|
||||
assert_eq!(e["location"], "y:room");
|
||||
assert_eq!(
|
||||
e["horizon:yungsapi"],
|
||||
json!({"name": "room", "max_count": 2, "min_depth": 1, "priority": true,
|
||||
"ignore_bounds": false, "condition": {"type": "all_of", "conditions": [
|
||||
{"type": "piece_in_horizontal_direction", "pieces": [], "range": 5, "rotation": "none"},
|
||||
{"type": "not", "condition": {"type": "false"}},
|
||||
{"type": "false"}]},
|
||||
"deadend_pool": "y:end",
|
||||
"modifiers": [{"condition": {"type": "piece_in_range", "pieces": ["y:pillar"],
|
||||
"horizontal_range": null, "above_range": 40, "below_range": null},
|
||||
"actions": [{"type": "transform", "output": ["y:covered"], "offset": [-1, 0, 0]},
|
||||
{"type": "delay_generation"}]}]})
|
||||
);
|
||||
assert!(e.get("deadend_pool").is_none() && e.get("modifiers").is_none());
|
||||
assert_eq!(
|
||||
e["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 12, "kernel_distance": 6, "top": "carve", "bottom": "none"})
|
||||
);
|
||||
assert_eq!(
|
||||
report.rules_lost,
|
||||
BTreeMap::from([
|
||||
("yungsapi condition yungsapi:biome".into(), 1),
|
||||
("yungsapi modifier action yungsapi:spin".into(), 1),
|
||||
("yungsapi modifier target yungsapi:all".into(), 1),
|
||||
])
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_processors_written_in_java() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1722,59 +1158,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_placements_keep_their_rules() {
|
||||
let tags = BTreeMap::from([(
|
||||
"bf:avoid".to_owned(),
|
||||
vec!["minecraft:nether_complexes".to_owned()],
|
||||
)]);
|
||||
let mut report = Upgrade::default();
|
||||
let mut fortress = json!({"placement": {"type": "yungsapi:enhanced_random_spread",
|
||||
"salt": 1, "spacing": 30, "separation": 20,
|
||||
"enhanced_exclusion_zone": {"other_set": "#bf:avoid", "chunk_count": 10}}});
|
||||
assert!(upgrade("structure_set", &mut fortress, &mut report, &tags));
|
||||
assert_eq!(
|
||||
fortress["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 1, "spacing": 30, "separation": 20,
|
||||
"horizon:exclusion_zone": {"other_sets": ["minecraft:nether_complexes"], "chunk_count": 10}})
|
||||
);
|
||||
let mut temple = json!({"placement": {"type": "betterdeserttemples:desert_temple",
|
||||
"salt": 2, "spacing": 30, "separation": 20,
|
||||
"exclusion_zone": {"other_set": "minecraft:villages", "chunk_count": 6}}});
|
||||
assert!(upgrade("structure_set", &mut temple, &mut report, &tags));
|
||||
assert_eq!(temple["placement"]["type"], "minecraft:random_spread");
|
||||
assert_eq!(temple["placement"]["exclusion_zone"]["chunk_count"], 6);
|
||||
assert_eq!(
|
||||
temple["placement"]["horizon:avoid_biomes"],
|
||||
json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120})
|
||||
);
|
||||
let mut stronghold = json!({"placement": {"type": "betterstrongholds:stronghold",
|
||||
"salt": 3, "spacing": 85, "separation": 50,
|
||||
"chunk_distance_to_first_ring": 80, "ring_chunk_thickness": 96}});
|
||||
assert!(upgrade(
|
||||
"structure_set",
|
||||
&mut stronghold,
|
||||
&mut report,
|
||||
&tags
|
||||
));
|
||||
assert_eq!(
|
||||
stronghold["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 3, "spacing": 85, "separation": 50,
|
||||
"horizon:ring_sections": {"distance_to_first_ring": 80, "thickness": 96}})
|
||||
);
|
||||
assert_eq!(report.mapped, 3);
|
||||
assert!(report.unsupported.is_empty());
|
||||
// The vanilla counterpart is turned off only for a structure the game starts.
|
||||
let mut ported = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(disables("betterstrongholds:stronghold", &mut ported));
|
||||
assert_eq!(ported["horizon:disables"], json!(["minecraft:stronghold"]));
|
||||
let mut java = json!({"type": "bettermineshafts:mineshaft"});
|
||||
assert!(!disables("bettermineshafts:mineshaft_oak", &mut java));
|
||||
let mut circle = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(!disables("betterwitchhuts:witch_circle", &mut circle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_limited_jigsaws_keep_their_layout() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1886,88 +1269,6 @@ mod tests {
|
||||
assert_eq!(report.mapped, 0);
|
||||
}
|
||||
|
||||
#[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 ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_tags_include_theirs() {
|
||||
let conventions = BTreeMap::from([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The JDK that compiles and runs the vanilla exporters. They call Mojang's own code, so they
|
||||
//! stay Java; everything around them is here.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -13,6 +14,20 @@ pub struct Jdk {
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal.
|
||||
fn command(program: impl AsRef<OsStr>) -> Command {
|
||||
let command = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
let command = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut command = command;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
command
|
||||
};
|
||||
command
|
||||
}
|
||||
|
||||
fn executable(dir: &Path, name: &str) -> PathBuf {
|
||||
dir.join(if cfg!(windows) {
|
||||
format!("{name}.exe")
|
||||
@@ -29,7 +44,7 @@ fn on_path(name: &str) -> Option<PathBuf> {
|
||||
|
||||
/// The major version `javac -version` reports ("javac 25.0.1" → 25; "javac 1.8.0" → 8).
|
||||
fn javac_major(javac: &Path) -> Result<u32, String> {
|
||||
let output = Command::new(javac)
|
||||
let output = command(javac)
|
||||
.arg("-version")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
@@ -183,7 +198,7 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
|
||||
candidates.push((javac, java, "PATH"));
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Ok(out) = Command::new("/usr/libexec/java_home")
|
||||
if let Ok(out) = command("/usr/libexec/java_home")
|
||||
.args(["-v", &major.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
@@ -260,7 +275,7 @@ impl Jdk {
|
||||
/// `javac --release <major> -proc:none` with Python's `subprocess.run(..., check=True,
|
||||
/// stdout=log, stderr=STDOUT, timeout=3600)`.
|
||||
pub fn javac(&self, major: i64, args: &[String], log: &fs::File) -> Result<(), String> {
|
||||
let mut command = Command::new(&self.javac);
|
||||
let mut command = command(&self.javac);
|
||||
command
|
||||
.args(["--release", &major.to_string(), "-proc:none"])
|
||||
.args(args);
|
||||
@@ -268,7 +283,7 @@ impl Jdk {
|
||||
}
|
||||
|
||||
pub fn java(&self, args: &[String], cwd: Option<&Path>, log: &fs::File) -> Result<(), String> {
|
||||
let mut command = Command::new(&self.java);
|
||||
let mut command = command(&self.java);
|
||||
command.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
command.current_dir(dir);
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod pyjson;
|
||||
mod sounds;
|
||||
mod survival;
|
||||
|
||||
pub use sounds::{fonts, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
pub use sounds::{fonts, languages, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
|
||||
use pyjson::Value;
|
||||
use sha2::Digest;
|
||||
|
||||
@@ -20,6 +20,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
|
||||
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare languages [--minecraft-dir PATH] [--root PATH]
|
||||
|
||||
--prepare Import the pinned game files from your own copy first
|
||||
--server-jar PATH The pinned server jar you downloaded yourself
|
||||
@@ -36,7 +37,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
|
||||
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
|
||||
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
|
||||
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
|
||||
Full preparation also imports all three after extracting the client JAR's placeholders.
|
||||
horizon-prepare languages adds the bundled languages and the language list the JAR lacks the same way.
|
||||
Full preparation also imports all four after extracting the client JAR's placeholders.
|
||||
|
||||
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
|
||||
launcher's asset store into the same folder, checking every file. It is optional: without it the
|
||||
@@ -68,8 +70,12 @@ fn main() -> ExitCode {
|
||||
};
|
||||
let mut root = None;
|
||||
let mut args = std::env::args_os().skip(1).peekable();
|
||||
let assets =
|
||||
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
|
||||
let assets = args.next_if(|a| {
|
||||
matches!(
|
||||
a.to_str(),
|
||||
Some("sounds" | "panorama" | "fonts" | "packs" | "languages")
|
||||
)
|
||||
});
|
||||
while let Some(arg) = args.next() {
|
||||
let text = arg.to_string_lossy().into_owned();
|
||||
let (flag, inline) = match text.split_once('=') {
|
||||
@@ -138,6 +144,9 @@ fn main() -> ExitCode {
|
||||
Some("packs") => {
|
||||
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
Some("languages") => {
|
||||
horizon_prepare::languages(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
_ => horizon_prepare::prepare(&options, &log),
|
||||
};
|
||||
match result {
|
||||
|
||||
@@ -112,6 +112,20 @@ pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf,
|
||||
copy_assets(root, minecraft, log, is_font, "Font", &UNIFONT)
|
||||
}
|
||||
|
||||
/// Every bundled language and the language list, both of which only the asset index holds
|
||||
/// (the client JAR carries `en_us` alone): `pack.mcmeta`'s `language` section is what the
|
||||
/// language screen offers, `minecraft/lang/<code>.json` the strings each one draws (#1272).
|
||||
/// Also usable on already-prepared resources without Java.
|
||||
pub fn languages(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
|
||||
copy_assets(root, minecraft, log, is_language, "Language", &LANGUAGES)
|
||||
}
|
||||
|
||||
const LANGUAGES: [&str; 1] = ["pack.mcmeta"];
|
||||
|
||||
fn is_language(name: &str) -> bool {
|
||||
name == "pack.mcmeta" || name.starts_with("minecraft/lang/")
|
||||
}
|
||||
|
||||
/// Full preparation can run from cached JARs without an official launcher install.
|
||||
pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
if let Err(error) = panorama(root, minecraft, log) {
|
||||
@@ -129,6 +143,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
|
||||
));
|
||||
}
|
||||
if let Err(error) = languages(root, minecraft, log) {
|
||||
log(&format!(
|
||||
"Languages not imported: {error}; run horizon-prepare languages later"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const PANORAMA: [&str; 7] = [
|
||||
@@ -278,6 +297,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn with_panorama(name: &str, panorama: &[&'static str]) -> Fixture {
|
||||
Self::with_extras(name, panorama, &[])
|
||||
}
|
||||
|
||||
/// The same store with extra index entries, for the files the index alone holds.
|
||||
fn with_extras(
|
||||
name: &str,
|
||||
panorama: &[&'static str],
|
||||
extras: &[(&'static str, &'static [u8])],
|
||||
) -> Fixture {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-prepare-sounds-{name}-{}",
|
||||
std::process::id()
|
||||
@@ -302,6 +330,9 @@ mod tests {
|
||||
// Distinct object bytes let the test catch swapped faces too.
|
||||
objects.push((name, name.as_bytes().to_vec()));
|
||||
}
|
||||
for &(name, data) in extras {
|
||||
objects.push((name, data.to_vec()));
|
||||
}
|
||||
let mut index = String::from("{\"objects\": {");
|
||||
for (i, (name, data)) in objects.iter().enumerate() {
|
||||
let hash = sha1_hex(data);
|
||||
@@ -391,6 +422,32 @@ mod tests {
|
||||
f.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_come_from_the_asset_store() {
|
||||
// The client JAR carries `en_us` alone, so every other language and the list in
|
||||
// `pack.mcmeta` come from the index (#1272).
|
||||
let f = Fixture::with_extras(
|
||||
"languages",
|
||||
&[],
|
||||
&[("pack.mcmeta", b"{\"language\": {\"fr_fr\": {}}}")],
|
||||
);
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let target = languages(&f.root, Some(&f.minecraft), &|l| {
|
||||
lines.borrow_mut().push(l.to_string())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fs::read(target.join("assets/pack.mcmeta"))
|
||||
.unwrap()
|
||||
.starts_with(b"{\"language\""));
|
||||
assert_eq!(
|
||||
fs::read(target.join("assets/minecraft/lang/fr_fr.json")).unwrap(),
|
||||
b"{}"
|
||||
);
|
||||
// Each entry point copies its own set: the sounds stay where they belong.
|
||||
assert!(!target.join("assets/minecraft/sounds.json").exists());
|
||||
assert_eq!(*lines.borrow(), ["Language assets ready: 2 files, 0.0 MB"]);
|
||||
}
|
||||
|
||||
/// #1076: the launcher holds an index Mojang republished under the same id, so its SHA-1
|
||||
/// is not the one in the version JSON. A known one is used quietly, an unknown one with a
|
||||
/// line in the log; the sounds come through either way.
|
||||
@@ -539,7 +596,10 @@ mod tests {
|
||||
lines[1..],
|
||||
[
|
||||
"Font assets ready: 2 files, 0.0 MB",
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB"
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB",
|
||||
// This store has no `pack.mcmeta`, so the languages report it.
|
||||
"Languages not imported: pack.mcmeta is missing from the asset index; \
|
||||
run horizon-prepare languages later"
|
||||
]
|
||||
),
|
||||
_ => {
|
||||
@@ -549,9 +609,14 @@ mod tests {
|
||||
&& lines[2].ends_with("; run horizon-prepare packs later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines[3].starts_with("Languages not imported: ")
|
||||
&& lines[3].ends_with("; run horizon-prepare languages later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines.len(), 4);
|
||||
for name in PANORAMA {
|
||||
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ sha1 = "0.10"
|
||||
# Secure chat: SHA256withRSA message signatures.
|
||||
sha2 = "0.10"
|
||||
|
||||
# The name servers of Windows' network adapters, for Minecraft SRV redirects.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
ipconfig = "0.3.4"
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "protocol_probe"
|
||||
|
||||
@@ -191,6 +191,19 @@ impl Connection {
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))?
|
||||
.collect();
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
/// Minecraft's SRV redirect applies to the socket destination only. Callers
|
||||
/// keep the player's original host and port in the protocol handshake.
|
||||
pub fn connect_server(host: &str, port: u16, timeout: Duration) -> Result<Self> {
|
||||
let addrs = crate::resolver::resolve(host, port)?;
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
fn connect_addresses(
|
||||
host: &str,
|
||||
addrs: Vec<std::net::SocketAddr>,
|
||||
timeout: Duration,
|
||||
) -> Result<Self> {
|
||||
let mut last = Error::new(format!("Unknown host {host}"));
|
||||
for addr in addrs {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod ids;
|
||||
pub mod item;
|
||||
pub mod packets;
|
||||
pub mod registry;
|
||||
mod resolver;
|
||||
pub mod rsa;
|
||||
pub mod secure_chat;
|
||||
pub mod servers;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Minecraft server DNS redirects (`ServerRedirectHandler` / `ServerNameResolver`).
|
||||
//! Port 25565 asks for `_minecraft._tcp.<host>`'s SRV record; no record or a
|
||||
//! failed query leaves the original address in use. The protocol handshake
|
||||
//! still uses that original address.
|
||||
use crate::{Error, Result};
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
|
||||
mod dns;
|
||||
|
||||
pub(crate) fn resolve(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
|
||||
resolve_with(host, port, srv_redirect, |host, port| {
|
||||
(host, port)
|
||||
.to_socket_addrs()
|
||||
.map(|addrs| addrs.collect())
|
||||
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_with(
|
||||
host: &str,
|
||||
port: u16,
|
||||
srv: impl FnOnce(&str) -> Option<(String, u16)>,
|
||||
addresses: impl FnOnce(&str, u16) -> Result<Vec<SocketAddr>>,
|
||||
) -> Result<Vec<SocketAddr>> {
|
||||
// Vanilla also asks for `_minecraft._tcp.127.0.0.1`, which never has a
|
||||
// record: IP literals and localhost skip the query.
|
||||
if port == 25565
|
||||
&& host.parse::<IpAddr>().is_err()
|
||||
&& host != "localhost"
|
||||
&& let Some((target, target_port)) =
|
||||
srv(&format!("_minecraft._tcp.{}.", host.trim_end_matches('.')))
|
||||
{
|
||||
// No second SRV lookup at the target, and no fallback to the original
|
||||
// host when the target is unknown.
|
||||
return addresses(&target, target_port);
|
||||
}
|
||||
addresses(host, port)
|
||||
}
|
||||
|
||||
fn srv_redirect(name: &str) -> Option<(String, u16)> {
|
||||
dns::query_srv(name, &dns::system_name_servers(), dns::SERVER_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redirects_default_port_even_when_the_original_host_has_no_addresses() {
|
||||
let result = resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|name| {
|
||||
assert_eq!(name, "_minecraft._tcp.play.example.org.");
|
||||
Some(("backend.example.org.".into(), 25570))
|
||||
},
|
||||
|host, port| {
|
||||
assert_eq!((host, port), ("backend.example.org.", 25570));
|
||||
Ok(vec!["127.0.0.1:25570".parse().unwrap()])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec!["127.0.0.1:25570".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_record_or_lookup_failure_keeps_the_original_address() {
|
||||
resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|_| None,
|
||||
|host, port| {
|
||||
assert_eq!((host, port), ("play.example.org", 25565));
|
||||
Ok(vec![])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nondefault_ports_and_ip_literals_do_not_query_srv() {
|
||||
for (host, port) in [
|
||||
("play.example.org", 25570),
|
||||
("127.0.0.1", 25565),
|
||||
("::1", 25565),
|
||||
("localhost", 25565),
|
||||
] {
|
||||
resolve_with(
|
||||
host,
|
||||
port,
|
||||
|_| panic!("unexpected SRV query"),
|
||||
|actual, actual_port| {
|
||||
assert_eq!((actual, actual_port), (host, port));
|
||||
Ok(vec![])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_qualified_host_does_not_get_a_second_trailing_dot() {
|
||||
resolve_with(
|
||||
"play.example.org.",
|
||||
25565,
|
||||
|name| {
|
||||
assert_eq!(name, "_minecraft._tcp.play.example.org.");
|
||||
None
|
||||
},
|
||||
|_, _| Ok(vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_redirect_target_does_not_fall_back_to_the_original() {
|
||||
let result = resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|_| Some(("missing.example.org.".into(), 25570)),
|
||||
|host, _| {
|
||||
assert_eq!(host, "missing.example.org.");
|
||||
Err(Error::new("unknown redirected host"))
|
||||
},
|
||||
);
|
||||
assert_eq!(result.unwrap_err().to_string(), "unknown redirected host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_address_can_connect_without_dns() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let connection = crate::connection::Connection::connect_server(
|
||||
"127.0.0.1",
|
||||
address.port(),
|
||||
std::time::Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
let (accepted, _) = listener.accept().unwrap();
|
||||
drop((connection, accepted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! The SRV question as JNDI's DNS client asks it: the system's name servers in
|
||||
//! turn, one UDP try of 1 s (`com.sun.jndi.dns.timeout.retries` = 1), TCP when
|
||||
//! the reply is truncated, and the answer's first SRV record. By hand, so the
|
||||
//! game needs no async runtime or resolver library for one question.
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const TYPE_SRV: u16 = 33;
|
||||
/// JNDI's `com.sun.jndi.dns.timeout.initial`, per name server.
|
||||
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
|
||||
/// Windows.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
std::fs::read_to_string("/etc/resolv.conf")
|
||||
.map_or_else(|_| Vec::new(), |conf| name_servers(&conf))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
let mut servers = Vec::new();
|
||||
for adapter in ipconfig::get_adapters().unwrap_or_default() {
|
||||
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
|
||||
continue;
|
||||
}
|
||||
for &ip in adapter.dns_servers() {
|
||||
let server = SocketAddr::new(ip, 53);
|
||||
if !servers.contains(&server) {
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// `nameserver` lines of resolv.conf, in order.
|
||||
#[cfg(any(unix, test))]
|
||||
fn name_servers(conf: &str) -> Vec<SocketAddr> {
|
||||
conf.lines()
|
||||
.filter_map(|line| line.strip_prefix("nameserver"))
|
||||
.filter_map(|rest| rest.split_whitespace().next())
|
||||
.filter_map(|ip| ip.split('%').next()?.parse::<std::net::IpAddr>().ok())
|
||||
.map(|ip| SocketAddr::new(ip, 53))
|
||||
.collect()
|
||||
}
|
||||
|
||||
enum Reply {
|
||||
/// The server answered: the first SRV record, if any.
|
||||
Answer(Option<(String, u16)>),
|
||||
Truncated,
|
||||
/// No reply, a server failure or a malformed reply: ask the next server.
|
||||
Unusable,
|
||||
}
|
||||
|
||||
pub(super) fn query_srv(
|
||||
name: &str,
|
||||
servers: &[SocketAddr],
|
||||
timeout: Duration,
|
||||
) -> Option<(String, u16)> {
|
||||
let mut id = [0; 2];
|
||||
getrandom::fill(&mut id).ok()?;
|
||||
let id = u16::from_be_bytes(id);
|
||||
let query = encode_query(id, name)?;
|
||||
for &server in servers {
|
||||
let reply = match ask_udp(server, &query, timeout).map(|reply| parse_reply(id, &reply)) {
|
||||
Some(Reply::Truncated) => ask_tcp(server, &query, timeout)
|
||||
.map_or(Reply::Unusable, |reply| parse_reply(id, &reply)),
|
||||
reply => reply.unwrap_or(Reply::Unusable),
|
||||
};
|
||||
if let Reply::Answer(srv) = reply {
|
||||
return srv;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ask_udp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
|
||||
let local: SocketAddr = if server.is_ipv4() {
|
||||
([0, 0, 0, 0], 0).into()
|
||||
} else {
|
||||
([0u16; 8], 0).into()
|
||||
};
|
||||
let socket = UdpSocket::bind(local).ok()?;
|
||||
socket.set_read_timeout(Some(timeout)).ok()?;
|
||||
socket.connect(server).ok()?;
|
||||
socket.send(query).ok()?;
|
||||
let mut buf = vec![0; 512];
|
||||
let len = socket.recv(&mut buf).ok()?;
|
||||
buf.truncate(len);
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn ask_tcp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
|
||||
let mut stream = TcpStream::connect_timeout(&server, timeout).ok()?;
|
||||
stream.set_read_timeout(Some(timeout)).ok()?;
|
||||
let mut framed = (query.len() as u16).to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(query);
|
||||
stream.write_all(&framed).ok()?;
|
||||
let mut len = [0; 2];
|
||||
stream.read_exact(&mut len).ok()?;
|
||||
let mut buf = vec![0; u16::from_be_bytes(len) as usize];
|
||||
stream.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// A recursive SRV question (RFC 1035 §4.1) for an absolute name.
|
||||
fn encode_query(id: u16, name: &str) -> Option<Vec<u8>> {
|
||||
let mut msg = Vec::with_capacity(18 + name.len());
|
||||
msg.extend_from_slice(&id.to_be_bytes());
|
||||
msg.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); // RD; one question
|
||||
for label in name.trim_end_matches('.').split('.') {
|
||||
if label.is_empty() || label.len() > 63 {
|
||||
return None;
|
||||
}
|
||||
msg.push(label.len() as u8);
|
||||
msg.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
msg.push(0);
|
||||
if msg.len() - 12 > 255 {
|
||||
return None;
|
||||
}
|
||||
msg.extend_from_slice(&TYPE_SRV.to_be_bytes());
|
||||
msg.extend_from_slice(&1u16.to_be_bytes()); // IN
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
fn parse_reply(id: u16, msg: &[u8]) -> Reply {
|
||||
parse_answer(id, msg).unwrap_or(Reply::Unusable)
|
||||
}
|
||||
|
||||
fn parse_answer(id: u16, msg: &[u8]) -> Option<Reply> {
|
||||
let u16_at = |at: usize| Some(u16::from_be_bytes([*msg.get(at)?, *msg.get(at + 1)?]));
|
||||
let flags = u16_at(2)?;
|
||||
if u16_at(0)? != id || flags & 0x8000 == 0 {
|
||||
return None;
|
||||
}
|
||||
if flags & 0x0200 != 0 {
|
||||
return Some(Reply::Truncated);
|
||||
}
|
||||
match flags & 0xf {
|
||||
0 => {}
|
||||
3 => return Some(Reply::Answer(None)), // NXDOMAIN
|
||||
_ => return None,
|
||||
}
|
||||
let mut at = 12;
|
||||
for _ in 0..u16_at(4)? {
|
||||
at = read_name(msg, at)?.1 + 4;
|
||||
}
|
||||
for _ in 0..u16_at(6)? {
|
||||
at = read_name(msg, at)?.1;
|
||||
let (kind, len) = (u16_at(at)?, u16_at(at + 8)? as usize);
|
||||
let data = at + 10;
|
||||
msg.get(data..data + len)?;
|
||||
if kind == TYPE_SRV && len >= 7 {
|
||||
let (target, _) = read_name(msg, data + 6)?;
|
||||
return Some(Reply::Answer(Some((target, u16_at(data + 4)?))));
|
||||
}
|
||||
at = data + len;
|
||||
}
|
||||
Some(Reply::Answer(None))
|
||||
}
|
||||
|
||||
/// The name at `at`, absolute (`a.b.`), and where the record continues.
|
||||
fn read_name(msg: &[u8], mut at: usize) -> Option<(String, usize)> {
|
||||
let mut name = String::new();
|
||||
let mut end = None;
|
||||
// Pointers must go backwards; the bound ends a loop of labels and pointers.
|
||||
for _ in 0..128 {
|
||||
let len = *msg.get(at)? as usize;
|
||||
match len {
|
||||
0 => {
|
||||
if name.is_empty() {
|
||||
name.push('.');
|
||||
}
|
||||
return Some((name, end.unwrap_or(at + 1)));
|
||||
}
|
||||
0xc0.. => {
|
||||
let target = (len & 0x3f) << 8 | *msg.get(at + 1)? as usize;
|
||||
if target >= at {
|
||||
return None;
|
||||
}
|
||||
end.get_or_insert(at + 2);
|
||||
at = target;
|
||||
}
|
||||
1..=63 => {
|
||||
let label = msg.get(at + 1..at + 1 + len)?;
|
||||
name.push_str(std::str::from_utf8(label).ok()?);
|
||||
name.push('.');
|
||||
at += 1 + len;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NAME: &str = "_minecraft._tcp.play.example.org.";
|
||||
|
||||
/// The reply a server gives to `query`: its question, then a CNAME and the
|
||||
/// SRV records, each owner name compressed to the question's name.
|
||||
fn reply(query: &[u8], rcode: u8, records: &[(u16, &str)]) -> Vec<u8> {
|
||||
let mut msg = query.to_vec();
|
||||
msg[2] |= 0x80;
|
||||
msg[3] = 0x80 | rcode;
|
||||
let answers = records.len() as u16 + u16::from(!records.is_empty());
|
||||
msg[6..8].copy_from_slice(&answers.to_be_bytes());
|
||||
if !records.is_empty() {
|
||||
// CNAME first: the parser must step over records of other types.
|
||||
msg.extend_from_slice(&[0xc0, 12, 0, 5, 0, 1, 0, 0, 0, 60, 0, 2, 0xc0, 12]);
|
||||
}
|
||||
for &(port, target) in records {
|
||||
let mut rdata = vec![0, 1, 0, 1];
|
||||
rdata.extend_from_slice(&port.to_be_bytes());
|
||||
for label in target.trim_end_matches('.').split('.') {
|
||||
rdata.push(label.len() as u8);
|
||||
rdata.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
rdata.push(0);
|
||||
msg.extend_from_slice(&[0xc0, 12, 0, 33, 0, 1, 0, 0, 0, 60]);
|
||||
msg.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
|
||||
msg.extend_from_slice(&rdata);
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
fn answer(msg: &[u8]) -> Option<Option<(String, u16)>> {
|
||||
match parse_reply(7, msg) {
|
||||
Reply::Answer(srv) => Some(srv),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_is_a_recursive_srv_question() {
|
||||
let query = encode_query(0x1234, NAME).unwrap();
|
||||
let mut expected = vec![0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0];
|
||||
expected.extend_from_slice(b"\x0a_minecraft\x04_tcp\x04play\x07example\x03org\x00");
|
||||
expected.extend_from_slice(&[0, 33, 0, 1]);
|
||||
assert_eq!(query, expected);
|
||||
assert!(encode_query(1, "a..b.").is_none());
|
||||
assert!(encode_query(1, &format!("{}.org.", "a".repeat(64))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_srv_record_of_the_answer_wins() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
let msg = reply(
|
||||
&query,
|
||||
0,
|
||||
&[(25570, "a.example.org."), (25571, "b.example.org.")],
|
||||
);
|
||||
assert_eq!(answer(&msg), Some(Some(("a.example.org.".into(), 25570))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_answer_and_nxdomain_mean_no_redirect() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
assert_eq!(answer(&reply(&query, 0, &[])), Some(None));
|
||||
assert_eq!(answer(&reply(&query, 3, &[])), Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_failures_and_bad_replies_ask_the_next_server() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
let good = reply(&query, 0, &[(25570, "a.example.org.")]);
|
||||
assert!(matches!(
|
||||
parse_reply(7, &reply(&query, 2, &[])),
|
||||
Reply::Unusable
|
||||
));
|
||||
assert!(matches!(parse_reply(8, &good), Reply::Unusable)); // another id
|
||||
assert!(matches!(parse_reply(7, &query), Reply::Unusable)); // not a response
|
||||
for cut in 0..good.len() {
|
||||
assert!(matches!(parse_reply(7, &good[..cut]), Reply::Unusable));
|
||||
}
|
||||
let mut forward = good.clone();
|
||||
let at = query.len(); // the first answer's owner pointer
|
||||
assert_eq!(forward[at..at + 2], [0xc0, 12]);
|
||||
forward[at + 1] = 0xff; // a pointer forwards
|
||||
assert!(matches!(parse_reply(7, &forward), Reply::Unusable));
|
||||
let mut truncated = good;
|
||||
truncated[2] |= 0x02;
|
||||
assert!(matches!(parse_reply(7, &truncated), Reply::Truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolv_conf_name_servers_in_order() {
|
||||
let conf = "# generated\nsearch lan\nnameserver 192.168.1.1\nnameserver fe80::1%en0\n\
|
||||
nameserver ::1 \nnameserver bogus\noptions ndots:1\n";
|
||||
let servers: Vec<String> = name_servers(conf).iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
|
||||
}
|
||||
|
||||
/// A local name server: a silent one first, then one that answers truncated
|
||||
/// over UDP and in full over TCP, like a real server with a big answer.
|
||||
#[test]
|
||||
fn asks_each_server_in_turn_and_retries_truncated_replies_over_tcp() {
|
||||
let silent = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let (udp, tcp) = loop {
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
if let Ok(tcp) = std::net::TcpListener::bind(udp.local_addr().unwrap()) {
|
||||
break (udp, tcp);
|
||||
}
|
||||
};
|
||||
let servers = [silent.local_addr().unwrap(), udp.local_addr().unwrap()];
|
||||
let server = std::thread::spawn(move || {
|
||||
let mut buf = [0; 512];
|
||||
let (len, from) = udp.recv_from(&mut buf).unwrap();
|
||||
let mut truncated = reply(&buf[..len], 0, &[]);
|
||||
truncated[2] |= 0x02;
|
||||
udp.send_to(&truncated, from).unwrap();
|
||||
let (mut stream, _) = tcp.accept().unwrap();
|
||||
let mut len = [0; 2];
|
||||
stream.read_exact(&mut len).unwrap();
|
||||
let mut query = vec![0; u16::from_be_bytes(len) as usize];
|
||||
stream.read_exact(&mut query).unwrap();
|
||||
let full = reply(&query, 0, &[(25570, "backend.example.org.")]);
|
||||
stream
|
||||
.write_all(&(full.len() as u16).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&full).unwrap();
|
||||
});
|
||||
let srv = query_srv(NAME, &servers, Duration::from_millis(200));
|
||||
server.join().unwrap();
|
||||
assert_eq!(srv, Some(("backend.example.org.".into(), 25570)));
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,7 @@
|
||||
//! `.horizon/api.json` that only the server's own user can read. The CLI manages a running server through
|
||||
//! it; the web panel and the MCP server will too.
|
||||
//!
|
||||
//! - `GET /api/v1/status`: the supervisor's state, the game's status (TPS, MSPT, players), RAM and CPU.
|
||||
//! - `GET /api/v1/logs?since=N`: the log lines after the Nth.
|
||||
//! - `POST /api/v1/command {"line": "..."}`: a console command; the lines it logged.
|
||||
//! - `POST /api/v1/stop`, `POST /api/v1/restart`.
|
||||
//! - `GET /api/v1/metrics?since=T`: the samples taken after T (seconds since 1970), up to 10 minutes.
|
||||
//! - `GET /api/v1/backups`; `POST /api/v1/backups {"name": "..."}` backs up now; `POST /api/v1/restore
|
||||
//! {"name": "..."}` restarts the game with that backup's world.
|
||||
//! - `POST /api/v1/update {"tag": "...", "timeout": seconds}`: restarts the game on that installed release,
|
||||
//! and goes back to the one in use if it isn't up within the timeout; without a tag, downloads the newest
|
||||
//! release first (update.rs). `GET /api/v1/status` says how it went (`update`).
|
||||
//! - `POST /api/v1/panel/login`: a one-time login link for the web panel (with the token only).
|
||||
//! Its routes are [`ROUTES`], listed in docs/api/server-reference.md.
|
||||
|
||||
use crate::{
|
||||
backup,
|
||||
@@ -41,6 +31,102 @@ const LOG_LINES: usize = 2000;
|
||||
/// The samples kept for the panel's graphs: 10 minutes, one a second.
|
||||
const METRICS: usize = 600;
|
||||
|
||||
/// One endpoint of the control API: what the reference (docs/api/server-reference.md) says about it.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "only the reference reads all of it")
|
||||
)]
|
||||
pub struct Route {
|
||||
pub method: &'static str,
|
||||
pub path: &'static str,
|
||||
/// The JSON body or query it takes, "" for none.
|
||||
pub takes: &'static str,
|
||||
/// The JSON it answers with.
|
||||
pub answers: &'static str,
|
||||
pub what: &'static str,
|
||||
}
|
||||
|
||||
/// Every endpoint `start` and [`route`] answer; a test checks the two agree.
|
||||
pub const ROUTES: [Route; 11] = [
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/status",
|
||||
takes: "",
|
||||
answers: "`phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update`",
|
||||
what: "How the server does",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/metrics",
|
||||
takes: "`?since=T` (seconds since 1970)",
|
||||
answers: "`{\"samples\": [...]}`: one a second, the last 10 minutes",
|
||||
what: "The samples taken after T, for graphs",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/logs",
|
||||
takes: "`?since=N`",
|
||||
answers: "`{\"next\": N, \"lines\": [...]}`",
|
||||
what: "The log lines after the Nth (the last 2000 are kept)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/command",
|
||||
takes: "`{\"line\": \"<one command>\"}`",
|
||||
answers: "the lines the command logged",
|
||||
what: "Run a console command on the running game",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/stop",
|
||||
takes: "",
|
||||
answers: "`{\"ok\": true}`",
|
||||
what: "Stop the game and the supervisor",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/restart",
|
||||
takes: "",
|
||||
answers: "`{\"ok\": true}`",
|
||||
what: "Restart the game",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/backups",
|
||||
takes: "",
|
||||
answers: "`{\"backups\": [{\"name\", \"bytes\"}]}`",
|
||||
what: "List the backups",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/backups",
|
||||
takes: "`{\"name\": \"...\"}` (optional)",
|
||||
answers: "`{\"name\": \"<file>\"}`",
|
||||
what: "Back up now (a running game saves first and stays up)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/restore",
|
||||
takes: "`{\"name\": \"<backup>\"}`",
|
||||
answers: "`{\"name\": \"<backup>\"}`",
|
||||
what: "Restart the game with that backup's world (the current one is kept in backups/)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/update",
|
||||
takes: "`{\"tag\": \"...\", \"timeout\": seconds}` (both optional)",
|
||||
answers: "`{\"ok\": true}`; `status` says how it went (`update`)",
|
||||
what: "Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/panel/login",
|
||||
takes: "",
|
||||
answers: "`{\"url\": \"...\"}`",
|
||||
what: "A one-time login link for the web panel (with the token only, not from the panel)",
|
||||
},
|
||||
];
|
||||
|
||||
/// What the supervisor is doing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
@@ -488,12 +574,7 @@ pub fn route(shared: &Arc<Shared>, request: &Request) -> Response {
|
||||
Err(e) => Response::error(409, &e),
|
||||
}
|
||||
}
|
||||
(
|
||||
_,
|
||||
"/api/v1/status" | "/api/v1/logs" | "/api/v1/command" | "/api/v1/stop"
|
||||
| "/api/v1/restart" | "/api/v1/backups" | "/api/v1/restore" | "/api/v1/metrics"
|
||||
| "/api/v1/update",
|
||||
) => Response::error(405, "wrong method"),
|
||||
(_, path) if ROUTES.iter().any(|r| r.path == path) => Response::error(405, "wrong method"),
|
||||
_ => Response::error(404, "no such endpoint"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,22 +19,22 @@ pub const TEMPLATE: &str = r##"# Horizon server settings (docs/server.md). The s
|
||||
# every start: edit this file, not that one.
|
||||
|
||||
[server]
|
||||
motd = "A Minecraft Server"
|
||||
port = 25565
|
||||
motd = "A Minecraft Server" # the line under the server's name in the server list
|
||||
port = 25565 # the port players connect to
|
||||
bind = "" # listen on every address, or only on this one (server-ip)
|
||||
max_players = 20
|
||||
max_players = 20 # how many players can be online at once
|
||||
online_mode = true # check players' Minecraft accounts (false: anyone joins under any name)
|
||||
whitelist = true # only whitelisted players and operators can join
|
||||
enforce_whitelist = false # kick online players taken off the whitelist
|
||||
view_distance = 10
|
||||
simulation_distance = 10
|
||||
view_distance = 10 # chunks sent around each player
|
||||
simulation_distance = 10 # chunks around each player where mobs, crops and redstone tick
|
||||
|
||||
[world]
|
||||
name = "world" # the world folder
|
||||
seed = "" # empty: a random seed on the first start
|
||||
game_mode = "survival" # survival, creative, adventure or spectator
|
||||
difficulty = "easy" # peaceful, easy, normal or hard
|
||||
hardcore = false
|
||||
hardcore = false # one life: a player who dies becomes a spectator
|
||||
|
||||
# Any other server.properties key, spelled as vanilla spells it, for example:
|
||||
# spawn-protection = 16
|
||||
@@ -43,7 +43,7 @@ hardcore = false
|
||||
[engine]
|
||||
path = "" # the game program; empty: the one installed beside horizon-server
|
||||
resources = "" # the prepared game files; empty: resources/ here, else the usual places
|
||||
restart_on_crash = true
|
||||
restart_on_crash = true # start the game again when it crashes
|
||||
|
||||
[backups]
|
||||
every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off
|
||||
@@ -58,7 +58,7 @@ allow_public = false # true lets bind be any address; never put the pane
|
||||
"##;
|
||||
|
||||
/// Each typed setting: its section, its name, and the vanilla key it writes.
|
||||
const TYPED: [(&str, &str, &str); 14] = [
|
||||
pub(crate) const TYPED: [(&str, &str, &str); 14] = [
|
||||
("server", "motd", "motd"),
|
||||
("server", "port", "server-port"),
|
||||
("server", "bind", "server-ip"),
|
||||
|
||||
@@ -68,7 +68,8 @@ pub fn program_in(
|
||||
/// system can't load the program, as on a Linux without the ALSA and udev libraries the full game links
|
||||
/// (the server build doesn't). The reason, with what to install.
|
||||
pub fn check_starts(program: &Path) -> Result<(), String> {
|
||||
let output = Command::new(program)
|
||||
let mut command = crate::process::command(program);
|
||||
let output = command
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
@@ -176,7 +177,8 @@ pub fn spawn(program: &Path, resources: &Path, dir: &Path) -> std::io::Result<Ch
|
||||
#[cfg(windows)]
|
||||
std::os::windows::process::CommandExt::creation_flags(
|
||||
&mut command,
|
||||
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP,
|
||||
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP
|
||||
| windows_sys::Win32::System::Threading::CREATE_NO_WINDOW,
|
||||
);
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ mod http;
|
||||
mod init;
|
||||
mod lists;
|
||||
mod panel;
|
||||
mod process;
|
||||
#[cfg(test)]
|
||||
mod reference;
|
||||
mod service;
|
||||
mod settings;
|
||||
mod signals;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The programs the server runs. Started by the launcher, the server has no console, and each
|
||||
//! console program it started would open a terminal window on Windows: those open none. Run from
|
||||
//! a terminal, they share it, so what they print still reaches it.
|
||||
use std::{ffi::OsStr, process::Command};
|
||||
|
||||
pub fn command(program: impl AsRef<OsStr>) -> Command {
|
||||
let command = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
let command = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use windows_sys::Win32::System::{Console::GetConsoleWindow, Threading::CREATE_NO_WINDOW};
|
||||
let mut command = command;
|
||||
// SAFETY: no arguments; it returns this process's console window or null.
|
||||
if unsafe { GetConsoleWindow() }.is_null() {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
command
|
||||
};
|
||||
command
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! docs/api/server-reference.md: `horizon-server`'s commands, every `server.toml` key and the control API's
|
||||
//! endpoints, generated from the source, and the tests that fail when it is out of date. Update it with:
|
||||
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference`.
|
||||
|
||||
use crate::{api::ROUTES, config};
|
||||
use std::fmt::Write;
|
||||
|
||||
const DOC: &str = "docs/api/server-reference.md";
|
||||
|
||||
/// One `key = value # what it means` line of the template, under its `[section]`.
|
||||
struct Key {
|
||||
section: String,
|
||||
name: String,
|
||||
default: String,
|
||||
what: String,
|
||||
}
|
||||
|
||||
/// The template's sections, each with the comment lines above it, and its keys, in order.
|
||||
fn template() -> (Vec<(String, String)>, Vec<Key>) {
|
||||
let (mut sections, mut keys, mut notes) = (Vec::new(), Vec::new(), Vec::<&str>::new());
|
||||
for line in config::TEMPLATE.lines().map(str::trim) {
|
||||
if let Some(note) = line.strip_prefix('#') {
|
||||
notes.push(note.trim());
|
||||
} else if let Some(section) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
sections.push((section.to_owned(), notes.join(" ")));
|
||||
notes.clear();
|
||||
} else if let Some((name, rest)) = line.split_once(" = ") {
|
||||
let (default, what) = rest.split_once(" # ").unwrap_or((rest, ""));
|
||||
keys.push(Key {
|
||||
section: sections.last().map(|(s, _)| s.clone()).unwrap_or_default(),
|
||||
name: name.trim().to_owned(),
|
||||
default: default.trim().to_owned(),
|
||||
what: what.trim().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
(sections, keys)
|
||||
}
|
||||
|
||||
pub fn markdown() -> String {
|
||||
let mut out = String::new();
|
||||
let w = &mut out;
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"<!-- Generated from crates/horizon-server/src (the help text, config::TEMPLATE, api::ROUTES) by reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference -->"
|
||||
);
|
||||
let _ = writeln!(w, "# Server reference\n");
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"Everything `horizon-server` offers a host or a tool: its commands, every `server.toml` key and the \
|
||||
control API. How to host: [hosting.md](../hosting.md); the design: [server.md](../server.md); \
|
||||
stability and versions: [stability.md](stability.md).\n"
|
||||
);
|
||||
|
||||
let _ = writeln!(w, "## Commands\n\n`horizon-server help` prints:\n");
|
||||
let _ = writeln!(w, "```text\n{}```\n", crate::HELP);
|
||||
|
||||
let _ = writeln!(w, "## `server.toml`\n");
|
||||
let (sections, keys) = template();
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"{} Unknown keys are refused. `horizon-server init` writes this file with these defaults; \
|
||||
`horizon-server config set SECTION.KEY=VALUE` changes it.\n",
|
||||
sections[0].1
|
||||
);
|
||||
for (i, (section, note)) in sections.iter().enumerate() {
|
||||
let _ = writeln!(w, "### `[{section}]`\n");
|
||||
if i > 0 && !note.is_empty() {
|
||||
let _ = writeln!(w, "{note}\n");
|
||||
}
|
||||
let rows: Vec<&Key> = keys.iter().filter(|k| &k.section == section).collect();
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"| Key | Default | In `server.properties` | What it does |\n| --- | --- | --- | --- |"
|
||||
);
|
||||
for k in rows {
|
||||
let vanilla = config::TYPED
|
||||
.iter()
|
||||
.find(|(s, n, _)| s == section && *n == k.name)
|
||||
.map_or(String::new(), |(_, _, v)| format!("`{v}`"));
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"| `{}` | `{}` | {vanilla} | {} |",
|
||||
k.name, k.default, k.what
|
||||
);
|
||||
}
|
||||
let _ = writeln!(w);
|
||||
}
|
||||
|
||||
let _ = writeln!(w, "## Control API (`/api/v1`)\n");
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"HTTP and JSON on 127.0.0.1. A running server writes its address and a random token to \
|
||||
`.horizon/api.json`, readable only by its own user; every request sends \
|
||||
`Authorization: Bearer <token>` and a `Host` that is that address. Errors answer \
|
||||
`{{\"error\": \"...\"}}` with 400, 401, 403, 404, 405 or 409. The web panel uses the same \
|
||||
routes with its session.\n"
|
||||
);
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"| Method | Path | Takes | Answers | What it does |\n| --- | --- | --- | --- | --- |"
|
||||
);
|
||||
for r in &ROUTES {
|
||||
let takes = if r.takes.is_empty() {
|
||||
"nothing"
|
||||
} else {
|
||||
r.takes
|
||||
};
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"| `{}` | `{}` | {takes} | {} | {} |",
|
||||
r.method, r.path, r.answers, r.what
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_reference_is_up_to_date() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join(DOC);
|
||||
let want = markdown();
|
||||
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
|
||||
std::fs::write(&path, &want).unwrap();
|
||||
}
|
||||
let have = std::fs::read_to_string(&path)
|
||||
.unwrap_or_default()
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference"
|
||||
);
|
||||
}
|
||||
|
||||
/// The template, which the reference lists, holds every key `Config` reads and no other.
|
||||
#[test]
|
||||
fn the_reference_lists_every_setting() {
|
||||
let config = toml::Value::try_from(config::Config::default()).unwrap();
|
||||
let mut listed: Vec<String> = template()
|
||||
.1
|
||||
.iter()
|
||||
.map(|k| format!("{}.{}", k.section, k.name))
|
||||
.collect();
|
||||
let mut read: Vec<String> = config
|
||||
.as_table()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|(section, keys)| Some((section, keys.as_table()?)))
|
||||
.flat_map(|(section, keys)| keys.keys().map(move |k| format!("{section}.{k}")))
|
||||
.collect();
|
||||
listed.sort();
|
||||
read.sort();
|
||||
assert_eq!(listed, read);
|
||||
for key in template().1 {
|
||||
assert!(
|
||||
!key.what.is_empty(),
|
||||
"{}.{} needs a `# what it does` comment in config::TEMPLATE",
|
||||
key.section,
|
||||
key.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ROUTES` holds exactly the endpoints the API's code answers.
|
||||
#[test]
|
||||
fn the_routes_table_matches_the_code() {
|
||||
let source = include_str!("api.rs");
|
||||
let mut answered: Vec<(String, String)> = Vec::new();
|
||||
for method in ["GET", "POST"] {
|
||||
let open = format!("(\"{method}\", \"/api/v1/");
|
||||
for (at, _) in source.match_indices(&open) {
|
||||
let rest = &source[at + open.len()..];
|
||||
let path = &rest[..rest.find('"').unwrap()];
|
||||
if rest[path.len()..].starts_with("\")") {
|
||||
answered.push((method.into(), format!("/api/v1/{path}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut listed: Vec<(String, String)> = ROUTES
|
||||
.iter()
|
||||
.map(|r| (r.method.into(), r.path.into()))
|
||||
.collect();
|
||||
answered.sort();
|
||||
listed.sort();
|
||||
assert_eq!(answered, listed);
|
||||
}
|
||||
@@ -178,7 +178,7 @@ fn systemd_path(service: &str, system: bool) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str]) -> Result<(), String> {
|
||||
let status = Command::new(program)
|
||||
let status = crate::process::command(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("{program}: {e}"))?;
|
||||
@@ -191,7 +191,7 @@ fn run(program: &str, args: &[&str]) -> Result<(), String> {
|
||||
|
||||
/// A step that may fail harmlessly (stopping what isn't running): its output would only alarm.
|
||||
fn quiet(program: &str, args: &[&str]) {
|
||||
let _ = Command::new(program)
|
||||
let _ = crate::process::command(program)
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
|
||||
@@ -10,7 +10,6 @@ use serde_json::{Value, json};
|
||||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
@@ -206,7 +205,7 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
|
||||
if link.exists() || link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_dir(link)?;
|
||||
}
|
||||
let status = Command::new("cmd")
|
||||
let status = crate::process::command("cmd")
|
||||
.arg("/C")
|
||||
.arg("mklink")
|
||||
.arg("/J")
|
||||
@@ -474,7 +473,7 @@ pub fn unpack(install: &Install, archive: &Path, tag: &str) -> Result<PathBuf, S
|
||||
// tar is on every Linux and macOS, and on Windows 10 and later. The files are the
|
||||
// user's, not the archive's owners: as root in a user namespace (an unprivileged LXC
|
||||
// or rootless container) taking those fails, and as plain root they'd be strangers'.
|
||||
let status = Command::new("tar")
|
||||
let status = crate::process::command("tar")
|
||||
.arg("--no-same-owner")
|
||||
.arg("-xzf")
|
||||
.arg(archive)
|
||||
@@ -1058,7 +1057,7 @@ mod tests {
|
||||
std::fs::write(inner.join("horizon-server"), "v2").unwrap();
|
||||
std::fs::write(inner.join("horizon-engine"), "engine").unwrap();
|
||||
let archive = source.join("horizon-server-linux-x86_64.tar.gz");
|
||||
let status = Command::new("tar")
|
||||
let status = std::process::Command::new("tar")
|
||||
.arg("-czf")
|
||||
.arg(&archive)
|
||||
.arg("-C")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
description = "Shader packs (the shaders/ folder format): reading packs, their .properties and .lang files, and later their GLSL"
|
||||
|
||||
[dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The packs in the game's `shaderpacks` folder: every folder and `.zip` in it (spec 1.1).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Pack;
|
||||
|
||||
/// One entry of the `shaderpacks` folder that is or looks like a pack.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Found {
|
||||
/// The folder or file name, which is how players and settings name the pack.
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
/// Why the entry can't be used, said to the player (a zip that doesn't open, a folder with
|
||||
/// no `shaders` folder at its root). `None` for a usable pack.
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// The packs in `dir`, sorted by name ignoring case. Hidden entries and files that are not
|
||||
/// `.zip` (the game's own option files live beside the packs) are skipped.
|
||||
pub fn discover(dir: &Path) -> Vec<Found> {
|
||||
let mut found: Vec<Found> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let path = entry.path();
|
||||
let is_zip = path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
|
||||
if name.starts_with('.') || !(path.is_dir() || is_zip) {
|
||||
return None;
|
||||
}
|
||||
let problem = Pack::open(&path).err().map(|e| e.to_string());
|
||||
Some(Found {
|
||||
name,
|
||||
path,
|
||||
problem,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
found.sort_by_cached_key(|f| (f.name.to_lowercase(), f.name.clone()));
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack::tests::{temp_dir, zip};
|
||||
|
||||
#[test]
|
||||
fn lists_folders_and_zips_with_their_problems() {
|
||||
let dir = temp_dir("discover");
|
||||
fs::create_dir_all(dir.join("Zeta/shaders")).unwrap();
|
||||
fs::create_dir_all(dir.join("Extracted wrong/Inner/shaders")).unwrap();
|
||||
fs::create_dir_all(dir.join(".hidden/shaders")).unwrap();
|
||||
fs::write(dir.join("alpha.ZIP"), zip(&[("shaders/final.fsh", "")])).unwrap();
|
||||
fs::write(dir.join("broken.zip"), "not a zip").unwrap();
|
||||
fs::write(dir.join("alpha.ZIP.txt"), "OPTION=1").unwrap();
|
||||
let found = discover(&dir);
|
||||
let summary: Vec<(&str, bool)> = found
|
||||
.iter()
|
||||
.map(|f| (f.name.as_str(), f.problem.is_none()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
("alpha.ZIP", true),
|
||||
("broken.zip", false),
|
||||
("Extracted wrong", false),
|
||||
("Zeta", true)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
found[2].problem.as_deref(),
|
||||
Some("no shaders folder at the root of the pack")
|
||||
);
|
||||
assert!(discover(&dir.join("missing")).is_empty());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! A pack's `lang/<locale>.lang` files (spec 8.4): `key=value` lines naming options, values,
|
||||
//! screens and profiles.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Pack;
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Lang(HashMap<String, String>);
|
||||
|
||||
impl Lang {
|
||||
/// English with the player's locale over it, so a key the locale lacks still has a name.
|
||||
/// File names match ignoring case: packs ship `en_US.lang` for Minecraft's `en_us`.
|
||||
/// Strings keep their `§` formatting codes for the game's text renderer.
|
||||
pub fn load(pack: &Pack, locale: &str) -> Self {
|
||||
let files = pack.list("lang");
|
||||
let mut lang = Self::default();
|
||||
for wanted in ["en_us", locale] {
|
||||
let file = files.iter().find(|f| {
|
||||
f.strip_suffix(".lang")
|
||||
.is_some_and(|stem| stem.eq_ignore_ascii_case(wanted))
|
||||
});
|
||||
if let Some(text) = file.and_then(|f| pack.read_text(&format!("lang/{f}"))) {
|
||||
lang.read(&text);
|
||||
}
|
||||
}
|
||||
lang
|
||||
}
|
||||
|
||||
fn read(&mut self, text: &str) {
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
self.0
|
||||
.insert(key.trim().to_owned(), value.trim().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack::tests::zip;
|
||||
|
||||
#[test]
|
||||
fn lang_falls_back_by_case_then_english() {
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"P.zip",
|
||||
zip(&[
|
||||
(
|
||||
"shaders/lang/en_US.lang",
|
||||
"# names\noption.A=Alpha\noption.B = §6Beta§r\n",
|
||||
),
|
||||
("shaders/lang/fr_FR.lang", "option.A=Alpha (fr)\n"),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let fr = Lang::load(&pack, "fr_fr");
|
||||
assert_eq!(fr.get("option.A"), Some("Alpha (fr)"));
|
||||
assert_eq!(fr.get("option.B"), Some("§6Beta§r"));
|
||||
assert_eq!(fr.get("# names"), None);
|
||||
let de = Lang::load(&pack, "de_de");
|
||||
assert_eq!(de.get("option.A"), Some("Alpha"));
|
||||
let none = Pack::from_zip_bytes("Q.zip", zip(&[("shaders/final.fsh", "")])).unwrap();
|
||||
assert_eq!(Lang::load(&none, "en_us"), Lang::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Shader packs: the `shaderpacks/*.zip` format, written from Horizon's clean-room specification
|
||||
//! (`shader-packs-spec.md`, revision 1). This crate reads the format and knows nothing of wgpu;
|
||||
//! the game's renderer runs what it produces.
|
||||
//!
|
||||
//! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1).
|
||||
//! - [`discover`]: the packs in a `shaderpacks` folder.
|
||||
//! - [`Lang`]: the pack's translated names (8.4).
|
||||
|
||||
mod discover;
|
||||
mod lang;
|
||||
mod pack;
|
||||
|
||||
pub use discover::{Found, discover};
|
||||
pub use lang::Lang;
|
||||
pub use pack::{Pack, PackError};
|
||||
@@ -0,0 +1,262 @@
|
||||
//! A pack is a folder or a `.zip` whose root holds a folder named `shaders`; everything else at
|
||||
//! the root (licences, readmes, decoy folders) is ignored (spec 1.1). Every path a pack uses is
|
||||
//! relative to that `shaders` folder, written with `/`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::{self, Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
const ROOT: &str = "shaders";
|
||||
|
||||
pub struct Pack {
|
||||
name: String,
|
||||
files: Files,
|
||||
}
|
||||
|
||||
enum Files {
|
||||
/// The pack's `shaders` folder.
|
||||
Folder(PathBuf),
|
||||
/// The archive and its entries under `shaders/`, by their path inside that folder.
|
||||
Zip {
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PackError {
|
||||
Io(io::Error),
|
||||
Zip(String),
|
||||
/// A readable folder or archive with no `shaders` folder at its root.
|
||||
NoShadersFolder,
|
||||
}
|
||||
|
||||
impl fmt::Display for PackError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "{e}"),
|
||||
Self::Zip(e) => write!(f, "not a readable zip: {e}"),
|
||||
Self::NoShadersFolder => f.write_str("no shaders folder at the root of the pack"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PackError {}
|
||||
|
||||
impl From<io::Error> for PackError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pack {
|
||||
/// Opens a pack folder or a `.zip` archive.
|
||||
pub fn open(path: &Path) -> Result<Self, PackError> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
||||
let files = if path.is_dir() {
|
||||
let root = path.join(ROOT);
|
||||
if !root.is_dir() {
|
||||
return Err(PackError::NoShadersFolder);
|
||||
}
|
||||
Files::Folder(root)
|
||||
} else {
|
||||
Self::zip_files(fs::read(path)?)?
|
||||
};
|
||||
Ok(Self { name, files })
|
||||
}
|
||||
|
||||
/// An archive held in memory; `name` is what [`Pack::name`] returns.
|
||||
pub fn from_zip_bytes(name: &str, bytes: Vec<u8>) -> Result<Self, PackError> {
|
||||
let files = Self::zip_files(bytes)?;
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
fn zip_files(bytes: Vec<u8>) -> Result<Files, PackError> {
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(bytes)))
|
||||
.map_err(|e| PackError::Zip(e.to_string()))?;
|
||||
let entries: BTreeMap<String, usize> = (0..archive.len())
|
||||
.filter_map(|i| {
|
||||
let name = archive.name_for_index(i)?.replace('\\', "/");
|
||||
let inner = name.strip_prefix(ROOT)?.strip_prefix('/')?;
|
||||
(!inner.is_empty() && !inner.ends_with('/')).then(|| (inner.to_owned(), i))
|
||||
})
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
return Err(PackError::NoShadersFolder);
|
||||
}
|
||||
Ok(Files::Zip { archive, entries })
|
||||
}
|
||||
|
||||
/// The folder or file name the pack was opened from (`BSL.zip`).
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The bytes of a file, by its path inside `shaders` (`lang/en_us.lang`). `None` when it
|
||||
/// doesn't exist or the path leaves the folder.
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let path = normalize(path)?;
|
||||
match &self.files {
|
||||
Files::Folder(root) => fs::read(root.join(&path)).ok(),
|
||||
Files::Zip { archive, entries } => {
|
||||
// A clone shares the bytes and the central directory; reading needs `&mut`.
|
||||
let mut archive = archive.clone();
|
||||
let mut entry = archive.by_index(*entries.get(&path)?).ok()?;
|
||||
let mut out = Vec::with_capacity(entry.size() as usize);
|
||||
entry.read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text file, decoded as UTF-8 (invalid bytes replaced) without its byte-order mark.
|
||||
pub fn read_text(&self, path: &str) -> Option<String> {
|
||||
let bytes = self.read(path)?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Some(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
|
||||
pub fn exists(&self, path: &str) -> bool {
|
||||
let Some(path) = normalize(path) else {
|
||||
return false;
|
||||
};
|
||||
match &self.files {
|
||||
Files::Folder(root) => root.join(path).is_file(),
|
||||
Files::Zip { entries, .. } => entries.contains_key(&path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the files directly inside a folder of the pack (`""` for `shaders` itself),
|
||||
/// sorted.
|
||||
pub fn list(&self, dir: &str) -> Vec<String> {
|
||||
let Some(dir) = normalize(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = match &self.files {
|
||||
Files::Folder(root) => fs::read_dir(root.join(&dir))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
Files::Zip { entries, .. } => {
|
||||
let prefix = if dir.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{dir}/")
|
||||
};
|
||||
entries
|
||||
.keys()
|
||||
.filter_map(|k| k.strip_prefix(&prefix))
|
||||
.filter(|rest| !rest.contains('/'))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
/// `a/./b/../c` → `a/c`; `None` for a path that climbs out of `shaders`.
|
||||
fn normalize(path: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split(['/', '\\']) {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// A zip of `(path, contents)` pairs, paths from the archive root.
|
||||
pub(crate) fn zip(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
for (path, contents) in files {
|
||||
w.start_file(*path, options).unwrap();
|
||||
w.write_all(contents.as_bytes()).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A fresh empty folder under the system's temp folder.
|
||||
pub(crate) fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shaders-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_packs_read_only_the_shaders_folder() {
|
||||
let bytes = zip(&[
|
||||
("License.txt", "x"),
|
||||
("shaderFile_A.placebo/shaders.properties", "decoy"),
|
||||
("shaders/shaders.properties", "sun=false"),
|
||||
("shaders/lang/en_US.lang", "a=b"),
|
||||
("shaders/lang/fr_FR.lang", "a=c"),
|
||||
("shaders/lib/common.glsl", ""),
|
||||
]);
|
||||
let pack = Pack::from_zip_bytes("P.zip", bytes).unwrap();
|
||||
assert_eq!(pack.read_text("shaders.properties").unwrap(), "sun=false");
|
||||
assert!(pack.exists("lib/common.glsl"));
|
||||
assert!(pack.exists("lib/../shaders.properties"));
|
||||
assert!(!pack.exists("../License.txt"));
|
||||
assert!(!pack.exists("License.txt"));
|
||||
assert_eq!(pack.list("lang"), ["en_US.lang", "fr_FR.lang"]);
|
||||
assert_eq!(pack.list(""), ["shaders.properties"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_without_a_shaders_folder_are_not_packs() {
|
||||
let bytes = zip(&[("Pack/shaders/final.fsh", ""), ("readme.txt", "")]);
|
||||
assert!(matches!(
|
||||
Pack::from_zip_bytes("P.zip", bytes),
|
||||
Err(PackError::NoShadersFolder)
|
||||
));
|
||||
assert!(matches!(
|
||||
Pack::from_zip_bytes("P.zip", b"not a zip".to_vec()),
|
||||
Err(PackError::Zip(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_read_the_same_way() {
|
||||
let dir = temp_dir("folder");
|
||||
fs::create_dir_all(dir.join("shaders/lang")).unwrap();
|
||||
fs::write(
|
||||
dir.join("shaders/lang/en_us.lang"),
|
||||
"\u{feff}option.A=Alpha",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.join("secret.txt"), "outside").unwrap();
|
||||
let pack = Pack::open(&dir).unwrap();
|
||||
assert_eq!(pack.read_text("lang/en_us.lang").unwrap(), "option.A=Alpha");
|
||||
assert_eq!(pack.list("lang"), ["en_us.lang"]);
|
||||
assert!(pack.read("../secret.txt").is_none());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Reads real packs as black boxes: `HORIZON_TEST_SHADER_PACKS=<folder> cargo test -p
|
||||
//! horizon-shaders --release -- --ignored --nocapture`. The folder (and its subfolders) holds
|
||||
//! pack zips or folders, which are never committed; this prints what was read and how long it took.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_shaders::{Lang, Pack, discover};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs HORIZON_TEST_SHADER_PACKS"]
|
||||
fn real_packs_read() {
|
||||
let dir = std::env::var_os("HORIZON_TEST_SHADER_PACKS").expect("HORIZON_TEST_SHADER_PACKS");
|
||||
let dir = Path::new(&dir);
|
||||
let mut found = discover(dir);
|
||||
for sub in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
if sub.path().is_dir() {
|
||||
found.extend(discover(&sub.path()));
|
||||
}
|
||||
}
|
||||
let mut read = 0;
|
||||
for f in found.iter().filter(|f| f.problem.is_none()) {
|
||||
let start = Instant::now();
|
||||
let pack = Pack::open(&f.path).unwrap();
|
||||
let properties = pack.read_text("shaders.properties");
|
||||
let lang = Lang::load(&pack, "en_us");
|
||||
println!(
|
||||
"{}: {} files at the root, shaders.properties {} bytes, lang {}, {:.2} ms",
|
||||
f.name,
|
||||
pack.list("").len(),
|
||||
properties.as_ref().map_or(0, String::len),
|
||||
if lang == Lang::default() {
|
||||
"none"
|
||||
} else {
|
||||
"read"
|
||||
},
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
assert!(properties.is_some(), "{}: no shaders.properties", f.name);
|
||||
read += 1;
|
||||
}
|
||||
assert!(read > 0, "no packs under {}", dir.display());
|
||||
}
|
||||
@@ -9,11 +9,19 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::payload::System;
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
/// What `program args` prints, trimmed, when it succeeds and prints something. On Windows the
|
||||
/// program opens no console window: a GUI launcher or a game started without one would flash a
|
||||
/// terminal for every probe.
|
||||
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let out = command.output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
@@ -318,23 +318,6 @@ impl ChunkData {
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn load_block_section<'r>(
|
||||
&self,
|
||||
block: BlockPos,
|
||||
registry: &'r BlockRegistry,
|
||||
) -> io::Result<Option<BlockSection<'r>>> {
|
||||
let Some(index) = self.section_index(block)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Tag::List(sections) = self.root.tag.get("sections").unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
sections[index]
|
||||
.get("block_states")
|
||||
.map(|tag| BlockSection::from_nbt(tag, registry))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Storage-only mutation. The caller must arrange simulation-derived updates
|
||||
/// before treating this as a gameplay block placement.
|
||||
pub fn set_block_data(&mut self, block: BlockPos, state: BlockState) -> io::Result<BlockState> {
|
||||
|
||||
+2
-1
@@ -13,13 +13,14 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
|
||||
- [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it.
|
||||
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
|
||||
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods, and [what carries over from Java mods](research/java-mod-compat.md).
|
||||
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods.
|
||||
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
|
||||
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
|
||||
- [Versioning](versioning.md): how releases are numbered from 0.2 (`v0.2.x`, `[email protected]`), how installs from before 0.2 get there, and the steps for the day 0.2 ships.
|
||||
- [Resource packs](resource-packs.md): everything a 26.3 resource pack can change, whether Horizon honours it, and the proof.
|
||||
- [Opt-in data collection](telemetry.md): the consent, exactly what is sent, how it is stored and sent.
|
||||
- [Benchmark report format](benchmark-report.md): the versioned JSON every benchmark writes (game, launcher, `tools/perf_bench.py`), the local log and, with consent, the upload.
|
||||
- [Horizon APIs](api/README.md): every surface other programs build on (mods, shaders, agent control, servers, the launcher, data formats), each with its guide, generated reference, stability and changelog, and the [stability and versioning policy](api/stability.md).
|
||||
- Menus: every screen is vanilla's (title, world list, create world, multiplayer, options, pause). Horizon's own settings hang off vanilla's Options the way mods add theirs: **Horizon Settings...** and **Mods...** in the last two slots of the Options grid (Horizon Settings opens Graphics, Advanced Graphics, Controller Settings, Extras and Mods, each a vanilla options list), with Horizon Graphics and Advanced Graphics also at the top of Video Settings and Controller Settings at the bottom of Controls. The title screen's and pause menu's Friends buttons open Join a Friend and Play with Friends ([play-with-friends.md](play-with-friends.md)).
|
||||
|
||||
## Parity
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Horizon APIs
|
||||
|
||||
Horizon has six surfaces that other programs and people build on. Each one has an **API** (the contract: what you
|
||||
may call, read or write, and what Horizon promises about it) and an **SDK** (the tools that help you use it). This
|
||||
section has one page per surface, plus the rules every surface follows:
|
||||
[stability and versioning](stability.md).
|
||||
|
||||
| Surface | For | API today | Stability | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Mods](mods.md) | mod authors | WIT `horizon:[email protected]`, manifest `api` 0.1 (code) and 0.2 (content) | preview | [mods-reference.md](mods-reference.md) (generated) |
|
||||
| [Shaders](shaders.md) | shader authors | none yet: packs were removed on 2026-10-24 | planned | none |
|
||||
| [Agent control](agent-control.md) | agents, scripts, tests | `horizon-ctl`, its MCP server, the game's control socket | preview | [horizon-ctl-commands.md](../horizon-ctl-commands.md) (generated) |
|
||||
| [Servers](server.md) | hosts, panels, bots | `horizon-server` commands, `server.toml`, control API `/api/v1` | stable | [server-reference.md](server-reference.md) (generated) |
|
||||
| [Launcher](launcher.md) | players' agents, pack makers | `horizon-launcher` commands and its MCP server | preview | [launcher-commands.md](../launcher-commands.md) (generated) |
|
||||
| [Data formats](data-formats.md) | tools that read Horizon's files | benchmark reports, telemetry, settings files, signed checksums | stable or preview, per format | on that page |
|
||||
|
||||
## How these APIs are designed
|
||||
|
||||
- **From the people who use them.** Each API starts from what a mod author, a pack author, a server host or an agent
|
||||
needs to do, written down as tasks ("a host backs up the world from a script"), and from how Horizon is built:
|
||||
Rust, wgpu, WebAssembly components and a server thread that runs every world. A function exists because a task
|
||||
needs it.
|
||||
- **Our own design.** Horizon's APIs are not modelled on any other project's. Contributors may know other mod
|
||||
loaders, renderers and server platforms exist, but their source and API references are never read, copied or
|
||||
paraphrased here, and our names, shapes and docs are not taken from theirs. Vanilla Minecraft 26.3 is the reference for game
|
||||
concepts: ids, commands, `server.properties`, data packs and resource packs.
|
||||
- **Safe by default.** Mods run in a sandbox and can only use the capabilities their manifest declares; the control
|
||||
sockets listen on this computer only, behind a token; nothing here is on by default where it would change vanilla's
|
||||
look or behaviour.
|
||||
- **Free when unused.** An API nobody uses costs nothing per tick or per frame: no engine, no thread, no allocation.
|
||||
- **One contract for every mode.** In-frame single player, the server thread, LAN and the dedicated server run the
|
||||
same gameplay code ([AGENTS.md](../../AGENTS.md)), so a server-side API serves all of them.
|
||||
|
||||
## References are generated
|
||||
|
||||
A reference page is written by code from the source it describes, and a test fails when the two differ:
|
||||
|
||||
| Page | Generated from | Update it with |
|
||||
| --- | --- | --- |
|
||||
| [mods-reference.md](mods-reference.md) | `crates/horizon-mod-sdk/wit/horizon.wit`, the manifest's capabilities | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference` |
|
||||
| [server-reference.md](server-reference.md) | `horizon-server`'s help, `config::TEMPLATE`, `api::ROUTES` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference` |
|
||||
| [horizon-ctl-commands.md](../horizon-ctl-commands.md) | the command table in `crates/horizon-client/src/bin/horizon-ctl/commands.rs` | `horizon-ctl help --markdown > docs/horizon-ctl-commands.md` |
|
||||
| [launcher-commands.md](../launcher-commands.md) | `launcher/src/cli.rs` | `HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference` |
|
||||
|
||||
So a pull request that changes an API also changes its reference, and the reviewer sees the API's diff in the
|
||||
reference's diff. The guides (overview, how-to, examples) are written by hand.
|
||||
|
||||
## Each surface's page
|
||||
|
||||
Every page has the same parts: **Overview** (what you can build), **Guide** (where to start), **Reference**,
|
||||
**Examples**, **Stability** (the level of each part, per [stability.md](stability.md)), **Changelog** and
|
||||
**Not there yet** (what is planned, so nobody builds on a guess).
|
||||
|
||||
The website's summary for players and authors is [playhorizon.dev/docs/apis](https://playhorizon.dev/docs/apis)
|
||||
(`website/docs/apis.md`).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Agent control API
|
||||
|
||||
How a program drives a running game: an AI agent through MCP, a script through `horizon-ctl`, a test through the
|
||||
control socket. Design and inventory: [agent-control.md](../agent-control.md); for players:
|
||||
[playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents).
|
||||
|
||||
## Overview
|
||||
|
||||
The game listens on a local control socket (a Unix socket, or localhost TCP with a token on Windows), takes one
|
||||
command per text line and answers one JSON line. `horizon-ctl` wraps it: it starts the game, sends commands and
|
||||
serves the same commands as MCP tools. The launcher's MCP server offers them too, as `game_<tool>`.
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| `horizon-ctl` commands | 96, in 8 groups (session, observe, move and look, act, screens and chat, multiplayer, world and cheats, scripting and benchmarks) |
|
||||
| MCP tools | 88 (8 commands are CLI only) |
|
||||
| Destructive commands (need `--confirm`) | 3 |
|
||||
| Command names the game's socket answers | about 160, test hooks included (`horizon-ctl help --game`) |
|
||||
|
||||
## Guide
|
||||
|
||||
1. Build `horizon-ctl` (`cargo build --profile fast -p horizon-client`), then
|
||||
`horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --wait --resources <prepared files>`.
|
||||
2. `horizon-ctl --socket … state`, `keys`, `turn`, `command`, `screenshot`, `quit`.
|
||||
3. For an agent: `horizon-ctl mcp`, or the launcher's `horizon-launcher mcp` with `agent-config` to connect a client
|
||||
([launcher-commands.md](../launcher-commands.md)).
|
||||
|
||||
## Reference
|
||||
|
||||
[horizon-ctl-commands.md](../horizon-ctl-commands.md), generated from the command table in
|
||||
`crates/horizon-client/src/bin/horizon-ctl/commands.rs`: every command, its MCP tool and its arguments.
|
||||
|
||||
## Examples
|
||||
|
||||
- `tools/agent_control_e2e.py`: starts a game and drives it end to end through `horizon-ctl` and MCP.
|
||||
- `tools/lan_e2e.py`: two games over LAN, both driven through their sockets.
|
||||
|
||||
## Stability
|
||||
|
||||
Preview ([stability.md](stability.md)). The commands in `horizon-ctl`'s table and their MCP tool names are what the
|
||||
API offers; the other commands the game's socket answers are test hooks and experimental.
|
||||
|
||||
## Not there yet
|
||||
|
||||
- **A version.** A `hello` command that answers the protocol version, the game's release and the features it has;
|
||||
MCP `serverInfo` carries the same version; `horizon-ctl` refuses a game whose major it doesn't speak.
|
||||
- **Reply schemas.** Each command's reply described in the table (fields, types, units), so the reference documents
|
||||
replies as well as arguments and MCP tools declare an output schema.
|
||||
- **Events as a stream.** `events --since` polls today; a subscription that pushes events as they happen.
|
||||
- **Test hooks apart.** The hooks only harnesses use move under their own prefix, out of the agent's tool list.
|
||||
- **Compatibility tests.** A transcript per protocol version: commands and the reply keys each must keep.
|
||||
|
||||
## Changelog
|
||||
|
||||
- Unversioned so far: the command table and the generated reference are the contract.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Data formats
|
||||
|
||||
The files Horizon writes that other tools may read: benchmark reports, telemetry, settings, mods, release checksums
|
||||
and worlds. Each entry names its version field, its documentation and its level ([stability.md](stability.md)).
|
||||
|
||||
## Overview
|
||||
|
||||
| Format | Where | Version | Level | Documented in |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Benchmark report | the benchmark's output, the local log, uploads | `"format": "horizon-benchmark-report"`, `"version": 1` | stable | [benchmark-report.md](../benchmark-report.md) |
|
||||
| Telemetry batch | `POST /v1/batch` from the launcher | `"schema": 1` | stable | [telemetry.md](../telemetry.md) |
|
||||
| Telemetry consent | `telemetry/consent.json` in the game folder | `"version": 2` | stable | [telemetry.md](../telemetry.md) |
|
||||
| Mod package | `.hmod`: a zip with `horizon-mod.toml`, `mod.wasm`, `assets/`, `data/` | the manifest's `api` | preview | [mods.md](../mods.md#the-manifest), [mods-reference.md](mods-reference.md) |
|
||||
| Mods turned on | `horizon-mods.txt` | none | preview | [mods.md](../mods.md#playing-with-mods) |
|
||||
| Horizon settings | `horizon-graphics.txt`, `horizon-extras.txt` (`key:value`, as `options.txt`) | none: unknown keys fall back to defaults | preview | [graphics.md](../graphics.md), [extras.md](../extras.md#options-storage) |
|
||||
| Server settings | `server.toml` | its keys | stable | [server-reference.md](server-reference.md) |
|
||||
| Control endpoints | the game's socket file (TCP form), `.horizon/api.json` | none | preview | [agent-control.md](agent-control.md), [server-reference.md](server-reference.md) |
|
||||
| Release checksums | `SHA256SUMS`, `SHA256SUMS.sig` on releases since signing began (#1011) | the signed text names the tag | stable | [security.md](../security.md) |
|
||||
| Worlds | the world folder | vanilla 26.3's (`DataVersion`) | vanilla's | [world-data.md](../world-data.md) |
|
||||
|
||||
Worlds are vanilla's format, so any tool that reads a 26.3 world reads a Horizon world; Horizon adds itself to
|
||||
`level.dat`'s `ServerBrands` as vanilla's server does.
|
||||
|
||||
## Rules for every format
|
||||
|
||||
- A reader ignores fields it doesn't know, and refuses a version newer than it knows with a clear message.
|
||||
- An optional field may be added in the same version; renaming, removing or changing the meaning of a field makes the
|
||||
next version, and Horizon's own readers keep reading the older ones for the deprecation window.
|
||||
- Each versioned format has a sample file of every version its reader accepts, read by a test.
|
||||
|
||||
## Not there yet
|
||||
|
||||
- Versions for the settings files and the control endpoint files (a `version` line or field).
|
||||
- Sample files and reader tests for every versioned format.
|
||||
- A generated schema for the benchmark report and the telemetry batch from their Rust types, checked by a test like
|
||||
the references.
|
||||
|
||||
## Changelog
|
||||
|
||||
- Benchmark report version 1; telemetry schema 1, consent version 2.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Launcher API
|
||||
|
||||
What a player's agent, a script or a pack maker can do with `horizon-launcher`: install and run versions, manage
|
||||
accounts, settings, mods, modpacks and hosted servers, from a terminal or over MCP. The launcher itself:
|
||||
[launcher/README.md](../../launcher/README.md).
|
||||
|
||||
## Overview
|
||||
|
||||
Every page of the launcher has a command, and every command but three has an MCP tool with the same arguments; a tool
|
||||
runs the command with `--json`. Tools that change the player's things for good need `confirm: true`; with No AI on,
|
||||
the MCP server is off.
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Commands | 46: versions, installs, play, the server, notes, links, accounts, settings, logs, updates, data collection, modpacks, mods, hosted servers, MCP, benchmark |
|
||||
| MCP tools | 43, plus the game's tools as `game_<tool>` ([agent-control.md](agent-control.md)) |
|
||||
| Modpack formats it imports | 3: a Modrinth `.mrpack`, a CurseForge zip, an instance folder |
|
||||
|
||||
## Guide
|
||||
|
||||
1. `horizon-launcher versions`, `horizon-launcher install latest`, `horizon-launcher play`.
|
||||
2. Modpacks: `horizon-launcher modpack import <file>`, then `modpack port` and `modpack play`
|
||||
([modpacks.md](../modpacks.md)).
|
||||
3. For an agent: `horizon-launcher agent-config` prints how to connect a client to `horizon-launcher mcp`.
|
||||
|
||||
## Reference
|
||||
|
||||
[launcher-commands.md](../launcher-commands.md), generated from `launcher/src/cli.rs`: every command, its arguments
|
||||
and its MCP tool.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
horizon-launcher versions --installed --json
|
||||
horizon-launcher mods search minimap --kind mod --json
|
||||
horizon-launcher servers create --name "Weekend" --player Alex --json
|
||||
```
|
||||
|
||||
## Stability
|
||||
|
||||
Preview ([stability.md](stability.md)): command names, arguments and MCP tool names are the contract; `--json`
|
||||
output shapes are not yet described in the reference.
|
||||
|
||||
## Not there yet
|
||||
|
||||
- **A Horizon modpack manifest.** A pack made for Horizon (not a port of a Java pack) needs its own file: the pack's
|
||||
id, name and version; the Horizon API lines and the release line it needs; its `.hmod` mods (id, version, SHA-256,
|
||||
where to download it, client, server or both); resource packs; default settings; and whether it has a server side.
|
||||
One manifest serves the launcher, `horizon-server` (a server installs the server side of the same pack) and the mod
|
||||
browser. Designed from what a pack maker does, versioned per [stability.md](stability.md), with a schema test.
|
||||
- **JSON output schemas** for every command, in the generated reference, so MCP tools declare their output.
|
||||
- **A version** in MCP `serverInfo` and `--json` output, as for agent control.
|
||||
- **Compatibility tests**: golden `--json` output per command.
|
||||
|
||||
## Changelog
|
||||
|
||||
- Unversioned so far: the command table and the generated reference are the contract.
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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
|
||||
[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
|
||||
`crates/horizon-mods/tests/index/index-v1.json`, read by the tests with the files it lists.
|
||||
|
||||
Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
|
||||
## `index.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-10-24T18:00:00Z",
|
||||
"projects": [{
|
||||
"id": "coords_hud", "kind": "mod", "name": "Coordinates HUD", "summary": "One line.",
|
||||
"description": "Markdown.", "authors": ["Horizon"], "owners": ["ludovic111"],
|
||||
"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",
|
||||
"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" }
|
||||
}]
|
||||
}],
|
||||
"revoked": [{ "id": "bad_mod", "sha256": "1111...", "reason": "Sent chat messages it didn't declare.", "date": "2026-10-24" }]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `schema` | 1. A reader refuses a higher number with "update Horizon" rather than misread it. |
|
||||
| `projects[].id` | `[a-z0-9_]{1,64}`, unique; a mod's is its manifest's `id`. |
|
||||
| `kind` | `mod`, `modpack`, `resourcepack`, `shader` (Horizon's own shader packs, not Iris or OptiFine packs). Other kinds (data packs, worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
|
||||
| `summary`, `description` | One line for lists; Markdown for the project's page. |
|
||||
| `owners` | GitHub logins allowed to publish updates. |
|
||||
| `license`, `source` | An SPDX expression and an `https://` address of the source code: both required. |
|
||||
| `icon` | `https://`, optional. |
|
||||
| `key` | The author's [minisign](https://jedisct1.github.io/minisign/) public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. |
|
||||
| `versions` | Newest first, at least one. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `revoked` | Files taken down: one file by `sha256`, or every version of `id` without it. The launcher refuses to install them and turns installed copies off with the reason. |
|
||||
|
||||
Readers ignore fields they don't know, so an optional field can be added to schema 1. Renaming or removing a field, or
|
||||
changing what one means, makes schema 2.
|
||||
|
||||
## Before a file is installed
|
||||
|
||||
`index::check_file` runs on the downloaded file, before it reaches the game folder:
|
||||
|
||||
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
|
||||
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`.
|
||||
4. A kind this build can't install is refused with "this Horizon can't install a … yet".
|
||||
|
||||
The game still checks a mod's component imports against its capabilities when it starts it, as for any mod.
|
||||
|
||||
## `.hpack`
|
||||
|
||||
A zip holding `horizon-modpack.toml` and, optionally, `overrides/`. The mods are not inside: the launcher downloads
|
||||
each from the index and checks it as above.
|
||||
|
||||
```toml
|
||||
format = 1
|
||||
id = "cozy" # [a-z0-9_]{1,64}: the profile's folder
|
||||
name = "Cozy"
|
||||
version = "1.0.0"
|
||||
description = "Coordinates and a calm render distance."
|
||||
authors = ["Horizon"]
|
||||
game = "26.3"
|
||||
|
||||
[[mods]]
|
||||
id = "coords_hud"
|
||||
version = "0.1.0"
|
||||
sha256 = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"
|
||||
on = true # optional, default true: turned on in the profile's horizon-mods.txt
|
||||
```
|
||||
|
||||
Unknown keys are refused. `overrides/` may hold only `options.txt`, `horizon-graphics.txt`, `horizon-extras.txt`,
|
||||
`resourcepacks/<name>.zip` and `horizon-mods/.horizon-mod-data/<a listed mod>/...` (a mod's own saved settings,
|
||||
capability `storage`), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
|
||||
them into the profile's game folder.
|
||||
|
||||
## Changelog
|
||||
|
||||
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Mod API
|
||||
|
||||
Mods are Rust crates compiled to sandboxed WebAssembly components. They run in the player's game and, once server
|
||||
mods land, wherever a world is simulated. Design and the runtime: [mods.md](../mods.md).
|
||||
|
||||
## Overview
|
||||
|
||||
A mod today can read the local player and the world clock, show chat lines only that player sees, draw a text panel
|
||||
on the HUD, keep small files of its own, register chat commands, and run on every tick, frame or chat line it asks
|
||||
for. A content mod adds blocks and items from JSON in its `.hmod`, copying vanilla templates, plus tags, recipes,
|
||||
loot, models, textures and structures in its own namespaces. Every capability is declared in the manifest, shown to
|
||||
the player before the mod is turned on, and checked against the component's real imports.
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Interfaces the game offers | 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 |
|
||||
| `cargo horizon-mod` | `new`, `build`, `run` |
|
||||
| Example mods | `coords-hud` (code), `ruby-content` (content) |
|
||||
|
||||
## Guide
|
||||
|
||||
1. `rustup target add wasm32-unknown-unknown`, `cargo install --git https://github.com/ludovic111/horizon
|
||||
cargo-horizon-mod`, then `cargo horizon-mod new my-mod` ([Making a mod](../mods.md#making-a-mod)).
|
||||
2. Implement `Mod` for your type, list the capabilities you import in `horizon-mod.toml`
|
||||
([The manifest](../mods.md#the-manifest)).
|
||||
3. `cargo horizon-mod run` builds the `.hmod` and starts the game with it as a dev mod; after an edit,
|
||||
`cargo horizon-mod build` and the running game reloads it.
|
||||
4. Content mods: [Content mods (API 0.2)](../mods.md#content-mods-api-02).
|
||||
|
||||
## Reference
|
||||
|
||||
[mods-reference.md](mods-reference.md), generated from `crates/horizon-mod-sdk/wit/horizon.wit`: every capability,
|
||||
interface, type, function and hook, with the limits the guide lists (16 HUD lines of 256 characters, 8 chat lines per
|
||||
call, 4 MiB of storage, fuel per call).
|
||||
|
||||
## Examples
|
||||
|
||||
- `crates/horizon-mod-sdk/examples/coords-hud`: coordinates, facing and time on the HUD; `/where`, `/coords`; storage.
|
||||
- `crates/horizon-mod-sdk/examples/ruby-content`: no code; a gem and a block of ruby from vanilla templates, with
|
||||
their recipes, loot, tags, models, textures and names.
|
||||
- `crates/horizon-mods/tests/guest`: a mod that misbehaves on command (spins, panics, grows its memory), for the
|
||||
sandbox tests.
|
||||
|
||||
## Ways in
|
||||
|
||||
One API (the WIT world) and one sandbox (WebAssembly), whatever the mod is written in. Every way in produces a
|
||||
component for the same world, checked against the same capabilities and given the same limits; there is no second
|
||||
scripting runtime in the game.
|
||||
|
||||
| Way in | For | Status | Template and example |
|
||||
| --- | --- | --- | --- |
|
||||
| Rust | most mods; the fastest | first-class: the SDK, `cargo horizon-mod` | `cargo horizon-mod new`, `coords-hud` |
|
||||
| Lua scripts | people who don't want a compiler | planned: a script mod whose code is a `.lua` file, run by Horizon's own interpreter mod | planned, with the same example as Rust |
|
||||
| C, C++, Zig | authors of those languages | to evaluate: bindings generated from the WIT, built without WASI | one each if it passes |
|
||||
| Go (TinyGo), AssemblyScript, JavaScript / TypeScript | authors of those languages | to evaluate: each needs its toolchain to emit a component without WASI imports, or an interpreter mod as for Lua | one each if it passes |
|
||||
|
||||
- **Scripts run inside the sandbox.** The Lua interpreter is itself a WebAssembly mod, built in Rust with the SDK and
|
||||
shipped by Horizon. A script mod's manifest names the interpreter and its own capabilities; the game links the
|
||||
interpreter with only those, so a script can do exactly what a compiled mod with the same manifest can.
|
||||
- **Hot reload.** The game keeps the interpreter compiled and loads the script into a fresh instance when the file
|
||||
changes, without restarting the game; what it saved with `storage` survives, as for dev mods.
|
||||
- **Measured, not guessed.** Each way in is listed with its measured cost on the same tick-heavy example (time and
|
||||
fuel per tick, memory, load time) against Rust, and a language is supported only with a template, an example and a
|
||||
test that builds and loads it in CI.
|
||||
|
||||
## Stability
|
||||
|
||||
Preview ([stability.md](stability.md)): API 0.1 and content 0.2 are served together; a mod built for 0.1.0 keeps
|
||||
loading on every 0.1.x game. Server mods come as their own line, `api = "0.3"`, experimental first.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **Entities**: query and change entities near a position; spawn from a mod's own entity types (data first).
|
||||
- **Block entities and menus**: ticking machines with slots and their screens.
|
||||
- **Screens and UI**: widgets built from vanilla's, a screen the mod owns, a settings page under Options > Mods.
|
||||
- **Networking**: a channel between a mod's two halves, client and server, typed by the mod's own WIT.
|
||||
- **Config**: typed settings the game shows and stores for the mod, on the server too.
|
||||
- **Keys and the crosshair target**: keybinds in Controls; what the player is looking at.
|
||||
- **Script mods and other languages**: see [Ways in](#ways-in).
|
||||
- **SDK**: a headless test runner (load a mod, feed it ticks and events, assert on its output, no window), templates
|
||||
per kind of mod, typed wrappers in the SDK for every interface. Dev mods already reload when rebuilt.
|
||||
|
||||
## Changelog
|
||||
|
||||
- API 0.2 (content): data-driven blocks and items, tags, `horizon:add_to`, terrain adaptation.
|
||||
- API 0.1.0: `log`, `game`, `chat`, `hud`, `storage`; `init`, `tick`, `frame`, `command`, `chat`.
|
||||
@@ -0,0 +1,137 @@
|
||||
<!-- Generated from crates/horizon-server/src (the help text, config::TEMPLATE, api::ROUTES) by reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference -->
|
||||
# Server reference
|
||||
|
||||
Everything `horizon-server` offers a host or a tool: its commands, every `server.toml` key and the control API. How to host: [hosting.md](../hosting.md); the design: [server.md](../server.md); stability and versions: [stability.md](stability.md).
|
||||
|
||||
## Commands
|
||||
|
||||
`horizon-server help` prints:
|
||||
|
||||
```text
|
||||
horizon-server: host a Horizon (Minecraft Java Edition 26.3) server
|
||||
|
||||
Usage: horizon-server [--dir DIR] <command>
|
||||
|
||||
init Set up a server here: the EULA, the world, the port, who may join (asks, or takes flags:
|
||||
--eula --world NAME --seed SEED --port N --whitelist/--no-whitelist --player NAME... --op NAME
|
||||
--offline --yes)
|
||||
run Run the server in the foreground; type console commands, Ctrl-C stops it cleanly. It
|
||||
restarts the game if it crashes.
|
||||
start Run the server in the background (its output goes to logs/supervisor.log)
|
||||
stop, restart
|
||||
status [--json]
|
||||
Up or not, TPS, MSPT, players, RAM and CPU
|
||||
cmd COMMAND...
|
||||
Run a console command on the running server and print what it answers
|
||||
logs [-f] [-n N]
|
||||
The last N lines of the log (default 50); -f follows it
|
||||
backup [NAME]
|
||||
Zip the world and the settings into backups/ (a running server saves first and stays up)
|
||||
backups [--json]
|
||||
List the backups
|
||||
restore BACKUP [--yes]
|
||||
Put a backup's world in place (the current one is kept in backups/); a running
|
||||
server restarts with it
|
||||
service install|uninstall|print [--system]
|
||||
Start the server at boot or login: a systemd unit on Linux (--system: for every user,
|
||||
with sudo), a launchd agent on macOS, a task at logon on Windows
|
||||
panel A one-time login link for the web panel ([panel] in server.toml; 127.0.0.1:8130 by
|
||||
default)
|
||||
update [--check [--json]] [--version TAG] [--timeout SECONDS]
|
||||
Install the newest release (checked against GitHub's SHA-256), back up the world and
|
||||
restart on it; goes back by itself if the game isn't up within the timeout (300 s).
|
||||
--archive FILE --version NAME installs an archive you have
|
||||
rollback Back to the release used before the update
|
||||
config [--json]
|
||||
The settings in server.toml
|
||||
config set KEY=VALUE... [--json]
|
||||
Change settings (for example world.difficulty=hard server.max_players=10), checked first;
|
||||
a running server takes the difficulty, default game mode and whitelist at once, the rest
|
||||
at its next start
|
||||
import FILE Write server.toml from a vanilla server.properties
|
||||
op NAME, deop NAME
|
||||
whitelist on|off|list, whitelist add|remove NAME
|
||||
Who may join (on the running server, or in the files when it is stopped)
|
||||
|
||||
--dir DIR The server folder (default: the current folder)
|
||||
|
||||
Settings live in server.toml; the server writes server.properties from it before every start.
|
||||
```
|
||||
|
||||
## `server.toml`
|
||||
|
||||
Horizon server settings (docs/server.md). The server writes them into server.properties before every start: edit this file, not that one. Unknown keys are refused. `horizon-server init` writes this file with these defaults; `horizon-server config set SECTION.KEY=VALUE` changes it.
|
||||
|
||||
### `[server]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `motd` | `"A Minecraft Server"` | `motd` | the line under the server's name in the server list |
|
||||
| `port` | `25565` | `server-port` | the port players connect to |
|
||||
| `bind` | `""` | `server-ip` | listen on every address, or only on this one (server-ip) |
|
||||
| `max_players` | `20` | `max-players` | how many players can be online at once |
|
||||
| `online_mode` | `true` | `online-mode` | check players' Minecraft accounts (false: anyone joins under any name) |
|
||||
| `whitelist` | `true` | `white-list` | only whitelisted players and operators can join |
|
||||
| `enforce_whitelist` | `false` | `enforce-whitelist` | kick online players taken off the whitelist |
|
||||
| `view_distance` | `10` | `view-distance` | chunks sent around each player |
|
||||
| `simulation_distance` | `10` | `simulation-distance` | chunks around each player where mobs, crops and redstone tick |
|
||||
|
||||
### `[world]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `"world"` | `level-name` | the world folder |
|
||||
| `seed` | `""` | `level-seed` | empty: a random seed on the first start |
|
||||
| `game_mode` | `"survival"` | `gamemode` | survival, creative, adventure or spectator |
|
||||
| `difficulty` | `"easy"` | `difficulty` | peaceful, easy, normal or hard |
|
||||
| `hardcore` | `false` | `hardcore` | one life: a player who dies becomes a spectator |
|
||||
|
||||
### `[properties]`
|
||||
|
||||
Any other server.properties key, spelled as vanilla spells it, for example: spawn-protection = 16
|
||||
|
||||
### `[engine]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `path` | `""` | | the game program; empty: the one installed beside horizon-server |
|
||||
| `resources` | `""` | | the prepared game files; empty: resources/ here, else the usual places |
|
||||
| `restart_on_crash` | `true` | | start the game again when it crashes |
|
||||
|
||||
### `[backups]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `every` | `"6h"` | | automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off |
|
||||
| `keep` | `12` | | automatic backups kept (backups made by hand are never deleted) |
|
||||
|
||||
### `[updates]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `keep` | `1` | | other releases kept by updates, besides the one in use and the one rollback goes to |
|
||||
|
||||
### `[panel]`
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `bind` | `"127.0.0.1:8130"` | | the web panel: this computer only, or a tailnet address (100.x); "" turns it off |
|
||||
| `allow_public` | `false` | | true lets bind be any address; never put the panel on the open internet |
|
||||
|
||||
## Control API (`/api/v1`)
|
||||
|
||||
HTTP and JSON on 127.0.0.1. A running server writes its address and a random token to `.horizon/api.json`, readable only by its own user; every request sends `Authorization: Bearer <token>` and a `Host` that is that address. Errors answer `{"error": "..."}` with 400, 401, 403, 404, 405 or 409. The web panel uses the same routes with its session.
|
||||
|
||||
| Method | Path | Takes | Answers | What it does |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `GET` | `/api/v1/status` | nothing | `phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update` | How the server does |
|
||||
| `GET` | `/api/v1/metrics` | `?since=T` (seconds since 1970) | `{"samples": [...]}`: one a second, the last 10 minutes | The samples taken after T, for graphs |
|
||||
| `GET` | `/api/v1/logs` | `?since=N` | `{"next": N, "lines": [...]}` | The log lines after the Nth (the last 2000 are kept) |
|
||||
| `POST` | `/api/v1/command` | `{"line": "<one command>"}` | the lines the command logged | Run a console command on the running game |
|
||||
| `POST` | `/api/v1/stop` | nothing | `{"ok": true}` | Stop the game and the supervisor |
|
||||
| `POST` | `/api/v1/restart` | nothing | `{"ok": true}` | Restart the game |
|
||||
| `GET` | `/api/v1/backups` | nothing | `{"backups": [{"name", "bytes"}]}` | List the backups |
|
||||
| `POST` | `/api/v1/backups` | `{"name": "..."}` (optional) | `{"name": "<file>"}` | Back up now (a running game saves first and stays up) |
|
||||
| `POST` | `/api/v1/restore` | `{"name": "<backup>"}` | `{"name": "<backup>"}` | Restart the game with that backup's world (the current one is kept in backups/) |
|
||||
| `POST` | `/api/v1/update` | `{"tag": "...", "timeout": seconds}` (both optional) | `{"ok": true}`; `status` says how it went (`update`) | Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout |
|
||||
| `POST` | `/api/v1/panel/login` | nothing | `{"url": "..."}` | A one-time login link for the web panel (with the token only, not from the panel) |
|
||||
@@ -0,0 +1,60 @@
|
||||
# Server API
|
||||
|
||||
What a host, a panel or a bot can do with a Horizon server: the `horizon-server` commands, `server.toml` and the
|
||||
control API. How to host: [hosting.md](../hosting.md); the design: [server.md](../server.md).
|
||||
|
||||
## Overview
|
||||
|
||||
`horizon-server` sets up a server folder, runs the game as a dedicated server under a supervisor (restarts on a crash,
|
||||
backups on a schedule, updates with a rollback), and lets other programs manage it through a local HTTP API with a
|
||||
token. The web panel uses that API.
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Commands | 22 (`init`, `run`, `start`, `stop`, `restart`, `status`, `cmd`, `logs`, `backup`, `backups`, `restore`, `service`, `panel`, `update`, `rollback`, `config`, `config set`, `import`, `op`, `deop`, `whitelist`, `version`) |
|
||||
| `server.toml` keys | 22 in 6 sections, plus `[properties]` for any other vanilla key |
|
||||
| Control API endpoints | 11 under `/api/v1` |
|
||||
|
||||
## Guide
|
||||
|
||||
1. `horizon-server init` in an empty folder, then `horizon-server start` ([hosting.md](../hosting.md)).
|
||||
2. From a script: `horizon-server status --json`, `horizon-server cmd say hi`, `horizon-server backup`.
|
||||
3. From a program: read `.horizon/api.json` (address and token), then `GET /api/v1/status` with
|
||||
`Authorization: Bearer <token>`.
|
||||
|
||||
## Reference
|
||||
|
||||
[server-reference.md](server-reference.md), generated from `horizon-server`'s source: the help text, every
|
||||
`server.toml` key with its default and the `server.properties` key it writes, and every endpoint.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
token=$(jq -r .token .horizon/api.json); address=$(jq -r .address .horizon/api.json)
|
||||
curl -s -H "Authorization: Bearer $token" "http://$address/api/v1/status"
|
||||
curl -s -H "Authorization: Bearer $token" -d '{"line": "list"}' "http://$address/api/v1/command"
|
||||
```
|
||||
|
||||
## Stability
|
||||
|
||||
Stable ([stability.md](stability.md)): the commands, `server.toml`'s keys and `/api/v1`. A new key or endpoint is an
|
||||
addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
|
||||
## Not there yet
|
||||
|
||||
- **Players**: list with ping and position, kick, ban and pardon, op and deop, the whitelist, as endpoints (today only
|
||||
through `cmd` and the files).
|
||||
- **Console stream**: the log as a stream (server-sent events) rather than `logs?since=N` polling; chat, joins, leaves,
|
||||
deaths and crashes as typed events.
|
||||
- **Metrics**: a plain-text metrics endpoint any monitoring tool can scrape (TPS, MSPT percentiles, players, chunks,
|
||||
entities, RAM, CPU), from the samples `status` already takes.
|
||||
- **Backups**: delete and download one; restore a single dimension.
|
||||
- **Server mods**: list, turn on and off, and their settings, once the mod API runs on the server
|
||||
([mods.md](mods.md)).
|
||||
- **An MCP server** for the same endpoints, so a host's agent manages the server as it manages the game.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
|
||||
## Changelog
|
||||
|
||||
- `api::ROUTES` lists the endpoints; the reference is generated from it.
|
||||
- `/api/v1`: status, metrics, logs, command, stop, restart, backups, restore, update, panel login.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Shader API
|
||||
|
||||
Horizon has no shader packs today: the earlier implementation was removed on 2026-10-24. Two things will replace it,
|
||||
designed separately: a **Horizon shader API**, described here, and a clean-room loader for the existing pack format
|
||||
(its own spec; it is not part of this API). Both sit on the same renderer hooks, agreed between the two.
|
||||
|
||||
## Overview: what a shader author will be able to do
|
||||
|
||||
Write WGSL against documented stages of Horizon's renderer, with typed inputs, options the player can change, a
|
||||
reload as soon as a file is saved and an overlay that shows what each pass costs and draws. The renderer keeps
|
||||
everything that makes it fast (culling, batching, LOD, the mesher); a pack changes how surfaces and light look, not
|
||||
how the world is fed to the GPU.
|
||||
|
||||
## The design
|
||||
|
||||
- **A pack** is a folder or a zip with `horizon-shaders.toml` (id, name, version, the `api` line, the stages it
|
||||
uses, its options) and WGSL files. No preprocessor and no other shading language: WGSL is what wgpu validates and
|
||||
compiles for Vulkan, Metal and DirectX 12.
|
||||
- **Stages, not pipelines.** A stage is a function with a fixed signature that the renderer calls inside its own pass,
|
||||
for example a terrain material (`fn material(input: TerrainInput) -> Surface`), the lighting of the visible
|
||||
surfaces, the sky, an optional shadow pass, and a chain of full-screen post passes ending in the frame. A pack
|
||||
that leaves a stage out gets vanilla's look for it.
|
||||
- **Typed inputs.** Each stage receives structs (the frame: time, camera, sun and moon, weather, fog; the surface:
|
||||
position, normal, block and its light) whose WGSL is generated from the Rust types the renderer uploads. The
|
||||
generated reference lists every field with its unit and range, and a test fails when it drifts.
|
||||
- **Options are WGSL `override` constants.** The manifest declares each option (on/off, a number in a range, a choice)
|
||||
with its default; the game shows them on the pack's settings screen and sets them when it builds the pipeline. No
|
||||
text substitution, and every option combination is validated the same way.
|
||||
- **Hot reload.** The game watches the pack's folder. A saved file is validated with naga and the stage rebuilt; on an
|
||||
error the last good version keeps drawing and the error, with its file and line, is shown in the overlay.
|
||||
- **Debug overlay.** GPU time per stage and pass (timestamp queries), the targets each pass writes (show any of them
|
||||
full-screen), the pack's options and its compile errors.
|
||||
- **Budgets.** A pack declares the targets and passes it needs; the renderer refuses more than its limits, and the
|
||||
benchmark ([benchmark-report.md](../benchmark-report.md)) records the pack and its cost, so "fast" stays measured.
|
||||
- **Off by default.** Without a pack the renderer is vanilla's look, and no pack code runs.
|
||||
|
||||
## Guide, reference and examples
|
||||
|
||||
None yet. When the first stage lands: a guide here, a generated reference (`shaders-reference.md`) and an example pack
|
||||
in the repository that the renderer's tests load.
|
||||
|
||||
## Stability
|
||||
|
||||
Not started. The first stages will be experimental (opt-in), then preview once a reference, an example and a test
|
||||
pack exist ([stability.md](stability.md)).
|
||||
|
||||
## Not there yet
|
||||
|
||||
Everything on this page. 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.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-24: the earlier shader pack code was removed; this API is designed from scratch.
|
||||
@@ -0,0 +1,103 @@
|
||||
# API stability and versioning
|
||||
|
||||
The rules every Horizon API follows ([the APIs](README.md)): how each one is versioned, what the stability levels
|
||||
promise, how something is deprecated and removed, and the tests that keep old mods, scripts and tools working.
|
||||
|
||||
## Versions
|
||||
|
||||
- **Each API has its own version**, separate from the game's release (`v0.2.14`, [versioning.md](../versioning.md))
|
||||
and from Minecraft's. A game release says which API versions it serves.
|
||||
- **Semantic versioning**, `MAJOR.MINOR.PATCH`. A **line** is what stays compatible: `0.1`, `0.2`, … before 1.0,
|
||||
then `1`, `2`, ….
|
||||
- Before 1.0: an addition makes the next patch (`0.1.0` → `0.1.1`); a breaking change starts the next line
|
||||
(`0.2.0`).
|
||||
- From 1.0: an addition makes the next minor (`1.0` → `1.1`); a breaking change starts the next major (`2.0`).
|
||||
- **Where the version lives:**
|
||||
|
||||
| API | The version | A user names the line they need |
|
||||
| --- | --- | --- |
|
||||
| Mods | the WIT package (`horizon:[email protected]`) | `api = "0.1"` in `horizon-mod.toml` |
|
||||
| Agent control | the control protocol's version (planned: a `hello` reply and MCP `serverInfo`) | `horizon-ctl --api 1` (planned) |
|
||||
| Server control API | the path (`/api/v1/`) | the path |
|
||||
| `server.toml` | the file's keys; a key is never renamed in place | nothing: old files keep loading |
|
||||
| Launcher commands | the command names and their `--json` output | nothing |
|
||||
| Data formats | a `version` or `schema` field in the file | the reader rejects versions newer than it knows |
|
||||
|
||||
## What counts as a breaking change
|
||||
|
||||
Breaking: removing or renaming anything; changing a type, a default, a unit or a meaning; making something optional
|
||||
required; refusing input that was accepted; a new case in a WIT `enum` or `variant` the game sends to mods (an older
|
||||
component cannot decode it); a JSON field that changes type.
|
||||
|
||||
Not breaking: a new function, interface, capability, command, endpoint, tool or `server.toml` key; a new optional
|
||||
argument or JSON field (readers must ignore fields they don't know); a new WIT case the mod *sends* (the game decodes
|
||||
every case it ever served); faster, or fixing behaviour to what the reference already says.
|
||||
|
||||
## Stability levels
|
||||
|
||||
Every part of an API has one level, shown in its reference and its surface page.
|
||||
|
||||
| | Experimental | Preview | Stable |
|
||||
| --- | --- | --- | --- |
|
||||
| Who may use it | opt-in only (a flag or a manifest field naming it); never in a release default | anyone; marked "preview" where players see it (a mod's row on the Mods page) | anyone |
|
||||
| May change | in any release, without notice | only at a new line, after a deprecation | only at a new major, after a deprecation |
|
||||
| Deprecation window | none | at least 30 days, with one release line serving old and new | at least 180 days, with the old major served beside the new one |
|
||||
| Needs | a design note | a generated reference, a guide section, an example, compatibility tests | the same, plus one line of real use without a break |
|
||||
|
||||
- **Promotion.** Experimental → preview when it has what the table asks. Preview → stable when real users (a published
|
||||
mod, a host's script, an agent's tool) used it for a whole line and nothing broke. The maintainers' orchestrator
|
||||
proposes the promotion with the evidence (the compatibility tests, the complete docs, that line of use) and Ludovic
|
||||
approves it.
|
||||
- **Horizon-only by design.** An API is stable only for what Horizon itself does; a vanilla concept it exposes (a block
|
||||
id, a command, a `server.properties` key) changes when vanilla changes it, with a version port
|
||||
([version-ports.md](../version-ports.md)), and the changelog says so.
|
||||
|
||||
## Deprecation and removal
|
||||
|
||||
1. The pull request that deprecates marks it where it is defined (a `/// Deprecated: use X; goes in 0.3.` doc comment,
|
||||
WIT's `@deprecated`, a `deprecated` field in a tool's schema) and adds the replacement in the same release.
|
||||
2. The reference shows it as deprecated, and the surface page's changelog says what replaces it and when it goes.
|
||||
3. Using it logs one warning per mod, session or client, naming the replacement; it keeps working.
|
||||
4. It is removed only after the window, and only at the next line (preview) or major (stable).
|
||||
5. A line or major that is no longer served is refused with a clear message (`needs API 0.1; this Horizon serves 0.3
|
||||
and 0.4`), never loaded half-way.
|
||||
|
||||
## Compatibility tests
|
||||
|
||||
Every preview and stable API has tests that run old users against the current code, in CI:
|
||||
|
||||
| API | The test |
|
||||
| --- | --- |
|
||||
| Mods | a test mod per served line, built from source against that line's WIT and SDK, loaded by today's runtime with expected output (`crates/horizon-mods/tests/`); a line is served as long as its test mod passes |
|
||||
| Agent control | a transcript per protocol version: commands and the reply keys each must have |
|
||||
| Server | `api::ROUTES` against `route()` (generated reference); a `server.toml` from each release line parses to the same settings; golden replies per endpoint |
|
||||
| Launcher | the generated reference; golden `--json` output per command |
|
||||
| Data formats | a sample file of every version a reader accepts, read by today's reader |
|
||||
|
||||
The generated references are the API's own diff: a pull request that changes an API changes its reference, and the
|
||||
reviewer applies the rules above to that diff.
|
||||
|
||||
## Changelog
|
||||
|
||||
Each surface page ends with a changelog: one line per change, newest first, with the API version and the pull request.
|
||||
Deprecations and breaking changes are in bold. The release notes of the game release that ships a change link it.
|
||||
|
||||
## Today
|
||||
|
||||
| Part | Level | Version |
|
||||
| --- | --- | --- |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 |
|
||||
| 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 |
|
||||
| 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 | |
|
||||
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
|
||||
| The game's own control-socket commands outside `horizon-ctl`'s table (test hooks) | experimental | |
|
||||
| `horizon-server` commands, `server.toml` | stable | `horizon-server --version` |
|
||||
| Server control API | stable | `/api/v1` |
|
||||
| `horizon-launcher` commands and MCP tools | preview | none yet (unversioned) |
|
||||
| Benchmark report | stable | `"version": 1` |
|
||||
| Telemetry batch and consent | stable | `"schema": 1`, consent `2` |
|
||||
| `horizon-graphics.txt`, `horizon-extras.txt`, `horizon-mods.txt` | preview | none |
|
||||
| `SHA256SUMS` and `SHA256SUMS.sig` | stable | |
|
||||
@@ -41,7 +41,7 @@ file selects ~103.
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `L/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `L/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
|
||||
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
|
||||
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
|
||||
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/boat_passengers.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
|
||||
| 28 | Villagers, trading, raids (50) | `L/mobs/villager*.rs`, `L/merchant.rs`, `G/trading.rs`, `G/villagers.rs`, `L/raid.rs`, `G/raids.rs`, `L/gossip.rs` | trading, raid, mob_ai |
|
||||
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
|
||||
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# How Horizon is built
|
||||
|
||||
Who writes Horizon, how changes are checked, and why it is safe to run. You can check every claim on this page in the repository, and anything not done yet is marked as not done.
|
||||
|
||||
This page is also on the website: [playhorizon.dev/docs/how-horizon-is-built](https://playhorizon.dev/docs/how-horizon-is-built). The longer version, with what is still missing, is [Security](security.md).
|
||||
|
||||
## Who builds it
|
||||
|
||||
Horizon is Ludovic Marie's project: he decides what it does and what ships. **Most of the code is written by AI agents**: Claude sessions that Ludovic runs, and contributors' own AI tools. Some contributors write their code by hand.
|
||||
|
||||
Agents say what they are. A pull request opened by one of Ludovic's agents starts with "Opened by Ludovic's AI agent (Claude), not by Ludovic himself.", its comments start with "🤖 Agent:", and its commits name the agent in a `Co-Authored-By` line.
|
||||
|
||||
## How changes are checked
|
||||
|
||||
Not every line is read by a person. Instead, every change is checked against the real game, automatically:
|
||||
|
||||
- **Harnesses.** A harness is a test that gives the same inputs to Horizon and to the real Minecraft 26.3, then compares the results. There are 326 of them (25 October 2026), from world generation to mobs, redstone and commands. The [status table](https://playhorizon.dev/#parity) shows what they cover. Passing them all does not prove that nothing else differs.
|
||||
- **Checks on every pull request.** `verify` runs on GitHub's machines for every pull request and every change to main: formatting, lints with warnings treated as errors, the tests, and the check that no Minecraft files are committed. Main takes no change without it. `parity` runs the harnesses a change can affect, on a machine that holds a copy of Minecraft. For a pull request from a fork, it runs once a maintainer has read the change.
|
||||
- **Outside contributions are checked again.** Before a contributor's pull request merges, Ludovic's agents compare what it claims with vanilla's own code, review it line by line, rewrite the weak parts, and test it in single player, on a LAN and on a dedicated server where those apply.
|
||||
- **Releases are tested first.** A release is published only after its smoke test passes (the game starts and draws a world) and while no issue is labelled `release-blocker`.
|
||||
|
||||
## Why it's safe to run
|
||||
|
||||
- **Open source.** All of the code is public on [GitHub](https://github.com/ludovic111/horizon), under the MIT licence.
|
||||
- **Built from the public code.** Releases are built by GitHub Actions, the build service of GitHub, from the public source. Linux and Windows are built on GitHub's machines, and macOS on Ludovic's Mac mini, which runs GitHub Actions jobs. Every build log is [public](https://github.com/ludovic111/horizon/actions/workflows/release.yml).
|
||||
- **Signed checksums.** Every release has a `SHA256SUMS` file, a fingerprint of each download, and `SHA256SUMS.sig`, a signature of that list made with Horizon's release key, which only release builds from main can use. The launcher and `horizon-server update` check both before they install anything, and refuse a file that does not match. The very first server install with `install.sh` checks the fingerprint but not the signature.
|
||||
- **Not yet signed by Apple or Microsoft.** The macOS app is not notarized by Apple, because it is not signed with a paid Apple developer account, so macOS asks you to confirm the first time ([Install](https://playhorizon.dev/docs/install#download-the-launcher) shows how). The Windows programs are not code-signed either, so Windows may warn you too.
|
||||
- **Mods run in a sandbox.** Horizon mods are WebAssembly programs with no access to your files, the network or other programs. A mod lists what it needs, you see that list before you turn it on, and it can do nothing else. See [Mods](mods.md).
|
||||
- **Nothing collected unless you say yes.** Data collection is off until you turn it on, with four separate switches. The one exception is an anonymous "I'm playing" ping for the launcher's player count, which holds nothing about you. See [Opt-in data collection](telemetry.md).
|
||||
|
||||
## What it doesn't contain
|
||||
|
||||
- **No Minecraft files.** Horizon contains no Minecraft code, assets or data, and never downloads them. The launcher builds the game files on your computer from your own copy of Minecraft: Java Edition 26.3. A check that runs on every pull request (`tools/check_public_tree.py`) keeps game files and decompiled code out of the repository and its history.
|
||||
- **No code from other mods.** Since 24 October 2026, Horizon's code may only be written from Minecraft 26.3 itself and public documentation, never from another mod, loader or renderer ([the rule](../CONTRIBUTING.md#only-original-code)). That day shader packs were removed ([#1561](https://github.com/ludovic111/horizon/pull/1561)), after a developer of Iris showed that our shader pack code had been written from Iris's source. They are being written again from a description of the pack format only.
|
||||
|
||||
## Check it yourself
|
||||
|
||||
- **Read the code and its checks.** [The source](https://github.com/ludovic111/horizon), [the checks on every pull request](https://github.com/ludovic111/horizon/actions/workflows/ci.yml) and [the release builds](https://github.com/ludovic111/horizon/actions/workflows/release.yml).
|
||||
- **Compare the fingerprints.** Put a release's files and its `SHA256SUMS` in one folder and run `sha256sum -c SHA256SUMS --ignore-missing` (on macOS `shasum -a 256 -c SHA256SUMS --ignore-missing`, on Windows `Get-FileHash <file>` in PowerShell). Each file should say `OK`.
|
||||
- **Check the signature.** Download a whole release into a folder, then from a clone of the repository run `cargo run -p horizon-release-sign -- verify --tag <tag> <folder>`. The public key is in `crates/horizon-release-sign/release-keys.txt`.
|
||||
- **Build it yourself.** The README explains how to [build from source](../README.md#build-from-source).
|
||||
+1
-1
@@ -6,7 +6,7 @@ packs, see how well each one runs before installing it, and install it with its
|
||||
|
||||
## What it offers
|
||||
|
||||
Horizon doesn't run Java code ([research/java-mod-compat.md](research/java-mod-compat.md)). It ports what a Java mod
|
||||
Horizon doesn't run Java code ([mods.md](mods.md)). It ports what a Java mod
|
||||
carries as data (blocks, items, recipes, loot tables, tags, worldgen, textures, names) with `horizon-port-pack`, and
|
||||
says before install which of the mod's features that leaves out. Horizon `.hmod` mods aren't on Modrinth or CurseForge
|
||||
yet (Modrinth has no `horizon` loader).
|
||||
|
||||
+12
-12
@@ -7,9 +7,8 @@ pipeline, not a list of hand ports. It takes a pack as players download it and p
|
||||
Horizon mods. The pipeline is AI agents in dependency order, each port checked against the
|
||||
original mod running in a dev-only Java instance.
|
||||
|
||||
This page is the design. It builds on [mods.md](mods.md) (the mod API, WASM in wasmtime) and
|
||||
[research/java-mod-compat.md](research/java-mod-compat.md) (why Java jars and Mixins cannot
|
||||
run in Horizon, so we port instead). The ATM10 inventory behind the numbers below is
|
||||
This page is the design. It builds on [mods.md](mods.md) (the mod API, WASM in wasmtime, and
|
||||
why Java jars cannot run in Horizon, so we port instead). The ATM10 inventory behind the numbers below is
|
||||
[modpacks/atm10-mods.csv](modpacks/atm10-mods.csv).
|
||||
|
||||
## In one picture
|
||||
@@ -141,12 +140,13 @@ derivative work. These rules are part of the pipeline, not advice to users:
|
||||
has given written permission, recorded in the index entry. The tooling refuses to package
|
||||
anything else. No-derivatives and non-commercial licences (14 in ATM10) are never published.
|
||||
For them, even a local port gets a warning that the licence may not allow it.
|
||||
4. **Mod code never enters this repository.** Mod jars, decompiled sources, Java oracle builds
|
||||
4. **Mod code never enters this repository, and nobody reads it.** Mod jars, Java oracle builds
|
||||
with mod code, and ports of mods we have no permission to publish all live outside the
|
||||
repo: in the user's pipeline work directory, or for our own development in
|
||||
`~/code/horizon-parity/modports/` (dev-only, never pushed). Ports are written from
|
||||
observed behaviour and the harness, with decompiled code as a local reference only. The
|
||||
repository holds the pipeline, the API, metadata (ids, names, versions, licences, counts)
|
||||
`~/code/horizon-parity/modports/` (dev-only, never pushed). A mod's code is never
|
||||
decompiled, read, quoted or paraphrased, by people or agents. Ports are written only from
|
||||
the mod's observed behaviour (the oracle and the harness), its own data files and its
|
||||
public documentation. The repository holds the pipeline, the API, metadata (ids, names, versions, licences, counts)
|
||||
and test fixtures we write ourselves.
|
||||
5. **Contacting authors or publishing any port is Ludovic's call.** Agents ask first.
|
||||
|
||||
@@ -177,7 +177,7 @@ frame, chat and command hooks. Server mods do not run yet. What a content pack n
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable |
|
||||
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
|
||||
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
|
||||
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone ([java-mod-compat.md](research/java-mod-compat.md#3-a-porting-guide-outline)) |
|
||||
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
|
||||
| Client interfaces: `keys`, `target`, `inventory-read`, `ui`, `render`, `world-read` | planned | client tier (38) | 0.2–0.4 | as in [mods.md](mods.md#planned-interfaces) |
|
||||
|
||||
Two rules from mods.md stay: **the sandbox** (no files, sockets or processes; memory cap; fuel
|
||||
@@ -711,16 +711,16 @@ For tiers logic, machines and deep, in dependency order, as many agents at once
|
||||
budget allows:
|
||||
|
||||
- **Input:** the inventory record, the carried-over data, the oracle's exports and traces, the
|
||||
harness, the Horizon mod API docs, the ports of its dependencies, and the mod's decompiled
|
||||
code as a local reference (dev-only, in the work directory).
|
||||
harness, the Horizon mod API docs, the ports of its dependencies, and the mod's public
|
||||
documentation. Never its code: the agent doesn't decompile, read or quote it.
|
||||
- **Output:** a Rust crate in `mods/<id>/port/` built with `cargo horizon-mod build`, and its
|
||||
`.hmod`.
|
||||
- **Loop:** write → build → run the harness → read the differences → fix, until the harness
|
||||
passes or the agent reports what blocks it. Usually that is an API gap, which becomes an
|
||||
issue against the mod API ("needs a scheduled-tick hook"), the way version ports turn
|
||||
differences into work items.
|
||||
- **Rules:** the agent writes the port from observed behaviour and the harness, not by
|
||||
transliterating decompiled Java; ports follow the mod's licence ([Licences](#licences)).
|
||||
- **Rules:** the agent writes the port from observed behaviour, the harness and the mod's
|
||||
own data files only; ports follow the mod's licence ([Licences](#licences)).
|
||||
Players without an AI agent get the carry-over tiers. A port the licence lets us publish can
|
||||
be downloaded instead of regenerated.
|
||||
|
||||
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user