mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
59
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
|
||||
@@ -39,6 +39,8 @@ 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
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -4046,6 +4046,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();
|
||||
@@ -4260,23 +4274,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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1939,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 {
|
||||
@@ -2036,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 {
|
||||
|
||||
@@ -612,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())
|
||||
@@ -1601,6 +1616,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;
|
||||
@@ -3064,6 +3085,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();
|
||||
@@ -4233,11 +4256,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
|
||||
@@ -4251,7 +4276,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;
|
||||
}
|
||||
@@ -4572,12 +4597,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));
|
||||
}
|
||||
@@ -4646,7 +4669,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);
|
||||
@@ -5505,4 +5528,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,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,
|
||||
})
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -448,60 +448,109 @@ pub fn check_file(
|
||||
(None, None) => Trust::Unsigned,
|
||||
_ => return Err("its signature and the project's key don't go together".into()),
|
||||
};
|
||||
match project.kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
crate::Content::read(path, &m)?;
|
||||
let mut caps = m.capabilities.clone();
|
||||
caps.sort();
|
||||
let mut listed = version.capabilities.clone();
|
||||
listed.sort();
|
||||
let differs = m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.api != version.api
|
||||
|| Some(m.side) != version.side
|
||||
|| caps != listed;
|
||||
if differs {
|
||||
return Err(format!(
|
||||
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
|
||||
m.id,
|
||||
m.version,
|
||||
m.api,
|
||||
m.side,
|
||||
names(&caps)
|
||||
));
|
||||
}
|
||||
}
|
||||
Kind::ResourcePack => check_resource_pack(path)?,
|
||||
Kind::Shader | Kind::Other(_) => {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
));
|
||||
}
|
||||
Kind::Modpack => {
|
||||
let pack = Modpack::read(path)?;
|
||||
let m = &pack.manifest;
|
||||
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
|
||||
if m.id != project.id
|
||||
|| m.version != version.version
|
||||
|| m.format.to_string() != version.api
|
||||
|| m.game != version.game
|
||||
|| mods != version.requires
|
||||
{
|
||||
return Err(format!(
|
||||
"its {} isn't what the index lists",
|
||||
modpack::MANIFEST
|
||||
));
|
||||
}
|
||||
}
|
||||
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.
|
||||
fn check_resource_pack(path: &Path) -> Result<(), String> {
|
||||
/// 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() {
|
||||
@@ -523,10 +572,35 @@ fn check_resource_pack(path: &Path) -> Result<(), String> {
|
||||
.map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
if !json["pack"].is_object() {
|
||||
let pack = &json["pack"];
|
||||
if !pack.is_object() {
|
||||
return Err("pack.mcmeta: no \"pack\" object".into());
|
||||
}
|
||||
Ok(())
|
||||
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 {
|
||||
@@ -534,5 +608,6 @@ fn names(caps: &[Capability]) -> String {
|
||||
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"));
|
||||
}
|
||||
@@ -151,7 +151,10 @@ fn resource_packs_need_a_pack_mcmeta() {
|
||||
path
|
||||
};
|
||||
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
|
||||
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
|
||||
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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The item wire harness's Rust side (tools/test_item_wire_parity.py): reads
|
||||
//! the oracle's stacks (JSON lines with `label` and `hex`), decodes each with
|
||||
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
|
||||
//! patch's component ids (added, then removed), which of them Horizon
|
||||
//! decoded into its own lossy types (the rest it reads past keeping their
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims) and
|
||||
//! whether encoding the decoded stack gives the same bytes back.
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
item::{Component, Slot},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
|
||||
fn hex(s: &str) -> Option<Vec<u8>> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(args.get(1).ok_or("usage: item_wire_probe IN OUT")?)?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(
|
||||
args.get(2).ok_or("usage: item_wire_probe IN OUT")?,
|
||||
)?);
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let v: Value = serde_json::from_str(line)?;
|
||||
let label = v["label"].clone();
|
||||
let Some(bytes) = v["hex"].as_str().and_then(hex) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = Reader::new(&bytes);
|
||||
let result = match Slot::read(&mut r) {
|
||||
Err(e) => json!({"label": label, "error": e.to_string()}),
|
||||
Ok(stack) => {
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&stack, &mut w);
|
||||
let (added, removed): (Vec<i32>, Vec<i32>) =
|
||||
stack.as_ref().map_or_else(Default::default, |s| {
|
||||
(s.added.iter().map(|(k, _)| *k).collect(), s.removed.clone())
|
||||
});
|
||||
let typed: Vec<i32> = stack
|
||||
.iter()
|
||||
.flat_map(|s| &s.added)
|
||||
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
json!({
|
||||
"label": label,
|
||||
"left": r.remaining(),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"typed": typed,
|
||||
"same_bytes": w.data == bytes,
|
||||
})
|
||||
}
|
||||
};
|
||||
writeln!(out, "{result}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
use crate::{Error, Result, buf::Reader, crypto::Cfb8, frame};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{Shutdown, TcpStream, ToSocketAddrs},
|
||||
net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicI64, Ordering},
|
||||
@@ -180,6 +180,13 @@ pub struct ConnWriter {
|
||||
pub threshold: Option<usize>,
|
||||
counters: Arc<Counters>,
|
||||
}
|
||||
/// `InetAddress.getByName` under Java's default `java.net.preferIPv6Addresses=false`: IPv4
|
||||
/// addresses first, whatever order the system resolver gives (macOS lists IPv6 first). Vanilla
|
||||
/// connects to that first address only; the rest stay here as fallbacks.
|
||||
fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
|
||||
addrs.sort_by_key(SocketAddr::is_ipv6);
|
||||
addrs
|
||||
}
|
||||
/// Both halves before the split.
|
||||
pub struct Connection {
|
||||
pub reader: ConnReader,
|
||||
@@ -199,19 +206,19 @@ impl Connection {
|
||||
let addrs = crate::resolver::resolve(host, port)?;
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
fn connect_addresses(
|
||||
host: &str,
|
||||
addrs: Vec<std::net::SocketAddr>,
|
||||
timeout: Duration,
|
||||
) -> Result<Self> {
|
||||
let mut last = Error::new(format!("Unknown host {host}"));
|
||||
for addr in addrs {
|
||||
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
|
||||
// Report the first address's failure: it is the one vanilla tries, and the IPv6
|
||||
// fallbacks after it often fail only because the client has no IPv6 route.
|
||||
let mut first = None;
|
||||
for addr in ipv4_first(addrs) {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => return Self::from_stream(stream),
|
||||
Err(e) => last = Error::new(format!("Connection refused: {e}")),
|
||||
Err(e) => {
|
||||
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
|
||||
}
|
||||
pub fn from_stream(stream: TcpStream) -> Result<Self> {
|
||||
stream.set_nodelay(true)?;
|
||||
@@ -328,6 +335,71 @@ mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn ipv4_first_keeps_each_familys_order() {
|
||||
let a = |s: &str| s.parse::<SocketAddr>().unwrap();
|
||||
let resolved = vec![
|
||||
a("[2001:db8::1]:25565"),
|
||||
a("192.0.2.1:25565"),
|
||||
a("[2001:db8::2]:25565"),
|
||||
a("10.0.0.1:25565"),
|
||||
];
|
||||
assert_eq!(
|
||||
ipv4_first(resolved),
|
||||
[
|
||||
a("192.0.2.1:25565"),
|
||||
a("10.0.0.1:25565"),
|
||||
a("[2001:db8::1]:25565"),
|
||||
a("[2001:db8::2]:25565"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn peer(c: &Connection) -> SocketAddr {
|
||||
match &c.reader.stream {
|
||||
Stream::Tcp(s) => s.peer_addr().unwrap(),
|
||||
Stream::Memory(_) => panic!("not a socket"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds `[::1]`, or `None` where the machine has no IPv6 loopback.
|
||||
fn ipv6_loopback() -> Option<TcpListener> {
|
||||
let listener = TcpListener::bind("[::1]:0");
|
||||
if listener.is_err() {
|
||||
eprintln!("skipped: no IPv6 loopback");
|
||||
}
|
||||
listener.ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connects_to_ipv4_although_the_resolver_lists_ipv6_first() {
|
||||
// Both listen, as with a server whose IPv6 address accepts and then resets.
|
||||
let Some(v6_listener) = ipv6_loopback() else {
|
||||
return;
|
||||
};
|
||||
let v4_listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let v6 = v6_listener.local_addr().unwrap();
|
||||
let v4 = v4_listener.local_addr().unwrap();
|
||||
let c =
|
||||
Connection::connect_addresses("test", vec![v6, v4], Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(peer(&c), v4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_ipv6_when_ipv4_refuses() {
|
||||
let Some(v6_listener) = ipv6_loopback() else {
|
||||
return;
|
||||
};
|
||||
let v6 = v6_listener.local_addr().unwrap();
|
||||
let closed = TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap();
|
||||
let c = Connection::connect_addresses("test", vec![v6, closed], Duration::from_secs(5))
|
||||
.unwrap();
|
||||
assert_eq!(peer(&c), v6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_with_compression_and_encryption() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
//! item's registry id and a data component patch, plus the hashed form the
|
||||
//! client sends back in container clicks (HashedStack).
|
||||
use crate::{
|
||||
Error, Result,
|
||||
Result,
|
||||
buf::{Codec, Reader, Writer},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
mod skip;
|
||||
|
||||
/// minecraft:data_component_type registry ids (26.3).
|
||||
pub mod component {
|
||||
pub const CUSTOM_DATA: i32 = 0;
|
||||
@@ -35,6 +37,7 @@ pub mod component {
|
||||
pub const CHARGED_PROJECTILES: i32 = 51;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
}
|
||||
|
||||
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
|
||||
@@ -78,6 +81,15 @@ pub enum Component {
|
||||
WrittenBook(WrittenBook),
|
||||
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
|
||||
Stacks(Vec<Stack>),
|
||||
/// `ArmorTrim` naming its material and pattern by registry id (`trim_material`,
|
||||
/// `trim_pattern`); a trim with a direct holder stays [`Component::Raw`].
|
||||
Trim {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// A component Horizon reads past without interpreting it: its encoded
|
||||
/// bytes, written back as they came.
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
@@ -224,11 +236,35 @@ impl Component {
|
||||
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
other => return Err(Error::new(format!("unsupported item component {other}"))),
|
||||
// ArmorTrim.STREAM_CODEC: two holders, each a reference's id plus
|
||||
// one. A direct material or pattern has no registry name: read past.
|
||||
TRIM => {
|
||||
let start = r.position();
|
||||
skip::component(TRIM, r)?;
|
||||
let bytes = r.since(start);
|
||||
let mut ids = Reader::new(bytes);
|
||||
match (ids.varint(), ids.varint()) {
|
||||
(Ok(material @ 1..), Ok(pattern @ 1..)) if ids.remaining() == 0 => Self::Trim {
|
||||
material: material - 1,
|
||||
pattern: pattern - 1,
|
||||
},
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let start = r.position();
|
||||
skip::component(other, r)?;
|
||||
Self::Raw(r.since(start).to_vec())
|
||||
}
|
||||
})
|
||||
}
|
||||
fn write(&self, kind: i32, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Raw(bytes) => w.bytes(bytes),
|
||||
Self::Trim { material, pattern } => {
|
||||
w.varint(material + 1);
|
||||
w.varint(pattern + 1);
|
||||
}
|
||||
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
|
||||
Self::Int(v) => w.varint(*v),
|
||||
Self::Unit => {}
|
||||
@@ -582,7 +618,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unknown_components_are_errors() {
|
||||
let bytes = [1, 5, 1, 0, 99];
|
||||
// 122 is past DataComponents' last id (cushion_color, 121).
|
||||
let bytes = [1, 5, 1, 0, 122];
|
||||
assert!(Slot::read(&mut Reader::new(&bytes)).is_err());
|
||||
}
|
||||
|
||||
@@ -631,4 +668,44 @@ mod tests {
|
||||
[("textures".to_owned(), "x".to_owned(), None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_read_past_keep_their_bytes() {
|
||||
// Vanilla 26.3's encoding (tools/test_item_wire_parity.py) of a chestplate's
|
||||
// armour trim (decoded: both holders are references), a banner's patterns
|
||||
// (one direct), a pot's sherds and a stew's effects: one server's
|
||||
// inventory, recipe book and advancements had them.
|
||||
for (hex, kind) in [
|
||||
("01bd0801003a0502", 58),
|
||||
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
|
||||
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
|
||||
("01a408010037020f6412a001", 55),
|
||||
] {
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let stack = Slot::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "component {kind}");
|
||||
let expected = |c: &Component| match kind {
|
||||
58 => {
|
||||
*c == Component::Trim {
|
||||
material: 4,
|
||||
pattern: 1,
|
||||
}
|
||||
}
|
||||
_ => matches!(c, Component::Raw(_)),
|
||||
};
|
||||
assert!(
|
||||
stack
|
||||
.as_ref()
|
||||
.and_then(|s| s.component(kind))
|
||||
.is_some_and(expected)
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&stack, &mut w);
|
||||
assert_eq!(w.data, bytes, "component {kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Reading past the data components Horizon doesn't interpret: each
|
||||
//! function consumes exactly what its vanilla StreamCodec writes
|
||||
//! (DataComponents' `networkSynchronized` codecs; a component without one
|
||||
//! goes as its persistent Codec's NBT, `ByteBufCodecs.fromCodecWithRegistries`).
|
||||
//! Item data has no length prefix, so a component read short or long makes
|
||||
//! everything after it unreadable.
|
||||
use super::{Component, Template};
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Codec, Reader},
|
||||
};
|
||||
|
||||
type Read = fn(&mut Reader) -> Result<()>;
|
||||
|
||||
/// DataComponents' registry order (26.3) for the ids `Component::read`
|
||||
/// doesn't decode itself.
|
||||
pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
|
||||
match kind {
|
||||
// Unit.STREAM_CODEC: glider, waxed.
|
||||
34 | 120 => Ok(()),
|
||||
// Components without a network codec go as their NBT: intangible_projectile,
|
||||
// map_decorations, debug_stick_state, recipes, lock, container_loot.
|
||||
22 | 49 | 59 | 68 | 81 | 82 => nbt(r),
|
||||
// A VarInt (VAR_INT, an idMapper, a registry or holderRegistry id): damage_type,
|
||||
// enchantable, additional_trade_cost, block_transformer, villager_food, dye,
|
||||
// ominous_bottle_amplifier, base_color, the mob variants and colours, the pottery
|
||||
// pattern and cushion_color.
|
||||
8 | 31 | 42 | 43 | 44 | 46 | 65 | 75 | 88..=108 | 110..=117 | 121 => varint(r),
|
||||
// FLOAT: minimum_attack_charge, potion_duration_scale.
|
||||
7 | 54 => f32(r),
|
||||
// Identifier.STREAM_CODEC: tooltip_style, note_block_sound.
|
||||
35 | 73 => ident(r),
|
||||
// UseEffects: canSprint, interactVibrations, speedMultiplier.
|
||||
5 => {
|
||||
r.bool()?;
|
||||
r.bool()?;
|
||||
f32(r)
|
||||
}
|
||||
// AdventureModePredicate: can_place_on, can_break.
|
||||
14 | 15 => list(r, block_predicate),
|
||||
16 => list(r, attribute_modifier),
|
||||
// FoodProperties.DIRECT_STREAM_CODEC: nutrition, saturation, canAlwaysEat.
|
||||
23 => {
|
||||
varint(r)?;
|
||||
f32(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
// Consumable: consumeSeconds, animation, sound, hasConsumeParticles, onConsumeEffects.
|
||||
24 => {
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
sound(r)?;
|
||||
boolean(r)?;
|
||||
list(r, consume_effect)
|
||||
}
|
||||
// UseRemainder, SulfurCubeContent: one template.
|
||||
25 | 80 => template(r),
|
||||
// UseCooldown: seconds, cooldownGroup.
|
||||
26 => {
|
||||
f32(r)?;
|
||||
optional(r, ident)
|
||||
}
|
||||
// DamageResistant, Repairable, provides_banner_patterns: a holder set.
|
||||
27 | 33 | 67 => holder_set(r),
|
||||
28 => tool(r),
|
||||
// Weapon: itemDamagePerAttack, disableBlockingForSeconds.
|
||||
29 => {
|
||||
varint(r)?;
|
||||
f32(r)
|
||||
}
|
||||
32 => equippable(r),
|
||||
// DeathProtection: deathEffects.
|
||||
36 => list(r, consume_effect),
|
||||
37 => blocks_attacks(r),
|
||||
// PiercingWeapon: dealsKnockback, dismounts, sound, hitSound.
|
||||
38 => {
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
39 => kinetic_weapon(r),
|
||||
// SwingAnimation: type, duration.
|
||||
40 | 41 => {
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}
|
||||
// BundleContents: templates.
|
||||
52 => list(r, template),
|
||||
53 => potion_contents(r),
|
||||
// SuspiciousStewEffects: effect, duration.
|
||||
55 => list(r, |r| {
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
// ArmorTrim: material, then Holder<TrimPattern> (assetId, description, decal).
|
||||
58 => {
|
||||
trim_material(r)?;
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
nbt(r)?;
|
||||
boolean(r)
|
||||
})
|
||||
}
|
||||
// TypedEntityData (entity_data, block_entity_data): the type id, then a compound.
|
||||
60 | 62 => {
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
}
|
||||
// CustomData: bucket_entity_data.
|
||||
61 => nbt(r),
|
||||
// InstrumentComponent: Holder<Instrument> (soundEvent, useDuration, range,
|
||||
// durabilityDamage, description).
|
||||
63 => holder(r, |r| {
|
||||
sound(r)?;
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
}),
|
||||
64 => trim_material(r),
|
||||
// JukeboxPlayable: Holder<JukeboxSong> (soundEvent, description, lengthInSeconds,
|
||||
// comparatorOutput).
|
||||
66 => holder(r, |r| {
|
||||
sound(r)?;
|
||||
nbt(r)?;
|
||||
f32(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
// LodestoneTracker: target (GlobalPos: dimension, BlockPos), tracked.
|
||||
69 => {
|
||||
optional(r, |r| {
|
||||
ident(r)?;
|
||||
r.i64().map(drop)
|
||||
})?;
|
||||
boolean(r)
|
||||
}
|
||||
70 => firework_explosion(r),
|
||||
// Fireworks: flightDuration, explosions.
|
||||
71 => {
|
||||
varint(r)?;
|
||||
list(r, firework_explosion)
|
||||
}
|
||||
// BannerPatternLayers: Holder<BannerPattern> (assetId, translationKey), colour.
|
||||
74 => list(r, |r| {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
string(r)
|
||||
})?;
|
||||
varint(r)
|
||||
}),
|
||||
// PotDecorations: back, left, right, front.
|
||||
76 => (0..4).try_for_each(|_| optional(r, template)),
|
||||
// ItemContainerContents: optional templates.
|
||||
77 => list(r, |r| optional(r, template)),
|
||||
// BlockItemStateProperties: a string to string map.
|
||||
78 => list(r, |r| {
|
||||
string(r)?;
|
||||
string(r)
|
||||
}),
|
||||
// Bees: Occupant (entity type and data, ticksInHive, minTicksInHive).
|
||||
79 => list(r, |r| {
|
||||
varint(r)?;
|
||||
nbt(r)?;
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
83 => sound(r),
|
||||
// Compostable: layers.
|
||||
84 => resolvable(r, i32),
|
||||
// CookingFuel (burnTime, speedMultiplier), BrewingFuel (uses, speedMultiplier).
|
||||
85 | 86 => {
|
||||
resolvable(r, i32)?;
|
||||
resolvable(r, f32)
|
||||
}
|
||||
// MobVisibility: targetingEntityTypes, visibility.
|
||||
87 => {
|
||||
holder_set(r)?;
|
||||
f32(r)
|
||||
}
|
||||
// Holder<PaintingVariant>: width, height, assetId, title, author.
|
||||
109 => holder(r, |r| {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
ident(r)?;
|
||||
optional(r, nbt)?;
|
||||
optional(r, nbt)
|
||||
}),
|
||||
// SignText: four messages, four optional filtered ones, colour, hasGlowingText.
|
||||
118 | 119 => {
|
||||
(0..4).try_for_each(|_| nbt(r))?;
|
||||
optional(r, |r| (0..4).try_for_each(|_| nbt(r)))?;
|
||||
varint(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
other => Err(Error::new(format!("unsupported item component {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean(r: &mut Reader) -> Result<()> {
|
||||
r.bool().map(drop)
|
||||
}
|
||||
fn varint(r: &mut Reader) -> Result<()> {
|
||||
r.varint().map(drop)
|
||||
}
|
||||
fn f32(r: &mut Reader) -> Result<()> {
|
||||
r.f32().map(drop)
|
||||
}
|
||||
fn i32(r: &mut Reader) -> Result<()> {
|
||||
r.i32().map(drop)
|
||||
}
|
||||
/// ByteBufCodecs.STRING_UTF8.
|
||||
fn string(r: &mut Reader) -> Result<()> {
|
||||
r.string(32767).map(drop)
|
||||
}
|
||||
/// Identifier.STREAM_CODEC, ResourceKey.streamCodec.
|
||||
fn ident(r: &mut Reader) -> Result<()> {
|
||||
string(r)
|
||||
}
|
||||
/// A text component or compound (fromCodecWithRegistries, COMPOUND_TAG).
|
||||
fn nbt(r: &mut Reader) -> Result<()> {
|
||||
r.nbt().map(drop)
|
||||
}
|
||||
fn template(r: &mut Reader) -> Result<()> {
|
||||
Template::read(r).map(drop)
|
||||
}
|
||||
/// ByteBufCodecs.optional: a flag, then the value.
|
||||
fn optional(r: &mut Reader, value: Read) -> Result<()> {
|
||||
if r.bool()? { value(r) } else { Ok(()) }
|
||||
}
|
||||
/// ByteBufCodecs.list and map: a VarInt count, then the elements (a map's
|
||||
/// key and value each). Every element is at least a byte, so the count
|
||||
/// can't pass what is left.
|
||||
fn list(r: &mut Reader, element: Read) -> Result<()> {
|
||||
let n = r.count(r.remaining())?;
|
||||
(0..n).try_for_each(|_| element(r))
|
||||
}
|
||||
/// ByteBufCodecs.holder: 0 and the value itself, or the registry id plus one.
|
||||
fn holder(r: &mut Reader, direct: Read) -> Result<()> {
|
||||
if r.varint()? == 0 { direct(r) } else { Ok(()) }
|
||||
}
|
||||
/// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
|
||||
fn holder_set(r: &mut Reader) -> Result<()> {
|
||||
match r.varint()? {
|
||||
0 => ident(r),
|
||||
n => {
|
||||
let n = usize::try_from(n - 1).map_err(|_| Error::new("negative holder set size"))?;
|
||||
if n > r.remaining() {
|
||||
return Err(Error::new("holder set past the end"));
|
||||
}
|
||||
(0..n).try_for_each(|_| varint(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SoundEvent.STREAM_CODEC: a holder of a location and an optional fixed range.
|
||||
fn sound(r: &mut Reader) -> Result<()> {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
optional(r, f32)
|
||||
})
|
||||
}
|
||||
/// ResolvableInt, ResolvableFloat: Either a constant (true) or a context provider's key.
|
||||
fn resolvable(r: &mut Reader, constant: Read) -> Result<()> {
|
||||
if r.bool()? { constant(r) } else { ident(r) }
|
||||
}
|
||||
/// TrimMaterial.STREAM_CODEC: a holder of a palette id and a description.
|
||||
fn trim_material(r: &mut Reader) -> Result<()> {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
nbt(r)
|
||||
})
|
||||
}
|
||||
/// MobEffectInstance: the effect id, then its Details.
|
||||
fn mob_effect(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
effect_details(r)
|
||||
}
|
||||
/// MobEffectInstance.Details: amplifier, duration, ambient, showParticles, showIcon, then the
|
||||
/// hidden effect's Details, recursively.
|
||||
fn effect_details(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
optional(r, effect_details)
|
||||
}
|
||||
/// ConsumeEffect.STREAM_CODEC: the consume_effect_type id, then that type's fields.
|
||||
fn consume_effect(r: &mut Reader) -> Result<()> {
|
||||
match r.varint()? {
|
||||
// apply_effects: effects, probability.
|
||||
0 => {
|
||||
list(r, mob_effect)?;
|
||||
f32(r)
|
||||
}
|
||||
// remove_effects: a mob effect holder set.
|
||||
1 => holder_set(r),
|
||||
// clear_all_effects.
|
||||
2 => Ok(()),
|
||||
// teleport_randomly: diameter, directionalParticles.
|
||||
3 => {
|
||||
f32(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
// play_sound.
|
||||
4 => sound(r),
|
||||
other => Err(Error::new(format!("unknown consume effect type {other}"))),
|
||||
}
|
||||
}
|
||||
/// BlockPredicate: blocks, properties, nbt, then DataComponentMatchers (exact, partial).
|
||||
fn block_predicate(r: &mut Reader) -> Result<()> {
|
||||
optional(r, holder_set)?;
|
||||
// StatePropertiesPredicate: PropertyMatchers, each a name and an
|
||||
// Either<ExactMatcher (value), RangedMatcher (min, max)>.
|
||||
optional(r, |r| {
|
||||
list(r, |r| {
|
||||
string(r)?;
|
||||
if r.bool()? {
|
||||
string(r)
|
||||
} else {
|
||||
optional(r, string)?;
|
||||
optional(r, string)
|
||||
}
|
||||
})
|
||||
})?;
|
||||
optional(r, nbt)?;
|
||||
// DataComponentExactPredicate: TypedDataComponents, a component type id and its value.
|
||||
list(r, |r| {
|
||||
let kind = r.varint()?;
|
||||
Component::read(kind, r).map(drop)
|
||||
})?;
|
||||
// DataComponentPredicate.STREAM_CODEC: an Either of a predicate type id or a component
|
||||
// type id, then the predicate as NBT (fromCodecWithRegistries).
|
||||
list(r, |r| {
|
||||
boolean(r)?;
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
})
|
||||
}
|
||||
/// ItemAttributeModifiers.Entry: attribute, modifier (id, amount, operation), slot, display.
|
||||
fn attribute_modifier(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
ident(r)?;
|
||||
r.f64()?;
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
// Display.Type: default, hidden, or override and its text.
|
||||
match r.varint()? {
|
||||
0 | 1 => Ok(()),
|
||||
2 => nbt(r),
|
||||
other => Err(Error::new(format!(
|
||||
"unknown attribute modifier display {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
/// Tool: rules (blocks, speed, correctForDrops), defaultMiningSpeed, damagePerBlock,
|
||||
/// canDestroyBlocksInCreative.
|
||||
fn tool(r: &mut Reader) -> Result<()> {
|
||||
list(r, |r| {
|
||||
holder_set(r)?;
|
||||
optional(r, f32)?;
|
||||
optional(r, boolean)
|
||||
})?;
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
/// Equippable: slot, equipSound, assetId, cameraOverlay, allowedEntities, dispensable,
|
||||
/// swappable, damageOnHurt, equipOnInteract, canBeSheared, shearingSound.
|
||||
fn equippable(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
sound(r)?;
|
||||
optional(r, ident)?;
|
||||
optional(r, ident)?;
|
||||
optional(r, holder_set)?;
|
||||
(0..5).try_for_each(|_| boolean(r))?;
|
||||
sound(r)
|
||||
}
|
||||
/// BlocksAttacks: blockDelaySeconds, disableCooldownScale, damageReductions (angle, type,
|
||||
/// base, factor), itemDamage (threshold, base, factor), bypassedBy, blockSound, disableSound.
|
||||
fn blocks_attacks(r: &mut Reader) -> Result<()> {
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
list(r, |r| {
|
||||
f32(r)?;
|
||||
optional(r, holder_set)?;
|
||||
f32(r)?;
|
||||
f32(r)
|
||||
})?;
|
||||
(0..3).try_for_each(|_| f32(r))?;
|
||||
optional(r, holder_set)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
/// KineticWeapon: contactCooldownTicks, delayTicks, the dismount, knockback and damage
|
||||
/// conditions (maxDurationTicks, minSpeed, minRelativeSpeed), forwardMovement,
|
||||
/// damageMultiplier, sound, hitSound.
|
||||
fn kinetic_weapon(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
(0..3).try_for_each(|_| {
|
||||
optional(r, |r| {
|
||||
varint(r)?;
|
||||
f32(r)?;
|
||||
f32(r)
|
||||
})
|
||||
})?;
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
/// PotionContents: potion, customColor, customEffects, customName.
|
||||
fn potion_contents(r: &mut Reader) -> Result<()> {
|
||||
optional(r, varint)?;
|
||||
optional(r, i32)?;
|
||||
list(r, mob_effect)?;
|
||||
optional(r, string)
|
||||
}
|
||||
/// FireworkExplosion: shape, colors, fadeColors, hasTrail, hasTwinkle.
|
||||
fn firework_explosion(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
list(r, i32)?;
|
||||
list(r, i32)?;
|
||||
boolean(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
@@ -90,6 +90,59 @@ Unknown keys are refused. `overrides/` may hold only `options.txt`, `horizon-gra
|
||||
capability `storage`), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
|
||||
them into the profile's game folder.
|
||||
|
||||
## The registry repository
|
||||
|
||||
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) holds one `mods/<id>.toml` per project, written by
|
||||
the submission form or by hand, and `revoked.toml`. What an entry can't vouch for is read from its file by the kind's
|
||||
own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mods, the size. Types and rules:
|
||||
`horizon_mods::index::registry`.
|
||||
|
||||
```toml
|
||||
id = "coords_hud" # the file's name, and the manifest's id
|
||||
kind = "mod" # mod | modpack | resourcepack; shader once its format exists
|
||||
name = "Coordinates HUD"
|
||||
summary = "One line, at most 160 characters."
|
||||
description = """Markdown.""" # optional
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"] # GitHub logins who may publish updates
|
||||
license = "MIT"
|
||||
source = "https://github.com/..."
|
||||
icon = "icons/coords_hud.png" # optional: a PNG of at most 256 KiB in the repository, or https://
|
||||
key = "RWT..." # optional: the minisign public key every version is signed with
|
||||
|
||||
[[versions]] # newest first
|
||||
version = "0.1.0" # letters, digits, '.', '-', '_': the release tag is <id>-<version>
|
||||
published = "2026-10-24"
|
||||
game = "26.3"
|
||||
file = "coords_hud-0.1.0.hmod" # the name players download
|
||||
sha256 = "e113..." # pins the reviewed file
|
||||
signature = """untrusted comment: ...""" # when the project has a key
|
||||
changelog = "" # optional
|
||||
```
|
||||
|
||||
```toml
|
||||
# revoked.toml
|
||||
[[revoked]]
|
||||
id = "bad_mod"
|
||||
sha256 = "1111..." # optional: without it, every version
|
||||
reason = "Sent chat messages it didn't declare."
|
||||
date = "2026-10-24"
|
||||
```
|
||||
|
||||
Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon-mod`):
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above), its signature with `--key` and `--signature`, and for a mod a trial run in the sandbox (40 ticks, 120 frames, a chat line, each command). |
|
||||
| `cargo horizon-mod registry check <base> <head> --files <dir>` | `check` on each file `head` lists and `base` doesn't: a pull request's new files. |
|
||||
| `cargo horizon-mod registry list <checkout>` | `<sha256> <id> <version> <file>` per version: the files to download. |
|
||||
| `cargo horizon-mod registry build <checkout> --files <dir> --out index.json` | Reads every entry and its file (`<dir>/<sha256>`), lists every problem, and writes the index with URLs `<release>/<id>-<version>/<file>`. |
|
||||
| `cargo horizon-mod registry review <base> <head> --author <login>` | A pull request's changes: a published file never changes and is never removed (take it down in `revoked.toml`), a kind never changes; changes by a non-owner and to owners or keys are pointed out for the reviewers. |
|
||||
|
||||
Kinds open to submissions: `mod`, `modpack`, `resourcepack`. `shader` is in the schema and stays closed until
|
||||
Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile
|
||||
the pack in a headless Horizon. A new kind gets its own check before it opens.
|
||||
|
||||
## Changelog
|
||||
|
||||
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
|
||||
+13
-1
@@ -16,7 +16,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run` |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, modpacks |
|
||||
| Registry index (`index.json`, `.hpack`), signatures | `horizon_mods::index`, [api/mod-index.md](api/mod-index.md) | schema 1; launcher and website next |
|
||||
@@ -226,6 +226,18 @@ always on, and the game checks its file once a second: rebuild and it restarts
|
||||
with "Mods reloaded" in chat. Its in-memory state starts over; whatever it
|
||||
saved with capability `storage` survives the reload.
|
||||
|
||||
Before you submit it to the registry, run what its CI runs:
|
||||
|
||||
```sh
|
||||
cargo horizon-mod check target/horizon-mod/my_mod.hmod # --json for the report as JSON
|
||||
```
|
||||
|
||||
It reads the manifest and content, refuses imports the capabilities don't
|
||||
cover, then plays the mod in the sandbox for 40 ticks, 120 frames, a chat line
|
||||
and each of its commands: it must start and never trap or overrun. It checks a
|
||||
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
|
||||
key> --signature <file>.minisig`.
|
||||
|
||||
### Template layout
|
||||
|
||||
```
|
||||
|
||||
+16
-19
@@ -1022,21 +1022,18 @@ fn download_for_owner(
|
||||
}
|
||||
}
|
||||
|
||||
/// A JDK of at least `major` for the preparation. `None` when horizon-prepare finds one by
|
||||
/// itself (JAVA_HOME, the PATH, an installed JDK): the preparation then runs as before, in an
|
||||
/// unchanged environment (a JDK found on the PATH can be a stub such as macOS's
|
||||
/// /usr/bin/javac, whose folder is no Java home: `JAVA_HOME=/usr` makes it call itself
|
||||
/// forever). Else the home of the Java the official launcher downloaded into one of the
|
||||
/// `games` folders, else of Minecraft's own Java downloaded from Mojang into Horizon's game
|
||||
/// folder, the runtime the version JSON at `manifest` names (Mojang's runtimes are whole JDKs).
|
||||
/// The home of a JDK whose compiler and runtime support `major`. Forward even an installed
|
||||
/// JDK to external preparers: older versions may otherwise rediscover incompatible PATH tools.
|
||||
/// Else try the official launcher's Java in `games`, then download the runtime named by
|
||||
/// `manifest` into Horizon's game folder (Mojang's runtimes are whole JDKs).
|
||||
fn java(
|
||||
major: i64,
|
||||
games: &[PathBuf],
|
||||
manifest: &Path,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
if horizon_prepare::java::find(major, &[]).is_ok() {
|
||||
return Ok(None);
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Ok(jdk) = horizon_prepare::java::find(major, &[]) {
|
||||
return Ok(jdk.home());
|
||||
}
|
||||
let mut runtimes: Vec<PathBuf> = Vec::new();
|
||||
for home in games
|
||||
@@ -1048,7 +1045,7 @@ fn java(
|
||||
}
|
||||
}
|
||||
let missing = match horizon_prepare::java::find(major, &runtimes) {
|
||||
Ok(jdk) => return Ok(Some(jdk.home())),
|
||||
Ok(jdk) => return Ok(jdk.home()),
|
||||
Err(e) => e,
|
||||
};
|
||||
let component = read_json(manifest)
|
||||
@@ -1073,7 +1070,7 @@ fn java(
|
||||
let home = mojang::java_runtime(&component, &runtime, 8, &fetch, log).map_err(|e| {
|
||||
format!("{missing}\n • Horizon also tried to download Minecraft's own Java from Mojang, but: {e}")
|
||||
})?;
|
||||
horizon_prepare::java::find(major, &[home]).map(|jdk| Some(jdk.home()))
|
||||
horizon_prepare::java::find(major, &[home]).map(|jdk| jdk.home())
|
||||
}
|
||||
|
||||
/// The dynamic loader glibc programs on x86-64 Linux start through.
|
||||
@@ -1196,14 +1193,12 @@ pub fn preparer(
|
||||
import: !reused,
|
||||
minecraft_dir: Some(minecraft.clone()),
|
||||
server_jar: (!reused).then_some(jar),
|
||||
java_home: java_home.clone(),
|
||||
java_home: Some(java_home.clone()),
|
||||
};
|
||||
return Ok((Preparer::InProcess(options), minecraft));
|
||||
}
|
||||
let mut cmd = Command::new(binary);
|
||||
if let Some(home) = &java_home {
|
||||
with_java(&mut cmd, home);
|
||||
}
|
||||
with_java(&mut cmd, &java_home);
|
||||
cmd.current_dir(&dir).arg("--root").arg(&dir);
|
||||
if !reused {
|
||||
cmd.args(["--prepare", "--server-jar"]).arg(&jar);
|
||||
@@ -1212,9 +1207,7 @@ pub fn preparer(
|
||||
return Ok((Preparer::Process(cmd), minecraft));
|
||||
}
|
||||
let mut cmd = Command::new(python.unwrap());
|
||||
if let Some(home) = &java_home {
|
||||
with_java(&mut cmd, home);
|
||||
}
|
||||
with_java(&mut cmd, &java_home);
|
||||
cmd.current_dir(&dir)
|
||||
.args(["-u", "tools/prepare_native_client.py"]);
|
||||
if !reused {
|
||||
@@ -2338,6 +2331,10 @@ impl Settings {
|
||||
#[path = "backend/release_loading_tests.rs"]
|
||||
mod release_loading_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "backend/java_tests.rs"]
|
||||
mod java_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selected_jdk_reaches_external_preparers() {
|
||||
const CHILD: &str = "HORIZON_LAUNCHER_JAVA_TEST";
|
||||
if let Some(root) = std::env::var_os(CHILD) {
|
||||
let root = PathBuf::from(root);
|
||||
let home = java(25, &[], &root.join("missing.json"), &|_| {}).unwrap();
|
||||
assert_eq!(
|
||||
fs::canonicalize(&home).unwrap(),
|
||||
fs::canonicalize(root.join("jdk25")).unwrap()
|
||||
);
|
||||
// An older preparer invokes Java tools by name; both must now resolve to this JDK.
|
||||
for name in ["java", "javac"] {
|
||||
let mut cmd = crate::process::command(name);
|
||||
with_java(&mut cmd, &home);
|
||||
let output = cmd.arg("-version").output().unwrap();
|
||||
assert!(output.status.success());
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
assert!(text.contains("25.0.1"), "{name}: {text}");
|
||||
assert_eq!(
|
||||
cmd.get_envs()
|
||||
.find(|(key, _)| *key == "JAVA_HOME")
|
||||
.unwrap()
|
||||
.1,
|
||||
Some(home.as_os_str())
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let root = std::env::temp_dir().join(format!("horizon-launcher-java-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let executable =
|
||||
|bin: &Path, name: &str| bin.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
|
||||
let probe = executable(&root, "probe");
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../crates/horizon-prepare/tests/support/java_probe.rs");
|
||||
let output = crate::process::command("rustc")
|
||||
.arg(source)
|
||||
.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"),
|
||||
("jdk25", Some("25.0.1"), "25.0.1"),
|
||||
] {
|
||||
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, executable(&bin, name)).unwrap();
|
||||
fs::write(bin.join(format!("{name}.version")), version).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = crate::process::command(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"backend::java_tests::selected_jdk_reaches_external_preparers",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, &root)
|
||||
.env_remove("JAVA_HOME")
|
||||
.env(
|
||||
"PATH",
|
||||
std::env::join_paths([root.join("jre21/bin"), root.join("jdk25/bin")]).unwrap(),
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import io.netty.buffer.Unpooled;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
@@ -78,7 +81,10 @@ import net.minecraft.world.phys.Vec3;
|
||||
* on|off} (the shift key, as ServerboundPlayerInputPacket), {@code fly NAME
|
||||
* on|off} (ServerboundPlayerAbilitiesPacket), {@code still NAME} (the
|
||||
* position reminder LocalPlayer.sendPosition sends every 20 ticks standing
|
||||
* still), {@code quit NAME}, {@code
|
||||
* still), {@code sync NAME} (runs {@code time query gametime}, whose reply is
|
||||
* not recorded, and prints {@code synced NAME} once it is back: the server has
|
||||
* handled everything this player sent before, and everything the server sent
|
||||
* this player before the reply has been recorded), {@code quit NAME}, {@code
|
||||
* players on|off} (whether each player also records the other players added
|
||||
* to and removed from its level, and its own respawns into a dimension),
|
||||
* {@code chat on|off} (whether the system chat each player receives is
|
||||
@@ -95,6 +101,8 @@ public final class LocatorBarClient {
|
||||
private static final Map<UUID, String> NAMES = new LinkedHashMap<>();
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// Bootstrap sends System.out to the logger: `synced` lines go to the real stdout.
|
||||
PrintStream stdout = new PrintStream(new FileOutputStream(FileDescriptor.out), true, StandardCharsets.UTF_8);
|
||||
ParityData.init();
|
||||
String host = args[0];
|
||||
int port = Integer.parseInt(args[1]);
|
||||
@@ -122,6 +130,13 @@ public final class LocatorBarClient {
|
||||
players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities));
|
||||
}
|
||||
case "still" -> players.get(w[1]).still();
|
||||
case "sync" -> {
|
||||
try {
|
||||
players.get(w[1]).sync();
|
||||
} finally {
|
||||
stdout.println("synced " + w[1]);
|
||||
}
|
||||
}
|
||||
case "quit" -> players.get(w[1]).close();
|
||||
case "players" -> LocatorBarClient.players = w[1].equals("on");
|
||||
case "chat" -> chat = w[1].equals("on");
|
||||
@@ -189,6 +204,7 @@ public final class LocatorBarClient {
|
||||
private final Map<Integer, String> seen = new java.util.HashMap<>();
|
||||
private final Object replyLock = new Object();
|
||||
private int replies;
|
||||
private int syncs;
|
||||
|
||||
Player(String name) {
|
||||
this.name = name;
|
||||
@@ -211,6 +227,23 @@ public final class LocatorBarClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** A round trip through the server thread, its reply left out of the transcript. */
|
||||
void sync() throws Exception {
|
||||
synchronized (replyLock) {
|
||||
int before = syncs;
|
||||
send(new ServerboundChatCommandPacket("time query gametime"));
|
||||
long until = System.nanoTime() + 30_000_000_000L;
|
||||
while (syncs == before) {
|
||||
long left = (until - System.nanoTime()) / 1_000_000L;
|
||||
if (left <= 0) {
|
||||
record("error no reply to sync");
|
||||
return;
|
||||
}
|
||||
replyLock.wait(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void record(String line) {
|
||||
synchronized (lines) {
|
||||
lines.add("step=" + step + " " + line);
|
||||
@@ -312,6 +345,13 @@ public final class LocatorBarClient {
|
||||
seen.clear();
|
||||
} else if (p instanceof ClientboundTrackedWaypointPacket) {
|
||||
record(waypoint(r.payload));
|
||||
} else if (p instanceof ClientboundSystemChatPacket c
|
||||
&& c.content().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t
|
||||
&& t.getKey().equals("commands.time.query.gametime")) {
|
||||
synchronized (replyLock) {
|
||||
syncs++;
|
||||
replyLock.notifyAll();
|
||||
}
|
||||
} else if (p instanceof ClientboundSystemChatPacket c) {
|
||||
if (chat) {
|
||||
record("chat " + (c.overlay() ? "overlay " : "") + net.minecraft.network.chat.ComponentSerialization.CODEC
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import it.unimi.dsi.fastutil.objects.Reference2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.objects.Reference2ObjectMaps;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.component.DataComponentPatch;
|
||||
import net.minecraft.core.component.DataComponentType;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.component.TypedDataComponent;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.world.entity.decoration.painting.PaintingVariant;
|
||||
import net.minecraft.world.item.JukeboxPlayable;
|
||||
import net.minecraft.world.item.JukeboxSong;
|
||||
import net.minecraft.world.item.component.MapPostProcessing;
|
||||
import net.minecraft.util.Unit;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/**
|
||||
* Item stacks as a server sends them (ItemStack.OPTIONAL_STREAM_CODEC), for
|
||||
* every data component: each default component value of every vanilla item
|
||||
* set on a stick (so it is in the patch), the SNBT fixtures given in a file
|
||||
* (one `label<TAB>stack SNBT` per line), and a few patches built directly.
|
||||
* Prints one JSON line per stack: its label, the encoded bytes in hex and the
|
||||
* patch's component type ids in encoding order (added, then removed), or
|
||||
* the error.
|
||||
*/
|
||||
public final class VanillaItemWireOracle {
|
||||
private static final int PER_TYPE = 12;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
List<String[]> fixtures = new ArrayList<>();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
if (line.isBlank() || line.startsWith("#")) continue;
|
||||
fixtures.add(line.split("\t", 2));
|
||||
}
|
||||
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
|
||||
// Each distinct default value, once per component type and
|
||||
// encoding, and at most PER_TYPE of them for one type.
|
||||
Set<String> seen = new HashSet<>();
|
||||
Map<DataComponentType<?>, Integer> perType = new HashMap<>();
|
||||
for (var item : BuiltInRegistries.ITEM) {
|
||||
for (TypedDataComponent<?> c : item.components()) {
|
||||
ItemStack stack = new ItemStack(Items.STICK);
|
||||
if (!set(stack, c)) continue;
|
||||
String hex = encode(stack);
|
||||
if (hex == null || perType.getOrDefault(c.type(), 0) >= PER_TYPE) continue;
|
||||
if (!seen.add(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()) + hex)) continue;
|
||||
perType.merge(c.type(), 1, Integer::sum);
|
||||
print(out, "default " + BuiltInRegistries.ITEM.getKey(item) + " " + BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()), stack);
|
||||
}
|
||||
}
|
||||
for (String[] f : fixtures) {
|
||||
try {
|
||||
print(out, f[0], ParityData.stack(f[1]));
|
||||
} catch (Exception e) {
|
||||
out.println("{\"label\":" + json(f[0]) + ",\"error\":" + json(String.valueOf(e)) + "}");
|
||||
}
|
||||
}
|
||||
// Components without a persistent codec, direct holders item SNBT
|
||||
// can't name, and removals.
|
||||
ItemStack direct = new ItemStack(Items.STICK);
|
||||
direct.set(DataComponents.ADDITIONAL_TRADE_COST, 4);
|
||||
direct.set(DataComponents.MAP_POST_PROCESSING, MapPostProcessing.SCALE);
|
||||
direct.set(DataComponents.JUKEBOX_PLAYABLE, new JukeboxPlayable(Holder.direct(
|
||||
new JukeboxSong(SoundEvents.ARMOR_EQUIP_CHAIN, Component.literal("S"), 10f, 3))));
|
||||
direct.set(DataComponents.PAINTING_VARIANT, Holder.direct(new PaintingVariant(
|
||||
2, 1, Identifier.withDefaultNamespace("kebab"), Optional.of(Component.literal("T")), Optional.empty())));
|
||||
print(out, "non-persistent and direct", direct);
|
||||
ItemStack locked = new ItemStack(Items.STONE);
|
||||
locked.set(DataComponents.CREATIVE_SLOT_LOCK, Unit.INSTANCE);
|
||||
print(out, "creative_slot_lock", locked);
|
||||
ItemStack removed = new ItemStack(Items.APPLE, 3);
|
||||
removed.remove(DataComponents.FOOD);
|
||||
removed.remove(DataComponents.CONSUMABLE);
|
||||
removed.set(DataComponents.DAMAGE, 4);
|
||||
print(out, "removed food and consumable", removed);
|
||||
print(out, "empty", ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> boolean set(ItemStack stack, TypedDataComponent<T> c) {
|
||||
if (stack.getPrototype().has(c.type()) && java.util.Objects.equals(stack.getPrototype().get(c.type()), c.value())) return false;
|
||||
stack.set((DataComponentType<T>) c.type(), c.value());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String encode(ItemStack stack) {
|
||||
var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), ParityData.access);
|
||||
try {
|
||||
ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, stack);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
byte[] bytes = new byte[buf.readableBytes()];
|
||||
buf.readBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private static void print(PrintWriter out, String label, ItemStack stack) {
|
||||
String hex = encode(stack);
|
||||
if (hex == null) {
|
||||
out.println("{\"label\":" + json(label) + ",\"error\":\"encode failed\"}");
|
||||
return;
|
||||
}
|
||||
// DataComponentPatch's encoding order: its map's fast iteration, added
|
||||
// components, then removals.
|
||||
StringBuilder kinds = new StringBuilder();
|
||||
StringBuilder removed = new StringBuilder();
|
||||
for (var e : Reference2ObjectMaps.fastIterable(patchMap(stack.getComponentsPatch()))) {
|
||||
// Removed (package-private) marks a removal.
|
||||
StringBuilder to = e.getValue().getClass().getName().equals("net.minecraft.core.component.Removed") ? removed : kinds;
|
||||
to.append(to.isEmpty() ? "" : ",").append(id(e.getKey()));
|
||||
}
|
||||
out.println("{\"label\":" + json(label) + ",\"hex\":\"" + hex + "\",\"added\":[" + kinds + "],\"removed\":[" + removed + "]}");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Reference2ObjectMap<DataComponentType<?>, Object> patchMap(DataComponentPatch patch) {
|
||||
try {
|
||||
Field map = DataComponentPatch.class.getDeclaredField("map");
|
||||
map.setAccessible(true);
|
||||
return (Reference2ObjectMap<DataComponentType<?>, Object>) map.get(patch);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static int id(DataComponentType<?> type) {
|
||||
return BuiltInRegistries.DATA_COMPONENT_TYPE.getId(type);
|
||||
}
|
||||
|
||||
private static String json(String s) {
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ZERO_OID = "0" * 40
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("sh") and shutil.which("python3"), "Git hook needs sh and python3")
|
||||
class PrePushTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.repo = Path(temporary.name)
|
||||
self.env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
|
||||
self.git("init", "-b", "main")
|
||||
(self.repo / "tools").mkdir()
|
||||
shutil.copyfile(ROOT / "tools/check_public_tree.py", self.repo / "tools/check_public_tree.py")
|
||||
(self.repo / ".gitignore").write_text("/.local/\n")
|
||||
(self.repo / "README.md").write_text("Public fixture\n")
|
||||
self.git("add", ".gitignore", "README.md")
|
||||
self.git("commit", "-m", "Public root")
|
||||
self.base = self.git("rev-parse", "HEAD")
|
||||
|
||||
def git(self, *args):
|
||||
return subprocess.check_output(
|
||||
["git", "-c", f"core.hooksPath={self.repo / 'no-hooks'}", "-c", "user.name=Test",
|
||||
"-c", "[email protected]", "-c", "commit.gpgsign=false",
|
||||
"-c", "tag.gpgsign=false", *args],
|
||||
cwd=self.repo, env=self.env, text=True, stderr=subprocess.PIPE).strip()
|
||||
|
||||
def commit_file(self, path, content="Public fixture\n"):
|
||||
destination = self.repo / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content)
|
||||
self.git("add", "-f", path)
|
||||
self.git("commit", "-m", f"Add {path}")
|
||||
return self.git("rev-parse", "HEAD")
|
||||
|
||||
def private_commit(self):
|
||||
return self.commit_file(".local/artifact.txt", "Private fixture, not game data\n")
|
||||
|
||||
def update(self, local_ref, local_oid, remote_ref=None, remote_oid=ZERO_OID):
|
||||
return f"{local_ref} {local_oid} {remote_ref or local_ref} {remote_oid}\n"
|
||||
|
||||
def push(self, *updates):
|
||||
return subprocess.run(["sh", str(ROOT / ".githooks/pre-push"), "origin", "unused"],
|
||||
cwd=self.repo, env=self.env, input="".join(updates),
|
||||
text=True, capture_output=True)
|
||||
|
||||
def assert_passed(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("Public tree check passed.", result.stdout)
|
||||
|
||||
def assert_private_rejected(self, result):
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn(".local/artifact.txt", result.stderr)
|
||||
|
||||
def test_clean_branch_ignores_unpushed_refs_and_head(self):
|
||||
self.git("checkout", "-b", "local-only")
|
||||
private = self.private_commit()
|
||||
for ref in ["refs/remotes/archive/private", "refs/original/refs/heads/main", "refs/tags/private"]:
|
||||
self.git("update-ref", ref, private)
|
||||
self.git("branch", "clean", self.base)
|
||||
for remote_oid in [ZERO_OID, private]:
|
||||
with self.subTest(remote_oid=remote_oid):
|
||||
self.assert_passed(self.push(self.update("refs/heads/clean", self.base, remote_oid=remote_oid)))
|
||||
# The standalone audit still checks every local ref.
|
||||
audit = subprocess.run([sys.executable, "tools/check_public_tree.py", "--history"],
|
||||
cwd=self.repo, env=self.env, text=True, capture_output=True)
|
||||
self.assert_private_rejected(audit)
|
||||
|
||||
def test_raw_revision_push_uses_supplied_oid(self):
|
||||
self.private_commit()
|
||||
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
|
||||
|
||||
def test_unpushed_replacement_does_not_taint_clean_history(self):
|
||||
private = self.private_commit()
|
||||
tree = self.git("rev-parse", f"{private}^{{tree}}")
|
||||
replacement = self.git("commit-tree", tree, "-m", "Private replacement root")
|
||||
self.git("replace", self.base, replacement)
|
||||
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
|
||||
|
||||
def test_replacement_cannot_hide_private_history(self):
|
||||
private = self.private_commit()
|
||||
self.git("replace", private, self.base)
|
||||
self.assert_private_rejected(self.push(self.update("refs/heads/main", private)))
|
||||
|
||||
def test_all_pushed_refs_are_checked(self):
|
||||
private = self.private_commit()
|
||||
clean_update = self.update("refs/heads/clean", self.base)
|
||||
private_update = self.update("refs/heads/main", private)
|
||||
for updates in [(clean_update, private_update), (private_update, clean_update)]:
|
||||
with self.subTest(updates=updates):
|
||||
self.assert_private_rejected(self.push(*updates))
|
||||
|
||||
def test_private_ancestors_are_rejected_even_after_removal_and_an_earlier_push(self):
|
||||
self.private_commit()
|
||||
self.git("rm", ".local/artifact.txt")
|
||||
self.git("commit", "-m", "Remove private fixture")
|
||||
remote = self.git("rev-parse", "HEAD")
|
||||
tip = self.commit_file("public.txt")
|
||||
for remote_oid in [ZERO_OID, remote]:
|
||||
with self.subTest(remote_oid=remote_oid):
|
||||
self.assert_private_rejected(self.push(self.update("refs/heads/main", tip, remote_oid=remote_oid)))
|
||||
|
||||
def test_annotated_tags_check_their_target_history(self):
|
||||
self.private_commit()
|
||||
self.git("rm", ".local/artifact.txt")
|
||||
self.git("commit", "-m", "Remove private fixture")
|
||||
self.git("tag", "-a", "clean", self.base, "-m", "Clean tag")
|
||||
self.git("tag", "-a", "private", "-m", "Private history tag")
|
||||
clean = self.git("rev-parse", "refs/tags/clean")
|
||||
private = self.git("rev-parse", "refs/tags/private")
|
||||
self.assert_passed(self.push(self.update("refs/tags/clean", clean)))
|
||||
self.assert_private_rejected(self.push(self.update("refs/tags/private", private)))
|
||||
|
||||
def test_empty_push_and_deletions_do_not_scan_unpushed_history(self):
|
||||
private = self.private_commit()
|
||||
deletion = self.update("(delete)", ZERO_OID, "refs/heads/private", private)
|
||||
clean_update = self.update(self.base, self.base, "refs/heads/public")
|
||||
for updates in [(), (deletion,), (deletion, clean_update)]:
|
||||
with self.subTest(updates=updates):
|
||||
self.assert_passed(self.push(*updates))
|
||||
|
||||
def test_private_file_introduced_only_by_merge_resolution_is_rejected(self):
|
||||
self.git("checkout", "-b", "feature")
|
||||
self.commit_file("feature.txt")
|
||||
self.git("checkout", "main")
|
||||
self.commit_file("main.txt")
|
||||
self.git("merge", "--no-ff", "--no-commit", "feature")
|
||||
merge = self.private_commit()
|
||||
self.assert_private_rejected(self.push(self.update("refs/heads/main", merge)))
|
||||
|
||||
def test_invalid_pushed_oid_fails_closed(self):
|
||||
result = self.push(self.update("refs/heads/missing", "f" * 40))
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("Public tree check passed.", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,19 +22,40 @@ def prohibited(path):
|
||||
return bool(parsed.parts and parsed.parts[0] in PRIVATE_ROOTS) or parsed.suffix.lower() in PRIVATE_EXTENSIONS
|
||||
|
||||
|
||||
def paths_in_repository(history=False):
|
||||
def paths_in_repository(history=False, revisions=None):
|
||||
if not history:
|
||||
result = subprocess.check_output(["git", "ls-files", "-z"])
|
||||
return result.decode("utf-8").split("\0")
|
||||
result = subprocess.check_output(["git", "log", "--all", "--format=", "--name-only", "-z"])
|
||||
# An empty push (including deletions only) must not fall back to HEAD or --all.
|
||||
if revisions is not None and not revisions:
|
||||
return []
|
||||
roots = ["--all"] if revisions is None else revisions
|
||||
# Include diffs against merge parents so merge-resolution-only artifacts are caught.
|
||||
# Git pushes the stored objects, not the history presented by local replace refs.
|
||||
result = subprocess.check_output(["git", "--no-replace-objects", "log", "--format=", "--name-only",
|
||||
"-z", "-m", *roots, "--"])
|
||||
return [path.lstrip("\n") for path in result.decode("utf-8").split("\0")]
|
||||
|
||||
|
||||
def pushed_revisions(stream):
|
||||
"""Read Git's pre-push ref updates and retain the non-deleted local object IDs."""
|
||||
revisions = []
|
||||
for line in stream:
|
||||
_local_ref, local_oid, _remote_ref, _remote_oid = line.split()
|
||||
if local_oid.strip("0"):
|
||||
revisions.append(local_oid)
|
||||
return revisions
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--history", action="store_true", help="Also inspect paths in all local Git history")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--history", action="store_true", help="Inspect paths in all local Git history")
|
||||
mode.add_argument("--pre-push", action="store_true", help="Inspect history reachable from Git's ref updates on stdin")
|
||||
args = parser.parse_args()
|
||||
rejected = sorted({path for path in paths_in_repository(args.history) if path and prohibited(path)})
|
||||
revisions = pushed_revisions(sys.stdin) if args.pre_push else None
|
||||
rejected = sorted({path for path in paths_in_repository(args.history or args.pre_push, revisions)
|
||||
if path and prohibited(path)})
|
||||
if rejected:
|
||||
print("Private game/profiling artifacts must stay outside Git:", file=sys.stderr)
|
||||
for path in rejected:
|
||||
|
||||
@@ -36,6 +36,11 @@ def group_setup():
|
||||
*setup('/kill @e[type=!player]'),
|
||||
{'op': 'tick', 'n': 25},
|
||||
*setup('/fill 0 200 0 15 228 15 air', '/time set 1000', '/weather clear', '/difficulty normal', '/clear @s'),
|
||||
# Before the gamerules above, while the oracle waits for its entities to load (a number of ticks that
|
||||
# depends on the machine), mobs spawn and some die with their drops on: a bat's death rolled
|
||||
# minecraft:entities/bat on 1 vanilla run in 24, one more sequence for the ticking group's
|
||||
# /random reset * to count. Every run starts the script without them.
|
||||
*setup('/random reset *'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+449
-2
@@ -87,7 +87,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- commands: the guest's chat completes commands and their arguments over the server's command
|
||||
tree (Tab), and runs what it completed;
|
||||
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
|
||||
places a log in the inventory's crafting grid (the server places it).
|
||||
places a log in the inventory's crafting grid (the server places it).
|
||||
- recipe_removal: the host and guest each forget all and individual recipes while the book
|
||||
is open; it stays empty when reopened, and a later grant appears (#1495).
|
||||
- feedback: the guest's command feedback names the guest and shows the numbers, and the host (an
|
||||
operator) is told of it as `[Guest: ...]`.
|
||||
- clicks: with real mouse presses (not click-slot, which skips the screen's press and drag
|
||||
@@ -102,6 +104,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
pushes it, as in vanilla), not ever faster across the ground.
|
||||
- eat: holding right click with bread, the guest's own screen plays the eating (the item in use
|
||||
counts up) and the server eats one loaf and feeds the guest.
|
||||
- consume-release: the host and the guest each cancel eating a golden apple and drinking milk from
|
||||
either hand: use ends on client and server, the item and effects stay, and holding
|
||||
use to completion still consumes the item (#1293).
|
||||
- eating: each side sees the other player eat: the item in use (LivingEntity's flags) on the
|
||||
player it draws, and no longer once the bread is eaten (#803);
|
||||
- xp: experience orbs 4 blocks away fly to the guest, show on the guest's screen and give the
|
||||
@@ -109,7 +114,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the
|
||||
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
|
||||
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
|
||||
the host has them; a falling block lands on both sides.
|
||||
the host has them; a falling block lands on both sides.
|
||||
- shulker_bullets: every shot a shulker fires has its own id and flies on both sides, following
|
||||
the same trajectory; the bullets disappear on both sides when they hit blocks (#1494).
|
||||
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
|
||||
sides (#1363, #1391);
|
||||
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
|
||||
@@ -136,6 +143,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
server's game time after the guest's game stalls for a few seconds (#848).
|
||||
- growth: far from the host, the chunks around the guest tick (#890): water placed there flows
|
||||
and wheat on farmland grows (random ticks), however the host runs its world.
|
||||
- farmland: the host and guest each trample planted farmland: the wheat breaks and drops its items,
|
||||
and both clients see dirt/air (#1492).
|
||||
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
|
||||
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
|
||||
- boats: the host (single player, on its server thread or not) and the guest each right-click a
|
||||
@@ -4897,6 +4906,82 @@ CHECKS += ['helduse']
|
||||
CHECK_FNS.update(helduse=check_held_use)
|
||||
|
||||
|
||||
def check_consume_release(host, guest, out):
|
||||
"""#1293: releasing a consumable in either hand cancels it on client and server, while
|
||||
holding it to completion applies its effects and remainder (Consumable.onConsume)."""
|
||||
apart(host, guest)
|
||||
seen = {}
|
||||
try:
|
||||
for game in (host, guest):
|
||||
game('command', command='gamemode survival')
|
||||
x, y, z = pos_of(game)
|
||||
game.raw('look-at', x, y + 1.6, z + 2)
|
||||
for hand in ('mainhand', 'offhand'):
|
||||
for item in ('golden_apple', 'milk_bucket'):
|
||||
case = f'{game.name}-{hand}-{item}'
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
||||
game('command', command='effect clear @s')
|
||||
if item == 'milk_bucket':
|
||||
game('command', command='effect give @s minecraft:speed 60')
|
||||
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:{item}')
|
||||
game.wait(10)
|
||||
assert game('looking_at')['kind'] == 'none', f'{case} is not aimed into the air'
|
||||
before = set(effect_ids(game))
|
||||
assert before == ({'speed'} if item == 'milk_bucket' else set()), f'{case}: {before}'
|
||||
game.raw('mouse', 'right', 'down')
|
||||
game.wait(5)
|
||||
during = {'client': game('state').get('using'), 'server': server_using(host, game)}
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game.wait(10)
|
||||
released = {'client': game('state').get('using'), 'server': server_using(host, game)}
|
||||
# Wait past the full use duration: the server must not finish eating after release.
|
||||
game.wait(60)
|
||||
effects = set(effect_ids(game))
|
||||
inventory = game('inventory')
|
||||
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
|
||||
cancelled = {'during': during, 'released': released,
|
||||
'stack': stack, 'effects': sorted(effects)}
|
||||
seen[case] = {'cancelled': cancelled}
|
||||
assert during['client'] and during['server'], f'{case} did not start on both sides: {cancelled}'
|
||||
assert released == {'client': None, 'server': None}, f'{case} did not cancel: {cancelled}'
|
||||
assert stack and stack['item'] == f'minecraft:{item}' and stack['count'] == 1, \
|
||||
f'{case} consumed the released item: {cancelled}'
|
||||
assert effects == before, f'{case} applied consumption effects after release: {cancelled}'
|
||||
game.shot(out, f'consume-release-{hand}-{item}')
|
||||
# A sustained press must still consume normally, including the milk's empty bucket.
|
||||
game.raw('mouse', 'right', 'down')
|
||||
game.wait(40)
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game.wait(10)
|
||||
inventory = game('inventory')
|
||||
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
|
||||
completed = {'stack': stack, 'effects': sorted(effect_ids(game)),
|
||||
'using': (game('state').get('using'), server_using(host, game))}
|
||||
seen[case]['completed'] = completed
|
||||
assert completed['using'] == (None, None), \
|
||||
f'{case} did not finish normally: {completed}'
|
||||
if item == 'golden_apple':
|
||||
assert inventory['counts'].get('minecraft:golden_apple', 0) == 0, \
|
||||
f'{case} kept the eaten apple: {completed}'
|
||||
assert set(completed['effects']) == {'regeneration', 'absorption'}, f'{case}: {completed}'
|
||||
else:
|
||||
assert not completed['effects'] and stack and stack['item'] == 'minecraft:bucket' \
|
||||
and stack['count'] == 1, f'{case}: {completed}'
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
||||
game('command', command='effect clear @s')
|
||||
face_each_other(host, guest)
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['consume-release']
|
||||
CHECK_FNS.update({'consume-release': check_consume_release})
|
||||
|
||||
|
||||
def check_wind(host, guest, out):
|
||||
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
|
||||
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
|
||||
@@ -5439,6 +5524,57 @@ CHECKS += ['knockback']
|
||||
CHECK_FNS.update(knockback=check_knockback)
|
||||
|
||||
|
||||
def check_spectator_travel(host, guest, out):
|
||||
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
|
||||
players = (host, guest) if SERVER_THREAD else (host,)
|
||||
seen = {}
|
||||
for game in players:
|
||||
home = pos_of(game)
|
||||
game('command', command='gamemode spectator')
|
||||
game.wait(10)
|
||||
try:
|
||||
for dimension in (NETHER, OVERWORLD):
|
||||
game('command', command=f'execute in {dimension} run tp @s .5 102 .5 0 0')
|
||||
there = wait_dimension(game, dimension, 600)
|
||||
assert there.get('dimension') == dimension, f'{game.name} stayed in {there.get("dimension")}'
|
||||
# A replacement body arrives before its chunks; fill needs the whole corridor loaded.
|
||||
for _ in range(30):
|
||||
if all(game.raw('block', *pos).get('loaded') for pos in ((-2, 101, -1), (2, 105, 16))):
|
||||
break
|
||||
game.wait(20)
|
||||
game('command', command='fill -2 101 -1 2 105 16 minecraft:air')
|
||||
game('command', command='fill -2 101 3 2 105 3 minecraft:stone')
|
||||
game.wait(10)
|
||||
wall = game.raw('block', 0, 102, 3)
|
||||
assert wall.get('loaded') and wall.get('block') == 'minecraft:stone', f'wall: {wall}'
|
||||
label = f'spectator-{dimension.removeprefix("minecraft:")}'
|
||||
game.shot(out, f'{label}-before')
|
||||
game.raw('key', 'w', 'down')
|
||||
game.wait(20)
|
||||
game.raw('key', 'w', 'up')
|
||||
game.wait(10)
|
||||
state = game('state')
|
||||
client = state['player']
|
||||
server = server_player(host, game)
|
||||
seen[f'{game.name}-{dimension}'] = {'client': client['position'], 'server': server['position']}
|
||||
game.shot(out, f'{label}-after')
|
||||
assert state.get('gamemode') == 'spectator' and client['flying'], f'{game.name} lost spectator flight'
|
||||
assert client['position'][2] > 4 and server['position'][2] > 4, \
|
||||
f'{game.name} collides after travel to {dimension}: {seen}'
|
||||
game('command', command='fill -2 101 3 2 105 3 minecraft:air')
|
||||
finally:
|
||||
game.raw('key', 'w', 'up')
|
||||
game('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
|
||||
wait_dimension(game, OVERWORLD, 600)
|
||||
game('command', command='gamemode survival')
|
||||
game.wait(20)
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['spectator-travel']
|
||||
CHECK_FNS.update({'spectator-travel': check_spectator_travel})
|
||||
|
||||
|
||||
def check_villagers(host, guest, out):
|
||||
"""Villager.DATA_VILLAGER_DATA and ZombieVillager's reach the guest: summoned villagers show
|
||||
their type, profession and level, and an unemployed villager that takes a composter placed
|
||||
@@ -5498,6 +5634,317 @@ CHECKS += ['villagers']
|
||||
CHECK_FNS.update(villagers=check_villagers)
|
||||
|
||||
|
||||
def check_recipe_removal(host, guest, out):
|
||||
"""ServerRecipeBook.removeRecipes sends ClientboundRecipeBookRemovePacket to the player:
|
||||
the open book updates immediately, reopening keeps it empty, and later grants still arrive."""
|
||||
seen = {}
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game('command', command='clear @s')
|
||||
game('command', command='give @s minecraft:oak_log 4')
|
||||
game('command', command='recipe give @s minecraft:oak_planks')
|
||||
game('command', command='recipe give @s minecraft:stick')
|
||||
game.wait(40)
|
||||
game.raw('key', 'e', 'tap')
|
||||
game.wait(5)
|
||||
try:
|
||||
book = game('screen').get('recipe_book')
|
||||
assert book, f'{game.name}: the inventory has no recipe book'
|
||||
if len(book['targets']) == 1:
|
||||
game.raw('click-at', *centre(book['targets'][0]))
|
||||
game.wait(5)
|
||||
book = game('screen')['recipe_book']
|
||||
known = book['known']
|
||||
assert known > 1, f'{game.name}: recipes were not granted: {book}'
|
||||
game.shot(out, 'recipe-removal-before')
|
||||
|
||||
game('command', command='recipe take @s *')
|
||||
game.wait(20)
|
||||
empty = game('screen')['recipe_book']['known']
|
||||
game.shot(out, 'recipe-removal-empty')
|
||||
assert empty == 0, f'{game.name}: forgotten recipes remain in the open book: {empty}'
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.wait(5)
|
||||
game.raw('key', 'e', 'tap')
|
||||
game.wait(5)
|
||||
reopened = game('screen')['recipe_book']['known']
|
||||
assert reopened == 0, f'{game.name}: reopening restored {reopened} forgotten recipes'
|
||||
|
||||
game('command', command='recipe give @s minecraft:oak_planks')
|
||||
game('command', command='recipe give @s minecraft:stick')
|
||||
game.wait(20)
|
||||
given = game('screen')['recipe_book']['known']
|
||||
assert given == 2, f'{game.name}: two later grants did not appear: {given}'
|
||||
game('command', command='recipe take @s minecraft:oak_planks')
|
||||
game.wait(20)
|
||||
one = game('screen')['recipe_book']['known']
|
||||
assert one == 1, f'{game.name}: taking one recipe left {one}, expected 1'
|
||||
game('command', command='recipe take @s minecraft:stick')
|
||||
game.wait(20)
|
||||
assert game('screen')['recipe_book']['known'] == 0, f'{game.name}: the last recipe remains'
|
||||
|
||||
game('command', command='recipe give @s minecraft:oak_planks')
|
||||
game.wait(20)
|
||||
granted = game('screen')['recipe_book']['known']
|
||||
assert granted == 1, f'{game.name}: a later grant did not appear: {granted}'
|
||||
game.shot(out, 'recipe-removal-granted')
|
||||
seen[game.name] = {'known': known, 'empty': empty, 'reopened': reopened,
|
||||
'given': given, 'one': one, 'granted': granted}
|
||||
finally:
|
||||
back_to_game(game)
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['recipe_removal']
|
||||
CHECK_FNS.update(recipe_removal=check_recipe_removal)
|
||||
|
||||
|
||||
def check_shulker_bullets(host, guest, out):
|
||||
"""ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing
|
||||
projectile renderer and interpolation, with distinct ids from the server's entity counter."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
tag = 'lan_shulker_bullets'
|
||||
selector = f'@e[type=minecraft:shulker,tag={tag},limit=1]'
|
||||
sound = 'minecraft:entity.shulker.shoot'
|
||||
paths = {g.name: {} for g in (host, guest)}
|
||||
before = {g.name: heard(g, sound) for g in (host, guest)}
|
||||
shots = dict.fromkeys(before, 0)
|
||||
first_shot = stopped = None
|
||||
photographed = False
|
||||
|
||||
def bullets(status):
|
||||
return [p for p in status['projectiles'] if p['kind'] == 'ShulkerBullet']
|
||||
|
||||
try:
|
||||
host('command', command='difficulty normal')
|
||||
host('command', command='gamemode creative')
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='effect give @s minecraft:resistance 120 4 true')
|
||||
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
|
||||
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
|
||||
# A west wall supports the shulker above the floor, leaving room for a short flight.
|
||||
host('command', command=f'fill {x - 1} {y} {z + 7} {x - 1} {y + 4} {z + 7} minecraft:stone')
|
||||
host('command', command=f'tp @s {x + 4.5} {y} {z - 0.5}')
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
host('command', command=f'summon minecraft:shulker {x + 0.5} {y + 3} {z + 7.5} '
|
||||
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
|
||||
for game in (host, guest):
|
||||
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
|
||||
guest.wait(20)
|
||||
host('command', command=f'data merge entity {selector} {{NoAI:0b}}')
|
||||
for waited in range(2, 402, 2):
|
||||
guest.wait(2)
|
||||
for game in (host, guest):
|
||||
status = game('status')
|
||||
live = bullets(status)
|
||||
ids = [p['id'] for p in live]
|
||||
assert all(ids) and len(set(ids)) == len(ids), f'{game.name}: invalid bullet ids {ids}'
|
||||
for p in live:
|
||||
paths[game.name].setdefault(p['id'], []).append(p['position'])
|
||||
shots[game.name] = status['heard'].get(sound, 0) - before[game.name]
|
||||
if shots['host'] and first_shot is None:
|
||||
first_shot = waited
|
||||
if first_shot is not None and waited - first_shot >= 8 and not photographed:
|
||||
host('command', command='tick freeze')
|
||||
try:
|
||||
for game in (host, guest):
|
||||
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
|
||||
game.raw('key', 'f1', 'tap')
|
||||
try:
|
||||
game.shot(out, 'shulker-bullets')
|
||||
finally:
|
||||
game.raw('key', 'f1', 'tap')
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
photographed = True
|
||||
if shots['host'] >= 2 and stopped is None:
|
||||
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
|
||||
stopped = waited
|
||||
if stopped is not None and waited - stopped >= 20:
|
||||
break
|
||||
# Keep the survival target in range after a hit applies levitation.
|
||||
if waited % 20 == 0:
|
||||
guest('command', command='effect clear @s minecraft:levitation')
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
lab.write_json(out / 'shulker-bullets.json', {'shots': shots, 'paths': paths})
|
||||
assert shots['host'] >= 2, f'the shulker did not attack twice: {shots}'
|
||||
for name, by_id in paths.items():
|
||||
assert len(by_id) == shots[name], f'{name}: {shots[name]} shots but bullet ids {list(by_id)}'
|
||||
for id, positions in by_id.items():
|
||||
assert any(math.dist(positions[0], p) > 0.5 for p in positions), \
|
||||
f'{name}: bullet {id} did not move: {positions}'
|
||||
assert paths['host'].keys() == paths['guest'].keys(), f'different bullets: {paths}'
|
||||
for id, positions in paths['guest'].items():
|
||||
# The clients interpolate three ticks behind, and reads are not simultaneous.
|
||||
assert all(min(math.dist(p, q) for q in paths['host'][id]) < 1.5 for p in positions), \
|
||||
f'bullet {id} diverged from the host: {paths}'
|
||||
|
||||
# Put blocks in the flight volume to exercise server removal without depending on homing.
|
||||
for game in (host, guest):
|
||||
game('command', command='gamemode creative')
|
||||
game('command', command=f'tp @s {x + 0.5} {y + 16} {z + 0.5}')
|
||||
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:stone')
|
||||
for _ in range(20):
|
||||
guest.wait(2)
|
||||
remaining = {g.name: bullets(g('status')) for g in (host, guest)}
|
||||
if not any(remaining.values()):
|
||||
break
|
||||
assert not any(remaining.values()), f'bullets survived block hits: {remaining}'
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
|
||||
for game in (host, guest):
|
||||
game('command', command='effect clear @s')
|
||||
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
game('command', command='gamemode survival')
|
||||
face_each_other(host, guest)
|
||||
return {'shots': shots, 'ids': sorted(paths['host']), 'removed': True}
|
||||
|
||||
|
||||
CHECKS += ['shulker_bullets']
|
||||
CHECK_FNS.update(shulker_bullets=check_shulker_bullets)
|
||||
|
||||
|
||||
def check_farmland(host, guest, out):
|
||||
"""FarmlandBlock.fallOn's base-block change breaks the unsupported crop (#1492):
|
||||
the host and guest each land on mature wheat, get its drops, and both clients see dirt/air."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
y = max(hy + 12, 100)
|
||||
starts = {g.name: pos_of(g) for g in (host, guest)}
|
||||
seen = {}
|
||||
host('command', command='gamerule random_tick_speed 0')
|
||||
try:
|
||||
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
|
||||
x, z = hx + 2, hz + dz
|
||||
soil, crop, marker = (x, y, z), (x, y + 1, z), (x + 4, y + 1, z)
|
||||
for g in (game, other):
|
||||
g('command', command='gamemode creative')
|
||||
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y} {z + 2} minecraft:stone')
|
||||
host('command', command=f'fill {x - 4} {y + 1} {z - 2} {x + 4} {y + 7} {z + 2} minecraft:air')
|
||||
host('command', command=f'setblock {x} {y} {z} minecraft:farmland[moisture=7]')
|
||||
host('command', command=f'setblock {x} {y + 1} {z} minecraft:wheat[age=7]')
|
||||
other('command', command=f'tp @s {x + 3.5} {y + 1} {z + 0.5}')
|
||||
game('command', command=f'tp @s {x + 2.5} {y + 1} {z + 0.5}')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='gamemode survival')
|
||||
game.wait(20)
|
||||
before = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
|
||||
for g in (host, guest)}
|
||||
assert all(v == ['minecraft:farmland', 'minecraft:wheat'] for v in before.values()), \
|
||||
f'{game.name}: planted wheat missing before landing: {before}'
|
||||
other.raw('look-at', x + 0.5, y + 1, z + 0.5)
|
||||
other.shot(out, f'farmland-{game.name}-before')
|
||||
# More than 1.5 blocks of fall distance guarantees the trampling random check.
|
||||
game('command', command=f'tp @s {x + 0.5} {y + 6} {z + 0.5}')
|
||||
game.wait(80)
|
||||
after = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
|
||||
for g in (host, guest)}
|
||||
# Test the authoritative blocks too, rather than only each client's view.
|
||||
game('command', command=f'execute if block {x} {y} {z} minecraft:dirt '
|
||||
f'if block {x} {y + 1} {z} minecraft:air run '
|
||||
f'setblock {marker[0]} {marker[1]} {marker[2]} minecraft:gold_block')
|
||||
game.wait(10)
|
||||
counts = {item: server_count(game, item) for item in ('wheat', 'wheat_seeds')}
|
||||
client_counts = game('inventory')['counts']
|
||||
seen[game.name] = {'blocks': after, 'server': game.raw('block', *marker).get('block'),
|
||||
'drops': counts,
|
||||
'client_drops': {item: client_counts.get('minecraft:' + item, 0)
|
||||
for item in counts}}
|
||||
other.shot(out, f'farmland-{game.name}-after')
|
||||
finally:
|
||||
host('command', command='gamerule random_tick_speed 3')
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
for g in (host, guest):
|
||||
x0, y0, z0 = starts[g.name]
|
||||
g('command', command=f'tp @s {x0} {y0} {z0}')
|
||||
g('command', command='gamemode survival')
|
||||
for name, s in seen.items():
|
||||
assert all(v == ['minecraft:dirt', 'minecraft:air'] for v in s['blocks'].values()), \
|
||||
f'{name}: unsupported wheat after landing: {seen}'
|
||||
assert s['server'] == 'minecraft:gold_block', f'{name}: server kept the unsupported wheat: {seen}'
|
||||
assert s['drops']['wheat'] == 1 and 1 <= s['drops']['wheat_seeds'] <= 4, \
|
||||
f'{name}: mature wheat drops missing: {seen}'
|
||||
assert s['client_drops'] == s['drops'], f'{name}: crop drops did not synchronize: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['farmland']
|
||||
CHECK_FNS.update(farmland=check_farmland)
|
||||
|
||||
|
||||
def check_large_chest(host, guest, out):
|
||||
"""Every player slot can be picked up or shift-clicked into a double chest (#1505, #1589).
|
||||
Reopening checks the authoritative contents, with a single chest as the control."""
|
||||
seen = {}
|
||||
for game in (host, guest):
|
||||
game('command', command='gamemode survival')
|
||||
x, y, z = (int(v // 1) for v in pos_of(game))
|
||||
chest, other = (x, y, z + 2), (x + 1, y, z + 2)
|
||||
try:
|
||||
for double in (False, True):
|
||||
size = 54 if double else 27
|
||||
# Double chest indices 54..89 include the old cutoff at 63/64 and every hotbar slot.
|
||||
sources = list(range(9, 36)) + list(range(9)) if double else [19, 0, 8]
|
||||
for shift in (False, True):
|
||||
back_to_game(game)
|
||||
for at in (chest, other):
|
||||
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
game('command', command='kill @e[type=minecraft:item,distance=..8]')
|
||||
game('command', command='clear @s')
|
||||
block = 'minecraft:chest[facing=north,type=left]' if double else 'minecraft:chest'
|
||||
game('command', command=f'setblock {x} {y} {z + 2} {block}')
|
||||
if double:
|
||||
game('command', command=f'setblock {x + 1} {y} {z + 2} minecraft:chest[facing=north,type=right]')
|
||||
for slot in sources:
|
||||
name = f'hotbar.{slot}' if slot < 9 else f'inventory.{slot - 9}'
|
||||
game('command', command=f'item replace entity @s {name} with minecraft:diamond')
|
||||
game.wait(10)
|
||||
game.raw('interact', *chest)
|
||||
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
game.wait(10)
|
||||
assert container_items(game)[1] == size, f'{game.name}: expected {size} chest slots'
|
||||
label = f'{"double" if double else "single"}-chest-{"shift" if shift else "pickup"}'
|
||||
for target, slot in enumerate(sources):
|
||||
if shift:
|
||||
game.raw('key', 'shift', 'down')
|
||||
try:
|
||||
mouse(game, slot)
|
||||
finally:
|
||||
if shift:
|
||||
game.raw('key', 'shift', 'up')
|
||||
if not shift:
|
||||
held = game('inventory').get('carried') or {}
|
||||
if held.get('item') != 'minecraft:diamond' or held.get('count') != 1:
|
||||
game.shot(out, f'{label}-slot-{slot}-failed')
|
||||
raise AssertionError(f'{game.name} {label}: slot {slot} picked up {held}')
|
||||
mouse(game, 300 + target)
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.wait(10)
|
||||
game.raw('interact', *chest)
|
||||
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
game.wait(10)
|
||||
contents = [s for s in game('screen')['slots'] if 300 <= s['slot'] < 300 + size and s.get('item')]
|
||||
inv = game('inventory')
|
||||
stored = sum(s['count'] for s in contents if s['item'] == 'minecraft:diamond')
|
||||
assert stored == len(sources) and not inv['counts'].get('minecraft:diamond') and not inv.get('carried'), \
|
||||
f'{game.name} {label}: chest {contents}, inventory {inv}'
|
||||
game.shot(out, label)
|
||||
seen[f'{game.name}-{label}'] = stored
|
||||
game.raw('key', 'esc', 'tap')
|
||||
finally:
|
||||
back_to_game(game)
|
||||
for at in (chest, other):
|
||||
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
game('command', command='kill @e[type=minecraft:item,distance=..8]')
|
||||
game('command', command='clear @s')
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['large_chest']
|
||||
CHECK_FNS.update(large_chest=check_large_chest)
|
||||
|
||||
|
||||
def check_falling(host, guest, out):
|
||||
"""ItemEntity and FallingBlockEntity tick on a client too (gravity, collisions, drag), and their
|
||||
position packets snap them (Entity's NO_OP InterpolationHandler). The server sends an item's
|
||||
|
||||
@@ -181,20 +181,25 @@ def settle(server, ticks=600, timeout=600):
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
def game_time():
|
||||
start = len(server.lines)
|
||||
server.console('time query gametime')
|
||||
while time.time() < deadline:
|
||||
for line in server.lines[start:]:
|
||||
if m := re.search(r'The (?:game )?time is (\d+)', line):
|
||||
return int(m.group(1))
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f'{server.log_path.name}: no game time in {timeout} s')
|
||||
until = game_time() + ticks
|
||||
while game_time() < until:
|
||||
until = game_time(server, deadline - time.time()) + ticks
|
||||
while game_time(server, deadline - time.time()) < until:
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def game_time(server, timeout=300):
|
||||
"""The server's game time, asked from its console: the console runs its queue in order, so every console
|
||||
command sent before has run."""
|
||||
deadline = time.time() + timeout
|
||||
start = len(server.lines)
|
||||
server.console('time query gametime')
|
||||
while time.time() < deadline:
|
||||
for line in server.lines[start:]:
|
||||
if m := re.search(r'The (?:game )?time is (\d+)', line):
|
||||
return int(m.group(1))
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f'{server.log_path.name}: no game time in {timeout:.0f} s')
|
||||
|
||||
|
||||
def legacy_ping(port):
|
||||
"""The reply to a 1.6 client's 0xFE 0x01 0xFA MC|PingHost query (LegacyQueryHandler)."""
|
||||
host = 'localhost'
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Item stacks on the wire: every data component, as vanilla 26.3 encodes it.
|
||||
|
||||
VanillaItemWireOracle encodes stacks with ItemStack.OPTIONAL_STREAM_CODEC
|
||||
(what a server sends in inventories, recipe books and advancements): each
|
||||
default component value of every vanilla item, the SNBT fixtures below
|
||||
(direct holders, tags and lists, optional fields, every consume effect,
|
||||
block predicates with component matchers, the mob variants) and patches
|
||||
built in Java (components without a persistent codec, removals). The Rust
|
||||
probe decodes each with horizon_protocol::item::Slot and must consume every
|
||||
byte and find the same component ids in the same order; a stack whose
|
||||
components Horizon only reads past must also encode back to the same bytes.
|
||||
Every id in DataComponents must be covered."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
|
||||
COMPONENT_TYPES = 122
|
||||
|
||||
FIXTURES = [
|
||||
('custom_data', '{custom_data:{a:1,b:"x",c:[1,2]}}'),
|
||||
('unbreakable', '{unbreakable:{}}'),
|
||||
('custom_name', '{custom_name:{text:"Hi",color:"red",bold:1b}}'),
|
||||
('lore', '{lore:["a",{text:"b",italic:0b}]}'),
|
||||
('enchantments', '{enchantments:{"minecraft:sharpness":5,"minecraft:unbreaking":3}}'),
|
||||
('can_break tag', '{can_break:{blocks:"#minecraft:logs"}}'),
|
||||
('can_break list state nbt', '{can_break:[{blocks:["minecraft:stone","minecraft:dirt"],state:{snowy:"true",age:{min:"1",max:"3"},level:{min:"2"}},nbt:"{a:1b}"}]}'),
|
||||
('can_place_on components', '{can_place_on:{blocks:"minecraft:chest",components:{"minecraft:custom_name":"x","minecraft:damage":3},predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{durability:{min:1}}}}}'),
|
||||
('can_place_on empty', '{can_place_on:[{}]}'),
|
||||
('custom_model_data', '{custom_model_data:{floats:[1.0f,2.5f],flags:[1b,0b],strings:["a"],colors:[16711680]}}'),
|
||||
('tooltip_display', '{tooltip_display:{hide_tooltip:1b,hidden_components:["minecraft:enchantments","minecraft:damage"]}}'),
|
||||
('repair_cost', '{repair_cost:7}'),
|
||||
('intangible_projectile', '{intangible_projectile:{}}'),
|
||||
('tooltip_style', '{tooltip_style:"minecraft:fancy"}'),
|
||||
('interact_animation', '{interact_animation:{type:"stab",duration:9}}'),
|
||||
('dyed_color', '{dyed_color:16711935}'),
|
||||
('map_id', '{map_id:42}'),
|
||||
('written_book_content', '{written_book_content:{title:{raw:"T",filtered:"F"},author:"me",generation:1,pages:[{raw:"p1",filtered:"q1"},"p2"],resolved:1b}}'),
|
||||
('trim registry', '{trim:{material:"minecraft:gold",pattern:"minecraft:coast"}}'),
|
||||
('trim direct', '{trim:{material:{palette_id:"minecraft:x",description:"X"},pattern:{asset_id:"minecraft:y",description:"Y",decal:1b}}}'),
|
||||
('block_entity_data', '{block_entity_data:{id:"minecraft:chest",Items:[]}}'),
|
||||
('lodestone_tracker target', '{lodestone_tracker:{target:{dimension:"minecraft:the_nether",pos:[1,-64,3]},tracked:0b}}'),
|
||||
('lodestone_tracker empty', '{lodestone_tracker:{}}'),
|
||||
('firework_explosion', '{firework_explosion:{shape:"star",colors:[1,2],fade_colors:[3],has_trail:1b,has_twinkle:0b}}'),
|
||||
('profile name', '{profile:{name:"Notch"}}'),
|
||||
('profile full', '{profile:{name:"jeb_",id:[I;1,2,3,4],properties:[{name:"textures",value:"abc",signature:"sig"}]}}'),
|
||||
('note_block_sound', '{note_block_sound:"minecraft:block.note_block.bell"}'),
|
||||
('base_color', '{base_color:"lime"}'),
|
||||
('sulfur_cube_content', '{sulfur_cube_content:{id:"minecraft:stone",count:1}}'),
|
||||
('lock', '{lock:{items:"minecraft:diamond"}}'),
|
||||
('container_loot', '{container_loot:{loot_table:"minecraft:chests/simple_dungeon",seed:5L}}'),
|
||||
('waxed', '{waxed:{}}'),
|
||||
('banner_patterns', '{banner_patterns:[{pattern:"minecraft:stripe_bottom",color:"red"},{pattern:{asset_id:"minecraft:x",translation_key:"y"},color:"blue"}]}'),
|
||||
('pot_decorations', '{pot_decorations:{back:{id:"minecraft:brick"},left:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd",count:1}}}'),
|
||||
('suspicious_stew_effects', '{suspicious_stew_effects:[{id:"minecraft:night_vision",duration:100},{id:"minecraft:poison"}]}'),
|
||||
('potion_contents full', '{potion_contents:{potion:"minecraft:swiftness",custom_color:123,custom_effects:[{id:"minecraft:speed",amplifier:1b,duration:200,ambient:1b,show_particles:0b,show_icon:1b,hidden_effect:{id:"minecraft:speed",duration:100,hidden_effect:{id:"minecraft:speed",duration:50}}}],custom_name:"x"}}'),
|
||||
('potion_contents bare', '{potion_contents:{}}'),
|
||||
('container', '{container:[{slot:0,item:{id:"minecraft:stone",count:3}},{slot:5,item:{id:"minecraft:diamond_sword",components:{"minecraft:damage":5,"minecraft:trim":{material:"minecraft:gold",pattern:"minecraft:coast"}}}}]}'),
|
||||
('bundle_contents', '{bundle_contents:[{id:"minecraft:stone",count:2},{id:"minecraft:potion",components:{"minecraft:potion_contents":{potion:"minecraft:water"}}}]}'),
|
||||
('charged_projectiles', '{charged_projectiles:[{id:"minecraft:arrow"},{id:"minecraft:firework_rocket",components:{"minecraft:fireworks":{flight_duration:2b,explosions:[{shape:"burst",colors:[5]}]}}}]}'),
|
||||
('block_state', '{block_state:{facing:"north",half:"top"}}'),
|
||||
('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'),
|
||||
('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'),
|
||||
('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'),
|
||||
('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'),
|
||||
('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'),
|
||||
('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'),
|
||||
('sign_text_back', '{sign_text_back:{messages:["a","","",""]}}'),
|
||||
('map_decorations', '{map_decorations:{a:{type:"minecraft:player",x:1.0d,z:2.0d,rotation:0f}}}'),
|
||||
('debug_stick_state', '{debug_stick_state:{"minecraft:oak_stairs":"facing"}}'),
|
||||
('recipes', '{recipes:["minecraft:stone_bricks"]}'),
|
||||
('attribute_modifiers', '{attribute_modifiers:[{type:"minecraft:attack_damage",id:"minecraft:x",amount:1.5d,operation:"add_value",slot:"mainhand",display:{type:"override",value:"hi"}},{type:"minecraft:armor",id:"minecraft:y",amount:2d,operation:"add_multiplied_total",display:{type:"hidden"}}]}'),
|
||||
('consumable all effects', '{consumable:{consume_seconds:2f,animation:"drink",sound:{sound_id:"minecraft:x",range:4f},has_consume_particles:0b,on_consume_effects:[{type:"apply_effects",effects:[{id:"minecraft:speed",duration:20}],probability:0.5f},{type:"remove_effects",effects:"minecraft:poison"},{type:"remove_effects",effects:["minecraft:poison","minecraft:wither"]},{type:"clear_all_effects"},{type:"teleport_randomly",diameter:8f},{type:"play_sound",sound:"minecraft:entity.generic.eat"}]}}'),
|
||||
('death_protection', '{death_protection:{death_effects:[{type:"clear_all_effects"}]}}'),
|
||||
('equippable full', '{equippable:{slot:"head",equip_sound:"minecraft:item.armor.equip_gold",asset_id:"minecraft:gold",camera_overlay:"minecraft:misc/pumpkinblur",allowed_entities:"#minecraft:skeletons",dispensable:0b,swappable:0b,damage_on_hurt:0b,equip_on_interact:1b,can_be_sheared:1b,shearing_sound:{sound_id:"minecraft:y"}}}'),
|
||||
('equippable bare', '{equippable:{slot:"chest"}}'),
|
||||
('tool rules', '{tool:{rules:[{blocks:"#minecraft:mineable/pickaxe",speed:4f,correct_for_drops:1b},{blocks:["minecraft:stone"]}],default_mining_speed:2f,damage_per_block:2,can_destroy_blocks_in_creative:0b}}'),
|
||||
('blocks_attacks full', '{blocks_attacks:{block_delay_seconds:0.5f,disable_cooldown_scale:2f,damage_reductions:[{horizontal_blocking_angle:45f,type:"#minecraft:is_fire",base:1f,factor:0.5f},{base:0f,factor:1f}],item_damage:{threshold:3f,base:1f,factor:2f},bypassed_by:"#minecraft:bypasses_shield",block_sound:"minecraft:item.shield.block",disable_sound:{sound_id:"minecraft:z"}}}'),
|
||||
('damage_resistant', '{damage_resistant:{types:"#minecraft:is_fire"}}'),
|
||||
('repairable list', '{repairable:{items:["minecraft:diamond","minecraft:emerald"]}}'),
|
||||
('use_cooldown group', '{use_cooldown:{seconds:1.5f,cooldown_group:"minecraft:x"}}'),
|
||||
('use_remainder', '{use_remainder:{id:"minecraft:bowl",count:2}}'),
|
||||
('fireworks', '{fireworks:{flight_duration:3b,explosions:[{shape:"creeper",colors:[1],fade_colors:[2,3],has_trail:1b},{shape:"small_ball"}]}}'),
|
||||
('provides_banner_patterns', '{provides_banner_patterns:"#minecraft:pattern_item/globe"}'),
|
||||
('wolf variant', '{"minecraft:wolf/variant":"minecraft:ashen","minecraft:wolf/sound_variant":"minecraft:angry","minecraft:wolf/collar":"red"}'),
|
||||
('fox salmon parrot', '{"minecraft:fox/variant":"snow","minecraft:salmon/size":"large","minecraft:parrot/variant":"blue"}'),
|
||||
('tropical fish', '{"minecraft:tropical_fish/pattern":"flopper","minecraft:tropical_fish/base_color":"white","minecraft:tropical_fish/pattern_color":"black"}'),
|
||||
('mooshroom rabbit', '{"minecraft:mooshroom/variant":"brown","minecraft:rabbit/variant":"gold"}'),
|
||||
('pig cow chicken', '{"minecraft:pig/variant":"minecraft:cold","minecraft:pig/sound_variant":"minecraft:big","minecraft:cow/variant":"minecraft:warm","minecraft:cow/sound_variant":"minecraft:classic","minecraft:chicken/variant":"minecraft:cold","minecraft:chicken/sound_variant":"minecraft:classic"}'),
|
||||
('nautilus frog horse', '{"minecraft:zombie_nautilus/variant":"minecraft:warm","minecraft:frog/variant":"minecraft:cold","minecraft:horse/variant":"chestnut"}'),
|
||||
('llama axolotl cat', '{"minecraft:llama/variant":"brown","minecraft:axolotl/variant":"blue","minecraft:cat/variant":"minecraft:tabby","minecraft:cat/sound_variant":"minecraft:classic","minecraft:cat/collar":"blue"}'),
|
||||
('sheep shulker villager', '{"minecraft:sheep/color":"pink","minecraft:shulker/color":"purple","minecraft:villager/variant":"minecraft:taiga"}'),
|
||||
('trim material direct provider', '{provides_trim_material:{palette_id:"minecraft:x",description:"X"}}'),
|
||||
('everything at once', '{damage:3,unbreakable:{},custom_name:"n",enchantments:{"minecraft:mending":1},trim:{material:"minecraft:gold",pattern:"minecraft:coast"},banner_patterns:[{pattern:"minecraft:globe",color:"red"}],pot_decorations:{right:{id:"minecraft:brick"}},lore:["x"],rarity:"epic"}'),
|
||||
]
|
||||
# Fixtures on another item than a stick (the components need it, or read better there).
|
||||
ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate',
|
||||
'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot',
|
||||
'everything at once': 'diamond_chestplate'}
|
||||
|
||||
|
||||
def compare(vanilla, rust):
|
||||
"""Failures, stacks checked, and the component ids covered."""
|
||||
results = {r['label']: r for r in rust}
|
||||
failures, covered = [], set()
|
||||
for v in vanilla:
|
||||
if 'error' in v:
|
||||
failures.append(f"oracle {v['label']}: {v['error']}")
|
||||
continue
|
||||
covered.update(v['added'] + v['removed'])
|
||||
r = results.get(v['label'])
|
||||
if r is None or 'error' in r:
|
||||
failures.append(f"{v['label']}: {r and r['error']}")
|
||||
elif r['left'] or r['added'] != v['added'] or r['removed'] != v['removed']:
|
||||
failures.append(f"{v['label']}: {r['left']} bytes left, ids {r['added']}/{r['removed']}, vanilla {v['added']}/{v['removed']}")
|
||||
elif not r['same_bytes'] and not r['typed']:
|
||||
failures.append(f"{v['label']}: read past, but encoded back differently")
|
||||
missing = sorted(set(range(COMPONENT_TYPES)) - covered)
|
||||
if missing:
|
||||
failures.append(f'component ids no stack covers: {missing}')
|
||||
return failures, len(vanilla), covered
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
args = parser.parse_args()
|
||||
build = lab.LOCAL / 'item-wire-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'
|
||||
report_path.unlink(missing_ok=True)
|
||||
if args.prepare:
|
||||
lab.prepare()
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java'
|
||||
classes = build / 'classes'
|
||||
classes.mkdir(exist_ok=True)
|
||||
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
|
||||
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
|
||||
dependency_path = os.pathsep.join(map(str, libraries))
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath',
|
||||
lab.ROOT / 'mods/horizon/src/test/java', '-cp', str(jars['server']) + os.pathsep + dependency_path,
|
||||
'-d', classes, oracle], 'compile-java')
|
||||
fixtures = build / 'fixtures.tsv'
|
||||
fixtures.write_text(''.join(f'{label}\t{{id:"minecraft:{ITEMS.get(label, "stick")}",count:1,components:{snbt}}}\n'
|
||||
for label, snbt in FIXTURES), encoding='utf-8')
|
||||
vanilla_path, rust_path = build / 'vanilla.jsonl', build / 'rust.jsonl'
|
||||
run(['java', '-Xmx1G', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
|
||||
'dev.horizonlab.parity.VanillaItemWireOracle', fixtures, vanilla_path], 'oracle')
|
||||
run(['cargo', 'build', '--release', '-p', 'horizon-protocol', '--example', 'item_wire_probe'], 'compile-rust')
|
||||
binary = lab.cargo_target() / 'release/examples' / ('item_wire_probe.exe' if os.name == 'nt' else 'item_wire_probe')
|
||||
run([binary, vanilla_path, rust_path], 'rust')
|
||||
read = lambda path: [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
failures, stacks, covered = compare(read(vanilla_path), read(rust_path))
|
||||
for failure in failures[:40]:
|
||||
print(failure)
|
||||
if failures:
|
||||
raise SystemExit(f'{len(failures)} item wire mismatches (of {stacks} stacks)')
|
||||
lab.write_json(report_path, {
|
||||
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(),
|
||||
'stacks': stacks, 'component_types_covered': len(covered),
|
||||
'oracle_sha256': lab.digest(vanilla_path, 'sha256'), 'rust_sha256': lab.digest(rust_path, 'sha256')})
|
||||
print(f'Item wire parity passed: {stacks} stacks, all {len(covered)} component types: {report_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -10,7 +10,11 @@ A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`)
|
||||
vanilla generated first, with Alice and Bob as operators in creative. LocatorBarClient joins them both (players made
|
||||
of vanilla's protocol code) and records, step by step, the system chat each receives and what the server changes
|
||||
about it: game mode events, teleports, inventory slots, effects, its death (with the death message), the other
|
||||
players it is sent or loses and the levels it respawns into. Each player's lines in each step are compared exactly,
|
||||
players it is sent or loses and the levels it respawns into. A step ends by the server's clock, not the wall's: its
|
||||
console commands have run (the console's `time query gametime` comes back), each player's commands have run (a
|
||||
`sync` round trip), the server has ticked STEP_TICKS more times, and each client holds what was sent until then (a
|
||||
last `sync`), so a server that stalls under load still has each packet recorded in its own step (a fixed 2.5 s
|
||||
filed a stalled vanilla's teleports under the next step). Each player's lines in each step are compared exactly,
|
||||
in sorted order (the packets of one step may leave in another order within the tick), with the locator bar's
|
||||
waypoints as each step leaves them (one manager per level: a player in another level is untracked). What a death does besides its messages
|
||||
is left to its own harness.
|
||||
@@ -25,12 +29,12 @@ import time
|
||||
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
from .test_dedicated_server_parity import Server
|
||||
from .test_dedicated_server_parity import Server, game_time
|
||||
from .test_locator_bar_parity import generate_world, server_dir
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
from test_dedicated_server_parity import Server
|
||||
from test_dedicated_server_parity import Server, game_time
|
||||
from test_locator_bar_parity import generate_world, server_dir
|
||||
|
||||
NAME = 'server-player-targets-parity'
|
||||
@@ -70,7 +74,8 @@ SCRIPT = [
|
||||
# What the players' locator bars show has its own harness (test_locator_bar_parity), and what a death does
|
||||
# besides its messages (drops, the body leaving) the player combat ones: here only the messages of `kill`.
|
||||
DEATH_STEP = 'step=console-kill'
|
||||
STEP_SECONDS = 2.5
|
||||
STEP_TICKS = 40
|
||||
PLAYERS = ('Alice', 'Bob')
|
||||
|
||||
|
||||
def run_side(label, server_command, client_command, build, world):
|
||||
@@ -83,32 +88,49 @@ def run_side(label, server_command, client_command, build, world):
|
||||
server.wait_for(r'Done \(')
|
||||
with (build / f'{label}-client.log').open('w') as log:
|
||||
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)],
|
||||
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT,
|
||||
text=True)
|
||||
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
def tell(line):
|
||||
client.stdin.write(line + '\n')
|
||||
client.stdin.flush()
|
||||
|
||||
def sync():
|
||||
for name in PLAYERS:
|
||||
tell(f'sync {name}')
|
||||
for name in PLAYERS:
|
||||
while (line := client.stdout.readline()) != f'synced {name}\n':
|
||||
if not line:
|
||||
raise AssertionError(f'{label}: the client exited (see {build}/{label}-client.log)')
|
||||
log.write(line)
|
||||
|
||||
def finish_step():
|
||||
sync()
|
||||
until = game_time(server) + STEP_TICKS
|
||||
while game_time(server) < until:
|
||||
time.sleep(0.2)
|
||||
for name in PLAYERS:
|
||||
tell(f'still {name}')
|
||||
sync()
|
||||
try:
|
||||
for name, x in (('Alice', 0.5), ('Bob', 4.5)):
|
||||
for name, x in zip(PLAYERS, (0.5, 4.5)):
|
||||
tell(f'join {name}')
|
||||
server.wait_for(rf'{name} joined the game', timeout=120)
|
||||
tell(f'fly {name} on')
|
||||
tell(f'cmd {name} tp @s {x} -60 0.5 0 0')
|
||||
time.sleep(STEP_SECONDS)
|
||||
finish_step()
|
||||
for flag in ('chat', 'state', 'players'):
|
||||
tell(f'{flag} on')
|
||||
for step, console, actions in SCRIPT:
|
||||
tell(f'step {step}')
|
||||
for command in console:
|
||||
server.console(command)
|
||||
game_time(server) # the console runs its queue in order: these commands have run
|
||||
for action in actions:
|
||||
tell(action)
|
||||
time.sleep(STEP_SECONDS)
|
||||
for name in ('Alice', 'Bob'):
|
||||
tell(f'still {name}')
|
||||
finish_step()
|
||||
tell('end')
|
||||
client.wait(timeout=60)
|
||||
log.write(client.communicate(timeout=60)[0])
|
||||
finally:
|
||||
if client.poll() is None:
|
||||
client.kill()
|
||||
|
||||
Reference in new issue
Block a user