mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
152
Commits
No files matched your search
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --history
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
|
||||
@@ -39,6 +39,17 @@ 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).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Generated
+15
@@ -1331,8 +1331,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1432,6 +1434,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1912,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"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
}
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
Weighted {
|
||||
variants: Vec<Variant>,
|
||||
variants: Vec<T>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice {
|
||||
impl Choice<Variant> {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,7 +41,23 @@ impl Choice {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -60,10 +76,11 @@ impl Choice {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -82,10 +99,19 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -95,14 +121,23 @@ impl CompiledChoices {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a Variant>,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -110,7 +145,7 @@ impl CompiledChoices {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
output.push(choice.select(random));
|
||||
f(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -119,7 +154,7 @@ impl CompiledChoices {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
output.push(self.multipart[*index].select(random));
|
||||
f(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! What a column costs to load, on real terrain: generates and lights the
|
||||
//! columns around a point with the vanilla generator, as a world being explored
|
||||
//! does, then meshes every section of the inner columns on this one thread, so a
|
||||
//! change to generation or meshing shows as time per column and per section.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 8);
|
||||
let rounds: usize = flag(&args, "--rounds", 3);
|
||||
let threads: usize = flag(
|
||||
&args,
|
||||
"--threads",
|
||||
thread::available_parallelism().map_or(2, |n| n.get()),
|
||||
);
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 100 {
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
let generated = started.elapsed() - Duration::from_millis(2 * 100);
|
||||
let columns = world.chunks.len();
|
||||
println!(
|
||||
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
|
||||
generated.as_secs_f64() * 1e3,
|
||||
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
|
||||
);
|
||||
|
||||
// Meshing every section of the columns whose neighbours are all loaded.
|
||||
let inner = radius - 1;
|
||||
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
|
||||
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
|
||||
.filter_map(|(x, z)| {
|
||||
let region: Region = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
region
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(Option::is_some)
|
||||
.then_some(region)
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"take" => count = book.remove_recipes(&index, &list()).len(),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
if let Some(angles) = input["angles"].as_array() {
|
||||
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Audio {
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -595,6 +598,7 @@ impl Audio {
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -825,6 +829,14 @@ impl Audio {
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
let selection = format!(
|
||||
"{} {}",
|
||||
source
|
||||
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
|
||||
.map_or("*", |s| s),
|
||||
event.as_deref().unwrap_or("*")
|
||||
);
|
||||
*self.stopped.entry(selection).or_default() += 1;
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
|
||||
@@ -13,6 +13,7 @@ mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boat_passengers;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -153,6 +154,7 @@ mod storage;
|
||||
mod structures;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod text_displays;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -1421,6 +1423,8 @@ struct Game<'a> {
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
text_display_vertices: usize,
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
@@ -4045,6 +4049,20 @@ impl Game<'_> {
|
||||
self.capture_loaded_world();
|
||||
Ok(())
|
||||
}
|
||||
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
|
||||
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
|
||||
/// farmland breaks. The local player's and every server player's landings.
|
||||
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
|
||||
use horizon_client::landing::FallEvent;
|
||||
let (position, flags) = match *event {
|
||||
FallEvent::Write {
|
||||
position, flags, ..
|
||||
} => (position, flags as i32),
|
||||
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
|
||||
_ => return,
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
@@ -4259,23 +4277,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
|
||||
position,
|
||||
flags,
|
||||
..
|
||||
}) => {
|
||||
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.notify_written(level, ticks, position, flags as i32)
|
||||
});
|
||||
}
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
|
||||
position, ..
|
||||
}) => {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
|
||||
});
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
@@ -4413,7 +4415,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());
|
||||
@@ -4818,11 +4820,15 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
|
||||
let _ = atlas_ptr;
|
||||
// The ToastManager draws over every screen: a server's packs
|
||||
// download while the connection screen shows.
|
||||
if self.remote_overlay(&mut batch) {
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if let Some(reason) = self.loading_reason() {
|
||||
self.draw_level_loading(&mut batch, reason);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
|
||||
@@ -5034,6 +5040,7 @@ impl Game<'_> {
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
@@ -6771,6 +6778,7 @@ impl Game<'_> {
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
@@ -7501,7 +7509,7 @@ impl Game<'_> {
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
(out, stars)
|
||||
}
|
||||
/// Load (or create) a world folder under saves/ and start streaming it.
|
||||
/// Enter a local world from the client, selecting its simulation mode.
|
||||
fn open_world(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -7524,16 +7532,9 @@ impl Game<'_> {
|
||||
if integrated::wanted(&self.options, &save_root) {
|
||||
return self.open_integrated(folder, name, seed, creative, create);
|
||||
}
|
||||
self.perf.world_opening();
|
||||
let request = world_loading::WorldRequest {
|
||||
save_root,
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
};
|
||||
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
|
||||
if self.window.is_some() {
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
@@ -7549,6 +7550,14 @@ impl Game<'_> {
|
||||
)?);
|
||||
return Ok(());
|
||||
}
|
||||
self.load_world(request)
|
||||
}
|
||||
|
||||
/// Load authoritative world state synchronously, without client entry or
|
||||
/// integrated-server selection. Server startup must use this entry point.
|
||||
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
|
||||
self.finish_open_world(prepared)
|
||||
}
|
||||
@@ -11883,6 +11892,8 @@ impl<'a> Game<'a> {
|
||||
breath_clouds: Vec::new(),
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
text_displays: Default::default(),
|
||||
text_display_vertices: 0,
|
||||
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
let Some(d) = &a.display else {
|
||||
return Text::literal(a.id.clone());
|
||||
};
|
||||
let color = match d.frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
};
|
||||
// Advancement.decorateName: the title in the frame's colour (styles
|
||||
// merged into it), a new line and the description.
|
||||
let coloured = text_of(&d.title).with_color(color);
|
||||
let mut component = coloured.component();
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
o.insert(
|
||||
"extra".into(),
|
||||
serde_json::json!(["\n", text_of(&d.description).component()]),
|
||||
);
|
||||
}
|
||||
let tooltip = coloured
|
||||
.append("\n")
|
||||
.append(text_of(&d.description))
|
||||
.with_component(component);
|
||||
let color = frame_color(d.frame);
|
||||
let title = text_of(&d.title).with_style(&Style {
|
||||
hover: Some(Box::new(tooltip)),
|
||||
hover: Some(Box::new(decorate_name(d))),
|
||||
..Style::default()
|
||||
});
|
||||
Text::translate("chat.square_brackets", &[title]).with_color(color)
|
||||
}
|
||||
fn frame_color(frame: Frame) -> u32 {
|
||||
match frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
}
|
||||
}
|
||||
/// `Advancement.decorateName`: a copy of the title with the frame's colour
|
||||
/// under its own style (mergeStyles), then a new line and the description
|
||||
/// after the title's own siblings, so both inherit the title's root style.
|
||||
/// Built on the title's JSON (a list as its first element with the rest as
|
||||
/// siblings): a parsed Text moves a styled root's style down a level once it
|
||||
/// has siblings.
|
||||
fn decorate_name(d: &adv::Display) -> Text {
|
||||
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
|
||||
Value::String(s) => serde_json::json!({ "text": s }),
|
||||
c => c,
|
||||
};
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
let frame = Text::empty().with_color(frame_color(d.frame)).component();
|
||||
o.entry("color").or_insert_with(|| frame["color"].clone());
|
||||
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
|
||||
if let Some(extra) = extra.as_array_mut() {
|
||||
extra.extend(["\n".into(), d.description.clone()]);
|
||||
}
|
||||
}
|
||||
text_of(&component).with_component(component)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
|
||||
/// its criteria): false when nothing changed.
|
||||
@@ -1051,17 +1061,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A text component from advancement JSON (`translate` or `text`).
|
||||
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
|
||||
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
|
||||
pub(super) fn text_of(v: &Value) -> Text {
|
||||
if let Some(key) = v["translate"].as_str() {
|
||||
Text::translate(key, &[])
|
||||
} else if let Some(s) = v["text"].as_str() {
|
||||
Text::literal(s)
|
||||
} else if let Some(s) = v.as_str() {
|
||||
Text::literal(s)
|
||||
} else {
|
||||
Text::empty()
|
||||
}
|
||||
horizon_client::scoreboard::comp_text(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1095,6 +1098,105 @@ mod tests {
|
||||
(seen, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_keep_their_siblings_and_styles() {
|
||||
let title = text_of(&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
|
||||
}));
|
||||
assert_eq!(title.plain(), "Quest Board");
|
||||
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
|
||||
assert_eq!(title.spans[1].style.color, None);
|
||||
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
|
||||
assert_eq!(
|
||||
vanilla,
|
||||
Text::translate("advancements.story.root.title", &[])
|
||||
);
|
||||
assert_eq!(
|
||||
vanilla.translation_key(),
|
||||
Some("advancements.story.root.title")
|
||||
);
|
||||
}
|
||||
|
||||
fn announced(title: Value, description: Value) -> adv::Advancement {
|
||||
adv::Advancement {
|
||||
id: "quests:board".into(),
|
||||
parent: None,
|
||||
display: Some(adv::Display {
|
||||
icon: "minecraft:book".into(),
|
||||
title,
|
||||
description,
|
||||
frame: Frame::Task,
|
||||
background: None,
|
||||
show_toast: true,
|
||||
announce: true,
|
||||
hidden: false,
|
||||
}),
|
||||
criteria: Vec::new(),
|
||||
requirements: Vec::new(),
|
||||
rewards: adv::Rewards::default(),
|
||||
}
|
||||
}
|
||||
/// The chat hover: the JSON a client receives, and the spans drawn here.
|
||||
fn hover(a: &adv::Advancement) -> (Value, Text) {
|
||||
let local = decorate_name(a.display.as_ref().unwrap());
|
||||
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
|
||||
(sent, local)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
|
||||
let a = announced(
|
||||
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
|
||||
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
|
||||
assert_eq!(colors(&sent), [gold, green, green, green]);
|
||||
// The spans drawn here come from the same JSON a client receives.
|
||||
assert_eq!(colors(&local), colors(&sent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_description_takes_a_styled_titles_own_style() {
|
||||
// mergeStyles keeps the title's gold; the description inherits it.
|
||||
let object = announced(
|
||||
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let list = announced(
|
||||
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
for a in [object, list] {
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let last = &sent.spans.last().unwrap().style;
|
||||
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
|
||||
assert_eq!(local.spans.last().unwrap().style, *last);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_of_a_vanilla_advancement() {
|
||||
let a = announced(
|
||||
serde_json::json!({"translate": "advancements.story.root.title"}),
|
||||
serde_json::json!({"translate": "advancements.story.root.description"}),
|
||||
);
|
||||
assert_eq!(
|
||||
hover(&a).0,
|
||||
serde_json::json!({
|
||||
"translate": "advancements.story.root.title",
|
||||
"color": "green",
|
||||
"extra": ["\n", {"translate": "advancements.story.root.description"}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toasts_slide_in_squared_and_out_after_five_seconds() {
|
||||
let mut toast = recipes(1);
|
||||
|
||||
@@ -472,6 +472,8 @@ impl Game<'_> {
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -320,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -401,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -477,6 +529,19 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
@@ -540,13 +605,14 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -556,9 +622,18 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -569,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -656,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -711,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -728,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -758,6 +830,7 @@ impl Game<'_> {
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
|
||||
@@ -1082,6 +1082,8 @@ impl Game<'_> {
|
||||
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
|
||||
}
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
|
||||
@@ -65,9 +65,10 @@ impl Game<'_> {
|
||||
tag
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
|
||||
// `saveWithoutId` writes the entity as saved, riders under
|
||||
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
|
||||
let i = self.mobs.iter().position(|m| m.id == id)?;
|
||||
self.mob_tree_nbt(i)
|
||||
}
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
|
||||
@@ -59,7 +59,15 @@ impl Game<'_> {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
self.level.recipe_book.remove_recipes(index, &recipes) as i32
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
@@ -345,7 +353,26 @@ impl Game<'_> {
|
||||
Some(Arg::Resource(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
self.audio.stop_sounds(sound.as_deref(), source);
|
||||
// StopSoundCommand: each target's connection gets the packet; the
|
||||
// window's own player stops its sounds.
|
||||
let packet = C::StopSound(play::StopSound {
|
||||
source: source.map(|s| s as i32),
|
||||
sound: sound.clone(),
|
||||
});
|
||||
for &t in &targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = match (source, &sound) {
|
||||
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
|
||||
(Some(_), None) => "commands.stopsound.success.source.any",
|
||||
|
||||
@@ -226,6 +226,16 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_section_sides(true),
|
||||
Some("off") => r.set_section_sides(false),
|
||||
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
@@ -981,6 +991,7 @@ impl Game<'_> {
|
||||
status["destroying"] = json!(destroying);
|
||||
status["physical_keys"] = json!(physical_keys);
|
||||
status["heard"] = json!(self.audio.heard);
|
||||
status["stopped_sounds"] = json!(self.audio.stopped);
|
||||
// Boats, minecarts, armor stands, item frames and paintings.
|
||||
status["objects"] = json!(self.objects_status());
|
||||
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
|
||||
|
||||
@@ -978,7 +978,9 @@ impl Game<'_> {
|
||||
m.ai.target = Some(target);
|
||||
}
|
||||
}
|
||||
MobEvent::Projectile(p) => {
|
||||
MobEvent::Projectile(mut p) => {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! its simulation and the play-state hub that LAN hosting uses, configured
|
||||
//! from server.properties, with the operator, whitelist and ban lists and a
|
||||
//! console on standard input.
|
||||
use super::{Game, Options, lan::LanSettings};
|
||||
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
|
||||
@@ -546,13 +546,13 @@ impl Game<'_> {
|
||||
let lists = Arc::new(Mutex::new(lists));
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
Some(props.seed),
|
||||
game_mode == 1,
|
||||
None,
|
||||
)?;
|
||||
)?)?;
|
||||
if new_world {
|
||||
self.level.hardcore = props.hardcore;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -287,13 +288,13 @@ impl Game<'_> {
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)?;
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
|
||||
@@ -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;
|
||||
@@ -1926,7 +1939,7 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
@@ -2023,20 +2036,17 @@ impl Game<'_> {
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let sounds = events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(sounds, marked.then_some(player.velocity))
|
||||
(events, marked.then_some(player.velocity))
|
||||
});
|
||||
let Some((sounds, marked)) = sounds else {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
for sound in sounds {
|
||||
self.player_hurt_sound(sound, at);
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
@@ -2439,6 +2449,32 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
|
||||
.to_vec();
|
||||
let facts = self.res.facts();
|
||||
// BlockItem.place: `playSound(player, ...)`, for the others only. The
|
||||
// placer's client plays it as its block appears.
|
||||
let placed: Vec<([f64; 3], String)> = changed
|
||||
.iter()
|
||||
.map(|&(x, y, z)| {
|
||||
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
|
||||
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
|
||||
(at, name.trim_start_matches("minecraft:").to_owned())
|
||||
})
|
||||
.collect();
|
||||
if let Some(capture) = self.audio.capture.as_mut() {
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
let (name, at, ..) = &capture[i];
|
||||
let place = placed
|
||||
.iter()
|
||||
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
|
||||
if place {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty()
|
||||
|| !changed.iter().all(|&(x, y, z)| {
|
||||
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
|
||||
|
||||
@@ -48,10 +48,9 @@ impl Game<'_> {
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
// startTrading with the guest's reputation. The session already
|
||||
// captured their Hero effect while they acted as the player.
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +265,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +346,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +391,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +509,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +587,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -650,25 +673,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +860,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -411,6 +415,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -607,6 +612,21 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
r.registries.name("minecraft:trim_material", *material),
|
||||
r.registries.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
@@ -1382,7 +1402,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1461,6 +1481,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1472,11 +1509,9 @@ 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.move_remote_text_display(id, position, rotation);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
@@ -1582,6 +1617,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Find a game slot in the menu's complete, contiguous slot mapping.
|
||||
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
|
||||
(0..)
|
||||
.map_while(|slot| Self::menu_slot(menu, slot))
|
||||
.position(|slot| slot == internal)
|
||||
}
|
||||
/// The workstation screen of a menu type a server opens.
|
||||
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
|
||||
use super::stations::Kind;
|
||||
@@ -1786,6 +1827,9 @@ impl Game<'_> {
|
||||
self.audio.play(&name, Some(at), p.volume, p.pitch);
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
.audio
|
||||
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
|
||||
C::AddEntity(p) => {
|
||||
let kind = self
|
||||
.remote
|
||||
@@ -1807,6 +1851,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,
|
||||
@@ -1817,6 +1863,7 @@ impl Game<'_> {
|
||||
p.data,
|
||||
);
|
||||
}
|
||||
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
@@ -2125,17 +2172,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2201,9 +2260,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2933,7 +2997,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2944,7 +3010,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2952,6 +3018,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;
|
||||
@@ -3007,6 +3090,8 @@ impl Game<'_> {
|
||||
}
|
||||
None => {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
// Player.tick derives noPhysics from the current game mode.
|
||||
player.spectator = self.spectator();
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
@@ -3149,6 +3234,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3176,6 +3264,7 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
@@ -3219,7 +3308,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3259,9 +3348,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3320,11 +3410,54 @@ 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) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
}
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3427,20 +3560,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 {
|
||||
@@ -3875,11 +4000,17 @@ impl Game<'_> {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
|| held
|
||||
.as_ref()
|
||||
.is_some_and(|stack| self.remote_can_place(&hit, stack))
|
||||
{
|
||||
let placed = if reacts {
|
||||
None
|
||||
} else {
|
||||
held.as_ref()
|
||||
.and_then(|stack| self.remote_can_place(&hit, stack))
|
||||
};
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -3939,18 +4070,7 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
|
||||
// server sends it to the others only.
|
||||
for (pos, sound, volume, pitch) in sounds {
|
||||
@@ -4010,12 +4130,45 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level before predicting its swing.
|
||||
/// The real level supplies collision shapes and building-blocking entities;
|
||||
/// block writes, random draws, scheduled ticks and events stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
|
||||
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
|
||||
/// level at once, so the next click of a fast bridge aims at them rather
|
||||
/// than past them (the server would refuse it). Its place sound is the
|
||||
/// placer's own (`playSound(player, ...)`: the server sends it to the
|
||||
/// others only).
|
||||
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// set_block plays the place sound of the block at `placing`.
|
||||
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
}
|
||||
/// The blocks a use changed, set on this client's level and kept as
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level: where it placed and the
|
||||
/// blocks it set. The real level supplies collision shapes and
|
||||
/// building-blocking entities; random draws, scheduled ticks and events
|
||||
/// stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
|
||||
if !self.may_build() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let ctx = horizon_world::level::PlaceContext {
|
||||
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
@@ -4030,14 +4183,16 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
matches!(
|
||||
b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx
|
||||
),
|
||||
horizon_world::level::PlaceOutcome::Placed { .. }
|
||||
)
|
||||
match b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx,
|
||||
) {
|
||||
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
|
||||
Some((pos, level.changed.into_iter().collect()))
|
||||
}
|
||||
horizon_world::level::PlaceOutcome::Failed => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4139,11 +4294,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go (or another slot is picked).
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
|
||||
.then_some((slot, ticks + 1));
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
@@ -4157,7 +4314,7 @@ impl Game<'_> {
|
||||
// Button release belongs to the next handleKeybinds phase, which
|
||||
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
|
||||
// quick press/release between ticks.
|
||||
if slot != self.selected {
|
||||
if !in_hand {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
@@ -4478,12 +4635,10 @@ impl Game<'_> {
|
||||
for &(slot, button, input) in ops {
|
||||
let slot = match slot {
|
||||
None => -999,
|
||||
Some(internal) => {
|
||||
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
Some(internal) => match Self::menu_slot_index(&menu, internal) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
clicks.push((slot, button, input));
|
||||
}
|
||||
@@ -4552,7 +4707,7 @@ impl Game<'_> {
|
||||
/// such slot.
|
||||
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
|
||||
let (container, menu) = self.remote_menu();
|
||||
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
|
||||
let slot = Self::menu_slot_index(&menu, internal);
|
||||
let Some(slot) = slot else { return false };
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
@@ -4987,6 +5142,8 @@ impl Game<'_> {
|
||||
.collect::<Vec<_>>(),
|
||||
// The player's own model exists (third person and the inventory draw it).
|
||||
"player_model": self.player_model.is_some(),
|
||||
"text_display_vertices": self.text_display_vertices,
|
||||
"text_displays": self.text_displays.len(),
|
||||
"health": r.health.0,
|
||||
"food": r.health.1,
|
||||
"decode_errors": r.decode_errors,
|
||||
@@ -5086,11 +5243,12 @@ type DrySound = (
|
||||
f32,
|
||||
f32,
|
||||
);
|
||||
/// The blocks a use on the dry level set.
|
||||
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
|
||||
/// What a use on the dry level changed, and the sounds it played.
|
||||
type DryUse = (
|
||||
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
Vec<DrySound>,
|
||||
);
|
||||
type DryUse = (DryChanges, Vec<DrySound>);
|
||||
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
|
||||
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
|
||||
|
||||
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
|
||||
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
|
||||
@@ -5411,4 +5569,28 @@ mod tests {
|
||||
}
|
||||
assert_eq!(Game::menu_slot("inventory", 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
|
||||
for (menu, internal, index) in [
|
||||
("minecraft:generic_9x6", 18, 63),
|
||||
("minecraft:generic_9x6", 19, 64),
|
||||
("minecraft:generic_9x6", 0, 81),
|
||||
("minecraft:generic_9x6", 8, 89),
|
||||
("minecraft:generic_9x6", 353, 53),
|
||||
("minecraft:generic_9x5", 8, 80),
|
||||
("minecraft:generic_9x4", 8, 71),
|
||||
("minecraft:generic_9x3", 8, 62),
|
||||
("inventory", 504, 45),
|
||||
] {
|
||||
assert_eq!(
|
||||
Game::menu_slot_index(menu, internal),
|
||||
Some(index),
|
||||
"{menu}, game slot {internal}"
|
||||
);
|
||||
}
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
);
|
||||
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
let mut c = super::minecarts::Minecart::new(id, cart, at);
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
@@ -93,6 +98,10 @@ impl Game<'_> {
|
||||
|
||||
/// `handleSetEntityData` for an object: what changes how it looks.
|
||||
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(display) = self.text_displays.get_mut(&id) {
|
||||
display.metadata(values);
|
||||
return;
|
||||
}
|
||||
let id = id as u32;
|
||||
for (index, _, value) in values {
|
||||
if self.item_frames.iter().any(|f| f.id == id) {
|
||||
@@ -273,6 +282,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -281,6 +291,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -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;
|
||||
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
// Homing state stays on the server; the client interpolates its positions.
|
||||
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
|
||||
target: horizon_client::mobs::Who::Player,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
|
||||
_ => return None,
|
||||
})
|
||||
@@ -185,11 +193,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;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
|
||||
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
|
||||
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
|
||||
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
|
||||
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -8,14 +8,19 @@ use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
multiplayer, packs,
|
||||
java_text, multiplayer, packs,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
|
||||
text::{Text, named_color},
|
||||
toasts::SystemToast,
|
||||
ui,
|
||||
};
|
||||
use horizon_core::jmath;
|
||||
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
|
||||
/// about, whether the last one is required, and the server's message.
|
||||
@@ -75,6 +80,111 @@ pub(super) struct ServerPackState {
|
||||
user: (String, u128),
|
||||
/// The packs loaded on top of the player's own, bottom first.
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
struct PackNotifier {
|
||||
progress: Receiver<download::Progress>,
|
||||
total: usize,
|
||||
/// `count`: the requests started.
|
||||
count: usize,
|
||||
/// `failCount`.
|
||||
failed: usize,
|
||||
/// `totalBytes`: the Content-Length of the request in flight.
|
||||
size: Option<u64>,
|
||||
/// The bytes it has written so far: none before the first response.
|
||||
bytes: Option<u64>,
|
||||
/// The last request has finished.
|
||||
done: bool,
|
||||
toast: Option<SystemToast>,
|
||||
}
|
||||
impl PackNotifier {
|
||||
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
|
||||
Self {
|
||||
progress,
|
||||
total,
|
||||
count: 0,
|
||||
failed: 0,
|
||||
size: None,
|
||||
bytes: None,
|
||||
done: false,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
/// The batch's events since the last call, with the toast updated once
|
||||
/// for all of them. False once the batch has ended and its toast is gone.
|
||||
fn poll(&mut self) -> bool {
|
||||
let (mut changed, mut hide) = (false, false);
|
||||
let ended = loop {
|
||||
let event = match self.progress.try_recv() {
|
||||
Ok(event) => event,
|
||||
Err(e) => break e == TryRecvError::Disconnected,
|
||||
};
|
||||
changed |= match event {
|
||||
download::Progress::RequestStart => {
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
download::Progress::DownloadStart(size) => {
|
||||
self.size = size;
|
||||
self.bytes = Some(0);
|
||||
true
|
||||
}
|
||||
download::Progress::Bytes(bytes) => {
|
||||
self.bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
download::Progress::RequestFinished(ok) => {
|
||||
self.failed += usize::from(!ok);
|
||||
self.done = self.count == self.total;
|
||||
// Every pack came: forceHide; else the failure title.
|
||||
hide |= self.done && self.failed == 0;
|
||||
self.done && self.failed > 0
|
||||
}
|
||||
};
|
||||
};
|
||||
if changed {
|
||||
let (title, message) = self.text();
|
||||
let (title, message) = (title.plain(), message.map(|m| m.plain()));
|
||||
// SystemToast.addOrUpdate.
|
||||
match &mut self.toast {
|
||||
Some(toast) => toast.reset(title, message),
|
||||
None => self.toast = Some(SystemToast::new(title, message)),
|
||||
}
|
||||
}
|
||||
if hide && let Some(toast) = &mut self.toast {
|
||||
toast.force_hide();
|
||||
}
|
||||
!ended || self.toast.is_some()
|
||||
}
|
||||
/// The toast's title and message.
|
||||
fn text(&self) -> (Text, Option<Text>) {
|
||||
if self.done && self.failed > 0 {
|
||||
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
|
||||
return (failed, None);
|
||||
}
|
||||
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
|
||||
let message = self.bytes.map(|bytes| match self.size {
|
||||
Some(size) if size > 0 => {
|
||||
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
|
||||
}
|
||||
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
|
||||
});
|
||||
(title, message)
|
||||
}
|
||||
}
|
||||
/// `com.mojang.realmsclient.Unit.humanReadable`, for
|
||||
/// `download.pack.progress.bytes`.
|
||||
fn human_readable(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
|
||||
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
|
||||
let value = bytes as f64 / 1024f64.powi(exp);
|
||||
format!("{} {prefix}B", java_text::format_fixed(value, 1))
|
||||
}
|
||||
|
||||
fn saves() -> PathBuf {
|
||||
@@ -89,6 +199,7 @@ impl Game<'_> {
|
||||
let state = &mut self.server_packs;
|
||||
state.user = user;
|
||||
state.prompt = None;
|
||||
state.notifiers.clear();
|
||||
let Some(address) = address else {
|
||||
state.address = None;
|
||||
state.packs.local_world();
|
||||
@@ -103,6 +214,7 @@ impl Game<'_> {
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
self.server_packs.prompt = None;
|
||||
self.server_packs.notifiers.clear();
|
||||
self.server_packs.packs.disconnect();
|
||||
self.tick_server_packs();
|
||||
}
|
||||
@@ -197,6 +309,7 @@ impl Game<'_> {
|
||||
/// 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) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
@@ -238,6 +351,15 @@ impl Game<'_> {
|
||||
Ok(queue) => state.queue = Some(queue),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to open the download queue: {e}");
|
||||
// Every request failed, as the notifier would hear it.
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
for _ in &requests {
|
||||
let _ = progress.send(download::Progress::RequestStart);
|
||||
let _ = progress.send(download::Progress::RequestFinished(false));
|
||||
}
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
let failed = requests.iter().map(|r| r.id).collect();
|
||||
state.packs.downloaded(horizon_client::server_packs::Batch {
|
||||
downloaded: Vec::new(),
|
||||
@@ -248,8 +370,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let headers = download::headers(&state.user.0, state.user.1);
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
if let Some(queue) = &state.queue {
|
||||
queue.download(requests, headers);
|
||||
queue.download(requests, headers, progress);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
@@ -297,6 +423,29 @@ impl Game<'_> {
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The download notifiers' toasts, each in the next 32-pixel slot
|
||||
/// below an advancement or recipe toast (`ToastManager`).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let mut slot = usize::from(!self.adv.toasts.is_empty());
|
||||
for notifier in &mut self.server_packs.notifiers {
|
||||
let Some(toast) = &mut notifier.toast else {
|
||||
continue;
|
||||
};
|
||||
let (sound, finished) = toast.update(multiplier);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
notifier.toast = None;
|
||||
continue;
|
||||
}
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
@@ -314,3 +463,97 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use download::Progress::*;
|
||||
|
||||
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(*event).unwrap();
|
||||
}
|
||||
let mut notifier = PackNotifier::new(receive, total);
|
||||
assert!(notifier.poll());
|
||||
notifier
|
||||
}
|
||||
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
|
||||
let (title, message) = n.text();
|
||||
(title.component(), message.map(|m| m.component()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toast_counts_requests_and_shows_progress() {
|
||||
let n = notifier(2, &[RequestStart]);
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(title["translate"], "download.pack.title");
|
||||
assert_eq!(title["with"], serde_json::json!([1, 2]));
|
||||
assert_eq!(message, None);
|
||||
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap(),
|
||||
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
|
||||
);
|
||||
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap()["with"],
|
||||
serde_json::json!([{"text": "1.3 KB"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
|
||||
let events = [
|
||||
RequestStart,
|
||||
RequestFinished(false),
|
||||
RequestStart,
|
||||
DownloadStart(Some(3)),
|
||||
Bytes(3),
|
||||
];
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(event).unwrap();
|
||||
}
|
||||
let mut n = PackNotifier::new(receive, 2);
|
||||
assert!(n.poll());
|
||||
// Not the last request yet: still downloading.
|
||||
assert_eq!(texts(&n).0["translate"], "download.pack.title");
|
||||
send.send(RequestFinished(true)).unwrap();
|
||||
drop(send);
|
||||
assert!(n.poll(), "the failure toast stays until it slides out");
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(
|
||||
title,
|
||||
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
|
||||
);
|
||||
assert_eq!(message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_batch_without_a_toast_is_dropped() {
|
||||
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
|
||||
drop(send);
|
||||
assert!(!PackNotifier::new(receive, 1).poll());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_read_as_realms_unit_human_readable() {
|
||||
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
|
||||
for (bytes, text) in [
|
||||
(0, "0 B"),
|
||||
(1023, "1023 B"),
|
||||
(1024, "1.0 KB"),
|
||||
(1280, "1.3 KB"),
|
||||
(1535, "1.5 KB"),
|
||||
(10_000, "9.8 KB"),
|
||||
(1_048_575, "1024.0 KB"),
|
||||
(1_048_576, "1.0 MB"),
|
||||
(52_428_800, "50.0 MB"),
|
||||
(262_144_000, "250.0 MB"),
|
||||
(1_073_741_824, "1.0 GB"),
|
||||
] {
|
||||
assert_eq!(human_readable(bytes), text, "{bytes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Server text displays: metadata, interpolation, billboard transforms and world text.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
text_display::{Align, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
pub(super) struct RemoteTextDisplay {
|
||||
pub data: TextDisplayData,
|
||||
from: TextDisplayData,
|
||||
shown: TextDisplayData,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
target: glam::DVec3,
|
||||
rotation: [f32; 2],
|
||||
old_rotation: [f32; 2],
|
||||
target_rotation: [f32; 2],
|
||||
steps: i32,
|
||||
age: i64,
|
||||
start: i64,
|
||||
duration: i32,
|
||||
dirty: bool,
|
||||
start_dirty: bool,
|
||||
duration_dirty: bool,
|
||||
initialized: bool,
|
||||
/// `Display.lastProgress`: the progress the last frame drew, where a new
|
||||
/// interpolation starts from.
|
||||
last_progress: f32,
|
||||
/// `TextDisplay.clientDisplayCache`: the split lines, until the render
|
||||
/// state changes.
|
||||
lines: Option<Lines>,
|
||||
}
|
||||
|
||||
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
|
||||
struct Lines {
|
||||
lines: Vec<(Text, f32)>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
|
||||
/// data a render state is built from (not the view range, size or glow).
|
||||
fn renders(id: u8) -> bool {
|
||||
matches!(id, 11..=16 | 18 | 19 | 23..=27)
|
||||
}
|
||||
|
||||
impl RemoteTextDisplay {
|
||||
pub fn new(position: [f64; 3], yaw: f32) -> Self {
|
||||
Self {
|
||||
data: Default::default(),
|
||||
from: Default::default(),
|
||||
shown: Default::default(),
|
||||
position: position.into(),
|
||||
previous: position.into(),
|
||||
target: position.into(),
|
||||
rotation: [yaw, 0.0],
|
||||
old_rotation: [yaw, 0.0],
|
||||
target_rotation: [yaw, 0.0],
|
||||
steps: 0,
|
||||
age: 0,
|
||||
start: 0,
|
||||
duration: 0,
|
||||
dirty: true,
|
||||
start_dirty: false,
|
||||
duration_dirty: false,
|
||||
initialized: false,
|
||||
last_progress: 0.0,
|
||||
lines: None,
|
||||
}
|
||||
}
|
||||
fn progress(&self, partial: f32) -> f32 {
|
||||
if self.duration <= 0 {
|
||||
1.0
|
||||
} else {
|
||||
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
|
||||
self.start_dirty |= values.iter().any(|v| v.0 == 8);
|
||||
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
|
||||
self.dirty |= values.iter().any(|v| renders(v.0));
|
||||
self.data.apply(values);
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
self.age += 1;
|
||||
self.previous = self.position;
|
||||
self.old_rotation = self.rotation;
|
||||
if self.steps > 0 {
|
||||
self.position += (self.target - self.position) / f64::from(self.steps);
|
||||
for i in 0..2 {
|
||||
self.rotation[i] +=
|
||||
angle(self.target_rotation[i] - self.rotation[i]) / self.steps as f32;
|
||||
}
|
||||
self.steps -= 1;
|
||||
}
|
||||
if self.start_dirty {
|
||||
self.start = self.age + i64::from(self.data.interpolation_delay);
|
||||
}
|
||||
if self.duration_dirty {
|
||||
self.duration = self.data.interpolation_duration;
|
||||
}
|
||||
if self.dirty {
|
||||
self.from = if self.initialized && self.duration > 0 {
|
||||
self.shown.blend(&self.from, self.last_progress)
|
||||
} else {
|
||||
self.data.clone()
|
||||
};
|
||||
self.shown = self.data.clone();
|
||||
self.initialized = true;
|
||||
self.lines = None;
|
||||
}
|
||||
self.dirty = false;
|
||||
self.start_dirty = false;
|
||||
self.duration_dirty = false;
|
||||
}
|
||||
fn move_to(&mut self, position: [f64; 3], rotation: Option<(f32, f32)>) {
|
||||
self.target = position.into();
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
self.target_rotation = [yaw, pitch];
|
||||
}
|
||||
self.steps = self.data.teleport_duration.clamp(0, 59);
|
||||
if self.steps == 0 {
|
||||
self.position = self.target;
|
||||
self.previous = self.target;
|
||||
self.rotation = self.target_rotation;
|
||||
self.old_rotation = self.rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn angle(d: f32) -> f32 {
|
||||
(d + 180.0).rem_euclid(360.0) - 180.0
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn move_remote_text_display(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
rotation: Option<(f32, f32)>,
|
||||
) {
|
||||
if let Some(d) = self.text_displays.get_mut(&id) {
|
||||
d.move_to(position, rotation);
|
||||
}
|
||||
}
|
||||
pub(super) fn tick_text_displays(&mut self) {
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.tick();
|
||||
}
|
||||
}
|
||||
pub(super) fn text_display_geometry(
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
partial: f64,
|
||||
normal: &mut Vec<Vertex>,
|
||||
see_through: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
let atlas = &self.gui.atlas;
|
||||
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
|
||||
let angles = if front {
|
||||
[self.yaw + 180.0, -self.pitch]
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
display.old_rotation[i]
|
||||
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
|
||||
});
|
||||
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
|
||||
if !matrix.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
|
||||
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
|
||||
let lines: Vec<_> = atlas
|
||||
.font
|
||||
.split(&d.text, d.line_width as f32)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let width = atlas.font.width_text(&line).ceil();
|
||||
(line, width)
|
||||
})
|
||||
.collect();
|
||||
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
|
||||
Lines { lines, width }
|
||||
});
|
||||
let width = *width;
|
||||
let height = lines.len() as f32 * 10.0 - 1.0;
|
||||
matrix *=
|
||||
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let bg = if d.flags & 4 != 0 {
|
||||
self.settings.text_background(0.25)
|
||||
} else {
|
||||
d.background as u32
|
||||
};
|
||||
if bg != 0 {
|
||||
batch.fill(
|
||||
atlas,
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
height + 1.0,
|
||||
rgba(
|
||||
(bg >> 16) as u8,
|
||||
(bg >> 8) as u8,
|
||||
bg as u8,
|
||||
(bg >> 24) as u8,
|
||||
),
|
||||
);
|
||||
}
|
||||
let align = d.align();
|
||||
for (i, (line, line_width)) in lines.iter().enumerate() {
|
||||
let x = match align {
|
||||
Align::Left => 0.0,
|
||||
Align::Right => width - line_width,
|
||||
Align::Center => (width - line_width) / 2.0,
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x,
|
||||
i as f32 * 10.0,
|
||||
rgba(255, 255, 255, d.opacity as u8),
|
||||
d.flags & 1 != 0,
|
||||
);
|
||||
}
|
||||
let world_light = self.world.light(
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
);
|
||||
let light = if d.brightness == -1 {
|
||||
horizon_client::block_entity_render::vertex_light(world_light)
|
||||
} else {
|
||||
((d.brightness as u32 >> 20 & 15) * 16)
|
||||
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
};
|
||||
let through = d.flags & 2 != 0;
|
||||
let out = if through {
|
||||
&mut *see_through
|
||||
} else {
|
||||
&mut *normal
|
||||
};
|
||||
for quad in batch.vertices.as_chunks::<6>().0 {
|
||||
for i in [0, 1, 2, 5] {
|
||||
let v = quad[i];
|
||||
let point =
|
||||
matrix.transform_point3(glam::Vec3::new(v.pos[0], v.pos[1], v.depth));
|
||||
out.push(Vertex {
|
||||
pos: point.to_array(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
}
|
||||
self.text_display_vertices += batch.vertices.len() / 6 * 4;
|
||||
self.map_patches.extend(batch.patches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
|
||||
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
|
||||
d.tick();
|
||||
d.metadata(&[
|
||||
(8, 1, Value::Int(0)),
|
||||
(9, 1, Value::Int(4)),
|
||||
(10, 1, Value::Int(2)),
|
||||
(11, 0, Value::Floats(vec![4.0, 0.0, 0.0])),
|
||||
]);
|
||||
d.tick();
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.5)).translation[0], 0.5);
|
||||
d.move_to([2.0, 0.0, 0.0], Some((90.0, 0.0)));
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 1.0);
|
||||
assert_eq!(d.rotation[0], 45.0);
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 2.0);
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.0)).translation[0], 2.0);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,23 @@ pub(super) struct PreparedWorld {
|
||||
}
|
||||
|
||||
impl WorldRequest {
|
||||
pub fn new(
|
||||
folder: &str,
|
||||
name: Option<&str>,
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
save_root: std::env::current_dir()?.join("saves").join(folder),
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
self,
|
||||
res: &Resources,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
environment::Sky,
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
sprites::SpriteAnimation,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Vec3, Vec4};
|
||||
@@ -156,9 +156,9 @@ struct SectionGpu {
|
||||
page: u32,
|
||||
start: u64,
|
||||
solid: u32,
|
||||
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
|
||||
sides: Sides,
|
||||
translucent: u32,
|
||||
/// The solid quads by facing: back-facing groups are skipped.
|
||||
faces: crate::mesher::FaceGroups,
|
||||
sort: translucency::SortState,
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
@@ -325,6 +325,8 @@ pub struct Renderer {
|
||||
/// counts reach a multi-draw's entries (Vulkan, Metal).
|
||||
cull: Option<cull::Cull>,
|
||||
cull_on: bool,
|
||||
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
|
||||
sides_on: bool,
|
||||
sky: wgpu::RenderPipeline,
|
||||
dark_disc: wgpu::RenderPipeline,
|
||||
lines: wgpu::RenderPipeline,
|
||||
@@ -2157,7 +2159,7 @@ impl Renderer {
|
||||
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
|
||||
cull.always = cull_env.as_deref() == Ok("all");
|
||||
if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
|
||||
cull.min_draws = cull::TILED_MIN_DRAWS;
|
||||
cull.min_sections = cull::TILED_MIN_SECTIONS;
|
||||
}
|
||||
cull
|
||||
});
|
||||
@@ -2204,6 +2206,7 @@ impl Renderer {
|
||||
translucent,
|
||||
flat_on: flat.is_some(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
flat,
|
||||
sky,
|
||||
@@ -2453,6 +2456,14 @@ impl Renderer {
|
||||
self.flat_on = on;
|
||||
Ok(())
|
||||
}
|
||||
/// Whether solid quads facing away from the eye are left out.
|
||||
pub fn section_sides(&self) -> bool {
|
||||
self.sides_on
|
||||
}
|
||||
/// Switches leaving out solid quads facing away, for A/B checks.
|
||||
pub fn set_section_sides(&mut self, on: bool) {
|
||||
self.sides_on = on;
|
||||
}
|
||||
/// Whether solid sections are occlusion culled (`cull`).
|
||||
pub fn section_cull(&self) -> bool {
|
||||
self.cull_on
|
||||
@@ -2575,8 +2586,12 @@ impl Renderer {
|
||||
page,
|
||||
start,
|
||||
solid,
|
||||
sides: if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
},
|
||||
translucent,
|
||||
faces: mesh.faces,
|
||||
sort: translucency::SortState::new(
|
||||
mesh.translucent
|
||||
.as_chunks::<4>()
|
||||
@@ -3042,6 +3057,9 @@ impl Renderer {
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(view_proj).filter(|_| self.sides_on);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
@@ -3106,10 +3124,6 @@ impl Renderer {
|
||||
// as one list of indirect draws over the section arena.
|
||||
self.draws.clear();
|
||||
self.draw_pages.clear();
|
||||
// The eye, camera-relative: view bobbing moves it off the camera.
|
||||
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
|
||||
.inverse()
|
||||
.transform_point3(Vec3::ZERO);
|
||||
// Occlusion culling leaves the sections its last result did not see
|
||||
// to phase 2, after the others, with instance counts of 0. Whether
|
||||
// the frame has draws enough goes by the last one's.
|
||||
@@ -3117,7 +3131,7 @@ impl Renderer {
|
||||
.cull
|
||||
.as_mut()
|
||||
.filter(|c| {
|
||||
self.cull_on && !frame.title && self.multi_draw && self.section_draws >= c.min_draws
|
||||
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
|
||||
})
|
||||
.and_then(|cull| cull.begin(&self.device).then_some(cull));
|
||||
self.phase2_draws.clear();
|
||||
@@ -3135,12 +3149,11 @@ impl Renderer {
|
||||
let start = draws.len();
|
||||
let instances = u32::from(first_phase);
|
||||
let mut indices = 0;
|
||||
// Only the solid groups that can face the eye.
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
draws.push([quads * 6, instances, 0, first as u32, i as u32]);
|
||||
pages.push((s.page, None));
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
indices += quads * 6;
|
||||
let base = s.start as u32 + first * 4;
|
||||
draws.push([quads * 6, instances, 0, base, i as u32]);
|
||||
pages.push((s.page, None));
|
||||
});
|
||||
let runs = if first_phase {
|
||||
&mut self.solid_runs
|
||||
@@ -3178,10 +3191,10 @@ impl Renderer {
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let s = &self.lods[&v.key].mesh;
|
||||
let start = self.draws.len();
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
self.draws
|
||||
.push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]);
|
||||
let instance = (visible.len() + i) as u32;
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
let base = s.start as u32 + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, base, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
});
|
||||
self.solid_runs
|
||||
@@ -3520,7 +3533,9 @@ impl Renderer {
|
||||
}
|
||||
let flat_frame = match &mut self.flat {
|
||||
Some(flat)
|
||||
if self.flat_on && !self.draws.is_empty() && self.draws.len() >= flat.min_draws =>
|
||||
if self.flat_on
|
||||
&& !self.draws.is_empty()
|
||||
&& self.drawn_sections >= flat.min_sections =>
|
||||
{
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
@@ -4202,6 +4217,34 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
|
||||
}
|
||||
|
||||
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
|
||||
/// space: the point whose clip x, y and w are all 0. `None` for a
|
||||
/// projection without one (orthographic).
|
||||
fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let m = view_proj.as_dmat4();
|
||||
let rows = [m.row(0), m.row(1), m.row(3)];
|
||||
let a = glam::DMat3::from_cols(
|
||||
DVec3::new(rows[0].x, rows[1].x, rows[2].x),
|
||||
DVec3::new(rows[0].y, rows[1].y, rows[2].y),
|
||||
DVec3::new(rows[0].z, rows[1].z, rows[2].z),
|
||||
);
|
||||
let det = a.determinant();
|
||||
if !det.is_normal() {
|
||||
return None;
|
||||
}
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
/// `origin`; all of them when there is no eye.
|
||||
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
|
||||
match eye {
|
||||
Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
|
||||
None if sides.quads() > 0 => draw(0, sides.quads()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
@@ -4429,6 +4472,25 @@ mod tests {
|
||||
assert_eq!(super::outline_width(3840), 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn the_projection_center_follows_the_view_bob() {
|
||||
use glam::{Mat4, Vec3};
|
||||
let projection = super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(30.0, 20.0);
|
||||
let eye = super::projection_center(projection * view).unwrap();
|
||||
assert!(eye.length() < 1e-5, "{eye}");
|
||||
// A pose translated by the bob: the eye is where the pose's inverse
|
||||
// takes the view's origin.
|
||||
let bob = Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.03, -0.08, 0.0));
|
||||
let expected = (bob * view).inverse().transform_point3(Vec3::ZERO);
|
||||
let eye = super::projection_center(projection * bob * view).unwrap();
|
||||
assert!(eye.distance(expected) < 1e-5, "{eye} {expected}");
|
||||
assert!(expected.length() > 0.05);
|
||||
assert!(
|
||||
super::projection_center(Mat4::orthographic_rh(-1.0, 1.0, -1.0, 1.0, 0.0, 1.0))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends)
|
||||
|
||||
@@ -46,13 +46,15 @@ const RETEST: u32 = 8;
|
||||
/// more than they save. Else one frame in `PROBE` tests every section.
|
||||
const MIN_SHARE: f32 = 0.1;
|
||||
const PROBE: u32 = 64;
|
||||
/// On Metal, section draws from which a frame culls. Apple's GPUs store and
|
||||
/// reload the whole frame at each of the passes the test adds, a fixed cost
|
||||
/// that the sections it hides repay only at high render distances: on an M4
|
||||
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
|
||||
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
|
||||
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
|
||||
pub const TILED_MIN_DRAWS: usize = 4096;
|
||||
/// On Metal, sections in view from which a frame culls. Apple's GPUs store
|
||||
/// and reload the whole frame at each of the passes the test adds, a fixed
|
||||
/// cost that the sections it hides repay only at high render distances: on
|
||||
/// an M4 Pro at 1080p and render distance 12 (1,000–1,600 draws) culling
|
||||
/// added about 0.05 ms of GPU time while hiding 15–30% of the solid
|
||||
/// indices; at 32 (over 4,096 draws) it saved 0.25–1.6 ms. Those draws were
|
||||
/// 2.2 to 2.5 per section; a section's draw count now depends on the sides
|
||||
/// it shows the eye (`mesher::Sides`), so the bar counts sections.
|
||||
pub const TILED_MIN_SECTIONS: usize = 1600;
|
||||
/// Boxes over more than this share of the screen count as seen untested.
|
||||
const BIG: f32 = 0.25;
|
||||
/// Results read back at once, some frames late so the CPU never waits.
|
||||
@@ -148,8 +150,8 @@ pub struct Cull {
|
||||
pub hidden: ([usize; 2], f32),
|
||||
/// Cull every frame, whatever the hidden share (A/B checks).
|
||||
pub always: bool,
|
||||
/// Frames with fewer section draws than this do not cull.
|
||||
pub min_draws: usize,
|
||||
/// Frames with fewer sections in view than this do not cull.
|
||||
pub min_sections: usize,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
@@ -348,7 +350,7 @@ impl Cull {
|
||||
stamp: 0,
|
||||
hidden: ([0; 2], 1.0),
|
||||
always: false,
|
||||
min_draws: 0,
|
||||
min_sections: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,17 +96,19 @@ pub struct Flat {
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Frames with fewer section draws keep the per-section draws.
|
||||
pub min_draws: usize,
|
||||
/// Frames with fewer sections in view keep the per-section draws.
|
||||
pub min_sections: usize,
|
||||
range_align: u64,
|
||||
}
|
||||
|
||||
/// Section draws from which a frame is drawn flat. Below it the index list
|
||||
/// costs the GPU more than the draw calls cost the CPU: on an M4 Pro at
|
||||
/// render distance 16 (2,900 draws) the copy and the list add about 0.4 ms
|
||||
/// of GPU time for 0.5–0.8 ms less CPU time; at 32 (9,300 draws) the CPU
|
||||
/// saves 1.3 ms.
|
||||
const MIN_DRAWS: usize = 4096;
|
||||
/// Sections in view (and distant tiles) from which a frame is drawn flat.
|
||||
/// Below it the index list costs the GPU more than the draw calls cost the
|
||||
/// CPU: on an M4 Pro at render distance 16 (2,900 draws) the copy and the
|
||||
/// list add about 0.4 ms of GPU time for 0.5–0.8 ms less CPU time; at 32
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -337,10 +339,10 @@ impl Flat {
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_DRAWS
|
||||
MIN_SECTIONS
|
||||
},
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PlayerHealth {
|
||||
self.hurt_animation_time -= 1;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
|
||||
/// tilt and red flash restart.
|
||||
pub fn animate_hurt(&mut self) {
|
||||
self.hurt.hurt_duration = 10;
|
||||
self.hurt.hurt_time = 10;
|
||||
self.hurt_animation_time = 10;
|
||||
}
|
||||
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
|
||||
/// health starts the hurt animation.
|
||||
pub fn flash_health_change(&mut self, health: f32) {
|
||||
let damage = self.health() - health;
|
||||
if damage > 0.0 {
|
||||
self.hurt.last_hurt = damage;
|
||||
self.animate_hurt();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn hurt(
|
||||
@@ -274,6 +290,9 @@ impl PlayerMotion {
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
) -> Result<BlockOutcome> {
|
||||
if self.client_side {
|
||||
return Ok(BlockOutcome::default());
|
||||
}
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(source, None, None, None)
|
||||
|
||||
@@ -55,6 +55,9 @@ impl PlayerMotion {
|
||||
Ok(())
|
||||
}
|
||||
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
|
||||
if self.client_side {
|
||||
return Ok(());
|
||||
}
|
||||
let h = &mut self.health;
|
||||
let mut world = TickWorld {
|
||||
resources,
|
||||
|
||||
@@ -432,18 +432,21 @@ impl Guest {
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
pub fn stack_to_network(
|
||||
item_id: impl Fn(&str) -> i32,
|
||||
enchantment_id: impl Fn(&str) -> Option<i32>,
|
||||
synced_id: impl Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &enchantment_id, s)
|
||||
network_stack(&item_id, &synced_id, s)
|
||||
}
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
enchantment_id: &dyn Fn(&str) -> Option<i32>,
|
||||
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
|
||||
if s.damage > 0 {
|
||||
@@ -481,6 +484,25 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(rgb),
|
||||
));
|
||||
}
|
||||
// ArmorTrim: both holders as references into the synced registries.
|
||||
if let Some((material, pattern)) = &d.trim {
|
||||
let id = |registry: &str, name: &str| {
|
||||
if name.contains(':') {
|
||||
synced_id(registry, name)
|
||||
} else {
|
||||
synced_id(registry, &format!("minecraft:{name}"))
|
||||
}
|
||||
};
|
||||
if let (Some(material), Some(pattern)) = (
|
||||
id("minecraft:trim_material", material),
|
||||
id("minecraft:trim_pattern", pattern),
|
||||
) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::TRIM,
|
||||
horizon_protocol::item::Component::Trim { material, pattern },
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -504,7 +526,7 @@ fn network_stack(
|
||||
let charged = d
|
||||
.charged
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
|
||||
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CHARGED_PROJECTILES,
|
||||
@@ -545,3 +567,32 @@ fn network_stack(
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::item::{Component, component};
|
||||
|
||||
#[test]
|
||||
fn trims_go_as_synced_registry_references() {
|
||||
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:trim_material", "minecraft:gold") => Some(4),
|
||||
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::TRIM),
|
||||
Some(&Component::Trim {
|
||||
material: 4,
|
||||
pattern: 1
|
||||
})
|
||||
);
|
||||
// A name the connection's registry lacks sends no trim.
|
||||
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ impl Hub {
|
||||
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
|
||||
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
|
||||
let sync = self.sync.clone();
|
||||
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
|
||||
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, packet: &C) {
|
||||
@@ -1827,7 +1827,9 @@ impl Hub {
|
||||
fn flush_advancements(&mut self, i: usize, show: bool) {
|
||||
let sync = self.sync.clone();
|
||||
let g = &mut self.guests[i];
|
||||
let unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
// A recipe given and taken before this flush must not reappear.
|
||||
unlocked.retain(|id| g.recipe_book.knows(id));
|
||||
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
|
||||
@@ -689,7 +689,7 @@ impl Lod {
|
||||
}
|
||||
let finer = finer.remove(&key).unwrap_or_default();
|
||||
let input = snapshot.mesh_input(key, finer);
|
||||
let mesh = mesher.mesh(
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
&covered,
|
||||
@@ -698,6 +698,7 @@ impl Lod {
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
}
|
||||
Done::Mesh {
|
||||
@@ -1727,10 +1728,6 @@ mod tests {
|
||||
let expected = (key.size() * key.size() - 16 * 16) as f32;
|
||||
assert_eq!(top_area(&mesh.solid, 70.0), expected);
|
||||
assert_eq!(top_area(&mesh.translucent, 80.0 - 1.0 / 9.0), expected);
|
||||
assert_eq!(
|
||||
mesh.faces.quads.iter().sum::<u32>() as usize,
|
||||
mesh.solid.len() / 4
|
||||
);
|
||||
assert!(mesh.translucent.iter().all(|v| v.light == 240));
|
||||
let side = |axis: usize, plane: f32, low: f32, high: f32| {
|
||||
let other = if axis == 0 { 2 } else { 0 };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use super::{Column, Span, TileKey};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mesher::{FaceGroups, OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
streaming::WorkerContext,
|
||||
};
|
||||
use horizon_assets::model::Direction;
|
||||
@@ -558,7 +558,6 @@ fn mesh_columns_with_edges(
|
||||
emit(&mut mesh, key, rect)
|
||||
}
|
||||
}
|
||||
mesh.faces = FaceGroups::group(&mut mesh.solid);
|
||||
mesh
|
||||
}
|
||||
|
||||
@@ -1003,7 +1002,6 @@ mod tests {
|
||||
}
|
||||
let mesh = mesh(&columns);
|
||||
assert_eq!(mesh.solid.len(), 6 * 4);
|
||||
assert_eq!(mesh.faces.quads.iter().sum::<u32>(), 6);
|
||||
assert!(mesh.translucent.is_empty());
|
||||
for q in mesh.solid.as_chunks::<4>().0.iter() {
|
||||
let center = q
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! `ModelBlockRenderer`/`BlockModelLighter` (smooth light, ambient occlusion,
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod facing;
|
||||
mod fluid;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
chunk::{Chunk, MIN_Y},
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
pub use facing::{FaceGroups, GROUPS as FACE_GROUPS};
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
@@ -22,6 +21,7 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use sides::Sides;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An entity, item and effect vertex (the entity pipelines), with a face
|
||||
@@ -116,8 +116,10 @@ mod vertex_tests {
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// `solid`'s quads by the side they face (`facing`).
|
||||
pub faces: FaceGroups,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
|
||||
/// order) are connected through non-opaque blocks.
|
||||
@@ -131,13 +133,20 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
faces: FaceGroups::default(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
@@ -692,12 +701,6 @@ pub struct Mesher<'a> {
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = self.tesselate(region, sy);
|
||||
out.faces = facing::group(&mut out.solid);
|
||||
out
|
||||
}
|
||||
/// The section's quads in model order, before `facing::group`.
|
||||
pub(crate) fn tesselate(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
@@ -715,6 +718,8 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
@@ -744,13 +749,15 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
let world = BlockPos::new(center.x * 16 + x, base_y + y, center.z * 16 + z);
|
||||
let Ok(models) = self.render.model_catalog.collect(BlockStateId(id), world)
|
||||
else {
|
||||
if catalog
|
||||
.models_at(BlockStateId(id), world, &mut models)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
}
|
||||
// ModelBlockRenderer: the first part decides smooth lighting
|
||||
// for all; light-emitting blocks are always lit flat.
|
||||
let Some((_, first)) = models.first() else {
|
||||
let Some(first) = models.first() else {
|
||||
continue;
|
||||
};
|
||||
let ao = self.smooth_lighting && state.emission == 0 && first.ambient_occlusion;
|
||||
@@ -780,7 +787,7 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
for (_, model) in &models {
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
continue;
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Solid quads grouped by the side they face, as Sodium's `ModelQuadFacing`
|
||||
//! does. A quad in the plane `x = p` facing +X is front-facing only while the
|
||||
//! eye is at `x > p`, and terrain culls back faces, so when the eye is on the
|
||||
//! back side of every quad of a group the renderer skips the group's draw:
|
||||
//! the GPU would have discarded all of it after shading its vertices. The
|
||||
//! picture is unchanged; the vertex work for those quads is gone.
|
||||
use super::TerrainVertex;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Groups in buffer order: the negative sides, quads not in an axis plane
|
||||
/// (rotated model elements, sloped fluid tops), then the positive sides.
|
||||
/// With the eye outside a section on every axis, one side per axis is
|
||||
/// visible, so the drawn groups form one to three runs.
|
||||
pub const GROUPS: usize = 7;
|
||||
const UNALIGNED: usize = 3;
|
||||
/// The group of a quad facing `-axis` (0 x, 1 y, 2 z) is `NEGATIVE[axis]`, of
|
||||
/// one facing `+axis` `GROUPS - 1 - NEGATIVE[axis]`.
|
||||
const NEGATIVE: [usize; 3] = [0, 2, 1];
|
||||
/// How far (blocks) the eye must be behind every plane of a group before
|
||||
/// the group is skipped: rounding on the GPU never makes such a quad
|
||||
/// front-facing.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
/// The solid quads' groups, filled by `group`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FaceGroups {
|
||||
/// Quads per group, in buffer order.
|
||||
pub quads: [u32; GROUPS],
|
||||
/// Per axis group, the plane nearest the side the group faces: the
|
||||
/// lowest plane of a positive group and the highest of a negative one.
|
||||
planes: [f32; GROUPS],
|
||||
}
|
||||
|
||||
impl Default for FaceGroups {
|
||||
/// Everything unaligned: always drawn.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quads: [0; GROUPS],
|
||||
planes: [0.0; GROUPS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceGroups {
|
||||
/// Group arbitrary-sized terrain quads, including distant LOD tiles.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> Self {
|
||||
group(solid)
|
||||
}
|
||||
|
||||
/// Whether group `g` can hold a front-facing quad for an eye at `eye`,
|
||||
/// relative to the section's minimum corner.
|
||||
pub fn visible(&self, g: usize, eye: Vec3) -> bool {
|
||||
if g == UNALIGNED {
|
||||
return true;
|
||||
}
|
||||
let (axis, positive) = axis(g);
|
||||
if positive {
|
||||
eye[axis] > self.planes[g] - MARGIN
|
||||
} else {
|
||||
eye[axis] < self.planes[g] + MARGIN
|
||||
}
|
||||
}
|
||||
/// The quad ranges (first quad, quad count) to draw for an eye at `eye`
|
||||
/// relative to the section's minimum corner, adjacent groups merged.
|
||||
pub fn ranges(&self, eye: Vec3, mut out: impl FnMut(u32, u32)) {
|
||||
let mut first = 0;
|
||||
let mut run: Option<(u32, u32)> = None;
|
||||
for g in 0..GROUPS {
|
||||
let quads = self.quads[g];
|
||||
if quads > 0 {
|
||||
if self.visible(g, eye) {
|
||||
run = Some(match run {
|
||||
Some((start, len)) => (start, len + quads),
|
||||
None => (first, quads),
|
||||
});
|
||||
} else if let Some((start, len)) = run.take() {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
first += quads;
|
||||
}
|
||||
if let Some((start, len)) = run {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis and sign of each axis group (`UNALIGNED`'s entry is unused).
|
||||
const AXES: [(usize, bool); GROUPS] = {
|
||||
let mut axes = [(0, false); GROUPS];
|
||||
let mut axis = 0;
|
||||
while axis < 3 {
|
||||
axes[NEGATIVE[axis]] = (axis, false);
|
||||
axes[GROUPS - 1 - NEGATIVE[axis]] = (axis, true);
|
||||
axis += 1;
|
||||
}
|
||||
axes
|
||||
};
|
||||
|
||||
/// The axis and sign of axis group `g`.
|
||||
fn axis(g: usize) -> (usize, bool) {
|
||||
debug_assert_ne!(g, UNALIGNED);
|
||||
AXES[g]
|
||||
}
|
||||
|
||||
/// The group of one quad, and its plane: an axis group only when all four
|
||||
/// corners lie exactly in one axis plane.
|
||||
fn facing(quad: &[TerrainVertex]) -> (usize, f32) {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0));
|
||||
for axis in 0..3 {
|
||||
let [a, b] = [(axis + 1) % 3, (axis + 2) % 3];
|
||||
let plane = quad[0].pos[axis];
|
||||
if normal[axis] != 0.0
|
||||
&& normal[a] == 0.0
|
||||
&& normal[b] == 0.0
|
||||
&& quad.iter().all(|v| v.pos[axis] == plane)
|
||||
{
|
||||
let negative = NEGATIVE[axis];
|
||||
let g = if normal[axis] > 0.0 {
|
||||
GROUPS - 1 - negative
|
||||
} else {
|
||||
negative
|
||||
};
|
||||
return (g, plane);
|
||||
}
|
||||
}
|
||||
(UNALIGNED, 0.0)
|
||||
}
|
||||
|
||||
/// Reorder `solid` by group, keeping the order of quads within a group (so
|
||||
/// coplanar quads such as a grass block's overlay still draw after their
|
||||
/// base), and return the groups.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> FaceGroups {
|
||||
let mut groups = FaceGroups::default();
|
||||
for (g, plane) in groups.planes.iter_mut().enumerate() {
|
||||
if g != UNALIGNED {
|
||||
*plane = if axis(g).1 { f32::MAX } else { f32::MIN };
|
||||
}
|
||||
}
|
||||
let faces: Vec<u8> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|quad| {
|
||||
let (g, plane) = facing(quad);
|
||||
groups.quads[g] += 1;
|
||||
if g != UNALIGNED {
|
||||
let nearest = &mut groups.planes[g];
|
||||
*nearest = if axis(g).1 {
|
||||
nearest.min(plane)
|
||||
} else {
|
||||
nearest.max(plane)
|
||||
};
|
||||
}
|
||||
g as u8
|
||||
})
|
||||
.collect();
|
||||
if faces.windows(2).all(|w| w[0] <= w[1]) {
|
||||
return groups;
|
||||
}
|
||||
let mut next = [0usize; GROUPS];
|
||||
let mut start = 0;
|
||||
for g in 0..GROUPS {
|
||||
next[g] = start;
|
||||
start += groups.quads[g] as usize;
|
||||
}
|
||||
let mut sorted = vec![solid[0]; solid.len()];
|
||||
for (quad, &g) in solid.as_chunks::<4>().0.iter().zip(&faces) {
|
||||
let at = next[g as usize] * 4;
|
||||
sorted[at..at + 4].copy_from_slice(quad);
|
||||
next[g as usize] += 1;
|
||||
}
|
||||
*solid = sorted;
|
||||
groups
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vertex(pos: [f32; 3], layer: u16) -> TerrainVertex {
|
||||
TerrainVertex::new(pos, [0.0; 2], layer, 0, 0)
|
||||
}
|
||||
/// A unit quad in the plane `axis = plane`, wound to face `sign`, tagged
|
||||
/// with `layer` so tests can follow it.
|
||||
fn quad(axis: usize, plane: f32, sign: f32, layer: u16) -> Vec<TerrainVertex> {
|
||||
let corner = |u: f32, v: f32| {
|
||||
let mut p = [0.0; 3];
|
||||
p[axis] = plane;
|
||||
p[(axis + 1) % 3] = u;
|
||||
p[(axis + 2) % 3] = v;
|
||||
vertex(p, layer)
|
||||
};
|
||||
let mut q = vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(1.0, 0.0),
|
||||
corner(1.0, 1.0),
|
||||
corner(0.0, 1.0),
|
||||
];
|
||||
if sign < 0.0 {
|
||||
q.reverse();
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_group_by_the_side_their_winding_faces() {
|
||||
for a in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let q = quad(a, 4.0, sign, 0);
|
||||
let (g, plane) = facing(&q);
|
||||
assert_eq!(axis(g), (a, sign > 0.0), "axis {a} sign {sign}");
|
||||
assert_eq!(plane, 4.0);
|
||||
let normal = (Vec3::from(q[1].pos) - Vec3::from(q[0].pos))
|
||||
.cross(Vec3::from(q[2].pos) - Vec3::from(q[0].pos));
|
||||
assert_eq!(normal[a].signum(), sign);
|
||||
}
|
||||
}
|
||||
// A cross plant's diagonal quad faces no axis.
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 0));
|
||||
assert_eq!(facing(&diagonal).0, UNALIGNED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_is_stable_and_skips_only_back_facing_groups() {
|
||||
// +X at x = 3 and x = 7 (layers 1, 2), -X at x = 5 (3), an unaligned
|
||||
// quad (4), a coplanar overlay on the x = 3 quad (5).
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 4));
|
||||
let mut solid = [
|
||||
quad(0, 3.0, 1.0, 1),
|
||||
quad(0, 5.0, -1.0, 3),
|
||||
diagonal.to_vec(),
|
||||
quad(0, 7.0, 1.0, 2),
|
||||
quad(0, 3.0, 1.0, 5),
|
||||
]
|
||||
.concat();
|
||||
let groups = group(&mut solid);
|
||||
let layers: Vec<u16> = solid.chunks(4).map(|q| q[0].layer).collect();
|
||||
assert_eq!(layers, [3, 4, 1, 2, 5]);
|
||||
let ranges = |eye: Vec3| {
|
||||
let mut out = Vec::new();
|
||||
groups.ranges(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
};
|
||||
// Between the planes: every group can face the eye.
|
||||
assert_eq!(ranges(Vec3::new(4.0, 8.0, 8.0)), [(0, 5)]);
|
||||
// Beyond x = 7: the -X quad at x = 5 faces away.
|
||||
assert_eq!(ranges(Vec3::new(20.0, 8.0, 8.0)), [(1, 4)]);
|
||||
// Before x = 3: every +X quad faces away.
|
||||
assert_eq!(ranges(Vec3::new(-10.0, 8.0, 8.0)), [(0, 2)]);
|
||||
// Within the margin of the nearest plane: still drawn.
|
||||
assert_eq!(ranges(Vec3::new(3.0, 8.0, 8.0)), [(0, 5)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! A mesh's solid quads sorted by the side they face, so that the renderer
|
||||
//! can leave out the quads turned away from the eye before the GPU shades
|
||||
//! their vertices.
|
||||
//!
|
||||
//! A quad in the plane `x = p` whose corners go counter-clockwise seen from
|
||||
//! +X faces +X. From an eye at `x < p` it is seen from behind, and at
|
||||
//! `x = p` edge on; the solid pipeline culls back faces, so it draws nothing
|
||||
//! from there. A bucket of such quads draws nothing at all while the eye is
|
||||
//! at or below the lowest of their planes, and likewise for the other five
|
||||
//! axis directions. The seventh bucket holds every other quad (sloped,
|
||||
//! rotated, degenerate, or whose two triangles wind opposite ways): it is
|
||||
//! drawn from everywhere.
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
/// common corner is then one run, or two.
|
||||
const BUCKETS: [Bucket; 7] = [
|
||||
Bucket::Side(0, true),
|
||||
Bucket::Side(2, true),
|
||||
Bucket::Other,
|
||||
Bucket::Side(1, true),
|
||||
Bucket::Side(2, false),
|
||||
Bucket::Side(0, false),
|
||||
Bucket::Side(1, false),
|
||||
];
|
||||
const OTHER: usize = 2;
|
||||
/// How far, in blocks, the eye must be behind a bucket's planes before the
|
||||
/// bucket is left out. The renderer computes both the eye and the section
|
||||
/// origins in `f32`, with errors far below this.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Bucket {
|
||||
/// Quads in a plane across `axis` facing its positive (`true`) or
|
||||
/// negative direction.
|
||||
Side(usize, bool),
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
|
||||
/// whether it can face the eye.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Sides {
|
||||
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
|
||||
ends: [u32; 7],
|
||||
/// Per bucket facing a positive direction, its lowest plane; facing a
|
||||
/// negative one, its highest (unused for the other bucket).
|
||||
planes: [f32; 7],
|
||||
}
|
||||
|
||||
impl Sides {
|
||||
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
|
||||
/// that were not sorted.
|
||||
pub fn unsorted(quads: u32) -> Self {
|
||||
let mut ends = [0; 7];
|
||||
ends[OTHER..].fill(quads);
|
||||
Self {
|
||||
ends,
|
||||
planes: [0.0; 7],
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[6]
|
||||
}
|
||||
|
||||
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
|
||||
/// (relative to the mesh's origin) can see the front of, nearest the
|
||||
/// start first. Empty buckets do not break a run.
|
||||
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
let mut run: Option<u32> = None;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = self.ends[i];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let seen = match *bucket {
|
||||
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
|
||||
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
|
||||
Bucket::Other => true,
|
||||
};
|
||||
match (seen, run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if let Some(first) = run {
|
||||
draw(first, start - first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket of a quad: the axis plane it lies in and the side its
|
||||
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
|
||||
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
let p = quad.map(|v| v.pos);
|
||||
for axis in 0..3 {
|
||||
if p.iter().any(|v| v[axis] != p[0][axis]) {
|
||||
continue;
|
||||
}
|
||||
// The normal's component along `axis`, twice each triangle's area.
|
||||
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
let area = |a: usize, b: usize, c: usize| {
|
||||
(p[b][u] - p[a][u]) * (p[c][w] - p[a][w]) - (p[b][w] - p[a][w]) * (p[c][u] - p[a][u])
|
||||
};
|
||||
let (first, second) = (area(0, 1, 2), area(0, 2, 3));
|
||||
let positive = first >= 0.0 && second >= 0.0;
|
||||
let negative = first <= 0.0 && second <= 0.0;
|
||||
if positive == negative {
|
||||
// Opposite windings, or no area at all.
|
||||
return OTHER;
|
||||
}
|
||||
return BUCKETS
|
||||
.iter()
|
||||
.position(|b| matches!(*b, Bucket::Side(a, s) if a == axis && s == positive))
|
||||
.expect("every side has a bucket");
|
||||
}
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let b = usize::from(b);
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
let p = quad[0].pos[axis];
|
||||
planes[b] = match (counts[b], positive) {
|
||||
(0, _) => p,
|
||||
(_, true) => planes[b].min(p),
|
||||
(_, false) => planes[b].max(p),
|
||||
};
|
||||
}
|
||||
counts[b] += 1;
|
||||
}
|
||||
let mut ends = [0u32; 7];
|
||||
let mut total = 0;
|
||||
for (end, count) in ends.iter_mut().zip(counts) {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let slot = &mut next[usize::from(b)];
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
|
||||
/// A unit block's six faces at `origin`, wound counter-clockwise seen
|
||||
/// from outside, as block models are.
|
||||
fn cube(origin: [f32; 3], layer: u16) -> Vec<TerrainVertex> {
|
||||
let [x, y, z] = origin;
|
||||
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
|
||||
[
|
||||
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]], // +X
|
||||
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]], // -X
|
||||
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]], // +Y
|
||||
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]], // -Y
|
||||
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]], // +Z
|
||||
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]], // -Z
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|q| quad(q, layer))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
sides.visible(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The quads of `solid` whose triangles face `eye`, by their layer.
|
||||
fn front_facing(solid: &[TerrainVertex], eye: [f32; 3]) -> Vec<u16> {
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
[[0, 1, 2], [0, 2, 3]].iter().any(|t| {
|
||||
let (e1, e2) = (sub(q[t[1]].pos, q[t[0]].pos), sub(q[t[2]].pos, q[t[0]].pos));
|
||||
let n = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let to_eye = sub(eye, q[t[0]].pos);
|
||||
n[0] * to_eye[0] + n[1] * to_eye[1] + n[2] * to_eye[2] > 0.0
|
||||
})
|
||||
})
|
||||
.map(|q| q[0].layer)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_faces_go_to_their_buckets_in_order() {
|
||||
let mut solid = cube([3.0, 4.0, 5.0], 0);
|
||||
for (i, v) in solid.iter_mut().enumerate() {
|
||||
v.layer = (i / 4) as u16;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
|
||||
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
|
||||
// +X, +Z, +Y, -Z, -X, -Y.
|
||||
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
|
||||
assert_eq!(sides.planes[0], 4.0); // +X at x = 4
|
||||
assert_eq!(sides.planes[5], 3.0); // -X at x = 3
|
||||
assert_eq!(sides.planes[3], 5.0); // +Y at y = 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drawn_runs_hold_every_quad_that_faces_the_eye() {
|
||||
// Blocks across a section, with a sloped quad and a bowtie.
|
||||
let mut solid = Vec::new();
|
||||
let mut layer = 0;
|
||||
for origin in [
|
||||
[0.0, 0.0, 0.0],
|
||||
[7.0, 3.0, 12.0],
|
||||
[15.0, 15.0, 15.0],
|
||||
[2.0, 9.0, 4.0],
|
||||
] {
|
||||
for q in cube(origin, 0).chunks(4) {
|
||||
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
|
||||
layer += 1;
|
||||
}
|
||||
}
|
||||
let slope = [
|
||||
[1.0, 1.0, 1.0],
|
||||
[2.0, 1.0, 1.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[1.0, 2.0, 2.0],
|
||||
];
|
||||
let bowtie = [
|
||||
[5.0, 1.0, 1.0],
|
||||
[5.0, 2.0, 2.0],
|
||||
[5.0, 1.0, 2.0],
|
||||
[5.0, 2.0, 1.0],
|
||||
];
|
||||
for corners in [slope, bowtie] {
|
||||
solid.extend(quad(corners, layer));
|
||||
layer += 1;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.quads(), u32::from(layer));
|
||||
let eyes = [-20.0f32, -0.5, 0.0, 3.5, 8.0, 15.99, 16.0, 30.0];
|
||||
for &x in &eyes {
|
||||
for &y in &eyes {
|
||||
for &z in &eyes {
|
||||
let eye = [x, y, z];
|
||||
let mut drawn = Vec::new();
|
||||
for (first, count) in runs(&sides, eye) {
|
||||
drawn.extend(
|
||||
solid[first as usize * 4..(first + count) as usize * 4]
|
||||
.iter()
|
||||
.step_by(4)
|
||||
.map(|v| v.layer),
|
||||
);
|
||||
}
|
||||
for layer in front_facing(&solid, eye) {
|
||||
assert!(drawn.contains(&layer), "{eye:?} misses quad {layer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_merge_across_hidden_and_empty_buckets() {
|
||||
let mut solid = cube([0.0; 3], 0);
|
||||
let sides = sort(&mut solid);
|
||||
// From above, +X and +Z: +X, +Z, (no other), +Y is one run.
|
||||
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
|
||||
// From below, -X and -Z: -Z, -X, -Y.
|
||||
assert_eq!(runs(&sides, [-8.0, -8.0, -8.0]), [(3, 3)]);
|
||||
// From above, -X and +Z: +Z, +Y, then -X.
|
||||
assert_eq!(runs(&sides, [-8.0, 8.0, 8.0]), [(1, 2), (4, 1)]);
|
||||
// Inside the block every face is seen from behind.
|
||||
assert!(runs(&sides, [0.5; 3]).is_empty());
|
||||
// Level with its top, the eye may see it: the margin keeps it.
|
||||
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_meshes_draw_everything() {
|
||||
let sides = Sides::unsorted(9);
|
||||
assert_eq!(sides.quads(), 9);
|
||||
assert_eq!(runs(&sides, [-100.0; 3]), [(0, 9)]);
|
||||
assert!(runs(&Sides::unsorted(0), [0.0; 3]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bucket_keeps_the_mesh_as_it_is() {
|
||||
let mut solid: Vec<_> = (0..3)
|
||||
.flat_map(|i| {
|
||||
quad(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
],
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let before = solid.clone();
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [3; 7]);
|
||||
assert_eq!(
|
||||
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -41,9 +41,12 @@ pub struct BakedModel {
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
choices: Vec<CompiledChoices>,
|
||||
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
|
||||
baked: BTreeMap<VariantKey, BakedModel>,
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
@@ -85,7 +88,7 @@ impl StateModels {
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<Self> {
|
||||
let mut choices = Vec::new();
|
||||
let mut states = BTreeMap::new();
|
||||
let mut states = vec![None; registry.len()];
|
||||
let mut variants = BTreeSet::new();
|
||||
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -178,7 +181,7 @@ impl StateModels {
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states.insert(id, (choices.len(), selected));
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
@@ -211,7 +214,8 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let mut baked = BTreeMap::new();
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
@@ -233,16 +237,22 @@ impl StateModels {
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
baked.insert(
|
||||
(name, turns, lock),
|
||||
BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
},
|
||||
);
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
states,
|
||||
@@ -260,26 +270,47 @@ impl StateModels {
|
||||
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
|
||||
/// state's first variant (see `CompiledChoices::particle_variant`).
|
||||
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
|
||||
let (choice, selected) = self.states.get(&state)?;
|
||||
let variant = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked.get(&key(variant))?.particle
|
||||
let (choice, selected) = self.state(state)?;
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
) -> Result<Vec<(&Variant, &BakedModel)>> {
|
||||
let (choice, selected) = self
|
||||
.states
|
||||
.get(&state)
|
||||
.ok_or("unbound gallery block state")?;
|
||||
let mut models = Vec::new();
|
||||
self.each_seeded(state, seed, |(variant, model)| {
|
||||
models.push((variant, model))
|
||||
})?;
|
||||
Ok(models)
|
||||
}
|
||||
/// The models a block draws at `position`, into `out` (cleared first):
|
||||
/// `collect` for the mesher, which reuses one list for every block.
|
||||
pub fn models_at<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
position: BlockPos,
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
mut f: impl FnMut(&'a (Variant, BakedModel)),
|
||||
) -> Result<()> {
|
||||
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut variants = Vec::new();
|
||||
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
|
||||
variants
|
||||
.into_iter()
|
||||
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
|
||||
.collect()
|
||||
self.choices[*choice].for_each(selected, &mut random, |&model| {
|
||||
f(&self.baked[model as usize]);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
|
||||
self.states.get(state.0 as usize)?.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// A client's copy of its own player (`Level.isClientSide`): only the
|
||||
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
|
||||
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
|
||||
pub client_side: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -465,6 +469,7 @@ impl PlayerMotion {
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
client_side: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -2860,6 +2865,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A client's own fall (multiplayer, a LAN guest, single player on its
|
||||
/// server thread) plays its landing but never hurts it: only the server
|
||||
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
|
||||
/// leaves the screen still.
|
||||
#[test]
|
||||
fn a_clients_own_fall_does_not_hurt_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for client_side in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
|
||||
player.client_side = client_side;
|
||||
player.fall_distance = 30.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
|
||||
assert!(w.player.ground.on_ground);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
// LivingEntity.causeFallDamage's sounds play on both sides.
|
||||
assert!(w.player.events.iter().any(|e| matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::BigFall,
|
||||
..
|
||||
})
|
||||
)));
|
||||
let hurt = w.player.health.health() < 20.0;
|
||||
assert_eq!(hurt, !client_side);
|
||||
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
|
||||
// The server's health then starts the client's hurt animation.
|
||||
if client_side {
|
||||
w.player.health.flash_health_change(20.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 0);
|
||||
w.player.health.flash_health_change(12.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -147,7 +147,12 @@ impl Session {
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Send a play packet; silently dropped once the connection is gone.
|
||||
/// Send a play packet; silently dropped once the connection is gone,
|
||||
/// and while the server has the connection back in configuration (a
|
||||
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
|
||||
/// the level before it acknowledges, so no play packet follows the
|
||||
/// acknowledgement; one that did would reach the server as a
|
||||
/// configuration packet, and Velocity drops such a client.
|
||||
pub fn send(&self, packet: play::Serverbound) {
|
||||
if self.debug
|
||||
&& !matches!(
|
||||
@@ -159,7 +164,11 @@ impl Session {
|
||||
{
|
||||
eprintln!("[net] -> {packet:?}");
|
||||
}
|
||||
if let Some(w) = self.writer.lock().unwrap().as_mut()
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
if self.configuring.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&packet.encode()).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
@@ -686,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();
|
||||
|
||||
@@ -787,13 +787,15 @@ impl Settings {
|
||||
}
|
||||
(displays, added)
|
||||
}
|
||||
/// `ServerRecipeBook.removeRecipes`: the displays removed.
|
||||
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> usize {
|
||||
let mut displays = 0;
|
||||
/// `ServerRecipeBook.removeRecipes`: the display ids removed, which its
|
||||
/// `ClientboundRecipeBookRemovePacket` lists (a transmute recipe has one
|
||||
/// per material count).
|
||||
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
|
||||
let mut displays = Vec::new();
|
||||
for id in ids {
|
||||
if self.recipes.remove(id) {
|
||||
self.to_be_displayed.remove(id);
|
||||
displays += index.displays(id);
|
||||
displays.extend(index.display_ids(id));
|
||||
}
|
||||
}
|
||||
displays
|
||||
|
||||
@@ -34,7 +34,21 @@ pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
|
||||
]
|
||||
}
|
||||
|
||||
type Job = (Vec<Request>, Vec<(&'static str, String)>);
|
||||
/// `HttpUtil.DownloadProgressListener`, on the channel a batch was given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Progress {
|
||||
/// `requestStart`: the batch's next request begins.
|
||||
RequestStart,
|
||||
/// `downloadStart`: the response came, with its Content-Length if it
|
||||
/// said one.
|
||||
DownloadStart(Option<u64>),
|
||||
/// `downloadedBytes`: what this request has written so far.
|
||||
Bytes(u64),
|
||||
/// `requestFinished`: whether it succeeded.
|
||||
RequestFinished(bool),
|
||||
}
|
||||
|
||||
type Job = (Vec<Request>, Vec<(&'static str, String)>, Sender<Progress>);
|
||||
|
||||
/// `DownloadQueue`: batches download in order, on one thread.
|
||||
pub struct Queue {
|
||||
@@ -50,16 +64,24 @@ impl Queue {
|
||||
std::thread::Builder::new()
|
||||
.name("download-queue".into())
|
||||
.spawn(move || {
|
||||
for (requests, headers) in todo {
|
||||
if finished.send(run(&dir, &requests, &headers)).is_err() {
|
||||
for (requests, headers, progress) in todo {
|
||||
if finished
|
||||
.send(run(&dir, &requests, &headers, &progress))
|
||||
.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));
|
||||
pub fn download(
|
||||
&self,
|
||||
requests: Vec<Request>,
|
||||
headers: Vec<(&'static str, String)>,
|
||||
progress: Sender<Progress>,
|
||||
) {
|
||||
let _ = self.jobs.send((requests, headers, progress));
|
||||
}
|
||||
/// The batches finished since the last call.
|
||||
pub fn finished(&self) -> Vec<Batch> {
|
||||
@@ -68,11 +90,18 @@ impl Queue {
|
||||
}
|
||||
|
||||
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
|
||||
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
|
||||
fn run(
|
||||
dir: &Path,
|
||||
requests: &[Request],
|
||||
headers: &[(&'static str, String)],
|
||||
progress: &Sender<Progress>,
|
||||
) -> 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);
|
||||
let _ = progress.send(Progress::RequestStart);
|
||||
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE, progress);
|
||||
let _ = progress.send(Progress::RequestFinished(result.is_ok()));
|
||||
// DownloadQueue.LogEntry, its fields in the codec's order.
|
||||
let mut entry = vec![
|
||||
("id", serde_json::Value::from(id)),
|
||||
@@ -165,6 +194,7 @@ pub fn download_file(
|
||||
request: &Request,
|
||||
headers: &[(&'static str, String)],
|
||||
max_size: u64,
|
||||
progress: &Sender<Progress>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
|
||||
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
|
||||
@@ -198,6 +228,7 @@ pub fn download_file(
|
||||
.header("Content-Length")
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let _ = progress.send(Progress::DownloadStart(size));
|
||||
if let Some(size) = size
|
||||
&& size > max_size
|
||||
{
|
||||
@@ -206,9 +237,13 @@ pub fn download_file(
|
||||
));
|
||||
}
|
||||
let mut input = response.into_reader();
|
||||
let mut report = |written: u64| {
|
||||
let _ = progress.send(Progress::Bytes(written));
|
||||
};
|
||||
match (&target, &request.sha1) {
|
||||
(Some(target), Some(sha1)) => {
|
||||
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
|
||||
let actual = save_hashed(&mut input, target, max_size, &mut report)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if &actual != sha1 {
|
||||
return Err(format!(
|
||||
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
|
||||
@@ -218,7 +253,7 @@ pub fn download_file(
|
||||
}
|
||||
_ => {
|
||||
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
|
||||
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
|
||||
let result = save_hashed(&mut input, &tmp, max_size, &mut report).and_then(|actual| {
|
||||
let path = dir.join(&actual);
|
||||
if matches(&path, &actual) {
|
||||
touch(&path);
|
||||
@@ -237,7 +272,12 @@ fn rand_suffix() -> u64 {
|
||||
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> {
|
||||
fn save_hashed(
|
||||
input: &mut dyn Read,
|
||||
path: &Path,
|
||||
max_size: u64,
|
||||
progress: &mut dyn FnMut(u64),
|
||||
) -> io::Result<String> {
|
||||
let mut out = fs::File::create(path)?;
|
||||
let mut hasher = Sha1::new();
|
||||
let mut buffer = [0u8; 8196];
|
||||
@@ -248,6 +288,7 @@ fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<S
|
||||
break;
|
||||
}
|
||||
read_so_far += n as u64;
|
||||
progress(read_so_far);
|
||||
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})"
|
||||
@@ -366,7 +407,14 @@ mod tests {
|
||||
url,
|
||||
sha1: Some(sha1(&body)),
|
||||
};
|
||||
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
|
||||
let path = download_file(
|
||||
&dir,
|
||||
&request,
|
||||
&headers("Alex", 0xab),
|
||||
MAX_SIZE,
|
||||
&channel().0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, dir.join(sha1(&body)));
|
||||
assert_eq!(fs::read(&path).unwrap(), body);
|
||||
let sent = server.join().unwrap();
|
||||
@@ -377,7 +425,10 @@ mod tests {
|
||||
url: "http://127.0.0.1:9/gone.zip".into(),
|
||||
..request
|
||||
};
|
||||
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
|
||||
assert_eq!(
|
||||
download_file(&dir, &cached, &[], MAX_SIZE, &channel().0).unwrap(),
|
||||
path
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -390,7 +441,7 @@ mod tests {
|
||||
url,
|
||||
sha1: Some("0".repeat(40)),
|
||||
};
|
||||
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
|
||||
let err = download_file(&dir, &wrong, &[], MAX_SIZE, &channel().0).unwrap_err();
|
||||
assert!(err.contains("did not match requested"), "{err}");
|
||||
server.join().unwrap();
|
||||
let dead = Request {
|
||||
@@ -398,7 +449,7 @@ mod tests {
|
||||
url: "http://127.0.0.1:9/pack.zip".into(),
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
|
||||
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE, &channel().0).is_err());
|
||||
assert!(!dir.join("dead").exists());
|
||||
let (url, server) = serve("200 OK", vec![0; 100]);
|
||||
let large = Request {
|
||||
@@ -406,7 +457,7 @@ mod tests {
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
let err = download_file(&dir, &large, &[], 10).unwrap_err();
|
||||
let err = download_file(&dir, &large, &[], 10, &channel().0).unwrap_err();
|
||||
assert!(err.contains("bigger than maximum"), "{err}");
|
||||
let _ = server.join();
|
||||
let (url, server) = serve("404 Not Found", Vec::new());
|
||||
@@ -415,7 +466,7 @@ mod tests {
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
|
||||
assert!(download_file(&dir, &missing, &[], MAX_SIZE, &channel().0).is_err());
|
||||
server.join().unwrap();
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -425,6 +476,7 @@ mod tests {
|
||||
let dir = scratch();
|
||||
let (url, server) = serve("200 OK", b"zip".to_vec());
|
||||
let queue = Queue::open(dir.clone()).unwrap();
|
||||
let (progress, events) = channel();
|
||||
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
|
||||
queue.download(
|
||||
vec![Request {
|
||||
@@ -433,6 +485,7 @@ mod tests {
|
||||
sha1: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
progress,
|
||||
);
|
||||
let batch = loop {
|
||||
if let Some(b) = queue.finished().pop() {
|
||||
@@ -445,6 +498,16 @@ mod tests {
|
||||
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
|
||||
.join(sha1(b"zip"));
|
||||
assert_eq!(batch.downloaded, [(id, file)]);
|
||||
// HttpUtil.downloadFile's listener calls, in order.
|
||||
assert_eq!(
|
||||
events.iter().collect::<Vec<_>>(),
|
||||
[
|
||||
Progress::RequestStart,
|
||||
Progress::DownloadStart(Some(3)),
|
||||
Progress::Bytes(3),
|
||||
Progress::RequestFinished(true),
|
||||
]
|
||||
);
|
||||
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");
|
||||
|
||||
@@ -504,7 +504,8 @@ impl Streaming {
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
let mut mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
|
||||
//! and ClientboundSetEntityDataPacket's own stream codec.
|
||||
use crate::text::Text;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `TextDisplay.Align`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// `Display` and `TextDisplay`'s synced data (ids 8 to 27); the default is
|
||||
/// what their `defineSynchedData` starts with.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TextDisplayData {
|
||||
pub interpolation_delay: i32,
|
||||
pub interpolation_duration: i32,
|
||||
pub teleport_duration: i32,
|
||||
pub translation: [f32; 3],
|
||||
pub scale: [f32; 3],
|
||||
pub left_rotation: [f32; 4],
|
||||
pub right_rotation: [f32; 4],
|
||||
pub billboard: u8,
|
||||
pub brightness: i32,
|
||||
pub view_range: f32,
|
||||
pub shadow_radius: f32,
|
||||
pub shadow_strength: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub glow_color: i32,
|
||||
/// Shared: a frame's interpolated copy of the data must not copy it.
|
||||
#[serde(skip)]
|
||||
pub text: Arc<Text>,
|
||||
pub line_width: i32,
|
||||
pub background: i32,
|
||||
pub opacity: i8,
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
impl Default for TextDisplayData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interpolation_delay: 0,
|
||||
interpolation_duration: 0,
|
||||
teleport_duration: 0,
|
||||
translation: [0.0; 3],
|
||||
scale: [1.0; 3],
|
||||
left_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
right_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
billboard: 0,
|
||||
brightness: -1,
|
||||
view_range: 1.0,
|
||||
shadow_radius: 0.0,
|
||||
shadow_strength: 1.0,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
glow_color: -1,
|
||||
text: Arc::new(Text::empty()),
|
||||
line_width: 200,
|
||||
background: 0x4000_0000,
|
||||
opacity: -1,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextDisplayData {
|
||||
/// DisplayRenderer's billboard orientation followed by Transformation's T*L*S*R.
|
||||
pub fn matrix(&self, entity: [f32; 2], camera: [f32; 2]) -> glam::Mat4 {
|
||||
let [yaw, pitch] = match self.billboard {
|
||||
1 => [camera[0] - 180.0, entity[1]],
|
||||
2 => [entity[0], -camera[1]],
|
||||
3 => [camera[0] - 180.0, -camera[1]],
|
||||
_ => entity,
|
||||
};
|
||||
let orientation = glam::Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
-yaw * 0.017_453_292,
|
||||
pitch * 0.017_453_292,
|
||||
0.0,
|
||||
);
|
||||
glam::Mat4::from_quat(orientation)
|
||||
* glam::Mat4::from_translation(self.translation.into())
|
||||
* glam::Mat4::from_quat(glam::Quat::from_array(self.left_rotation))
|
||||
* glam::Mat4::from_scale(self.scale.into())
|
||||
* glam::Mat4::from_quat(glam::Quat::from_array(self.right_rotation))
|
||||
}
|
||||
|
||||
pub fn blend(&self, from: &Self, t: f32) -> Self {
|
||||
let mut out = self.clone();
|
||||
for i in 0..3 {
|
||||
out.translation[i] =
|
||||
from.translation[i] + t * (self.translation[i] - from.translation[i]);
|
||||
out.scale[i] = from.scale[i] + t * (self.scale[i] - from.scale[i]);
|
||||
}
|
||||
out.left_rotation = glam::Quat::from_array(from.left_rotation)
|
||||
.slerp(glam::Quat::from_array(self.left_rotation), t)
|
||||
.to_array();
|
||||
out.right_rotation = glam::Quat::from_array(from.right_rotation)
|
||||
.slerp(glam::Quat::from_array(self.right_rotation), t)
|
||||
.to_array();
|
||||
let int = |a: i32, b: i32| a + (t * (b as f32 - a as f32)).floor() as i32;
|
||||
out.opacity = int(i32::from(from.opacity), i32::from(self.opacity)) as i8;
|
||||
out.background = (0..4).fold(0, |color, channel| {
|
||||
color
|
||||
| (int(
|
||||
(from.background as u32 >> (channel * 8) & 255) as i32,
|
||||
(self.background as u32 >> (channel * 8) & 255) as i32,
|
||||
) << (channel * 8))
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Omitted fields retain their previous values.
|
||||
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
|
||||
for (id, _, value) in values {
|
||||
match (id, value) {
|
||||
(8, Value::Int(v)) => self.interpolation_delay = *v,
|
||||
(9, Value::Int(v)) => self.interpolation_duration = *v,
|
||||
(10, Value::Int(v)) => self.teleport_duration = *v,
|
||||
(11, Value::Floats(v)) if v.len() == 3 => self.translation.copy_from_slice(v),
|
||||
(12, Value::Floats(v)) if v.len() == 3 => self.scale.copy_from_slice(v),
|
||||
(13, Value::Floats(v)) if v.len() == 4 => self.left_rotation.copy_from_slice(v),
|
||||
(14, Value::Floats(v)) if v.len() == 4 => self.right_rotation.copy_from_slice(v),
|
||||
(15, Value::Byte(v)) => self.billboard = *v as u8,
|
||||
(16, Value::Int(v)) => self.brightness = *v,
|
||||
(17, Value::Float(v)) => self.view_range = *v,
|
||||
(18, Value::Float(v)) => self.shadow_radius = *v,
|
||||
(19, Value::Float(v)) => self.shadow_strength = *v,
|
||||
(20, Value::Float(v)) => self.width = *v,
|
||||
(21, Value::Float(v)) => self.height = *v,
|
||||
(22, Value::Int(v)) => self.glow_color = *v,
|
||||
(23, Value::Text(v)) => self.text = Arc::new(crate::entity_nbt::name_text(v)),
|
||||
(24, Value::Int(v)) => self.line_width = *v,
|
||||
(25, Value::Int(v)) => self.background = *v,
|
||||
(26, Value::Byte(v)) => self.opacity = *v,
|
||||
(27, Value::Byte(v)) => self.flags = *v as u8,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TextDisplay.getAlign`: the left flag wins over the right one.
|
||||
pub fn align(&self) -> Align {
|
||||
if self.flags & 8 != 0 {
|
||||
Align::Left
|
||||
} else if self.flags & 16 != 0 {
|
||||
Align::Right
|
||||
} else {
|
||||
Align::Center
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn partial_updates_retain_values_and_bad_vectors_do_not_panic() {
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&[(24, 1, Value::Int(80)), (27, 0, Value::Byte(24))]);
|
||||
d.apply(&[(11, 0, Value::Floats(vec![1.0]))]);
|
||||
assert_eq!(d.line_width, 80);
|
||||
assert_eq!(d.align(), Align::Left);
|
||||
assert_eq!(d.translation, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -1150,7 +1150,7 @@ impl Title {
|
||||
+ 30.0;
|
||||
// Toast.xPos: in from the right edge by the visible portion.
|
||||
let x = w - width * slide.portion;
|
||||
toast_background(atlas, batch, x, width);
|
||||
crate::toasts::background(atlas, batch, x, 0.0, width);
|
||||
batch.text(atlas, &title, x + 18.0, 7.0, rgba(255, 255, 0, 255), false);
|
||||
batch.text(atlas, &message, x + 18.0, 19.0, u32::MAX, false);
|
||||
}
|
||||
@@ -1553,36 +1553,6 @@ impl Title {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
|
||||
fn toast_background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, width: f32) {
|
||||
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
|
||||
return;
|
||||
};
|
||||
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
|
||||
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
|
||||
for col in 0..3 {
|
||||
for (sy, sh, y, h) in [
|
||||
(0.0, 28.0, 0.0, 28.0),
|
||||
(rect.h as f32 - 4.0, 4.0, 28.0, 4.0),
|
||||
] {
|
||||
let mut piece = rect;
|
||||
let du = (rect.u1 - rect.u0) / rect.w as f32;
|
||||
let dv = (rect.v1 - rect.v0) / rect.h as f32;
|
||||
piece.u0 = rect.u0 + source_x[col] * du;
|
||||
piece.u1 = rect.u0 + source_x[col + 1] * du;
|
||||
piece.v0 = rect.v0 + sy * dv;
|
||||
piece.v1 = rect.v0 + (sy + sh) * dv;
|
||||
batch.rect_uv(
|
||||
target_x[col],
|
||||
y,
|
||||
target_x[col + 1] - target_x[col],
|
||||
h,
|
||||
piece,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
|
||||
batch.text(
|
||||
atlas,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `ToastManager`'s slide, shared by the game's toasts and the title
|
||||
//! screens' system toasts.
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use std::time::Instant;
|
||||
|
||||
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
|
||||
@@ -56,3 +57,104 @@ impl Slide {
|
||||
self.update(origin.elapsed().as_millis() as i64, wants_show)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SystemToast`: a yellow title over a white message (or the title alone,
|
||||
/// lower down) on the system background. It stays until its display time
|
||||
/// has passed since its last change, or until it is hidden.
|
||||
pub struct SystemToast {
|
||||
pub slide: Slide,
|
||||
title: String,
|
||||
message: Option<String>,
|
||||
/// `changed`: the next update takes its time as `lastChanged`.
|
||||
changed: bool,
|
||||
last_changed: i64,
|
||||
force_hide: bool,
|
||||
}
|
||||
impl SystemToast {
|
||||
/// `SystemToastId.displayTime`'s default, in milliseconds.
|
||||
const DISPLAY_TIME: f64 = 5000.0;
|
||||
pub fn new(title: String, message: Option<String>) -> Self {
|
||||
Self {
|
||||
slide: Slide::default(),
|
||||
title,
|
||||
message,
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
force_hide: false,
|
||||
}
|
||||
}
|
||||
/// `reset`: new text, and the display time starts again.
|
||||
pub fn reset(&mut self, title: String, message: Option<String>) {
|
||||
self.title = title;
|
||||
self.message = message;
|
||||
self.changed = true;
|
||||
}
|
||||
pub fn force_hide(&mut self) {
|
||||
self.force_hide = true;
|
||||
}
|
||||
/// `ToastInstance.update` with `SystemToast.update`, the display time
|
||||
/// scaled by the Notification Time option: the visibility it switched
|
||||
/// to (the sound to play), and whether it has slid out.
|
||||
pub fn update(&mut self, multiplier: f64) -> (Option<bool>, bool) {
|
||||
self.slide.update_now(|fully| {
|
||||
if std::mem::take(&mut self.changed) {
|
||||
self.last_changed = fully;
|
||||
}
|
||||
!self.force_hide
|
||||
&& ((fully - self.last_changed) as f64) < Self::DISPLAY_TIME * multiplier
|
||||
})
|
||||
}
|
||||
/// `extractRenderState` at `y`, in from the right edge by the visible
|
||||
/// portion (`Toast.xPos`).
|
||||
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, y: f32) {
|
||||
let width = std::iter::once(&self.title)
|
||||
.chain(&self.message)
|
||||
.map(|line| HudBatch::text_width(atlas, line))
|
||||
.fold(0.0, f32::max)
|
||||
.ceil()
|
||||
.max(160.0)
|
||||
+ 30.0;
|
||||
let x = batch.width - width * self.slide.portion;
|
||||
background(atlas, batch, x, y, width);
|
||||
let yellow = crate::hud::rgba(255, 255, 0, 255);
|
||||
match &self.message {
|
||||
Some(message) => {
|
||||
batch.text(atlas, &self.title, x + 18.0, y + 7.0, yellow, false);
|
||||
batch.text(atlas, message, x + 18.0, y + 19.0, u32::MAX, false);
|
||||
}
|
||||
None => {
|
||||
batch.text(atlas, &self.title, x + 18.0, y + 12.0, yellow, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
|
||||
pub fn background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, y: f32, width: f32) {
|
||||
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
|
||||
return;
|
||||
};
|
||||
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
|
||||
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
|
||||
for col in 0..3 {
|
||||
for (sy, sh, y, h) in [
|
||||
(0.0, 28.0, y, 28.0),
|
||||
(rect.h as f32 - 4.0, 4.0, y + 28.0, 4.0),
|
||||
] {
|
||||
let mut piece = rect;
|
||||
let du = (rect.u1 - rect.u0) / rect.w as f32;
|
||||
let dv = (rect.v1 - rect.v0) / rect.h as f32;
|
||||
piece.u0 = rect.u0 + source_x[col] * du;
|
||||
piece.u1 = rect.u0 + source_x[col + 1] * du;
|
||||
piece.v0 = rect.v0 + sy * dv;
|
||||
piece.v1 = rect.v0 + (sy + sh) * dv;
|
||||
batch.rect_uv(
|
||||
target_x[col],
|
||||
y,
|
||||
target_x[col + 1] - target_x[col],
|
||||
h,
|
||||
piece,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,9 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
let fresh = mesher.mesh(&borrow(®ion), sy);
|
||||
// The workers sort solid quads by side, as uploads expect.
|
||||
let mut fresh = mesher.mesh(&borrow(®ion), sy);
|
||||
fresh.sort_solid();
|
||||
let bytes = |m: &SectionMesh| {
|
||||
[
|
||||
bytemuck::cast_slice::<_, u8>(&m.solid).to_vec(),
|
||||
|
||||
@@ -148,10 +148,11 @@ impl PerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.add_volume(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
self.add_volume::<true>(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
}
|
||||
|
||||
fn add_volume(
|
||||
/// `BATCH` = false evaluates one Y at a time: the reference the batched loop is tested against.
|
||||
fn add_volume<const BATCH: bool>(
|
||||
&self,
|
||||
buffer: &mut [f32],
|
||||
volume: DensityVolume,
|
||||
@@ -207,7 +208,10 @@ impl PerlinNoise {
|
||||
let mut previous_y = i32::MIN;
|
||||
let mut xz_dots = [0.0_f32; 8];
|
||||
let mut y_gradients = [0.0_f32; 8];
|
||||
for (sample, out) in ys.iter().zip(&mut buffer[index..index + size_y]) {
|
||||
let column = &mut buffer[index..index + size_y];
|
||||
let mut y = 0;
|
||||
while y < size_y {
|
||||
let sample = &ys[y];
|
||||
if previous_y != sample.cell {
|
||||
let gradients = self.gradients_at(p, q, sample.cell, cell_z);
|
||||
for i in 0..8 {
|
||||
@@ -217,11 +221,31 @@ impl PerlinNoise {
|
||||
}
|
||||
previous_y = sample.cell;
|
||||
}
|
||||
// Four Ys in one cell share the corners: side by side, LLVM computes them
|
||||
// as vectors. Cell boundaries and the column's tail go one at a time.
|
||||
if BATCH
|
||||
&& y + 4 <= size_y
|
||||
&& ys[y + 1..y + 4].iter().all(|s| s.cell == sample.cell)
|
||||
{
|
||||
add_four(
|
||||
&mut column[y..y + 4],
|
||||
&ys[y..y + 4],
|
||||
xz_dots,
|
||||
y_gradients,
|
||||
fade_x,
|
||||
fade_z,
|
||||
amplitude,
|
||||
);
|
||||
y += 4;
|
||||
continue;
|
||||
}
|
||||
let out = &mut column[y];
|
||||
let dy = sample.offset;
|
||||
let values = std::array::from_fn(|i| {
|
||||
xz_dots[i] + y_gradients[i] * (dy - ((i >> 1) & 1) as f32)
|
||||
});
|
||||
*out += amplitude * lerp3([fade_x, sample.fade, fade_z], values);
|
||||
y += 1;
|
||||
}
|
||||
index += size_y;
|
||||
}
|
||||
@@ -293,7 +317,7 @@ impl SmearedPerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.base.add_volume(
|
||||
self.base.add_volume::<true>(
|
||||
buffer,
|
||||
volume,
|
||||
xz_scale,
|
||||
@@ -312,3 +336,97 @@ fn compute_fudge_y(input_y: f64, relative_y: f64, scale: f64) -> f64 {
|
||||
};
|
||||
f64::from(floor_i32(limited_y / scale + f64::from(1e-7_f32))) * scale
|
||||
}
|
||||
|
||||
/// Adds four Y samples of one cell, laid out lane by lane so LLVM vectorizes them (no intrinsics,
|
||||
/// no target feature). Each lane does the one-at-a-time loop's operations in the same order.
|
||||
#[inline]
|
||||
fn add_four(
|
||||
out: &mut [f32],
|
||||
ys: &[YSample],
|
||||
dots: [f32; 8],
|
||||
gradients: [f32; 8],
|
||||
fade_x: f32,
|
||||
fade_z: f32,
|
||||
amplitude: f32,
|
||||
) {
|
||||
let dy: [f32; 4] = std::array::from_fn(|lane| ys[lane].offset);
|
||||
let fy: [f32; 4] = std::array::from_fn(|lane| ys[lane].fade);
|
||||
let values: [[f32; 4]; 8] = std::array::from_fn(|i| {
|
||||
std::array::from_fn(|lane| dots[i] + gradients[i] * (dy[lane] - ((i >> 1) & 1) as f32))
|
||||
});
|
||||
for lane in 0..4 {
|
||||
out[lane] += amplitude * lerp3([fade_x, fy[lane], fade_z], values.map(|v| v[lane]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn batched_volume_is_bit_identical() {
|
||||
let scales = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.001,
|
||||
-0.001,
|
||||
0.125,
|
||||
-0.5,
|
||||
1.0,
|
||||
4.0,
|
||||
1e100,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NAN,
|
||||
];
|
||||
let amplitudes = [
|
||||
0.0,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NAN,
|
||||
f32::from_bits(1),
|
||||
];
|
||||
for seed in [0, -1, 1234, i64::MIN] {
|
||||
let noise = PerlinNoise::new(&mut LegacyRandom::new(seed));
|
||||
for height in [1, 2, 3, 4, 5, 7, 8, 9, 31, 64, 65, 97] {
|
||||
for (case, &scale) in scales.iter().enumerate() {
|
||||
let minimum = [
|
||||
[-17, -64, 49],
|
||||
[i32::MAX, i32::MIN, -16777216],
|
||||
[16777216, 33554431, -33554432],
|
||||
][case % 3];
|
||||
let volume = DensityVolume::new(
|
||||
[2, height, 2],
|
||||
minimum,
|
||||
[4, if case % 3 == 0 { i32::MAX } else { 1 }, 16],
|
||||
)
|
||||
.unwrap();
|
||||
for fudge in [None, Some(0.125), Some(-0.25), Some(0.0), Some(f64::NAN)] {
|
||||
let initial = [0.0, -0.0, 1.5, f32::from_bits(1)];
|
||||
let mut a: Vec<_> =
|
||||
(0..volume.size() + 3).map(|i| initial[i % 4]).collect();
|
||||
let mut b = a.clone();
|
||||
let amplitude = amplitudes[case % amplitudes.len()];
|
||||
noise
|
||||
.add_volume::<false>(&mut a, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
noise
|
||||
.add_volume::<true>(&mut b, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
for (i, (a, b)) in a.iter().zip(&b).enumerate() {
|
||||
assert_eq!(
|
||||
a.to_bits(),
|
||||
b.to_bits(),
|
||||
"seed={seed} height={height} scale={scale} fudge={fudge:?} index={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ wasmtime = { version = "36.0.18", default-features = false, features = ["runtime
|
||||
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
|
||||
wit-component = { version = "0.239", optional = true }
|
||||
wit-parser = { version = "0.239", optional = true }
|
||||
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
minisign-verify = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
@@ -24,6 +27,8 @@ wit-parser = { version = "0.239", optional = true }
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
|
||||
index = ["dep:sha2", "dep:minisign-verify"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! 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()),
|
||||
};
|
||||
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
));
|
||||
}
|
||||
inspect(&project.kind, path)?.matches(project, version)?;
|
||||
Ok(trust)
|
||||
}
|
||||
|
||||
/// What a file says about itself, read by its kind's own check
|
||||
/// ([`inspect`]). The registry's CI writes these into the index; the
|
||||
/// launcher compares them with the index before it installs.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
/// A mod's or a modpack's manifest id; None for a resource pack.
|
||||
pub id: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// A mod's API line, a modpack's format, a resource pack's pack format.
|
||||
pub api: String,
|
||||
pub side: Option<Side>,
|
||||
/// In the manifest's order.
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The Minecraft version, when the file names one (a modpack does).
|
||||
pub game: Option<String>,
|
||||
pub requires: Vec<Requirement>,
|
||||
}
|
||||
|
||||
impl Facts {
|
||||
/// Whether the file is the one `version` of `project` describes.
|
||||
pub fn matches(&self, project: &Project, version: &Version) -> Result<(), String> {
|
||||
let (mut caps, mut listed) = (self.capabilities.clone(), version.capabilities.clone());
|
||||
caps.sort();
|
||||
listed.sort();
|
||||
let differs = self.id.as_ref().is_some_and(|id| *id != project.id)
|
||||
|| self.version.as_ref().is_some_and(|v| *v != version.version)
|
||||
|| self.api != version.api
|
||||
|| self.side != version.side
|
||||
|| caps != listed
|
||||
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|
||||
|| self.requires != version.requires;
|
||||
if !differs {
|
||||
return Ok(());
|
||||
}
|
||||
Err(match project.kind {
|
||||
Kind::Mod => format!(
|
||||
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
|
||||
self.id.as_deref().unwrap_or("?"),
|
||||
self.version.as_deref().unwrap_or("?"),
|
||||
self.api,
|
||||
self.side,
|
||||
names(&self.capabilities)
|
||||
),
|
||||
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
|
||||
_ => format!("its pack format {} isn't what the index lists", self.api),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and checks a file as its kind: a mod's manifest and content, a
|
||||
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
|
||||
/// paths. Shader packs and kinds this build doesn't know are refused: their
|
||||
/// checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
crate::Content::read(path, &m)?;
|
||||
Ok(Facts {
|
||||
capabilities: m.capabilities,
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
api: m.api,
|
||||
side: Some(m.side),
|
||||
..Facts::default()
|
||||
})
|
||||
}
|
||||
Kind::Modpack => {
|
||||
let m = Modpack::read(path)?.manifest;
|
||||
Ok(Facts {
|
||||
requires: m.mods.iter().map(modpack::PackMod::requirement).collect(),
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
api: m.format.to_string(),
|
||||
game: Some(m.game),
|
||||
..Facts::default()
|
||||
})
|
||||
}
|
||||
Kind::ResourcePack => Ok(Facts {
|
||||
api: check_resource_pack(path)?,
|
||||
..Facts::default()
|
||||
}),
|
||||
Kind::Shader => Err(
|
||||
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
|
||||
),
|
||||
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
|
||||
/// object, and only plain paths. Returns its pack format (`pack_format`, or
|
||||
/// `min_format` in newer packs), empty when it names none.
|
||||
fn check_resource_pack(path: &Path) -> Result<String, 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}"))?;
|
||||
let pack = &json["pack"];
|
||||
if !pack.is_object() {
|
||||
return Err("pack.mcmeta: no \"pack\" object".into());
|
||||
}
|
||||
let format = [&pack["pack_format"], &pack["min_format"]]
|
||||
.into_iter()
|
||||
.find(|f| !f.is_null())
|
||||
.map(|f| match f {
|
||||
serde_json::Value::Array(parts) => parts
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("."),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// The kinds the registry takes submissions for. Shader packs wait for
|
||||
/// Horizon's own format; other kinds for theirs.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
|
||||
Kind::Shader => Err(
|
||||
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
|
||||
),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(caps: &[Capability]) -> String {
|
||||
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
pub mod registry;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,415 @@
|
||||
//! The registry repository (`ludovic111/horizon-mods`, docs/api/mod-index.md):
|
||||
//! one `mods/<id>.toml` per project, written by the submission form or by
|
||||
//! hand and reviewed in a pull request, plus `revoked.toml`. Its CI turns
|
||||
//! them into `index.json` with [`build`], reading every listed file, and
|
||||
//! checks a pull request's changes with [`review`].
|
||||
//!
|
||||
//! What an entry can't say for itself (a mod's API, side and capabilities,
|
||||
//! a modpack's mods, a file's size) is read from the file, never typed.
|
||||
pub use super::open;
|
||||
use super::{File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The folder of entries in the registry repository.
|
||||
pub const ENTRIES: &str = "mods";
|
||||
/// The list of files taken down.
|
||||
pub const REVOKED: &str = "revoked.toml";
|
||||
/// Ids the site uses for its own pages (`/mods/submit`).
|
||||
const RESERVED: [&str; 1] = ["submit"];
|
||||
/// The biggest icon, a PNG in `icons/`.
|
||||
const MAX_ICON: u64 = 256 << 10;
|
||||
|
||||
/// `mods/<id>.toml`.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// GitHub logins allowed to publish updates.
|
||||
pub owners: Vec<String>,
|
||||
pub license: String,
|
||||
pub source: String,
|
||||
/// `icons/<id>.png` in the registry repository, or an `https://` address.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// The author's minisign public key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Newest first.
|
||||
pub versions: Vec<EntryVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EntryVersion {
|
||||
pub version: String,
|
||||
/// `YYYY-MM-DD`.
|
||||
pub published: String,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// The name players download, with the kind's extension.
|
||||
pub file: String,
|
||||
/// Lowercase hex: pins the reviewed file.
|
||||
pub sha256: String,
|
||||
/// The `.minisig` text, when the project has a key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub changelog: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RevokedFile {
|
||||
#[serde(default)]
|
||||
revoked: Vec<Revoked>,
|
||||
}
|
||||
|
||||
/// Where [`build`] reads and what it writes into the index.
|
||||
pub struct Sources<'a> {
|
||||
/// A checkout of the registry repository.
|
||||
pub registry: &'a Path,
|
||||
/// Every listed file, named by its SHA-256 (CI downloads them there).
|
||||
pub files: &'a Path,
|
||||
/// Where the files are published: `<files>/<id>-<version>/<file>`
|
||||
/// ([`super::FILE_HOST`]; `file:///` for a local test registry).
|
||||
pub release: &'a str,
|
||||
/// Where the registry's site is (its icons): `https://.../horizon-mods/`.
|
||||
pub site: &'a str,
|
||||
/// RFC 3339.
|
||||
pub generated: String,
|
||||
}
|
||||
|
||||
/// The release tag a version's file is published under.
|
||||
pub fn tag(id: &str, version: &str) -> String {
|
||||
format!("{id}-{version}")
|
||||
}
|
||||
|
||||
/// Reads `mods/*.toml` and `revoked.toml`: every problem found, not only the
|
||||
/// first, so a pull request's check lists them all.
|
||||
pub fn load(registry: &Path) -> Result<(Vec<Entry>, Vec<Revoked>), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
let dir = registry.join(ENTRIES);
|
||||
let mut paths: Vec<PathBuf> = match std::fs::read_dir(&dir) {
|
||||
Ok(read) => read.filter_map(|e| e.ok().map(|e| e.path())).collect(),
|
||||
Err(e) => return Err(vec![format!("{}: {e}", dir.display())]),
|
||||
};
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let Some(stem) = name.strip_suffix(".toml") else {
|
||||
errors.push(format!("{ENTRIES}/{name}: only <id>.toml files go here"));
|
||||
continue;
|
||||
};
|
||||
let parsed = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| toml::from_str::<Entry>(&text).map_err(|e| e.to_string()));
|
||||
match parsed {
|
||||
Ok(entry) if entry.id == stem => entries.push(entry),
|
||||
Ok(entry) => errors.push(format!(
|
||||
"{ENTRIES}/{name}: its id is {:?}; the file must be {ENTRIES}/{}.toml",
|
||||
entry.id, entry.id
|
||||
)),
|
||||
Err(e) => errors.push(format!("{ENTRIES}/{name}: {e}")),
|
||||
}
|
||||
}
|
||||
let revoked = match std::fs::read_to_string(registry.join(REVOKED)) {
|
||||
Ok(text) => match toml::from_str::<RevokedFile>(&text) {
|
||||
Ok(file) => file.revoked,
|
||||
Err(e) => {
|
||||
errors.push(format!("{REVOKED}: {e}"));
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
errors.push(format!("{REVOKED}: {e}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if errors.is_empty() {
|
||||
Ok((entries, revoked))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// A version string that is also a plain release tag and file name part.
|
||||
fn plain_version(v: &str) -> bool {
|
||||
!v.is_empty()
|
||||
&& v.len() <= 64
|
||||
&& v.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& v.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
|
||||
}
|
||||
|
||||
fn plain_date(d: &str) -> bool {
|
||||
let b = d.as_bytes();
|
||||
b.len() == 10
|
||||
&& b[4] == b'-'
|
||||
&& b[7] == b'-'
|
||||
&& b.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// One project's entry and its files, as the index lists them.
|
||||
pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let id = &entry.id;
|
||||
if !valid_id(id) {
|
||||
return Err(vec![format!("{id:?} is not [a-z0-9_]{{1,64}}")]);
|
||||
}
|
||||
if RESERVED.contains(&id.as_str()) {
|
||||
return Err(vec![format!("{id:?} is reserved: pick another id")]);
|
||||
}
|
||||
open(&entry.kind).map_err(|e| vec![format!("{id}: {e}")])?;
|
||||
if entry.owners.is_empty() {
|
||||
errors.push(format!("{id}: needs at least one owner (a GitHub login)"));
|
||||
}
|
||||
if entry.summary.chars().count() > 160 {
|
||||
errors.push(format!("{id}: the summary is over 160 characters"));
|
||||
}
|
||||
let icon = match entry.icon.as_deref() {
|
||||
None => None,
|
||||
Some(url) if url.starts_with("https://") => Some(url.to_owned()),
|
||||
Some(path) => {
|
||||
let ok = path
|
||||
.strip_prefix("icons/")
|
||||
.is_some_and(|name| super::plain_name(name) && name.ends_with(".png"));
|
||||
let size = std::fs::metadata(src.registry.join(path)).map(|m| m.len());
|
||||
match (ok, size) {
|
||||
(true, Ok(n)) if n <= MAX_ICON => Some(format!("{}{path}", src.site)),
|
||||
(true, Ok(_)) => {
|
||||
errors.push(format!("{id}: {path} is over {} KiB", MAX_ICON >> 10));
|
||||
None
|
||||
}
|
||||
(true, Err(e)) => {
|
||||
errors.push(format!("{id}: {path}: {e}"));
|
||||
None
|
||||
}
|
||||
(false, _) => {
|
||||
errors.push(format!(
|
||||
"{id}: icon {path:?} is neither icons/<name>.png nor https://"
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if entry.versions.is_empty() {
|
||||
errors.push(format!("{id}: has no version"));
|
||||
}
|
||||
let mut versions = Vec::new();
|
||||
for v in &entry.versions {
|
||||
match version(entry, v, src) {
|
||||
Ok(v) => versions.push(v),
|
||||
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
Ok(Project {
|
||||
id: id.clone(),
|
||||
kind: entry.kind.clone(),
|
||||
name: entry.name.clone(),
|
||||
summary: entry.summary.clone(),
|
||||
description: entry.description.clone(),
|
||||
authors: entry.authors.clone(),
|
||||
owners: entry.owners.clone(),
|
||||
license: entry.license.clone(),
|
||||
source: entry.source.clone(),
|
||||
icon,
|
||||
key: entry.key.as_ref().map(|k| k.trim().to_owned()),
|
||||
versions,
|
||||
})
|
||||
}
|
||||
|
||||
fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, String> {
|
||||
if !plain_version(&v.version) {
|
||||
return Err("a version is letters, digits, '.', '-' and '_'".into());
|
||||
}
|
||||
if !plain_date(&v.published) {
|
||||
return Err(format!("published {:?} is not YYYY-MM-DD", v.published));
|
||||
}
|
||||
hex64(&v.sha256)?;
|
||||
let path = src.files.join(&v.sha256);
|
||||
let size = std::fs::metadata(&path)
|
||||
.map_err(|e| format!("its file {}: {e}", v.sha256))?
|
||||
.len();
|
||||
if super::sha256_file(&path)? != v.sha256 {
|
||||
return Err("the file isn't the one its sha256 names".into());
|
||||
}
|
||||
match (&entry.key, &v.signature) {
|
||||
(Some(key), Some(sig)) => super::verify_signature(key, sig, &path)?,
|
||||
(Some(_), None) => return Err("the project has a key: sign the file with it".into()),
|
||||
(None, Some(_)) => return Err("signed, but the project has no key".into()),
|
||||
(None, None) => {}
|
||||
}
|
||||
let facts = inspect(&entry.kind, &path)?;
|
||||
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
|
||||
return Err(format!(
|
||||
"the file's id is {:?}, not {:?}",
|
||||
facts.id.unwrap_or_default(),
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
if facts.version.as_ref().is_some_and(|x| *x != v.version) {
|
||||
return Err(format!(
|
||||
"the file says version {:?}",
|
||||
facts.version.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if facts.game.as_ref().is_some_and(|g| *g != v.game) {
|
||||
return Err(format!(
|
||||
"the file is for {:?}, the entry says {:?}",
|
||||
facts.game.unwrap_or_default(),
|
||||
v.game
|
||||
));
|
||||
}
|
||||
Ok(Version {
|
||||
version: v.version.clone(),
|
||||
published: v.published.clone(),
|
||||
changelog: v.changelog.clone(),
|
||||
api: facts.api,
|
||||
side: facts.side,
|
||||
capabilities: facts.capabilities,
|
||||
game: v.game.clone(),
|
||||
requires: facts.requires,
|
||||
file: File {
|
||||
url: format!("{}{}/{}", src.release, tag(&entry.id, &v.version), v.file),
|
||||
name: v.file.clone(),
|
||||
size,
|
||||
sha256: v.sha256.clone(),
|
||||
signature: v.signature.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The entry as `mods/<id>.toml` holds it (the submission form writes it).
|
||||
pub fn to_toml(&self) -> Result<String, String> {
|
||||
toml::to_string(self).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the whole registry: every entry and file checked, then the
|
||||
/// index's own rules ([`Index::check`]: names, a modpack's mods present).
|
||||
pub fn build(src: &Sources) -> Result<Index, Vec<String>> {
|
||||
let (mut entries, revoked) = load(src.registry)?;
|
||||
entries.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut errors = Vec::new();
|
||||
let mut projects = Vec::new();
|
||||
for entry in &entries {
|
||||
match project(entry, src) {
|
||||
Ok(p) => projects.push(p),
|
||||
Err(e) => errors.extend(e),
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
let index = Index {
|
||||
schema: SCHEMA,
|
||||
generated: src.generated.clone(),
|
||||
projects,
|
||||
revoked,
|
||||
};
|
||||
let local = src.release.starts_with("file:///");
|
||||
index.check_with(local).map_err(|e| vec![e])?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// What a pull request changes, for its reviewers.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Review {
|
||||
/// Changes the registry refuses whoever reviews them.
|
||||
pub errors: Vec<String>,
|
||||
/// What reviewers should look at: new projects, new owners or keys, a
|
||||
/// change by someone who isn't an owner.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
|
||||
/// a published file never changes and is never removed (take it down in
|
||||
/// `revoked.toml`), a project's kind never changes, and changes by someone
|
||||
/// who isn't an owner, or to owners and keys, are pointed out.
|
||||
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
|
||||
let mut r = Review::default();
|
||||
for old in base {
|
||||
let Some(new) = head.iter().find(|e| e.id == old.id) else {
|
||||
r.errors.push(format!(
|
||||
"{}: removed; take its files down in {REVOKED} instead",
|
||||
old.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if new == old {
|
||||
continue;
|
||||
}
|
||||
if !old.owners.iter().any(|o| o.eq_ignore_ascii_case(author)) {
|
||||
r.notes.push(format!(
|
||||
"{}: changed by {author}, who isn't one of its owners ({})",
|
||||
old.id,
|
||||
old.owners.join(", ")
|
||||
));
|
||||
}
|
||||
if new.kind != old.kind {
|
||||
r.errors.push(format!("{}: its kind can't change", old.id));
|
||||
}
|
||||
for v in &old.versions {
|
||||
match new.versions.iter().find(|w| w.version == v.version) {
|
||||
None => r.errors.push(format!(
|
||||
"{} {}: removed; take it down in {REVOKED} instead",
|
||||
old.id, v.version
|
||||
)),
|
||||
Some(w) if w.sha256 != v.sha256 || w.file != v.file => r.errors.push(format!(
|
||||
"{} {}: a published file never changes: publish a new version",
|
||||
old.id, v.version
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if new.owners != old.owners {
|
||||
r.notes.push(format!(
|
||||
"{}: owners {} -> {}",
|
||||
old.id,
|
||||
old.owners.join(", "),
|
||||
new.owners.join(", ")
|
||||
));
|
||||
}
|
||||
if new.key != old.key {
|
||||
r.notes.push(format!(
|
||||
"{}: its signing key changes; updates signed with the old one stop working",
|
||||
old.id
|
||||
));
|
||||
}
|
||||
let added = new.versions.len().saturating_sub(old.versions.len());
|
||||
if added > 0 {
|
||||
r.notes.push(format!("{}: {added} new version(s)", old.id));
|
||||
}
|
||||
}
|
||||
for new in head {
|
||||
if !base.iter().any(|e| e.id == new.id) {
|
||||
r.notes.push(format!(
|
||||
"{}: a new {} by {}",
|
||||
new.id,
|
||||
new.kind.name(),
|
||||
new.owners.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,237 @@
|
||||
//! A registry checkout holding the sample index's mod and modpack builds the
|
||||
//! sample index (`tests/index/index-v1.json`) exactly.
|
||||
use super::*;
|
||||
|
||||
const HMOD: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
|
||||
const HPACK: &str = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65";
|
||||
|
||||
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 hud_entry() -> String {
|
||||
let p = sample();
|
||||
let sig = p.project("coords_hud").unwrap().versions[0]
|
||||
.file
|
||||
.signature
|
||||
.clone()
|
||||
.unwrap();
|
||||
format!(
|
||||
r#"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 = "icons/coords_hud.png"
|
||||
key = "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-24"
|
||||
game = "26.3"
|
||||
file = "coords_hud-0.1.0.hmod"
|
||||
sha256 = "{HMOD}"
|
||||
signature = """{sig}"""
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
const COZY: &str = r#"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"
|
||||
game = "26.3"
|
||||
file = "cozy-1.0.0.hpack"
|
||||
sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
"#;
|
||||
|
||||
const REVOKED_TOML: &str = r#"[[revoked]]
|
||||
id = "bad_mod"
|
||||
sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
reason = "Sent chat messages it didn't declare."
|
||||
date = "2026-10-24"
|
||||
"#;
|
||||
|
||||
/// A registry checkout and its downloaded files, in a scratch folder.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Scratch {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-registry-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for sub in ["repo/mods", "repo/icons", "files"] {
|
||||
std::fs::create_dir_all(dir.join(sub)).unwrap();
|
||||
}
|
||||
let s = Scratch(dir);
|
||||
s.write("mods/coords_hud.toml", &hud_entry());
|
||||
s.write("mods/cozy.toml", COZY);
|
||||
s.write("revoked.toml", REVOKED_TOML);
|
||||
s.write("icons/coords_hud.png", "png");
|
||||
for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] {
|
||||
std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn write(&self, path: &str, text: &str) {
|
||||
std::fs::write(self.0.join("repo").join(path), text).unwrap();
|
||||
}
|
||||
|
||||
fn build(&self) -> Result<Index, Vec<String>> {
|
||||
let (repo, files) = (self.0.join("repo"), self.0.join("files"));
|
||||
build(&Sources {
|
||||
registry: &repo,
|
||||
files: &files,
|
||||
release: super::super::FILE_HOST,
|
||||
site: "https://ludovic111.github.io/horizon-mods/",
|
||||
generated: "2026-10-24T18:00:00Z".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_the_sample_index() {
|
||||
let s = Scratch::new("sample");
|
||||
let built = s.build().unwrap();
|
||||
let sample = sample();
|
||||
assert_eq!(built.projects, sample.projects[..2]);
|
||||
assert_eq!(built.revoked, sample.revoked);
|
||||
assert_eq!(built.generated, sample.generated);
|
||||
// What it writes reads back.
|
||||
Index::parse(&serde_json::to_string_pretty(&built).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_every_problem_of_a_registry() {
|
||||
let s = Scratch::new("bad");
|
||||
// A file that isn't the one its hash names, an unsigned version of a
|
||||
// signed project, a shader pack, and a mod whose file is another mod.
|
||||
std::fs::write(s.0.join("files").join(HPACK), b"other bytes").unwrap();
|
||||
s.write(
|
||||
"mods/coords_hud.toml",
|
||||
&hud_entry().replace("signature = \"\"\"", "changelog = \"\"\""),
|
||||
);
|
||||
s.write(
|
||||
"mods/glow.toml",
|
||||
&COZY
|
||||
.replace("\"cozy\"", "\"glow\"")
|
||||
.replace("modpack", "shader"),
|
||||
);
|
||||
s.write(
|
||||
"mods/other.toml",
|
||||
&hud_entry()
|
||||
.replace("\"coords_hud\"", "\"other\"")
|
||||
.replace("key = ", "# key = ")
|
||||
.replace("signature = \"\"\"", "changelog = \"\"\""),
|
||||
);
|
||||
let errors = s.build().unwrap_err();
|
||||
let has = |part: &str| errors.iter().any(|e| e.contains(part));
|
||||
assert!(
|
||||
has("cozy 1.0.0: the file isn't the one its sha256 names"),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}");
|
||||
assert!(has("glow: shader packs open when"), "{errors:?}");
|
||||
assert!(
|
||||
has("other 0.1.0: the file's id is \"coords_hud\""),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert_eq!(errors.len(), 4, "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_is_named_by_its_id_and_has_only_known_fields() {
|
||||
let s = Scratch::new("names");
|
||||
s.write("mods/hud.toml", &hud_entry());
|
||||
s.write("mods/notes.txt", "");
|
||||
s.write("mods/cozy.toml", &format!("{COZY}homepage = \"x\"\n"));
|
||||
let errors = load(&s.0.join("repo")).unwrap_err();
|
||||
assert_eq!(errors.len(), 3, "{errors:?}");
|
||||
assert!(errors[0].contains("cozy.toml") && errors[0].contains("homepage"));
|
||||
assert!(errors[1].contains("must be mods/coords_hud.toml"));
|
||||
assert!(errors[2].contains("only <id>.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_written_reads_back() {
|
||||
let entry: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
let text = entry.to_toml().unwrap();
|
||||
assert_eq!(toml::from_str::<Entry>(&text).unwrap(), entry);
|
||||
assert!(text.contains("[[versions]]"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_review_protects_published_files() {
|
||||
let base: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
let cozy: Entry = toml::from_str(COZY).unwrap();
|
||||
let one = std::slice::from_ref(&base);
|
||||
let same = review(one, one, "someone");
|
||||
assert_eq!(same, Review::default());
|
||||
|
||||
let mut update = base.clone();
|
||||
let mut next = update.versions[0].clone();
|
||||
next.version = "0.2.0".into();
|
||||
update.versions.insert(0, next);
|
||||
let r = review(one, std::slice::from_ref(&update), "ludovic111");
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert_eq!(r.notes, ["coords_hud: 1 new version(s)"]);
|
||||
let r = review(one, &[update], "stranger");
|
||||
assert!(r.notes[0].contains("isn't one of its owners"), "{r:?}");
|
||||
|
||||
let mut swapped = base.clone();
|
||||
swapped.versions[0].sha256 = HPACK.into();
|
||||
swapped.kind = Kind::ResourcePack;
|
||||
swapped.key = None;
|
||||
let r = review(&[base.clone(), cozy.clone()], &[swapped], "ludovic111");
|
||||
assert_eq!(r.errors.len(), 3, "{r:?}");
|
||||
assert!(r.errors.iter().any(|e| e.contains("cozy: removed")));
|
||||
assert!(r.errors.iter().any(|e| e.contains("never changes")));
|
||||
assert!(r.errors.iter().any(|e| e.contains("kind can't change")));
|
||||
assert!(r.notes.iter().any(|n| n.contains("signing key")));
|
||||
|
||||
let r = review(&[], &[cozy], "newcomer");
|
||||
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_open_kinds_are_taken() {
|
||||
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
|
||||
assert!(open(&Kind::Shader).unwrap_err().contains("Iris"));
|
||||
assert!(open(&Kind::Other("world".into())).is_err());
|
||||
let mut submit: Entry = toml::from_str(COZY).unwrap();
|
||||
submit.id = "submit".into();
|
||||
let dir = std::env::temp_dir();
|
||||
let src = Sources {
|
||||
registry: &dir,
|
||||
files: &dir,
|
||||
release: super::super::FILE_HOST,
|
||||
site: "https://x.test/",
|
||||
generated: String::new(),
|
||||
};
|
||||
assert!(project(&submit, &src).unwrap_err()[0].contains("reserved"));
|
||||
assert!(plain_version("1.0.0-beta.2") && !plain_version("../1") && !plain_version("1 0"));
|
||||
assert!(plain_date("2026-10-24") && !plain_date("24/10/2026"));
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 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("75".into())
|
||||
);
|
||||
let e = check_resource_pack(&pack(&[("assets/x.png", "")])).unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", "{}")])).unwrap_err();
|
||||
assert!(e.contains("\"pack\""), "{e}");
|
||||
let e = check_resource_pack(&pack(&[("pack.mcmeta", meta), ("../x", "")])).unwrap_err();
|
||||
assert!(e.contains("plain path"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
//! Nothing here runs unless a mod is installed and turned on: an empty
|
||||
//! [`Mods`] makes no engine and every hook returns at once.
|
||||
pub mod content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
pub mod manifest;
|
||||
pub mod modpack;
|
||||
#[cfg(feature = "runtime")]
|
||||
mod runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt,
|
||||
io::Read,
|
||||
@@ -20,6 +20,9 @@ pub const APIS: [&str; 2] = [API, "0.2"];
|
||||
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
|
||||
/// to those, docs/mods.md) and the game's own.
|
||||
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
|
||||
/// A mod's data (capability `storage`), in `horizon-mods/` beside the mods
|
||||
/// (`find` never mistakes it for a mod: it has no manifest).
|
||||
pub const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
/// The biggest `mod.wasm` a mod may ship.
|
||||
const MAX_WASM: u64 = 32 << 20;
|
||||
|
||||
@@ -48,7 +51,7 @@ pub struct Manifest {
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Side {
|
||||
/// Runs in the player's game only (HUD, keys, chat commands).
|
||||
@@ -62,7 +65,7 @@ pub enum Side {
|
||||
|
||||
/// What a mod may do beyond writing its log. Each is a WIT interface the
|
||||
/// component imports; the player sees them before turning the mod on.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Capability {
|
||||
GameRead,
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! `.hpack`, a Horizon modpack (docs/api/mod-index.md): registry mods
|
||||
//! played together as a profile. A zip holding `horizon-modpack.toml`, which
|
||||
//! names each mod by id, version and SHA-256 (the files themselves come from
|
||||
//! the registry), and optionally `overrides/`: settings and resource packs
|
||||
//! copied into the profile's game folder.
|
||||
use crate::manifest::STORAGE_DIR;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// The manifest's file name in a `.hpack`.
|
||||
pub const MANIFEST: &str = "horizon-modpack.toml";
|
||||
/// The manifest formats this build reads.
|
||||
pub const FORMAT: u32 = 1;
|
||||
const OVERRIDES: &str = "overrides/";
|
||||
/// Bytes per override file and in all, unpacked.
|
||||
const MAX_OVERRIDE: u64 = 64 << 20;
|
||||
const MAX_OVERRIDES: u64 = 256 << 20;
|
||||
/// The settings files an override may replace.
|
||||
const SETTINGS: [&str; 3] = ["options.txt", "horizon-graphics.txt", "horizon-extras.txt"];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format: u32,
|
||||
/// `[a-z0-9_]{1,64}`: its profile's folder.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<PackMod>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackMod {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
/// Turned on in the profile (`horizon-mods.txt`).
|
||||
#[serde(default = "on")]
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
fn on() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "index")]
|
||||
impl PackMod {
|
||||
pub fn requirement(&self) -> crate::index::Requirement {
|
||||
crate::index::Requirement {
|
||||
id: self.id.clone(),
|
||||
version: self.version.clone(),
|
||||
sha256: self.sha256.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let m: Manifest = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
if m.format != FORMAT {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: format {}; this Horizon reads {FORMAT}",
|
||||
m.format
|
||||
));
|
||||
}
|
||||
let id_ok = |id: &str| {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
};
|
||||
if !id_ok(&m.id) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: id {:?} is not [a-z0-9_]{{1,64}}",
|
||||
m.id
|
||||
));
|
||||
}
|
||||
for (i, pm) in m.mods.iter().enumerate() {
|
||||
let hex = pm.sha256.len() == 64
|
||||
&& pm
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
|
||||
if !id_ok(&pm.id) || !hex || pm.version.is_empty() {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: mod {:?} needs an id, a version and a lowercase hex sha256",
|
||||
pm.id
|
||||
));
|
||||
}
|
||||
if m.mods[..i].iter().any(|other| other.id == pm.id) {
|
||||
return Err(format!("{MANIFEST}: mod {} is listed twice", pm.id));
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.hpack` as read from disk: its manifest and its override files, by
|
||||
/// path under `overrides/`.
|
||||
#[derive(Debug)]
|
||||
pub struct Modpack {
|
||||
pub manifest: Manifest,
|
||||
pub overrides: Vec<String>,
|
||||
}
|
||||
|
||||
impl Modpack {
|
||||
pub fn read(path: &Path) -> Result<Modpack, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut text = String::new();
|
||||
zip.by_name(MANIFEST)
|
||||
.map_err(|_| format!("{MANIFEST}: missing"))?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
let manifest = Manifest::parse(&text)?;
|
||||
let (mut overrides, mut total) = (Vec::new(), 0u64);
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name();
|
||||
if name == MANIFEST || entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = name.strip_prefix(OVERRIDES) else {
|
||||
return Err(format!(
|
||||
"{name}: a modpack holds only {MANIFEST} and overrides/"
|
||||
));
|
||||
};
|
||||
check_override(rest, &manifest)?;
|
||||
if entry.size() > MAX_OVERRIDE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_OVERRIDE >> 20));
|
||||
}
|
||||
total += entry.size();
|
||||
overrides.push(rest.to_owned());
|
||||
}
|
||||
if total > MAX_OVERRIDES {
|
||||
return Err(format!("overrides are over {} MiB", MAX_OVERRIDES >> 20));
|
||||
}
|
||||
overrides.sort();
|
||||
Ok(Modpack {
|
||||
manifest,
|
||||
overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the overrides of the pack at `path` into the game folder
|
||||
/// `home`, each checked again.
|
||||
pub fn extract_overrides(path: &Path, home: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let pack = Modpack::read(path)?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut written = Vec::new();
|
||||
for name in &pack.overrides {
|
||||
let mut entry = zip
|
||||
.by_name(&format!("{OVERRIDES}{name}"))
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
let target = home.join(name);
|
||||
if let Some(dir) = target.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
let mut out =
|
||||
std::fs::File::create(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
std::io::copy(&mut (&mut entry).take(MAX_OVERRIDE), &mut out)
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
written.push(target);
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
/// An override's path under `overrides/`: a settings file, a resource pack
|
||||
/// zip, or the stored settings of one of the pack's mods. Plain parts only.
|
||||
fn check_override(path: &str, manifest: &Manifest) -> Result<(), String> {
|
||||
let bad = || {
|
||||
format!(
|
||||
"overrides/{path}: only {}, resourcepacks/<name>.zip and horizon-mods/{STORAGE_DIR}/<a listed mod>/ may be overridden",
|
||||
SETTINGS.join(", ")
|
||||
)
|
||||
};
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let plain = |p: &&str| {
|
||||
!p.is_empty()
|
||||
&& !p.starts_with('.')
|
||||
&& p.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+ ".contains(&b))
|
||||
};
|
||||
let ok = match parts.as_slice() {
|
||||
[file] => SETTINGS.contains(file),
|
||||
["resourcepacks", name] => plain(name) && name.ends_with(".zip"),
|
||||
["horizon-mods", dir, id, rest @ ..] => {
|
||||
*dir == STORAGE_DIR
|
||||
&& manifest.mods.iter().any(|m| m.id == *id)
|
||||
&& !rest.is_empty()
|
||||
&& rest.iter().all(plain)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok { Ok(()) } else { Err(bad()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const PACK: &str = r#"
|
||||
format = 1
|
||||
id = "cozy"
|
||||
name = "Cozy"
|
||||
version = "1.0.0"
|
||||
game = "26.3"
|
||||
[[mods]]
|
||||
id = "coords_hud"
|
||||
version = "0.1.0"
|
||||
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
"#;
|
||||
|
||||
fn write_pack(dir: &Path, manifest: &str, files: &[(&str, &[u8])]) -> PathBuf {
|
||||
let path = dir.join("cozy.hpack");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(manifest.as_bytes()).unwrap();
|
||||
for (name, bytes) in files {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-hpack-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_pack_and_writes_its_overrides() {
|
||||
let dir = scratch("read");
|
||||
let storage = format!("overrides/horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt");
|
||||
let path = write_pack(
|
||||
&dir,
|
||||
PACK,
|
||||
&[
|
||||
("overrides/options.txt", b"renderDistance:8\n"),
|
||||
("overrides/resourcepacks/Cozy Look.zip", b"zip"),
|
||||
(&storage, b"top-left"),
|
||||
],
|
||||
);
|
||||
let pack = Modpack::read(&path).unwrap();
|
||||
assert_eq!(pack.manifest.mods[0].id, "coords_hud");
|
||||
assert!(pack.manifest.mods[0].on);
|
||||
assert_eq!(pack.overrides.len(), 3);
|
||||
let home = dir.join("home");
|
||||
Modpack::extract_overrides(&path, &home).unwrap();
|
||||
let options = std::fs::read_to_string(home.join("options.txt")).unwrap();
|
||||
assert_eq!(options, "renderDistance:8\n");
|
||||
let corner = home.join(format!("horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt"));
|
||||
assert!(corner.is_file());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_a_pack_may_not_hold() {
|
||||
let dir = scratch("refuse");
|
||||
for name in [
|
||||
"mods/evil.jar",
|
||||
"overrides/../../.bashrc",
|
||||
"overrides/horizon-mods/evil.hmod",
|
||||
"overrides/resourcepacks/../options.txt",
|
||||
"overrides/servers.dat",
|
||||
"overrides/horizon-mods/.horizon-mod-data/other_mod/x",
|
||||
"overrides/resourcepacks/.hidden.zip",
|
||||
] {
|
||||
let path = write_pack(&dir, PACK, &[(name, b"x")]);
|
||||
assert!(Modpack::read(&path).is_err(), "{name} was accepted");
|
||||
}
|
||||
let twice = format!("{PACK}{}", &PACK[PACK.find("[[mods]]").unwrap()..]);
|
||||
assert!(Manifest::parse(&twice).unwrap_err().contains("twice"));
|
||||
let newer = PACK.replace("format = 1", "format = 2");
|
||||
assert!(Manifest::parse(&newer).unwrap_err().contains("format 2"));
|
||||
let short = PACK.replace("0000000000000000", "00");
|
||||
assert!(Manifest::parse(&short).is_err());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,6 @@ const STORAGE_MAX_KEYS: usize = 64;
|
||||
/// a cheap-in-fuel loop replacing one key with large payloads needs its own
|
||||
/// cap, separate from the on-disk quota it would otherwise stay under.
|
||||
const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
|
||||
/// A mod's data, beside its `horizon-mods` folder (`find`'s `path.parent()`
|
||||
/// never has a manifest there, so `find` never mistakes it for a mod).
|
||||
const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
|
||||
/// What mods may read of the game, copied once per tick by the game.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -237,7 +234,7 @@ impl Mods {
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(STORAGE_DIR)
|
||||
.join(crate::manifest::STORAGE_DIR)
|
||||
.join(&m.id);
|
||||
let state = State {
|
||||
name: m.name.clone(),
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=
|
||||
trusted comment: coords_hud 0.1.0
|
||||
IESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==
|
||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-10-24T18:00:00Z",
|
||||
"projects": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"kind": "mod",
|
||||
"name": "Coordinates HUD",
|
||||
"summary": "Your coordinates, facing and the time of day in a corner of the screen.",
|
||||
"description": "`/where` puts them in chat, `/coords` hides or shows the panel.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"icon": "https://ludovic111.github.io/horizon-mods/icons/coords_hud.png",
|
||||
"key": "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "0.1",
|
||||
"side": "client",
|
||||
"capabilities": [
|
||||
"game-read",
|
||||
"hud",
|
||||
"chat"
|
||||
],
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "coords_hud-0.1.0.hmod",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/coords_hud-0.1.0.hmod",
|
||||
"size": 438,
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53",
|
||||
"signature": "untrusted comment: signature from minisign secret key\nRUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=\ntrusted comment: coords_hud 0.1.0\nIESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cozy",
|
||||
"kind": "modpack",
|
||||
"name": "Cozy",
|
||||
"summary": "Coordinates and a calm render distance.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
"owners": [
|
||||
"ludovic111"
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/ludovic111/horizon-mods",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"published": "2026-10-24",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"requires": [
|
||||
{
|
||||
"id": "coords_hud",
|
||||
"version": "0.1.0",
|
||||
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"
|
||||
}
|
||||
],
|
||||
"file": {
|
||||
"name": "cozy-1.0.0.hpack",
|
||||
"url": "https://github.com/ludovic111/horizon-mods/releases/download/cozy-1.0.0/cozy-1.0.0.hpack",
|
||||
"size": 471,
|
||||
"sha256": "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skyblock_start",
|
||||
"kind": "world",
|
||||
"name": "Skyblock start",
|
||||
"summary": "A kind this reader doesn't know yet: kept, not offered.",
|
||||
"license": "CC0-1.0",
|
||||
"source": "https://example.org/skyblock",
|
||||
"future_field": true,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1",
|
||||
"api": "1",
|
||||
"game": "26.3",
|
||||
"file": {
|
||||
"name": "skyblock.hworld",
|
||||
"url": "https://example.org/skyblock.hworld",
|
||||
"size": 1,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"revoked": [
|
||||
{
|
||||
"id": "bad_mod",
|
||||
"sha256": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"reason": "Sent chat messages it didn't declare.",
|
||||
"date": "2026-10-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
untrusted comment: minisign public key 705764FC9BE1B6D5
|
||||
RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb
|
||||
@@ -814,7 +814,7 @@ pub fn carry(
|
||||
if kind == "structure" {
|
||||
let path = rel.trim_start_matches("worldgen/structure/");
|
||||
let structure = format!("{ns}:{}", path.trim_end_matches(".json"));
|
||||
changed |= crate::worldgen::disables(&structure, &mut v);
|
||||
changed |= crate::legacy_structure_mods::yung::disables(&structure, &mut v);
|
||||
changed |=
|
||||
crate::worldgen::add_piece_counts(&structure, &mut v, &piece_counts);
|
||||
}
|
||||
@@ -829,9 +829,9 @@ pub fn carry(
|
||||
// Additions to other sets and pools, as files the game merges.
|
||||
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
|
||||
let types = &mut report.worldgen_types;
|
||||
if let Some((kind, mut added)) =
|
||||
modifier.and_then(|v| crate::worldgen::modifier(&v, loaded, types))
|
||||
{
|
||||
if let Some((kind, mut added)) = modifier.and_then(|v| {
|
||||
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
|
||||
}) {
|
||||
crate::processors::carry(
|
||||
kind,
|
||||
&mut added,
|
||||
@@ -910,7 +910,7 @@ pub fn carry(
|
||||
}
|
||||
// Entries the mod adds to other structure sets in Java code, for the
|
||||
// structures it has.
|
||||
for (file, set, entries) in crate::worldgen::code_set_entries(id) {
|
||||
for (file, set, entries) in crate::legacy_structure_mods::ctov::code_set_entries(id) {
|
||||
let entries: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.filter(|(s, _)| {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Choice Theorem's Overhauled Village: the entries it adds to other
|
||||
//! structure sets in Java code, and its `modular_compat` processor.
|
||||
use crate::processors::{ProcessorData, referenced};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub(crate) fn code_set_entries(
|
||||
mod_id: &str,
|
||||
) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choice Theorem's Overhauled Village's processor that runs the list
|
||||
/// `reference` when mod `modid` is loaded (ModularCompatProcessor).
|
||||
const MODULAR_COMPAT: &str = "ctov:modular_compat";
|
||||
|
||||
/// Notes which mods its compat processors name are `loaded`.
|
||||
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
|
||||
fn walk(v: &Value, out: &mut BTreeSet<String>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) {
|
||||
if let Some(id) = o.get("modid").and_then(Value::as_str) {
|
||||
out.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
o.values().for_each(|v| walk(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| walk(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut named = BTreeSet::new();
|
||||
data.lists.values().for_each(|l| walk(l, &mut named));
|
||||
data.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect();
|
||||
}
|
||||
|
||||
/// A `ctov:modular_compat` processor in a list being carried, at `depth`
|
||||
/// (see [`super::lithostitched`]'s references): counted in `carried`, and
|
||||
/// the processors of the list it names when its mod is loaded, else none.
|
||||
/// `None` for any other processor, or past the depth limit.
|
||||
pub(super) fn modular_compat(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> Option<Vec<Value>> {
|
||||
if p["processor_type"] != MODULAR_COMPAT || depth >= 8 {
|
||||
return None;
|
||||
}
|
||||
*carried.entry(MODULAR_COMPAT.into()).or_default() += 1;
|
||||
let loaded = p["modid"]
|
||||
.as_str()
|
||||
.is_some_and(|m| data.compat_loaded.contains(m));
|
||||
Some(
|
||||
loaded
|
||||
.then(|| referenced(&p["reference"], data))
|
||||
.flatten()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::processors::carry;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modular_compat_runs_its_list_only_with_its_mod() {
|
||||
let mut data = ProcessorData::default();
|
||||
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
|
||||
data.lists
|
||||
.insert("v:house_ns".into(), json!({"processors": [swap.clone()]}));
|
||||
let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"});
|
||||
let gravity = json!({"processor_type": "minecraft:gravity"});
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}),
|
||||
);
|
||||
data.mark_loaded(&|m| m == "ns");
|
||||
assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()]));
|
||||
let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut v, &data, &mut carried));
|
||||
assert_eq!(v, json!({"processors": [swap, gravity]}));
|
||||
assert_eq!(carried["ctov:modular_compat"], 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
|
||||
//! processors.
|
||||
use super::ctov;
|
||||
use crate::processors::{ProcessorData, carry_all, processors, referenced};
|
||||
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
|
||||
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
|
||||
/// `start_checks`). `None` for any other file.
|
||||
pub(crate) fn upgrade(
|
||||
kind: &str,
|
||||
v: &mut Value,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) -> Option<bool> {
|
||||
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
|
||||
return None;
|
||||
}
|
||||
start_checks(v, "lowest", lost, false);
|
||||
if v.as_object_mut()
|
||||
.and_then(|o| o.remove(ENHANCED_TERRAIN))
|
||||
.is_some()
|
||||
{
|
||||
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
|
||||
}
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// A lithostitched worldgen modifier
|
||||
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
|
||||
/// sets or template pools, as a file the game merges into them (the key
|
||||
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
|
||||
/// modifier's conditions are checked against `loaded`, the mods ported
|
||||
/// along: a compat addition for a mod that is not would place that mod's
|
||||
/// blocks, which the game does not have. Other modifier types are left out.
|
||||
/// Both are counted in `report`.
|
||||
pub(crate) fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let t = v["type"].as_str().unwrap_or_default();
|
||||
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
|
||||
let (kind, targets, from, to) = match t {
|
||||
"lithostitched:add_structure_set_entries" => {
|
||||
("structure_set", "structure_sets", "entries", "structures")
|
||||
}
|
||||
"lithostitched:add_template_pool_elements" => {
|
||||
("template_pool", "template_pools", "elements", "elements")
|
||||
}
|
||||
_ => {
|
||||
left(t);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !conditions_hold(v, loaded) {
|
||||
left("conditions");
|
||||
return None;
|
||||
}
|
||||
let targets: Option<Vec<&str>> = match &v[targets] {
|
||||
Value::String(s) => Some(vec![s.as_str()]),
|
||||
Value::Array(a) => a.iter().map(Value::as_str).collect(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
|
||||
else {
|
||||
left("target");
|
||||
return None;
|
||||
};
|
||||
let entries = v[from].as_array().cloned().unwrap_or_default();
|
||||
for target in &targets {
|
||||
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
|
||||
}
|
||||
Some((
|
||||
kind,
|
||||
serde_json::json!({"horizon:add_to": targets, to: entries}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
|
||||
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
|
||||
/// type not known here does not hold.
|
||||
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
|
||||
list.as_array().is_some_and(|a| a.iter().all(test))
|
||||
};
|
||||
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| predicate(q, loaded);
|
||||
let list = &p["predicates"];
|
||||
match p["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("lithostitched:"))
|
||||
{
|
||||
Some("true") => true,
|
||||
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
|
||||
Some("not") => !predicate(&p["predicate"], loaded),
|
||||
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| neoforge(q, loaded);
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:not") => !neoforge(&c["value"], loaded),
|
||||
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| fabric(q, loaded);
|
||||
let mods = || {
|
||||
c["values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(Value::as_str)
|
||||
};
|
||||
match c["condition"].as_str() {
|
||||
Some("fabric:true") => true,
|
||||
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:not") => !fabric(&c["value"], loaded),
|
||||
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
|
||||
let loader = match (
|
||||
v.get("neoforge:conditions"),
|
||||
v.get("fabric:load_conditions"),
|
||||
) {
|
||||
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
|
||||
// One condition or a list of them.
|
||||
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
|
||||
(None, Some(c)) => fabric(c, loaded),
|
||||
(None, None) => true,
|
||||
};
|
||||
litho && loader
|
||||
}
|
||||
|
||||
/// The data form of one of Lithostitched's processors: the processors to put
|
||||
/// in its place, or `None` when it has none (or is not Lithostitched's).
|
||||
pub(crate) fn processor(
|
||||
kind: &str,
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Vec<Value>> {
|
||||
match kind {
|
||||
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
|
||||
"lithostitched:block_swap" => {
|
||||
// The same map; the game's swap keeps the properties both have.
|
||||
p["blocks"].as_object()?;
|
||||
Some(vec![
|
||||
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
|
||||
])
|
||||
}
|
||||
"lithostitched:set_block" => {
|
||||
// A simple or weighted state provider, the block entity left as
|
||||
// it is.
|
||||
let provider = p["state_provider"]["type"].as_str()?;
|
||||
let modifier = p["block_entity_modifier"]["type"].as_str();
|
||||
let plain =
|
||||
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
|
||||
if !plain
|
||||
|| !matches!(
|
||||
provider,
|
||||
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut out = p.clone();
|
||||
out["processor_type"] = "horizon:set_block".into();
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.remove("block_entity_modifier");
|
||||
}
|
||||
Some(vec![out])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The references (Lithostitched's, and CTOV's compat processors) in a
|
||||
/// processor list written in; whether any was.
|
||||
pub(crate) fn expand_references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> (Vec<Value>, bool) {
|
||||
references(list, data, carried, 0)
|
||||
}
|
||||
|
||||
/// A processor list inside a carried processor: an id stays (the game reads
|
||||
/// that file, carried on its own); an inline one is carried here.
|
||||
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
|
||||
let mut list = list.clone();
|
||||
if let Some(ps) = processors(&mut list) {
|
||||
*ps = carry_all(std::mem::take(ps), data, carried).0;
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
|
||||
/// lists, or its set of lists at weight 1. A tag is not carried.
|
||||
fn apply_random(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
let lists = &p["processor_lists"];
|
||||
let weighted = lists.as_array().is_some_and(|a| {
|
||||
a.iter()
|
||||
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
|
||||
});
|
||||
let entries: Vec<(Value, Value)> = if weighted {
|
||||
lists
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|e| (e["weight"].clone(), e["data"].clone()))
|
||||
.collect()
|
||||
} else {
|
||||
match lists {
|
||||
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
|
||||
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
|
||||
vec![(1.into(), lists.clone())]
|
||||
}
|
||||
Value::Array(a)
|
||||
if a.iter()
|
||||
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
|
||||
{
|
||||
a.iter().map(|x| (1.into(), x.clone())).collect()
|
||||
}
|
||||
Value::Object(_) => vec![(1.into(), lists.clone())],
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
let lists: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
|
||||
.collect();
|
||||
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
|
||||
if let Some(mode) = p.get("mode") {
|
||||
out["mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
|
||||
/// every condition in it (true, all_of, any_of, not, random_chance,
|
||||
/// matching_blocks with plain property values); else it stays Java.
|
||||
fn condition(
|
||||
p: &Value,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
) -> Option<Value> {
|
||||
fn runs(c: &Value) -> bool {
|
||||
if let Some(all) = c.as_array() {
|
||||
return all.iter().all(runs);
|
||||
}
|
||||
let kind = c["type"].as_str().unwrap_or_default();
|
||||
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
|
||||
"true" | "random_chance" => true,
|
||||
"all_of" | "any_of" => c["conditions"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().all(runs)),
|
||||
"not" => runs(&c["condition"]),
|
||||
"matching_blocks" => c.get("properties").is_none_or(|p| {
|
||||
p.as_object()
|
||||
.is_some_and(|o| o.values().all(Value::is_string))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if !runs(&p["if_true"]) {
|
||||
return None;
|
||||
}
|
||||
// One processor or a list of them.
|
||||
let mut branch = |key: &str| -> Value {
|
||||
let list = match p.get(key) {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
carry_all(list, data, carried).0.into()
|
||||
};
|
||||
let (then, otherwise) = (branch("then"), branch("else"));
|
||||
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
|
||||
"then": then, "else": otherwise});
|
||||
if let Some(mode) = p.get("random_mode") {
|
||||
out["random_mode"] = mode.clone();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Lithostitched's references in a list, each replaced by the processors of
|
||||
/// the lists it names, in order (references in them too, to a depth of 8).
|
||||
/// One naming a tag or a list the mod does not have stays, to be dropped.
|
||||
/// Whether any was replaced.
|
||||
fn references(
|
||||
list: Vec<Value>,
|
||||
data: &ProcessorData,
|
||||
carried: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> (Vec<Value>, bool) {
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
let mut changed = false;
|
||||
for p in list {
|
||||
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
|
||||
changed = true;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
continue;
|
||||
}
|
||||
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
|
||||
.then(|| referenced(&p["processor_lists"], data))
|
||||
.flatten();
|
||||
match named {
|
||||
Some(inner) => {
|
||||
*carried.entry("lithostitched:reference".into()).or_default() += 1;
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
changed = true;
|
||||
}
|
||||
None => out.push(p),
|
||||
}
|
||||
}
|
||||
(out, changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::legacy_structure_mods::data;
|
||||
use crate::processors::carry;
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_sets_and_pools() {
|
||||
let mut report = Upgrade::default();
|
||||
let only_fd = |m: &str| m == "farmersdelight";
|
||||
let set = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "minecraft:villages",
|
||||
"entries": [{"structure": "x:village", "weight": 4}]});
|
||||
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "structure_set");
|
||||
assert_eq!(
|
||||
added,
|
||||
json!({"horizon:add_to": ["minecraft:villages"],
|
||||
"structures": [{"structure": "x:village", "weight": 4}]})
|
||||
);
|
||||
// A compat addition holds only when its mod is ported along.
|
||||
let pool = |modid: &str| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements",
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
|
||||
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
|
||||
"template_pools": ["x:houses", "y:houses"],
|
||||
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
|
||||
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
|
||||
};
|
||||
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
|
||||
assert_eq!(added["elements"][0]["weight"], 5);
|
||||
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
|
||||
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
|
||||
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"fabric:load_conditions": [{"condition": "fabric:not",
|
||||
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
|
||||
"predicate": {"type": "lithostitched:all_of", "predicates": [
|
||||
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
|
||||
{"type": "lithostitched:true"}]},
|
||||
"structure_sets": "minecraft:villages", "entries": []});
|
||||
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
|
||||
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "#x:sets", "entries": []});
|
||||
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
|
||||
let features = json!({"type": "lithostitched:add_features"});
|
||||
assert!(modifier(&features, &only_fd, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.added_to,
|
||||
BTreeMap::from([
|
||||
("minecraft:villages".into(), 1),
|
||||
("x:houses".into(), 1),
|
||||
("y:houses".into(), 1),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:add_features".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_block_swaps_become_the_games() {
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data(), &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
|
||||
);
|
||||
// A map that is not one stays Java (dropped later).
|
||||
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_references_are_written_in() {
|
||||
let mut data = data();
|
||||
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
|
||||
data.lists
|
||||
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
|
||||
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
|
||||
);
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
// House (its own reference to road first, then its swap), then road;
|
||||
// the swap inside is carried too.
|
||||
assert_eq!(p[0], gravity);
|
||||
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
|
||||
assert_eq!(p[2], gravity);
|
||||
// A tag or a list the mod lacks stays, to be dropped.
|
||||
assert_eq!(p[3]["processor_lists"], "#v:roads");
|
||||
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
|
||||
assert_eq!(carried["lithostitched:reference"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_random_and_condition_become_the_games() {
|
||||
let mut data = data();
|
||||
let swap = json!({"processor_type": "lithostitched:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
data.lists
|
||||
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
|
||||
let mut list = json!({"processors": [
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [
|
||||
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
|
||||
{"processor_type": "v:java"}]}}]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": ["v:green", "v:red"]},
|
||||
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
|
||||
"processor_lists": "#v:colours"},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
|
||||
{"processor_type": "lithostitched:condition",
|
||||
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
|
||||
"then": []}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut list, &data, &mut carried));
|
||||
let p = list["processors"].as_array().unwrap();
|
||||
let green = json!({"processor_type": "horizon:block_swap",
|
||||
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
|
||||
// Inline lists are carried; Java left in them is dropped later.
|
||||
assert_eq!(
|
||||
p[0],
|
||||
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
|
||||
{"weight": 3, "data": "minecraft:empty"},
|
||||
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
|
||||
);
|
||||
assert_eq!(
|
||||
p[1]["processor_lists"],
|
||||
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
|
||||
);
|
||||
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
|
||||
assert_eq!(
|
||||
p[3],
|
||||
json!({"processor_type": "horizon:condition",
|
||||
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
|
||||
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
|
||||
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
|
||||
);
|
||||
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
|
||||
assert_eq!(carried["lithostitched:apply_random"], 2);
|
||||
assert_eq!(carried["lithostitched:condition"], 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The conversion of three families of structure mods' data, kept apart from
|
||||
//! the general conversion code (`worldgen`, `processors`, `carry`):
|
||||
//!
|
||||
//! - [`yung`]: YUNG's API and the mods built on it (Better Dungeons, Desert
|
||||
//! Temples, Jungle Temples, Witch Huts, Ocean Monuments, Strongholds,
|
||||
//! Fortresses, Mineshafts), written under `horizon:yungsapi` and the other
|
||||
//! keys `horizon_world::worldgen::structure::yung` reads.
|
||||
//! - [`lithostitched`]: Lithostitched's jigsaw, worldgen modifiers and
|
||||
//! processors.
|
||||
//! - [`ctov`]: Choice Theorem's Overhauled Village's code-registered set
|
||||
//! entries and its modular compat processor.
|
||||
//!
|
||||
//! The general code reaches them only through the `pub(crate)` entry points
|
||||
//! of these three modules. Nothing in them is used by another mod's
|
||||
//! conversion.
|
||||
pub(crate) mod ctov;
|
||||
pub(crate) mod lithostitched;
|
||||
pub(crate) mod yung;
|
||||
|
||||
#[cfg(test)]
|
||||
fn data() -> crate::processors::ProcessorData {
|
||||
let mut d = crate::processors::ProcessorData::default();
|
||||
d.spawners.insert(
|
||||
"repurposed_structures:dungeons/badlands".into(),
|
||||
vec![
|
||||
("minecraft:husk".into(), 10.),
|
||||
("minecraft:cave_spider".into(), 3.),
|
||||
],
|
||||
);
|
||||
d.replace_vanilla.insert(("mvs".into(), "village".into()));
|
||||
d
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ pub mod fetch;
|
||||
pub mod import;
|
||||
pub mod inventory;
|
||||
pub mod jar;
|
||||
pub(crate) mod legacy_structure_mods;
|
||||
pub mod modrinth;
|
||||
pub mod oracle;
|
||||
pub mod pipeline;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,11 @@
|
||||
//! by name. Each rewrite is counted in the carry report, and every type left
|
||||
//! as it is is listed, so the coverage report can say what generates and how
|
||||
//! closely.
|
||||
//!
|
||||
//! YUNG's, Lithostitched's and CTOV's worldgen files are converted by
|
||||
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
|
||||
//! processors.
|
||||
use crate::legacy_structure_mods::{lithostitched, yung};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -21,8 +26,7 @@ use std::{
|
||||
};
|
||||
|
||||
/// Structure types that are vanilla's jigsaw with extra start rules.
|
||||
const JIGSAWS: [&str; 3] = [
|
||||
"lithostitched:jigsaw",
|
||||
const JIGSAWS: [&str; 2] = [
|
||||
"moogs_structures:moogs_structures_generic_jigsaw_structure",
|
||||
"repurposed_structures:generic_jigsaw_structure",
|
||||
];
|
||||
@@ -57,35 +61,6 @@ const SPREADS: [&str; 2] = [
|
||||
"moogs_structures:advanced_random_spread",
|
||||
"repurposed_structures:advanced_random_spread",
|
||||
];
|
||||
/// YUNG's placements: vanilla's random spread with an enhanced exclusion
|
||||
/// zone (several sets), plus, for the temples, no start near an ocean or a
|
||||
/// river, and for strongholds, starts only in odd rings around the origin.
|
||||
const YUNG_SPREADS: [&str; 4] = [
|
||||
"yungsapi:enhanced_random_spread",
|
||||
"betterdeserttemples:desert_temple",
|
||||
"betterjungletemples:jungle_temple",
|
||||
"betterstrongholds:stronghold",
|
||||
];
|
||||
/// YUNG's structures and the vanilla structure type each one's mod turns
|
||||
/// off (its DisableVanilla*Mixin, on by default in each mod's config).
|
||||
const YUNG_REPLACES: [(&str, &str); 7] = [
|
||||
(
|
||||
"betterdeserttemples:desert_temple",
|
||||
"minecraft:desert_pyramid",
|
||||
),
|
||||
(
|
||||
"betterjungletemples:jungle_temple",
|
||||
"minecraft:jungle_temple",
|
||||
),
|
||||
("betterwitchhuts:witch_hut", "minecraft:swamp_hut"),
|
||||
(
|
||||
"betteroceanmonuments:ocean_monument",
|
||||
"minecraft:ocean_monument",
|
||||
),
|
||||
("betterstrongholds:stronghold", "minecraft:stronghold"),
|
||||
("betterfortresses:fortress", "minecraft:fortress"),
|
||||
("bettermineshafts:mineshaft_", "minecraft:mineshaft"),
|
||||
];
|
||||
/// Mo' Structures' placement (`mostructures:type`) takes its spacing and
|
||||
/// separation from the mod's config, not its files: the defaults it writes
|
||||
/// (MoStructuresConfig), by config key, as (spacing, separation).
|
||||
@@ -130,8 +105,8 @@ const LAYOUT_RULES: [&str; 4] = [
|
||||
];
|
||||
/// The libraries' terrain kernel field, the game's
|
||||
/// `horizon:enhanced_terrain_adaptation` (docs/mods.md).
|
||||
const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
pub(crate) const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
|
||||
pub(crate) const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
|
||||
/// Fields of older versions the mods' codecs no longer read: their own
|
||||
/// structures ignore them, and so does carry. Repurposed's jungle fortress
|
||||
/// and End stronghold still write `vertical_distance_from_start_piece`.
|
||||
@@ -181,195 +156,6 @@ pub struct Upgrade {
|
||||
/// `data/<id>/worldgen/<kind>/horizon_added/<name>.json`.
|
||||
pub const ADDED: &str = "horizon_added";
|
||||
|
||||
/// A lithostitched worldgen modifier
|
||||
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
|
||||
/// sets or template pools, as a file the game merges into them (the key
|
||||
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
|
||||
/// modifier's conditions are checked against `loaded`, the mods ported
|
||||
/// along: a compat addition for a mod that is not would place that mod's
|
||||
/// blocks, which the game does not have. Other modifier types are left out.
|
||||
/// Both are counted in `report`.
|
||||
pub fn modifier(
|
||||
v: &Value,
|
||||
loaded: &dyn Fn(&str) -> bool,
|
||||
report: &mut Upgrade,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let t = v["type"].as_str().unwrap_or_default();
|
||||
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
|
||||
let (kind, targets, from, to) = match t {
|
||||
"lithostitched:add_structure_set_entries" => {
|
||||
("structure_set", "structure_sets", "entries", "structures")
|
||||
}
|
||||
"lithostitched:add_template_pool_elements" => {
|
||||
("template_pool", "template_pools", "elements", "elements")
|
||||
}
|
||||
_ => {
|
||||
left(t);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !conditions_hold(v, loaded) {
|
||||
left("conditions");
|
||||
return None;
|
||||
}
|
||||
let targets: Option<Vec<&str>> = match &v[targets] {
|
||||
Value::String(s) => Some(vec![s.as_str()]),
|
||||
Value::Array(a) => a.iter().map(Value::as_str).collect(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
|
||||
else {
|
||||
left("target");
|
||||
return None;
|
||||
};
|
||||
let entries = v[from].as_array().cloned().unwrap_or_default();
|
||||
for target in &targets {
|
||||
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
|
||||
}
|
||||
Some((
|
||||
kind,
|
||||
serde_json::json!({"horizon:add_to": targets, to: entries}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
|
||||
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
|
||||
/// type not known here does not hold.
|
||||
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
|
||||
list.as_array().is_some_and(|a| a.iter().all(test))
|
||||
};
|
||||
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| predicate(q, loaded);
|
||||
let list = &p["predicates"];
|
||||
match p["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("lithostitched:"))
|
||||
{
|
||||
Some("true") => true,
|
||||
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
|
||||
Some("not") => !predicate(&p["predicate"], loaded),
|
||||
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| neoforge(q, loaded);
|
||||
match c["type"].as_str() {
|
||||
Some("neoforge:true") => true,
|
||||
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
|
||||
Some("neoforge:not") => !neoforge(&c["value"], loaded),
|
||||
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
|
||||
let test = |q: &Value| fabric(q, loaded);
|
||||
let mods = || {
|
||||
c["values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(Value::as_str)
|
||||
};
|
||||
match c["condition"].as_str() {
|
||||
Some("fabric:true") => true,
|
||||
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
|
||||
Some("fabric:not") => !fabric(&c["value"], loaded),
|
||||
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
|
||||
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
|
||||
let loader = match (
|
||||
v.get("neoforge:conditions"),
|
||||
v.get("fabric:load_conditions"),
|
||||
) {
|
||||
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
|
||||
// One condition or a list of them.
|
||||
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
|
||||
(None, Some(c)) => fabric(c, loaded),
|
||||
(None, None) => true,
|
||||
};
|
||||
litho && loader
|
||||
}
|
||||
|
||||
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
|
||||
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
|
||||
/// (`CTOV.registerstructure`, through lithostitched), with its default
|
||||
/// config: every village type in three sizes, weighted 10, 4 and 1, and
|
||||
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
|
||||
const CTOV_VILLAGES: [&str; 21] = [
|
||||
"beach",
|
||||
"christmas",
|
||||
"desert",
|
||||
"desert_oasis",
|
||||
"dark_forest",
|
||||
"jungle",
|
||||
"jungle_tree",
|
||||
"mesa",
|
||||
"mesa_fortified",
|
||||
"mountain",
|
||||
"mountain_alpine",
|
||||
"mushroom",
|
||||
"plains",
|
||||
"plains_fortified",
|
||||
"savanna",
|
||||
"savanna_na",
|
||||
"snowy_igloo",
|
||||
"swamp",
|
||||
"swamp_fortified",
|
||||
"taiga",
|
||||
"taiga_fortified",
|
||||
];
|
||||
const CTOV_OUTPOSTS: [&str; 11] = [
|
||||
"beach",
|
||||
"dark_forest",
|
||||
"desert",
|
||||
"jungle",
|
||||
"badlands",
|
||||
"mountain",
|
||||
"plains",
|
||||
"savanna",
|
||||
"snowy",
|
||||
"swamp",
|
||||
"taiga",
|
||||
];
|
||||
|
||||
/// The entries a mod adds to other structure sets in Java code, as its
|
||||
/// default config has them: (file name, set, `[(structure, weight)]`).
|
||||
/// Lithostitched appends them in the order of a hash map of every modifier
|
||||
/// in the pack, which carry cannot know; they are written in the order the
|
||||
/// mod registers them. Each set picks by weight, so how often each starts
|
||||
/// is the same.
|
||||
pub fn code_set_entries(mod_id: &str) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
|
||||
match mod_id {
|
||||
"ctov" => {
|
||||
let outposts = CTOV_OUTPOSTS
|
||||
.iter()
|
||||
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
|
||||
.collect();
|
||||
let villages = [("small", 10), ("medium", 4), ("large", 1)]
|
||||
.into_iter()
|
||||
.flat_map(|(size, weight)| {
|
||||
CTOV_VILLAGES
|
||||
.iter()
|
||||
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("pillager_outposts", "minecraft:pillager_outposts", outposts),
|
||||
("villages", "minecraft:villages", villages),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites one `data/<ns>/worldgen/<kind>/...` file in place; whether it
|
||||
/// changed.
|
||||
/// `set_tags` are the mod's structure set tags, expanded
|
||||
@@ -474,15 +260,16 @@ fn upgrade_types(
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
) -> bool {
|
||||
let lost = &mut report.rules_lost;
|
||||
// YUNG's and Lithostitched's types are converted apart from this code.
|
||||
if let Some(changed) =
|
||||
yung::upgrade(kind, v, set_tags, lost).or_else(|| lithostitched::upgrade(kind, v, lost))
|
||||
{
|
||||
report.mapped += usize::from(changed);
|
||||
return changed;
|
||||
}
|
||||
let mut unsupported = |t: &str| *report.unsupported.entry(t.to_owned()).or_default() += 1;
|
||||
let changed = match kind {
|
||||
"structure" => match v["type"].as_str() {
|
||||
// Better Dungeons' small Nether dungeon is YUNG's jigsaw behind a
|
||||
// config switch that ships on.
|
||||
Some("yungsapi:yung_jigsaw" | "betterdungeons:small_nether_dungeon") => {
|
||||
yung_structure(v, lost);
|
||||
true
|
||||
}
|
||||
Some(t)
|
||||
if JIGSAWS.contains(&t)
|
||||
|| NETHER_JIGSAWS.contains(&t)
|
||||
@@ -591,11 +378,6 @@ fn upgrade_types(
|
||||
true
|
||||
}
|
||||
Some("mostructures:type") => mo_placement(&mut v["placement"], lost),
|
||||
Some(t) if YUNG_SPREADS.contains(&t) => {
|
||||
let t = t.to_owned();
|
||||
yung_placement(&t, &mut v["placement"], set_tags, lost);
|
||||
true
|
||||
}
|
||||
Some(t) if !t.starts_with("minecraft:") => {
|
||||
unsupported(t);
|
||||
false
|
||||
@@ -615,6 +397,10 @@ fn upgrade_types(
|
||||
}
|
||||
}
|
||||
for element in elements {
|
||||
if yung::upgrade_pool_element(element, lost) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
let Some(t) = element["element_type"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
@@ -629,9 +415,6 @@ fn upgrade_types(
|
||||
}
|
||||
element["element_type"] = (*to).into();
|
||||
changed = true;
|
||||
} else if t == "yungsapi:yung_single_element" {
|
||||
yung_element(element, lost);
|
||||
changed = true;
|
||||
} else if t == "formations:single_pool_element" {
|
||||
// Vanilla's element with its own ground level (1 when
|
||||
// missing, as its codec reads it).
|
||||
@@ -653,167 +436,10 @@ fn upgrade_types(
|
||||
changed
|
||||
}
|
||||
|
||||
/// YUNG's API's jigsaw as vanilla's, laid out by its own assembler in the
|
||||
/// game (`horizon:yungsapi`, `horizon_world::worldgen::structure::yung`):
|
||||
/// the heights pieces stay within move under the key, its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`. A start offset in
|
||||
/// the chunk is lost.
|
||||
fn yung_structure(v: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert("type".into(), "minecraft:jigsaw".into());
|
||||
let mut rules = serde_json::Map::new();
|
||||
for key in ["min_y", "max_y"] {
|
||||
if let Some(y) = o.remove(key) {
|
||||
rules.insert(key.into(), y);
|
||||
}
|
||||
}
|
||||
// YungJigsawStructure's adaptation (NONE by default): every piece's
|
||||
// junctions, and the start's box grown by its radius.
|
||||
let own = o.remove(ENHANCED_TERRAIN);
|
||||
let mut kernel = yung_terrain(own.as_ref().unwrap_or(&Value::Null), lost);
|
||||
kernel["rigid_junctions"] = true.into();
|
||||
kernel["inflate"] = true.into();
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), kernel);
|
||||
for key in ["x_offset_in_chunk", "z_offset_in_chunk"] {
|
||||
if o.remove(key).is_some_and(|n| n != 0) {
|
||||
*lost.entry(key.into()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// YUNG's single pool element as vanilla's, its rules under
|
||||
/// `horizon:yungsapi`: name and `max_count`, depth range, priority,
|
||||
/// `ignore_bounds`, its condition, dead-end pool and modifiers; its terrain
|
||||
/// adaptation under `horizon:enhanced_terrain_adaptation`.
|
||||
fn yung_element(element: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
let Some(o) = element.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
o.insert(
|
||||
"element_type".into(),
|
||||
"minecraft:single_pool_element".into(),
|
||||
);
|
||||
let mut rules = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("name", "name"),
|
||||
("max_count", "max_count"),
|
||||
("min_required_depth", "min_depth"),
|
||||
("max_possible_depth", "max_depth"),
|
||||
("is_priority", "priority"),
|
||||
("ignore_bounds", "ignore_bounds"),
|
||||
] {
|
||||
if let Some(x) = o.remove(from) {
|
||||
rules.insert(to.into(), x);
|
||||
}
|
||||
}
|
||||
if let Some(c) = o.remove("condition") {
|
||||
rules.insert("condition".into(), yung_condition(&c, lost));
|
||||
}
|
||||
// A piece's own adaptation, NONE included, replaces its structure's.
|
||||
if let Some(a) = o.remove(ENHANCED_TERRAIN) {
|
||||
o.insert(HORIZON_ENHANCED_TERRAIN.into(), yung_terrain(&a, lost));
|
||||
}
|
||||
if let Some(pool) = o.remove("deadend_pool").filter(Value::is_string) {
|
||||
rules.insert("deadend_pool".into(), pool);
|
||||
}
|
||||
let modifiers: Vec<Value> = o
|
||||
.remove("modifiers")
|
||||
.and_then(|m| m.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|m| yung_modifier(m, lost))
|
||||
.collect();
|
||||
if !modifiers.is_empty() {
|
||||
rules.insert("modifiers".into(), modifiers.into());
|
||||
}
|
||||
o.insert("horizon:yungsapi".into(), rules.into());
|
||||
}
|
||||
|
||||
/// One of YUNG's structure modifiers in the game's terms: its condition and
|
||||
/// its transform and delay_generation actions, on the piece itself (`self`,
|
||||
/// the API's only target). Another target loses the modifier, another action
|
||||
/// just that action.
|
||||
fn yung_modifier(m: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let target = m["target_selector"]["type"].as_str().unwrap_or_default();
|
||||
if target != "yungsapi:self" {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier target {target}"))
|
||||
.or_default() += 1;
|
||||
return None;
|
||||
}
|
||||
let mut actions = Vec::new();
|
||||
for a in m["actions"].as_array().into_iter().flatten() {
|
||||
let int = |k: &str| a[k].as_i64().unwrap_or(0);
|
||||
match a["type"].as_str().unwrap_or_default() {
|
||||
"yungsapi:transform" => actions.push(serde_json::json!({"type": "transform",
|
||||
"output": a["output"].clone(),
|
||||
"offset": [int("x_offset"), int("y_offset"), int("z_offset")]})),
|
||||
"yungsapi:delay_generation" => {
|
||||
actions.push(serde_json::json!({"type": "delay_generation"}))
|
||||
}
|
||||
other => {
|
||||
*lost
|
||||
.entry(format!("yungsapi modifier action {other}"))
|
||||
.or_default() += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
let condition = match m.get("condition") {
|
||||
Some(c) => yung_condition(c, lost),
|
||||
None => serde_json::json!({"type": "true"}),
|
||||
};
|
||||
Some(serde_json::json!({"condition": condition, "actions": actions}))
|
||||
}
|
||||
|
||||
/// YUNG's API's terrain adaptation as the game's kernel: its two presets
|
||||
/// spelled out, NONE (also the default) as size 0. An aquifer override
|
||||
/// (none of ATM10's) is lost.
|
||||
fn yung_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let kernel = |size: i32, distance: i32, top: &str, bottom: &str| {
|
||||
serde_json::json!({"kernel_size": size, "kernel_distance": distance,
|
||||
"top": top, "bottom": bottom})
|
||||
};
|
||||
match a["type"].as_str() {
|
||||
Some("yungsapi:carved_top_no_beard_large") => kernel(24, 16, "carve", "none"),
|
||||
Some("yungsapi:carved_top_no_beard_small") => kernel(12, 6, "carve", "none"),
|
||||
Some("yungsapi:custom") => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for key in [
|
||||
"kernel_size",
|
||||
"kernel_distance",
|
||||
"top",
|
||||
"bottom",
|
||||
"bottom_offset",
|
||||
"padding",
|
||||
] {
|
||||
if let Some(x) = a.get(key) {
|
||||
out.insert(key.into(), x.clone());
|
||||
}
|
||||
}
|
||||
if a.get("aquifer_override")
|
||||
.is_some_and(|x| x["type"] != "yungsapi:none")
|
||||
{
|
||||
*lost.entry("yungsapi aquifer_override".into()).or_default() += 1;
|
||||
}
|
||||
moogs_terrain(&out.into(), lost).unwrap_or_else(|| kernel(0, 0, "none", "none"))
|
||||
}
|
||||
None | Some("yungsapi:none") => kernel(0, 0, "none", "none"),
|
||||
Some(other) => {
|
||||
*lost
|
||||
.entry(format!("yungsapi terrain adaptation {other}"))
|
||||
.or_default() += 1;
|
||||
kernel(0, 0, "none", "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MoogsStructureLib's terrain adaptation: already the game's shape (with
|
||||
/// its band), when it has the fields the game needs (else it is lost, as
|
||||
/// the library's codec would refuse it).
|
||||
fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
pub(crate) fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
|
||||
let ok = ["kernel_size", "kernel_distance"]
|
||||
.iter()
|
||||
.all(|k| a[k].is_u64())
|
||||
@@ -824,58 +450,15 @@ fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value>
|
||||
ok.then(|| a.clone())
|
||||
}
|
||||
|
||||
/// One of YUNG's structure conditions in the game's terms. `mod_loaded`
|
||||
/// holds for Minecraft alone: a mod that needs code is not ported, and the
|
||||
/// pieces it guards would place its blocks. A condition the game does not
|
||||
/// have does not hold, and is counted.
|
||||
fn yung_condition(c: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
|
||||
let t = c["type"].as_str().unwrap_or_default();
|
||||
let list = |lost: &mut BTreeMap<String, usize>| -> Value {
|
||||
let all = c["conditions"].as_array().into_iter().flatten();
|
||||
all.map(|x| yung_condition(x, lost))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
};
|
||||
let opt = |k: &str| c.get(k).cloned().unwrap_or(Value::Null);
|
||||
match t.trim_start_matches("yungsapi:") {
|
||||
"always_true" => serde_json::json!({"type": "true"}),
|
||||
"not" => {
|
||||
serde_json::json!({"type": "not", "condition": yung_condition(&c["condition"], lost)})
|
||||
}
|
||||
"all_of" => serde_json::json!({"type": "all_of", "conditions": list(lost)}),
|
||||
"any_of" => serde_json::json!({"type": "any_of", "conditions": list(lost)}),
|
||||
"depth" => serde_json::json!({"type": "depth",
|
||||
"min_depth": opt("min_required_depth"), "max_depth": opt("max_possible_depth")}),
|
||||
"altitude" => serde_json::json!({"type": "altitude",
|
||||
"bottom": opt("bottom_cutoff_y"), "top": opt("top_cutoff_y")}),
|
||||
"random_chance" => serde_json::json!({"type": "random_chance", "chance": opt("chance")}),
|
||||
"rotation" => serde_json::json!({"type": "rotation", "rotations": opt("rotations")}),
|
||||
"piece_in_horizontal_direction" => {
|
||||
// "yungsapi:all" (or no list) matches any piece.
|
||||
let pieces: Vec<Value> = c["pieces"].as_array().cloned().unwrap_or_default();
|
||||
let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all");
|
||||
serde_json::json!({"type": "piece_in_horizontal_direction",
|
||||
"pieces": if any { Vec::new() } else { pieces },
|
||||
"range": opt("range"), "rotation": opt("rotation")})
|
||||
}
|
||||
// The game reads "yungsapi:all" (or no list) as any piece.
|
||||
"piece_in_range" => serde_json::json!({"type": "piece_in_range",
|
||||
"pieces": c.get("pieces").cloned().unwrap_or_else(|| Vec::<Value>::new().into()),
|
||||
"horizontal_range": opt("horizontal_range"), "above_range": opt("above_range"),
|
||||
"below_range": opt("below_range")}),
|
||||
"mod_loaded" if c["modid"] == "minecraft" => serde_json::json!({"type": "true"}),
|
||||
"mod_loaded" => serde_json::json!({"type": "false"}),
|
||||
_ => {
|
||||
*lost.entry(format!("yungsapi condition {t}")).or_default() += 1;
|
||||
serde_json::json!({"type": "false"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a Repurposed Structures or Moog's jigsaw's start rules under
|
||||
/// `horizon:start_checks`, and counts the layout rules it loses. `liquid` is
|
||||
/// the block its no-start-in-liquid rule reads.
|
||||
fn start_checks(v: &mut Value, liquid: &str, lost: &mut BTreeMap<String, usize>, limited: bool) {
|
||||
pub(crate) fn start_checks(
|
||||
v: &mut Value,
|
||||
liquid: &str,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
limited: bool,
|
||||
) {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -1043,7 +626,7 @@ fn moogs_spacing(p: &mut Value, lost: &mut BTreeMap<String, usize>) {
|
||||
|
||||
/// An advanced random spread's minimum distance from the world origin and
|
||||
/// its super exclusion zone (sets named by a tag) under `horizon:` keys.
|
||||
fn placement_rules(
|
||||
pub(crate) fn placement_rules(
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
@@ -1115,65 +698,6 @@ fn mo_placement(p: &mut Value, lost: &mut BTreeMap<String, usize>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One of YUNG's placements as vanilla's random spread with `horizon:` keys:
|
||||
/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples'
|
||||
/// ocean and river search (findBiomeHorizontal around the chunk's middle
|
||||
/// block at y 120, 48 blocks, every 2 quarts) as `horizon:avoid_biomes`; the
|
||||
/// strongholds' rings as `horizon:ring_sections`.
|
||||
fn yung_placement(
|
||||
t: &str,
|
||||
p: &mut Value,
|
||||
set_tags: &BTreeMap<String, Vec<String>>,
|
||||
lost: &mut BTreeMap<String, usize>,
|
||||
) {
|
||||
placement_rules(p, set_tags, lost);
|
||||
p["type"] = "minecraft:random_spread".into();
|
||||
let Some(o) = p.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
match t {
|
||||
"betterdeserttemples:desert_temple" | "betterjungletemples:jungle_temple" => {
|
||||
o.insert(
|
||||
"horizon:avoid_biomes".into(),
|
||||
serde_json::json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120}),
|
||||
);
|
||||
}
|
||||
"betterstrongholds:stronghold" => {
|
||||
let mut rings = serde_json::Map::new();
|
||||
for (from, to) in [
|
||||
("chunk_distance_to_first_ring", "distance_to_first_ring"),
|
||||
("ring_chunk_thickness", "thickness"),
|
||||
("max_ring_section", "max_section"),
|
||||
] {
|
||||
if let Some(n) = o.remove(from) {
|
||||
rings.insert(to.into(), n);
|
||||
}
|
||||
}
|
||||
o.insert("horizon:ring_sections".into(), rings.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a YUNG's structure that the game can start (its type is vanilla's
|
||||
/// after the upgrade) with the vanilla type its mod turns off, under
|
||||
/// `horizon:disables`. One the game cannot start leaves the vanilla
|
||||
/// structure in place, so the world keeps one of the two.
|
||||
pub fn disables(id: &str, v: &mut Value) -> bool {
|
||||
let Some((_, vanilla)) = YUNG_REPLACES.iter().find(|(s, _)| id.starts_with(s)) else {
|
||||
return false;
|
||||
};
|
||||
if !v["type"]
|
||||
.as_str()
|
||||
.is_some_and(|t| t.starts_with("minecraft:"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
v["horizon:disables"] = serde_json::json!([vanilla]);
|
||||
true
|
||||
}
|
||||
|
||||
/// The mod's structure set tags (`data/<ns>/tags/worldgen/structure_set/`),
|
||||
/// by tag id, with the tags they include expanded. Optional entries
|
||||
/// (`{"id": ..., "required": false}`) count as their id.
|
||||
@@ -1590,94 +1114,6 @@ mod tests {
|
||||
assert_eq!(report.rules_lost["super_exclusion_zone"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_jigsaws_keep_their_rules() {
|
||||
let mut report = Upgrade::default();
|
||||
let mut s = json!({"type": "yungsapi:yung_jigsaw", "start_pool": "y:start", "size": 20,
|
||||
"max_y": 60, "x_offset_in_chunk": 0, "max_distance_from_center": 80,
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:custom", "kernel_size": 24,
|
||||
"kernel_distance": 24, "top": "carve", "bottom": "none"}});
|
||||
assert!(upgrade("structure", &mut s, &mut report, &NO_TAGS));
|
||||
assert_eq!(s["type"], "minecraft:jigsaw");
|
||||
assert_eq!(s["horizon:yungsapi"], json!({"max_y": 60}));
|
||||
assert_eq!(
|
||||
s["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 24, "kernel_distance": 24, "top": "carve", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
for gone in ["max_y", "x_offset_in_chunk", "enhanced_terrain_adaptation"] {
|
||||
assert!(s.get(gone).is_none(), "{gone}");
|
||||
}
|
||||
// Better Dungeons' small Nether dungeon is the same jigsaw.
|
||||
let mut nether = json!({"type": "betterdungeons:small_nether_dungeon",
|
||||
"start_pool": "betterdungeons:small_nether_dungeon", "size": 10,
|
||||
"start_jigsaw_name": "betterdungeons:small_nether_dungeon_anchor",
|
||||
"max_distance_from_center": 80, "liquid_settings": "ignore_waterlogging"});
|
||||
assert!(upgrade("structure", &mut nether, &mut report, &NO_TAGS));
|
||||
assert_eq!(nether["type"], "minecraft:jigsaw");
|
||||
assert_eq!(nether["horizon:yungsapi"], json!({}));
|
||||
assert_eq!(
|
||||
nether["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 0, "kernel_distance": 0, "top": "none", "bottom": "none",
|
||||
"rigid_junctions": true, "inflate": true})
|
||||
);
|
||||
assert_eq!(
|
||||
nether["start_jigsaw_name"],
|
||||
"betterdungeons:small_nether_dungeon_anchor"
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
let mut pool = json!({"elements": [{"weight": 3, "element": {
|
||||
"element_type": "yungsapi:yung_single_element", "location": "y:room",
|
||||
"processors": "minecraft:empty", "projection": "rigid",
|
||||
"name": "room", "max_count": 2, "min_required_depth": 1, "is_priority": true,
|
||||
"ignore_bounds": false, "deadend_pool": "y:end",
|
||||
"modifiers": [
|
||||
{"condition": {"type": "yungsapi:piece_in_range", "pieces": ["y:pillar"],
|
||||
"above_range": 40},
|
||||
"target_selector": {"type": "yungsapi:self"},
|
||||
"actions": [{"type": "yungsapi:transform", "output": ["y:covered"], "x_offset": -1},
|
||||
{"type": "yungsapi:delay_generation"}, {"type": "yungsapi:spin"}]},
|
||||
{"condition": {"type": "yungsapi:always_true"},
|
||||
"target_selector": {"type": "yungsapi:all"}, "actions": []}],
|
||||
"enhanced_terrain_adaptation": {"type": "yungsapi:carved_top_no_beard_small"},
|
||||
"condition": {"type": "yungsapi:all_of", "conditions": [
|
||||
{"type": "yungsapi:piece_in_horizontal_direction", "pieces": ["yungsapi:all"],
|
||||
"range": 5, "rotation": "none"},
|
||||
{"type": "yungsapi:not", "condition": {"type": "yungsapi:mod_loaded", "modid": "create"}},
|
||||
{"type": "yungsapi:biome", "biomes": []}]}}}]});
|
||||
assert!(upgrade("template_pool", &mut pool, &mut report, &NO_TAGS));
|
||||
let e = &pool["elements"][0]["element"];
|
||||
assert_eq!(e["element_type"], "minecraft:single_pool_element");
|
||||
assert_eq!(e["location"], "y:room");
|
||||
assert_eq!(
|
||||
e["horizon:yungsapi"],
|
||||
json!({"name": "room", "max_count": 2, "min_depth": 1, "priority": true,
|
||||
"ignore_bounds": false, "condition": {"type": "all_of", "conditions": [
|
||||
{"type": "piece_in_horizontal_direction", "pieces": [], "range": 5, "rotation": "none"},
|
||||
{"type": "not", "condition": {"type": "false"}},
|
||||
{"type": "false"}]},
|
||||
"deadend_pool": "y:end",
|
||||
"modifiers": [{"condition": {"type": "piece_in_range", "pieces": ["y:pillar"],
|
||||
"horizontal_range": null, "above_range": 40, "below_range": null},
|
||||
"actions": [{"type": "transform", "output": ["y:covered"], "offset": [-1, 0, 0]},
|
||||
{"type": "delay_generation"}]}]})
|
||||
);
|
||||
assert!(e.get("deadend_pool").is_none() && e.get("modifiers").is_none());
|
||||
assert_eq!(
|
||||
e["horizon:enhanced_terrain_adaptation"],
|
||||
json!({"kernel_size": 12, "kernel_distance": 6, "top": "carve", "bottom": "none"})
|
||||
);
|
||||
assert_eq!(
|
||||
report.rules_lost,
|
||||
BTreeMap::from([
|
||||
("yungsapi condition yungsapi:biome".into(), 1),
|
||||
("yungsapi modifier action yungsapi:spin".into(), 1),
|
||||
("yungsapi modifier target yungsapi:all".into(), 1),
|
||||
])
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_processors_written_in_java() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1722,59 +1158,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yungs_placements_keep_their_rules() {
|
||||
let tags = BTreeMap::from([(
|
||||
"bf:avoid".to_owned(),
|
||||
vec!["minecraft:nether_complexes".to_owned()],
|
||||
)]);
|
||||
let mut report = Upgrade::default();
|
||||
let mut fortress = json!({"placement": {"type": "yungsapi:enhanced_random_spread",
|
||||
"salt": 1, "spacing": 30, "separation": 20,
|
||||
"enhanced_exclusion_zone": {"other_set": "#bf:avoid", "chunk_count": 10}}});
|
||||
assert!(upgrade("structure_set", &mut fortress, &mut report, &tags));
|
||||
assert_eq!(
|
||||
fortress["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 1, "spacing": 30, "separation": 20,
|
||||
"horizon:exclusion_zone": {"other_sets": ["minecraft:nether_complexes"], "chunk_count": 10}})
|
||||
);
|
||||
let mut temple = json!({"placement": {"type": "betterdeserttemples:desert_temple",
|
||||
"salt": 2, "spacing": 30, "separation": 20,
|
||||
"exclusion_zone": {"other_set": "minecraft:villages", "chunk_count": 6}}});
|
||||
assert!(upgrade("structure_set", &mut temple, &mut report, &tags));
|
||||
assert_eq!(temple["placement"]["type"], "minecraft:random_spread");
|
||||
assert_eq!(temple["placement"]["exclusion_zone"]["chunk_count"], 6);
|
||||
assert_eq!(
|
||||
temple["placement"]["horizon:avoid_biomes"],
|
||||
json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
|
||||
"radius": 48, "step": 2, "y": 120})
|
||||
);
|
||||
let mut stronghold = json!({"placement": {"type": "betterstrongholds:stronghold",
|
||||
"salt": 3, "spacing": 85, "separation": 50,
|
||||
"chunk_distance_to_first_ring": 80, "ring_chunk_thickness": 96}});
|
||||
assert!(upgrade(
|
||||
"structure_set",
|
||||
&mut stronghold,
|
||||
&mut report,
|
||||
&tags
|
||||
));
|
||||
assert_eq!(
|
||||
stronghold["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 3, "spacing": 85, "separation": 50,
|
||||
"horizon:ring_sections": {"distance_to_first_ring": 80, "thickness": 96}})
|
||||
);
|
||||
assert_eq!(report.mapped, 3);
|
||||
assert!(report.unsupported.is_empty());
|
||||
// The vanilla counterpart is turned off only for a structure the game starts.
|
||||
let mut ported = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(disables("betterstrongholds:stronghold", &mut ported));
|
||||
assert_eq!(ported["horizon:disables"], json!(["minecraft:stronghold"]));
|
||||
let mut java = json!({"type": "bettermineshafts:mineshaft"});
|
||||
assert!(!disables("bettermineshafts:mineshaft_oak", &mut java));
|
||||
let mut circle = json!({"type": "minecraft:jigsaw"});
|
||||
assert!(!disables("betterwitchhuts:witch_circle", &mut circle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_limited_jigsaws_keep_their_layout() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1886,88 +1269,6 @@ mod tests {
|
||||
assert_eq!(report.mapped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_add_to_sets_and_pools() {
|
||||
let mut report = Upgrade::default();
|
||||
let only_fd = |m: &str| m == "farmersdelight";
|
||||
let set = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "minecraft:villages",
|
||||
"entries": [{"structure": "x:village", "weight": 4}]});
|
||||
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "structure_set");
|
||||
assert_eq!(
|
||||
added,
|
||||
json!({"horizon:add_to": ["minecraft:villages"],
|
||||
"structures": [{"structure": "x:village", "weight": 4}]})
|
||||
);
|
||||
// A compat addition holds only when its mod is ported along.
|
||||
let pool = |modid: &str| {
|
||||
json!({"type": "lithostitched:add_template_pool_elements",
|
||||
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
|
||||
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
|
||||
"template_pools": ["x:houses", "y:houses"],
|
||||
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
|
||||
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
|
||||
};
|
||||
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
|
||||
assert_eq!(kind, "template_pool");
|
||||
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
|
||||
assert_eq!(added["elements"][0]["weight"], 5);
|
||||
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
|
||||
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
|
||||
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"fabric:load_conditions": [{"condition": "fabric:not",
|
||||
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
|
||||
"predicate": {"type": "lithostitched:all_of", "predicates": [
|
||||
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
|
||||
{"type": "lithostitched:true"}]},
|
||||
"structure_sets": "minecraft:villages", "entries": []});
|
||||
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
|
||||
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
|
||||
"structure_sets": "#x:sets", "entries": []});
|
||||
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
|
||||
let features = json!({"type": "lithostitched:add_features"});
|
||||
assert!(modifier(&features, &only_fd, &mut report).is_none());
|
||||
assert_eq!(
|
||||
report.added_to,
|
||||
BTreeMap::from([
|
||||
("minecraft:villages".into(), 1),
|
||||
("x:houses".into(), 1),
|
||||
("y:houses".into(), 1),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
report.modifiers_left,
|
||||
BTreeMap::from([
|
||||
("conditions".into(), 1),
|
||||
("lithostitched:add_features".into(), 1),
|
||||
("target".into(), 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctov_joins_vanillas_villages_and_outposts() {
|
||||
let sets = code_set_entries("ctov");
|
||||
let (file, set, outposts) = &sets[0];
|
||||
assert_eq!(
|
||||
(*file, *set),
|
||||
("pillager_outposts", "minecraft:pillager_outposts")
|
||||
);
|
||||
assert_eq!(outposts.len(), 11);
|
||||
assert!(outposts.iter().all(|(_, w)| *w == 1));
|
||||
let (_, set, villages) = &sets[1];
|
||||
assert_eq!(*set, "minecraft:villages");
|
||||
assert_eq!(villages.len(), 63);
|
||||
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
|
||||
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
|
||||
assert_eq!(
|
||||
villages[62],
|
||||
("ctov:large/village_taiga_fortified".into(), 1)
|
||||
);
|
||||
assert!(code_set_entries("mvs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convention_tags_include_theirs() {
|
||||
let conventions = BTreeMap::from([
|
||||
|
||||
@@ -36,41 +36,82 @@ fn executable(dir: &Path, name: &str) -> PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
fn on_path(name: &str) -> Option<PathBuf> {
|
||||
std::env::split_paths(&std::env::var_os("PATH")?)
|
||||
.map(|dir| executable(&dir, name))
|
||||
.find(|p| p.is_file())
|
||||
/// `fs::canonicalize`, without the `\\?\` prefix it gives a Windows drive path: the
|
||||
/// home goes into `JAVA_HOME` and the PATH of preparers, whose lookups don't take it.
|
||||
fn real_path(path: PathBuf) -> PathBuf {
|
||||
let real = fs::canonicalize(&path).unwrap_or(path);
|
||||
if cfg!(windows) {
|
||||
without_verbatim(real)
|
||||
} else {
|
||||
real
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(javac)
|
||||
fn without_verbatim(path: PathBuf) -> PathBuf {
|
||||
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
|
||||
_ => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve compiler symlinks before picking its sibling runtime. The first `java` on the
|
||||
/// PATH may be an older, runtime-only install; it must not be paired with a newer `javac`.
|
||||
fn path_jdks(path: &OsStr) -> Vec<PathBuf> {
|
||||
std::env::split_paths(path)
|
||||
.map(|dir| executable(&dir, "javac"))
|
||||
.filter(|javac| javac.is_file())
|
||||
.map(real_path)
|
||||
.filter_map(|javac| {
|
||||
let bin = javac.parent()?;
|
||||
// macOS's system tools are dispatchers, not a JDK home: JAVA_HOME=/usr
|
||||
// makes them call themselves. /usr/libexec/java_home finds the actual JDK.
|
||||
if bin.file_name()? != "bin"
|
||||
|| (cfg!(target_os = "macos") && bin == Path::new("/usr/bin"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(bin.parent()?.to_path_buf())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The major version `javac -version` or `java -version` reports, including Java 8's `1.8`.
|
||||
fn tool_major(program: &Path, name: &str) -> Result<u32, String> {
|
||||
let output = command(program)
|
||||
.arg("-version")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| format!("{}: {e}", javac.display()))?;
|
||||
.map_err(|e| format!("{}: {e}", program.display()))?;
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
// macOS ships /usr/bin/java and /usr/bin/javac as placeholders.
|
||||
if text.contains("Unable to locate a Java Runtime") {
|
||||
return Err(format!(
|
||||
"{} is macOS's placeholder (no Java installed)",
|
||||
program.display()
|
||||
));
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{} -version failed ({}): {}",
|
||||
program.display(),
|
||||
output.status,
|
||||
text.trim()
|
||||
));
|
||||
}
|
||||
let marker = if name == "javac" { "javac" } else { "version" };
|
||||
let version = text
|
||||
.split_whitespace()
|
||||
.skip_while(|w| *w != "javac")
|
||||
.skip_while(|w| *w != marker)
|
||||
.nth(1)
|
||||
.ok_or_else(|| {
|
||||
// macOS ships /usr/bin/javac as a placeholder that only says no Java is installed.
|
||||
if text.contains("Unable to locate a Java Runtime") {
|
||||
format!(
|
||||
"{} is macOS's placeholder (no Java installed)",
|
||||
javac.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} -version printed no version: {}",
|
||||
javac.display(),
|
||||
text.trim()
|
||||
)
|
||||
}
|
||||
format!(
|
||||
"{} -version printed no version: {}",
|
||||
program.display(),
|
||||
text.trim()
|
||||
)
|
||||
})?;
|
||||
let mut parts = version.split(['.', '-', '+', '_']);
|
||||
let mut parts = version.trim_matches('"').split(['.', '-', '+', '_']);
|
||||
let first: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
Ok(if first == 1 {
|
||||
parts.next().and_then(|p| p.parse().ok()).unwrap_or(0)
|
||||
@@ -175,11 +216,10 @@ pub fn minecraft_runtimes(runtime: &Path) -> Vec<PathBuf> {
|
||||
homes
|
||||
}
|
||||
|
||||
/// Finds a JDK with `javac` of at least `major`, the version research-lock.json pins:
|
||||
/// `JAVA_HOME` when it holds one, else `javac` and `java` on the PATH (as the Python preparer
|
||||
/// ran them), else, on macOS, the JDK `/usr/libexec/java_home -v <major>` reports, else the JDK
|
||||
/// homes in `more` (the launcher's pick, Minecraft's own runtimes), else a JDK in the folders
|
||||
/// installers put them in (`jdk_parents`).
|
||||
/// Finds a JDK whose compiler and runtime are both at least `major`, the version
|
||||
/// research-lock.json pins: `JAVA_HOME`, else JDKs on the PATH, else, on macOS, the JDK
|
||||
/// `/usr/libexec/java_home -v <major>` reports, else the homes in `more` (the launcher's pick,
|
||||
/// Minecraft's own runtimes), else the folders installers use (`jdk_parents`).
|
||||
pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
|
||||
let need = format!(
|
||||
"Java {major} is needed (a JDK with javac, e.g. Eclipse Temurin {major}). \
|
||||
@@ -194,8 +234,11 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
|
||||
"JAVA_HOME",
|
||||
));
|
||||
}
|
||||
if let (Some(javac), Some(java)) = (on_path("javac"), on_path("java")) {
|
||||
candidates.push((javac, java, "PATH"));
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
for home in path_jdks(&path) {
|
||||
let bin = home.join("bin");
|
||||
candidates.push((executable(&bin, "javac"), executable(&bin, "java"), "PATH"));
|
||||
}
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Ok(out) = command("/usr/libexec/java_home")
|
||||
@@ -239,10 +282,19 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match javac_major(&javac) {
|
||||
Ok(found) if i64::from(found) >= major => return Ok(Jdk { javac, java }),
|
||||
Ok(found) => problems.push(format!("{from} has Java {found} ({})", javac.display())),
|
||||
Err(e) => problems.push(e),
|
||||
let checked = [(&javac, "javac"), (&java, "java")]
|
||||
.into_iter()
|
||||
.try_for_each(|(program, name)| {
|
||||
let found = tool_major(program, name)?;
|
||||
if i64::from(found) >= major {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{name} {found} ({})", program.display()))
|
||||
}
|
||||
});
|
||||
match checked {
|
||||
Ok(()) => return Ok(Jdk { javac, java }),
|
||||
Err(e) => problems.push(format!("{from}: {e}")),
|
||||
}
|
||||
}
|
||||
if problems.is_empty() {
|
||||
@@ -328,6 +380,22 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verbatim_drive_paths_lose_their_prefix() {
|
||||
let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe";
|
||||
assert_eq!(
|
||||
without_verbatim(PathBuf::from(format!(r"\\?\{jdk}"))),
|
||||
PathBuf::from(jdk)
|
||||
);
|
||||
// A UNC share keeps its verbatim form: stripping it would leave no valid path.
|
||||
let unc = r"\\?\UNC\server\jdk\bin\javac.exe";
|
||||
assert_eq!(without_verbatim(PathBuf::from(unc)), PathBuf::from(unc));
|
||||
assert_eq!(
|
||||
without_verbatim(PathBuf::from("/usr/lib/jvm/bin/javac")),
|
||||
PathBuf::from("/usr/lib/jvm/bin/javac")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_jdks_prefer_the_pinned_major() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-prepare-jdks-{}", std::process::id()));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod pyjson;
|
||||
mod sounds;
|
||||
mod survival;
|
||||
|
||||
pub use sounds::{fonts, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
pub use sounds::{fonts, languages, packs, panorama, sounds, BUILT_IN_PACKS};
|
||||
|
||||
use pyjson::Value;
|
||||
use sha2::Digest;
|
||||
|
||||
@@ -20,6 +20,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
|
||||
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare languages [--minecraft-dir PATH] [--root PATH]
|
||||
|
||||
--prepare Import the pinned game files from your own copy first
|
||||
--server-jar PATH The pinned server jar you downloaded yourself
|
||||
@@ -36,7 +37,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
|
||||
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
|
||||
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
|
||||
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
|
||||
Full preparation also imports all three after extracting the client JAR's placeholders.
|
||||
horizon-prepare languages adds the bundled languages and the language list the JAR lacks the same way.
|
||||
Full preparation also imports all four after extracting the client JAR's placeholders.
|
||||
|
||||
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
|
||||
launcher's asset store into the same folder, checking every file. It is optional: without it the
|
||||
@@ -68,8 +70,12 @@ fn main() -> ExitCode {
|
||||
};
|
||||
let mut root = None;
|
||||
let mut args = std::env::args_os().skip(1).peekable();
|
||||
let assets =
|
||||
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
|
||||
let assets = args.next_if(|a| {
|
||||
matches!(
|
||||
a.to_str(),
|
||||
Some("sounds" | "panorama" | "fonts" | "packs" | "languages")
|
||||
)
|
||||
});
|
||||
while let Some(arg) = args.next() {
|
||||
let text = arg.to_string_lossy().into_owned();
|
||||
let (flag, inline) = match text.split_once('=') {
|
||||
@@ -138,6 +144,9 @@ fn main() -> ExitCode {
|
||||
Some("packs") => {
|
||||
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
Some("languages") => {
|
||||
horizon_prepare::languages(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
_ => horizon_prepare::prepare(&options, &log),
|
||||
};
|
||||
match result {
|
||||
|
||||
@@ -112,6 +112,20 @@ pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf,
|
||||
copy_assets(root, minecraft, log, is_font, "Font", &UNIFONT)
|
||||
}
|
||||
|
||||
/// Every bundled language and the language list, both of which only the asset index holds
|
||||
/// (the client JAR carries `en_us` alone): `pack.mcmeta`'s `language` section is what the
|
||||
/// language screen offers, `minecraft/lang/<code>.json` the strings each one draws (#1272).
|
||||
/// Also usable on already-prepared resources without Java.
|
||||
pub fn languages(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
|
||||
copy_assets(root, minecraft, log, is_language, "Language", &LANGUAGES)
|
||||
}
|
||||
|
||||
const LANGUAGES: [&str; 1] = ["pack.mcmeta"];
|
||||
|
||||
fn is_language(name: &str) -> bool {
|
||||
name == "pack.mcmeta" || name.starts_with("minecraft/lang/")
|
||||
}
|
||||
|
||||
/// Full preparation can run from cached JARs without an official launcher install.
|
||||
pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
if let Err(error) = panorama(root, minecraft, log) {
|
||||
@@ -129,6 +143,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
|
||||
));
|
||||
}
|
||||
if let Err(error) = languages(root, minecraft, log) {
|
||||
log(&format!(
|
||||
"Languages not imported: {error}; run horizon-prepare languages later"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const PANORAMA: [&str; 7] = [
|
||||
@@ -278,6 +297,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn with_panorama(name: &str, panorama: &[&'static str]) -> Fixture {
|
||||
Self::with_extras(name, panorama, &[])
|
||||
}
|
||||
|
||||
/// The same store with extra index entries, for the files the index alone holds.
|
||||
fn with_extras(
|
||||
name: &str,
|
||||
panorama: &[&'static str],
|
||||
extras: &[(&'static str, &'static [u8])],
|
||||
) -> Fixture {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-prepare-sounds-{name}-{}",
|
||||
std::process::id()
|
||||
@@ -302,6 +330,9 @@ mod tests {
|
||||
// Distinct object bytes let the test catch swapped faces too.
|
||||
objects.push((name, name.as_bytes().to_vec()));
|
||||
}
|
||||
for &(name, data) in extras {
|
||||
objects.push((name, data.to_vec()));
|
||||
}
|
||||
let mut index = String::from("{\"objects\": {");
|
||||
for (i, (name, data)) in objects.iter().enumerate() {
|
||||
let hash = sha1_hex(data);
|
||||
@@ -391,6 +422,32 @@ mod tests {
|
||||
f.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_come_from_the_asset_store() {
|
||||
// The client JAR carries `en_us` alone, so every other language and the list in
|
||||
// `pack.mcmeta` come from the index (#1272).
|
||||
let f = Fixture::with_extras(
|
||||
"languages",
|
||||
&[],
|
||||
&[("pack.mcmeta", b"{\"language\": {\"fr_fr\": {}}}")],
|
||||
);
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let target = languages(&f.root, Some(&f.minecraft), &|l| {
|
||||
lines.borrow_mut().push(l.to_string())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fs::read(target.join("assets/pack.mcmeta"))
|
||||
.unwrap()
|
||||
.starts_with(b"{\"language\""));
|
||||
assert_eq!(
|
||||
fs::read(target.join("assets/minecraft/lang/fr_fr.json")).unwrap(),
|
||||
b"{}"
|
||||
);
|
||||
// Each entry point copies its own set: the sounds stay where they belong.
|
||||
assert!(!target.join("assets/minecraft/sounds.json").exists());
|
||||
assert_eq!(*lines.borrow(), ["Language assets ready: 2 files, 0.0 MB"]);
|
||||
}
|
||||
|
||||
/// #1076: the launcher holds an index Mojang republished under the same id, so its SHA-1
|
||||
/// is not the one in the version JSON. A known one is used quietly, an unknown one with a
|
||||
/// line in the log; the sounds come through either way.
|
||||
@@ -539,7 +596,10 @@ mod tests {
|
||||
lines[1..],
|
||||
[
|
||||
"Font assets ready: 2 files, 0.0 MB",
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB"
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB",
|
||||
// This store has no `pack.mcmeta`, so the languages report it.
|
||||
"Languages not imported: pack.mcmeta is missing from the asset index; \
|
||||
run horizon-prepare languages later"
|
||||
]
|
||||
),
|
||||
_ => {
|
||||
@@ -549,9 +609,14 @@ mod tests {
|
||||
&& lines[2].ends_with("; run horizon-prepare packs later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines[3].starts_with("Languages not imported: ")
|
||||
&& lines[3].ends_with("; run horizon-prepare languages later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines.len(), 4);
|
||||
for name in PANORAMA {
|
||||
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Each scenario runs in its own process so Java discovery sees a controlled environment.
|
||||
use horizon_prepare::java;
|
||||
use std::{env, fs, path::Path, process::Command};
|
||||
|
||||
const TEST: &str = "selects_a_compatible_compiler_and_runtime";
|
||||
const CHILD: &str = "HORIZON_JAVA_SELECTION_CHILD";
|
||||
const ROOT: &str = "HORIZON_JAVA_SELECTION_ROOT";
|
||||
|
||||
fn tool(bin: &Path, name: &str) -> std::path::PathBuf {
|
||||
bin.join(format!("{name}{}", env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_a_compatible_compiler_and_runtime() {
|
||||
if let Ok(scenario) = env::var(CHILD) {
|
||||
let root = std::path::PathBuf::from(env::var_os(ROOT).unwrap());
|
||||
if scenario.ends_with("error") {
|
||||
// Use a future compiler version to keep real installed JDKs out of the result.
|
||||
let error = java::find(255, &[]).err().expect("accepted an old runtime");
|
||||
let runtime = if scenario == "legacy-runtime-error" {
|
||||
8
|
||||
} else {
|
||||
21
|
||||
};
|
||||
assert!(error.contains(&format!("java {runtime} (")), "{error}");
|
||||
return;
|
||||
}
|
||||
let jdk = java::find(25, &[]).unwrap();
|
||||
let expected = root.join(if scenario == "target-release" {
|
||||
"newer"
|
||||
} else {
|
||||
"jdk25"
|
||||
});
|
||||
for (actual, name) in [(&jdk.javac, "javac"), (&jdk.java, "java")] {
|
||||
assert_eq!(
|
||||
fs::canonicalize(actual).unwrap(),
|
||||
fs::canonicalize(tool(&expected.join("bin"), name)).unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs::canonicalize(jdk.home()).unwrap(),
|
||||
fs::canonicalize(expected).unwrap()
|
||||
);
|
||||
let log_path = root.join(format!("{scenario}.log"));
|
||||
let log = fs::File::create(&log_path).unwrap();
|
||||
jdk.java(&[], None, &log).unwrap();
|
||||
assert_eq!(fs::read_to_string(log_path).unwrap().trim(), "java 25.0.1");
|
||||
return;
|
||||
}
|
||||
|
||||
let root = env::temp_dir().join(format!("horizon-java-selection-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let probe = tool(&root, "probe");
|
||||
let output = Command::new("rustc")
|
||||
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/java_probe.rs"))
|
||||
.arg("-o")
|
||||
.arg(&probe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
for (home, compiler, runtime) in [
|
||||
("jre21", None, "21.0.1"),
|
||||
("jdk21", Some("21.0.1"), "21.0.1"),
|
||||
("jdk25", Some("25.0.1"), "25.0.1"),
|
||||
("mixed", Some("25.0.1"), "21.0.1"),
|
||||
("failing", Some("25.0.1"), "25.0.1"),
|
||||
("newer", Some("26-ea+1"), "25.0.1"),
|
||||
("future", Some("255.0.1"), "21.0.1"),
|
||||
("legacy", Some("255.0.1"), "1.8.0_442"),
|
||||
] {
|
||||
let bin = root.join(home).join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
for (name, version) in [("javac", compiler), ("java", Some(runtime))] {
|
||||
if let Some(version) = version {
|
||||
fs::copy(&probe, tool(&bin, name)).unwrap();
|
||||
fs::write(bin.join(format!("{name}.version")), version).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(root.join("failing/bin/java.exit"), "7").unwrap();
|
||||
let scenarios = vec![
|
||||
("older-runtime", None, vec!["jre21", "jdk25"]),
|
||||
("older-jdk", None, vec!["jdk21", "jdk25"]),
|
||||
("incompatible-home", Some("mixed"), vec!["jdk25"]),
|
||||
("failed-runtime", Some("failing"), vec!["jdk25"]),
|
||||
("target-release", Some("newer"), vec![]),
|
||||
("runtime-error", Some("future"), vec![]),
|
||||
("legacy-runtime-error", Some("legacy"), vec![]),
|
||||
];
|
||||
#[cfg(unix)]
|
||||
let scenarios = {
|
||||
let mut scenarios = scenarios;
|
||||
let bin = root.join("shims/bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
std::os::unix::fs::symlink(tool(&root.join("jdk25/bin"), "javac"), tool(&bin, "javac"))
|
||||
.unwrap();
|
||||
std::os::unix::fs::symlink(tool(&root.join("jre21/bin"), "java"), tool(&bin, "java"))
|
||||
.unwrap();
|
||||
scenarios.push(("symlinked-path", None, vec!["shims"]));
|
||||
scenarios
|
||||
};
|
||||
for (scenario, home, path) in scenarios {
|
||||
let mut child = Command::new(env::current_exe().unwrap());
|
||||
child
|
||||
.args(["--exact", TEST, "--nocapture"])
|
||||
.env(CHILD, scenario)
|
||||
.env(ROOT, &root)
|
||||
.env(
|
||||
"PATH",
|
||||
env::join_paths(path.iter().map(|home| root.join(home).join("bin"))).unwrap(),
|
||||
)
|
||||
.env("HOME", &root)
|
||||
.env("USERPROFILE", &root)
|
||||
.env("ProgramFiles", &root)
|
||||
.env("ProgramW6432", &root)
|
||||
.env_remove("JAVA_HOME");
|
||||
if let Some(home) = home {
|
||||
child.env("JAVA_HOME", root.join(home));
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{scenario}:\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! A fake Java tool for process-level JDK selection tests; no installed Java is needed.
|
||||
use std::{env, fs, process};
|
||||
|
||||
fn main() {
|
||||
let executable = env::current_exe().unwrap();
|
||||
let bin = executable.parent().unwrap();
|
||||
let tool = executable.file_stem().unwrap().to_str().unwrap();
|
||||
let version = fs::read_to_string(bin.join(format!("{tool}.version"))).unwrap();
|
||||
if env::args().nth(1).as_deref() == Some("-version") {
|
||||
if tool == "javac" {
|
||||
println!("javac {version}");
|
||||
} else {
|
||||
eprintln!("openjdk version \"{version}\"");
|
||||
}
|
||||
} else {
|
||||
println!("{tool} {version}");
|
||||
}
|
||||
if let Ok(status) = fs::read_to_string(bin.join(format!("{tool}.exit"))) {
|
||||
process::exit(status.parse().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,9 @@ ipconfig = "0.3.4"
|
||||
name = "protocol_probe"
|
||||
path = "examples/probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "item_wire_probe"
|
||||
path = "examples/item_wire_probe.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user