Merge branch 'main' into fix/home-leak-tests

This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-25 01:09:27 +02:00
commit 75983739ec
230 files changed
+6340 -8129

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -95
View File
@@ -430,7 +430,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -442,101 +442,7 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+8 -3
View File
@@ -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'
+1 -1
View File
@@ -47,7 +47,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `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`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
+9
View File
@@ -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
+44 -6
View File
@@ -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",
]
@@ -1278,6 +1278,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"ureq",
@@ -1314,7 +1315,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.5.10",
"spake2",
]
@@ -1377,6 +1378,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-core",
"horizon-world",
"ipconfig",
"serde_json",
"sha1",
"sha2",
@@ -1653,6 +1655,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"
@@ -3137,6 +3152,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"
@@ -3577,10 +3602,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voxel-kernels"
version = "0.1.0"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4297,6 +4318,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"
@@ -4429,6 +4456,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
View File
@@ -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/voxel-kernels", "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-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
-30
View File
@@ -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
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
+1 -8
View File
@@ -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"])
+7 -1
View File
@@ -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(),
+2
View File
@@ -27,6 +27,8 @@ bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
@@ -5,7 +5,7 @@
//!
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
use horizon_client::{
hud::HudAtlas,
hud::{HudAtlas, PACK_ICON_SIZE},
lang,
pack_screen::{Compat, PackInfo, PackScreen, Source},
packs::{self, Position},
@@ -13,7 +13,7 @@ use horizon_client::{
text::Text,
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::PathBuf};
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
pub fn runs(t: &Text) -> Value {
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
Some("unknown") => Compat::Unknown,
_ => Compat::Compatible,
},
icon: p["icon"].as_bool().unwrap_or(false),
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
}),
required: p["required"].as_bool().unwrap_or(false),
position: if p["position"] == "top" {
Position::Top
@@ -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();
+24
View File
@@ -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
@@ -0,0 +1,75 @@
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
//! centred; Escape answers no.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
pub struct Confirm {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
let [yes, no] = self.buttons;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if on(yes) {
Some(true)
} else if on(no) {
Some(false)
} else {
None
}
}
/// The lines and the buttons (yes first), over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
}
}
}
+33 -15
View File
@@ -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;
@@ -137,6 +138,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_packs;
mod server_travel;
mod shield;
mod sign_edit;
@@ -1126,6 +1128,8 @@ struct Game<'a> {
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -4410,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());
@@ -4771,7 +4775,14 @@ impl Game<'_> {
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = self.hud_layers(size);
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
HudBatch::new(size)
} else {
self.hud_layers(size)
};
self.draw_pack_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
self.draw_debug_overlay(&mut batch, size);
@@ -9080,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();
}
@@ -10995,6 +10996,13 @@ impl ApplicationHandler for Game<'_> {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
}
WindowEvent::MouseInput { state, button, .. }
if self.server_packs.prompt.is_some() =>
{
if state == ElementState::Pressed && button == MouseButton::Left {
self.pack_prompt_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -11194,6 +11202,14 @@ impl ApplicationHandler for Game<'_> {
_ => {}
}
}
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
{
self.answer_pack_prompt(false)?;
}
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Chat && self.title.is_none() =>
{
@@ -11572,6 +11588,7 @@ impl ApplicationHandler for Game<'_> {
event_loop.exit();
return;
}
self.tick_server_packs();
self.poll_control(event_loop);
if self.pending_save.is_some() {
return;
@@ -11666,6 +11683,7 @@ impl<'a> Game<'a> {
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
@@ -13254,7 +13272,7 @@ pub fn run() -> Result<()> {
return run_loading(options, early_settings, vanilla_root, None, started);
}
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
@@ -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));
}
}
+89 -16
View File
@@ -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") {
+4 -1
View File
@@ -1783,8 +1783,11 @@ impl Game<'_> {
self.level_set(pos, state)
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
}
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
&& self.world.block(pos[0], pos[1], pos[2]) == state
&& self.res.facts().get(landed).name == new
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
{
self.world.block_entities.insert(key, entity);
+9 -3
View File
@@ -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 {
@@ -3,6 +3,7 @@
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
hud::{HudAtlas, HudBatch},
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
.flatten()
.filter(|v| (r.min..=r.max).contains(v))
}
fn widest(font: &Font, lines: &[Text]) -> f32 {
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
}
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
font.split_lines(t, width)
.into_iter()
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
named_color("yellow").unwrap_or(0xFFFF55)
}
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
/// Yes / No 16 below, 4 apart, all centred.
struct Confirm {
lines: Vec<(Text, f32, f32)>,
buttons: [(f32, f32); 2],
}
impl Confirm {
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
let message: Vec<Text> = lines(font, &message, w - 50.0)
.into_iter()
.take(15)
.collect();
let (tw, mw) = (font.width_text(&title), widest(font, &message));
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut out = vec![(title, cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines: out,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// ConfirmScreen "Game Rule Changes".
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
Confirm::new(font, size, &title, &message)
}
impl RulesScreen {
@@ -301,13 +272,10 @@ impl RulesScreen {
let (w, h) = size;
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
if self.confirm {
let c = Confirm::new(&atlas.font, size);
let [(yx, yy), (nx, ny)] = c.buttons;
if on(yx, yy, 150.0, 20.0) {
return Some(Exit::Discard);
}
if on(nx, ny, 150.0, 20.0) {
self.confirm = false;
match confirm(&atlas.font, size).answer_at(cursor) {
Some(true) => return Some(Exit::Discard),
Some(false) => self.confirm = false,
None => {}
}
return None;
}
@@ -367,7 +335,7 @@ impl RulesScreen {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
return confirm(&atlas.font, size).answer_at(cursor).is_some();
}
let [done, cancel] = footer(w, h);
if (on(done) && !self.invalid()) || on(cancel) {
@@ -384,15 +352,11 @@ impl RulesScreen {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let c = Confirm::new(&atlas.font, (w, h));
for (line, x, y) in &c.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
let label = Text::translate(key, &[]);
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
}
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
];
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
return;
}
let rows = self.rows();
+8 -3
View File
@@ -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);
+14 -1
View File
@@ -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;
+10 -9
View File
@@ -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.
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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;
+30 -17
View File
@@ -4,7 +4,7 @@ use super::Game;
use horizon_client::{
hud::HudAtlas,
lang,
pack_screen::{Compat, PackInfo, Source as PackSource},
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
text::{Style, Text},
};
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// The resource root to load from, given the vanilla root and the selection.
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
vanilla: &Path,
selected: &[String],
server: &[Pack],
) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
let mut enabled = packs::enabled(&all, selected);
enabled.extend_from_slice(server);
packs::resource_root(vanilla, &enabled, &cache_dir())
}
fn info(p: &Pack) -> PackInfo {
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
PackSource::Default
},
compat: p.compatibility,
icon: p.icon,
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
required: false,
position: Position::Top,
fixed: false,
}
}
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
fn vanilla_info() -> PackInfo {
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
/// client JAR's `pack.png` that the prepared resources hold.
fn vanilla_info(vanilla: &Path) -> PackInfo {
PackInfo {
id: "vanilla".into(),
title: Text::translate("resourcePack.vanilla.name", &[]),
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
description_style: Style::default(),
source: PackSource::BuiltIn,
compat: Compat::Compatible,
icon: true,
icon: std::fs::read(vanilla.join("pack.png"))
.ok()
.as_deref()
.and_then(pack_screen::load_icon),
required: true,
position: Position::Bottom,
fixed: false,
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
.iter()
.map(info)
.collect();
infos.push(vanilla_info());
infos.push(vanilla_info(vanilla));
infos
}
impl Game<'_> {
@@ -113,14 +123,7 @@ impl Game<'_> {
self.pending_pack_reload = true;
return;
}
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let result = startup_root(&vanilla, &self.settings.resource_packs)
.map_err(Into::into)
.and_then(|root| self.reload_resources(&root));
if let Err(e) = result {
if let Err(e) = self.reload_selected_packs() {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
@@ -131,6 +134,16 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
self.reload_resources(&root)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
+26 -5
View File
@@ -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() {
+88 -54
View File
@@ -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| {
+60 -3
View File
@@ -305,6 +305,16 @@ impl Game<'_> {
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(local.is_none().then_some(address), user);
let session = match local {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
@@ -453,6 +463,7 @@ impl Game<'_> {
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.server_packs_disconnect();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -827,6 +838,8 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
@@ -3136,6 +3149,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(());
};
@@ -3246,9 +3262,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;
@@ -3307,11 +3324,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;
};
@@ -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;
@@ -0,0 +1,316 @@
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::Game;
use horizon_client::{
Result,
confirm_screen::Confirm,
hud::{HudAtlas, HudBatch},
multiplayer, packs,
scoreboard::{comp_text, tag_to_json},
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
text::{Text, named_color},
ui,
};
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
use std::path::PathBuf;
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
/// about, whether the last one is required, and the server's message.
pub(super) struct PackPrompt {
requests: Vec<(u128, String, String)>,
required: bool,
prompt: Option<Text>,
}
impl PackPrompt {
fn texts(&self) -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
let (title, line2) = if self.required {
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
(
t("multiplayer.requiredTexturePrompt.line1"),
t("multiplayer.requiredTexturePrompt.line2")
.with_color(yellow)
.bold(),
)
} else {
(
t("multiplayer.texturePrompt.line1"),
t("multiplayer.texturePrompt.line2"),
)
};
// preparePackPrompt.
let message = match &self.prompt {
Some(prompt) => Text::translate(
"multiplayer.texturePrompt.serverPrompt",
&[line2, prompt.clone()],
),
None => line2,
};
let buttons = if self.required {
[t("gui.proceed"), t("menu.disconnect")]
} else {
[t("gui.yes"), t("gui.no")]
};
(title, message, buttons)
}
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
let (title, message, _) = self.texts();
Confirm::new(&atlas.font, size, &title, &message)
}
}
/// The game's `DownloadedPackSource`.
#[derive(Default)]
pub(super) struct ServerPackState {
packs: ServerPacks,
/// `downloads/` in the game folder, opened at the first download.
queue: Option<download::Queue>,
pub(super) prompt: Option<PackPrompt>,
/// The server list entry the setting is saved to (none: a direct join).
address: Option<String>,
/// The account the downloads are made for (the download headers).
user: (String, u128),
/// The packs loaded on top of the player's own, bottom first.
pub(super) active: Vec<packs::Pack>,
}
fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
/// all, quietly (`configureForLocalWorld`).
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
let state = &mut self.server_packs;
state.user = user;
state.prompt = None;
let Some(address) = address else {
state.address = None;
state.packs.local_world();
return;
};
let file = multiplayer::servers_file(&saves());
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
let setting = entry.as_ref().and_then(|e| e.accept_textures);
state.address = entry.map(|e| e.ip);
state.packs.connect(Prompt::for_server(setting));
}
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
pub(super) fn server_packs_disconnect(&mut self) {
self.server_packs.prompt = None;
self.server_packs.packs.disconnect();
self.tick_server_packs();
}
/// `handleResourcePackPush`.
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
if !valid_url(&p.url) {
self.send_pack_status(p.id, action::INVALID_URL);
return;
}
let setting = self.server_setting();
let prompt = Prompt::for_server(setting);
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
self.server_packs
.packs
.push(p.id, &p.url, parse_sha1(&p.hash));
return;
}
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
let state = &mut self.server_packs;
let open = state.prompt.get_or_insert(PackPrompt {
requests: Vec::new(),
required: p.required,
prompt: None,
});
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
match id {
Some(id) => self.server_packs.packs.pop(id),
None => self.server_packs.packs.pop_all(),
}
}
/// The joined server's entry in the server list: its `acceptTextures`.
fn server_setting(&self) -> Option<bool> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
.into_iter()
.find(|s| s.ip == address)?
.accept_textures
}
fn send_pack_status(&self, id: u128, status: i32) {
if let Some(r) = &self.remote {
r.session.send_pack_status(id, status);
}
}
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
/// setting follows it, declining a required pack disconnects, and every
/// pack it asked about is pushed.
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
let Some(prompt) = self.server_packs.prompt.take() else {
return Ok(());
};
if self.player.is_some() && self.screen == super::Screen::Playing {
self.capture(true);
}
let setting = if yes {
self.server_packs.packs.allow();
Some(true)
} else {
self.server_packs.packs.reject();
if prompt.required {
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
return self.leave_server(Some(reason.plain()));
}
Some(false)
};
for (id, url, hash) in prompt.requests {
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
}
// ServerList.saveSingleServer.
if let Some(address) = &self.server_packs.address {
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accept_textures = setting;
}
if let Err(e) = servers::save(&file, &list) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
Ok(())
}
/// Runs what the packs need (`createUpdateScheduler`): the downloads
/// that finished, the ones to start, a resource reload when the active
/// packs change, and the statuses for the server.
pub(super) fn tick_server_packs(&mut self) {
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
let finished = self
.server_packs
.queue
.as_ref()
.map(download::Queue::finished)
.unwrap_or_default();
for batch in finished {
self.server_packs.packs.downloaded(batch);
}
while self.server_packs.packs.wants_tick() {
let tick = self.server_packs.packs.tick();
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
}
}
for (id, status) in self.server_packs.packs.take_replies() {
self.send_pack_status(id, status);
}
}
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
let state = &mut self.server_packs;
if state.queue.is_none() {
let dir = std::env::current_dir()
.unwrap_or_default()
.join("downloads");
match download::Queue::open(dir) {
Ok(queue) => state.queue = Some(queue),
Err(e) => {
log::error!("Horizon: failed to open the download queue: {e}");
let failed = requests.iter().map(|r| r.id).collect();
state.packs.downloaded(horizon_client::server_packs::Batch {
downloaded: Vec::new(),
failed,
});
return;
}
}
}
let headers = download::headers(&state.user.0, state.user.1);
if let Some(queue) = &state.queue {
queue.download(requests, headers);
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(packs) = packs else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
return false;
};
self.server_packs.active = packs;
match self.reload_selected_packs() {
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
}
}
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
let Some(prompt) = &self.server_packs.prompt else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(
atlas,
0.0,
0.0,
w,
h,
horizon_client::hud::rgba(20, 20, 20, 255),
);
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
let (_, _, [yes, no]) = prompt.texts();
prompt
.layout(atlas, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// A left click on the prompt: its answer when it hit a button.
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
let Some(prompt) = &self.server_packs.prompt else {
return Ok(());
};
let answer = prompt
.layout(&self.gui.atlas, self.gui_size())
.answer_at(self.cursor);
match answer {
Some(yes) => {
self.audio.play_button_click();
self.answer_pack_prompt(yes)
}
None => Ok(()),
}
}
}
@@ -219,12 +219,11 @@ pub(super) fn spawn_loader(
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let root = packs::startup_root(&vanilla_root, &settings.resource_packs).unwrap_or_else(
|error| {
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
vanilla_root.clone()
},
);
});
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
let result = load_resources(
root,
@@ -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;
+2 -8
View File
@@ -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()
);
}
-51
View File
@@ -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();
+16 -1
View File
@@ -81,6 +81,12 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
pub const SKIN_SLOTS: usize = 32;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
/// (`PackSelectionScreen.loadPackIcon` registers a texture per pack): two lists
/// of 36-pixel rows at GUI scale 2 on a 4K screen still fit.
pub const PACK_ICON_SLOTS: usize = 64;
/// A pack icon slot's side: vanilla's own icons are 128x128.
pub const PACK_ICON_SIZE: u32 = 128;
/// Atlas slots for armour trim textures made at run time (`trims`), each the
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
@@ -252,7 +258,7 @@ impl HudAtlas {
)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
@@ -284,6 +290,15 @@ impl HudAtlas {
vec![0; 64 * 64 * 4],
));
}
let side = PACK_ICON_SIZE as usize;
for i in 0..PACK_ICON_SLOTS {
images.push((
format!("horizon/pack-icon{i}"),
PACK_ICON_SIZE,
PACK_ICON_SIZE,
vec![0; side * side * 4],
));
}
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
+2
View File
@@ -41,6 +41,7 @@ pub mod command;
pub mod command_block;
pub mod command_storage;
pub mod component;
pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
@@ -157,6 +158,7 @@ pub mod screen_effects;
pub mod screen_layout;
pub mod sculk;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod settings;
pub mod shadows;
pub mod signs;
+3 -12
View File
@@ -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(&center);
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(&region, (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,
+5 -1
View File
@@ -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.
+2 -1
View File
@@ -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,
+94 -2
View File
@@ -67,6 +67,9 @@ pub enum Screen {
name: String,
address: String,
focus: u8,
/// Server Resource Packs (`acceptTextures`): Enabled, Disabled, or
/// none for Prompt.
packs: Option<bool>,
},
Direct {
address: String,
@@ -224,6 +227,19 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
}
/// The Server Resource Packs button's label: `CommonComponents.optionNameValue`.
fn pack_setting_label(packs: Option<bool>) -> String {
let (key, fallback) = match packs {
Some(true) => ("manageServer.resourcePack.enabled", "Enabled"),
Some(false) => ("manageServer.resourcePack.disabled", "Disabled"),
None => ("manageServer.resourcePack.prompt", "Prompt"),
};
let name = crate::lang::get_or("manageServer.resourcePack", "Server Resource Packs");
crate::lang::get_or("options.generic_value", "%s: %s")
.replacen("%s", &name, 1)
.replacen("%s", &crate::lang::get_or(key, fallback), 1)
}
/// JoinMultiplayerScreen's `HeaderAndFooterLayout(33, 60)`.
const HEADER: f32 = 33.0;
const FOOTER: f32 = 60.0;
@@ -461,7 +477,16 @@ impl Screen {
),
]
}
Self::Edit { address, .. } => vec![
Self::Edit { address, packs, .. } => vec![
// ManageServerScreen's CycleButton, above Done.
b(
w / 2.0 - 100.0,
h / 4.0 + 72.0,
200.0,
&pack_setting_label(*packs),
true,
60,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
@@ -847,6 +872,7 @@ impl Screen {
name,
address,
focus,
..
} => {
centered(
batch,
@@ -996,6 +1022,7 @@ impl Screen {
name: "Minecraft Server".into(),
address: String::new(),
focus: 1,
packs: None,
}
}
(
@@ -1011,6 +1038,7 @@ impl Screen {
name: s.name,
address: s.ip,
focus: 0,
packs: s.accept_textures,
};
}
}
@@ -1125,21 +1153,32 @@ impl Screen {
*selected = Some(index);
}
}
// CycleButton: Enabled, Disabled, Prompt, and round again.
(Self::Edit { packs, .. }, Some(60)) => {
*packs = match *packs {
Some(true) => Some(false),
Some(false) => None,
None => Some(true),
}
}
(
Self::Edit {
index,
name,
address,
packs,
..
},
Some(50),
) => {
let mut list = servers::load(&servers_file(saves));
let entry = ServerEntry::new(name.trim(), address.trim());
let mut entry = ServerEntry::new(name.trim(), address.trim());
entry.accept_textures = *packs;
match index.and_then(|i| list.get_mut(i)) {
Some(e) => {
e.name = entry.name;
e.ip = entry.ip;
e.accept_textures = entry.accept_textures;
}
None => list.push(entry),
}
@@ -1637,4 +1676,57 @@ mod tests {
assert_eq!(names(), ["Keep"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edit_cycles_and_saves_the_server_resource_pack_setting() {
let dir = std::env::temp_dir().join(format!("horizon-mp-packs-{}", std::process::id()));
let saves = dir.join("saves");
std::fs::create_dir_all(&saves).unwrap();
let file = servers_file(&saves);
let mut entry = ServerEntry::new("Packs", "127.0.0.1:4");
entry.accept_textures = Some(false);
servers::save(&file, &[entry]).unwrap();
let size = (427.0, 240.0);
let mut screen = Screen::list(&saves);
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
let cycle = |screen: &Screen| {
screen
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.5 == 60)
.map(|b| (b.0, b.1, b.2, b.3))
};
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
assert_eq!(
cycle(&screen),
Some((
113.5,
132.0,
200.0,
"Server Resource Packs: Disabled".into()
))
);
// Disabled, Prompt, Enabled: Cancel keeps what was saved.
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
screen.click(Some(51), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(false));
// Done saves it.
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Enabled");
screen.click(Some(50), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(true));
// A new server asks.
screen.click(Some(42), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
let _ = std::fs::remove_dir_all(&dir);
}
}
+66 -18
View File
@@ -20,6 +20,7 @@ use horizon_protocol::{
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{Receiver, Sender, channel},
},
thread::JoinHandle,
@@ -43,6 +44,10 @@ pub enum Event {
DecodeError(String),
/// The server sent us back to configuration and finished it again.
Reconfigured(Arc<Dynamic>),
/// ClientboundResourcePackPushPacket, in configuration or in play.
PackPush(cfg::ResourcePackPush),
/// ClientboundResourcePackPopPacket: one pack, or every pack.
PackPop(Option<u128>),
/// The connection ended. `from_server`: the server gave the reason (or
/// the connection was refused for good), rather than the connection
/// failing (auto-reconnect retries only failures and shutdowns).
@@ -55,6 +60,9 @@ pub enum Event {
pub struct Session {
events: Receiver<Event>,
writer: Arc<Mutex<Option<ConnWriter>>>,
/// The connection is in configuration (else in play). Changed only
/// with `writer` locked, as the packet that switches is sent.
configuring: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
pub address: String,
pub name: String,
@@ -92,8 +100,10 @@ impl Session {
) -> Self {
let (send, events) = channel();
let writer = Arc::new(Mutex::new(None));
let configuring = Arc::new(AtomicBool::new(true));
let counters = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
let thread_configuring = configuring.clone();
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
.name("horizon-net".into())
@@ -107,6 +117,7 @@ impl Session {
&lang,
&send,
&thread_writer,
&thread_configuring,
&thread_counters,
);
let from_server = result.is_ok();
@@ -124,6 +135,7 @@ impl Session {
Self {
events,
writer,
configuring,
thread,
address: address.to_owned(),
name: name.to_owned(),
@@ -153,6 +165,21 @@ impl Session {
w.shutdown();
}
}
/// ServerboundResourcePackPacket, in whichever phase the connection is.
pub fn send_pack_status(&self, id: u128, action: i32) {
let status = cfg::ResourcePackResponse { id, action };
let mut writer = self.writer.lock().unwrap();
let body = if self.configuring.load(Ordering::Relaxed) {
cfg::Serverbound::ResourcePack(status).encode()
} else {
play::Serverbound::ResourcePack(status).encode()
};
if let Some(w) = writer.as_mut()
&& w.send(&body).is_err()
{
w.shutdown();
}
}
pub fn disconnect(&mut self) {
if let Some(w) = self.writer.lock().unwrap().take() {
w.shutdown();
@@ -253,6 +280,7 @@ fn run(
lang: &Language,
send: &Sender<Event>,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
shared_counters: &Mutex<Option<Arc<Counters>>>,
) -> Result<String, String> {
let (host, port, mut c) = if let Some(pipe) = local {
@@ -268,8 +296,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());
@@ -377,18 +410,19 @@ fn run(
.map_err(e)?;
let (mut reader, writer) = c.split();
*shared_writer.lock().unwrap() = Some(writer);
let registries = configure(&mut reader, shared_writer, lang, send)?;
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Joined {
registries: Arc::new(registries),
uuid: profile.uuid,
name: profile.name,
});
receive_play(&mut reader, shared_writer, lang, send)
receive_play(&mut reader, shared_writer, configuring, lang, send)
}
fn receive_play(
reader: &mut ConnReader,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<String, String> {
@@ -416,15 +450,17 @@ fn receive_play(
play::Clientbound::KeepAlive(k) => reply(play::Serverbound::KeepAlive(k)),
play::Clientbound::BundleDelimiter(_) => {}
play::Clientbound::ResourcePackPush(p) => {
// Declined: Horizon has no server resource packs.
reply(play::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}));
let _ = send.send(Event::PackPush(p));
}
play::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
play::Clientbound::StartConfiguration(_) => {
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
let registries = configure(reader, shared_writer, lang, send)?;
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
configuring.store(true, Ordering::Relaxed);
}
let registries = configure(reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
}
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
@@ -445,6 +481,7 @@ fn receive_play(
fn configure(
reader: &mut ConnReader,
writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<Dynamic, String> {
@@ -471,11 +508,12 @@ fn configure(
cfg::Clientbound::CodeOfConduct(_) => {
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
}
// The game answers: it may prompt the player first.
cfg::Clientbound::ResourcePackPush(p) => {
reply(cfg::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}))?
let _ = send.send(Event::PackPush(p));
}
cfg::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
cfg::Clientbound::CookieRequest(q) => {
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
@@ -485,7 +523,11 @@ fn configure(
}
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
cfg::Clientbound::FinishConfiguration(_) => {
reply(cfg::Serverbound::FinishConfiguration(Empty))?;
let mut w = writer.lock().unwrap();
let w = w.as_mut().ok_or("Disconnected")?;
w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode())
.map_err(|e| e.to_string())?;
configuring.store(false, Ordering::Relaxed);
return Ok(registries);
}
_ => {}
@@ -559,7 +601,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())?;
@@ -602,7 +644,13 @@ mod tests {
let (send, events) = channel();
let thread_writer = writer.clone();
let thread = std::thread::spawn(move || {
receive_play(&mut reader, &thread_writer, &Language::new(), &send)
receive_play(
&mut reader,
&thread_writer,
&AtomicBool::new(false),
&Language::new(),
&send,
)
});
let mut server = Connection::local(server);
let teleport = play::Clientbound::PlayerPosition(play::PlayerPosition {
+3 -1
View File
@@ -2079,7 +2079,9 @@ impl OptionsUi {
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
let draws = self.packs(atlas, (w, h)).draw(&m, mouse, blink);
let packs = self.packs(atlas, (w, h));
let mut draws = packs.draw(&m, mouse, blink);
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
+163 -3
View File
@@ -6,10 +6,13 @@
//! [`PackScreen::draw`] gives the screen's GUI calls. The game shows it;
//! tools/test_pack_screen_parity.py compares it with vanilla.
use crate::{
font::Patch,
hud::{HudAtlas, PACK_ICON_SIZE, PACK_ICON_SLOTS},
packs::{Position, Selectable, SelectionModel},
screen_draw::{self as d, Draw, Measure, TextField},
text::{Style, Text},
};
use std::{collections::HashMap, sync::Arc};
/// `PackCompatibility`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -50,8 +53,8 @@ pub struct PackInfo {
pub description_style: Style,
pub source: Source,
pub compat: Compat,
/// Whether the pack has a `pack.png`.
pub icon: bool,
/// The pack's `pack.png` ([`load_icon`]); none draws `unknown_pack`.
pub icon: Option<Arc<[u8]>>,
pub required: bool,
pub position: Position,
pub fixed: bool,
@@ -90,6 +93,27 @@ impl PackInfo {
(t, Style::color(d::GRAY))
}
}
/// A `pack.png` as the pack screen draws it: RGBA, stretched to a
/// `PACK_ICON_SIZE` square by nearest texels, as vanilla's icon texture is
/// sampled into its 32x32 square. None when it does not decode, where
/// `loadPackIcon` falls back to `unknown_pack`.
pub fn load_icon(png: &[u8]) -> Option<Arc<[u8]>> {
let (w, h, rgba) = crate::hud::decode_bytes(png).ok()?;
if w == 0 || h == 0 {
return None;
}
let size = u64::from(PACK_ICON_SIZE);
let mut out = Vec::with_capacity((size * size * 4) as usize);
for y in 0..size {
let sy = ((2 * y + 1) * u64::from(h) / (2 * size)) as usize;
for x in 0..size {
let sx = ((2 * x + 1) * u64::from(w) / (2 * size)) as usize;
let at = (sy * w as usize + sx) * 4;
out.extend_from_slice(&rgba[at..at + 4]);
}
}
Some(out.into())
}
/// `PackSelectionScreen.loadPackIcon`'s texture for a pack with an icon.
pub fn icon_texture(id: &str) -> String {
let sanitized: String = id
@@ -306,6 +330,8 @@ pub struct PackScreen {
confirm: Option<Confirm>,
/// The selection changed since the screen opened.
pub changed: bool,
/// The packs' icons by their [`icon_texture`].
icons: HashMap<String, Arc<[u8]>>,
}
fn tr(key: &str) -> Text {
@@ -329,11 +355,16 @@ impl PackScreen {
italic: Some(true),
..Style::default()
}));
let icons = packs
.iter()
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
width,
height,
in_world,
packs,
icons,
model,
search,
focus: Focus::None,
@@ -760,6 +791,35 @@ impl PackScreen {
pub fn commit(&self) -> Vec<String> {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = 0..PACK_ICON_SLOTS;
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
};
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some(slot) = slots.next() else {
return;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
return;
};
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
*texture = sprite;
}
}
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
/// is the time since the search box took the focus.
@@ -891,7 +951,7 @@ impl PackScreen {
-8978432,
);
}
let icon = if p.icon {
let icon = if p.icon.is_some() {
icon_texture(id)
} else {
"textures/misc/unknown_pack.png".into()
@@ -979,6 +1039,106 @@ impl PackScreen {
}
}
#[cfg(test)]
mod icon_tests {
use super::*;
fn png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(rgba)
.unwrap();
out
}
fn pack(id: &str, icon: Option<Arc<[u8]>>) -> PackInfo {
PackInfo {
id: id.into(),
title: Text::literal(id),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon,
required: false,
position: Position::Top,
fixed: false,
}
}
/// Any size stretches over the whole square, nearest texels, as the
/// icon texture is sampled into its 32x32 blit.
#[test]
fn icons_stretch_to_the_slot_by_nearest_texels() {
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 128]);
let icon = load_icon(&png(2, 1, &[red, blue].concat())).unwrap();
let side = PACK_ICON_SIZE as usize;
assert_eq!(icon.len(), side * side * 4);
let at = |x: usize, y: usize| &icon[(y * side + x) * 4..][..4];
assert_eq!((at(0, 0), at(side / 2 - 1, side - 1)), (&red[..], &red[..]));
assert_eq!(
(at(side / 2, 0), at(side - 1, side - 1)),
(&blue[..], &blue[..])
);
assert!(load_icon(b"not a png").is_none());
}
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
/// without one keeps `unknown_pack`.
#[test]
fn drawn_icons_take_atlas_slots() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let dirt = load_icon(&png(1, 1, &[134, 96, 67, 255])).unwrap();
let contrast = load_icon(&png(1, 1, &[0, 0, 0, 255])).unwrap();
let packs = vec![
pack("programmer_art", Some(dirt.clone())),
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
let icons: Vec<&str> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack") => Some(texture.as_str()),
_ => None,
})
.collect();
// The list shows them sorted by id.
assert_eq!(
icons,
[
"textures/misc/unknown_pack.png",
"horizon/pack-icon0",
"horizon/pack-icon1"
]
);
assert_eq!(patches.len(), 2);
for (i, (patch, icon)) in patches.iter().zip([&contrast, &dirt]).enumerate() {
let rect = atlas.sprites[&format!("horizon/pack-icon{i}")];
assert_eq!(
(patch.x, patch.y, patch.width, patch.height),
(
(rect.u0 * atlas.width as f32).round() as u32,
(rect.v0 * atlas.height as f32).round() as u32,
PACK_ICON_SIZE,
PACK_ICON_SIZE
)
);
assert_eq!(&patch.rgba[..], &icon[..]);
}
}
}
#[cfg(test)]
mod click_sound_tests {
use super::*;
+59 -7
View File
@@ -34,8 +34,6 @@ pub struct Pack {
pub description_style: Style,
pub compatible: bool,
pub compatibility: crate::pack_screen::Compat,
/// The pack has a `pack.png`.
pub icon: bool,
/// Overlay directories active for this game version, in `pack.mcmeta` order.
pub overlays: Vec<String>,
/// `filter.block` entries (namespace and path patterns) hiding resources
@@ -260,6 +258,15 @@ fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
})
.collect()
}
impl Pack {
/// The pack's `pack.png` (`PackResources.getRootResource`), for its icon.
pub fn icon_png(&self) -> Option<Vec<u8>> {
match &self.source {
Source::Dir(d) => fs::read(d.join("pack.png")).ok(),
Source::Zip(z) => Zip::open(z).ok()?.read("pack.png").ok(),
}
}
}
fn read_meta(source: &Source) -> Option<serde_json::Value> {
let bytes = match source {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
@@ -270,10 +277,6 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
let icon = match &source {
Source::Dir(d) => d.join("pack.png").is_file(),
Source::Zip(z) => Zip::open(z).is_ok_and(|z| z.read("pack.png").is_ok()),
};
Some(Pack {
id,
title,
@@ -282,7 +285,6 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
icon,
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -629,6 +631,17 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
};
serde_json::to_vec(&out).ok()
}
/// `DownloadedPackSource.loadRequestedPacks`: a server's downloaded pack,
/// "World Specific Resources"; None when its `pack.mcmeta` can't be read. Its
/// id names the file, which is named after its hash.
pub fn server_pack(id: u128, path: &Path) -> Option<Pack> {
let file = path.file_name()?.to_string_lossy();
pack(
format!("server/{id:032x}/{file}"),
Text::translate("resourcePack.server.name", &[]),
Source::Zip(path.to_path_buf()),
)
}
/// The packs to apply for an options.txt `resourcePacks` list (bottom first).
pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
ids.iter()
@@ -983,6 +996,45 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn built_in_and_folder_packs_give_their_pack_png() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let built_in = base.join("vanilla/assets/minecraft/resourcepacks");
fs::create_dir_all(&built_in).unwrap();
let mut zip =
zip::ZipWriter::new(fs::File::create(built_in.join("programmer_art.zip")).unwrap());
for (name, data) in [("pack.mcmeta", &meta[..]), ("pack.png", b"dirt")] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
for (name, icon) in [("Iconless", None), ("Mine", Some(&b"mine"[..]))] {
let dir = base.join("packs").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("pack.mcmeta"), meta).unwrap();
if let Some(icon) = icon {
fs::write(dir.join("pack.png"), icon).unwrap();
}
}
let packs = discover(&base.join("vanilla"), &base.join("packs"));
let icons: Vec<_> = packs
.iter()
.map(|p| (p.id.as_str(), p.icon_png()))
.collect();
assert_eq!(
icons,
[
("programmer_art", Some(b"dirt".to_vec())),
("file/Iconless", None),
("file/Mine", Some(b"mine".to_vec())),
]
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn overlays_and_filters() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let meta = v(r#"{"overlays":{"entries":[
+618
View File
@@ -0,0 +1,618 @@
//! Server resource packs (`ServerPackManager`, `DownloadedPackSource`): the
//! packs a server pushes, the player's answer to its prompt, their downloads,
//! their activation on top of the player's own packs, and the status each one
//! reports back to the server. The game drives it: it shows the prompt,
//! starts the downloads [`ServerPacks::tick`] asks for and reloads the
//! resources with [`ServerPacks::active`] when it asks for a reload.
pub mod download;
use std::path::PathBuf;
/// `ServerboundResourcePackPacket.Action`, by its protocol number.
pub mod action {
pub const SUCCESSFULLY_LOADED: i32 = 0;
pub const DECLINED: i32 = 1;
pub const FAILED_DOWNLOAD: i32 = 2;
pub const ACCEPTED: i32 = 3;
pub const DOWNLOADED: i32 = 4;
pub const INVALID_URL: i32 = 5;
pub const FAILED_RELOAD: i32 = 6;
pub const DISCARDED: i32 = 7;
}
/// `ServerPackManager.PackPromptStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prompt {
Pending,
Allowed,
Declined,
}
impl Prompt {
/// `ConnectScreen.convertPackStatus`: a server list entry's setting
/// (servers.dat `acceptTextures`; none is Prompt).
pub fn for_server(accept_textures: Option<bool>) -> Self {
match accept_textures {
Some(true) => Self::Allowed,
Some(false) => Self::Declined,
None => Self::Pending,
}
}
}
/// `ClientCommonPacketListenerImpl.parseResourcePackUrl`: only http and https.
pub fn valid_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// `DownloadedPackSource.tryParseSha1Hash`: 40 hex digits, else no check.
pub fn parse_sha1(hash: &str) -> Option<String> {
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| hash.to_ascii_lowercase())
}
/// One pack to download (`DownloadQueue.DownloadRequest`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub id: u128,
pub url: String,
pub sha1: Option<String>,
}
/// `DownloadQueue.BatchResult`: the file each downloaded pack is in, and the
/// packs that failed.
#[derive(Debug, Default)]
pub struct Batch {
pub downloaded: Vec<(u128, PathBuf)>,
pub failed: Vec<u128>,
}
/// What the game has to do after a [`ServerPacks::tick`].
#[derive(Debug, Default, PartialEq)]
pub struct Tick {
/// Download these, then hand the result to [`ServerPacks::downloaded`].
pub download: Vec<Request>,
/// Reload the resources with these packs on top, bottom first
/// (`PackReloadConfig.scheduleReload`), then call [`ServerPacks::reloaded`].
pub reload: Option<Vec<(u128, PathBuf)>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Download {
Requested,
Pending,
Done,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Activation {
Inactive,
Pending,
Active,
}
/// `ServerPackManager.RemovalReason`, with the status it reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Removal {
DownloadFailed,
ActivationFailed,
Declined,
Discarded,
ServerRemoved,
ServerReplaced,
}
impl Removal {
fn reply(self) -> Option<i32> {
match self {
Self::DownloadFailed => Some(action::FAILED_DOWNLOAD),
Self::ActivationFailed => Some(action::FAILED_RELOAD),
Self::Declined => Some(action::DECLINED),
Self::Discarded => Some(action::DISCARDED),
Self::ServerRemoved | Self::ServerReplaced => None,
}
}
}
/// `ServerPackManager.ServerPackData`.
#[derive(Debug)]
struct Pack {
id: u128,
url: String,
sha1: Option<String>,
path: Option<PathBuf>,
removal: Option<Removal>,
download: Download,
activation: Activation,
accepted: bool,
}
impl Pack {
fn remove(&mut self, reason: Removal) {
self.removal.get_or_insert(reason);
}
fn removed(&self) -> bool {
self.removal.is_some()
}
}
/// The reload in progress: the packs it loads and unloads (the callbacks
/// `triggerReloadIfNeeded` gives `scheduleReload`).
#[derive(Debug)]
struct Reload {
load: Vec<u128>,
unload: Vec<u128>,
/// It reloads what was active before a failed one (`isRecovery`).
recovery: bool,
}
/// `ServerPackManager` with the feedback `DownloadedPackSource` gives it.
#[derive(Debug)]
pub struct ServerPacks {
packs: Vec<Pack>,
prompt: Prompt,
reload: Option<Reload>,
/// `registerForUpdate`: something changed, [`Self::tick`] again.
update: bool,
/// Report to the server (`configureForServerControl`); otherwise the
/// statuses are only logged, as for a local world.
reporting: bool,
replies: Vec<(u128, i32)>,
}
impl Default for ServerPacks {
fn default() -> Self {
Self {
packs: Vec::new(),
prompt: Prompt::Pending,
reload: None,
update: false,
reporting: false,
replies: Vec::new(),
}
}
}
impl ServerPacks {
fn report(&mut self, id: u128, status: i32) {
log::debug!("Horizon: server pack {id:032x} changed status to {status}");
if self.reporting {
self.replies.push((id, status));
}
}
/// The statuses to send the server, oldest first.
pub fn take_replies(&mut self) -> Vec<(u128, i32)> {
std::mem::take(&mut self.replies)
}
/// Whether [`Self::tick`] has something to do.
pub fn wants_tick(&self) -> bool {
self.update
}
/// `DownloadedPackSource.configureForServerControl`.
pub fn connect(&mut self, prompt: Prompt) {
self.reporting = true;
match prompt {
Prompt::Allowed => self.allow(),
Prompt::Declined => self.reject(),
Prompt::Pending => self.prompt = Prompt::Pending,
}
}
/// `DownloadedPackSource.configureForLocalWorld`: single player's own
/// server; its packs are taken, and nothing is reported.
pub fn local_world(&mut self) {
self.reporting = false;
self.allow();
}
/// `DownloadedPackSource.cleanupAfterDisconnect`: every pack goes, and
/// the next server starts from its own setting.
pub fn disconnect(&mut self) {
self.pop_all();
self.reporting = false;
self.replies.clear();
self.prompt = Prompt::Pending;
}
pub fn push(&mut self, id: u128, url: &str, sha1: Option<String>) {
if self.prompt == Prompt::Declined {
self.report(id, action::DECLINED);
return;
}
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
pack.remove(Removal::ServerReplaced);
}
self.packs.push(Pack {
id,
url: url.to_owned(),
sha1,
path: None,
removal: None,
download: Download::Requested,
activation: Activation::Inactive,
accepted: false,
});
if self.prompt == Prompt::Allowed {
self.accept(self.packs.len() - 1);
}
self.update = true;
}
fn accept(&mut self, i: usize) {
self.packs[i].accepted = true;
let id = self.packs[i].id;
self.report(id, action::ACCEPTED);
}
pub fn pop(&mut self, id: u128) {
if let Some(pack) = self.packs.iter_mut().find(|p| !p.removed() && p.id == id) {
pack.remove(Removal::ServerRemoved);
self.update = true;
}
}
pub fn pop_all(&mut self) {
for pack in &mut self.packs {
pack.remove(Removal::ServerRemoved);
}
self.update = true;
}
/// `allowServerPacks`: the prompt said yes (or the server is Enabled).
pub fn allow(&mut self) {
self.prompt = Prompt::Allowed;
for i in 0..self.packs.len() {
if !self.packs[i].accepted && !self.packs[i].removed() {
self.accept(i);
}
}
self.update = true;
}
/// `rejectServerPacks`: the prompt said no (or the server is Disabled).
pub fn reject(&mut self) {
self.prompt = Prompt::Declined;
for pack in self.packs.iter_mut().filter(|p| !p.accepted) {
pack.remove(Removal::Declined);
}
self.update = true;
}
/// `ServerPackManager.tick`: start the downloads the accepted packs
/// need; once none is left, reload if the active packs should change;
/// then forget the packs that are gone, reporting why.
pub fn tick(&mut self) -> Tick {
self.update = false;
let mut out = Tick::default();
let mut downloading = false;
for pack in self.packs.iter_mut().filter(|p| !p.removed() && p.accepted) {
downloading |= pack.download != Download::Done;
if pack.download == Download::Requested {
pack.download = Download::Pending;
out.download.push(Request {
id: pack.id,
url: pack.url.clone(),
sha1: pack.sha1.clone(),
});
}
}
if !downloading {
out.reload = self.reload_if_needed();
}
let mut gone = Vec::new();
self.packs.retain(|p| {
let keep = p.activation != Activation::Inactive || p.removal.is_none();
if !keep && let Some(reply) = p.removal.and_then(Removal::reply) {
gone.push((p.id, reply));
}
keep
});
for (id, reply) in gone {
self.report(id, reply);
}
out
}
fn reload_if_needed(&mut self) -> Option<Vec<(u128, PathBuf)>> {
if self.reload.is_some()
|| self
.packs
.iter()
.any(|p| p.activation == Activation::Pending)
{
return None;
}
let mut changed = false;
let (mut load, mut unload) = (Vec::new(), Vec::new());
for pack in &self.packs {
let wanted = pack.accepted && pack.download == Download::Done && !pack.removed();
match pack.activation {
Activation::Inactive if wanted => {
load.push(pack.id);
changed = true;
}
Activation::Active if wanted => load.push(pack.id),
Activation::Active => {
unload.push(pack.id);
changed = true;
}
_ => {}
}
}
if !changed {
return None;
}
for pack in &mut self.packs {
let loading = load.contains(&pack.id) && pack.activation != Activation::Active;
if loading || unload.contains(&pack.id) {
pack.activation = Activation::Pending;
}
}
let paths = self.paths(&load);
self.reload = Some(Reload {
load,
unload,
recovery: false,
});
Some(paths)
}
/// The packs to load (`PackReloadConfig.Callbacks.packsToLoad`), bottom first.
fn paths(&self, ids: &[u128]) -> Vec<(u128, PathBuf)> {
ids.iter()
.filter_map(|id| {
let pack = self
.packs
.iter()
.find(|p| p.id == *id && p.path.is_some())?;
Some((*id, pack.path.clone()?))
})
.collect()
}
/// `ServerPackManager.onDownload`.
pub fn downloaded(&mut self, batch: Batch) {
if !batch.failed.is_empty() {
for pack in self
.packs
.iter_mut()
.filter(|p| p.activation != Activation::Active)
{
pack.remove(if batch.failed.contains(&pack.id) {
Removal::DownloadFailed
} else {
Removal::Discarded
});
}
}
let mut done = Vec::new();
for (id, path) in batch.downloaded {
if let Some(pack) = self
.packs
.iter_mut()
.find(|p| p.id == id && p.download == Download::Pending)
{
pack.download = Download::Done;
pack.path = Some(path);
if !pack.removed() {
done.push(id);
}
}
}
for id in done {
self.report(id, action::DOWNLOADED);
}
self.update = true;
}
/// The reload [`Tick::reload`] asked for finished (`Callbacks.onSuccess`),
/// or its packs could not be loaded (`onFailure`). After a first failure
/// the packs that were active are loaded again: the packs to reload with
/// are returned, and this is called again with that reload's outcome.
pub fn reloaded(&mut self, ok: bool) -> Option<Vec<(u128, PathBuf)>> {
let mut reload = self.reload.take()?;
self.update = true;
if ok {
let mut applied = Vec::new();
for pack in &mut self.packs {
if reload.load.contains(&pack.id) {
pack.activation = Activation::Active;
if pack.removal.is_none() {
applied.push(pack.id);
}
} else if reload.unload.contains(&pack.id) {
pack.activation = Activation::Inactive;
}
}
for id in applied {
self.report(id, action::SUCCESSFULLY_LOADED);
}
return None;
}
if reload.recovery {
// onFailure(true): nothing of the server's is loaded.
for pack in &mut self.packs {
if pack.activation == Activation::Pending {
pack.activation = Activation::Inactive;
}
}
return None;
}
reload.load.clear();
for pack in &mut self.packs {
match pack.activation {
Activation::Inactive => pack.remove(Removal::Discarded),
Activation::Pending => {
pack.activation = Activation::Inactive;
pack.remove(Removal::ActivationFailed);
}
Activation::Active => reload.load.push(pack.id),
}
}
reload.recovery = true;
let paths = self.paths(&reload.load);
self.reload = Some(reload);
Some(paths)
}
}
#[cfg(test)]
mod tests {
use super::*;
use action::*;
fn ok(id: u128) -> Batch {
Batch {
downloaded: vec![(id, PathBuf::from(format!("/p/{id}")))],
failed: vec![],
}
}
/// Runs the updates as `createUpdateScheduler` does, downloading and
/// reloading with `download` and `reload` as the game would.
fn settle(
sp: &mut ServerPacks,
download: &mut dyn FnMut(&[Request]) -> Batch,
reload: &mut dyn FnMut(&[(u128, PathBuf)]) -> bool,
) {
let mut rounds = 0;
while sp.wants_tick() {
rounds += 1;
assert!(rounds < 50);
let t = sp.tick();
if !t.download.is_empty() {
let batch = download(&t.download);
sp.downloaded(batch);
}
if let Some(mut packs) = t.reload {
while let Some(back) = sp.reloaded(reload(&packs)) {
packs = back;
}
}
}
}
#[test]
fn an_enabled_server_s_pack_is_accepted_downloaded_and_loaded() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut loaded = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
loaded = p.to_vec();
true
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, SUCCESSFULLY_LOADED)]
);
assert_eq!(loaded, [(1, PathBuf::from("/p/1"))]);
}
#[test]
fn the_prompt_holds_packs_until_answered() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Pending);
sp.push(1, "http://a/p.zip", None);
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert!(sp.take_replies().is_empty());
sp.reject();
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert_eq!(sp.take_replies(), [(1, DECLINED)]);
// Declined: later pushes are declined at once.
sp.push(2, "http://a/q.zip", None);
assert_eq!(sp.take_replies(), [(2, DECLINED)]);
}
#[test]
fn a_failed_download_discards_the_rest_of_the_batch() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/dead.zip", None);
settle(
&mut sp,
&mut |_| Batch {
downloaded: vec![(1, PathBuf::from("/p/1"))],
failed: vec![2],
},
&mut |_| unreachable!(),
);
assert_eq!(
sp.take_replies(),
[
(1, ACCEPTED),
(2, ACCEPTED),
(1, DISCARDED),
(2, FAILED_DOWNLOAD)
]
);
}
#[test]
fn a_pack_that_does_not_load_fails_its_reload() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut reloads = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
reloads.push(p.to_vec());
reloads.len() > 1
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, FAILED_RELOAD)]
);
// The recovery reload has none of the server's packs.
assert_eq!(reloads[1], []);
}
#[test]
fn popping_and_disconnecting_unload_the_packs() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/q.zip", None);
let last = std::cell::RefCell::new(Vec::new());
let mut reload = |p: &[(u128, PathBuf)]| {
*last.borrow_mut() = p.to_vec();
true
};
settle(
&mut sp,
&mut |r| Batch {
downloaded: r
.iter()
.map(|q| (q.id, PathBuf::from(format!("/p/{}", q.id))))
.collect(),
failed: vec![],
},
&mut reload,
);
sp.take_replies();
sp.pop(1);
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
// Callbacks.onSuccess reports every pack the reload loaded, again.
assert_eq!(sp.take_replies(), [(2, SUCCESSFULLY_LOADED)]);
assert_eq!(*last.borrow(), [(2, PathBuf::from("/p/2"))]);
sp.disconnect();
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
assert!(last.borrow().is_empty());
assert!(sp.packs.is_empty());
}
#[test]
fn a_pushed_id_replaces_its_pack() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/old.zip", None);
sp.push(1, "http://a/new.zip", None);
let mut urls = Vec::new();
settle(
&mut sp,
&mut |r| {
urls.extend(r.iter().map(|q| q.url.clone()));
ok(1)
},
&mut |_| true,
);
assert_eq!(urls, ["http://a/new.zip"]);
}
#[test]
fn urls_and_hashes_are_read_as_vanilla_reads_them() {
assert!(valid_url("https://example.com/p.zip"));
assert!(valid_url("HTTP://example.com/p.zip"));
assert!(!valid_url("ftp://example.com/p.zip"));
assert!(!valid_url("example.com/p.zip"));
let sha1 = "0123456789ABCDEF0123456789abcdef01234567";
assert_eq!(parse_sha1(sha1), Some(sha1.to_ascii_lowercase()));
assert_eq!(parse_sha1("abc"), None);
}
}
@@ -0,0 +1,482 @@
//! Server pack downloads into the game directory's `downloads/` cache, as
//! vanilla keeps it (`DownloadQueue`, `HttpUtil.downloadFile`,
//! `DownloadCacheCleaner`): `downloads/<pack id>/<SHA-1 of the file>`, one
//! batch at a time on a thread of its own, every download logged in
//! `downloads/log.json`, and the cache cut to its 20 newest files on start.
use super::{Batch, Request};
use sha1::{Digest, Sha1};
use std::{
fs,
io::{self, Read, Write},
path::{Path, PathBuf},
sync::mpsc::{Receiver, Sender, channel},
time::SystemTime,
};
/// `DownloadedPackSource`'s MAX_PACK_SIZE_BYTES (250 MiB).
pub const MAX_SIZE: u64 = 262_144_000;
/// `DownloadQueue.MAX_KEPT_PACKS`.
const MAX_KEPT: usize = 20;
/// `createDownloadHeaders`: who is downloading, with which game.
pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
use horizon_core::version::{RESOURCE_PACK_FORMAT, VANILLA_VERSION, VERSION_ID};
vec![
("X-Minecraft-Username", name.to_owned()),
("X-Minecraft-UUID", format!("{uuid:032x}")),
("X-Minecraft-Version", VANILLA_VERSION.to_owned()),
("X-Minecraft-Version-ID", VERSION_ID.to_owned()),
(
"X-Minecraft-Pack-Format",
format!("{}.{}", RESOURCE_PACK_FORMAT.0, RESOURCE_PACK_FORMAT.1),
),
("User-Agent", format!("Minecraft Java/{VANILLA_VERSION}")),
]
}
type Job = (Vec<Request>, Vec<(&'static str, String)>);
/// `DownloadQueue`: batches download in order, on one thread.
pub struct Queue {
jobs: Sender<Job>,
done: Receiver<Batch>,
}
impl Queue {
pub fn open(dir: PathBuf) -> io::Result<Self> {
fs::create_dir_all(&dir)?;
vacuum(&dir, MAX_KEPT);
let (jobs, todo) = channel::<Job>();
let (finished, done) = channel();
std::thread::Builder::new()
.name("download-queue".into())
.spawn(move || {
for (requests, headers) in todo {
if finished.send(run(&dir, &requests, &headers)).is_err() {
break;
}
}
})?;
Ok(Self { jobs, done })
}
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
let _ = self.jobs.send((requests, headers));
}
/// The batches finished since the last call.
pub fn finished(&self) -> Vec<Batch> {
self.done.try_iter().collect()
}
}
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
let mut batch = Batch::default();
for request in requests {
let id = horizon_protocol::crypto::uuid_string(request.id);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
// DownloadQueue.LogEntry, its fields in the codec's order.
let mut entry = vec![
("id", serde_json::Value::from(id)),
("url", request.url.as_str().into()),
(
"time",
chrono::Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)
.into(),
),
];
if let Some(sha1) = &request.sha1 {
entry.push(("hash", sha1.as_str().into()));
}
entry.push(match &result {
Ok(path) => match fs::metadata(path) {
Ok(meta) => {
let name = path.strip_prefix(dir).unwrap_or(path);
let name = name.to_string_lossy().replace('\\', "/");
(
"file",
serde_json::json!({"name": name, "size": meta.len()}),
)
}
Err(_) => ("error", "no_access".into()),
},
Err(e) => {
log::error!("Horizon: failed to download {}: {e}", request.url);
("error", "download_failed".into())
}
});
if let Err(e) = append_log(&dir.join("log.json"), &entry) {
log::error!("Horizon: failed to log download of {}: {e}", request.url);
}
match result {
Ok(path) => batch.downloaded.push((request.id, path)),
Err(_) => batch.failed.push(request.id),
}
}
batch
}
/// `JsonEventLog.write`: one JSON object a line, as Gson writes it (with
/// its HTML escaping).
fn append_log(path: &Path, entry: &[(&str, serde_json::Value)]) -> io::Result<()> {
let fields: Vec<String> = entry
.iter()
.map(|(k, v)| format!("{}:{v}", serde_json::Value::from(*k)))
.collect();
let mut line = String::new();
for c in format!("{{{}}}", fields.join(",")).chars() {
match c {
'<' | '>' | '&' | '=' | '\'' => line.push_str(&format!("\\u{:04x}", c as u32)),
c => line.push(c),
}
}
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?
.write_all(line.as_bytes())
}
fn sha1_of(path: &Path) -> io::Result<String> {
let mut hasher = Sha1::new();
io::copy(&mut fs::File::open(path)?, &mut hasher)?;
Ok(hex(&hasher.finalize()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// `checkExistingFile`: the file is there with that hash.
fn matches(path: &Path, sha1: &str) -> bool {
path.exists() && sha1_of(path).is_ok_and(|actual| actual == sha1)
}
/// `updateModificationTime`: used now, so the cleaner keeps it longest.
fn touch(path: &Path) {
let _ = fs::File::options()
.append(true)
.open(path)
.and_then(|f| f.set_modified(SystemTime::now()));
}
/// `HttpUtil.downloadFile`: into `<dir>/<SHA-1>`. With a hash the cached file
/// is used when it matches, and a download that does not is an error;
/// without one the file is named after the hash of what came.
pub fn download_file(
dir: &Path,
request: &Request,
headers: &[(&'static str, String)],
max_size: u64,
) -> Result<PathBuf, String> {
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
if matches(target, sha1) {
log::info!("Horizon: returning cached file since actual hash matches requested");
touch(target);
return Ok(target.clone());
}
if target.exists() {
log::warn!(
"Horizon: existing file {} not found or had mismatched hash",
target.display()
);
}
fs::remove_file(target)
.or_else(|e| {
if e.kind() == io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
}
})
.map_err(|e| format!("Failed to remove existing file {}: {e}", target.display()))?;
}
let mut call = ureq::AgentBuilder::new().build().get(&request.url);
for (name, value) in headers {
call = call.set(name, value);
}
let response = call.call().map_err(|e| e.to_string())?;
let size = response
.header("Content-Length")
.and_then(|v| v.trim().parse::<u64>().ok());
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
if let Some(size) = size
&& size > max_size
{
return Err(format!(
"Filesize is bigger than maximum allowed (file is {size}, limit is {max_size})"
));
}
let mut input = response.into_reader();
match (&target, &request.sha1) {
(Some(target), Some(sha1)) => {
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
if &actual != sha1 {
return Err(format!(
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
));
}
Ok(target.clone())
}
_ => {
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
let path = dir.join(&actual);
if matches(&path, &actual) {
touch(&path);
} else {
fs::rename(&tmp, &path)?;
}
Ok(path)
});
let _ = fs::remove_file(&tmp);
result.map_err(|e| e.to_string())
}
}
}
fn rand_suffix() -> u64 {
use std::hash::{BuildHasher, RandomState};
RandomState::new().hash_one(std::time::Instant::now())
}
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
let mut out = fs::File::create(path)?;
let mut hasher = Sha1::new();
let mut buffer = [0u8; 8196];
let mut read_so_far = 0u64;
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
read_so_far += n as u64;
if read_so_far > max_size {
return Err(io::Error::other(format!(
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
)));
}
out.write_all(&buffer[..n])?;
hasher.update(&buffer[..n]);
}
Ok(hex(&hasher.finalize()))
}
/// `DownloadCacheCleaner.vacuumCacheDir`: keep the `max_files` files most
/// worth keeping (in the pack folders: the newest, and one per pack before a
/// second of any), then drop the folders left empty.
pub fn vacuum(dir: &Path, max_files: usize) {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let Ok(meta) = e.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() && path.parent() != Some(dir) {
let time = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
files.push((path, time));
}
}
}
let Some(remove) = files.len().checked_sub(max_files).filter(|n| *n > 0) else {
return;
};
// Newest first, then each file's rank in its folder (0 for a folder's
// newest), reversed, and stably sorted by rank, highest first.
files.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut counts = std::collections::HashMap::<PathBuf, usize>::new();
let mut ranked: Vec<(PathBuf, usize)> = files
.into_iter()
.map(|(path, _)| {
let count = counts
.entry(path.parent().unwrap_or(dir).to_path_buf())
.or_default();
let rank = *count;
*count += 1;
(path, rank)
})
.collect();
ranked.reverse();
ranked.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut emptied = std::collections::HashSet::new();
for (path, rank) in ranked.into_iter().take(remove) {
match fs::remove_file(&path) {
Ok(()) if rank == 0 => {
emptied.insert(path.parent().unwrap_or(dir).to_path_buf());
}
Ok(()) => {}
Err(e) => log::warn!(
"Horizon: failed to delete cache file {}: {e}",
path.display()
),
}
}
emptied.remove(dir);
for d in emptied {
let _ = fs::remove_dir(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// A one-shot HTTP server answering `status` with `body`; the request it got.
fn serve(status: &str, body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/pack.zip", listener.local_addr().unwrap());
let status = status.to_owned();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
let n = stream.read(&mut buf).unwrap();
request.extend_from_slice(&buf[..n]);
}
let head = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
String::from_utf8_lossy(&request).into_owned()
});
(url, handle)
}
fn sha1(bytes: &[u8]) -> String {
hex(&Sha1::digest(bytes))
}
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-packs-{:016x}", rand_suffix()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_pack_lands_under_its_hash_and_is_reused_from_the_cache() {
let dir = scratch();
let body = b"PK pack".to_vec();
let (url, server) = serve("200 OK", body.clone());
let request = Request {
id: 7,
url,
sha1: Some(sha1(&body)),
};
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
assert_eq!(path, dir.join(sha1(&body)));
assert_eq!(fs::read(&path).unwrap(), body);
let sent = server.join().unwrap();
assert!(sent.contains("X-Minecraft-Username: Alex"), "{sent}");
assert!(sent.contains("X-Minecraft-Pack-Format: 97.1"), "{sent}");
// Cached: no server this time.
let cached = Request {
url: "http://127.0.0.1:9/gone.zip".into(),
..request
};
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_wrong_hash_a_dead_url_and_a_large_pack_fail() {
let dir = scratch();
let (url, server) = serve("200 OK", b"other bytes".to_vec());
let wrong = Request {
id: 1,
url,
sha1: Some("0".repeat(40)),
};
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
assert!(err.contains("did not match requested"), "{err}");
server.join().unwrap();
let dead = Request {
id: 2,
url: "http://127.0.0.1:9/pack.zip".into(),
sha1: None,
};
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
assert!(!dir.join("dead").exists());
let (url, server) = serve("200 OK", vec![0; 100]);
let large = Request {
id: 3,
url,
sha1: None,
};
let err = download_file(&dir, &large, &[], 10).unwrap_err();
assert!(err.contains("bigger than maximum"), "{err}");
let _ = server.join();
let (url, server) = serve("404 Not Found", Vec::new());
let missing = Request {
id: 4,
url,
sha1: None,
};
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
server.join().unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_queue_logs_each_download() {
let dir = scratch();
let (url, server) = serve("200 OK", b"zip".to_vec());
let queue = Queue::open(dir.clone()).unwrap();
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
queue.download(
vec![Request {
id,
url,
sha1: None,
}],
Vec::new(),
);
let batch = loop {
if let Some(b) = queue.finished().pop() {
break b;
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
server.join().unwrap();
let file = dir
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
.join(sha1(b"zip"));
assert_eq!(batch.downloaded, [(id, file)]);
let log = fs::read_to_string(dir.join("log.json")).unwrap();
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
assert_eq!(entry["file"]["size"], 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_cleaner_keeps_the_newest_file_of_each_pack_longest() {
let dir = scratch();
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
for (pack, file, time) in [
("a", "1", 10),
("a", "2", 20),
("a", "3", 30),
("b", "1", 5),
] {
fs::create_dir_all(dir.join(pack)).unwrap();
let path = dir.join(pack).join(file);
fs::write(&path, b"x").unwrap();
fs::File::options()
.append(true)
.open(&path)
.unwrap()
.set_modified(at(time))
.unwrap();
}
fs::write(dir.join("log.json"), b"").unwrap();
vacuum(&dir, 2);
assert!(dir.join("a/3").exists() && dir.join("b/1").exists());
assert!(!dir.join("a/1").exists() && !dir.join("a/2").exists());
assert!(dir.join("log.json").exists());
fs::remove_dir_all(dir).unwrap();
}
}
-5
View File
@@ -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,
+122 -4
View File
@@ -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}"
);
}
}
}
}
}
}
}
+12
View File
@@ -14,19 +14,24 @@ interface log {
warn,
error,
}
/// One line in the game's log as `[mod <id>] message`, at that level.
write: func(level: level, message: string);
}
/// Capability `game-read`: the local player and the world clock.
interface game {
record player {
/// Feet position, in blocks.
x: f64,
y: f64,
z: f64,
/// Degrees, as F3 shows them.
yaw: f32,
/// Degrees: -90 looks straight up, 90 straight down.
pitch: f32,
/// Hit points as vanilla counts them: 20 is full health without effects.
health: f32,
/// 0 to 20, as the hunger bar.
food: u32,
/// `minecraft:overworld`, `minecraft:the_nether`, ...
dimension: string,
@@ -39,6 +44,7 @@ interface game {
}
/// None on the title screen.
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
}
@@ -58,11 +64,14 @@ interface hud {
bottom-right,
}
record line {
/// Plain text, up to 256 characters.
text: string,
/// 0xRRGGBB.
color: u32,
}
/// Replaces the mod's panel: up to 16 lines in that corner of the screen.
set-panel: func(corner: corner, lines: list<line>);
/// Removes the mod's panel.
clear: func();
}
@@ -92,10 +101,13 @@ interface events {
}
/// Once, after loading. An error keeps the mod off and is shown.
init: func() -> result<registration, string>;
/// Each game tick while in a world, with the game's tick counter.
tick: func(tick: u64);
/// Each rendered frame, with the seconds since the last one.
frame: func(seconds: f32);
/// `/name args`: ok(some(text)) is shown as feedback, err(text) in red.
command: func(name: string, args: string) -> result<option<string>, string>;
/// Each chat line the player receives, as plain text.
chat: func(text: string);
}
+5
View File
@@ -30,5 +30,10 @@ pack = ["dep:wit-component", "dep:wit-parser"]
name = "sandbox"
required-features = ["runtime", "pack"]
# docs/api/mods-reference.md from the WIT contract (HORIZON_WRITE_DOCS=1 rewrites it).
[[test]]
name = "api_reference"
required-features = ["pack"]
[lints]
workspace = true
+304
View File
@@ -0,0 +1,304 @@
//! docs/api/mods-reference.md, generated from the WIT contract and the manifest's capabilities, and a test that
//! fails when it is out of date. Update it with:
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference`.
use horizon_mods::manifest::{APIS, Capability};
use std::{fmt::Write, path::Path};
use wit_parser::{Docs, Function, Handle, Resolve, Type, TypeDefKind, WorldItem};
const WIT: &str = "crates/horizon-mod-sdk/wit/horizon.wit";
const DOC: &str = "docs/api/mods-reference.md";
fn root() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}
/// A doc comment on one line, for a table cell.
fn cell(docs: &Docs) -> String {
let text = docs.contents.as_deref().unwrap_or("").trim();
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('|', "\\|")
}
/// A type as WIT spells it.
fn ty(resolve: &Resolve, t: &Type) -> String {
let id = match t {
Type::Bool => return "bool".into(),
Type::U8 => return "u8".into(),
Type::U16 => return "u16".into(),
Type::U32 => return "u32".into(),
Type::U64 => return "u64".into(),
Type::S8 => return "s8".into(),
Type::S16 => return "s16".into(),
Type::S32 => return "s32".into(),
Type::S64 => return "s64".into(),
Type::F32 => return "f32".into(),
Type::F64 => return "f64".into(),
Type::Char => return "char".into(),
Type::String => return "string".into(),
Type::ErrorContext => return "error-context".into(),
Type::Id(id) => *id,
};
match &resolve.types[id].name {
Some(name) => name.clone(),
None => definition(resolve, &resolve.types[id].kind),
}
}
/// What an anonymous type, or the right-hand side of a `type` alias, spells.
fn definition(resolve: &Resolve, kind: &TypeDefKind) -> String {
let of = |t: &Option<Type>| t.as_ref().map_or("_".into(), |t| ty(resolve, t));
match kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(resolve, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(resolve, t)),
TypeDefKind::FixedSizeList(t, n) => format!("list<{}, {n}>", ty(resolve, t)),
TypeDefKind::Result(r) => match (&r.ok, &r.err) {
(None, None) => "result".into(),
(ok, None) => format!("result<{}>", of(ok)),
(ok, err) => format!("result<{}, {}>", of(ok), of(err)),
},
TypeDefKind::Tuple(t) => {
let items: Vec<_> = t.types.iter().map(|t| ty(resolve, t)).collect();
format!("tuple<{}>", items.join(", "))
}
TypeDefKind::Future(t) => t
.as_ref()
.map_or("future".into(), |t| format!("future<{}>", ty(resolve, t))),
TypeDefKind::Stream(t) => t
.as_ref()
.map_or("stream".into(), |t| format!("stream<{}>", ty(resolve, t))),
TypeDefKind::Handle(Handle::Own(t)) => ty(resolve, &Type::Id(*t)),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(resolve, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(resolve, t),
other => panic!(
"{WIT}: an anonymous {} the reference can't spell",
other.as_str()
),
}
}
fn signature(resolve: &Resolve, f: &Function) -> String {
let params: Vec<_> = f
.params
.iter()
.map(|(name, t)| format!("{name}: {}", ty(resolve, t)))
.collect();
let result = f
.result
.as_ref()
.map_or(String::new(), |t| format!(" -> {}", ty(resolve, t)));
format!("func({}){result}", params.join(", "))
}
/// Who may use an interface: the capability that grants it, or what it is.
fn access(interface: &str, exported: bool) -> String {
if exported {
return "exported by the mod: the game calls it".into();
}
if interface == "log" {
return "always granted".into();
}
match Capability::ALL.iter().find(|c| c.interface() == interface) {
Some(c) => format!("capability `{c}`"),
None => "not granted by any capability".into(),
}
}
fn markdown() -> String {
let mut resolve = Resolve::new();
let package = resolve
.push_file(root().join(WIT))
.expect("the WIT contract parses");
let package = &resolve.packages[package];
let name = &package.name;
let mut out = String::new();
let w = &mut out;
writeln!(w, "<!-- Generated from {WIT} and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->").unwrap();
writeln!(w, "# Mod API reference\n").unwrap();
writeln!(
w,
"The contract between Horizon and its mods: the WIT package `{name}`, every interface, type, function and hook in it, \
and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: \
[stability.md](stability.md). Manifest `api` lines this game serves: {}.\n",
APIS.map(|a| format!("`{a}`")).join(", ")
)
.unwrap();
writeln!(w, "## Capabilities\n").unwrap();
writeln!(
w,
"| In `capabilities` | Grants | The player reads \"May …\" |\n| --- | --- | --- |"
)
.unwrap();
for c in Capability::ALL {
let grants = if package.interfaces.contains_key(c.interface()) {
format!("[`{0}`](#{0})", c.interface())
} else {
"data files (`assets/`, `data/`), no interface".into()
};
writeln!(w, "| `{c}` | {grants} | {} |", c.describe()).unwrap();
}
let mut exported = Vec::new();
for (world_name, &world) in &package.worlds {
let world = &resolve.worlds[world];
let prefix = format!("{}:{}/", name.namespace, name.name);
let names = |keys: Vec<&wit_parser::WorldKey>| {
keys.into_iter()
.map(|key| {
let full = resolve.name_world_key(key);
let short = full.trim_start_matches(&prefix);
format!("`{}`", short.split('@').next().unwrap_or(short))
})
.collect::<Vec<_>>()
.join(", ")
};
writeln!(w, "\n## World `{world_name}`\n").unwrap();
if world.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&world.docs)).unwrap();
}
writeln!(
w,
"- Imports (what the game offers): {}",
names(world.imports.keys().collect())
)
.unwrap();
writeln!(
w,
"- Exports (what the mod provides): {}",
names(world.exports.keys().collect())
)
.unwrap();
for item in world.exports.values() {
if let WorldItem::Interface { id, .. } = item {
exported.push(*id);
}
}
}
for (interface_name, &id) in &package.interfaces {
let interface = &resolve.interfaces[id];
writeln!(w, "\n## `{interface_name}`\n").unwrap();
writeln!(
w,
"Access: {}.\n",
access(interface_name, exported.contains(&id))
)
.unwrap();
if interface.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&interface.docs)).unwrap();
}
for (type_name, &type_id) in &interface.types {
let def = &resolve.types[type_id];
writeln!(w, "### {} `{type_name}`\n", def.kind.as_str()).unwrap();
if def.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&def.docs)).unwrap();
}
match &def.kind {
TypeDefKind::Record(r) => {
writeln!(w, "| Field | Type | |\n| --- | --- | --- |").unwrap();
for f in &r.fields {
writeln!(
w,
"| `{}` | `{}` | {} |",
f.name,
ty(&resolve, &f.ty),
cell(&f.docs)
)
.unwrap();
}
}
TypeDefKind::Enum(e) => {
writeln!(w, "| Case | |\n| --- | --- |").unwrap();
for c in &e.cases {
writeln!(w, "| `{}` | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Variant(v) => {
writeln!(w, "| Case | Payload | |\n| --- | --- | --- |").unwrap();
for c in &v.cases {
let payload =
c.ty.as_ref()
.map_or(String::new(), |t| format!("`{}`", ty(&resolve, t)));
writeln!(w, "| `{}` | {payload} | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Flags(f) => {
writeln!(w, "| Flag | |\n| --- | --- |").unwrap();
for flag in &f.flags {
writeln!(w, "| `{}` | {} |", flag.name, cell(&flag.docs)).unwrap();
}
}
TypeDefKind::Resource => {
writeln!(w, "A resource: the game owns it, the mod holds a handle.").unwrap()
}
kind => writeln!(w, "`type {type_name} = {}`", definition(&resolve, kind)).unwrap(),
}
writeln!(w).unwrap();
}
if !interface.functions.is_empty() {
writeln!(
w,
"| Function | Signature | What it does |\n| --- | --- | --- |"
)
.unwrap();
for (fn_name, f) in &interface.functions {
writeln!(
w,
"| `{fn_name}` | `{}` | {} |",
signature(&resolve, f),
cell(&f.docs)
)
.unwrap();
}
}
}
out
}
#[test]
fn the_mod_api_reference_is_up_to_date() {
let path = root().join(DOC);
let want = markdown();
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
std::fs::write(&path, &want).unwrap();
}
let have = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
have == want,
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference"
);
}
#[test]
fn every_function_is_documented() {
let mut resolve = Resolve::new();
resolve.push_file(root().join(WIT)).unwrap();
for (_, interface) in resolve.interfaces.iter() {
for (name, f) in &interface.functions {
assert!(
f.docs.contents.is_some(),
"{WIT}: `{name}` needs a doc comment: the reference shows it to mod authors"
);
}
}
}
#[test]
fn every_capability_with_an_interface_is_in_the_contract() {
let mut resolve = Resolve::new();
let package = resolve.push_file(root().join(WIT)).unwrap();
let interfaces = &resolve.packages[package].interfaces;
for c in Capability::ALL {
if c != Capability::Registries {
assert!(
interfaces.contains_key(c.interface()),
"capability {c} grants `{}`, missing from {WIT}",
c.interface()
);
}
}
}
+19 -4
View File
@@ -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);
+5 -3
View File
@@ -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;
@@ -676,13 +676,15 @@ fn steps(
}
// The playable client needs every client asset (textures, models, fonts, GUI)
// and every data-pack resource (worldgen, recipes, loot tables, tags).
// and every data-pack resource (worldgen, recipes, loot tables, tags), and
// the vanilla pack's icon (`pack.png`) for the pack screen.
(run.log)("Copying the client's assets and data");
let mut count = 0usize;
let mut client = open_zip(&run.client)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
for name in zip_names(&client) {
if !(name.starts_with("assets/") || name.starts_with("data/")) || name.ends_with('/') {
let wanted = name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png";
if !wanted || name.ends_with('/') {
continue;
}
let relative = member_path(&name, "Invalid resource path")?;
+12 -3
View File
@@ -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 {
+67 -2
View File
@@ -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");
}
+4
View File
@@ -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"
+13
View File
@@ -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) {
+1
View File
@@ -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;
+3 -2
View File
@@ -13,8 +13,8 @@ use crate::{
packets::{
Empty,
configuration::{
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPush,
ResourcePackResponse, UpdateTags,
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPop,
ResourcePackPush, ResourcePackResponse, UpdateTags,
},
login::Property,
},
@@ -1640,6 +1640,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
RemoveEntities(RemoveEntities) = REMOVE_ENTITIES,
RemoveMobEffect(RemoveMobEffect) = REMOVE_MOB_EFFECT,
ResetScore(ResetScore) = RESET_SCORE,
ResourcePackPop(ResourcePackPop) = RESOURCE_PACK_POP,
ResourcePackPush(ResourcePackPush) = RESOURCE_PACK_PUSH,
Respawn(Respawn) = RESPAWN,
RotateHead(RotateHead) = ROTATE_HEAD,
+145
View File
@@ -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));
}
}
+339
View File
@@ -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)));
}
}
+4 -2
View File
@@ -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()
}
+1
View File
@@ -10,6 +10,7 @@ mod http;
mod init;
mod lists;
mod panel;
mod process;
mod service;
mod settings;
mod signals;
+20
View File
@@ -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
}
+2 -2
View File
@@ -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())
+3 -4
View File
@@ -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")
+13 -5
View File
@@ -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)
}
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "voxel-kernels"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "voxel-bench"
path = "src/main.rs"
[lints]
workspace = true
-136
View File
@@ -1,136 +0,0 @@
//! Original occupancy kernels for synthetic 16³ voxel sections.
//! A set bit means an opaque full cube; all neighbors outside the section are air.
//! This is not a Minecraft renderer or a model of partial/transparent blocks.
pub const SIDE: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Section {
rows: [u16; SIDE * SIDE],
}
impl Default for Section {
fn default() -> Self {
Self {
rows: [0; SIDE * SIDE],
}
}
}
impl Section {
pub fn from_fn(mut occupied: impl FnMut(usize, usize, usize) -> bool) -> Self {
let mut section = Self::default();
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if occupied(x, y, z) {
section.rows[z * SIDE + y] |= 1 << x;
}
}
}
}
section
}
pub fn occupied(&self, x: usize, y: usize, z: usize) -> bool {
assert!(x < SIDE && y < SIDE && z < SIDE);
self.rows[z * SIDE + y] & (1 << x) != 0
}
/// Simple per-voxel reference. Count each opaque cube's exposed unit faces.
pub fn exposed_faces_scalar(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if !self.occupied(x, y, z) {
continue;
}
faces += u32::from(x == 0 || !self.occupied(x - 1, y, z));
faces += u32::from(x + 1 == SIDE || !self.occupied(x + 1, y, z));
faces += u32::from(y == 0 || !self.occupied(x, y - 1, z));
faces += u32::from(y + 1 == SIDE || !self.occupied(x, y + 1, z));
faces += u32::from(z == 0 || !self.occupied(x, y, z - 1));
faces += u32::from(z + 1 == SIDE || !self.occupied(x, y, z + 1));
}
}
}
faces
}
/// Evaluate sixteen voxels at once with row masks and population counts.
pub fn exposed_faces_bitset(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
let i = z * SIDE + y;
let row = self.rows[i];
let below = if y == 0 { 0 } else { self.rows[i - 1] };
let above = if y + 1 == SIDE { 0 } else { self.rows[i + 1] };
let back = if z == 0 { 0 } else { self.rows[i - SIDE] };
let front = if z + 1 == SIDE {
0
} else {
self.rows[i + SIDE]
};
faces += (row & !(row << 1)).count_ones();
faces += (row & !(row >> 1)).count_ones();
faces += (row & !below).count_ones();
faces += (row & !above).count_ones();
faces += (row & !back).count_ones();
faces += (row & !front).count_ones();
}
}
faces
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(section: &Section, expected: u32) {
assert_eq!(section.exposed_faces_scalar(), expected);
assert_eq!(section.exposed_faces_bitset(), expected);
}
#[test]
fn known_geometry() {
check(&Section::default(), 0);
check(&Section::from_fn(|_, _, _| true), 6 * 16 * 16);
check(&Section::from_fn(|x, y, z| x == 3 && y == 4 && z == 5), 6);
check(&Section::from_fn(|x, y, z| x < 2 && y == 4 && z == 5), 10);
check(
&Section::from_fn(|x, y, z| (x + y + z) % 2 == 0),
4096 / 2 * 6,
);
}
#[test]
fn every_single_voxel_including_boundaries() {
for i in 0..SIDE.pow(3) {
let mut section = Section::default();
section.rows[i / SIDE] = 1 << (i % SIDE);
check(&section, 6);
}
}
#[test]
fn random_occupancy_matches_reference_at_multiple_densities() {
let mut state = 0x1234_5678_9abc_def0_u64;
for threshold in [0, 1, 16, 64, 128, 192, 254, 255, 256] {
for _ in 0..32 {
let section = Section::from_fn(|_, _, _| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state & 255) < threshold
});
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
}
}
}
-92
View File
@@ -1,92 +0,0 @@
use std::{hint::black_box, time::Instant};
use voxel_kernels::Section;
fn sample(corpus: &[Section], kernel: fn(&Section) -> u32, repeats: usize) -> f64 {
let start = Instant::now();
let mut checksum = 0_u64;
for _ in 0..repeats {
for section in corpus {
checksum = checksum.wrapping_add(u64::from(kernel(black_box(section))));
}
}
black_box(checksum);
start.elapsed().as_nanos() as f64 / (corpus.len() * repeats) as f64
}
fn median(samples: &mut [f64]) -> f64 {
samples.sort_by(f64::total_cmp);
samples[samples.len() / 2]
}
fn main() {
let repeats: usize = match std::env::args().nth(1) {
None => 100,
Some(value) => match value.parse() {
Ok(n) if n > 0 => n,
_ => {
eprintln!("usage: voxel-bench [positive repeat count]");
std::process::exit(2);
}
},
};
let mut rng = 0x7065_7273_6f6e_616c_u64;
let random = |rng: &mut u64| {
*rng ^= *rng << 13;
*rng ^= *rng >> 7;
*rng ^= *rng << 17;
*rng
};
println!(
"workload,sections,samples,repeats,scalar_ns_per_section,bitset_ns_per_section,speedup"
);
for workload in [
"empty",
"solid",
"flat",
"checkerboard",
"random_10",
"random_50",
"random_90",
] {
let corpus: Vec<_> = (0..32)
.map(|i| {
Section::from_fn(|x, y, z| match workload {
"empty" => false,
"solid" => true,
"flat" => y < 1 + i % 15,
"checkerboard" => (x + y + z + i) % 2 == 0,
"random_10" => random(&mut rng) % 100 < 10,
"random_50" => random(&mut rng) % 100 < 50,
"random_90" => random(&mut rng) % 100 < 90,
_ => unreachable!(),
})
})
.collect();
for section in &corpus {
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
for kernel in [Section::exposed_faces_scalar, Section::exposed_faces_bitset] {
sample(&corpus, kernel, 10);
}
let mut scalar = Vec::new();
let mut bitset = Vec::new();
for trial in 0..21 {
if trial % 2 == 0 {
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
} else {
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
}
}
let scalar = median(&mut scalar);
let bitset = median(&mut bitset);
println!(
"{workload},32,21,{repeats},{scalar:.2},{bitset:.2},{:.2}",
scalar / bitset
);
}
}
+3 -3
View File
@@ -13,7 +13,7 @@ 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.
@@ -45,6 +45,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Collision geometry](collision-geometry.md): shapes, collision, movement and fluids.
- [Face visibility](face-visibility.md): which model faces the mesher emits.
## The earlier Java track (no longer developed)
## Java comparison profiling
- [Java performance packs](java-packs.md), with [Vulkan on your Mac](vulkan.md), [collector comparisons](collectors.md), [profiling](profiling.md), [process memory](process-memory.md) and [tick state](tick-state.md). The Fabric server starter is retired: [host a server](hosting.md) with horizon-server.
- [JFR analysis for Java comparisons](profiling.md).
+141
View File
@@ -0,0 +1,141 @@
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
# Mod API reference
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`.
## Capabilities
| In `capabilities` | Grants | The player reads "May …" |
| --- | --- | --- |
| `game-read` | [`game`](#game) | see your position, health and the time |
| `chat` | [`chat`](#chat) | show you chat messages |
| `hud` | [`hud`](#hud) | draw text on your screen |
| `storage` | [`storage`](#storage) | save its own settings to disk |
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
## World `horizon-mod`
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
- Exports (what the mod provides): `events`
## `log`
Access: always granted.
The mod's log, in the game's log under the mod's id. Always granted.
### enum `level`
| Case | |
| --- | --- |
| `debug` | |
| `info` | |
| `warn` | |
| `error` | |
| Function | Signature | What it does |
| --- | --- | --- |
| `write` | `func(level: level, message: string)` | One line in the game's log as `[mod <id>] message`, at that level. |
## `game`
Access: capability `game-read`.
Capability `game-read`: the local player and the world clock.
### record `player`
| Field | Type | |
| --- | --- | --- |
| `x` | `f64` | Feet position, in blocks. |
| `y` | `f64` | |
| `z` | `f64` | |
| `yaw` | `f32` | Degrees, as F3 shows them. |
| `pitch` | `f32` | Degrees: -90 looks straight up, 90 straight down. |
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
| `food` | `u32` | 0 to 20, as the hunger bar. |
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
### record `clock`
| Field | Type | |
| --- | --- | --- |
| `game-time` | `u64` | Ticks since the world was created. |
| `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. |
| Function | Signature | What it does |
| --- | --- | --- |
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
## `chat`
Access: capability `chat`.
Capability `chat`: messages only the local player sees.
| Function | Signature | What it does |
| --- | --- | --- |
| `show` | `func(text: string)` | Shown in chat after the mod's name, like a system message. |
## `hud`
Access: capability `hud`.
Capability `hud`: one panel of text lines per mod, drawn by the game every frame until the mod changes or clears it (no call per frame).
### enum `corner`
| Case | |
| --- | --- |
| `top-left` | |
| `top-right` | |
| `bottom-left` | |
| `bottom-right` | |
### record `line`
| Field | Type | |
| --- | --- | --- |
| `text` | `string` | Plain text, up to 256 characters. |
| `color` | `u32` | 0xRRGGBB. |
| Function | Signature | What it does |
| --- | --- | --- |
| `set-panel` | `func(corner: corner, lines: list<line>)` | Replaces the mod's panel: up to 16 lines in that corner of the screen. |
| `clear` | `func()` | Removes the mod's panel. |
## `storage`
Access: capability `storage`.
Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/mods.md). Keys are `[a-z0-9_.-]{1,64}`, one path segment.
| Function | Signature | What it does |
| --- | --- | --- |
| `get` | `func(key: string) -> option<list<u8>>` | None if `key` has never been `set`, or the key is not well-formed. |
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
| `delete` | `func(key: string)` | No error if `key` was already absent. |
## `events`
Access: exported by the mod: the game calls it.
What the game calls. Only the hooks `init` asks for are ever called.
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Chat commands, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every game tick (20 per second while playing). |
| `frame` | `bool` | Call `frame` every rendered frame. Budget: 0.1 ms. |
| `chat` | `bool` | Call `chat` for each chat line the player receives. |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is shown. |
| `tick` | `func(tick: u64)` | Each game tick while in a world, with the game's tick counter. |
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
+2 -2
View File
@@ -27,7 +27,7 @@ file selects ~103.
| 10 | Launcher (124) | `launcher/src/` (`main.rs`, `backend.rs`, `cli.rs`, `mcp.rs`, `servers_page.rs`) | `cd launcher && cargo test`; `python3 tools/launcher_mcp_e2e.py` |
| 11 | Mob types and species (117) | `L/mobs.rs`, `L/mobs/species.rs`, `L/mobs/data.rs`, `L/mobs/living.rs`, `L/mobs/drops.rs`, `L/mobs/gear.rs` | mob_attributes, entity_attribute, entity_death_loot, mob_ai |
| 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py`, `packs/` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) |
| 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
| 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys |
@@ -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 |
+1 -1
View File
@@ -21,7 +21,7 @@ One game is started per window size (1280x720 and 1920x1080 by default) and rend
- **Warm-up excluded.** Each scene's flight starts only after its chunks are loaded and drawn and 2 seconds have passed; the load itself is reported separately. JIT warm-up of the JVM during the first scene is part of vanilla's real behaviour and is not removed.
- **The same statistics code.** Horizon returns its raw frame times (`horizon-ctl fly ... raw`) and the vanilla mod writes its own; `stats()` in the driver computes every figure for both.
- **Vanilla as players run it.** Your own 26.3 client jar and libraries (as the official launcher installs them for your OS), the official launcher's default JVM arguments (`-Xmx2G` and its G1 options), Java 25. The game's assets index is not used, so neither game plays sound (Horizon runs with `HORIZON_NO_AUDIO=1`, vanilla with OpenAL's null device); textures, models and shaders come from the client jar as usual.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `packs/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `tools/benchmark-deps/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
## Known caveats
-48
View File
@@ -1,48 +0,0 @@
# Compare Java collectors
Use the balanced pack first with the launcher's existing JVM policy. Java 25
HotSpot supports explicit G1 (`-XX:+UseG1GC`) and ZGC (`-XX:+UseZGC`) selections;
the [Java command reference](https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html)
documents both. A collector choice can change CPU, heap headroom, collection
behavior and frame pacing. Lower pauses alone do not establish lower RAM or
higher FPS. The packs do not automatically select a collector.
For a Mac comparison, duplicate one instance. In Edit > Settings > Java, enable
the Java Arguments override checkbox. Preserve existing non-collector arguments,
but use exactly one collector selector in each instance. Choose G1 in one and
ZGC in the other. Use the same native Java 25 ARM64, maximum/minimum heap, compact
header setting, world copy, resolution, shaders, distances and warmed camera route.
Alternate at least three 120-second runs rather than comparing unrelated captures.
Remove the explicit selector to restore the launcher's policy, then restart.
The viewer shows the actual collector names captured by the game. Check them:
setting a launcher's text field does not establish which flags reached the VM.
Prism's per-instance Java Arguments and Memory settings have separate override
checkboxes. A global collector selection may need removal or replacement when
creating an explicit per-instance experiment.
Record FPS, slowest-1% rate, frame spikes, heap and process RSS together. RSS does
not represent all shared GPU memory on Apple Silicon. `gc_time_ms` sums collector
MXBean counters; concurrent cycles and overlapping phases can contribute, so it
is not a measured pause total. Use JFR root pause events for pause analysis.
The streaming reader is checked against the JDK's event exporter with both
default-GC and ZGC fixtures in CI on Windows, Linux and ARM macOS.
The developer demo launcher accepts `--gc default`, `--gc g1` or `--gc zgc`.
For example:
```sh
python3 tools/runtime.py demo --instance .local/runtime/collector-test --heap 6G --gc zgc --compact-headers
python3 tools/test_jfr.py --gc zgc
```
Prepare demo mods/worlds before comparing.
Options affect only the launched process; they do not change global Java settings.
The collector and compact-header experiments can be tested independently.
A local 3 GiB, compact-header ZGC client completed a 120-second game recording.
That recording also encountered a distant-terrain rejection storm. Its workload
and cache history differed from G1 captures, so the larger memory footprint and
different frame rate cannot be attributed to the collector alone. The dispatcher
experiment addresses the identified retry loop separately. No M2 collector
performance winner is established.
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4 -3
View File
@@ -3,8 +3,9 @@
Horizon gets its own mod system, written in Rust and run in a sandbox, plus
two ways to bring over what Java players use: native Horizon ports of popular
mods and the data-driven parts of packs and mods. Fabric and NeoForge jars do
**not** run as they are: [research/java-mod-compat.md](research/java-mod-compat.md)
explains why and what we do instead.
**not** run as they are: their code calls and patches Minecraft's Java classes, and
Horizon has none. What carries over is the data and assets inside a jar
([modpacks.md](modpacks.md)).
Mods are **off and absent by default**. A fresh install has no `horizon-mods`
folder, loads no runtime and plays exactly like vanilla 26.3.
@@ -102,7 +103,7 @@ name (the mod reads it as `args`; API 0.1 has no way to declare argument types).
### Planned interfaces
Each is a new capability, added in a 0.x minor version so older mods keep
loading. Ordered by what the first native ports need ([research](research/java-mod-compat.md#port-first)):
loading. Ordered by what the first native ports need:
| Interface | For | Notes |
|---|---|---|
-130
View File
@@ -1,130 +0,0 @@
# Java performance mods: which of their techniques apply to Horizon
A survey of the techniques used by eight Java Edition performance mods. Asked for in #991, from famescare on Discord. For each technique it gives the vanilla cost it targets, whether Horizon already avoids that cost, and whether it is worth measuring here.
**Rules.** Techniques only, taken from each mod's public description, documentation and changelogs (linked below). No mod code was read or copied. Several of these mods are LGPL-3.0, and Horizon's code stays its own. Anything Horizon adopts is reimplemented against vanilla 26.3's behaviour. It keeps parity, so the harnesses stay unchanged, and it needs before/after numbers like any other perf PR (`docs/benchmark.md`, `docs/profiling.md`).
**Why many don't apply.** A large share of these techniques work around costs of the JVM, OpenGL or GLFW rather than costs of the game's rules. Examples are object headers and duplicate objects, boxing, lambdas and Streams, garbage collection, JNI upcalls, immediate-mode GL draws and GL error checks. Horizon is Rust on wgpu and winit, so it never pays those costs. The techniques that do carry over remove work the game's rules don't need: sleeping block entities, skipped searches, and cheaper geometry tests.
Verdicts:
- **has**: Horizon already avoids the cost, by its design or with a merged change.
- **n/a**: the cost comes from the JVM, OpenGL or GLFW, and Horizon doesn't have it.
- **later**: missing, and worth measuring. These are listed in "Worth measuring" below and in the LATER list.
- **no**: missing, but it would change vanilla's behaviour or look, or the gain here is too small to pay for itself.
## Lithium (server and shared game logic)
Source: [Lithium's mixin configuration](https://github.com/CaffeineMC/lithium/blob/develop/lithium-fabric-mixin-config.md). Lithium keeps vanilla's behaviour, which is the same rule Horizon follows.
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Hopper caching, inventory change listeners and hopper sleeping | Every hopper looks up its source and target containers and tries a transfer every tick, even when nothing can move | `game/machines.rs` ticks every hopper on each tick. It checks the block's name and cooldown, then runs the transfer attempt. Nothing is cached or put to sleep | **later** |
| Block entity sleeping (furnaces, brewing stands, campfires, crafters, shulker boxes, sculk) | Idle block entities still tick | `game/block_entity_ticks.rs` ticks every ticker each tick, as vanilla does | **later** |
| Entity collisions: faster block access, chunk caching, the supporting block first | Collision scans read blocks through generic level lookups | Collision scans already run over Horizon's own section arrays (`horizon-world` `block_collision_scan`). A counting cursor that does fewer lookups is open as #659 | **has** (more in #659) |
| Specialised cuboid and empty `VoxelShape`s, precomputed shape arrays | Generic voxel shape intersection and merging | `horizon-core::geometry` has its own immutable shapes and box lists (`docs/collision-geometry.md`) | **has** |
| POI search and portal search | POI lookups go through vanilla's stream-heavy search | Section-indexed POIs (`poi_sections.rs`), searched in vanilla's order | **has** |
| Pathfinding: cached node types per block | Node evaluation reads the same blocks over and over | Not measured. Mob pathfinding costs haven't been profiled against vanilla yet | **later** (profile first) |
| Explosions: one access per block on a ray, skip air, skip exposure work | Each ray step reads the block and the fluid again | `game/explosion.rs` follows vanilla's loop. It hasn't been profiled, and TNT chains are the case to measure | **later** (profile first) |
| Random ticks: roll the chance early, fast block search | Sections with nothing that ticks randomly are still walked | Sections with nothing random-tickable are skipped (`docs/random-tick-eligibility-benchmark.md`) | **has** |
| Tick scheduler: faster collections, packed scheduled ticks | Boxed `ScheduledTick` objects in hash sets | Horizon's own tick heap and index (`horizon-core::ticks`, `docs/scheduled-ticks.md`) | **has** |
| Collections and allocation (fastutil maps, cached `values()`, fewer `BlockPos`, no Streams) | JVM allocation, boxing, GC | Rust value types, no GC | **n/a** |
| Math inlining, a smaller sine table | JIT inlining limits | Compiled and inlined by rustc. Horizon keeps vanilla's `Mth.sin` table where results depend on it | **n/a** |
| Chunk access without debug locks or bounds validation | `PalettedContainer`'s thread-check lock | Horizon doesn't have that lock (`docs/performance/paletted-container-access-results.md`) | **n/a** |
| Palette replacement and compaction | Hash palette resizing on the JVM | Horizon's own paletted containers (`horizon-core::paletted`) | **has** |
| Game event dispatchers made only for sections with a listener | Every section holds a dispatcher | Not checked. This is small next to the items above | **no** (until a profile shows it) |
| Item entity merging grouped by item type (experimental) | Each item entity checks every nearby item to merge with | Not present. It matters only with thousands of dropped items (see #996: fresh worlds drop about twice as many items as vanilla) | **later**, after #996 |
| World border cache, redstone wire double reads, fluid flow reads, framed-map scans | Small repeated reads | Each is minor. Fix them where a profile points at them | **no** |
## FerriteCore (memory)
Source: [FerriteCore's summary of its changes](https://github.com/malte0811/FerriteCore/blob/main/summary.md).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| One `FastMap` of neighbour states per block instead of a table per state | About 600 MB of per-state neighbour tables in large packs | Block states are numeric ids with flat tables, with no object per state | **n/a** |
| Compact property storage per state | A per-state `ImmutableMap` | Same as above | **n/a** |
| Cached multipart predicates, shared multipart models | Hundreds of thousands of model and predicate objects | Models are baked once into a shared library (`docs/shared-model-library-benchmark.md`) | **has** |
| Deduplicated baked-quad vertex arrays | Identical `int[]` per quad | Section meshes are built per section from the shared baked models, and no per-quad arrays are kept | **has** |
| Deduplicated shape caches | Duplicate cached `VoxelShape`s per state | One table of distinct shapes, indexed per state (`horizon-world` `CachedCollisionRegistry`) | **has** |
| String deduplication in model locations | Duplicate `String`s from the constructor's round-trips | No `ModelResourceLocation` objects, so the round-trip doesn't exist | **n/a** |
| Partly loaded chunk NBT kept between loading steps | The whole tag is kept | Chunks are decoded into Horizon's own structures | **n/a** |
Horizon's process memory is tracked against vanilla in `docs/process-memory.md`.
## ImmediatelyFast (client draw submission)
Source: [ImmediatelyFast on Modrinth](https://modrinth.com/mod/immediatelyfast). Its gains show only when the CPU is the bottleneck.
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| A batching buffer for immediate-mode draws (entities, block entities, particles, text, GUI) | Many small draw calls and uploads | The HUD is one atlas and one batch of quads (`hud.rs`). Entities are grouped by material (`gfx/entity_batch.rs`). Terrain uses indirect multi-draws (`gfx.rs`, `gfx/flat.rs`) | **has** |
| HUD batching, fewer GUI draw calls | One draw per GUI element | Same batch (`hud.rs`) | **has** |
| Fast text lookup, a bigger font atlas | Per-character lookups, atlas thrash with large fonts | Glyphs live in maps in `font.rs`, drawn into the same HUD atlas | **has** |
| One texture for all map item textures | One texture per visible map | Not measured. It matters only with walls of maps in item frames | **later** (with a map-wall scene) |
| Cached closed-chest geometry | Re-tessellating chests each frame | Merged in #728 (Online5277) | **has** |
| Buffered sign text instead of redrawing it each frame | Text redrawn every frame | Sign text is recorded as text calls in the batch. Not measured with sign-heavy scenes | **later** (with a sign-heavy scene) |
| Fewer framebuffer switches | GL state changes between render types | wgpu render passes are fixed by Horizon's pipeline | **n/a** |
| GL error checking off | `glGetError` overhead | No GL. Release builds run without wgpu's backend validation and, outside DirectX 12, without its indirect-draw validation (`gfx.rs` `instance_descriptor`) | **n/a** |
## Gnetum (HUD rendering)
Source: [Gnetum on Modrinth](https://modrinth.com/mod/gnetum).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Draw the HUD into an offscreen buffer in several passes, one pass per frame, and show the last complete buffer | The HUD is redrawn every frame | Horizon redraws its one HUD batch every frame | **no**. The HUD would then update at a lower rate than vanilla's, which changes what players see. Horizon's HUD is already one batch. |
| Adaptive distribution and a separate HUD FPS cap | Same | Same | **no**, for the same reason |
## ScalableLux (lighting, from Starlight)
Source: [ScalableLux on Modrinth](https://modrinth.com/mod/scalablelux).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Starlight's light engine: per-section light arrays and direct propagation queues | Vanilla's light engine is a bottleneck in fast chunk generation | Horizon has its own engine with vanilla's rules (`light.rs`). New chunks are lit on the streaming worker pool, and edits are relit incrementally (`relight_block`). See `docs/performance/lighting-worker-results.md` | **has** |
| Parallel light updates | One light thread | Chunk lighting already runs on several workers during streaming | **has**. Measure the light share of fresh-chunk generation before doing more. |
## Structure Layout Optimizer (jigsaw structures)
Source: [Structure Layout Optimizer on Modrinth](https://modrinth.com/mod/structure-layout-optimizer).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| An octree of piece boxes instead of a `VoxelShape` for free space | Voxel shape boolean ops slow down as pieces add up | Free space is a container box minus a list of placed boxes (`worldgen/structure/jigsaw.rs` `Free`). That avoids the voxel shapes, but the scan is linear per test | **has**. An octree pays only for structures with hundreds of pieces. |
| Skip jigsaw blocks that face the structure boundary or another piece | Every parent jigsaw is matched against every child | Not present | **later** (time trial chambers and ancient cities first) |
| Skip pool elements already found not to fit at this spot | Weighted pools repeat an element once per weight point, and it is re-tested each time | Not present. Only the variant that keeps the result identical applies (no deduplication of the list, which changes layouts) | **later** |
| Cull positions outside the chunk before processors run | Whole templates are processed and then mostly discarded | Not checked | **later** (same measurement) |
| Palette-based template storage | Templates keep every block and NBT object | Templates are Horizon's own structures. Memory isn't measured | **no** (until memory shows it) |
## AsyncParticles (particles)
Source: [AsyncParticles on Modrinth](https://modrinth.com/mod/asyncparticles).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Particle culling | Off-screen particles are built anyway | Vanilla already skips quad particles whose position is outside the frustum (`QuadParticleGroup.extractRenderState`, `Frustum.pointInFrustum`). Horizon builds every particle's quad (`particles.rs` `render`). This is a parity gap as well as a cost | **later** (vanilla's frustum point test) |
| Vertex transformation on the GPU | Every particle's quad is built on the CPU and uploaded every frame | Same in Horizon (`particles.rs` `render`). Instancing would upload one record per particle | **later** (measure with 10k+ particles) |
| Particle ticks on another thread | Particle ticks on the client thread | Horizon ticks particles on the game thread | **no**. Vanilla's particle ticks read the level, and racing them against block changes risks parity. Revisit only if a profile shows particle ticks are big. |
| Asynchronous light cache for particles | A light lookup per particle per frame | `particles.rs` looks light up per particle | **later**, with the item above |
| Texture animation one frame late | Animated textures tick on the client tick | Not adopted | **no**, because it changes timing |
## Ixeris (input and the render thread)
Source: [Ixeris on Modrinth](https://modrinth.com/project/p8RJPJIC).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Event polling on the main thread, rendering on its own thread | GLFW event polling and JNI upcalls take time from rendering | winit, without JNI. Input and rendering share the event loop (`game.rs`, `ApplicationHandler`) | **n/a** for the JNI part |
| Buffered raw input (`GetRawInputBuffer`) | One `GetRawInputData` call per event. An 8000 Hz mouse costs a lot on Windows | winit reads `WM_INPUT` one message at a time | **later**. Measure on Windows with a high-polling-rate mouse before changing it. |
| Cached GLFW state | Round trips to the main thread | No GLFW | **n/a** |
| A frame limiter that sleeps, then spins | Imprecise sleeps | Horizon has its own limiter | **no** (no reported problem) |
## Worth measuring
Each of these needs a profile or a benchmark first. They are ordered by the expected gain. They are in the LATER list in `~/code/horizon-parity/FEATURES.md`, not filed as issues:
1. Hopper and block-entity sleeping, with vanilla-identical results (Lithium). Benchmark: a hopper and furnace array.
2. Particles: vanilla's frustum point test (a parity fix too), then GPU-built quads (AsyncParticles). Benchmark: 1,000+ campfires.
3. Jigsaw placement: skip blocked connectors and already-failed elements without changing the layout (Structure Layout Optimizer). Benchmark: trial chambers and ancient cities in fresh worlds, `docs/generation-scan-benchmark.md`.
4. Windows raw input in batches for high-polling-rate mice (Ixeris). Benchmark: an 8000 Hz mouse on Windows.
5. Profile before choosing a technique: mob pathfinding, TNT explosions, item merging after #996, map walls and sign-heavy scenes.
-53
View File
@@ -1,53 +0,0 @@
# Measuring resident process memory
Horizon 0.6.0 records process RSS alongside JVM heap and nonheap pools. RSS
counts resident pages belonging to the Minecraft process, including native
allocations and mapped pages. It helps detect cases where reducing heap use
merely moves memory elsewhere. It is not unique physical-memory ownership or
a complete accounting of GPU allocations, shared pages, compressed memory,
swap, or Apple Silicon unified memory. Do not add heap to RSS: heap pages can
already be included in it.
Minecraft 26.3 already ships OSHI 6.9.0 and JNA on both client and server. The
original mod uses those dependencies without embedding them. Native reader
initialization and refresh happen on a low-priority daemon thread, never on
the render or server-tick thread. One reader serves overlapping frame and
tick recordings. Reads run about once per second only while recordings are
active; the scheduler stops polling when the last recording finishes. Ordinary
gameplay without a benchmark performs no resident-memory reads.
Reports include these optional schema-1 fields:
| Field | Meaning |
| --- | --- |
| `process_rss_peak_sampled_bytes` | Largest successful resident-memory sample during this recording |
| `process_rss_last_sampled_bytes` | Last successful sample, which can precede recording completion |
| `process_rss_samples` | Number of successful samples delivered to this recording |
| `process_rss_sample_interval_ms` | Requested 1,000 ms interval; reads can take longer |
| `process_rss_last_sample_age_ms` | Time from the last successful sample to report completion |
Unsupported or failed reads leave these fields absent if no sample succeeded;
zero is not used as a substitute. Frames and ticks continue recording. A read
that completes after a recording finishes is ignored. Sampled peaks can miss
brief spikes, and the initial read can be delayed by native-library startup.
The offline viewer compares RSS separately from heap/nonheap and warns about
stale resident samples. Keep recording duration, workload and warm-up matched
when comparing peaks. Process CPU counters cover the whole process over the
recording wall time, including native work and the integrated server; they do
not identify which thread or mod caused CPU use.
`gc_time_ms` sums collector MXBean collection-time counters. With concurrent
collectors these can include cycle duration and overlapping phases; it is not a
direct stop-the-world pause measurement. The viewer calls it collection time.
Use [JFR pause events](profiling.md) when investigating GC-induced stalls.
A three-pair heap experiment compared 3 GiB and 1.5 GiB
maximum heaps with the same core stack. It recorded variable RSS savings, nearly
unchanged average FPS and increased GC accounting; playable defaults stayed
unchanged.
The native-read test exercises the real library, overlapping recordings,
background execution, idle cancellation and unavailable-reader behavior on
the build platform. This validates measurement availability, not an FPS or
RAM optimization result.
+4 -12
View File
@@ -1,7 +1,7 @@
# Find the next bottleneck
Frame and tick recordings describe player-visible timings. A private Java Flight
Recorder capture helps identify the code doing work or allocating objects. Use
For Java comparison runs, a private Java Flight Recorder capture helps identify
the code doing work or allocating objects. Use
native Java 25, with `jcmd` and `java` from a JDK. Keep normal benchmark runs separate
from profiling runs because sampling adds overhead.
@@ -53,8 +53,7 @@ allocated bytes or proving that one object type owns most of the allocation.
GC pause totals/maximum use top-level `jdk.GCPhasePause`, excluding nested phases
and concurrent collection-cycle duration. `gc_cycles` is a separate count.
`heap_used_peak_at_gc_bytes` considers recorded GC heap summaries and can miss
peaks between collections. It is separate from the game's once-per-second heap
and process-RSS measurements.
peaks between collections. It is separate from externally sampled process RSS.
The reader ignores unselected event types and never extracts properties or
arguments. `event_span_seconds` is the span of all observed event timestamps,
@@ -70,12 +69,5 @@ python3 tools/test_jfr.py
This creates a real JDK recording of bounded allocations and GC, then compares
the reader's counter differences, observation counts, GC counts and heap summaries
against `jfr print --json`. It also verifies escaped thread names and that sampled
weights do not appear as numeric allocation totals. CI runs it on Windows, Linux
and Apple Silicon macOS. The reader was independently checked against the older
weights do not appear as numeric allocation totals. The manual `jfr-analysis` CI job runs both collector fixtures on the Linux game runner. The reader was independently checked against the older
real-game profile's JDK JSON export for 85 threads.
The post-queue software-rendered profile confirms remaining allocations on the
integrated-server and chunk-meshing threads. It is not a matched comparison with
the older capture: world/cache state, heap and object-header flags differ. It
cannot establish M2 frame gains. Mac captures are needed before choosing a larger
renderer or native-kernel replacement.
+9
View File
@@ -312,6 +312,15 @@ and by hand; pushes to `launcher` and `launcher-*` branches only build.
- Published with `--latest=false`: the repository's latest release stays a game release. Notes list the pull
requests under New and Fixed; Kira posts launcher releases without pinging.
Making a launcher release GitHub's latest instead would break servers: every `horizon-server` already installed,
`install.sh` and `install.ps1` ask for `releases/latest` and expect the game's archives there, and a server that
can't update can't fetch the fix either. So the game release stays the latest, and it sends people to the launcher
(#1647): its notes open with a "Want to play? Download the Horizon launcher" block (`PLAYERS` in
`tools/release_train.py`: raw HTML, which GitHub shows and launchers' notes skip; the site's Releases page leaves
it out too), and its archives carry labels ("Game files the
launcher installs: Linux (…)") that GitHub shows in place of the file names. Everything that downloads matches
the asset's name, which is unchanged.
Starting by hand, pausing and rolling back work as for the game (`gh workflow run launcher …`,
`RELEASES_PAUSED`, `gh release edit [email protected] --draft`). Launchers older than the withdrawn release then
see the previous one as the newest; launchers that already updated keep running.
-3
View File
@@ -75,11 +75,8 @@ separate implementation. [Geometry and collision scope](collision-geometry.md).
```sh
cargo test --workspace
cargo run --release --bin voxel-bench -- 100 > .local/voxel-benchmark.csv
```
The dependency-free occupancy experiment compares scalar and bitset exposed-face counting for synthetic 16³ sections. It has no real mesh, material/AO/light work, neighboring-section context, JNI bridge or game integration. Speedups are kernel-only, not Minecraft FPS.
Public branding and new mod downloads use **Horizon**. The existing Fabric mod
ID and `dev.horizonlab` namespace remain stable for installed instances and
measurement compatibility. The installer backs up both old `horizon-lab-*.jar`
-183
View File
@@ -1,183 +0,0 @@
# Java mods and Horizon: what can carry over
Players ask "will my mods work?". Short answer: **Fabric and NeoForge jars
will not run in Horizon as they are**, and no bridge we could build would
change that for more than a handful of mods. What carries over is the
*content* (data packs, resource packs, the data and assets inside mod jars)
and the *ideas* (native Horizon ports of the mods people use most, written
with [Horizon's own mod system](../mods.md)). This page is the reasoning.
## How Java mods work
### Fabric
- **Fabric Loader** finds jars in `mods/`, reads `fabric.mod.json`
(entrypoints, dependencies, mixin configs, access wideners), and starts the
game through a class loader that can rewrite classes as they load.
- **Mixin** (SpongePowered) is how nearly every mod changes the game: a mixin
class names a Minecraft class (`net.minecraft.client.gui.hud.InGameHud`) and
injects code at points inside its methods (`@Inject(method = "render", at =
@At("TAIL"))`, `@Redirect`, `@ModifyVariable`, `@Overwrite`...). The bytecode
of the vanilla class is rewritten at load time. Access wideners make private
fields and methods public for the same purpose.
- **Fabric API** is a set of modules that give mods events and registries
instead of raw mixins: `fabric-lifecycle-events` (`ClientTickEvents`,
`ServerTickEvents`), `fabric-rendering` (`HudRenderCallback`,
`WorldRenderEvents`), `fabric-command-api`, `fabric-key-binding-api`,
`fabric-networking-api` (custom payloads), `fabric-registry-sync`,
`fabric-resource-loader`, `fabric-screen-api`, `fabric-item-group`,
`fabric-events-interaction` and about forty more. **Fabric API itself is
implemented with mixins** into the same vanilla classes.
- Mods mix the two freely: a mod using Fabric API events still calls
`MinecraftClient.getInstance().player.getBlockPos()`,
`world.getBlockState(pos)`, `DrawContext.drawText(...)`: direct calls into
vanilla's classes, by their mapped names.
### NeoForge (and Forge)
- An event bus (`NeoForge.EVENT_BUS`, mod buses) with hundreds of events,
`DeferredRegister` for registries, data attachments and capabilities,
networking payloads, config specs. NeoForge patches vanilla's source
directly (binary patches) to fire its events.
- Mods also use mixins and access transformers, and call vanilla classes
directly, as on Fabric.
### Data and assets in a jar
Besides code, a mod jar carries the same folders as packs:
`assets/<namespace>/` (models, blockstates, textures, sounds, language files,
shaders) and `data/<namespace>/` (recipes, loot tables, tags, advancements,
predicates, functions, worldgen: biomes, features, structures). These are
**vanilla formats**, read by vanilla's own code.
## Why a Java bridge cannot run them
Horizon is a reimplementation in Rust: there are no `net.minecraft` classes
at runtime, no `InGameHud`, no `ClientPlayerEntity`, no bytecode to rewrite.
1. **Mixins have nothing to bind to.** A mixin is a patch against a specific
method's bytecode. Without vanilla's classes there is no method, no
injection point and no local variable to modify. This is not a missing
feature we could add: it is the mechanism itself.
2. **Fabric API is mixins too.** A shim Fabric API would have to be
re-implemented from scratch as Java classes calling into Horizon. That part
alone is possible for the event modules.
3. **Mods call vanilla directly.** Even "pure Fabric API" mods use vanilla's
classes for everything else: the player, the world, the block states, the
renderer, the text drawing. Running them means providing Java classes with
vanilla's names and behaviour, backed by Horizon's state: in effect a
second Minecraft, in Java, on top of Horizon. Thousands of classes, in the
mapped names of every mapping set (Yarn, Mojang, intermediary), version by
version.
4. **The JVM itself** would ship with every install (a trimmed JRE is still
40-60 MB, the full one 150+ MB), start in a second or more, hold a few
hundred MB of heap before any mod loads, and bring garbage-collector pauses
back into the frame: the costs Horizon exists to remove. JNI calls cost
tens to hundreds of nanoseconds each and every one crosses with copies.
5. **No sandbox.** Java mods are fully trusted native-speed code with file
and network access. Horizon's mods are sandboxed and their permissions are
shown; a Java bridge would be the one hole in that.
**What a bridge could support**, if we ever built one: a stable subset of
Fabric API events (client and server tick, HUD render callback, client
commands, key bindings, custom payloads), with a tiny "vanilla view" API
(player position, held item, chat), for mods written only against that
subset. Surveying the most-downloaded Fabric mods, nearly none qualify: the
popular ones are popular precisely because they reach into rendering, the
world or the UI. The cost (a JVM in the game, a second API surface to keep
stable) is far above the benefit. **We do not build it.** If that changes,
the bridge would sit on top of the WIT API like any other mod runtime, so the
sandbox would still hold.
## What we do instead
### 1. Data-driven content (packs and the data in mod jars)
Horizon already reads vanilla resource packs and data packs (functions,
function tags, predicates today). Extending data-pack coverage to recipes,
loot tables, tags, advancements and worldgen JSON brings most "content"
packs over with no mod code at all, and it is vanilla behaviour, so the parity
harnesses check it.
For **Fabric/NeoForge jars**, the launcher can offer "import the data": read
`data/` and `assets/` from the jar and load them as a data pack plus a
resource pack. What works: new recipes, loot changes, tags, advancements,
functions, worldgen tweaks that only use vanilla blocks, retextures, language
files. What does not: anything referring to a block, item or entity the jar
registers in Java code (the ids do not exist); the import skips those files and
lists them. With Horizon's planned `registries` interface, a native port can
register those ids and reuse the jar's JSON models, textures, recipes and loot
as they are.
### 2. Native Horizon ports of popular mods
Horizon itself covers the performance mods: Sodium, Lithium, FerriteCore,
ModernFix, EntityCulling and ImmediatelyFast exist because vanilla Java is
slow, and Horizon's renderer and simulation are the replacement (see
[benchmark](../benchmark.md)). Zoom (OK Zoomer, Zoomify), FPS and keystroke
overlays, a minimap and auto-reconnect are already built in as
[extras](../extras.md).
#### Port first
Ranked by players reached per unit of API work. Each needs the planned
interfaces listed in [mods.md](../mods.md#planned-interfaces).
| Mod (Java) | What it does | Horizon port | Needs |
|---|---|---|---|
| **Mod Menu** | list mods, open their settings | built in: Options > Mods (done), settings pages next | `ui` |
| **AppleSkin** | food saturation and exhaustion on the HUD and tooltips | SDK mod | `inventory-read`, `ui` |
| **Jade / WTHIT** | what the crosshair points at: block, entity, harvest tool, container contents | SDK mod | `target`, `ui` |
| **JEI / EMI / REI** | browse items, recipes and uses | SDK mod (or built in: Horizon already has the recipe book's data) | `recipes`, `inventory-read`, `ui` |
| **Xaero's Minimap / JourneyMap** | minimap, world map, waypoints | the minimap is an extra; a world map and waypoints as an SDK mod | `world-read`, `render`, `storage` |
| **Litematica** | schematic overlay to build from | SDK mod | `world-read`, `render`, `storage`, `ui` |
| **Mouse Tweaks / Inventory Profiles Next** | drag and sort inventory | built in or SDK mod | `inventory-read` + a sanctioned click API |
| **Continuity / CIT** | connected and custom item textures | built into the renderer (pack formats) | none: resource-pack features |
| **Distant Horizons** | low-detail far terrain | a Horizon renderer feature, not a mod | none |
| **Shader packs** | shaders | removed for now; a clean-room Horizon renderer feature, later | none |
| **Simple Voice Chat** | proximity voice | server + client mod | `server`, `net`, audio (later) |
### 3. A porting guide (outline)
To be written as `docs/porting-from-fabric.md` once API 0.2 lands:
1. **Separate logic from glue.** Most of a mod is plain logic (maths,
formatting, data structures): port it to Rust once, unit-test it on the
host.
2. **Map events.**
| Fabric | Horizon |
|---|---|
| `ClientModInitializer.onInitializeClient` | `Mod::init`, returning a `Registration` |
| `ClientTickEvents.END_CLIENT_TICK` | `Registration::on_tick`, `Mod::tick` |
| `HudRenderCallback` | `hud::set_panel` when the content changes (retained), or `on_frame` |
| `ClientCommandRegistrationCallback` | `Registration::command`, `Mod::command` |
| `ClientReceiveMessageEvents` | `Registration::on_chat`, `Mod::chat` |
| `KeyBindingHelper.registerKeyBinding` | `keys` (planned) |
| `Registry.register(Registries.ITEM, ...)` | `registries` (planned) with JSON in `data/` |
| `ServerPlayNetworking` payloads | `net` (planned) |
| config libraries (Cloth Config, YACL) | `storage` + a `ui` settings page (planned) |
3. **Replace mixins with hooks.** For each mixin, write down what it observes
or changes. If a WIT hook covers it, use the hook. If not, open an issue
asking for the hook ("I need X before/after Y"): hooks are added to the API
for everyone instead of each mod patching the game.
4. **Declare capabilities** in `horizon-mod.toml`, the least the mod needs.
5. **Reuse assets**: textures, models, language files and recipes move as
they are into the `.hmod`'s `assets/` and `data/` (with `registries`).
6. **Test** with `cargo horizon-mod run` and hot reload; add unit tests for the
logic; publish with a signed `.hmod`.
## Sources
- Fabric documentation (`docs.fabricmc.net`): mod structure, Mixin
introduction, Fabric API modules and events.
- SpongePowered Mixin wiki: injection points and how class transformation
works.
- NeoForge documentation (`docs.neoforged.net`): events, registries,
networking.
- Modrinth's most-downloaded Fabric mods, for the porting list.
- Horizon's own Java test mod (`mods/horizon`, built by `tools/build_mod.py`
and the CI `portable-mod` job) is a Fabric mod used by the benchmarks and
parity oracles: it shows the Mixin pattern (`FrameHookMixin`) this page
describes, and stays a developer tool, not a player-facing mod.
+3 -1
View File
@@ -33,6 +33,7 @@ harness named checks that loader against vanilla).
| Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) |
| Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) |
| Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` |
| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server |
| Other namespaces | `assets/<namespace>/` | works | `test_resource_pack_parity` |
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
@@ -74,7 +75,7 @@ harness named checks that loader against vanilla).
| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; a pack's GLSL core shaders aren't run |
| GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used |
Summary: 44 rows; 31 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
Summary: 45 rows; 32 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
villager hats, trims on worn armour, locator bar styles, post effects, core shaders), 2 don't apply. Except the
shaders, the missing pieces are missing vanilla features too, and each vanilla parity fix covers the pack case as well.
@@ -83,6 +84,7 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p
| Gap | Size | Where |
| --- | --- | --- |
| Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) |
| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` |
| GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) |
| Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) |
| `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |
-1
View File
@@ -54,7 +54,6 @@ across 1,441,816 state pairs, including all pinned suppression-rule families.
Animation metadata, frame sequences and tick-state selection now have native
differential coverage, including all bundled animated texture metadata. GPU
interpolation and game-loop synchronization remain unfinished.
The playable Java performance packs remain available during development.
## Completion criteria and playable builds
+2 -3
View File
@@ -7,8 +7,7 @@ should beat vanilla, Paper and Fabric + Lithium on TPS/MSPT, RAM, CPU, chunk gen
Gameplay stays vanilla: there are no Paper-style rule changes unless the owner turns one on.
This page covers what the server does today (measured), the design of `horizon-server`, and what ships for the
first working version (2026-10-23). It replaced the Java/Fabric starter (`docs/server-quickstart.txt`,
`tools/server.py`, retired on 2026-10-19). The owner's guide is docs/hosting.md, also at
first working version (2026-10-23). The owner's guide is docs/hosting.md, also at
https://playhorizon.dev/server.
## 1. Today
@@ -467,7 +466,7 @@ reproducible by anyone, and show the same gameplay on both sides.
- Horizon (`horizon-server`);
- vanilla 26.3 (the owner's `server.jar`);
- Paper (a jar the owner downloads; Paper's defaults are kept, and noted as non-vanilla);
- optionally Fabric + Lithium (the old starter's setup, via the pinned `packs/mods.lock.json`).
- optionally Fabric + Lithium (via the pinned `tools/benchmark-deps/mods.lock.json`).
Java servers run on Java 25 with `-Xms` = `-Xmx` (the same heap for all, 4 GB by default), and the heap is
reported.
-26
View File
@@ -1,26 +0,0 @@
# Interpreting measured server ticks
Tick execution time omits scheduler sleep. `stats.meanRate` is the inverse average duration, not actual TPS. `observed_ticks_per_second` already counts accepted plus dropped measured invocations divided by wall-clock recording duration. Frozen tick invocations count; paused wall time stays in the denominator. Neither number measures completed simulation work.
Version 0.20 observes the server tick manager after each measured invocation on the server thread. Reports record the first and last sampled configured rate, frozen/stepping/sprinting flags and `runs_game_elements`, plus counters for samples with each nonstandard state. This is read-only instrumentation: it does not set a tick rate, freeze worlds or change game rules. Frame reports do not claim to sample server state; use their accompanying integrated-server tick reports.
The recorder retains which state fields changed between samples. A 20 → 40 → 20 rate change or freeze → unfreeze sequence stays visible even when first and last states match. Both comparison tools mark such recordings as changed workloads, mark differing sampled boundary states, and warn about frozen, stepping, sprinting, suppressed-game-element and nonstandard-rate samples. A consistently configured custom rate can match another report at the same rate, with an explicit warning about that workload. Older, absent or invalid state metadata remains unknown, rather than normal by assumption.
State and tick duration are accepted under the same recording lock, including buffer overflow. The observation path retains scalar values and allocates no maps or lists per invocation; report serialization creates them once. Non-finite or sub-minimum rates are counted as invalid observations without aborting duration capture. The state object is only allocated for tick recordings.
Sampling has limits. These are first/last sampled server states, not exact recording boundary states. Changes entirely between samples can be missed. An integrated server paused by the game menu does not call the measured superclass method, so state counters do not enumerate paused time. The manager flag also does not establish that every entity/chunk was simulated, or detect all other reasons a server skips work. A final stepping tick may already have decremented the remaining-step counter to zero while `runs_game_elements` is still true. Compare world copies, entity populations, completed work and real player workloads separately.
## Validation
Pure recording checks exercise restored intermediate changes, distinct frozen/suppressed states, late observations, absent/invalid manager state and wall-clock TPS calculation under G1, compact headers and ZGC. Offline comparison checks cover map ordering, changed/custom states and legacy/malformed input. The desktop and 390-pixel viewer checks also load the actual discarded paused capture (12 ticks over approximately 120 seconds), display its roughly 0.1 observed TPS and distinguish it from execution capacity. That capture remains excluded from performance evidence.
The private `TickStateIntegration` fixture runs only on a disposable demo-world copy. It starts a short recording, samples normal ticking, changes to 40 configured ticks/second and freezes, steps ten ticks, restores 20/unfrozen state, and requests a five-tick sprint. It checks that sampled boundary states match, all five intermediate state changes/counters survive, and accepted state/duration counts agree. The fixture is compiled in platform CI and excluded from the production JAR and downloadable packs. The [final local applied-hook observation](../benchmarks/tick-state-2026-10-04/live-observations.json) records 285 invocations, with 161 frozen, 9 stepping, 5 sprinting, 151 suppressed-game-element and 161 nonstandard-rate samples. All five change fields survive restored boundary states; CSV count/mean and zero invalid/drop observations are verified, and the client exits with code zero. This is functional evidence rather than a performance comparison. Compilation is not a live Mac result.
Compile the private fixture after building the original mod:
```sh
python3 tools/build_mod.py
python3 -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
```
Install `.local/mod-build/tick-state-fixture.jar` only into a disposable locally prepared balanced demo instance. Open its copied demo world and dismiss the demo screen. The fixture validates the report and closes the client after its sequence. Never add it to a normal play instance or starter package.
-32
View File
@@ -1,32 +0,0 @@
# Compare the Vulkan renderer on your Mac
The `vulkan` profile asks Minecraft 26.3 to use its existing Vulkan backend, with
its original OpenGL fallback. It uses balanced mods/distances.
The game renderer handles the backend; this project has not written a separate
Metal renderer. The benchmark records the actual backend and driver.
The checksum-verified 26.3 manifest includes LWJGL Vulkan 3.4.3 Mac ARM64 natives.
Inspecting that official native JAR confirmed `libMoltenVK.dylib`; its paired
LWJGL loader first tries MoltenVK on macOS. [MoltenVK](https://github.com/KhronosGroup/MoltenVK)
implements Vulkan functionality over Apple's Metal framework. This verifies a
bundled route to Metal, rather than proving that a particular Mac backend starts
or performs better. No separately installed Vulkan SDK is required for the bundled
library; driver/backend compatibility still needs a real Mac test.
Import `m2-balanced` and `vulkan` as separate Prism instances. Keep Java 25 ARM64,
heap, header/collector flags, world copies, resolution, view/simulation distance
and camera route equal. Warm terrain and shader/pipeline caches before comparing.
Run at least three alternating 120-second F8 recordings. Inspect nearby blocks,
distant terrain, transparency, water, lighting and reloads as well as timings.
Use the viewer's actual backend field: OpenGL fallback is an OpenGL result.
After the isolated test works, `foreground-first` and `foreground-vulkan` compare
the two backends with the same combined terrain controls. Their 120 FPS adaptive
goal controls background duty cycle; rendering remains uncapped in pack options.
Do not add shaders to one side of an isolated backend comparison.
Linux software rendering checks reached the actual Vulkan backend, rendered the
demo world and recorded matching CSV/JSON frame/tick samples. An unavailable
Vulkan-driver test reached the menu using OpenGL fallback. These tests do not
validate MoltenVK, real GPU speeds, all visual details or macOS 27. The user's
M2 Pro remains the priority device for those checks.
+18 -3
View File
@@ -97,6 +97,10 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
Long pages, dialogs, the server console and data previews have a visible scrollbar when
their content overflows. Drag the thumb or click the track to navigate without a mouse wheel.
Pages and Home's landscape fill the window as it resizes. Windows uses a matching title
bar with native dragging, resizing, Snap,
minimize, maximize/restore and close; macOS and Linux keep their system title bars.
## Updates
The launcher looks for a newer Horizon release when it starts and every 30 minutes while it
@@ -127,7 +131,12 @@ and `horizon-launcher-windows-x64.zip`, never marked latest. The version is buil
## Command line
The same binary is a command-line tool; without a subcommand it opens the window.
The same binary is a command-line tool; without a subcommand it opens the window. On Windows it
is a windowed program, so a desktop start opens no terminal; a subcommand writes to the terminal
it was typed in, or to the pipes and files it was given (MCP keeps its stdio). Interactive `cmd`
and PowerShell don't wait for a windowed program: they show the prompt again before its output
and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code use
`start /wait horizon-launcher …` or `Start-Process -Wait`, or read its output through a pipe.
horizon-launcher versions [--installed] releases on GitHub, or what is installed (* = selected)
horizon-launcher install <tag|latest>
@@ -250,14 +259,20 @@ Each session writes its own measurements to `benchmarks/launcher-<time>-session.
launcher's data folder, in the [benchmark report format](../docs/benchmark-report.md): start to the
first frame, the release list fetch, installs and updates (download and unpack), preparing, Play to
the game's first frame, memory, UI frame times and the binary's size, with the machine's CPU, RAM
and OS. Attach it to an issue about the launcher being slow. `horizon-launcher benchmark` measures
on purpose: it starts the launcher `--repeats` times (5; the first start is the cold one, as far
and OS (read on a thread after the first frame: on Windows those queries take seconds). Attach it
to an issue about the launcher being slow. `horizon-launcher benchmark` measures on purpose: it starts the launcher `--repeats` times (5; the first start is the cold one, as far
as the OS's file cache allows), switches pages for a few seconds in each, rests 3 seconds to
measure idle CPU, fetches the release list 3 times, and writes one report there with means and
95% confidence intervals. Its window has to be on a screen that is awake. Session logs stay on
your machine; a `benchmark` report is uploaded, dated to the day, only if you allowed benchmark
uploads ([data collection](../docs/telemetry.md)).
Long lists on Mods (results, installed entries, coverage reports and project versions), Versions
and Patch notes build only nearby rows, including on their first frame. Variable row heights
are measured as they enter view; hidden ranges share spacers in the existing page scrollbar.
On Windows, `cargo test --bin horizon-launcher desktop_list_renders_the_viewport_after_scrollbar_jumps
-- --ignored` checks 10,000 rows of different heights in a real GPUI window (requires an awake desktop/GPU).
## Building
cargo build --release
+2 -2
View File
@@ -122,8 +122,8 @@ pub fn locate(program: &str) -> std::path::PathBuf {
/// Settings page's "Test the connection".
pub fn self_test(exe: &Path) -> Result<usize, String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new(exe)
use std::process::Stdio;
let mut child = crate::process::command(exe)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
+1 -1
View File
@@ -207,7 +207,7 @@ impl Launcher {
/// Runs a client's add command; (went well, what it said).
fn run_add(program: &str, args: &[String]) -> (bool, String) {
match std::process::Command::new(agents::locate(program))
match horizon_launcher::process::command(agents::locate(program))
.args(args)
.output()
{
+104 -50
View File
@@ -2,39 +2,36 @@
//! and the landscape behind Home (the vanilla grass block is `browser::images::grass_block`).
//! GPUI samples images smoothly, so every image is baked at the window's scale with each texel a
//! whole block of device pixels, and drawn at exactly its size: the edges stay sharp. Each image
//! is made once per appearance and scale, then reused.
use gpui::RenderImage;
//! is made once per appearance and scale, then reused; Home's landscape is made again only when
//! the window grows wider than it.
use gpui::{RenderImage, Window};
use horizon_theme as tokens;
use std::collections::HashMap;
use std::sync::Arc;
/// What an image is, to find it again.
#[derive(Clone, PartialEq, Eq, Hash)]
enum Key {
Stone { dark: bool, scale: u32 },
Scene { dark: bool, scale: u32 },
}
#[derive(Default)]
pub struct Art {
made: HashMap<Key, Arc<RenderImage>>,
/// The stone texture for each appearance and scale.
stones: HashMap<(bool, u32), Arc<RenderImage>>,
/// Home's landscape: its appearance and scale, and its width in texels.
scene: Option<((bool, u32), usize, Arc<RenderImage>)>,
/// Landscapes replaced since the last frame, still in the window's sprite atlas.
replaced: Vec<Arc<RenderImage>>,
}
/// The stone texture comes in squares of this many logical pixels (4 by 4 tiles).
pub const STONE: f32 = tokens::TILE_TEXEL * (tokens::TILE_TEXELS * 4) as f32;
/// Home's landscape: texels of this many logical pixels, and its size in texels.
/// Home's landscape: texels of this many logical pixels, and its height in texels. Its width
/// grows in steps of `SCENE_STEP` texels, so resizing a window rarely makes another.
pub const SCENE_TEXEL: f32 = 6.;
pub const SCENE_SIZE: (usize, usize) = (220, 56);
pub const SCENE_HEIGHT: usize = 56;
const SCENE_STEP: usize = 64;
impl Art {
/// The page's stone texture, for a window at `scale`.
pub fn stone(&mut self, page: u32, dark: bool, scale: f32) -> Arc<RenderImage> {
let key = Key::Stone {
dark,
scale: scale_key(scale),
};
self.made
.entry(key)
self.stones
.entry((dark, scale_key(scale)))
.or_insert_with(|| {
let n = tokens::TILE_TEXELS * 4;
bake(&opaque(&stone(page, n)), n, n, tokens::TILE_TEXEL, scale)
@@ -42,20 +39,30 @@ impl Art {
.clone()
}
/// Home's landscape: a sunset in the dark appearance, a dawn in the light one.
pub fn scene(&mut self, dark: bool, scale: f32) -> Arc<RenderImage> {
let key = Key::Scene {
dark,
scale: scale_key(scale),
};
self.made
.entry(key)
.or_insert_with(|| {
let (w, h) = SCENE_SIZE;
let palette = if dark { &SUNSET } else { &DAWN };
bake(&opaque(&scene(palette, 3, w, h)), w, h, SCENE_TEXEL, scale)
})
.clone()
/// Home's landscape, at least `width` logical pixels wide (the banner clips the rest), and
/// its width: a sunset in the dark appearance, a dawn in the light one.
pub fn scene(&mut self, dark: bool, scale: f32, width: f32) -> (Arc<RenderImage>, f32) {
let key = (dark, scale_key(scale));
let texels = (width / SCENE_TEXEL).ceil().max(1.) as usize;
if !matches!(&self.scene, Some((k, w, _)) if *k == key && *w >= texels) {
let w = texels.next_multiple_of(SCENE_STEP);
let palette = if dark { &SUNSET } else { &DAWN };
let texels = opaque(&scene(palette, 3, w, SCENE_HEIGHT));
let image = bake(&texels, w, SCENE_HEIGHT, SCENE_TEXEL, scale);
if let Some((_, _, old)) = self.scene.replace((key, w, image)) {
self.replaced.push(old);
}
}
let (_, w, image) = self.scene.as_ref().expect("made above");
(image.clone(), *w as f32 * SCENE_TEXEL)
}
/// Frees the landscapes replaced since the last frame: GPUI keeps an image in the window's
/// sprite atlas until it is dropped there.
pub fn free_replaced(&mut self, window: &mut Window) {
for image in self.replaced.drain(..) {
let _ = window.drop_image(image);
}
}
}
@@ -199,8 +206,14 @@ pub const DAWN: Palette = Palette {
/// A blocky landscape, `w` by `h` texels: banded sky, a sun half set behind the far hills and cut
/// into slices like Horizon's mark, flat clouds, three ridges of hills and a few square trees.
/// Each part draws from its own generator from left to right, so a wider landscape only adds
/// columns: the first `w` are the same at every width, and a resized window keeps its view.
pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec<u32> {
let mut rng = Rng(seed.wrapping_mul(0x2545_f491) | 1);
let mut seeds = Rng(seed.wrapping_mul(0x2545_f491) | 1);
let mut rng = || {
seeds.next();
Rng(seeds.0)
};
let mut out = vec![0; w * h];
let horizon = h * 62 / 100;
let band = horizon as f32 / p.sky.len() as f32;
@@ -223,20 +236,23 @@ pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec<u32> {
}
}
};
for _ in 0..4 {
let cx = (rng.next() * w as f32) as isize;
let cy = (horizon as f32 * (0.12 + rng.next() * 0.4)) as isize;
let cw = 10 + (rng.next() * 14.) as usize;
let under = p.sky[((cy as f32 / band) as usize + 1).min(p.sky.len() - 1)];
let c = mix(under, 0xffffff, 0.12);
rect(&mut out, cx, cy, cw, 2, c);
rect(&mut out, cx + 3, cy - 2, cw.saturating_sub(6), 2, c);
// Four clouds in every stretch of `CLOUDS` texels.
const CLOUDS: usize = 220;
let mut clouds = rng();
for stretch in 0..w.div_ceil(CLOUDS) {
for _ in 0..4 {
let cx = (stretch * CLOUDS) as isize + (clouds.next() * CLOUDS as f32) as isize;
let cy = (horizon as f32 * (0.12 + clouds.next() * 0.4)) as isize;
let cw = 10 + (clouds.next() * 14.) as usize;
let under = p.sky[((cy as f32 / band) as usize + 1).min(p.sky.len() - 1)];
let c = mix(under, 0xffffff, 0.12);
rect(&mut out, cx, cy, cw, 2, c);
rect(&mut out, cx + 3, cy - 2, cw.saturating_sub(6), 2, c);
}
}
// The sun, where it was when the landscape had one width (two thirds of 220 texels).
let r = (h as f32 * 0.2) as isize;
let (cx, cy) = (
(w as f32 * 0.68) as isize,
horizon as isize - (h as f32 * 0.11) as isize,
);
let (cx, cy) = (149, horizon as isize - (h as f32 * 0.11) as isize);
for dy in -r..=0 {
let half = ((r * r - dy * dy) as f32).sqrt().round() as isize;
let t = (dy + r) as f32 / r as f32;
@@ -251,7 +267,7 @@ pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec<u32> {
rect(&mut out, cx - half, cy + dy, (half * 2) as usize, 1, c);
}
}
let ridge = |rng: &mut Rng, base: f32, amp: f32, rough: f32| -> Vec<usize> {
let ridge = |mut rng: Rng, base: f32, amp: f32, rough: f32| -> Vec<usize> {
let (mut y, mut v) = (base, 0.);
(0..w)
.map(|x| {
@@ -277,7 +293,7 @@ pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec<u32> {
];
let mut near = Vec::new();
for (c, base, amp, rough, top) in layers {
let hs = ridge(&mut rng, base, amp, rough);
let hs = ridge(rng(), base, amp, rough);
for (x, &y) in hs.iter().enumerate() {
for yy in y.min(h)..h {
out[yy * w + x] = c;
@@ -288,9 +304,12 @@ pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec<u32> {
}
near = hs;
}
for _ in 0..w / 26 {
let tx = 4 + (rng.next() * (w - 10) as f32) as isize;
let ty = near[tx as usize] as isize;
// A tree somewhere in every 26 texels of the near hills.
let mut trees = rng();
for cell in 0..w.div_ceil(26) {
let tx = cell * 26 + 2 + (trees.next() * 22.) as usize;
let Some(&ty) = near.get(tx) else { break };
let (tx, ty) = (tx as isize, ty as isize);
rect(&mut out, tx - 2, ty - 8, 5, 5, p.trees);
rect(&mut out, tx - 1, ty - 10, 3, 2, p.trees);
rect(&mut out, tx, ty - 3, 1, 3, p.trees);
@@ -320,4 +339,39 @@ mod tests {
let size = image.size(0);
assert_eq!((size.width.0, size.height.0), (16, 16));
}
#[test]
fn the_landscape_covers_the_banner_and_is_made_again_only_when_it_grows() {
let mut art = Art::default();
let stone = art.stone(0x0f0d17, true, 2.);
let (first, drawn) = art.scene(true, 2., 700.);
assert!(drawn >= 700.);
assert_eq!(first.size(0).width.0, (drawn * 2.) as i32);
assert_eq!(
first.size(0).height.0,
(SCENE_HEIGHT as f32 * SCENE_TEXEL * 2.) as i32
);
// A step wide: a few pixels more or a narrower window keep it.
for width in [drawn, 400.] {
assert!(Arc::ptr_eq(&first, &art.scene(true, 2., width).0));
}
let (wider, wider_drawn) = art.scene(true, 2., 1900.);
assert!(!Arc::ptr_eq(&first, &wider) && wider_drawn >= 1900.);
assert!(Arc::ptr_eq(&art.replaced[0], &first));
assert!(Arc::ptr_eq(&stone, &art.stone(0x0f0d17, true, 2.)));
}
#[test]
fn a_wider_landscape_only_adds_columns() {
let (h, narrow, wide) = (SCENE_HEIGHT, 150, 400);
let a = scene(&SUNSET, 3, narrow, h);
let b = scene(&SUNSET, 3, wide, h);
for y in 0..h {
assert_eq!(
a[y * narrow..(y + 1) * narrow],
b[y * wide..y * wide + narrow],
"row {y}"
);
}
}
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more