mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
109
Commits
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -45,9 +45,10 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `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
|
||||
|
||||
@@ -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
+59
-6
@@ -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",
|
||||
]
|
||||
|
||||
@@ -1330,8 +1331,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1377,6 +1380,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1430,6 +1434,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1653,6 +1664,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1897,6 +1921,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3137,6 +3167,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spake2"
|
||||
version = "0.4.0"
|
||||
@@ -3577,10 +3617,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 +4333,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4429,6 +4471,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/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-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
|
||||
@@ -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 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
|
||||
@@ -245,13 +245,19 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
hardware,
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
},
|
||||
widgets,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -468,6 +472,8 @@ impl Game<'_> {
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -592,10 +598,20 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| m.alive()) {
|
||||
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
|
||||
// VillagerData as this game knows it: on a client, what the
|
||||
// server's synched data says.
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
|
||||
v["villager"] = json!({
|
||||
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
|
||||
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
|
||||
"level": m.extra.variant3,
|
||||
});
|
||||
}
|
||||
push(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.id.as_ref(),
|
||||
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
|
||||
v,
|
||||
);
|
||||
}
|
||||
for item in &self.dropped {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -320,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -401,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -477,6 +529,19 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
@@ -540,13 +605,14 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -556,9 +622,18 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -569,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -656,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -711,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -728,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -758,6 +830,7 @@ impl Game<'_> {
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -608,6 +608,7 @@ impl Game<'_> {
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -834,8 +834,11 @@ impl Game<'_> {
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
pub(super) fn tick_lan(&mut self) {
|
||||
/// Run the hub for this tick and apply what the guests did. While the
|
||||
/// single player game is `paused` only the connections run
|
||||
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
|
||||
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
|
||||
pub(super) fn tick_lan(&mut self, paused: bool) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -1000,7 +1003,9 @@ impl Game<'_> {
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan_living_flags(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
|
||||
@@ -1277,10 +1277,24 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let eye = {
|
||||
let g = &host.hub.guests[gi];
|
||||
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
|
||||
};
|
||||
let object = || {
|
||||
let (lo, hi, origin) = self.object_target(id)?;
|
||||
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
let Some(l) = location else {
|
||||
// A boat's mobs sit over its middle: aim low at its near
|
||||
// side, so the ray meets the boat before them (the server
|
||||
// attacks the entity it is sent).
|
||||
let carries = u32::try_from(id).is_ok_and(|v| {
|
||||
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
|
||||
});
|
||||
if carries {
|
||||
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
|
||||
return Some([near(0), lo[1] + 0.05, near(2)]);
|
||||
}
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
@@ -1311,7 +1325,6 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
|
||||
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +265,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +346,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +391,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +509,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +587,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -650,25 +673,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +860,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -305,6 +309,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 => {
|
||||
@@ -401,6 +415,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -453,6 +468,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 +843,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;
|
||||
@@ -1369,7 +1387,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1448,6 +1466,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1459,9 +1494,6 @@ impl Game<'_> {
|
||||
steps: 3,
|
||||
},
|
||||
);
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
@@ -1794,6 +1826,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
@@ -2112,17 +2146,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2188,9 +2234,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2920,7 +2971,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2931,7 +2984,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2939,6 +2992,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
|
||||
return;
|
||||
@@ -3136,6 +3206,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3163,6 +3236,7 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
@@ -3206,7 +3280,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3246,9 +3320,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3307,11 +3382,51 @@ impl Game<'_> {
|
||||
za *= f64::from(0.05_f32);
|
||||
push = Vec3::new(push.x - xa, push.y, push.z - za);
|
||||
}
|
||||
// AbstractBoat.push(player) from the boat's own tick (its box grown
|
||||
// by 0.2 sideways) and from the player's pushEntities (its box).
|
||||
let player_team = teams.of(Who::Player);
|
||||
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
|
||||
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
|
||||
let bb = b.bounds();
|
||||
let times = usize::from(
|
||||
bounds.intersects(bb.inflate(area))
|
||||
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
|
||||
) + usize::from(
|
||||
bounds.intersects(bb)
|
||||
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
|
||||
);
|
||||
if times > 0
|
||||
&& super::boat_passengers::boat_pushes(bb, bounds, false)
|
||||
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
|
||||
{
|
||||
push = push.add_vector(v.scale(times as f64));
|
||||
}
|
||||
}
|
||||
push
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3414,20 +3529,12 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
}
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
&& l.steps > 0
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (l.target[0] - d.position.x) / n,
|
||||
d.position.y + (l.target[1] - d.position.y) / n,
|
||||
d.position.z + (l.target[2] - d.position.z) / n,
|
||||
);
|
||||
l.steps -= 1;
|
||||
}
|
||||
d.tick(world, facts, collision, false);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VillagerData as the network sends it: the type and the profession by
|
||||
/// their built-in registry ids, then the level.
|
||||
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
|
||||
let &[kind, profession, level] = data else {
|
||||
return;
|
||||
};
|
||||
let index = |registry: &str, id: i32, names: &[&str]| {
|
||||
let name = builtin
|
||||
.get(registry)?
|
||||
.name(id)?
|
||||
.strip_prefix("minecraft:")?;
|
||||
names.iter().position(|n| *n == name).map(|i| i as u8)
|
||||
};
|
||||
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
|
||||
m.extra.variant = t;
|
||||
}
|
||||
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
|
||||
m.extra.variant2 = p;
|
||||
}
|
||||
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
apply(m, values, &r.builtin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +104,23 @@ mod tests {
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
}
|
||||
|
||||
/// The prepared resources' built-in registries (empty without them).
|
||||
fn builtin() -> Builtin {
|
||||
Builtin::load(&root().join("registries.json")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
@@ -133,4 +177,45 @@ mod tests {
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
return;
|
||||
};
|
||||
let registries = builtin();
|
||||
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
|
||||
let (Some(desert), Some(farmer), Some(librarian)) = (
|
||||
id("minecraft:villager_type", "minecraft:desert"),
|
||||
id("minecraft:villager_profession", "minecraft:farmer"),
|
||||
id("minecraft:villager_profession", "minecraft:librarian"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let name = |m: &Mob| {
|
||||
(
|
||||
VILLAGER_TYPES[m.extra.variant as usize],
|
||||
PROFESSIONS[m.extra.variant2 as usize],
|
||||
m.extra.variant3,
|
||||
)
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "farmer", 2));
|
||||
// A job taken later: only the profession and the level change.
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "librarian", 5));
|
||||
// A zombie villager's data comes after Zombie's three entries.
|
||||
let mut z = mob("minecraft:zombie_villager").unwrap();
|
||||
apply(
|
||||
&mut z,
|
||||
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -185,11 +185,17 @@ impl Game<'_> {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
f.previous = f.position;
|
||||
f.age += 1;
|
||||
f.position = step(f.id, f.position);
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
|
||||
// Block.updateEntityMovementAfterFallOn, then the air drag.
|
||||
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
|
||||
@@ -88,8 +88,10 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
@@ -129,17 +131,20 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
let seat = b.position;
|
||||
self.boats[i] = b;
|
||||
Some(seat)
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
if let Some(v) = seat {
|
||||
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
|
||||
p.position = v;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -227,8 +227,6 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
pub cull_leaves: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
@@ -262,7 +260,6 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
@@ -308,7 +305,6 @@ impl Graphics {
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
@@ -343,12 +339,11 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
@@ -448,7 +443,6 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
@@ -475,7 +469,7 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ pub enum H {
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
@@ -138,11 +137,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::CullLeaves,
|
||||
None,
|
||||
"Hides shared leaf and mangrove root faces; foliage can look less dense.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -343,7 +337,6 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
@@ -418,7 +411,6 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
@@ -455,7 +447,6 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CullLeaves) => g.cull_leaves,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
@@ -1003,48 +994,6 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_leaves_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::CullLeaves);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: Off");
|
||||
for enabled in [false, true] {
|
||||
s.horizon.cull_leaves = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.cull_leaves, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
point,
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PlayerHealth {
|
||||
self.hurt_animation_time -= 1;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
|
||||
/// tilt and red flash restart.
|
||||
pub fn animate_hurt(&mut self) {
|
||||
self.hurt.hurt_duration = 10;
|
||||
self.hurt.hurt_time = 10;
|
||||
self.hurt_animation_time = 10;
|
||||
}
|
||||
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
|
||||
/// health starts the hurt animation.
|
||||
pub fn flash_health_change(&mut self, health: f32) {
|
||||
let damage = self.health() - health;
|
||||
if damage > 0.0 {
|
||||
self.hurt.last_hurt = damage;
|
||||
self.animate_hurt();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn hurt(
|
||||
@@ -274,6 +290,9 @@ impl PlayerMotion {
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
) -> Result<BlockOutcome> {
|
||||
if self.client_side {
|
||||
return Ok(BlockOutcome::default());
|
||||
}
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(source, None, None, None)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -55,6 +55,9 @@ impl PlayerMotion {
|
||||
Ok(())
|
||||
}
|
||||
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
|
||||
if self.client_side {
|
||||
return Ok(());
|
||||
}
|
||||
let h = &mut self.health;
|
||||
let mut world = TickWorld {
|
||||
resources,
|
||||
|
||||
@@ -119,6 +119,88 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`component_value`]: a JSON component (a server.properties
|
||||
/// prompt) as ComponentSerialization.CODEC writes it with NbtOps. An array is
|
||||
/// its first component with the rest appended (`createFromList`); a list of
|
||||
/// components mixing plain strings and objects has the strings as `{text}`.
|
||||
pub fn component_tag(v: &crate::server::json::Json) -> horizon_world::nbt::Tag {
|
||||
use crate::server::json::Json;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
fn list(items: &[Json]) -> Tag {
|
||||
same_kind(items.iter().map(component_tag).collect())
|
||||
}
|
||||
fn same_kind(mut tags: Vec<Tag>) -> Tag {
|
||||
if tags.iter().any(|t| matches!(t, Tag::Compound(_))) {
|
||||
for t in &mut tags {
|
||||
if let Tag::String(s) = t {
|
||||
*t = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Tag::List(tags)
|
||||
}
|
||||
fn value(v: &Json) -> Tag {
|
||||
match v {
|
||||
Json::Bool(b) => Tag::Byte(i8::from(*b)),
|
||||
Json::Number(n) => n.parse().map_or_else(
|
||||
|_| Tag::Double(n.parse::<f64>().unwrap_or(0.0).to_bits()),
|
||||
Tag::Int,
|
||||
),
|
||||
Json::String(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Array(items) => list(items),
|
||||
Json::Object(members) => Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), value(v)))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Json::Null => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
match v {
|
||||
Json::Array(items) if !items.is_empty() => {
|
||||
let mut first = component_tag(&items[0]);
|
||||
if let Tag::String(s) = &first {
|
||||
first = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
if let Tag::Compound(map) = &mut first {
|
||||
let mut extra = match map.remove(&JavaString::from("extra")) {
|
||||
Some(Tag::List(e)) => e,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
extra.extend(items[1..].iter().map(component_tag));
|
||||
map.insert(JavaString::from("extra"), same_kind(extra));
|
||||
}
|
||||
first
|
||||
}
|
||||
Json::Object(members) => {
|
||||
if let [(key, Json::String(text))] = members.as_slice()
|
||||
&& key == "text"
|
||||
{
|
||||
return Tag::String(JavaString::from(text.as_str()));
|
||||
}
|
||||
Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let tag = match k.as_str() {
|
||||
"extra" | "with" => match v {
|
||||
Json::Array(items) => list(items),
|
||||
other => value(other),
|
||||
},
|
||||
_ => value(v),
|
||||
};
|
||||
(k.as_str(), tag)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Json::String(s) | Json::Number(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Bool(b) => Tag::String(JavaString::from(b.to_string().as_str())),
|
||||
_ => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
|
||||
/// StringUtil.isValidPlayerName.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
|
||||
@@ -745,6 +827,14 @@ fn login_and_configure(
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
|
||||
if let Some(pack) = &shared.config.resource_pack
|
||||
&& let Some(rejected) = offer_pack(c, pack, &mut info)?
|
||||
{
|
||||
let (reason, text) = pack_rejected(shared, name, rejected);
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
|
||||
return Ok(None);
|
||||
}
|
||||
send(c, cfg::Clientbound::FinishConfiguration(Empty))?;
|
||||
loop {
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
@@ -763,6 +853,79 @@ fn login_and_configure(
|
||||
}))
|
||||
}
|
||||
|
||||
/// ServerboundResourcePackPacket.Action.DECLINED.
|
||||
const PACK_DECLINED: i32 = 1;
|
||||
|
||||
/// ServerCommonPacketListenerImpl.handleResourcePackResponse: a player who
|
||||
/// declined the required pack is disconnected. The reason and its text.
|
||||
fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt::Tag, String) {
|
||||
let reason = translatable("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
let Some(log) = &shared.config.log else {
|
||||
return (reason, String::new());
|
||||
};
|
||||
let line = format!(
|
||||
"Disconnecting {name} due to resource pack {} rejection",
|
||||
uuid_string(pack)
|
||||
);
|
||||
(log.write)("Server thread", "INFO", &line);
|
||||
let text = horizon_protocol::text::plain(&reason, &log.lang);
|
||||
(reason, text)
|
||||
}
|
||||
|
||||
/// ServerResourcePackConfigurationTask: offer the server's pack, then wait
|
||||
/// for the client's final answer (`Action.isTerminal`: anything but
|
||||
/// ACCEPTED or DOWNLOADED). The player may take a while to answer the
|
||||
/// prompt, so the connection is kept alive meanwhile, as
|
||||
/// ServerConfigurationPacketListenerImpl.tick does. The id of a required
|
||||
/// pack the client declined, which disconnects it.
|
||||
fn offer_pack(
|
||||
c: &mut Connection,
|
||||
pack: &crate::server::properties::ResourcePackInfo,
|
||||
info: &mut ClientInformation,
|
||||
) -> Result<Option<u128>, String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
let push = cfg::ResourcePackPush {
|
||||
id: pack.id,
|
||||
url: pack.url.clone(),
|
||||
hash: pack.hash.clone(),
|
||||
required: pack.required,
|
||||
prompt: pack.prompt.as_ref().map(component_tag),
|
||||
};
|
||||
c.send(&cfg::Clientbound::ResourcePackPush(push).encode())
|
||||
.map_err(e)?;
|
||||
std::thread::scope(|scope| {
|
||||
let (stop, stopped) = channel::<()>();
|
||||
let writer = &mut c.writer;
|
||||
scope.spawn(move || {
|
||||
let start = Instant::now();
|
||||
while stopped.recv_timeout(KEEP_ALIVE_EVERY) == Err(RecvTimeoutError::Timeout) {
|
||||
let id = i64::try_from(start.elapsed().as_millis()).unwrap_or(0);
|
||||
let keep_alive = cfg::Clientbound::KeepAlive(cfg::KeepAlive { id });
|
||||
if writer.send(&keep_alive.encode()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let answer = loop {
|
||||
let (id, payload) = match c.reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(err) => break Err(err.to_string()),
|
||||
};
|
||||
match cfg::Serverbound::decode(id, &payload) {
|
||||
Ok(cfg::Serverbound::ResourcePack(r)) if !matches!(r.action, 3 | 4) => {
|
||||
let rejected = r.action == PACK_DECLINED && pack.required;
|
||||
break Ok(rejected.then_some(r.id));
|
||||
}
|
||||
Ok(cfg::Serverbound::ClientInformation(i)) => *info = i,
|
||||
Ok(_) => {}
|
||||
Err(err) => break Err(err.to_string()),
|
||||
}
|
||||
};
|
||||
drop(stop);
|
||||
answer
|
||||
})
|
||||
}
|
||||
|
||||
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
|
||||
let conn_id = shared.next_id();
|
||||
let events = shared.events.clone();
|
||||
@@ -784,6 +947,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
let name = parts.name.clone();
|
||||
let joined = Joined {
|
||||
conn: conn_id,
|
||||
uuid: parts.uuid,
|
||||
@@ -795,8 +959,20 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
latency,
|
||||
address,
|
||||
};
|
||||
let required_pack = shared
|
||||
.config
|
||||
.resource_pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.required);
|
||||
let reason = if events.send(NetEvent::Joined(Box::new(joined))).is_ok() {
|
||||
read_loop(&mut reader, conn_id, &events, &alive)
|
||||
match read_loop(&mut reader, conn_id, &events, &alive, required_pack) {
|
||||
Ok(reason) => reason,
|
||||
Err(rejected) => {
|
||||
let (reason, text) = pack_rejected(shared, &name, rejected);
|
||||
let _ = tx.send(Out::Disconnect(reason));
|
||||
text
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"server closed".to_owned()
|
||||
};
|
||||
@@ -808,12 +984,16 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
shared.release(parts.uuid);
|
||||
}
|
||||
|
||||
/// Forward the player's packets until the connection ends: why it did, or
|
||||
/// the id of a required resource pack the player declined (with
|
||||
/// `required_pack`), which disconnects them.
|
||||
fn read_loop(
|
||||
reader: &mut ConnReader,
|
||||
conn: u32,
|
||||
events: &Sender<NetEvent>,
|
||||
alive: &AtomicI64,
|
||||
) -> String {
|
||||
required_pack: bool,
|
||||
) -> Result<String, u128> {
|
||||
loop {
|
||||
let (id, payload) = match reader.recv() {
|
||||
Ok(p) => p,
|
||||
@@ -824,19 +1004,24 @@ fn read_loop(
|
||||
|| e.0.contains("reset by peer")
|
||||
|| e.0.contains("aborted") =>
|
||||
{
|
||||
return "Disconnected".to_owned();
|
||||
return Ok("Disconnected".to_owned());
|
||||
}
|
||||
Err(e) => return e.to_string(),
|
||||
Err(e) => return Ok(e.to_string()),
|
||||
};
|
||||
match play::Serverbound::decode(id, &payload) {
|
||||
Ok(play::Serverbound::KeepAlive(k)) => alive.store(k.id, Ordering::Relaxed),
|
||||
Ok(play::Serverbound::ResourcePack(r))
|
||||
if r.action == PACK_DECLINED && required_pack =>
|
||||
{
|
||||
return Err(r.id);
|
||||
}
|
||||
Ok(p) => {
|
||||
if events.send(NetEvent::Packet(conn, Box::new(p))).is_err() {
|
||||
return "server closed".to_owned();
|
||||
return Ok("server closed".to_owned());
|
||||
}
|
||||
}
|
||||
// A malformed packet disconnects like vanilla's decoder exception.
|
||||
Err(e) => return format!("Invalid packet {id}: {e}"),
|
||||
Err(e) => return Ok(format!("Invalid packet {id}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1173,24 @@ fn write_loop(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompts_become_the_components_vanilla_sends() {
|
||||
let tag = |json: &str| {
|
||||
let tag = component_tag(&crate::server::json::parse(json, true).unwrap());
|
||||
component_json(&tag)
|
||||
};
|
||||
assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#);
|
||||
assert_eq!(tag(r#""Hi""#), r#""Hi""#);
|
||||
assert_eq!(
|
||||
tag(r#"["Hi ",{"text":"there","bold":true}]"#),
|
||||
r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"#
|
||||
);
|
||||
assert_eq!(
|
||||
tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#),
|
||||
r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#
|
||||
);
|
||||
}
|
||||
|
||||
fn unpack(out: Out) -> Vec<Vec<u8>> {
|
||||
match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
|
||||
@@ -139,6 +139,9 @@ pub struct NetConfig {
|
||||
/// Online mode: players are authenticated (none on a LAN world).
|
||||
pub online: Option<Arc<OnlineMode>>,
|
||||
pub log: Option<NetLog>,
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -164,6 +167,7 @@ impl NetConfig {
|
||||
gate: None,
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -230,8 +230,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod particle_tests;
|
||||
#[cfg(test)]
|
||||
mod visibility_tests;
|
||||
@@ -277,7 +275,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
mangrove_roots: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -440,7 +437,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
mangrove_roots: s.has_class("MangroveRootsBlock"),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -692,8 +688,6 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Cull shared leaf and mangrove-root faces, including transparent leaves.
|
||||
pub cull_leaves: bool,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -773,12 +767,9 @@ impl Mesher<'_> {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// Adapted from CullLeaves (MIT); see THIRD_PARTY_NOTICES.md.
|
||||
let shared_foliage = (state.leaves
|
||||
&& other.leaves
|
||||
&& (!self.cutout_leaves || self.cull_leaves))
|
||||
|| (self.cull_leaves && state.mangrove_roots && other.mangrove_roots);
|
||||
visible[d as usize] = !shared_foliage
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
use super::*;
|
||||
use horizon_assets::model::Direction as ModelDirection;
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_removes_only_internal_leaf_and_root_faces() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let oak = state("minecraft:oak_leaves");
|
||||
let birch = state("minecraft:birch_leaves");
|
||||
let roots = state("minecraft:mangrove_roots");
|
||||
let stone = state("minecraft:stone");
|
||||
let mesh = |blocks: &[([i32; 3], u32)], cull_leaves, cutout_leaves| {
|
||||
let mut center = Chunk::empty(0, 0);
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for &([x, y, z], state) in blocks {
|
||||
let chunk = if x < 16 { &mut center } else { &mut east };
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&east);
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves,
|
||||
cull_leaves,
|
||||
}
|
||||
.tesselate(®ion, (0 - MIN_Y) / 16)
|
||||
.solid
|
||||
};
|
||||
|
||||
// Native leaves can contain unculled interior geometry as well as their
|
||||
// outer faces. Select the touching cull group from the actual baked model,
|
||||
// preserving every other quad and its full vertex attributes.
|
||||
let without_face = |at: [i32; 3], id, direction| {
|
||||
let vertices = mesh(&[(at, id)], false, true);
|
||||
let models = render
|
||||
.model_catalog
|
||||
.collect(BlockStateId(id), BlockPos::new(at[0], at[1], at[2]))
|
||||
.unwrap();
|
||||
let faces: Vec<_> = models.iter().flat_map(|(_, model)| &model.faces).collect();
|
||||
assert_eq!(vertices.len(), faces.len() * 4);
|
||||
let retained: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(faces)
|
||||
.filter(|(_, face)| face.quad.cull != Some(direction))
|
||||
.flat_map(|(quad, _)| quad.iter().copied())
|
||||
.collect();
|
||||
assert!(retained.len() < vertices.len(), "fixture has a cull group");
|
||||
retained
|
||||
};
|
||||
|
||||
// Same-species leaves, mixed leaves and roots hide only their touching
|
||||
// model groups. Disabling the option restores the isolated models.
|
||||
for (a, b) in [(oak, oak), (oak, birch), (roots, roots)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
off.len(),
|
||||
mesh(&[blocks[0]], false, true).len() + mesh(&[blocks[1]], false, true).len()
|
||||
);
|
||||
let mut retained = without_face(blocks[0].0, a, ModelDirection::East);
|
||||
retained.extend(without_face(blocks[1].0, b, ModelDirection::West));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&retained)
|
||||
);
|
||||
}
|
||||
|
||||
// Roots do not join leaves into a single culling group. Ordinary opaque
|
||||
// neighbors also keep their existing occlusion behavior.
|
||||
for (a, b) in [(oak, roots), (roots, oak), (oak, stone)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&off)
|
||||
);
|
||||
}
|
||||
|
||||
// Opaque leaves already hide internal leaf faces independently of this
|
||||
// option, and a leaf beside air retains its complete baked model.
|
||||
let pair = [([7, 7, 7], oak), ([8, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&pair, false, false).len(),
|
||||
mesh(&pair, true, false).len()
|
||||
);
|
||||
let isolated = [([7, 7, 7], oak)];
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, false, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, true, true))
|
||||
);
|
||||
|
||||
// The padding read must also remove the center chunk's internal face
|
||||
// against a different leaf species in the neighboring chunk.
|
||||
let boundary = [([15, 7, 7], oak), ([16, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&boundary, false, true).len(),
|
||||
mesh(&[boundary[0]], false, true).len()
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&boundary, true, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&without_face(boundary[0].0, oak, ModelDirection::East))
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
mangrove_roots: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -736,9 +736,13 @@ pub struct Extra {
|
||||
/// The sniffer's and the ghastling's brain.
|
||||
pub animal_brain: Option<Box<animal_brain::Brain>>,
|
||||
pub axolotl: axolotl::Animation,
|
||||
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
|
||||
/// The mob or boat this one rides, and the mob riding it (`getVehicle`,
|
||||
/// `getFirstPassenger`).
|
||||
pub mount: Option<u32>,
|
||||
pub rider: Option<u32>,
|
||||
/// `Entity.boardingCooldown`: ticks after leaving a boat before a boat
|
||||
/// can pick this mob up again.
|
||||
pub boarding_cooldown: i32,
|
||||
/// A chicken carrying a zombie (`IsChickenJockey`).
|
||||
pub jockey: bool,
|
||||
/// A skeleton horse waiting to spring its trap, and how long it has waited.
|
||||
|
||||
@@ -96,7 +96,8 @@ fn xp_reward(m: &Mob) -> i32 {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn is_animal(s: Species) -> bool {
|
||||
/// The mobs that extend `Animal`.
|
||||
pub fn is_animal(s: Species) -> bool {
|
||||
use Species as S;
|
||||
matches!(
|
||||
s,
|
||||
|
||||
@@ -636,11 +636,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
} else {
|
||||
"entity/zombie_villager"
|
||||
};
|
||||
let baby = if m.is_baby() { "baby/" } else { "" };
|
||||
// VillagerProfessionLayer: a baby's type texture is in `baby/`.
|
||||
let group = if m.is_baby() { "baby" } else { "type" };
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/type/{baby}{t}"),
|
||||
&format!("{folder}/{group}/{t}"),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -1098,9 +1099,11 @@ fn render_visible(
|
||||
let mut poses = Poses::new(&part);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
|
||||
&& let Some((folder, variant)) = layer.texture.split_once("/type/")
|
||||
&& let Some((folder, variant)) = layer
|
||||
.texture
|
||||
.split_once("/type/")
|
||||
.or_else(|| layer.texture.split_once("/baby/"))
|
||||
{
|
||||
let variant = variant.strip_prefix("baby/").unwrap_or(variant);
|
||||
let profession = PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()];
|
||||
data.villager_hats
|
||||
.read()
|
||||
@@ -1275,6 +1278,44 @@ pub fn held_item_transform(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// VillagerProfessionLayer's textures (type, or baby for a baby;
|
||||
/// profession; level) of every villager and zombie villager are in the
|
||||
/// prepared resources: a missing one draws nothing.
|
||||
#[test]
|
||||
fn villager_layers_name_textures_that_exist() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
for kind in ["minecraft:villager", "minecraft:zombie_villager"] {
|
||||
let kind = std::sync::Arc::new(types.types[kind].clone());
|
||||
for baby in [false, true] {
|
||||
for t in 0..VILLAGER_TYPES.len() {
|
||||
for p in 0..PROFESSIONS.len() {
|
||||
let mut m =
|
||||
Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
m.extra.age = if baby { -24000 } else { 0 };
|
||||
(m.extra.variant, m.extra.variant2, m.extra.variant3) =
|
||||
(t as u8, p as u8, 5);
|
||||
let layers = layers(&m);
|
||||
let group = if baby { "/baby/" } else { "/type/" };
|
||||
assert!(
|
||||
layers.iter().any(|l| l.texture.contains(group)),
|
||||
"{kind:?} baby={baby}: no {group} layer"
|
||||
);
|
||||
for layer in layers {
|
||||
let png = root
|
||||
.join(format!("assets/minecraft/textures/{}.png", layer.texture));
|
||||
assert!(png.is_file(), "missing {}", png.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sulfur_cube_opaque_core_is_submitted_before_its_translucent_shell() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// A client's copy of its own player (`Level.isClientSide`): only the
|
||||
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
|
||||
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
|
||||
pub client_side: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -465,6 +469,7 @@ impl PlayerMotion {
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
client_side: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -2860,6 +2865,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A client's own fall (multiplayer, a LAN guest, single player on its
|
||||
/// server thread) plays its landing but never hurts it: only the server
|
||||
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
|
||||
/// leaves the screen still.
|
||||
#[test]
|
||||
fn a_clients_own_fall_does_not_hurt_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for client_side in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
|
||||
player.client_side = client_side;
|
||||
player.fall_distance = 30.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
|
||||
assert!(w.player.ground.on_ground);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
// LivingEntity.causeFallDamage's sounds play on both sides.
|
||||
assert!(w.player.events.iter().any(|e| matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::BigFall,
|
||||
..
|
||||
})
|
||||
)));
|
||||
let hurt = w.player.health.health() < 20.0;
|
||||
assert_eq!(hurt, !client_side);
|
||||
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
|
||||
// The server's health then starts the client's hurt animation.
|
||||
if client_side {
|
||||
w.player.health.flash_health_change(20.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 0);
|
||||
w.player.health.flash_health_change(12.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -135,7 +147,12 @@ impl Session {
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Send a play packet; silently dropped once the connection is gone.
|
||||
/// Send a play packet; silently dropped once the connection is gone,
|
||||
/// and while the server has the connection back in configuration (a
|
||||
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
|
||||
/// the level before it acknowledges, so no play packet follows the
|
||||
/// acknowledgement; one that did would reach the server as a
|
||||
/// configuration packet, and Velocity drops such a client.
|
||||
pub fn send(&self, packet: play::Serverbound) {
|
||||
if self.debug
|
||||
&& !matches!(
|
||||
@@ -147,12 +164,31 @@ impl Session {
|
||||
{
|
||||
eprintln!("[net] -> {packet:?}");
|
||||
}
|
||||
if let Some(w) = self.writer.lock().unwrap().as_mut()
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
if self.configuring.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&packet.encode()).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// 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 +289,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 +305,13 @@ fn run(
|
||||
target
|
||||
}
|
||||
};
|
||||
let c =
|
||||
Connection::connect(&host, port, Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
let c = if address.starts_with(FRIEND) {
|
||||
// The friends relay supplies a concrete endpoint, not a Minecraft hostname.
|
||||
Connection::connect(&host, port, Duration::from_secs(10))
|
||||
} else {
|
||||
Connection::connect_server(&host, port, Duration::from_secs(10))
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
(host, port, c)
|
||||
};
|
||||
*shared_counters.lock().unwrap() = Some(c.reader.counters.clone());
|
||||
@@ -377,18 +419,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 +459,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 +490,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 +517,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 +532,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 +610,7 @@ pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128), String> {
|
||||
use horizon_protocol::packets::status;
|
||||
let (host, port) = parse_address(address).ok_or("Invalid address")?;
|
||||
let mut c = Connection::connect(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
let mut c = Connection::connect_server(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
c.reader
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -602,7 +653,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 {
|
||||
@@ -638,6 +695,60 @@ mod tests {
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_packets_wait_out_a_reconfiguration() {
|
||||
let (client, server) = horizon_protocol::connection::Pipe::pair();
|
||||
let (mut reader, writer) = Connection::local(client).split();
|
||||
let (send, events) = channel();
|
||||
let session = Session {
|
||||
events,
|
||||
writer: Arc::new(Mutex::new(Some(writer))),
|
||||
configuring: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
address: "local".into(),
|
||||
name: "Player".into(),
|
||||
counters: Arc::new(Mutex::new(None)),
|
||||
debug: false,
|
||||
};
|
||||
let (thread_writer, thread_configuring) =
|
||||
(session.writer.clone(), session.configuring.clone());
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let tick_end = play::Serverbound::ClientTickEnd(Empty);
|
||||
server
|
||||
.send(&play::Clientbound::StartConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
play::Serverbound::decode(id, &body).unwrap(),
|
||||
play::Serverbound::ConfigurationAcknowledged(Empty)
|
||||
);
|
||||
// The game thread still ticks the old level: nothing of it goes out.
|
||||
session.send(tick_end.clone());
|
||||
server
|
||||
.send(&cfg::Clientbound::FinishConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
cfg::Serverbound::decode(id, &body).unwrap(),
|
||||
cfg::Serverbound::FinishConfiguration(Empty)
|
||||
);
|
||||
// Back in play, the game's packets flow again.
|
||||
session.send(tick_end.clone());
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(play::Serverbound::decode(id, &body).unwrap(), tick_end);
|
||||
drop(session);
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
|
||||
let (send, events) = channel();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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":[
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
cull_leaves: bool,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -258,7 +257,6 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
pub cull_leaves: bool,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -500,7 +498,6 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
cull_leaves: settings.cull_leaves,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -556,7 +553,6 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -1177,7 +1173,6 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
cull_leaves: self.cull_leaves,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -215,7 +215,6 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
@@ -148,10 +148,11 @@ impl PerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.add_volume(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
self.add_volume::<true>(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
}
|
||||
|
||||
fn add_volume(
|
||||
/// `BATCH` = false evaluates one Y at a time: the reference the batched loop is tested against.
|
||||
fn add_volume<const BATCH: bool>(
|
||||
&self,
|
||||
buffer: &mut [f32],
|
||||
volume: DensityVolume,
|
||||
@@ -207,7 +208,10 @@ impl PerlinNoise {
|
||||
let mut previous_y = i32::MIN;
|
||||
let mut xz_dots = [0.0_f32; 8];
|
||||
let mut y_gradients = [0.0_f32; 8];
|
||||
for (sample, out) in ys.iter().zip(&mut buffer[index..index + size_y]) {
|
||||
let column = &mut buffer[index..index + size_y];
|
||||
let mut y = 0;
|
||||
while y < size_y {
|
||||
let sample = &ys[y];
|
||||
if previous_y != sample.cell {
|
||||
let gradients = self.gradients_at(p, q, sample.cell, cell_z);
|
||||
for i in 0..8 {
|
||||
@@ -217,11 +221,31 @@ impl PerlinNoise {
|
||||
}
|
||||
previous_y = sample.cell;
|
||||
}
|
||||
// Four Ys in one cell share the corners: side by side, LLVM computes them
|
||||
// as vectors. Cell boundaries and the column's tail go one at a time.
|
||||
if BATCH
|
||||
&& y + 4 <= size_y
|
||||
&& ys[y + 1..y + 4].iter().all(|s| s.cell == sample.cell)
|
||||
{
|
||||
add_four(
|
||||
&mut column[y..y + 4],
|
||||
&ys[y..y + 4],
|
||||
xz_dots,
|
||||
y_gradients,
|
||||
fade_x,
|
||||
fade_z,
|
||||
amplitude,
|
||||
);
|
||||
y += 4;
|
||||
continue;
|
||||
}
|
||||
let out = &mut column[y];
|
||||
let dy = sample.offset;
|
||||
let values = std::array::from_fn(|i| {
|
||||
xz_dots[i] + y_gradients[i] * (dy - ((i >> 1) & 1) as f32)
|
||||
});
|
||||
*out += amplitude * lerp3([fade_x, sample.fade, fade_z], values);
|
||||
y += 1;
|
||||
}
|
||||
index += size_y;
|
||||
}
|
||||
@@ -293,7 +317,7 @@ impl SmearedPerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.base.add_volume(
|
||||
self.base.add_volume::<true>(
|
||||
buffer,
|
||||
volume,
|
||||
xz_scale,
|
||||
@@ -312,3 +336,97 @@ fn compute_fudge_y(input_y: f64, relative_y: f64, scale: f64) -> f64 {
|
||||
};
|
||||
f64::from(floor_i32(limited_y / scale + f64::from(1e-7_f32))) * scale
|
||||
}
|
||||
|
||||
/// Adds four Y samples of one cell, laid out lane by lane so LLVM vectorizes them (no intrinsics,
|
||||
/// no target feature). Each lane does the one-at-a-time loop's operations in the same order.
|
||||
#[inline]
|
||||
fn add_four(
|
||||
out: &mut [f32],
|
||||
ys: &[YSample],
|
||||
dots: [f32; 8],
|
||||
gradients: [f32; 8],
|
||||
fade_x: f32,
|
||||
fade_z: f32,
|
||||
amplitude: f32,
|
||||
) {
|
||||
let dy: [f32; 4] = std::array::from_fn(|lane| ys[lane].offset);
|
||||
let fy: [f32; 4] = std::array::from_fn(|lane| ys[lane].fade);
|
||||
let values: [[f32; 4]; 8] = std::array::from_fn(|i| {
|
||||
std::array::from_fn(|lane| dots[i] + gradients[i] * (dy[lane] - ((i >> 1) & 1) as f32))
|
||||
});
|
||||
for lane in 0..4 {
|
||||
out[lane] += amplitude * lerp3([fade_x, fy[lane], fade_z], values.map(|v| v[lane]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn batched_volume_is_bit_identical() {
|
||||
let scales = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.001,
|
||||
-0.001,
|
||||
0.125,
|
||||
-0.5,
|
||||
1.0,
|
||||
4.0,
|
||||
1e100,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NAN,
|
||||
];
|
||||
let amplitudes = [
|
||||
0.0,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NAN,
|
||||
f32::from_bits(1),
|
||||
];
|
||||
for seed in [0, -1, 1234, i64::MIN] {
|
||||
let noise = PerlinNoise::new(&mut LegacyRandom::new(seed));
|
||||
for height in [1, 2, 3, 4, 5, 7, 8, 9, 31, 64, 65, 97] {
|
||||
for (case, &scale) in scales.iter().enumerate() {
|
||||
let minimum = [
|
||||
[-17, -64, 49],
|
||||
[i32::MAX, i32::MIN, -16777216],
|
||||
[16777216, 33554431, -33554432],
|
||||
][case % 3];
|
||||
let volume = DensityVolume::new(
|
||||
[2, height, 2],
|
||||
minimum,
|
||||
[4, if case % 3 == 0 { i32::MAX } else { 1 }, 16],
|
||||
)
|
||||
.unwrap();
|
||||
for fudge in [None, Some(0.125), Some(-0.25), Some(0.0), Some(f64::NAN)] {
|
||||
let initial = [0.0, -0.0, 1.5, f32::from_bits(1)];
|
||||
let mut a: Vec<_> =
|
||||
(0..volume.size() + 3).map(|i| initial[i % 4]).collect();
|
||||
let mut b = a.clone();
|
||||
let amplitude = amplitudes[case % amplitudes.len()];
|
||||
noise
|
||||
.add_volume::<false>(&mut a, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
noise
|
||||
.add_volume::<true>(&mut b, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
for (i, (a, b)) in a.iter().zip(&b).enumerate() {
|
||||
assert_eq!(
|
||||
a.to_bits(),
|
||||
b.to_bits(),
|
||||
"seed={seed} height={height} scale={scale} fudge={fudge:?} index={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ wasmtime = { version = "36.0.18", default-features = false, features = ["runtime
|
||||
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
|
||||
wit-component = { version = "0.239", optional = true }
|
||||
wit-parser = { version = "0.239", optional = true }
|
||||
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
minisign-verify = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
@@ -24,11 +27,18 @@ wit-parser = { version = "0.239", optional = true }
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
|
||||
index = ["dep:sha2", "dep:minisign-verify"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
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
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The mod registry's index, `index.json` (docs/api/mod-index.md): every
|
||||
//! reviewed mod and modpack, its versions, the file of each with its size,
|
||||
//! SHA-256 and minisign signature, and the files taken down. The launcher
|
||||
//! reads it to browse and install, the website to show it, and the
|
||||
//! registry's CI writes it.
|
||||
//!
|
||||
//! [`check_file`] is what a file must pass before it is installed: its size
|
||||
//! and hash are the ones the index lists, its signature verifies against the
|
||||
//! project's pinned key, and what its own manifest says matches the entry.
|
||||
use crate::{
|
||||
Capability, Package, Side,
|
||||
modpack::{self, Modpack},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::Read, path::Path};
|
||||
|
||||
/// The index format this build reads and writes. A reader refuses a newer
|
||||
/// one rather than misread it.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
/// The biggest file the registry takes (a `.hmod` with content, a `.hpack`).
|
||||
pub const MAX_FILE: u64 = 64 << 20;
|
||||
/// Where files may be downloaded from: the registry's release assets.
|
||||
pub const FILE_HOST: &str = "https://github.com/ludovic111/horizon-mods/releases/download/";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Index {
|
||||
pub schema: u32,
|
||||
/// When CI built it, RFC 3339.
|
||||
#[serde(default)]
|
||||
pub generated: String,
|
||||
#[serde(default)]
|
||||
pub projects: Vec<Project>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revoked>,
|
||||
}
|
||||
|
||||
/// What a project is. New kinds (data packs, worlds, themes) can be added
|
||||
/// without a new schema: a reader keeps a kind it doesn't know as
|
||||
/// [`Kind::Other`] and doesn't offer it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
ResourcePack,
|
||||
/// A Horizon shader pack (the native WGSL format, not Iris or OptiFine
|
||||
/// packs). Listed by the schema; submissions open with its API.
|
||||
Shader,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
Kind::Other(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of its files; None for a kind this build doesn't know.
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack | Kind::Shader => Some("zip"),
|
||||
Kind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
_ => Kind::Other(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Kind> for String {
|
||||
fn from(kind: Kind) -> String {
|
||||
kind.name().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// The manifest's id, `[a-z0-9_]{1,64}`: unique across the index.
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
/// One line for lists.
|
||||
pub summary: String,
|
||||
/// Markdown, for the project's page.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The GitHub logins allowed to publish its updates.
|
||||
#[serde(default)]
|
||||
pub owners: Vec<String>,
|
||||
/// An SPDX expression.
|
||||
pub license: String,
|
||||
/// Where its source code is, `https://`.
|
||||
pub source: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// The author's minisign public key: every version must be signed with
|
||||
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Newest first.
|
||||
pub versions: Vec<Version>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
pub version: String,
|
||||
/// The day it was published, `YYYY-MM-DD`.
|
||||
#[serde(default)]
|
||||
pub published: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub changelog: String,
|
||||
/// A mod's API line (`0.1`), from its manifest; a modpack's format (`1`).
|
||||
pub api: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub side: Option<Side>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// A modpack's mods, as its manifest lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub requires: Vec<Requirement>,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Requirement {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct File {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
/// Lowercase hex.
|
||||
pub sha256: String,
|
||||
/// The `.minisig` file's text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// A file taken down: by hash, or every version of `id` when `sha256` is
|
||||
/// None. The launcher refuses to install it and turns installed copies off.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Revoked {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
/// How far a checked file is trusted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Trust {
|
||||
/// Signed with the project's pinned key.
|
||||
Verified,
|
||||
/// The project has no key: only the hash pins it.
|
||||
Unsigned,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses and checks an index (see [`Index::check`]).
|
||||
pub fn parse(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, false)
|
||||
}
|
||||
|
||||
/// [`Index::parse`] for a local test registry, whose files may be
|
||||
/// `file://` paths (trying a registry out before it is published).
|
||||
pub fn parse_local(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, true)
|
||||
}
|
||||
|
||||
fn parse_with(text: &str, local_files: bool) -> Result<Index, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
schema: u32,
|
||||
}
|
||||
let head: Head = serde_json::from_str(text).map_err(|e| format!("not a mod index: {e}"))?;
|
||||
if head.schema > SCHEMA {
|
||||
return Err(format!(
|
||||
"the mod index is schema {}; this build reads {SCHEMA}: update Horizon",
|
||||
head.schema
|
||||
));
|
||||
}
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("mod index: {e}"))?;
|
||||
index.check_with(local_files)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Every rule a reader relies on: unique valid ids, at least one
|
||||
/// version each, plain file names with the kind's extension, `https`
|
||||
/// URLs, hex hashes, keys and signatures that decode, and each
|
||||
/// modpack's mods present in the index with the same hash.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.check_with(false)
|
||||
}
|
||||
|
||||
fn check_with(&self, local_files: bool) -> Result<(), String> {
|
||||
for (i, p) in self.projects.iter().enumerate() {
|
||||
let at = |e: String| format!("{}: {e}", p.id);
|
||||
if !valid_id(&p.id) {
|
||||
return Err(format!("project id {:?} is not [a-z0-9_]{{1,64}}", p.id));
|
||||
}
|
||||
if self.projects[..i].iter().any(|q| q.id == p.id) {
|
||||
return Err(at("listed twice".into()));
|
||||
}
|
||||
if p.name.trim().is_empty() || p.license.trim().is_empty() {
|
||||
return Err(at("needs a name and a licence".into()));
|
||||
}
|
||||
https(&p.source).map_err(at)?;
|
||||
if let Some(icon) = &p.icon {
|
||||
https(icon).map_err(at)?;
|
||||
}
|
||||
if let Some(key) = &p.key {
|
||||
minisign_verify::PublicKey::from_base64(key.trim())
|
||||
.map_err(|e| at(format!("key: {e}")))?;
|
||||
}
|
||||
if p.versions.is_empty() {
|
||||
return Err(at("has no version".into()));
|
||||
}
|
||||
for (j, v) in p.versions.iter().enumerate() {
|
||||
let at = |e: String| format!("{} {}: {e}", p.id, v.version);
|
||||
if v.version.trim().is_empty()
|
||||
|| p.versions[..j].iter().any(|w| w.version == v.version)
|
||||
{
|
||||
return Err(at("empty or repeated version".into()));
|
||||
}
|
||||
let f = &v.file;
|
||||
let extension_ok = p
|
||||
.kind
|
||||
.extension()
|
||||
.is_none_or(|e| f.name.ends_with(&format!(".{e}")));
|
||||
if !plain_name(&f.name) || !extension_ok {
|
||||
return Err(at(format!(
|
||||
"{:?} is not a plain file name for a {}",
|
||||
f.name,
|
||||
p.kind.name()
|
||||
)));
|
||||
}
|
||||
if !(local_files && f.url.starts_with("file:///")) {
|
||||
https(&f.url).map_err(at)?;
|
||||
}
|
||||
hex64(&f.sha256).map_err(at)?;
|
||||
if f.size == 0 || f.size > MAX_FILE {
|
||||
return Err(at(format!(
|
||||
"size {} is not 1 byte to {} MiB",
|
||||
f.size,
|
||||
MAX_FILE >> 20
|
||||
)));
|
||||
}
|
||||
match (&p.key, &f.signature) {
|
||||
(Some(_), None) => {
|
||||
return Err(at("unsigned, but the project has a key".into()));
|
||||
}
|
||||
(None, Some(_)) => return Err(at("signed, but the project has no key".into())),
|
||||
(Some(_), Some(sig)) => {
|
||||
minisign_verify::Signature::decode(sig)
|
||||
.map_err(|e| at(format!("signature: {e}")))?;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for r in &v.requires {
|
||||
let found = self
|
||||
.project(&r.id)
|
||||
.and_then(|q| q.versions.iter().find(|w| w.version == r.version));
|
||||
match found {
|
||||
Some(w) if w.file.sha256 == r.sha256 => {}
|
||||
Some(_) => {
|
||||
return Err(at(format!(
|
||||
"{} {}: another file than the index's",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(at(format!(
|
||||
"needs {} {}, which is not in the index",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in &self.revoked {
|
||||
if let Some(h) = &r.sha256 {
|
||||
hex64(h).map_err(|e| format!("revoked {}: {e}", r.id))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project(&self, id: &str) -> Option<&Project> {
|
||||
self.projects.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Why the file of `id` with this hash was taken down, if it was.
|
||||
pub fn revoked(&self, id: &str, sha256: &str) -> Option<&Revoked> {
|
||||
self.revoked.iter().find(|r| {
|
||||
r.id == id
|
||||
&& r.sha256
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.eq_ignore_ascii_case(sha256))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9_]{1,64}`, the manifests' id rule.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
fn https(url: &str) -> Result<(), String> {
|
||||
match url.strip_prefix("https://") {
|
||||
Some(rest) if !rest.is_empty() && !url.chars().any(char::is_whitespace) => Ok(()),
|
||||
_ => Err(format!("{url:?} is not an https:// address")),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex64(h: &str) -> Result<(), String> {
|
||||
if h.len() == 64
|
||||
&& h.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{h:?} is not a lowercase hex SHA-256"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One plain file name: no folder, no hidden file, nothing a file system
|
||||
/// reads as something else.
|
||||
fn plain_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 128
|
||||
&& !name.starts_with('.')
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
||||
}
|
||||
|
||||
/// The SHA-256 of a file, lowercase hex.
|
||||
pub fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Checks a minisign signature (`.minisig` text) of `path` against `key`.
|
||||
pub fn verify_signature(key: &str, signature: &str, path: &Path) -> Result<(), String> {
|
||||
let key =
|
||||
minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("key: {e}"))?;
|
||||
let sig =
|
||||
minisign_verify::Signature::decode(signature).map_err(|e| format!("signature: {e}"))?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut verifier = key
|
||||
.verify_stream(&sig)
|
||||
.map_err(|e| format!("signature: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
verifier.update(&buf[..n]);
|
||||
}
|
||||
verifier
|
||||
.finalize()
|
||||
.map_err(|_| "the signature doesn't match the project's key".to_string())
|
||||
}
|
||||
|
||||
/// Checks the file at `path` against its entry before it is installed:
|
||||
/// size, SHA-256, revocation, signature, and that its manifest says what the
|
||||
/// index says (id, version, API, side, capabilities; a modpack's mods).
|
||||
pub fn check_file(
|
||||
index: &Index,
|
||||
project: &Project,
|
||||
version: &Version,
|
||||
path: &Path,
|
||||
) -> Result<Trust, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size != version.file.size {
|
||||
return Err(format!(
|
||||
"{} bytes, the index lists {}",
|
||||
size, version.file.size
|
||||
));
|
||||
}
|
||||
let hash = sha256_file(path)?;
|
||||
if hash != version.file.sha256 {
|
||||
return Err("its SHA-256 isn't the one the index lists: nothing was installed".into());
|
||||
}
|
||||
if let Some(r) = index.revoked(&project.id, &hash) {
|
||||
return Err(format!("{} was taken down: {}", project.name, r.reason));
|
||||
}
|
||||
let trust = match (&project.key, &version.file.signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
verify_signature(key, sig, path)?;
|
||||
Trust::Verified
|
||||
}
|
||||
(None, None) => Trust::Unsigned,
|
||||
_ => return Err("its signature and the project's key don't go together".into()),
|
||||
};
|
||||
match project.kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
crate::Content::read(path, &m)?;
|
||||
let mut caps = m.capabilities.clone();
|
||||
caps.sort();
|
||||
let mut listed = version.capabilities.clone();
|
||||
listed.sort();
|
||||
let differs = m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.api != version.api
|
||||
|| Some(m.side) != version.side
|
||||
|| caps != listed;
|
||||
if differs {
|
||||
return Err(format!(
|
||||
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
|
||||
m.id,
|
||||
m.version,
|
||||
m.api,
|
||||
m.side,
|
||||
names(&caps)
|
||||
));
|
||||
}
|
||||
}
|
||||
Kind::ResourcePack => check_resource_pack(path)?,
|
||||
Kind::Shader | Kind::Other(_) => {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
));
|
||||
}
|
||||
Kind::Modpack => {
|
||||
let pack = Modpack::read(path)?;
|
||||
let m = &pack.manifest;
|
||||
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
|
||||
if m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.format.to_string() != version.api
|
||||
|| m.game != version.game
|
||||
|| mods != version.requires
|
||||
{
|
||||
return Err(format!(
|
||||
"its {} isn't what the index lists",
|
||||
modpack::MANIFEST
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(trust)
|
||||
}
|
||||
|
||||
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
|
||||
/// object, and only plain paths.
|
||||
fn check_resource_pack(path: &Path) -> Result<(), String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
for i in 0..zip.len() {
|
||||
let name = zip
|
||||
.by_index_raw(i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.name()
|
||||
.to_owned();
|
||||
if name.starts_with('/') || name.split('/').any(|part| part == "..") || name.contains('\\')
|
||||
{
|
||||
return Err(format!("{name:?} is not a plain path"));
|
||||
}
|
||||
}
|
||||
let mut meta = String::new();
|
||||
zip.by_name("pack.mcmeta")
|
||||
.map_err(|_| "pack.mcmeta: missing".to_string())?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut meta)
|
||||
.map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
if !json["pack"].is_object() {
|
||||
return Err("pack.mcmeta: no \"pack\" object".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn names(caps: &[Capability]) -> String {
|
||||
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! The sample index (`tests/index/index-v1.json`, the one docs/api/mod-index.md
|
||||
//! shows) and its files: `coords_hud-0.1.0.hmod`, signed with the throwaway
|
||||
//! key `test.pub` by minisign 0.12 (its secret key was deleted), and
|
||||
//! `cozy-1.0.0.hpack`, unsigned.
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const OTHER_KEY: &str = "RWSe8ZD+cTOAqNd/jnXTmDtoKp0c0HxkGMrBBWcWQeEkPmcHB+XkaE9S";
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
|
||||
}
|
||||
|
||||
fn sample() -> Index {
|
||||
Index::parse(&std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn check(index: &Index, id: &str, file: &Path) -> Result<Trust, String> {
|
||||
let p = index.project(id).unwrap();
|
||||
check_file(index, p, &p.versions[0], file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_sample_and_keeps_unknown_kinds() {
|
||||
let index = sample();
|
||||
assert_eq!(index.schema, SCHEMA);
|
||||
let kinds: Vec<&str> = index.projects.iter().map(|p| p.kind.name()).collect();
|
||||
assert_eq!(kinds, ["mod", "modpack", "world"]);
|
||||
assert_eq!(index.projects[2].kind, Kind::Other("world".into()));
|
||||
let hud = index.project("coords_hud").unwrap();
|
||||
assert_eq!(
|
||||
hud.versions[0].capabilities,
|
||||
[Capability::GameRead, Capability::Hud, Capability::Chat]
|
||||
);
|
||||
// What CI writes reads back the same.
|
||||
let again = Index::parse(&serde_json::to_string(&index).unwrap()).unwrap();
|
||||
assert_eq!(again, index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_pass_against_their_entries() {
|
||||
let index = sample();
|
||||
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
|
||||
assert_eq!(check(&index, "coords_hud", &hmod), Ok(Trust::Verified));
|
||||
let hpack = fixtures().join("cozy-1.0.0.hpack");
|
||||
assert_eq!(check(&index, "cozy", &hpack), Ok(Trust::Unsigned));
|
||||
let world = index.project("skyblock_start").unwrap();
|
||||
let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err();
|
||||
assert!(e.contains("bytes"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_file_key_or_manifest_is_refused() {
|
||||
let index = sample();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-index-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
|
||||
let mut bytes = std::fs::read(&hmod).unwrap();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 1;
|
||||
let tampered = dir.join("coords_hud-0.1.0.hmod");
|
||||
std::fs::write(&tampered, &bytes).unwrap();
|
||||
let e = check(&index, "coords_hud", &tampered).unwrap_err();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
|
||||
let mut other_key = index.clone();
|
||||
other_key.projects[0].key = Some(OTHER_KEY.into());
|
||||
let e = check(&other_key, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("key"), "{e}");
|
||||
|
||||
let mut more_caps = index.clone();
|
||||
more_caps.projects[0].versions[0].capabilities.pop();
|
||||
let e = check(&more_caps, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("isn't what the index lists"), "{e}");
|
||||
|
||||
let mut revoked = index.clone();
|
||||
revoked.revoked.push(Revoked {
|
||||
id: "coords_hud".into(),
|
||||
sha256: None,
|
||||
reason: "Test.".into(),
|
||||
date: String::new(),
|
||||
});
|
||||
let e = check(&revoked, "coords_hud", &hmod).unwrap_err();
|
||||
assert!(e.contains("taken down: Test."), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocations_match_by_hash_or_by_id() {
|
||||
let index = sample();
|
||||
let ones = "1".repeat(64);
|
||||
assert!(index.revoked("bad_mod", &ones).is_some());
|
||||
assert!(index.revoked("bad_mod", &"2".repeat(64)).is_none());
|
||||
assert!(index.revoked("coords_hud", &ones).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_broken_indexes() {
|
||||
let text = std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap();
|
||||
let newer = text.replacen("\"schema\": 1", "\"schema\": 2", 1);
|
||||
assert!(Index::parse(&newer).unwrap_err().contains("schema 2"));
|
||||
let broken = |edit: &dyn Fn(&mut Index)| {
|
||||
let mut index = sample();
|
||||
edit(&mut index);
|
||||
index.check().unwrap_err()
|
||||
};
|
||||
let e = broken(&|i| {
|
||||
let twin = i.projects[0].clone();
|
||||
i.projects.push(twin)
|
||||
});
|
||||
assert!(e.contains("listed twice"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.url = "http://example.org/x.hmod".into());
|
||||
assert!(e.contains("https"), "{e}");
|
||||
// A local test registry may list files on disk; the published one may not.
|
||||
let local = text.replace(
|
||||
"https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/",
|
||||
"file:///tmp/",
|
||||
);
|
||||
assert!(Index::parse(&local).unwrap_err().contains("https"));
|
||||
assert!(Index::parse_local(&local).is_ok());
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.name = "../coords_hud.hmod".into());
|
||||
assert!(e.contains("plain file name"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.name = "coords_hud.zip".into());
|
||||
assert!(e.contains("plain file name"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].versions[0].file.signature = None);
|
||||
assert!(e.contains("unsigned"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].key = Some("not a key".into()));
|
||||
assert!(e.contains("key"), "{e}");
|
||||
let e = broken(&|i| i.projects[1].versions[0].requires[0].version = "9.9".into());
|
||||
assert!(e.contains("not in the index"), "{e}");
|
||||
let e = broken(&|i| i.projects[1].versions[0].requires[0].sha256 = "2".repeat(64));
|
||||
assert!(e.contains("another file"), "{e}");
|
||||
let e = broken(&|i| i.projects[0].id = "Coords".into());
|
||||
assert!(e.contains("is not"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_packs_need_a_pack_mcmeta() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-index-rp-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pack = |files: &[(&str, &str)]| {
|
||||
let path = dir.join("look.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
for (name, text) in files {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
};
|
||||
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
|
||||
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
|
||||
let e = check_resource_pack(&pack(&[("assets/x.png", "")])).unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", "{}")])).unwrap_err();
|
||||
assert!(e.contains("\"pack\""), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", meta), ("../x", "")])).unwrap_err();
|
||||
assert!(e.contains("plain path"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
//! Nothing here runs unless a mod is installed and turned on: an empty
|
||||
//! [`Mods`] makes no engine and every hook returns at once.
|
||||
pub mod content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
pub mod manifest;
|
||||
pub mod modpack;
|
||||
#[cfg(feature = "runtime")]
|
||||
mod runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt,
|
||||
io::Read,
|
||||
@@ -20,6 +20,9 @@ pub const APIS: [&str; 2] = [API, "0.2"];
|
||||
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
|
||||
/// to those, docs/mods.md) and the game's own.
|
||||
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
|
||||
/// A mod's data (capability `storage`), in `horizon-mods/` beside the mods
|
||||
/// (`find` never mistakes it for a mod: it has no manifest).
|
||||
pub const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
/// The biggest `mod.wasm` a mod may ship.
|
||||
const MAX_WASM: u64 = 32 << 20;
|
||||
|
||||
@@ -48,7 +51,7 @@ pub struct Manifest {
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Side {
|
||||
/// Runs in the player's game only (HUD, keys, chat commands).
|
||||
@@ -62,7 +65,7 @@ pub enum Side {
|
||||
|
||||
/// What a mod may do beyond writing its log. Each is a WIT interface the
|
||||
/// component imports; the player sees them before turning the mod on.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Capability {
|
||||
GameRead,
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! `.hpack`, a Horizon modpack (docs/api/mod-index.md): registry mods
|
||||
//! played together as a profile. A zip holding `horizon-modpack.toml`, which
|
||||
//! names each mod by id, version and SHA-256 (the files themselves come from
|
||||
//! the registry), and optionally `overrides/`: settings and resource packs
|
||||
//! copied into the profile's game folder.
|
||||
use crate::manifest::STORAGE_DIR;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// The manifest's file name in a `.hpack`.
|
||||
pub const MANIFEST: &str = "horizon-modpack.toml";
|
||||
/// The manifest formats this build reads.
|
||||
pub const FORMAT: u32 = 1;
|
||||
const OVERRIDES: &str = "overrides/";
|
||||
/// Bytes per override file and in all, unpacked.
|
||||
const MAX_OVERRIDE: u64 = 64 << 20;
|
||||
const MAX_OVERRIDES: u64 = 256 << 20;
|
||||
/// The settings files an override may replace.
|
||||
const SETTINGS: [&str; 3] = ["options.txt", "horizon-graphics.txt", "horizon-extras.txt"];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format: u32,
|
||||
/// `[a-z0-9_]{1,64}`: its profile's folder.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<PackMod>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackMod {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
/// Turned on in the profile (`horizon-mods.txt`).
|
||||
#[serde(default = "on")]
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
fn on() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "index")]
|
||||
impl PackMod {
|
||||
pub fn requirement(&self) -> crate::index::Requirement {
|
||||
crate::index::Requirement {
|
||||
id: self.id.clone(),
|
||||
version: self.version.clone(),
|
||||
sha256: self.sha256.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let m: Manifest = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
if m.format != FORMAT {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: format {}; this Horizon reads {FORMAT}",
|
||||
m.format
|
||||
));
|
||||
}
|
||||
let id_ok = |id: &str| {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
};
|
||||
if !id_ok(&m.id) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: id {:?} is not [a-z0-9_]{{1,64}}",
|
||||
m.id
|
||||
));
|
||||
}
|
||||
for (i, pm) in m.mods.iter().enumerate() {
|
||||
let hex = pm.sha256.len() == 64
|
||||
&& pm
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
|
||||
if !id_ok(&pm.id) || !hex || pm.version.is_empty() {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: mod {:?} needs an id, a version and a lowercase hex sha256",
|
||||
pm.id
|
||||
));
|
||||
}
|
||||
if m.mods[..i].iter().any(|other| other.id == pm.id) {
|
||||
return Err(format!("{MANIFEST}: mod {} is listed twice", pm.id));
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.hpack` as read from disk: its manifest and its override files, by
|
||||
/// path under `overrides/`.
|
||||
#[derive(Debug)]
|
||||
pub struct Modpack {
|
||||
pub manifest: Manifest,
|
||||
pub overrides: Vec<String>,
|
||||
}
|
||||
|
||||
impl Modpack {
|
||||
pub fn read(path: &Path) -> Result<Modpack, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut text = String::new();
|
||||
zip.by_name(MANIFEST)
|
||||
.map_err(|_| format!("{MANIFEST}: missing"))?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
let manifest = Manifest::parse(&text)?;
|
||||
let (mut overrides, mut total) = (Vec::new(), 0u64);
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name();
|
||||
if name == MANIFEST || entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = name.strip_prefix(OVERRIDES) else {
|
||||
return Err(format!(
|
||||
"{name}: a modpack holds only {MANIFEST} and overrides/"
|
||||
));
|
||||
};
|
||||
check_override(rest, &manifest)?;
|
||||
if entry.size() > MAX_OVERRIDE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_OVERRIDE >> 20));
|
||||
}
|
||||
total += entry.size();
|
||||
overrides.push(rest.to_owned());
|
||||
}
|
||||
if total > MAX_OVERRIDES {
|
||||
return Err(format!("overrides are over {} MiB", MAX_OVERRIDES >> 20));
|
||||
}
|
||||
overrides.sort();
|
||||
Ok(Modpack {
|
||||
manifest,
|
||||
overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the overrides of the pack at `path` into the game folder
|
||||
/// `home`, each checked again.
|
||||
pub fn extract_overrides(path: &Path, home: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let pack = Modpack::read(path)?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut written = Vec::new();
|
||||
for name in &pack.overrides {
|
||||
let mut entry = zip
|
||||
.by_name(&format!("{OVERRIDES}{name}"))
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
let target = home.join(name);
|
||||
if let Some(dir) = target.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
let mut out =
|
||||
std::fs::File::create(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
std::io::copy(&mut (&mut entry).take(MAX_OVERRIDE), &mut out)
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
written.push(target);
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
/// An override's path under `overrides/`: a settings file, a resource pack
|
||||
/// zip, or the stored settings of one of the pack's mods. Plain parts only.
|
||||
fn check_override(path: &str, manifest: &Manifest) -> Result<(), String> {
|
||||
let bad = || {
|
||||
format!(
|
||||
"overrides/{path}: only {}, resourcepacks/<name>.zip and horizon-mods/{STORAGE_DIR}/<a listed mod>/ may be overridden",
|
||||
SETTINGS.join(", ")
|
||||
)
|
||||
};
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let plain = |p: &&str| {
|
||||
!p.is_empty()
|
||||
&& !p.starts_with('.')
|
||||
&& p.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+ ".contains(&b))
|
||||
};
|
||||
let ok = match parts.as_slice() {
|
||||
[file] => SETTINGS.contains(file),
|
||||
["resourcepacks", name] => plain(name) && name.ends_with(".zip"),
|
||||
["horizon-mods", dir, id, rest @ ..] => {
|
||||
*dir == STORAGE_DIR
|
||||
&& manifest.mods.iter().any(|m| m.id == *id)
|
||||
&& !rest.is_empty()
|
||||
&& rest.iter().all(plain)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok { Ok(()) } else { Err(bad()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const PACK: &str = r#"
|
||||
format = 1
|
||||
id = "cozy"
|
||||
name = "Cozy"
|
||||
version = "1.0.0"
|
||||
game = "26.3"
|
||||
[[mods]]
|
||||
id = "coords_hud"
|
||||
version = "0.1.0"
|
||||
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
"#;
|
||||
|
||||
fn write_pack(dir: &Path, manifest: &str, files: &[(&str, &[u8])]) -> PathBuf {
|
||||
let path = dir.join("cozy.hpack");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(manifest.as_bytes()).unwrap();
|
||||
for (name, bytes) in files {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-hpack-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_pack_and_writes_its_overrides() {
|
||||
let dir = scratch("read");
|
||||
let storage = format!("overrides/horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt");
|
||||
let path = write_pack(
|
||||
&dir,
|
||||
PACK,
|
||||
&[
|
||||
("overrides/options.txt", b"renderDistance:8\n"),
|
||||
("overrides/resourcepacks/Cozy Look.zip", b"zip"),
|
||||
(&storage, b"top-left"),
|
||||
],
|
||||
);
|
||||
let pack = Modpack::read(&path).unwrap();
|
||||
assert_eq!(pack.manifest.mods[0].id, "coords_hud");
|
||||
assert!(pack.manifest.mods[0].on);
|
||||
assert_eq!(pack.overrides.len(), 3);
|
||||
let home = dir.join("home");
|
||||
Modpack::extract_overrides(&path, &home).unwrap();
|
||||
let options = std::fs::read_to_string(home.join("options.txt")).unwrap();
|
||||
assert_eq!(options, "renderDistance:8\n");
|
||||
let corner = home.join(format!("horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt"));
|
||||
assert!(corner.is_file());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_a_pack_may_not_hold() {
|
||||
let dir = scratch("refuse");
|
||||
for name in [
|
||||
"mods/evil.jar",
|
||||
"overrides/../../.bashrc",
|
||||
"overrides/horizon-mods/evil.hmod",
|
||||
"overrides/resourcepacks/../options.txt",
|
||||
"overrides/servers.dat",
|
||||
"overrides/horizon-mods/.horizon-mod-data/other_mod/x",
|
||||
"overrides/resourcepacks/.hidden.zip",
|
||||
] {
|
||||
let path = write_pack(&dir, PACK, &[(name, b"x")]);
|
||||
assert!(Modpack::read(&path).is_err(), "{name} was accepted");
|
||||
}
|
||||
let twice = format!("{PACK}{}", &PACK[PACK.find("[[mods]]").unwrap()..]);
|
||||
assert!(Manifest::parse(&twice).unwrap_err().contains("twice"));
|
||||
let newer = PACK.replace("format = 1", "format = 2");
|
||||
assert!(Manifest::parse(&newer).unwrap_err().contains("format 2"));
|
||||
let short = PACK.replace("0000000000000000", "00");
|
||||
assert!(Manifest::parse(&short).is_err());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,6 @@ const STORAGE_MAX_KEYS: usize = 64;
|
||||
/// a cheap-in-fuel loop replacing one key with large payloads needs its own
|
||||
/// cap, separate from the on-disk quota it would otherwise stay under.
|
||||
const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
|
||||
/// A mod's data, beside its `horizon-mods` folder (`find`'s `path.parent()`
|
||||
/// never has a manifest there, so `find` never mistakes it for a mod).
|
||||
const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
|
||||
/// What mods may read of the game, copied once per tick by the game.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -237,7 +234,7 @@ impl Mods {
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(STORAGE_DIR)
|
||||
.join(crate::manifest::STORAGE_DIR)
|
||||
.join(&m.id);
|
||||
let state = State {
|
||||
name: m.name.clone(),
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=
|
||||
trusted comment: coords_hud 0.1.0
|
||||
IESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==
|
||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-10-24T18:00:00Z",
|
||||
"projects": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"kind": "mod",
|
||||
"name": "Coordinates HUD",
|
||||
"summary": "Your coordinates, facing and the time of day in a corner of the screen.",
|
||||
"description": "`/where` puts them in chat, `/coords` hides or shows the panel.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"icon": "https://ludovic111.github.io/horizon-mods/icons/coords_hud.png",
|
||||
"key": "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "0.1",
|
||||
"side": "client",
|
||||
"capabilities": [
|
||||
"game-read",
|
||||
"hud",
|
||||
"chat"
|
||||
],
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "coords_hud-0.1.0.hmod",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/coords_hud-0.1.0.hmod",
|
||||
"size": 438,
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53",
|
||||
"signature": "untrusted comment: signature from minisign secret key\nRUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=\ntrusted comment: coords_hud 0.1.0\nIESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cozy",
|
||||
"kind": "modpack",
|
||||
"name": "Cozy",
|
||||
"summary": "Coordinates and a calm render distance.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon-mods",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"requires": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"version": "0.1.0",
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"
|
||||
}
|
||||
],
|
||||
"file": {
|
||||
"name": "cozy-1.0.0.hpack",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/cozy-1.0.0/cozy-1.0.0.hpack",
|
||||
"size": 471,
|
||||
"sha256": "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skyblock_start",
|
||||
"kind": "world",
|
||||
"name": "Skyblock start",
|
||||
"summary": "A kind this reader doesn't know yet: kept, not offered.",
|
||||
"license": "CC0-1.0",
|
||||
"source": "https://example.org/skyblock",
|
||||
"future_field": true,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "skyblock.hworld",
|
||||
"url": "https://example.org/skyblock.hworld",
|
||||
"size": 1,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"revoked": [
|
||||
{
|
||||
"id": "bad_mod",
|
||||
"sha256": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"reason": "Sent chat messages it didn't declare.",
|
||||
"date": "2026-10-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
untrusted comment: minisign public key 705764FC9BE1B6D5
|
||||
RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb
|
||||
@@ -814,7 +814,7 @@ pub fn carry(
|
||||
if kind == "structure" {
|
||||
let path = rel.trim_start_matches("worldgen/structure/");
|
||||
let structure = format!("{ns}:{}", path.trim_end_matches(".json"));
|
||||
changed |= crate::worldgen::disables(&structure, &mut v);
|
||||
changed |= crate::legacy_structure_mods::yung::disables(&structure, &mut v);
|
||||
changed |=
|
||||
crate::worldgen::add_piece_counts(&structure, &mut v, &piece_counts);
|
||||
}
|
||||
@@ -829,9 +829,9 @@ pub fn carry(
|
||||
// Additions to other sets and pools, as files the game merges.
|
||||
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
|
||||
let types = &mut report.worldgen_types;
|
||||
if let Some((kind, mut added)) =
|
||||
modifier.and_then(|v| crate::worldgen::modifier(&v, loaded, types))
|
||||
{
|
||||
if let Some((kind, mut added)) = modifier.and_then(|v| {
|
||||
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
|
||||
}) {
|
||||
crate::processors::carry(
|
||||
kind,
|
||||
&mut added,
|
||||
@@ -910,7 +910,7 @@ pub fn carry(
|
||||
}
|
||||
// Entries the mod adds to other structure sets in Java code, for the
|
||||
// structures it has.
|
||||
for (file, set, entries) in crate::worldgen::code_set_entries(id) {
|
||||
for (file, set, entries) in crate::legacy_structure_mods::ctov::code_set_entries(id) {
|
||||
let entries: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.filter(|(s, _)| {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Choice Theorem's Overhauled Village: the entries it adds to other
|
||||
//! structure sets in Java code, and its `modular_compat` processor.
|
||||
use crate::processors::{ProcessorData, referenced};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub(crate) fn code_set_entries(
|
||||
mod_id: &str,
|
||||
) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choice Theorem's Overhauled Village's processor that runs the list
|
||||
/// `reference` when mod `modid` is loaded (ModularCompatProcessor).
|
||||
const MODULAR_COMPAT: &str = "ctov:modular_compat";
|
||||
|
||||
/// Notes which mods its compat processors name are `loaded`.
|
||||
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
|
||||
fn walk(v: &Value, out: &mut BTreeSet<String>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) {
|
||||
if let Some(id) = o.get("modid").and_then(Value::as_str) {
|
||||
out.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
o.values().for_each(|v| walk(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| walk(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut named = BTreeSet::new();
|
||||
data.lists.values().for_each(|l| walk(l, &mut named));
|
||||
data.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect();
|
||||
}
|
||||
|
||||
/// A `ctov:modular_compat` processor in a list being carried, at `depth`
|
||||
/// (see [`super::lithostitched`]'s references): counted in `carried`, and
|
||||
/// the processors of the list it names when its mod is loaded, else none.
|
||||
/// `None` for any other processor, or past the depth limit.
|
||||
pub(super) fn modular_compat(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> Option<Vec<Value>> {
|
||||
if p["processor_type"] != MODULAR_COMPAT || depth >= 8 {
|
||||
return None;
|
||||
}
|
||||
*carried.entry(MODULAR_COMPAT.into()).or_default() += 1;
|
||||
let loaded = p["modid"]
|
||||
.as_str()
|
||||
.is_some_and(|m| data.compat_loaded.contains(m));
|
||||
Some(
|
||||
loaded
|
||||
.then(|| referenced(&p["reference"], data))
|
||||
.flatten()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::processors::carry;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modular_compat_runs_its_list_only_with_its_mod() {
|
||||
let mut data = ProcessorData::default();
|
||||
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
|
||||
data.lists
|
||||
.insert("v:house_ns".into(), json!({"processors": [swap.clone()]}));
|
||||
let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"});
|
||||
let gravity = json!({"processor_type": "minecraft:gravity"});
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}),
|
||||
);
|
||||
data.mark_loaded(&|m| m == "ns");
|
||||
assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()]));
|
||||
let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut v, &data, &mut carried));
|
||||
assert_eq!(v, json!({"processors": [swap, gravity]}));
|
||||
assert_eq!(carried["ctov:modular_compat"], 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
|
||||
//! processors.
|
||||
use super::ctov;
|
||||
use crate::processors::{ProcessorData, carry_all, processors, referenced};
|
||||
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
|
||||
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
|
||||
/// `start_checks`). `None` for any other file.
|
||||
pub(crate) fn upgrade(
|
||||
kind: &str,
|
||||
v: &mut Value,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) -> Option<bool> {
|
||||
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
|
||||
return None;
|
||||
}
|
||||
start_checks(v, "lowest", lost, false);
|
||||
if v.as_object_mut()
|
||||
.and_then(|o| o.remove(ENHANCED_TERRAIN))
|
||||
.is_some()
|
||||
{
|
||||
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
|
||||
}
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// A lithostitched worldgen modifier
|
||||
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
|
||||
/// sets or template pools, as a file the game merges into them (the key
|
||||
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
|
||||
/// modifier's conditions are checked against `loaded`, the mods ported
|
||||
/// along: a compat addition for a mod that is not would place that mod's
|
||||
/// blocks, which the game does not have. Other modifier types are left out.
|
||||
/// Both are counted in `report`.
|
||||
pub(crate) fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let t = v["type"].as_str().unwrap_or_default();
|
||||
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
|
||||
let (kind, targets, from, to) = match t {
|
||||
"lithostitched:add_structure_set_entries" => {
|
||||
("structure_set", "structure_sets", "entries", "structures")
|
||||
}
|
||||
"lithostitched:add_template_pool_elements" => {
|
||||
("template_pool", "template_pools", "elements", "elements")
|
||||
}
|
||||
_ => {
|
||||
left(t);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !conditions_hold(v, loaded) {
|
||||
left("conditions");
|
||||
return None;
|
||||
}
|
||||
let targets: Option<Vec<&str>> = match &v[targets] {
|
||||
Value::String(s) => Some(vec![s.as_str()]),
|
||||
Value::Array(a) => a.iter().map(Value::as_str).collect(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
|
||||
else {
|
||||
left("target");
|
||||
return None;
|
||||
};
|
||||
let entries = v[from].as_array().cloned().unwrap_or_default();
|
||||
for target in &targets {
|
||||
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
|
||||
}
|
||||
Some((
|
||||
kind,
|
||||
serde_json::json!({"horizon:add_to": targets, to: entries}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
|
||||
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
|
||||
/// type not known here does not hold.
|
||||
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
|
||||
list.as_array().is_some_and(|a| a.iter().all(test))
|
||||
};
|
||||
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| predicate(q, loaded);
|
||||
let list = &p["predicates"];
|
||||
match p["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("lithostitched:"))
|
||||
{
|
||||
Some("true") => true,
|
||||
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
|
||||
Some("not") => !predicate(&p["predicate"], loaded),
|
||||
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| neoforge(q, loaded);
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:not") => !neoforge(&c["value"], loaded),
|
||||
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| fabric(q, loaded);
|
||||
let mods = || {
|
||||
c["values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(Value::as_str)
|
||||
};
|
||||
match c["condition"].as_str() {
|
||||
Some("fabric:true") => true,
|
||||
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:not") => !fabric(&c["value"], loaded),
|
||||
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
|
||||
let loader = match (
|
||||
v.get("neoforge:conditions"),
|
||||
v.get("fabric:load_conditions"),
|
||||
) {
|
||||
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
|
||||
// One condition or a list of them.
|
||||
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
|
||||
(None, Some(c)) => fabric(c, loaded),
|
||||
(None, None) => true,
|
||||
};
|
||||
litho && loader
|
||||
}
|
||||
|
||||
/// The data form of one of Lithostitched's processors: the processors to put
|
||||
/// in its place, or `None` when it has none (or is not Lithostitched's).
|
||||
pub(crate) fn processor(
|
||||
kind: &str,
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Vec<Value>> {
|
||||
match kind {
|
||||
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:block_swap" => {
|
||||
// The same map; the game's swap keeps the properties both have.
|
||||
p["blocks"].as_object()?;
|
||||
Some(vec![
|
||||
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
|
||||
])
|
||||
}
|
||||
"lithostitched:set_block" => {
|
||||
// A simple or weighted state provider, the block entity left as
|
||||
// it is.
|
||||
let provider = p["state_provider"]["type"].as_str()?;
|
||||
let modifier = p["block_entity_modifier"]["type"].as_str();
|
||||
let plain =
|
||||
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
|
||||
if !plain
|
||||
|| !matches!(
|
||||
provider,
|
||||
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut out = p.clone();
|
||||
out["processor_type"] = "horizon:set_block".into();
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.remove("block_entity_modifier");
|
||||
}
|
||||
Some(vec![out])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The references (Lithostitched's, and CTOV's compat processors) in a
|
||||
/// processor list written in; whether any was.
|
||||
pub(crate) fn expand_references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> (Vec<Value>, bool) {
|
||||
references(list, data, carried, 0)
|
||||
}
|
||||
|
||||
/// A processor list inside a carried processor: an id stays (the game reads
|
||||
/// that file, carried on its own); an inline one is carried here.
|
||||
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
|
||||
let mut list = list.clone();
|
||||
if let Some(ps) = processors(&mut list) {
|
||||
*ps = carry_all(std::mem::take(ps), data, carried).0;
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
|
||||
/// lists, or its set of lists at weight 1. A tag is not carried.
|
||||
fn apply_random(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
let lists = &p["processor_lists"];
|
||||
let weighted = lists.as_array().is_some_and(|a| {
|
||||
a.iter()
|
||||
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
|
||||
});
|
||||
let entries: Vec<(Value, Value)> = if weighted {
|
||||
lists
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|e| (e["weight"].clone(), e["data"].clone()))
|
||||
.collect()
|
||||
} else {
|
||||
match lists {
|
||||
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
|
||||
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
|
||||
vec![(1.into(), lists.clone())]
|
||||
}
|
||||
Value::Array(a)
|
||||
if a.iter()
|
||||
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
|
||||
{
|
||||
a.iter().map(|x| (1.into(), x.clone())).collect()
|
||||
}
|
||||
Value::Object(_) => vec![(1.into(), lists.clone())],
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
let lists: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
|
||||
.collect();
|
||||
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
|
||||
if let Some(mode) = p.get("mode") {
|
||||
out["mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
|
||||
/// every condition in it (true, all_of, any_of, not, random_chance,
|
||||
/// matching_blocks with plain property values); else it stays Java.
|
||||
fn condition(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
fn runs(c: &Value) -> bool {
|
||||
if let Some(all) = c.as_array() {
|
||||
return all.iter().all(runs);
|
||||
}
|
||||
let kind = c["type"].as_str().unwrap_or_default();
|
||||
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
|
||||
"true" | "random_chance" => true,
|
||||
"all_of" | "any_of" => c["conditions"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().all(runs)),
|
||||
"not" => runs(&c["condition"]),
|
||||
"matching_blocks" => c.get("properties").is_none_or(|p| {
|
||||
p.as_object()
|
||||
.is_some_and(|o| o.values().all(Value::is_string))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if !runs(&p["if_true"]) {
|
||||
return None;
|
||||
}
|
||||
// One processor or a list of them.
|
||||
let mut branch = |key: &str| -> Value {
|
||||
let list = match p.get(key) {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
carry_all(list, data, carried).0.into()
|
||||
};
|
||||
let (then, otherwise) = (branch("then"), branch("else"));
|
||||
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
|
||||
"then": then, "else": otherwise});
|
||||
if let Some(mode) = p.get("random_mode") {
|
||||
out["random_mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's references in a list, each replaced by the processors of
|
||||
/// the lists it names, in order (references in them too, to a depth of 8).
|
||||
/// One naming a tag or a list the mod does not have stays, to be dropped.
|
||||
/// Whether any was replaced.
|
||||
fn references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> (Vec<Value>, bool) {
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
let mut changed = false;
|
||||
for p in list {
|
||||
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
|
||||
changed = true;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
continue;
|
||||
}
|
||||
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
|
||||
.then(|| referenced(&p["processor_lists"], data))
|
||||
.flatten();
|
||||
match named {
|
||||
Some(inner) => {
|
||||
*carried.entry("lithostitched:reference".into()).or_default() += 1;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
changed = true;
|
||||
}
|
||||
None => out.push(p),
|
||||
}
|
||||
}
|
||||
(out, changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::legacy_structure_mods::data;
|
||||
use crate::processors::carry;
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_sets_and_pools() {
|
||||
let mut report = Upgrade::default();
|
||||
let only_fd = |m: &str| m == "farmersdelight";
|
||||
let set = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "minecraft:villages",
|
||||
"entries": [{"structure": "x:village", "weight": 4}]});
|
||||
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "structure_set");
|
||||
assert_eq!(
|
||||
added,
|
||||
json!({"horizon:add_to": ["minecraft:villages"],
|
||||
"structures": [{"structure": "x:village", "weight": 4}]})
|
||||
);
|
||||
// A compat addition holds only when its mod is ported along.
|
||||
let pool = |modid: &str| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements",
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
|
||||
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
|
||||
"template_pools": ["x:houses", "y:houses"],
|
||||
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
|
||||
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
|
||||
};
|
||||
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
|
||||
assert_eq!(added["elements"][0]["weight"], 5);
|
||||
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
|
||||
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
|
||||
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"fabric:load_conditions": [{"condition": "fabric:not",
|
||||
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
|
||||
"predicate": {"type": "lithostitched:all_of", "predicates": [
|
||||
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
|
||||
{"type": "lithostitched:true"}]},
|
||||
"structure_sets": "minecraft:villages", "entries": []});
|
||||
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
|
||||
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "#x:sets", "entries": []});
|
||||
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
|
||||
let features = json!({"type": "lithostitched:add_features"});
|
||||
assert!(modifier(&features, &only_fd, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.added_to,
|
||||
BTreeMap::from([
|
||||
("minecraft:villages".into(), 1),
|
||||
("x:houses".into(), 1),
|
||||
("y:houses".into(), 1),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:add_features".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_block_swaps_become_the_games() {
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data(), &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
|
||||
);
|
||||
// A map that is not one stays Java (dropped later).
|
||||
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_references_are_written_in() {
|
||||
let mut data = data();
|
||||
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
|
||||
data.lists
|
||||
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
|
||||
);
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
// House (its own reference to road first, then its swap), then road;
|
||||
// the swap inside is carried too.
|
||||
assert_eq!(p[0], gravity);
|
||||
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
|
||||
assert_eq!(p[2], gravity);
|
||||
// A tag or a list the mod lacks stays, to be dropped.
|
||||
assert_eq!(p[3]["processor_lists"], "#v:roads");
|
||||
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
|
||||
assert_eq!(carried["lithostitched:reference"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_random_and_condition_become_the_games() {
|
||||
let mut data = data();
|
||||
let swap = json!({"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
data.lists
|
||||
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
|
||||
{"processor_type": "v:java"}]}}]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": ["v:green", "v:red"]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": "#v:colours"},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
|
||||
"then": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
let green = json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
// Inline lists are carried; Java left in them is dropped later.
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
|
||||
);
|
||||
assert_eq!(
|
||||
p[1]["processor_lists"],
|
||||
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
|
||||
);
|
||||
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
|
||||
assert_eq!(
|
||||
p[3],
|
||||
json!({"processor_type": "horizon:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
|
||||
);
|
||||
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
|
||||
assert_eq!(carried["lithostitched:apply_random"], 2);
|
||||
assert_eq!(carried["lithostitched:condition"], 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The conversion of three families of structure mods' data, kept apart from
|
||||
//! the general conversion code (`worldgen`, `processors`, `carry`):
|
||||
//!
|
||||
//! - [`yung`]: YUNG's API and the mods built on it (Better Dungeons, Desert
|
||||
//! Temples, Jungle Temples, Witch Huts, Ocean Monuments, Strongholds,
|
||||
//! Fortresses, Mineshafts), written under `horizon:yungsapi` and the other
|
||||
//! keys `horizon_world::worldgen::structure::yung` reads.
|
||||
//! - [`lithostitched`]: Lithostitched's jigsaw, worldgen modifiers and
|
||||
//! processors.
|
||||
//! - [`ctov`]: Choice Theorem's Overhauled Village's code-registered set
|
||||
//! entries and its modular compat processor.
|
||||
//!
|
||||
//! The general code reaches them only through the `pub(crate)` entry points
|
||||
//! of these three modules. Nothing in them is used by another mod's
|
||||
//! conversion.
|
||||
pub(crate) mod ctov;
|
||||
pub(crate) mod lithostitched;
|
||||
pub(crate) mod yung;
|
||||
|
||||
#[cfg(test)]
|
||||
fn data() -> crate::processors::ProcessorData {
|
||||
let mut d = crate::processors::ProcessorData::default();
|
||||
d.spawners.insert(
|
||||
"repurposed_structures:dungeons/badlands".into(),
|
||||
vec![
|
||||
("minecraft:husk".into(), 10.),
|
||||
("minecraft:cave_spider".into(), 3.),
|
||||
],
|
||||
);
|
||||
d.replace_vanilla.insert(("mvs".into(), "village".into()));
|
||||
d
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ pub mod fetch;
|
||||
pub mod import;
|
||||
pub mod inventory;
|
||||
pub mod jar;
|
||||
pub(crate) mod legacy_structure_mods;
|
||||
pub mod modrinth;
|
||||
pub mod oracle;
|
||||
pub mod pipeline;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,11 @@
|
||||
//! by name. Each rewrite is counted in the carry report, and every type left
|
||||
//! as it is is listed, so the coverage report can say what generates and how
|
||||
//! closely.
|
||||
//!
|
||||
//! YUNG's, Lithostitched's and CTOV's worldgen files are converted by
|
||||
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
|
||||
//! processors.
|
||||
use crate::legacy_structure_mods::{lithostitched, yung};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -21,8 +26,7 @@ use std::{
|
||||
};
|
||||
|
||||
/// Structure types that are vanilla's jigsaw with extra start rules.
|
||||
const JIGSAWS: [&str; 3] = [
|
||||
"lithostitched:jigsaw",
|
||||
const JIGSAWS: [&str; 2] = [
|
||||
"moogs_structures:moogs_structures_generic_jigsaw_structure",
|
||||
"repurposed_structures:generic_jigsaw_structure",
|
||||
];
|
||||
@@ -57,35 +61,6 @@ const SPREADS: [&str; 2] = [
|
||||
"moogs_structures:advanced_random_spread",
|
||||
"repurposed_structures:advanced_random_spread",
|
||||
];
|
||||
/// YUNG's placements: vanilla's random spread with an enhanced exclusion
|
||||
/// zone (several sets), plus, for the temples, no start near an ocean or a
|
||||
/// river, and for strongholds, starts only in odd rings around the origin.
|
||||
const YUNG_SPREADS: [&str; 4] = [
|
||||
"yungsapi:enhanced_random_spread",
|
||||
"betterdeserttemples:desert_temple",
|
||||
"betterjungletemples:jungle_temple",
|
||||
"betterstrongholds:stronghold",
|
||||
];
|
||||
/// YUNG's structures and the vanilla structure type each one's mod turns
|
||||
/// off (its DisableVanilla*Mixin, on by default in each mod's config).
|
||||
const YUNG_REPLACES: [(&str, &str); 7] = [
|
||||
(
|
||||
"betterdeserttemples:desert_temple",
|
||||
"minecraft:desert_pyramid",
|
||||
),
|
||||
(
|
||||
"betterjungletemples:jungle_temple",
|
||||
"minecraft:jungle_temple",
|
||||
),
|
||||
("betterwitchhuts:witch_hut", "minecraft:swamp_hut"),
|
||||
(
|
||||
"betteroceanmonuments:ocean_monument",
|
||||
"minecraft:ocean_monument",
|
||||
),
|
||||
("betterstrongholds:stronghold", "minecraft:stronghold"),
|
||||
("betterfortresses:fortress", "minecraft:fortress"),
|
||||
("bettermineshafts:mineshaft_", "minecraft:mineshaft"),
|
||||
];
|
||||
/// Mo' Structures' placement (`mostructures:type`) takes its spacing and
|
||||
/// separation from the mod's config, not its files: the defaults it writes
|
||||
/// (MoStructuresConfig), by config key, as (spacing, separation).
|
||||
@@ -130,8 +105,8 @@ const LAYOUT_RULES: [&str; 4] = [
|
||||
];
|
||||
/// The libraries' terrain kernel field, the game's
|
||||
/// `horizon:enhanced_terrain_adaptation` (docs/mods.md).
|
||||
const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
pub(crate) const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
pub(crate) const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
/// Fields of older versions the mods' codecs no longer read: their own
|
||||
/// structures ignore them, and so does carry. Repurposed's jungle fortress
|
||||
/// and End stronghold still write `vertical_distance_from_start_piece`.
|
||||
@@ -181,195 +156,6 @@ pub struct Upgrade {
|
||||
/// `data/<id>/worldgen/<kind>/horizon_added/<name>.json`.
|
||||
pub const ADDED: &str = "horizon_added";
|
||||
|
||||
/// A lithostitched worldgen modifier
|
||||
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
|
||||
/// sets or template pools, as a file the game merges into them (the key
|
||||
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
|
||||
/// modifier's conditions are checked against `loaded`, the mods ported
|
||||
/// along: a compat addition for a mod that is not would place that mod's
|
||||
/// blocks, which the game does not have. Other modifier types are left out.
|
||||
/// Both are counted in `report`.
|
||||
pub fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let t = v["type"].as_str().unwrap_or_default();
|
||||
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
|
||||
let (kind, targets, from, to) = match t {
|
||||
"lithostitched:add_structure_set_entries" => {
|
||||
("structure_set", "structure_sets", "entries", "structures")
|
||||
}
|
||||
"lithostitched:add_template_pool_elements" => {
|
||||
("template_pool", "template_pools", "elements", "elements")
|
||||
}
|
||||
_ => {
|
||||
left(t);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !conditions_hold(v, loaded) {
|
||||
left("conditions");
|
||||
return None;
|
||||
}
|
||||
let targets: Option<Vec<&str>> = match &v[targets] {
|
||||
Value::String(s) => Some(vec![s.as_str()]),
|
||||
Value::Array(a) => a.iter().map(Value::as_str).collect(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
|
||||
else {
|
||||
left("target");
|
||||
return None;
|
||||
};
|
||||
let entries = v[from].as_array().cloned().unwrap_or_default();
|
||||
for target in &targets {
|
||||
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
|
||||
}
|
||||
Some((
|
||||
kind,
|
||||
serde_json::json!({"horizon:add_to": targets, to: entries}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
|
||||
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
|
||||
/// type not known here does not hold.
|
||||
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
|
||||
list.as_array().is_some_and(|a| a.iter().all(test))
|
||||
};
|
||||
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| predicate(q, loaded);
|
||||
let list = &p["predicates"];
|
||||
match p["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("lithostitched:"))
|
||||
{
|
||||
Some("true") => true,
|
||||
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
|
||||
Some("not") => !predicate(&p["predicate"], loaded),
|
||||
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| neoforge(q, loaded);
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:not") => !neoforge(&c["value"], loaded),
|
||||
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| fabric(q, loaded);
|
||||
let mods = || {
|
||||
c["values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(Value::as_str)
|
||||
};
|
||||
match c["condition"].as_str() {
|
||||
Some("fabric:true") => true,
|
||||
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:not") => !fabric(&c["value"], loaded),
|
||||
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
|
||||
let loader = match (
|
||||
v.get("neoforge:conditions"),
|
||||
v.get("fabric:load_conditions"),
|
||||
) {
|
||||
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
|
||||
// One condition or a list of them.
|
||||
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
|
||||
(None, Some(c)) => fabric(c, loaded),
|
||||
(None, None) => true,
|
||||
};
|
||||
litho && loader
|
||||
}
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub fn code_set_entries(mod_id: &str) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites one `data/<ns>/worldgen/<kind>/...` file in place; whether it
|
||||
/// changed.
|
||||
/// `set_tags` are the mod's structure set tags, expanded
|
||||
@@ -474,15 +260,16 @@ fn upgrade_types(
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
) -> bool {
|
||||
let lost = &mut report.rules_lost;
|
||||
// YUNG's and Lithostitched's types are converted apart from this code.
|
||||
if let Some(changed) =
|
||||
yung::upgrade(kind, v, set_tags, lost).or_else(|| lithostitched::upgrade(kind, v, lost))
|
||||
{
|
||||
report.mapped += usize::from(changed);
|
||||
return changed;
|
||||
}
|
||||
let mut unsupported = |t: &str| *report.unsupported.entry(t.to_owned()).or_default() += 1;
|
||||
let changed = match kind {
|
||||
"structure" => match v["type"].as_str() {
|
||||
// Better Dungeons' small Nether dungeon is YUNG's jigsaw behind a
|
||||
// config switch that ships on.
|
||||
Some("yungsapi:yung_jigsaw" | "betterdungeons:small_nether_dungeon") => {
|
||||
yung_structure(v, lost);
|
||||
true
|
||||
}
|
||||
Some(t)
|
||||
if JIGSAWS.contains(&t)
|
||||
|| NETHER_JIGSAWS.contains(&t)
|
||||
@@ -591,11 +378,6 @@ fn upgrade_types(
|
||||
true
|
||||
}
|
||||
Some("mostructures:type") => mo_placement(&mut v["placement"], lost),
|
||||
Some(t) if YUNG_SPREADS.contains(&t) => {
|
||||
let t = t.to_owned();
|
||||
yung_placement(&t, &mut v["placement"], set_tags, lost);
|
||||
true
|
||||
}
|
||||
Some(t) if !t.starts_with("minecraft:") => {
|
||||
unsupported(t);
|
||||
false
|
||||
@@ -615,6 +397,10 @@ fn upgrade_types(
|
||||
}
|
||||
}
|
||||
for element in elements {
|
||||
if yung::upgrade_pool_element(element, lost) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
let Some(t) = element["element_type"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
@@ -629,9 +415,6 @@ fn upgrade_types(
|
||||
}
|
||||
element["element_type"] = (*to).into();
|
||||
changed = true;
|
||||
} else if t == "yungsapi:yung_single_element" {
|
||||
yung_element(element, lost);
|
||||
changed = true;
|
||||
} else if t == "formations:single_pool_element" {
|
||||
// Vanilla's element with its own ground level (1 when
|
||||
// missing, as its codec reads it).
|
||||
@@ -653,167 +436,10 @@ fn upgrade_types(
|
||||
changed
|
||||
}
|
||||
|
||||
/// YUNG's API's jigsaw as vanilla's, laid out by its own assembler in the
|
||||
/// game (`horizon:yungsapi`, `horizon_world::worldgen::structure::yung`):
|
||||
/// the heights pieces stay within move under the key, its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`. A start offset in
|
||||
/// the chunk is lost.
|
||||
fn yung_structure(v: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert("type".into(), "minecraft:jigsaw".into());
|
||||
let mut rules = serde_json::Map::new();
|
||||
for key in ["min_y", "max_y"] {
|
||||
if let Some(y) = o.remove(key) {
|
||||
rules.insert(key.into(), y);
|
||||
}
|
||||
}
|
||||
// YungJigsawStructure's adaptation (NONE by default): every piece's
|
||||
// junctions, and the start's box grown by its radius.
|
||||
let own = o.remove(ENHANCED_TERRAIN);
|
||||
let mut kernel = yung_terrain(own.as_ref().unwrap_or(&Value::Null), lost);
|
||||
kernel["rigid_junctions"] = true.into();
|
||||
kernel["inflate"] = true.into();
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), kernel);
|
||||
for key in ["x_offset_in_chunk", "z_offset_in_chunk"] {
|
||||
if o.remove(key).is_some_and(|n| n != 0) {
|
||||
*lost.entry(key.into()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// YUNG's single pool element as vanilla's, its rules under
|
||||
/// `horizon:yungsapi`: name and `max_count`, depth range, priority,
|
||||
/// `ignore_bounds`, its condition, dead-end pool and modifiers; its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`.
|
||||
fn yung_element(element: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = element.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert(
|
||||
"element_type".into(),
|
||||
"minecraft:single_pool_element".into(),
|
||||
);
|
||||
let mut rules = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("name", "name"),
|
||||
("max_count", "max_count"),
|
||||
("min_required_depth", "min_depth"),
|
||||
("max_possible_depth", "max_depth"),
|
||||
("is_priority", "priority"),
|
||||
("ignore_bounds", "ignore_bounds"),
|
||||
] {
|
||||
if let Some(x) = o.remove(from) {
|
||||
rules.insert(to.into(), x);
|
||||
}
|
||||
}
|
||||
if let Some(c) = o.remove("condition") {
|
||||
rules.insert("condition".into(), yung_condition(&c, lost));
|
||||
}
|
||||
// A piece's own adaptation, NONE included, replaces its structure's.
|
||||
if let Some(a) = o.remove(ENHANCED_TERRAIN) {
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), yung_terrain(&a, lost));
|
||||
}
|
||||
if let Some(pool) = o.remove("deadend_pool").filter(Value::is_string) {
|
||||
rules.insert("deadend_pool".into(), pool);
|
||||
}
|
||||
let modifiers: Vec<Value> = o
|
||||
.remove("modifiers")
|
||||
.and_then(|m| m.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|m| yung_modifier(m, lost))
|
||||
.collect();
|
||||
if !modifiers.is_empty() {
|
||||
rules.insert("modifiers".into(), modifiers.into());
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// One of YUNG's structure modifiers in the game's terms: its condition and
|
||||
/// its transform and delay_generation actions, on the piece itself (`self`,
|
||||
/// the API's only target). Another target loses the modifier, another action
|
||||
/// just that action.
|
||||
fn yung_modifier(m: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let target = m["target_selector"]["type"].as_str().unwrap_or_default();
|
||||
if target != "yungsapi:self" {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier target {target}"))
|
||||
.or_default() += 1;
|
||||
return None;
|
||||
}
|
||||
let mut actions = Vec::new();
|
||||
for a in m["actions"].as_array().into_iter().flatten() {
|
||||
let int = |k: &str| a[k].as_i64().unwrap_or(0);
|
||||
match a["type"].as_str().unwrap_or_default() {
|
||||
"yungsapi:transform" => actions.push(serde_json::json!({"type": "transform",
|
||||
"output": a["output"].clone(),
|
||||
"offset": [int("x_offset"), int("y_offset"), int("z_offset")]})),
|
||||
"yungsapi:delay_generation" => {
|
||||
actions.push(serde_json::json!({"type": "delay_generation"}))
|
||||
}
|
||||
other => {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier action {other}"))
|
||||
.or_default() += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
let condition = match m.get("condition") {
|
||||
Some(c) => yung_condition(c, lost),
|
||||
None => serde_json::json!({"type": "true"}),
|
||||
};
|
||||
Some(serde_json::json!({"condition": condition, "actions": actions}))
|
||||
}
|
||||
|
||||
/// YUNG's API's terrain adaptation as the game's kernel: its two presets
|
||||
/// spelled out, NONE (also the default) as size 0. An aquifer override
|
||||
/// (none of ATM10's) is lost.
|
||||
fn yung_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let kernel = |size: i32, distance: i32, top: &str, bottom: &str| {
|
||||
serde_json::json!({"kernel_size": size, "kernel_distance": distance,
|
||||
"top": top, "bottom": bottom})
|
||||
};
|
||||
match a["type"].as_str() {
|
||||
Some("yungsapi:carved_top_no_beard_large") => kernel(24, 16, "carve", "none"),
|
||||
Some("yungsapi:carved_top_no_beard_small") => kernel(12, 6, "carve", "none"),
|
||||
Some("yungsapi:custom") => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for key in [
|
||||
"kernel_size",
|
||||
"kernel_distance",
|
||||
"top",
|
||||
"bottom",
|
||||
"bottom_offset",
|
||||
"padding",
|
||||
] {
|
||||
if let Some(x) = a.get(key) {
|
||||
out.insert(key.into(), x.clone());
|
||||
}
|
||||
}
|
||||
if a.get("aquifer_override")
|
||||
.is_some_and(|x| x["type"] != "yungsapi:none")
|
||||
{
|
||||
*lost.entry("yungsapi aquifer_override".into()).or_default() += 1;
|
||||
}
|
||||
moogs_terrain(&out.into(), lost).unwrap_or_else(|| kernel(0, 0, "none", "none"))
|
||||
}
|
||||
None | Some("yungsapi:none") => kernel(0, 0, "none", "none"),
|
||||
Some(other) => {
|
||||
*lost
|
||||
.entry(format!("yungsapi terrain adaptation {other}"))
|
||||
.or_default() += 1;
|
||||
kernel(0, 0, "none", "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MoogsStructureLib's terrain adaptation: already the game's shape (with
|
||||
/// its band), when it has the fields the game needs (else it is lost, as
|
||||
/// the library's codec would refuse it).
|
||||
fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
pub(crate) fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let ok = ["kernel_size", "kernel_distance"]
|
||||
.iter()
|
||||
.all(|k| a[k].is_u64())
|
||||
@@ -824,58 +450,15 @@ fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value>
|
||||
ok.then(|| a.clone())
|
||||
}
|
||||
|
||||
/// One of YUNG's structure conditions in the game's terms. `mod_loaded`
|
||||
/// holds for Minecraft alone: a mod that needs code is not ported, and the
|
||||
/// pieces it guards would place its blocks. A condition the game does not
|
||||
/// have does not hold, and is counted.
|
||||
fn yung_condition(c: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let t = c["type"].as_str().unwrap_or_default();
|
||||
let list = |lost: &mut BTreeMap<String, usize>| -> Value {
|
||||
let all = c["conditions"].as_array().into_iter().flatten();
|
||||
all.map(|x| yung_condition(x, lost))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
};
|
||||
let opt = |k: &str| c.get(k).cloned().unwrap_or(Value::Null);
|
||||
match t.trim_start_matches("yungsapi:") {
|
||||
"always_true" => serde_json::json!({"type": "true"}),
|
||||
"not" => {
|
||||
serde_json::json!({"type": "not", "condition": yung_condition(&c["condition"], lost)})
|
||||
}
|
||||
"all_of" => serde_json::json!({"type": "all_of", "conditions": list(lost)}),
|
||||
"any_of" => serde_json::json!({"type": "any_of", "conditions": list(lost)}),
|
||||
"depth" => serde_json::json!({"type": "depth",
|
||||
"min_depth": opt("min_required_depth"), "max_depth": opt("max_possible_depth")}),
|
||||
"altitude" => serde_json::json!({"type": "altitude",
|
||||
"bottom": opt("bottom_cutoff_y"), "top": opt("top_cutoff_y")}),
|
||||
"random_chance" => serde_json::json!({"type": "random_chance", "chance": opt("chance")}),
|
||||
"rotation" => serde_json::json!({"type": "rotation", "rotations": opt("rotations")}),
|
||||
"piece_in_horizontal_direction" => {
|
||||
// "yungsapi:all" (or no list) matches any piece.
|
||||
let pieces: Vec<Value> = c["pieces"].as_array().cloned().unwrap_or_default();
|
||||
let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all");
|
||||
serde_json::json!({"type": "piece_in_horizontal_direction",
|
||||
"pieces": if any { Vec::new() } else { pieces },
|
||||
"range": opt("range"), "rotation": opt("rotation")})
|
||||
}
|
||||
// The game reads "yungsapi:all" (or no list) as any piece.
|
||||
"piece_in_range" => serde_json::json!({"type": "piece_in_range",
|
||||
"pieces": c.get("pieces").cloned().unwrap_or_else(|| Vec::<Value>::new().into()),
|
||||
"horizontal_range": opt("horizontal_range"), "above_range": opt("above_range"),
|
||||
"below_range": opt("below_range")}),
|
||||
"mod_loaded" if c["modid"] == "minecraft" => serde_json::json!({"type": "true"}),
|
||||
"mod_loaded" => serde_json::json!({"type": "false"}),
|
||||
_ => {
|
||||
*lost.entry(format!("yungsapi condition {t}")).or_default() += 1;
|
||||
serde_json::json!({"type": "false"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a Repurposed Structures or Moog's jigsaw's start rules under
|
||||
/// `horizon:start_checks`, and counts the layout rules it loses. `liquid` is
|
||||
/// the block its no-start-in-liquid rule reads.
|
||||
fn start_checks(v: &mut Value, liquid: &str, lost: &mut BTreeMap<String, usize>, limited: bool) {
|
||||
pub(crate) fn start_checks(
|
||||
v: &mut Value,
|
||||
liquid: &str,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
limited: bool,
|
||||
) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -1043,7 +626,7 @@ fn moogs_spacing(p: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
|
||||
/// An advanced random spread's minimum distance from the world origin and
|
||||
/// its super exclusion zone (sets named by a tag) under `horizon:` keys.
|
||||
fn placement_rules(
|
||||
pub(crate) fn placement_rules(
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
@@ -1115,65 +698,6 @@ fn mo_placement(p: &mut Value, lost: &mut BTreeMap<String, usize>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One of YUNG's placements as vanilla's random spread with `horizon:` keys:
|
||||
/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples'
|
||||
/// ocean and river search (findBiomeHorizontal around the chunk's middle
|
||||
/// block at y 120, 48 blocks, every 2 quarts) as `horizon:avoid_biomes`; the
|
||||
/// strongholds' rings as `horizon:ring_sections`.
|
||||
fn yung_placement(
|
||||
t: &str,
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) {
|
||||
placement_rules(p, set_tags, lost);
|
||||
p["type"] = "minecraft:random_spread".into();
|
||||
let Some(o) = p.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
match t {
|
||||
"betterdeserttemples:desert_temple" | "betterjungletemples:jungle_temple" => {
|
||||
o.insert(
|
||||
"horizon:avoid_biomes".into(),
|
||||
serde_json::json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120}),
|
||||
);
|
||||
}
|
||||
"betterstrongholds:stronghold" => {
|
||||
let mut rings = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("chunk_distance_to_first_ring", "distance_to_first_ring"),
|
||||
("ring_chunk_thickness", "thickness"),
|
||||
("max_ring_section", "max_section"),
|
||||
] {
|
||||
if let Some(n) = o.remove(from) {
|
||||
rings.insert(to.into(), n);
|
||||
}
|
||||
}
|
||||
o.insert("horizon:ring_sections".into(), rings.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a YUNG's structure that the game can start (its type is vanilla's
|
||||
/// after the upgrade) with the vanilla type its mod turns off, under
|
||||
/// `horizon:disables`. One the game cannot start leaves the vanilla
|
||||
/// structure in place, so the world keeps one of the two.
|
||||
pub fn disables(id: &str, v: &mut Value) -> bool {
|
||||
let Some((_, vanilla)) = YUNG_REPLACES.iter().find(|(s, _)| id.starts_with(s)) else {
|
||||
return false;
|
||||
};
|
||||
if !v["type"]
|
||||
.as_str()
|
||||
.is_some_and(|t| t.starts_with("minecraft:"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
v["horizon:disables"] = serde_json::json!([vanilla]);
|
||||
true
|
||||
}
|
||||
|
||||
/// The mod's structure set tags (`data/<ns>/tags/worldgen/structure_set/`),
|
||||
/// by tag id, with the tags they include expanded. Optional entries
|
||||
/// (`{"id": ..., "required": false}`) count as their id.
|
||||
@@ -1590,94 +1114,6 @@ mod tests {
|
||||
assert_eq!(report.rules_lost["super_exclusion_zone"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_jigsaws_keep_their_rules() {
|
||||
let mut report = Upgrade::default();
|
||||
let mut s = json!({"type": "yungsapi:yung_jigsaw", "start_pool": "y:start", "size": 20,
|
||||
"max_y": 60, "x_offset_in_chunk": 0, "max_distance_from_center": 80,
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:custom", "kernel_size": 24,
|
||||
"kernel_distance": 24, "top": "carve", "bottom": "none"}});
|
||||
assert!(upgrade("structure", &mut s, &mut report, &NO_TAGS));
|
||||
assert_eq!(s["type"], "minecraft:jigsaw");
|
||||
assert_eq!(s["horizon:yungsapi"], json!({"max_y": 60}));
|
||||
assert_eq!(
|
||||
s["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 24, "kernel_distance": 24, "top": "carve", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
for gone in ["max_y", "x_offset_in_chunk", "enhanced_terrain_adaptation"] {
|
||||
assert!(s.get(gone).is_none(), "{gone}");
|
||||
}
|
||||
// Better Dungeons' small Nether dungeon is the same jigsaw.
|
||||
let mut nether = json!({"type": "betterdungeons:small_nether_dungeon",
|
||||
"start_pool": "betterdungeons:small_nether_dungeon", "size": 10,
|
||||
"start_jigsaw_name": "betterdungeons:small_nether_dungeon_anchor",
|
||||
"max_distance_from_center": 80, "liquid_settings": "ignore_waterlogging"});
|
||||
assert!(upgrade("structure", &mut nether, &mut report, &NO_TAGS));
|
||||
assert_eq!(nether["type"], "minecraft:jigsaw");
|
||||
assert_eq!(nether["horizon:yungsapi"], json!({}));
|
||||
assert_eq!(
|
||||
nether["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 0, "kernel_distance": 0, "top": "none", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
assert_eq!(
|
||||
nether["start_jigsaw_name"],
|
||||
"betterdungeons:small_nether_dungeon_anchor"
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
let mut pool = json!({"elements": [{"weight": 3, "element": {
|
||||
"element_type": "yungsapi:yung_single_element", "location": "y:room",
|
||||
"processors": "minecraft:empty", "projection": "rigid",
|
||||
"name": "room", "max_count": 2, "min_required_depth": 1, "is_priority": true,
|
||||
"ignore_bounds": false, "deadend_pool": "y:end",
|
||||
"modifiers": [
|
||||
{"condition": {"type": "yungsapi:piece_in_range", "pieces": ["y:pillar"],
|
||||
"above_range": 40},
|
||||
"target_selector": {"type": "yungsapi:self"},
|
||||
"actions": [{"type": "yungsapi:transform", "output": ["y:covered"], "x_offset": -1},
|
||||
{"type": "yungsapi:delay_generation"}, {"type": "yungsapi:spin"}]},
|
||||
{"condition": {"type": "yungsapi:always_true"},
|
||||
"target_selector": {"type": "yungsapi:all"}, "actions": []}],
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:carved_top_no_beard_small"},
|
||||
"condition": {"type": "yungsapi:all_of", "conditions": [
|
||||
{"type": "yungsapi:piece_in_horizontal_direction", "pieces": ["yungsapi:all"],
|
||||
"range": 5, "rotation": "none"},
|
||||
{"type": "yungsapi:not", "condition": {"type": "yungsapi:mod_loaded", "modid": "create"}},
|
||||
{"type": "yungsapi:biome", "biomes": []}]}}}]});
|
||||
assert!(upgrade("template_pool", &mut pool, &mut report, &NO_TAGS));
|
||||
let e = &pool["elements"][0]["element"];
|
||||
assert_eq!(e["element_type"], "minecraft:single_pool_element");
|
||||
assert_eq!(e["location"], "y:room");
|
||||
assert_eq!(
|
||||
e["horizon:yungsapi"],
|
||||
json!({"name": "room", "max_count": 2, "min_depth": 1, "priority": true,
|
||||
"ignore_bounds": false, "condition": {"type": "all_of", "conditions": [
|
||||
{"type": "piece_in_horizontal_direction", "pieces": [], "range": 5, "rotation": "none"},
|
||||
{"type": "not", "condition": {"type": "false"}},
|
||||
{"type": "false"}]},
|
||||
"deadend_pool": "y:end",
|
||||
"modifiers": [{"condition": {"type": "piece_in_range", "pieces": ["y:pillar"],
|
||||
"horizontal_range": null, "above_range": 40, "below_range": null},
|
||||
"actions": [{"type": "transform", "output": ["y:covered"], "offset": [-1, 0, 0]},
|
||||
{"type": "delay_generation"}]}]})
|
||||
);
|
||||
assert!(e.get("deadend_pool").is_none() && e.get("modifiers").is_none());
|
||||
assert_eq!(
|
||||
e["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 12, "kernel_distance": 6, "top": "carve", "bottom": "none"})
|
||||
);
|
||||
assert_eq!(
|
||||
report.rules_lost,
|
||||
BTreeMap::from([
|
||||
("yungsapi condition yungsapi:biome".into(), 1),
|
||||
("yungsapi modifier action yungsapi:spin".into(), 1),
|
||||
("yungsapi modifier target yungsapi:all".into(), 1),
|
||||
])
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_processors_written_in_java() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1722,59 +1158,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_placements_keep_their_rules() {
|
||||
let tags = BTreeMap::from([(
|
||||
"bf:avoid".to_owned(),
|
||||
vec!["minecraft:nether_complexes".to_owned()],
|
||||
)]);
|
||||
let mut report = Upgrade::default();
|
||||
let mut fortress = json!({"placement": {"type": "yungsapi:enhanced_random_spread",
|
||||
"salt": 1, "spacing": 30, "separation": 20,
|
||||
"enhanced_exclusion_zone": {"other_set": "#bf:avoid", "chunk_count": 10}}});
|
||||
assert!(upgrade("structure_set", &mut fortress, &mut report, &tags));
|
||||
assert_eq!(
|
||||
fortress["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 1, "spacing": 30, "separation": 20,
|
||||
"horizon:exclusion_zone": {"other_sets": ["minecraft:nether_complexes"], "chunk_count": 10}})
|
||||
);
|
||||
let mut temple = json!({"placement": {"type": "betterdeserttemples:desert_temple",
|
||||
"salt": 2, "spacing": 30, "separation": 20,
|
||||
"exclusion_zone": {"other_set": "minecraft:villages", "chunk_count": 6}}});
|
||||
assert!(upgrade("structure_set", &mut temple, &mut report, &tags));
|
||||
assert_eq!(temple["placement"]["type"], "minecraft:random_spread");
|
||||
assert_eq!(temple["placement"]["exclusion_zone"]["chunk_count"], 6);
|
||||
assert_eq!(
|
||||
temple["placement"]["horizon:avoid_biomes"],
|
||||
json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120})
|
||||
);
|
||||
let mut stronghold = json!({"placement": {"type": "betterstrongholds:stronghold",
|
||||
"salt": 3, "spacing": 85, "separation": 50,
|
||||
"chunk_distance_to_first_ring": 80, "ring_chunk_thickness": 96}});
|
||||
assert!(upgrade(
|
||||
"structure_set",
|
||||
&mut stronghold,
|
||||
&mut report,
|
||||
&tags
|
||||
));
|
||||
assert_eq!(
|
||||
stronghold["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 3, "spacing": 85, "separation": 50,
|
||||
"horizon:ring_sections": {"distance_to_first_ring": 80, "thickness": 96}})
|
||||
);
|
||||
assert_eq!(report.mapped, 3);
|
||||
assert!(report.unsupported.is_empty());
|
||||
// The vanilla counterpart is turned off only for a structure the game starts.
|
||||
let mut ported = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(disables("betterstrongholds:stronghold", &mut ported));
|
||||
assert_eq!(ported["horizon:disables"], json!(["minecraft:stronghold"]));
|
||||
let mut java = json!({"type": "bettermineshafts:mineshaft"});
|
||||
assert!(!disables("bettermineshafts:mineshaft_oak", &mut java));
|
||||
let mut circle = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(!disables("betterwitchhuts:witch_circle", &mut circle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_limited_jigsaws_keep_their_layout() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1886,88 +1269,6 @@ mod tests {
|
||||
assert_eq!(report.mapped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_sets_and_pools() {
|
||||
let mut report = Upgrade::default();
|
||||
let only_fd = |m: &str| m == "farmersdelight";
|
||||
let set = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "minecraft:villages",
|
||||
"entries": [{"structure": "x:village", "weight": 4}]});
|
||||
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "structure_set");
|
||||
assert_eq!(
|
||||
added,
|
||||
json!({"horizon:add_to": ["minecraft:villages"],
|
||||
"structures": [{"structure": "x:village", "weight": 4}]})
|
||||
);
|
||||
// A compat addition holds only when its mod is ported along.
|
||||
let pool = |modid: &str| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements",
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
|
||||
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
|
||||
"template_pools": ["x:houses", "y:houses"],
|
||||
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
|
||||
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
|
||||
};
|
||||
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
|
||||
assert_eq!(added["elements"][0]["weight"], 5);
|
||||
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
|
||||
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
|
||||
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"fabric:load_conditions": [{"condition": "fabric:not",
|
||||
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
|
||||
"predicate": {"type": "lithostitched:all_of", "predicates": [
|
||||
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
|
||||
{"type": "lithostitched:true"}]},
|
||||
"structure_sets": "minecraft:villages", "entries": []});
|
||||
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
|
||||
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "#x:sets", "entries": []});
|
||||
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
|
||||
let features = json!({"type": "lithostitched:add_features"});
|
||||
assert!(modifier(&features, &only_fd, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.added_to,
|
||||
BTreeMap::from([
|
||||
("minecraft:villages".into(), 1),
|
||||
("x:houses".into(), 1),
|
||||
("y:houses".into(), 1),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:add_features".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_tags_include_theirs() {
|
||||
let conventions = BTreeMap::from([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The JDK that compiles and runs the vanilla exporters. They call Mojang's own code, so they
|
||||
//! stay Java; everything around them is here.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -13,6 +14,20 @@ pub struct Jdk {
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal.
|
||||
fn command(program: impl AsRef<OsStr>) -> Command {
|
||||
let command = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
let command = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut command = command;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
command
|
||||
};
|
||||
command
|
||||
}
|
||||
|
||||
fn executable(dir: &Path, name: &str) -> PathBuf {
|
||||
dir.join(if cfg!(windows) {
|
||||
format!("{name}.exe")
|
||||
@@ -29,7 +44,7 @@ fn on_path(name: &str) -> Option<PathBuf> {
|
||||
|
||||
/// The major version `javac -version` reports ("javac 25.0.1" → 25; "javac 1.8.0" → 8).
|
||||
fn javac_major(javac: &Path) -> Result<u32, String> {
|
||||
let output = Command::new(javac)
|
||||
let output = command(javac)
|
||||
.arg("-version")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
@@ -183,7 +198,7 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
|
||||
candidates.push((javac, java, "PATH"));
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Ok(out) = Command::new("/usr/libexec/java_home")
|
||||
if let Ok(out) = command("/usr/libexec/java_home")
|
||||
.args(["-v", &major.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
@@ -260,7 +275,7 @@ impl Jdk {
|
||||
/// `javac --release <major> -proc:none` with Python's `subprocess.run(..., check=True,
|
||||
/// stdout=log, stderr=STDOUT, timeout=3600)`.
|
||||
pub fn javac(&self, major: i64, args: &[String], log: &fs::File) -> Result<(), String> {
|
||||
let mut command = Command::new(&self.javac);
|
||||
let mut command = command(&self.javac);
|
||||
command
|
||||
.args(["--release", &major.to_string(), "-proc:none"])
|
||||
.args(args);
|
||||
@@ -268,7 +283,7 @@ impl Jdk {
|
||||
}
|
||||
|
||||
pub fn java(&self, args: &[String], cwd: Option<&Path>, log: &fs::File) -> Result<(), String> {
|
||||
let mut command = Command::new(&self.java);
|
||||
let mut command = command(&self.java);
|
||||
command.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
command.current_dir(dir);
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod pyjson;
|
||||
mod sounds;
|
||||
mod survival;
|
||||
|
||||
pub use sounds::{fonts, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
pub use sounds::{fonts, languages, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
|
||||
use pyjson::Value;
|
||||
use sha2::Digest;
|
||||
@@ -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")?;
|
||||
|
||||
@@ -20,6 +20,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
|
||||
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare languages [--minecraft-dir PATH] [--root PATH]
|
||||
|
||||
--prepare Import the pinned game files from your own copy first
|
||||
--server-jar PATH The pinned server jar you downloaded yourself
|
||||
@@ -36,7 +37,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
|
||||
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
|
||||
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
|
||||
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
|
||||
Full preparation also imports all three after extracting the client JAR's placeholders.
|
||||
horizon-prepare languages adds the bundled languages and the language list the JAR lacks the same way.
|
||||
Full preparation also imports all four after extracting the client JAR's placeholders.
|
||||
|
||||
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
|
||||
launcher's asset store into the same folder, checking every file. It is optional: without it the
|
||||
@@ -68,8 +70,12 @@ fn main() -> ExitCode {
|
||||
};
|
||||
let mut root = None;
|
||||
let mut args = std::env::args_os().skip(1).peekable();
|
||||
let assets =
|
||||
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
|
||||
let assets = args.next_if(|a| {
|
||||
matches!(
|
||||
a.to_str(),
|
||||
Some("sounds" | "panorama" | "fonts" | "packs" | "languages")
|
||||
)
|
||||
});
|
||||
while let Some(arg) = args.next() {
|
||||
let text = arg.to_string_lossy().into_owned();
|
||||
let (flag, inline) = match text.split_once('=') {
|
||||
@@ -138,6 +144,9 @@ fn main() -> ExitCode {
|
||||
Some("packs") => {
|
||||
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
Some("languages") => {
|
||||
horizon_prepare::languages(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
_ => horizon_prepare::prepare(&options, &log),
|
||||
};
|
||||
match result {
|
||||
|
||||
@@ -112,6 +112,20 @@ pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf,
|
||||
copy_assets(root, minecraft, log, is_font, "Font", &UNIFONT)
|
||||
}
|
||||
|
||||
/// Every bundled language and the language list, both of which only the asset index holds
|
||||
/// (the client JAR carries `en_us` alone): `pack.mcmeta`'s `language` section is what the
|
||||
/// language screen offers, `minecraft/lang/<code>.json` the strings each one draws (#1272).
|
||||
/// Also usable on already-prepared resources without Java.
|
||||
pub fn languages(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
|
||||
copy_assets(root, minecraft, log, is_language, "Language", &LANGUAGES)
|
||||
}
|
||||
|
||||
const LANGUAGES: [&str; 1] = ["pack.mcmeta"];
|
||||
|
||||
fn is_language(name: &str) -> bool {
|
||||
name == "pack.mcmeta" || name.starts_with("minecraft/lang/")
|
||||
}
|
||||
|
||||
/// Full preparation can run from cached JARs without an official launcher install.
|
||||
pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
if let Err(error) = panorama(root, minecraft, log) {
|
||||
@@ -129,6 +143,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
|
||||
));
|
||||
}
|
||||
if let Err(error) = languages(root, minecraft, log) {
|
||||
log(&format!(
|
||||
"Languages not imported: {error}; run horizon-prepare languages later"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const PANORAMA: [&str; 7] = [
|
||||
@@ -278,6 +297,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn with_panorama(name: &str, panorama: &[&'static str]) -> Fixture {
|
||||
Self::with_extras(name, panorama, &[])
|
||||
}
|
||||
|
||||
/// The same store with extra index entries, for the files the index alone holds.
|
||||
fn with_extras(
|
||||
name: &str,
|
||||
panorama: &[&'static str],
|
||||
extras: &[(&'static str, &'static [u8])],
|
||||
) -> Fixture {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-prepare-sounds-{name}-{}",
|
||||
std::process::id()
|
||||
@@ -302,6 +330,9 @@ mod tests {
|
||||
// Distinct object bytes let the test catch swapped faces too.
|
||||
objects.push((name, name.as_bytes().to_vec()));
|
||||
}
|
||||
for &(name, data) in extras {
|
||||
objects.push((name, data.to_vec()));
|
||||
}
|
||||
let mut index = String::from("{\"objects\": {");
|
||||
for (i, (name, data)) in objects.iter().enumerate() {
|
||||
let hash = sha1_hex(data);
|
||||
@@ -391,6 +422,32 @@ mod tests {
|
||||
f.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_come_from_the_asset_store() {
|
||||
// The client JAR carries `en_us` alone, so every other language and the list in
|
||||
// `pack.mcmeta` come from the index (#1272).
|
||||
let f = Fixture::with_extras(
|
||||
"languages",
|
||||
&[],
|
||||
&[("pack.mcmeta", b"{\"language\": {\"fr_fr\": {}}}")],
|
||||
);
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let target = languages(&f.root, Some(&f.minecraft), &|l| {
|
||||
lines.borrow_mut().push(l.to_string())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fs::read(target.join("assets/pack.mcmeta"))
|
||||
.unwrap()
|
||||
.starts_with(b"{\"language\""));
|
||||
assert_eq!(
|
||||
fs::read(target.join("assets/minecraft/lang/fr_fr.json")).unwrap(),
|
||||
b"{}"
|
||||
);
|
||||
// Each entry point copies its own set: the sounds stay where they belong.
|
||||
assert!(!target.join("assets/minecraft/sounds.json").exists());
|
||||
assert_eq!(*lines.borrow(), ["Language assets ready: 2 files, 0.0 MB"]);
|
||||
}
|
||||
|
||||
/// #1076: the launcher holds an index Mojang republished under the same id, so its SHA-1
|
||||
/// is not the one in the version JSON. A known one is used quietly, an unknown one with a
|
||||
/// line in the log; the sounds come through either way.
|
||||
@@ -539,7 +596,10 @@ mod tests {
|
||||
lines[1..],
|
||||
[
|
||||
"Font assets ready: 2 files, 0.0 MB",
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB"
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB",
|
||||
// This store has no `pack.mcmeta`, so the languages report it.
|
||||
"Languages not imported: pack.mcmeta is missing from the asset index; \
|
||||
run horizon-prepare languages later"
|
||||
]
|
||||
),
|
||||
_ => {
|
||||
@@ -549,9 +609,14 @@ mod tests {
|
||||
&& lines[2].ends_with("; run horizon-prepare packs later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines[3].starts_with("Languages not imported: ")
|
||||
&& lines[3].ends_with("; run horizon-prepare languages later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines.len(), 4);
|
||||
for name in PANORAMA {
|
||||
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ sha1 = "0.10"
|
||||
# Secure chat: SHA256withRSA message signatures.
|
||||
sha2 = "0.10"
|
||||
|
||||
# The name servers of Windows' network adapters, for Minecraft SRV redirects.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
ipconfig = "0.3.4"
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "protocol_probe"
|
||||
|
||||
@@ -191,6 +191,19 @@ impl Connection {
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))?
|
||||
.collect();
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
/// Minecraft's SRV redirect applies to the socket destination only. Callers
|
||||
/// keep the player's original host and port in the protocol handshake.
|
||||
pub fn connect_server(host: &str, port: u16, timeout: Duration) -> Result<Self> {
|
||||
let addrs = crate::resolver::resolve(host, port)?;
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
fn connect_addresses(
|
||||
host: &str,
|
||||
addrs: Vec<std::net::SocketAddr>,
|
||||
timeout: Duration,
|
||||
) -> Result<Self> {
|
||||
let mut last = Error::new(format!("Unknown host {host}"));
|
||||
for addr in addrs {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod ids;
|
||||
pub mod item;
|
||||
pub mod packets;
|
||||
pub mod registry;
|
||||
mod resolver;
|
||||
pub mod rsa;
|
||||
pub mod secure_chat;
|
||||
pub mod servers;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Minecraft server DNS redirects (`ServerRedirectHandler` / `ServerNameResolver`).
|
||||
//! Port 25565 asks for `_minecraft._tcp.<host>`'s SRV record; no record or a
|
||||
//! failed query leaves the original address in use. The protocol handshake
|
||||
//! still uses that original address.
|
||||
use crate::{Error, Result};
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
|
||||
mod dns;
|
||||
|
||||
pub(crate) fn resolve(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
|
||||
resolve_with(host, port, srv_redirect, |host, port| {
|
||||
(host, port)
|
||||
.to_socket_addrs()
|
||||
.map(|addrs| addrs.collect())
|
||||
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_with(
|
||||
host: &str,
|
||||
port: u16,
|
||||
srv: impl FnOnce(&str) -> Option<(String, u16)>,
|
||||
addresses: impl FnOnce(&str, u16) -> Result<Vec<SocketAddr>>,
|
||||
) -> Result<Vec<SocketAddr>> {
|
||||
// Vanilla also asks for `_minecraft._tcp.127.0.0.1`, which never has a
|
||||
// record: IP literals and localhost skip the query.
|
||||
if port == 25565
|
||||
&& host.parse::<IpAddr>().is_err()
|
||||
&& host != "localhost"
|
||||
&& let Some((target, target_port)) =
|
||||
srv(&format!("_minecraft._tcp.{}.", host.trim_end_matches('.')))
|
||||
{
|
||||
// No second SRV lookup at the target, and no fallback to the original
|
||||
// host when the target is unknown.
|
||||
return addresses(&target, target_port);
|
||||
}
|
||||
addresses(host, port)
|
||||
}
|
||||
|
||||
fn srv_redirect(name: &str) -> Option<(String, u16)> {
|
||||
dns::query_srv(name, &dns::system_name_servers(), dns::SERVER_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redirects_default_port_even_when_the_original_host_has_no_addresses() {
|
||||
let result = resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|name| {
|
||||
assert_eq!(name, "_minecraft._tcp.play.example.org.");
|
||||
Some(("backend.example.org.".into(), 25570))
|
||||
},
|
||||
|host, port| {
|
||||
assert_eq!((host, port), ("backend.example.org.", 25570));
|
||||
Ok(vec!["127.0.0.1:25570".parse().unwrap()])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec!["127.0.0.1:25570".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_record_or_lookup_failure_keeps_the_original_address() {
|
||||
resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|_| None,
|
||||
|host, port| {
|
||||
assert_eq!((host, port), ("play.example.org", 25565));
|
||||
Ok(vec![])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nondefault_ports_and_ip_literals_do_not_query_srv() {
|
||||
for (host, port) in [
|
||||
("play.example.org", 25570),
|
||||
("127.0.0.1", 25565),
|
||||
("::1", 25565),
|
||||
("localhost", 25565),
|
||||
] {
|
||||
resolve_with(
|
||||
host,
|
||||
port,
|
||||
|_| panic!("unexpected SRV query"),
|
||||
|actual, actual_port| {
|
||||
assert_eq!((actual, actual_port), (host, port));
|
||||
Ok(vec![])
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_qualified_host_does_not_get_a_second_trailing_dot() {
|
||||
resolve_with(
|
||||
"play.example.org.",
|
||||
25565,
|
||||
|name| {
|
||||
assert_eq!(name, "_minecraft._tcp.play.example.org.");
|
||||
None
|
||||
},
|
||||
|_, _| Ok(vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_redirect_target_does_not_fall_back_to_the_original() {
|
||||
let result = resolve_with(
|
||||
"play.example.org",
|
||||
25565,
|
||||
|_| Some(("missing.example.org.".into(), 25570)),
|
||||
|host, _| {
|
||||
assert_eq!(host, "missing.example.org.");
|
||||
Err(Error::new("unknown redirected host"))
|
||||
},
|
||||
);
|
||||
assert_eq!(result.unwrap_err().to_string(), "unknown redirected host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_address_can_connect_without_dns() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let connection = crate::connection::Connection::connect_server(
|
||||
"127.0.0.1",
|
||||
address.port(),
|
||||
std::time::Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
let (accepted, _) = listener.accept().unwrap();
|
||||
drop((connection, accepted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! The SRV question as JNDI's DNS client asks it: the system's name servers in
|
||||
//! turn, one UDP try of 1 s (`com.sun.jndi.dns.timeout.retries` = 1), TCP when
|
||||
//! the reply is truncated, and the answer's first SRV record. By hand, so the
|
||||
//! game needs no async runtime or resolver library for one question.
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const TYPE_SRV: u16 = 33;
|
||||
/// JNDI's `com.sun.jndi.dns.timeout.initial`, per name server.
|
||||
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
|
||||
/// Windows.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
std::fs::read_to_string("/etc/resolv.conf")
|
||||
.map_or_else(|_| Vec::new(), |conf| name_servers(&conf))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
let mut servers = Vec::new();
|
||||
for adapter in ipconfig::get_adapters().unwrap_or_default() {
|
||||
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
|
||||
continue;
|
||||
}
|
||||
for &ip in adapter.dns_servers() {
|
||||
let server = SocketAddr::new(ip, 53);
|
||||
if !servers.contains(&server) {
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// `nameserver` lines of resolv.conf, in order.
|
||||
#[cfg(any(unix, test))]
|
||||
fn name_servers(conf: &str) -> Vec<SocketAddr> {
|
||||
conf.lines()
|
||||
.filter_map(|line| line.strip_prefix("nameserver"))
|
||||
.filter_map(|rest| rest.split_whitespace().next())
|
||||
.filter_map(|ip| ip.split('%').next()?.parse::<std::net::IpAddr>().ok())
|
||||
.map(|ip| SocketAddr::new(ip, 53))
|
||||
.collect()
|
||||
}
|
||||
|
||||
enum Reply {
|
||||
/// The server answered: the first SRV record, if any.
|
||||
Answer(Option<(String, u16)>),
|
||||
Truncated,
|
||||
/// No reply, a server failure or a malformed reply: ask the next server.
|
||||
Unusable,
|
||||
}
|
||||
|
||||
pub(super) fn query_srv(
|
||||
name: &str,
|
||||
servers: &[SocketAddr],
|
||||
timeout: Duration,
|
||||
) -> Option<(String, u16)> {
|
||||
let mut id = [0; 2];
|
||||
getrandom::fill(&mut id).ok()?;
|
||||
let id = u16::from_be_bytes(id);
|
||||
let query = encode_query(id, name)?;
|
||||
for &server in servers {
|
||||
let reply = match ask_udp(server, &query, timeout).map(|reply| parse_reply(id, &reply)) {
|
||||
Some(Reply::Truncated) => ask_tcp(server, &query, timeout)
|
||||
.map_or(Reply::Unusable, |reply| parse_reply(id, &reply)),
|
||||
reply => reply.unwrap_or(Reply::Unusable),
|
||||
};
|
||||
if let Reply::Answer(srv) = reply {
|
||||
return srv;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ask_udp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
|
||||
let local: SocketAddr = if server.is_ipv4() {
|
||||
([0, 0, 0, 0], 0).into()
|
||||
} else {
|
||||
([0u16; 8], 0).into()
|
||||
};
|
||||
let socket = UdpSocket::bind(local).ok()?;
|
||||
socket.set_read_timeout(Some(timeout)).ok()?;
|
||||
socket.connect(server).ok()?;
|
||||
socket.send(query).ok()?;
|
||||
let mut buf = vec![0; 512];
|
||||
let len = socket.recv(&mut buf).ok()?;
|
||||
buf.truncate(len);
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn ask_tcp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
|
||||
let mut stream = TcpStream::connect_timeout(&server, timeout).ok()?;
|
||||
stream.set_read_timeout(Some(timeout)).ok()?;
|
||||
let mut framed = (query.len() as u16).to_be_bytes().to_vec();
|
||||
framed.extend_from_slice(query);
|
||||
stream.write_all(&framed).ok()?;
|
||||
let mut len = [0; 2];
|
||||
stream.read_exact(&mut len).ok()?;
|
||||
let mut buf = vec![0; u16::from_be_bytes(len) as usize];
|
||||
stream.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// A recursive SRV question (RFC 1035 §4.1) for an absolute name.
|
||||
fn encode_query(id: u16, name: &str) -> Option<Vec<u8>> {
|
||||
let mut msg = Vec::with_capacity(18 + name.len());
|
||||
msg.extend_from_slice(&id.to_be_bytes());
|
||||
msg.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); // RD; one question
|
||||
for label in name.trim_end_matches('.').split('.') {
|
||||
if label.is_empty() || label.len() > 63 {
|
||||
return None;
|
||||
}
|
||||
msg.push(label.len() as u8);
|
||||
msg.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
msg.push(0);
|
||||
if msg.len() - 12 > 255 {
|
||||
return None;
|
||||
}
|
||||
msg.extend_from_slice(&TYPE_SRV.to_be_bytes());
|
||||
msg.extend_from_slice(&1u16.to_be_bytes()); // IN
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
fn parse_reply(id: u16, msg: &[u8]) -> Reply {
|
||||
parse_answer(id, msg).unwrap_or(Reply::Unusable)
|
||||
}
|
||||
|
||||
fn parse_answer(id: u16, msg: &[u8]) -> Option<Reply> {
|
||||
let u16_at = |at: usize| Some(u16::from_be_bytes([*msg.get(at)?, *msg.get(at + 1)?]));
|
||||
let flags = u16_at(2)?;
|
||||
if u16_at(0)? != id || flags & 0x8000 == 0 {
|
||||
return None;
|
||||
}
|
||||
if flags & 0x0200 != 0 {
|
||||
return Some(Reply::Truncated);
|
||||
}
|
||||
match flags & 0xf {
|
||||
0 => {}
|
||||
3 => return Some(Reply::Answer(None)), // NXDOMAIN
|
||||
_ => return None,
|
||||
}
|
||||
let mut at = 12;
|
||||
for _ in 0..u16_at(4)? {
|
||||
at = read_name(msg, at)?.1 + 4;
|
||||
}
|
||||
for _ in 0..u16_at(6)? {
|
||||
at = read_name(msg, at)?.1;
|
||||
let (kind, len) = (u16_at(at)?, u16_at(at + 8)? as usize);
|
||||
let data = at + 10;
|
||||
msg.get(data..data + len)?;
|
||||
if kind == TYPE_SRV && len >= 7 {
|
||||
let (target, _) = read_name(msg, data + 6)?;
|
||||
return Some(Reply::Answer(Some((target, u16_at(data + 4)?))));
|
||||
}
|
||||
at = data + len;
|
||||
}
|
||||
Some(Reply::Answer(None))
|
||||
}
|
||||
|
||||
/// The name at `at`, absolute (`a.b.`), and where the record continues.
|
||||
fn read_name(msg: &[u8], mut at: usize) -> Option<(String, usize)> {
|
||||
let mut name = String::new();
|
||||
let mut end = None;
|
||||
// Pointers must go backwards; the bound ends a loop of labels and pointers.
|
||||
for _ in 0..128 {
|
||||
let len = *msg.get(at)? as usize;
|
||||
match len {
|
||||
0 => {
|
||||
if name.is_empty() {
|
||||
name.push('.');
|
||||
}
|
||||
return Some((name, end.unwrap_or(at + 1)));
|
||||
}
|
||||
0xc0.. => {
|
||||
let target = (len & 0x3f) << 8 | *msg.get(at + 1)? as usize;
|
||||
if target >= at {
|
||||
return None;
|
||||
}
|
||||
end.get_or_insert(at + 2);
|
||||
at = target;
|
||||
}
|
||||
1..=63 => {
|
||||
let label = msg.get(at + 1..at + 1 + len)?;
|
||||
name.push_str(std::str::from_utf8(label).ok()?);
|
||||
name.push('.');
|
||||
at += 1 + len;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NAME: &str = "_minecraft._tcp.play.example.org.";
|
||||
|
||||
/// The reply a server gives to `query`: its question, then a CNAME and the
|
||||
/// SRV records, each owner name compressed to the question's name.
|
||||
fn reply(query: &[u8], rcode: u8, records: &[(u16, &str)]) -> Vec<u8> {
|
||||
let mut msg = query.to_vec();
|
||||
msg[2] |= 0x80;
|
||||
msg[3] = 0x80 | rcode;
|
||||
let answers = records.len() as u16 + u16::from(!records.is_empty());
|
||||
msg[6..8].copy_from_slice(&answers.to_be_bytes());
|
||||
if !records.is_empty() {
|
||||
// CNAME first: the parser must step over records of other types.
|
||||
msg.extend_from_slice(&[0xc0, 12, 0, 5, 0, 1, 0, 0, 0, 60, 0, 2, 0xc0, 12]);
|
||||
}
|
||||
for &(port, target) in records {
|
||||
let mut rdata = vec![0, 1, 0, 1];
|
||||
rdata.extend_from_slice(&port.to_be_bytes());
|
||||
for label in target.trim_end_matches('.').split('.') {
|
||||
rdata.push(label.len() as u8);
|
||||
rdata.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
rdata.push(0);
|
||||
msg.extend_from_slice(&[0xc0, 12, 0, 33, 0, 1, 0, 0, 0, 60]);
|
||||
msg.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
|
||||
msg.extend_from_slice(&rdata);
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
fn answer(msg: &[u8]) -> Option<Option<(String, u16)>> {
|
||||
match parse_reply(7, msg) {
|
||||
Reply::Answer(srv) => Some(srv),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_is_a_recursive_srv_question() {
|
||||
let query = encode_query(0x1234, NAME).unwrap();
|
||||
let mut expected = vec![0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0];
|
||||
expected.extend_from_slice(b"\x0a_minecraft\x04_tcp\x04play\x07example\x03org\x00");
|
||||
expected.extend_from_slice(&[0, 33, 0, 1]);
|
||||
assert_eq!(query, expected);
|
||||
assert!(encode_query(1, "a..b.").is_none());
|
||||
assert!(encode_query(1, &format!("{}.org.", "a".repeat(64))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_srv_record_of_the_answer_wins() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
let msg = reply(
|
||||
&query,
|
||||
0,
|
||||
&[(25570, "a.example.org."), (25571, "b.example.org.")],
|
||||
);
|
||||
assert_eq!(answer(&msg), Some(Some(("a.example.org.".into(), 25570))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_answer_and_nxdomain_mean_no_redirect() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
assert_eq!(answer(&reply(&query, 0, &[])), Some(None));
|
||||
assert_eq!(answer(&reply(&query, 3, &[])), Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_failures_and_bad_replies_ask_the_next_server() {
|
||||
let query = encode_query(7, NAME).unwrap();
|
||||
let good = reply(&query, 0, &[(25570, "a.example.org.")]);
|
||||
assert!(matches!(
|
||||
parse_reply(7, &reply(&query, 2, &[])),
|
||||
Reply::Unusable
|
||||
));
|
||||
assert!(matches!(parse_reply(8, &good), Reply::Unusable)); // another id
|
||||
assert!(matches!(parse_reply(7, &query), Reply::Unusable)); // not a response
|
||||
for cut in 0..good.len() {
|
||||
assert!(matches!(parse_reply(7, &good[..cut]), Reply::Unusable));
|
||||
}
|
||||
let mut forward = good.clone();
|
||||
let at = query.len(); // the first answer's owner pointer
|
||||
assert_eq!(forward[at..at + 2], [0xc0, 12]);
|
||||
forward[at + 1] = 0xff; // a pointer forwards
|
||||
assert!(matches!(parse_reply(7, &forward), Reply::Unusable));
|
||||
let mut truncated = good;
|
||||
truncated[2] |= 0x02;
|
||||
assert!(matches!(parse_reply(7, &truncated), Reply::Truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolv_conf_name_servers_in_order() {
|
||||
let conf = "# generated\nsearch lan\nnameserver 192.168.1.1\nnameserver fe80::1%en0\n\
|
||||
nameserver ::1 \nnameserver bogus\noptions ndots:1\n";
|
||||
let servers: Vec<String> = name_servers(conf).iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
|
||||
}
|
||||
|
||||
/// A local name server: a silent one first, then one that answers truncated
|
||||
/// over UDP and in full over TCP, like a real server with a big answer.
|
||||
#[test]
|
||||
fn asks_each_server_in_turn_and_retries_truncated_replies_over_tcp() {
|
||||
let silent = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let (udp, tcp) = loop {
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
if let Ok(tcp) = std::net::TcpListener::bind(udp.local_addr().unwrap()) {
|
||||
break (udp, tcp);
|
||||
}
|
||||
};
|
||||
let servers = [silent.local_addr().unwrap(), udp.local_addr().unwrap()];
|
||||
let server = std::thread::spawn(move || {
|
||||
let mut buf = [0; 512];
|
||||
let (len, from) = udp.recv_from(&mut buf).unwrap();
|
||||
let mut truncated = reply(&buf[..len], 0, &[]);
|
||||
truncated[2] |= 0x02;
|
||||
udp.send_to(&truncated, from).unwrap();
|
||||
let (mut stream, _) = tcp.accept().unwrap();
|
||||
let mut len = [0; 2];
|
||||
stream.read_exact(&mut len).unwrap();
|
||||
let mut query = vec![0; u16::from_be_bytes(len) as usize];
|
||||
stream.read_exact(&mut query).unwrap();
|
||||
let full = reply(&query, 0, &[(25570, "backend.example.org.")]);
|
||||
stream
|
||||
.write_all(&(full.len() as u16).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&full).unwrap();
|
||||
});
|
||||
let srv = query_srv(NAME, &servers, Duration::from_millis(200));
|
||||
server.join().unwrap();
|
||||
assert_eq!(srv, Some(("backend.example.org.".into(), 25570)));
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,7 @@
|
||||
//! `.horizon/api.json` that only the server's own user can read. The CLI manages a running server through
|
||||
//! it; the web panel and the MCP server will too.
|
||||
//!
|
||||
//! - `GET /api/v1/status`: the supervisor's state, the game's status (TPS, MSPT, players), RAM and CPU.
|
||||
//! - `GET /api/v1/logs?since=N`: the log lines after the Nth.
|
||||
//! - `POST /api/v1/command {"line": "..."}`: a console command; the lines it logged.
|
||||
//! - `POST /api/v1/stop`, `POST /api/v1/restart`.
|
||||
//! - `GET /api/v1/metrics?since=T`: the samples taken after T (seconds since 1970), up to 10 minutes.
|
||||
//! - `GET /api/v1/backups`; `POST /api/v1/backups {"name": "..."}` backs up now; `POST /api/v1/restore
|
||||
//! {"name": "..."}` restarts the game with that backup's world.
|
||||
//! - `POST /api/v1/update {"tag": "...", "timeout": seconds}`: restarts the game on that installed release,
|
||||
//! and goes back to the one in use if it isn't up within the timeout; without a tag, downloads the newest
|
||||
//! release first (update.rs). `GET /api/v1/status` says how it went (`update`).
|
||||
//! - `POST /api/v1/panel/login`: a one-time login link for the web panel (with the token only).
|
||||
//! Its routes are [`ROUTES`], listed in docs/api/server-reference.md.
|
||||
|
||||
use crate::{
|
||||
backup,
|
||||
@@ -41,6 +31,102 @@ const LOG_LINES: usize = 2000;
|
||||
/// The samples kept for the panel's graphs: 10 minutes, one a second.
|
||||
const METRICS: usize = 600;
|
||||
|
||||
/// One endpoint of the control API: what the reference (docs/api/server-reference.md) says about it.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "only the reference reads all of it")
|
||||
)]
|
||||
pub struct Route {
|
||||
pub method: &'static str,
|
||||
pub path: &'static str,
|
||||
/// The JSON body or query it takes, "" for none.
|
||||
pub takes: &'static str,
|
||||
/// The JSON it answers with.
|
||||
pub answers: &'static str,
|
||||
pub what: &'static str,
|
||||
}
|
||||
|
||||
/// Every endpoint `start` and [`route`] answer; a test checks the two agree.
|
||||
pub const ROUTES: [Route; 11] = [
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/status",
|
||||
takes: "",
|
||||
answers: "`phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update`",
|
||||
what: "How the server does",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/metrics",
|
||||
takes: "`?since=T` (seconds since 1970)",
|
||||
answers: "`{\"samples\": [...]}`: one a second, the last 10 minutes",
|
||||
what: "The samples taken after T, for graphs",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/logs",
|
||||
takes: "`?since=N`",
|
||||
answers: "`{\"next\": N, \"lines\": [...]}`",
|
||||
what: "The log lines after the Nth (the last 2000 are kept)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/command",
|
||||
takes: "`{\"line\": \"<one command>\"}`",
|
||||
answers: "the lines the command logged",
|
||||
what: "Run a console command on the running game",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/stop",
|
||||
takes: "",
|
||||
answers: "`{\"ok\": true}`",
|
||||
what: "Stop the game and the supervisor",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/restart",
|
||||
takes: "",
|
||||
answers: "`{\"ok\": true}`",
|
||||
what: "Restart the game",
|
||||
},
|
||||
Route {
|
||||
method: "GET",
|
||||
path: "/api/v1/backups",
|
||||
takes: "",
|
||||
answers: "`{\"backups\": [{\"name\", \"bytes\"}]}`",
|
||||
what: "List the backups",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/backups",
|
||||
takes: "`{\"name\": \"...\"}` (optional)",
|
||||
answers: "`{\"name\": \"<file>\"}`",
|
||||
what: "Back up now (a running game saves first and stays up)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/restore",
|
||||
takes: "`{\"name\": \"<backup>\"}`",
|
||||
answers: "`{\"name\": \"<backup>\"}`",
|
||||
what: "Restart the game with that backup's world (the current one is kept in backups/)",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/update",
|
||||
takes: "`{\"tag\": \"...\", \"timeout\": seconds}` (both optional)",
|
||||
answers: "`{\"ok\": true}`; `status` says how it went (`update`)",
|
||||
what: "Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout",
|
||||
},
|
||||
Route {
|
||||
method: "POST",
|
||||
path: "/api/v1/panel/login",
|
||||
takes: "",
|
||||
answers: "`{\"url\": \"...\"}`",
|
||||
what: "A one-time login link for the web panel (with the token only, not from the panel)",
|
||||
},
|
||||
];
|
||||
|
||||
/// What the supervisor is doing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
@@ -488,12 +574,7 @@ pub fn route(shared: &Arc<Shared>, request: &Request) -> Response {
|
||||
Err(e) => Response::error(409, &e),
|
||||
}
|
||||
}
|
||||
(
|
||||
_,
|
||||
"/api/v1/status" | "/api/v1/logs" | "/api/v1/command" | "/api/v1/stop"
|
||||
| "/api/v1/restart" | "/api/v1/backups" | "/api/v1/restore" | "/api/v1/metrics"
|
||||
| "/api/v1/update",
|
||||
) => Response::error(405, "wrong method"),
|
||||
(_, path) if ROUTES.iter().any(|r| r.path == path) => Response::error(405, "wrong method"),
|
||||
_ => Response::error(404, "no such endpoint"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,22 +19,22 @@ pub const TEMPLATE: &str = r##"# Horizon server settings (docs/server.md). The s
|
||||
# every start: edit this file, not that one.
|
||||
|
||||
[server]
|
||||
motd = "A Minecraft Server"
|
||||
port = 25565
|
||||
motd = "A Minecraft Server" # the line under the server's name in the server list
|
||||
port = 25565 # the port players connect to
|
||||
bind = "" # listen on every address, or only on this one (server-ip)
|
||||
max_players = 20
|
||||
max_players = 20 # how many players can be online at once
|
||||
online_mode = true # check players' Minecraft accounts (false: anyone joins under any name)
|
||||
whitelist = true # only whitelisted players and operators can join
|
||||
enforce_whitelist = false # kick online players taken off the whitelist
|
||||
view_distance = 10
|
||||
simulation_distance = 10
|
||||
view_distance = 10 # chunks sent around each player
|
||||
simulation_distance = 10 # chunks around each player where mobs, crops and redstone tick
|
||||
|
||||
[world]
|
||||
name = "world" # the world folder
|
||||
seed = "" # empty: a random seed on the first start
|
||||
game_mode = "survival" # survival, creative, adventure or spectator
|
||||
difficulty = "easy" # peaceful, easy, normal or hard
|
||||
hardcore = false
|
||||
hardcore = false # one life: a player who dies becomes a spectator
|
||||
|
||||
# Any other server.properties key, spelled as vanilla spells it, for example:
|
||||
# spawn-protection = 16
|
||||
@@ -43,7 +43,7 @@ hardcore = false
|
||||
[engine]
|
||||
path = "" # the game program; empty: the one installed beside horizon-server
|
||||
resources = "" # the prepared game files; empty: resources/ here, else the usual places
|
||||
restart_on_crash = true
|
||||
restart_on_crash = true # start the game again when it crashes
|
||||
|
||||
[backups]
|
||||
every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off
|
||||
@@ -58,7 +58,7 @@ allow_public = false # true lets bind be any address; never put the pane
|
||||
"##;
|
||||
|
||||
/// Each typed setting: its section, its name, and the vanilla key it writes.
|
||||
const TYPED: [(&str, &str, &str); 14] = [
|
||||
pub(crate) const TYPED: [(&str, &str, &str); 14] = [
|
||||
("server", "motd", "motd"),
|
||||
("server", "port", "server-port"),
|
||||
("server", "bind", "server-ip"),
|
||||
|
||||
@@ -68,7 +68,8 @@ pub fn program_in(
|
||||
/// system can't load the program, as on a Linux without the ALSA and udev libraries the full game links
|
||||
/// (the server build doesn't). The reason, with what to install.
|
||||
pub fn check_starts(program: &Path) -> Result<(), String> {
|
||||
let output = Command::new(program)
|
||||
let mut command = crate::process::command(program);
|
||||
let output = command
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
@@ -176,7 +177,8 @@ pub fn spawn(program: &Path, resources: &Path, dir: &Path) -> std::io::Result<Ch
|
||||
#[cfg(windows)]
|
||||
std::os::windows::process::CommandExt::creation_flags(
|
||||
&mut command,
|
||||
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP,
|
||||
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP
|
||||
| windows_sys::Win32::System::Threading::CREATE_NO_WINDOW,
|
||||
);
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ mod http;
|
||||
mod init;
|
||||
mod lists;
|
||||
mod panel;
|
||||
mod process;
|
||||
#[cfg(test)]
|
||||
mod reference;
|
||||
mod service;
|
||||
mod settings;
|
||||
mod signals;
|
||||
|
||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user