mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3d4a069c | ||
|
|
a839062259 | ||
|
|
6194463454 | ||
|
|
a5f574b77e | ||
|
|
c4573fb561 | ||
|
|
57fc1570b2 | ||
|
|
6926c76492 | ||
|
|
d2c96af142 | ||
|
|
d33f84ef73 | ||
|
|
ad63f2e960 | ||
|
|
7d0ae56589 | ||
|
|
e3de4a4604 | ||
|
|
9ae86086dc | ||
|
|
fbfec6435f | ||
|
|
7a3578b7d6 | ||
|
|
21ff80eb8c | ||
|
|
f3213aa158 | ||
|
|
71f3c23dd9 | ||
|
|
574d54e6e0 | ||
|
|
c74490a003 | ||
|
|
a61c7faa23 | ||
|
|
c0bf5cf954 | ||
|
|
fb1f421117 | ||
|
|
0c5d229ad0 | ||
|
|
327a6cafc7 | ||
|
|
6a32ee68d4 | ||
|
|
0e73edceff | ||
|
|
f7bd3e381b | ||
|
|
ea0905e8a2 | ||
|
|
b149a0ec3f | ||
|
|
81b1be055c | ||
|
|
ebf76f4c03 | ||
|
|
fa093994ae | ||
|
|
273c456adc | ||
|
|
d30be3d994 | ||
|
|
c1e90297e2 | ||
|
|
9dfdff9d62 | ||
|
|
e56acaa7a9 | ||
|
|
65425da01d | ||
|
|
6b88291009 | ||
|
|
e29a71e5e0 | ||
|
|
80eafe84e9 | ||
|
|
6017830623 | ||
|
|
fb34815ce1 | ||
|
|
f49e3a2d24 | ||
|
|
cb0865e242 | ||
|
|
a27322f250 | ||
|
|
ed3243ca62 | ||
|
|
f59bb8b8d5 | ||
|
|
00705d8431 | ||
|
|
0564ab0bcd | ||
|
|
eb1fdf5fb4 | ||
|
|
2ee008f8fa | ||
|
|
8a0303ed62 | ||
|
|
b15e46c3af | ||
|
|
e988b9b833 | ||
|
|
504cb4f74c | ||
|
|
659bfad86c | ||
|
|
76408b734e | ||
|
|
4e3e038c80 | ||
|
|
b0918f3be1 | ||
|
|
08491bcda3 | ||
|
|
b165506d6e | ||
|
|
09e95ae5a1 | ||
|
|
5a0e650e63 | ||
|
|
3b586ff903 | ||
|
|
6acf7fb6ab | ||
|
|
afb3ae79a2 | ||
|
|
2c80009c70 | ||
|
|
41f8cfa3bc | ||
|
|
5d19fcaa06 | ||
|
|
489b5a4ffa | ||
|
|
342cb6c500 | ||
|
|
eed98be9cf | ||
|
|
859236bd9d | ||
|
|
07519df062 | ||
|
|
b1eae8a5b8 | ||
|
|
cc65eeec82 | ||
|
|
792ac58f46 | ||
|
|
578f077347 | ||
|
|
b6ec56ac9b | ||
|
|
e334dd3d55 | ||
|
|
8948bf90c4 | ||
|
|
753a99b933 | ||
|
|
dc1e16f472 | ||
|
|
c36e81916b | ||
|
|
6e86e07781 | ||
|
|
961af30997 | ||
|
|
a96dccf691 | ||
|
|
6d1f6965d2 | ||
|
|
f66c257ae5 | ||
|
|
9f1a880951 | ||
|
|
7a7bfdde79 | ||
|
|
96b6ed4916 | ||
|
|
e55b5b7c00 | ||
|
|
a93c1507f9 | ||
|
|
a26297058a | ||
|
|
149864c3b4 | ||
|
|
42afd721e7 | ||
|
|
75c009e3fc | ||
|
|
ba09b086a6 | ||
|
|
f07d66c7cf | ||
|
|
6d735c26d3 | ||
|
|
3dc14b6eda | ||
|
|
3e3ab7a413 | ||
|
|
fab71df185 | ||
|
|
c41a053c15 | ||
|
|
0d1736da8f | ||
|
|
977ef7181f | ||
|
|
282ecf16a9 | ||
|
|
7b56b59e7c | ||
|
|
7d76923a04 | ||
|
|
59281c5c73 | ||
|
|
2716ad41be | ||
|
|
be34f4cd5f | ||
|
|
4ad05aa94a | ||
|
|
8c611e677b | ||
|
|
3f1a7b28af | ||
|
|
891e1c1283 | ||
|
|
a5f5d8df34 | ||
|
|
90b5e162ae | ||
|
|
d742d725f4 | ||
|
|
7c1dd49810 | ||
|
|
352645b417 | ||
|
|
9223b401e7 | ||
|
|
db758a622f | ||
|
|
7a1638a190 |
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
|
||||
@@ -0,0 +1,34 @@
|
||||
name: mod-registry
|
||||
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
|
||||
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-mod-registry
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
}
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
Weighted {
|
||||
variants: Vec<Variant>,
|
||||
variants: Vec<T>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice {
|
||||
impl Choice<Variant> {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,7 +41,23 @@ impl Choice {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -60,10 +76,11 @@ impl Choice {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -82,10 +99,19 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -95,14 +121,23 @@ impl CompiledChoices {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a Variant>,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -110,7 +145,7 @@ impl CompiledChoices {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
output.push(choice.select(random));
|
||||
f(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -119,7 +154,7 @@ impl CompiledChoices {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
output.push(self.multipart[*index].select(random));
|
||||
f(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! What a column costs to load, on real terrain: generates and lights the
|
||||
//! columns around a point with the vanilla generator, as a world being explored
|
||||
//! does, then meshes every section of the inner columns on this one thread, so a
|
||||
//! change to generation or meshing shows as time per column and per section.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 8);
|
||||
let rounds: usize = flag(&args, "--rounds", 3);
|
||||
let threads: usize = flag(
|
||||
&args,
|
||||
"--threads",
|
||||
thread::available_parallelism().map_or(2, |n| n.get()),
|
||||
);
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 100 {
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
let generated = started.elapsed() - Duration::from_millis(2 * 100);
|
||||
let columns = world.chunks.len();
|
||||
println!(
|
||||
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
|
||||
generated.as_secs_f64() * 1e3,
|
||||
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
|
||||
);
|
||||
|
||||
// Meshing every section of the columns whose neighbours are all loaded.
|
||||
let inner = radius - 1;
|
||||
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
|
||||
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
|
||||
.filter_map(|(x, z)| {
|
||||
let region: Region = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
region
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(Option::is_some)
|
||||
.then_some(region)
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"take" => count = book.remove_recipes(&index, &list()).len(),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
if let Some(angles) = input["angles"].as_array() {
|
||||
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Audio {
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -595,6 +598,7 @@ impl Audio {
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -825,6 +829,14 @@ impl Audio {
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
let selection = format!(
|
||||
"{} {}",
|
||||
source
|
||||
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
|
||||
.map_or("*", |s| s),
|
||||
event.as_deref().unwrap_or("*")
|
||||
);
|
||||
*self.stopped.entry(selection).or_default() += 1;
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
|
||||
//! showing, in the order their add packets arrived.
|
||||
use crate::{
|
||||
hud_overlays::BossBar,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{BossAction, BossEvent};
|
||||
|
||||
/// `LerpingBossEvent.LERP_MILLISECONDS`.
|
||||
const LERP_MS: u128 = 100;
|
||||
|
||||
/// `Util.getMillis`.
|
||||
pub fn now_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One `LerpingBossEvent`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Event {
|
||||
id: u128,
|
||||
name: Text,
|
||||
/// The progress the lerp started from (`progress`).
|
||||
progress: f32,
|
||||
/// `targetPercent`.
|
||||
target: f32,
|
||||
/// `setTime`.
|
||||
set_at: u128,
|
||||
color: u8,
|
||||
overlay: u8,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
}
|
||||
impl Event {
|
||||
/// `LerpingBossEvent.getProgress`.
|
||||
fn displayed(&self, now: u128) -> f32 {
|
||||
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
|
||||
self.progress + (self.target - self.progress) * t
|
||||
}
|
||||
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
|
||||
/// whatever it is showing now.
|
||||
fn set_progress(&mut self, progress: f32, now: u128) {
|
||||
self.progress = self.displayed(now);
|
||||
self.target = progress;
|
||||
self.set_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BossOverlay {
|
||||
events: Vec<Event>,
|
||||
/// `bossOverlayWorldDarkening` and the previous tick's value.
|
||||
darken: f32,
|
||||
darken_o: f32,
|
||||
}
|
||||
impl BossOverlay {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
|
||||
/// handler. An id already shown keeps its place when added again.
|
||||
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
|
||||
// An ordinal past the enum shows as its last value.
|
||||
let color = |c: i32| c.clamp(0, 6) as u8;
|
||||
let overlay = |o: i32| o.clamp(0, 4) as u8;
|
||||
let id = packet.id;
|
||||
if let BossAction::Add {
|
||||
name,
|
||||
progress,
|
||||
color: c,
|
||||
overlay: o,
|
||||
darken,
|
||||
music,
|
||||
fog,
|
||||
} = &packet.action
|
||||
{
|
||||
let event = Event {
|
||||
id,
|
||||
name: comp_text(&tag_to_json(name)),
|
||||
progress: *progress,
|
||||
target: *progress,
|
||||
set_at: now,
|
||||
color: color(*c),
|
||||
overlay: overlay(*o),
|
||||
darken: *darken,
|
||||
music: *music,
|
||||
fog: *fog,
|
||||
};
|
||||
match self.events.iter_mut().find(|e| e.id == id) {
|
||||
Some(slot) => *slot = event,
|
||||
None => self.events.push(event),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let BossAction::Remove = packet.action {
|
||||
self.events.retain(|e| e.id != id);
|
||||
return;
|
||||
}
|
||||
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
|
||||
return;
|
||||
};
|
||||
match &packet.action {
|
||||
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
|
||||
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
|
||||
BossAction::UpdateStyle {
|
||||
color: c,
|
||||
overlay: o,
|
||||
} => {
|
||||
e.color = color(*c);
|
||||
e.overlay = overlay(*o);
|
||||
}
|
||||
BossAction::UpdateProperties { darken, music, fog } => {
|
||||
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
|
||||
}
|
||||
BossAction::Add { .. } | BossAction::Remove => {}
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.tick` while the level runs.
|
||||
pub fn tick_darken(&mut self, running: bool) {
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
self.darken_o = self.darken;
|
||||
if self.should_darken() {
|
||||
self.darken = (self.darken + 0.05).min(1.0);
|
||||
} else if self.darken > 0.0 {
|
||||
self.darken = (self.darken - 0.0125).max(0.0);
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`.
|
||||
pub fn darken_amount(&self, partial: f32) -> f32 {
|
||||
let t = partial.clamp(0.0, 1.0);
|
||||
self.darken_o + (self.darken - self.darken_o) * t
|
||||
}
|
||||
pub fn should_darken(&self) -> bool {
|
||||
self.events.iter().any(|e| e.darken)
|
||||
}
|
||||
pub fn should_play_music(&self) -> bool {
|
||||
self.events.iter().any(|e| e.music)
|
||||
}
|
||||
pub fn should_create_world_fog(&self) -> bool {
|
||||
self.events.iter().any(|e| e.fog)
|
||||
}
|
||||
/// The bars `extractRenderState` walks, with the lerped progress.
|
||||
pub fn bars(&self, now: u128) -> Vec<BossBar> {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|e| BossBar {
|
||||
name: e.name.clone(),
|
||||
progress: e.displayed(now),
|
||||
color: e.color as usize,
|
||||
overlay: e.overlay as usize,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::Add {
|
||||
name: Tag::String(name.into()),
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken,
|
||||
music: false,
|
||||
fog,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn progress(id: u128, progress: f32) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::UpdateProgress { progress },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_lerps_over_a_tenth_of_a_second() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 0.0, true, false), 0);
|
||||
overlay.apply(&progress(1, 1.0), 0);
|
||||
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
|
||||
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
|
||||
// A new target starts from the value on screen.
|
||||
overlay.apply(&progress(1, 0.0), 50);
|
||||
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darken_ramps_and_fades() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 1.0, true, false), 0);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 1,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
|
||||
assert!(!overlay.should_play_music());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_keep_arrival_order() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(2, "b", 1.0, false, true), 0);
|
||||
overlay.apply(&add(1, "a", 1.0, false, false), 0);
|
||||
overlay.apply(&add(2, "c", 0.5, false, true), 0);
|
||||
// An update for a bar this client never got is ignored.
|
||||
overlay.apply(&progress(9, 0.5), 0);
|
||||
let names: Vec<_> = overlay
|
||||
.bars(0)
|
||||
.into_iter()
|
||||
.map(|b| b.name.plain())
|
||||
.collect();
|
||||
assert_eq!(names, ["c", "a"]);
|
||||
assert!(overlay.should_create_world_fog());
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 2,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert!(!overlay.should_create_world_fog());
|
||||
}
|
||||
}
|
||||
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
|
||||
"notched_12",
|
||||
"notched_20",
|
||||
];
|
||||
/// `BossBarColor.getFormatting`.
|
||||
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
|
||||
pub fn color_rgb(color: &str) -> u32 {
|
||||
let name = match color {
|
||||
"pink" => "red",
|
||||
"red" => "dark_red",
|
||||
"purple" => "dark_purple",
|
||||
"purple" => "dark_blue",
|
||||
other => other,
|
||||
};
|
||||
text::named_color(name).unwrap_or(text::WHITE)
|
||||
}
|
||||
|
||||
/// A version-4 shaped id from a resource id. Saved boss events do not keep
|
||||
/// their UUID (`CustomBossEvents` mints a new one on load).
|
||||
pub fn stable_uuid(id: &str) -> u128 {
|
||||
let mut h = 0xcbf29ce484222325u64;
|
||||
for b in id.as_bytes() {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
let most = (h as i64) & -61441 | 16384;
|
||||
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BossBar {
|
||||
pub id: String,
|
||||
/// The `ServerBossEvent` UUID. Not saved.
|
||||
pub uuid: u128,
|
||||
pub name: Comp,
|
||||
pub color: String,
|
||||
pub style: String,
|
||||
pub value: i32,
|
||||
pub max: i32,
|
||||
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
|
||||
/// a player is added.
|
||||
pub visible: bool,
|
||||
/// Whether the (single) local player is in the bar's player set.
|
||||
pub player: bool,
|
||||
pub darken: bool,
|
||||
pub music: bool,
|
||||
pub fog: bool,
|
||||
/// `CustomBossEvent.players`, the UUIDs told about the bar.
|
||||
pub players: Vec<[i32; 4]>,
|
||||
}
|
||||
impl BossBar {
|
||||
pub fn new(id: &str, name: Comp) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
uuid: stable_uuid(id),
|
||||
name,
|
||||
color: "white".into(),
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: true,
|
||||
player: false,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// `CustomBossEvent.getDisplayName`.
|
||||
@@ -91,31 +115,34 @@ impl BossBars {
|
||||
pub fn ids(&self) -> Vec<String> {
|
||||
self.bars.iter().map(|b| b.id.clone()).collect()
|
||||
}
|
||||
/// The saved compound; the player's UUID stands for "the local player".
|
||||
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
|
||||
/// The saved compound (`CustomBossEvent.Packed`).
|
||||
pub fn pack(&self) -> Tag {
|
||||
let mut c = Compound::new();
|
||||
for b in &self.bars {
|
||||
let mut e = vec![
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(0)),
|
||||
("PlayBossMusic", Tag::Byte(0)),
|
||||
("CreateWorldFog", Tag::Byte(0)),
|
||||
];
|
||||
let players = match (b.player, player) {
|
||||
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
e.push(("Players", Tag::List(players)));
|
||||
c.insert(b.id.as_str().into(), Tag::compound(e));
|
||||
let players = b
|
||||
.players
|
||||
.iter()
|
||||
.map(|u| Tag::IntArray(u.to_vec()))
|
||||
.collect();
|
||||
c.insert(
|
||||
b.id.as_str().into(),
|
||||
Tag::compound([
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
|
||||
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
|
||||
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
|
||||
("Players", Tag::List(players)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Tag::Compound(c)
|
||||
}
|
||||
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
|
||||
pub fn unpack(data: &Tag) -> Self {
|
||||
let mut bars = Vec::new();
|
||||
if let Tag::Compound(c) = data {
|
||||
for (id, e) in c {
|
||||
@@ -130,21 +157,30 @@ impl BossBars {
|
||||
Some(Tag::Byte(v)) => i32::from(*v),
|
||||
_ => d,
|
||||
};
|
||||
let in_players = match (get("Players"), player) {
|
||||
(Some(Tag::List(l)), Some(u)) => l
|
||||
let players = match get("Players") {
|
||||
Some(Tag::List(l)) => l
|
||||
.iter()
|
||||
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
|
||||
_ => false,
|
||||
.filter_map(|t| match t {
|
||||
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let id = id.to_string_lossy();
|
||||
bars.push(BossBar {
|
||||
id: id.to_string_lossy(),
|
||||
uuid: stable_uuid(&id),
|
||||
id,
|
||||
name: get("Name").map_or_else(|| literal(""), tag_to_json),
|
||||
color: text("Color", "white"),
|
||||
style: text("Overlay", "progress"),
|
||||
value: num("Value", 0),
|
||||
max: num("Max", 100),
|
||||
visible: num("Visible", 0) != 0,
|
||||
player: in_players,
|
||||
darken: num("DarkenScreen", 0) != 0,
|
||||
music: num("PlayBossMusic", 0) != 0,
|
||||
fog: num("CreateWorldFog", 0) != 0,
|
||||
players,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -167,17 +203,15 @@ mod tests {
|
||||
bar.value = 25;
|
||||
bar.max = 50;
|
||||
bar.visible = true;
|
||||
bar.player = true;
|
||||
let uuid = Some([1, 2, 3, 4]);
|
||||
let back = BossBars::unpack(&b.pack(uuid), uuid);
|
||||
bar.darken = true;
|
||||
bar.players = vec![[1, 2, 3, 4]];
|
||||
let back = BossBars::unpack(&b.pack());
|
||||
assert_eq!(back, b);
|
||||
// Another player's UUID does not include the local player.
|
||||
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
|
||||
let bar = b.get("test:x").unwrap();
|
||||
assert_eq!(bar.progress(), 0.5);
|
||||
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
|
||||
assert_eq!(color_rgb("pink"), text::RED);
|
||||
assert_eq!(color_rgb("purple"), 0xAA00AA);
|
||||
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
|
||||
b.remove("test:x");
|
||||
assert!(b.bars.is_empty());
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ impl CommandStorage {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().into(), v.clone()))
|
||||
.collect();
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&file(root, namespace),
|
||||
Tag::compound([
|
||||
(
|
||||
@@ -64,7 +64,9 @@ impl CommandStorage {
|
||||
),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("command storage {namespace} was not written: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `get`: an empty compound when unset.
|
||||
|
||||
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
|
||||
pub blindness: Option<(i32, bool)>,
|
||||
/// The darkness effect's blend factor, while the effect is on.
|
||||
pub darkness: Option<f32>,
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
|
||||
pub darken_world: f32,
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let brightness = (1.0 - darkness) * (1.0 - darkness);
|
||||
color = color.map(|c| c * brightness);
|
||||
}
|
||||
// FogRenderer.computeFogColor: a boss bar's world darkening.
|
||||
if input.darken_world > 0.0 {
|
||||
let t = input.darken_world;
|
||||
color[0] += (color[0] * 0.7 - color[0]) * t;
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
@@ -394,6 +403,7 @@ mod tests {
|
||||
skybox: true,
|
||||
blindness: None,
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +432,25 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
fog_color: [1.0, 1.0, 1.0],
|
||||
sky_color: [1.0, 1.0, 1.0],
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.skybox = false;
|
||||
input.darken_world = 1.0;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
|
||||
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
|
||||
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
|
||||
}
|
||||
#[test]
|
||||
fn void_darkness_starts_32_above_the_floor() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
|
||||
@@ -44,6 +44,7 @@ mod enchant_effects;
|
||||
mod end;
|
||||
mod entity_culling;
|
||||
mod events;
|
||||
mod execution_role;
|
||||
mod explosion;
|
||||
mod extras;
|
||||
mod falling_damage;
|
||||
@@ -154,6 +155,7 @@ mod storage;
|
||||
mod structures;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod text_displays;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -290,8 +292,8 @@ struct Options {
|
||||
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
|
||||
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
/// What this Game executes; independent of its current server connection.
|
||||
execution_role: execution_role::ExecutionRole,
|
||||
}
|
||||
impl Options {
|
||||
/// The name to join servers with: the one given, else the offline name kept
|
||||
@@ -348,7 +350,7 @@ impl Options {
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -362,6 +364,7 @@ impl Options {
|
||||
return Ok(None);
|
||||
}
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
o.dedicated = Some(args.by_ref().collect());
|
||||
break;
|
||||
}
|
||||
@@ -1422,6 +1425,8 @@ struct Game<'a> {
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
text_display_vertices: usize,
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
@@ -1566,6 +1571,8 @@ struct Game<'a> {
|
||||
score: scoreboard::ScoreState,
|
||||
fns: functions::FnState,
|
||||
bosses: bossbars::BossState,
|
||||
/// `Hud.bossOverlay`: the bars this window is showing.
|
||||
boss_overlay: horizon_client::boss_overlay::BossOverlay,
|
||||
exec: commands::execute::ExecState,
|
||||
rand_sequences: horizon_client::random_sequences::RandomSequences,
|
||||
border: horizon_client::worldborder::WorldBorder,
|
||||
@@ -4046,6 +4053,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();
|
||||
@@ -4064,6 +4085,8 @@ impl Game<'_> {
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
// GameRenderer.tick: a wither's bar darkens the world, then fades.
|
||||
self.boss_overlay.tick_darken(running);
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
if self.ui.rules.bool("advance_weather") {
|
||||
@@ -4260,23 +4283,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),
|
||||
@@ -4439,6 +4446,8 @@ impl Game<'_> {
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
}
|
||||
// ServerBossEvent broadcasts, after this level's entities have ticked.
|
||||
self.publish_boss_bars();
|
||||
// Minecraft.handleKeybinds: held use repeats only while no item is in
|
||||
// use (a raised shield or a drawn bow places and uses nothing).
|
||||
if self.right
|
||||
@@ -4819,11 +4828,15 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
|
||||
let _ = atlas_ptr;
|
||||
// The ToastManager draws over every screen: a server's packs
|
||||
// download while the connection screen shows.
|
||||
if self.remote_overlay(&mut batch) {
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if let Some(reason) = self.loading_reason() {
|
||||
self.draw_level_loading(&mut batch, reason);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
|
||||
@@ -5035,6 +5048,7 @@ impl Game<'_> {
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
@@ -6772,6 +6786,7 @@ impl Game<'_> {
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
@@ -7502,7 +7517,7 @@ impl Game<'_> {
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
(out, stars)
|
||||
}
|
||||
/// Load (or create) a world folder under saves/ and start streaming it.
|
||||
/// Enter a local world from the client, selecting its simulation mode.
|
||||
fn open_world(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -7521,20 +7536,16 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
let save_root = std::env::current_dir()?.join("saves").join(folder);
|
||||
if integrated::wanted(&self.options, &save_root) {
|
||||
if self
|
||||
.options
|
||||
.execution_role
|
||||
.starts_integrated_server(self.options.server_thread)
|
||||
{
|
||||
return self.open_integrated(folder, name, seed, creative, create);
|
||||
}
|
||||
self.perf.world_opening();
|
||||
let request = world_loading::WorldRequest {
|
||||
save_root,
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
};
|
||||
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
|
||||
if self.window.is_some() {
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
@@ -7550,6 +7561,14 @@ impl Game<'_> {
|
||||
)?);
|
||||
return Ok(());
|
||||
}
|
||||
self.load_world(request)
|
||||
}
|
||||
|
||||
/// Load authoritative world state synchronously, without client entry or
|
||||
/// integrated-server selection. Server startup must use this entry point.
|
||||
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
|
||||
self.finish_open_world(prepared)
|
||||
}
|
||||
@@ -7963,6 +7982,8 @@ impl Game<'_> {
|
||||
self.session_lock = None;
|
||||
horizon_client::crash::clear_detail("World");
|
||||
self.debug_charts.reset();
|
||||
self.boss_overlay.reset();
|
||||
self.bosses = Default::default();
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
@@ -10096,7 +10117,7 @@ impl Game<'_> {
|
||||
if !self.settings.horizon.fog
|
||||
&& !underwater
|
||||
&& self.darkness_fog().is_none()
|
||||
&& !self.dragon_bar_shown()
|
||||
&& !self.boss_overlay.should_create_world_fog()
|
||||
&& self.potions.effects.get("blindness").is_none()
|
||||
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
|
||||
{
|
||||
@@ -10167,6 +10188,9 @@ impl Game<'_> {
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(),
|
||||
block_flicker: self.fog.block_light_flicker,
|
||||
boss_darken: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
menu_blur: self.menu_blur(),
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
@@ -10557,7 +10581,7 @@ impl Game<'_> {
|
||||
}
|
||||
// The loading screen's chunk map fills ring by ring, as vanilla's.
|
||||
self.streaming.loading =
|
||||
self.options.dedicated.is_none() && self.loading_reason().is_some();
|
||||
self.options.execution_role.is_client() && self.loading_reason().is_some();
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
if let Some(lod) = &mut self.lod {
|
||||
@@ -11884,6 +11908,8 @@ impl<'a> Game<'a> {
|
||||
breath_clouds: Vec::new(),
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
text_displays: Default::default(),
|
||||
text_display_vertices: 0,
|
||||
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
@@ -11971,6 +11997,7 @@ impl<'a> Game<'a> {
|
||||
score: Default::default(),
|
||||
fns: Default::default(),
|
||||
bosses: Default::default(),
|
||||
boss_overlay: Default::default(),
|
||||
exec: Default::default(),
|
||||
rand_sequences: Default::default(),
|
||||
border: Default::default(),
|
||||
@@ -12244,7 +12271,7 @@ fn synchronous_startup(options: &Options) -> bool {
|
||||
|| options.world_frames.is_some()
|
||||
|| options.benchmark.is_some()
|
||||
|| options.control.is_some()
|
||||
|| options.dedicated.is_some()
|
||||
|| !options.execution_role.is_client()
|
||||
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
@@ -12698,6 +12725,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -12785,7 +12813,7 @@ fn start_game<'a>(
|
||||
game.sculk_fixture(&spec)?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.dedicated.is_some() {
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
game.run_dedicated()?;
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -13062,7 +13090,7 @@ impl Loading {
|
||||
);
|
||||
let renderer_ms = renderer_at.elapsed().as_millis();
|
||||
// Audio is created on the main thread, after the core is ready.
|
||||
let audio = if options.dedicated.is_some() {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
@@ -13237,13 +13265,15 @@ pub fn run() -> Result<()> {
|
||||
let Some(mut options) = Options::read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
|
||||
if options.execution_role == execution_role::ExecutionRole::DedicatedServer
|
||||
&& !dedicated::preflight(&mut options)?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.dedicated.is_none() {
|
||||
if options.execution_role.is_client() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
@@ -13253,7 +13283,7 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
|
||||
// server, a benchmark or a run that quits by itself.
|
||||
if options.dedicated.is_none()
|
||||
if options.execution_role.is_client()
|
||||
&& options.benchmark.is_none()
|
||||
&& !options.offscreen
|
||||
&& options.frames.is_none()
|
||||
@@ -13294,7 +13324,7 @@ pub fn run() -> Result<()> {
|
||||
)?;
|
||||
let gui = loaded.gui.ok_or("missing menu bundle")?;
|
||||
options.resources = loaded.root.clone();
|
||||
let audio = if options.dedicated.is_some() {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
|
||||
use super::{
|
||||
Game,
|
||||
commands::{R, err},
|
||||
Dim, Game,
|
||||
commands::{R, Target, err},
|
||||
};
|
||||
use horizon_client::{
|
||||
boss_overlay,
|
||||
bossbar::{self, BossBars},
|
||||
command::{Arg, Context},
|
||||
hud_overlays, save,
|
||||
scoreboard::{self as sb, Comp, comp_text},
|
||||
mobs::Species,
|
||||
save,
|
||||
scoreboard::{self as sb, Comp, json_to_tag},
|
||||
scoreboard_cmd::format_list,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BossState {
|
||||
pub bars: BossBars,
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
@@ -36,7 +43,7 @@ impl Game<'_> {
|
||||
if let Some(root) = save::read_dat(&self.bossbar_path())
|
||||
&& let Some(data) = root.get("data")
|
||||
{
|
||||
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
|
||||
self.bosses.bars = BossBars::unpack(data);
|
||||
}
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
}
|
||||
@@ -47,13 +54,15 @@ impl Game<'_> {
|
||||
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
|
||||
return;
|
||||
}
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&self.bossbar_path(),
|
||||
Tag::compound([
|
||||
("data", self.bosses.bars.pack(self.level.player_uuid)),
|
||||
("data", self.bosses.bars.pack()),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("boss bars were not written: {e}");
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
|
||||
self.bosses.bars.get(id).is_some()
|
||||
@@ -68,6 +77,7 @@ impl Game<'_> {
|
||||
b.value = b.value.min(b.max);
|
||||
}
|
||||
}
|
||||
self.publish_boss_bars();
|
||||
}
|
||||
fn bar_id(ctx: &Context) -> String {
|
||||
match ctx.get("id") {
|
||||
@@ -86,6 +96,7 @@ impl Game<'_> {
|
||||
self.load_bossbars();
|
||||
let result = self.bossbar_command(id, ctx);
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
self.publish_boss_bars();
|
||||
result
|
||||
}
|
||||
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
@@ -97,6 +108,10 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.component_json(ctx, "name")?;
|
||||
self.bosses.bars.add(&bar, name);
|
||||
if let Some(b) = self.bosses.bars.get_mut(&bar) {
|
||||
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
|
||||
b.uuid = insecure_uuid(&mut self.world.random);
|
||||
}
|
||||
let shown = self
|
||||
.bosses
|
||||
.bars
|
||||
@@ -173,19 +188,20 @@ impl Game<'_> {
|
||||
i32::from(b.visible)
|
||||
}
|
||||
_ => {
|
||||
if b.player {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
let names = self.bossbar_player_names(&b.players);
|
||||
let n = names.len();
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(n), format_list(names)],
|
||||
));
|
||||
}
|
||||
i32::from(b.player)
|
||||
n as i32
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -295,31 +311,46 @@ impl Game<'_> {
|
||||
Ok(0)
|
||||
}
|
||||
_ => {
|
||||
// players [<targets>]
|
||||
let include = if ctx.get("targets").is_some() {
|
||||
!self.entities(ctx, "targets", true)?.is_empty()
|
||||
// players [<targets>]: replace the set. No argument clears it.
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", true)?
|
||||
} else {
|
||||
false
|
||||
Vec::new()
|
||||
};
|
||||
let mut uuids = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
for t in &targets {
|
||||
let Some(uuid) = self.player_target_uuid(*t) else {
|
||||
continue;
|
||||
};
|
||||
if !uuids.contains(&uuid) {
|
||||
uuids.push(uuid);
|
||||
names.push(self.target_name(*t));
|
||||
}
|
||||
}
|
||||
let b = self.bosses.bars.get_mut(bar).expect("checked");
|
||||
if b.player == include {
|
||||
let mut had = b.players.clone();
|
||||
had.sort();
|
||||
let mut next = uuids.clone();
|
||||
next.sort();
|
||||
if had == next {
|
||||
return Err(err("commands.bossbar.set.players.unchanged", &[]));
|
||||
}
|
||||
b.player = include;
|
||||
b.players = uuids;
|
||||
let n = b.players.len();
|
||||
let s = shown(self);
|
||||
if include {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(targets.len()), format_list(names)],
|
||||
));
|
||||
}
|
||||
Ok(i32::from(include))
|
||||
Ok(n as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,29 +364,316 @@ impl Game<'_> {
|
||||
Ok(self.resolve_component(value))
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------------
|
||||
|
||||
/// The visible custom bars (`/bossbar`), after the built-in ones.
|
||||
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for b in self
|
||||
.bosses
|
||||
.bars
|
||||
.bars
|
||||
.iter()
|
||||
.filter(|b| b.visible && b.player)
|
||||
{
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: comp_text(&b.name),
|
||||
progress: b.progress(),
|
||||
color: bossbar::COLORS
|
||||
/// Names of the online players in a boss bar's set, in join order.
|
||||
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
|
||||
self.known_boss_players()
|
||||
.into_iter()
|
||||
.filter(|(uuid, _)| players.contains(uuid))
|
||||
.map(|(_, name)| name)
|
||||
.collect()
|
||||
}
|
||||
/// The players who can be told about a bar: the one in place, then guests.
|
||||
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
|
||||
let mut out = Vec::new();
|
||||
if self.player.is_some() {
|
||||
out.push((self.acting_uuid(), self.target_name(Target::Player)));
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
|
||||
if out.iter().any(|(uuid, _)| *uuid == ints) {
|
||||
continue;
|
||||
}
|
||||
out.push((ints, Text::literal(g.name.clone())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
|
||||
match target {
|
||||
Target::Player => Some(self.acting_uuid()),
|
||||
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.position(|c| *c == b.color)
|
||||
.unwrap_or(0),
|
||||
overlay: bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == b.style)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ServerBossEvent delivery ------------------------------------------------
|
||||
|
||||
/// Tell every viewer the boss events they should be seeing. The window
|
||||
/// that simulates the world applies them to its own overlay; everyone
|
||||
/// else gets `ClientboundBossEventPacket`s.
|
||||
pub(super) fn publish_boss_bars(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.load_bossbars();
|
||||
let now = boss_overlay::now_millis();
|
||||
let viewers = self.boss_viewers();
|
||||
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
|
||||
self.bosses.sent.retain(|id, _| live.contains(id));
|
||||
let mut packets = Vec::new();
|
||||
for viewer in &viewers {
|
||||
let desired = self.boss_events_for(viewer);
|
||||
for event in self.diff_boss_events(viewer, desired) {
|
||||
if viewer.local {
|
||||
self.boss_overlay.apply(&event, now);
|
||||
} else if let Some(conn) = viewer.conn {
|
||||
packets.push((conn, C::BossEvent(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if packets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for (conn, packet) in &packets {
|
||||
host.hub.send_to(*conn, packet);
|
||||
}
|
||||
}
|
||||
fn boss_viewers(&self) -> Vec<Viewer> {
|
||||
let mut out = Vec::new();
|
||||
// While `as_guest` has swapped a guest in, the host's own position
|
||||
// is not in place. Their overlay updates once the tick puts it back.
|
||||
let local =
|
||||
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
|
||||
if local && let Some(p) = &self.player {
|
||||
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
|
||||
out.push(Viewer {
|
||||
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
|
||||
ints,
|
||||
at: [p.position.x, p.position.y, p.position.z],
|
||||
alive: !p.health.dead_or_dying(),
|
||||
here: true,
|
||||
local: true,
|
||||
conn: None,
|
||||
view_chunks: self.settings.render_distance,
|
||||
});
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
if out.iter().any(|v| v.uuid == g.uuid) {
|
||||
continue;
|
||||
}
|
||||
out.push(Viewer {
|
||||
uuid: g.uuid,
|
||||
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
at: g.position,
|
||||
alive: !g.dead,
|
||||
here: g.dimension == self.dim.id(),
|
||||
local: false,
|
||||
conn: Some(g.conn),
|
||||
view_chunks: g.view_distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
|
||||
/// joins), then the dragon, withers and the raid.
|
||||
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
|
||||
let mut out = Vec::new();
|
||||
for b in &self.bosses.bars.bars {
|
||||
if !b.visible || !b.players.contains(&viewer.ints) {
|
||||
continue;
|
||||
}
|
||||
out.push(Offer {
|
||||
id: b.uuid,
|
||||
name: json_to_tag(&b.name),
|
||||
progress: b.progress(),
|
||||
color: color_index(&b.color),
|
||||
overlay: style_index(&b.style),
|
||||
darken: b.darken,
|
||||
music: b.music,
|
||||
fog: b.fog,
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid("minecraft:ender_dragon"),
|
||||
name: translate_tag("entity.minecraft.ender_dragon"),
|
||||
progress,
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
darken: false,
|
||||
music: true,
|
||||
fog: true,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
if viewer.here {
|
||||
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
|
||||
for m in self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
|
||||
{
|
||||
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
|
||||
if dx * dx + dz * dz > range * range {
|
||||
continue;
|
||||
}
|
||||
let progress = if m.extra.counter > 0 {
|
||||
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
|
||||
} else {
|
||||
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
|
||||
};
|
||||
let name = if let Some(text) = &m.custom_name_text {
|
||||
json_to_tag(&text.component())
|
||||
} else if let Some(name) = &m.custom_name {
|
||||
Tag::String(name.as_str().into())
|
||||
} else {
|
||||
translate_tag("entity.minecraft.wither")
|
||||
};
|
||||
out.push(Offer {
|
||||
id: super::lan::entity_uuid(m.id),
|
||||
name,
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken: true,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid(&format!("raid:{id}")),
|
||||
name,
|
||||
progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The packets that take `viewer` from what it was last sent to `desired`
|
||||
/// (`ServerBossEvent`'s broadcasts).
|
||||
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
|
||||
let full = viewer.local;
|
||||
let dim = self.dim;
|
||||
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
|
||||
let mut out = Vec::new();
|
||||
let event = |id, action| play::BossEvent { id, action };
|
||||
sent.retain(|id, prev| {
|
||||
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
|
||||
let keep = !owned || desired.iter().any(|e| e.id == *id);
|
||||
if !keep {
|
||||
out.push(event(*id, BossAction::Remove));
|
||||
}
|
||||
keep
|
||||
});
|
||||
for offer in desired {
|
||||
let Some(prev) = sent.get(&offer.id) else {
|
||||
out.push(event(
|
||||
offer.id,
|
||||
BossAction::Add {
|
||||
name: offer.name.clone(),
|
||||
progress: offer.progress,
|
||||
color: offer.color,
|
||||
overlay: offer.overlay,
|
||||
darken: offer.darken,
|
||||
music: offer.music,
|
||||
fog: offer.fog,
|
||||
},
|
||||
));
|
||||
sent.insert(offer.id, offer);
|
||||
continue;
|
||||
};
|
||||
let id = offer.id;
|
||||
if prev.progress != offer.progress {
|
||||
let progress = offer.progress;
|
||||
out.push(event(id, BossAction::UpdateProgress { progress }));
|
||||
}
|
||||
if prev.name != offer.name {
|
||||
let name = offer.name.clone();
|
||||
out.push(event(id, BossAction::UpdateName { name }));
|
||||
}
|
||||
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
|
||||
let (color, overlay) = (offer.color, offer.overlay);
|
||||
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
|
||||
}
|
||||
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
|
||||
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
|
||||
out.push(event(
|
||||
id,
|
||||
BossAction::UpdateProperties { darken, music, fog },
|
||||
));
|
||||
}
|
||||
sent.insert(id, offer);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ClientPacketListener.handleBossUpdate`.
|
||||
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
|
||||
self.boss_overlay.apply(packet, boss_overlay::now_millis());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.createInsecureUUID`.
|
||||
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
|
||||
let most = random.next_long() & -61441 | 16384;
|
||||
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
fn translate_tag(key: &str) -> Tag {
|
||||
Tag::compound([("translate", Tag::String(key.into()))])
|
||||
}
|
||||
fn color_index(color: &str) -> i32 {
|
||||
bossbar::COLORS
|
||||
.iter()
|
||||
.position(|c| *c == color)
|
||||
.unwrap_or(6) as i32
|
||||
}
|
||||
fn style_index(style: &str) -> i32 {
|
||||
bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == style)
|
||||
.unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Scope {
|
||||
Global,
|
||||
Level(Dim),
|
||||
}
|
||||
struct Viewer {
|
||||
uuid: u128,
|
||||
ints: [i32; 4],
|
||||
at: [f64; 3],
|
||||
alive: bool,
|
||||
here: bool,
|
||||
local: bool,
|
||||
conn: Option<u32>,
|
||||
view_chunks: i32,
|
||||
}
|
||||
/// A boss event as one viewer should see it, and as it was last sent.
|
||||
struct Offer {
|
||||
id: u128,
|
||||
name: Tag,
|
||||
progress: f32,
|
||||
/// `BossBarColor` and `BossBarOverlay` ordinals.
|
||||
color: i32,
|
||||
overlay: i32,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
scope: Scope,
|
||||
}
|
||||
@@ -570,8 +570,14 @@ impl Game<'_> {
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it.
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
if self.remote.is_some() {
|
||||
if let Some(command) = message.strip_prefix('/')
|
||||
&& self.mod_command(command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.send_chat(message);
|
||||
return;
|
||||
}
|
||||
@@ -816,8 +822,12 @@ impl Game<'_> {
|
||||
("data", data),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]);
|
||||
let _ = horizon_client::save::write_dat(&path, root);
|
||||
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
|
||||
if let Err(e) = horizon_client::save::write_dat(&path, root) {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
|
||||
log::warn!("the difficulty was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
|
||||
@@ -1082,6 +1082,8 @@ impl Game<'_> {
|
||||
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
|
||||
}
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
|
||||
@@ -65,9 +65,10 @@ impl Game<'_> {
|
||||
tag
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
|
||||
// `saveWithoutId` writes the entity as saved, riders under
|
||||
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
|
||||
let i = self.mobs.iter().position(|m| m.id == id)?;
|
||||
self.mob_tree_nbt(i)
|
||||
}
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
|
||||
@@ -59,7 +59,15 @@ impl Game<'_> {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
self.level.recipe_book.remove_recipes(index, &recipes) as i32
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
@@ -345,7 +353,26 @@ impl Game<'_> {
|
||||
Some(Arg::Resource(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
self.audio.stop_sounds(sound.as_deref(), source);
|
||||
// StopSoundCommand: each target's connection gets the packet; the
|
||||
// window's own player stops its sounds.
|
||||
let packet = C::StopSound(play::StopSound {
|
||||
source: source.map(|s| s as i32),
|
||||
sound: sound.clone(),
|
||||
});
|
||||
for &t in &targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = match (source, &sound) {
|
||||
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
|
||||
(Some(_), None) => "commands.stopsound.success.source.any",
|
||||
|
||||
@@ -226,6 +226,16 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_section_sides(true),
|
||||
Some("off") => r.set_section_sides(false),
|
||||
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
@@ -981,6 +991,7 @@ impl Game<'_> {
|
||||
status["destroying"] = json!(destroying);
|
||||
status["physical_keys"] = json!(physical_keys);
|
||||
status["heard"] = json!(self.audio.heard);
|
||||
status["stopped_sounds"] = json!(self.audio.stopped);
|
||||
// Boats, minecarts, armor stands, item frames and paintings.
|
||||
status["objects"] = json!(self.objects_status());
|
||||
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
|
||||
|
||||
@@ -978,7 +978,9 @@ impl Game<'_> {
|
||||
m.ai.target = Some(target);
|
||||
}
|
||||
}
|
||||
MobEvent::Projectile(p) => {
|
||||
MobEvent::Projectile(mut p) => {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
|
||||
@@ -185,7 +185,9 @@ impl Game<'_> {
|
||||
// Minecraft.emergencySaveAndCrash: the world is saved, then the
|
||||
// game stops; main writes the crash report.
|
||||
let crash = horizon_client::crash::DebugCrash::new();
|
||||
let _ = self.save_game();
|
||||
if let Err(e) = self.save_game() {
|
||||
log::error!("the emergency save before the crash failed: {e}");
|
||||
}
|
||||
return Err(crash.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! its simulation and the play-state hub that LAN hosting uses, configured
|
||||
//! from server.properties, with the operator, whitelist and ban lists and a
|
||||
//! console on standard input.
|
||||
use super::{Game, Options, lan::LanSettings};
|
||||
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
|
||||
@@ -218,21 +218,22 @@ impl Lists {
|
||||
use_whitelist: false,
|
||||
enforce_whitelist: false,
|
||||
};
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
if let Err(e) = r {
|
||||
log("WARN", &format!("Failed to load {what}: {e}"));
|
||||
log("WARN", &format!("Failed to {what}: {e}"));
|
||||
}
|
||||
};
|
||||
// DedicatedPlayerList's constructor order.
|
||||
warn("user banlist", l.bans.load());
|
||||
let _ = l.bans.save();
|
||||
warn("ip banlist", l.ip_bans.load());
|
||||
let _ = l.ip_bans.save();
|
||||
warn("operators list", l.ops.load());
|
||||
warn("white-list", l.white.load());
|
||||
let _ = l.ops.save();
|
||||
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
|
||||
warn("load user banlist", l.bans.load());
|
||||
warn("save user banlist", saved(l.bans.save()));
|
||||
warn("load ip banlist", l.ip_bans.load());
|
||||
warn("save ip banlist", saved(l.ip_bans.save()));
|
||||
warn("load operators list", l.ops.load());
|
||||
warn("load white-list", l.white.load());
|
||||
warn("save operators list", saved(l.ops.save()));
|
||||
if !l.white.file.exists() {
|
||||
let _ = l.white.save();
|
||||
warn("save white-list", saved(l.white.save()));
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -546,13 +547,13 @@ impl Game<'_> {
|
||||
let lists = Arc::new(Mutex::new(lists));
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
Some(props.seed),
|
||||
game_mode == 1,
|
||||
None,
|
||||
)?;
|
||||
)?)?;
|
||||
if new_world {
|
||||
self.level.hardcore = props.hardcore;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -2157,23 +2156,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
|
||||
/// raids' and the custom bars, in that order.
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
let mut bars = Vec::new();
|
||||
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(
|
||||
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
|
||||
),
|
||||
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
self.wither_bars(&mut bars);
|
||||
self.raid_bars(&mut bars);
|
||||
self.custom_bossbars(&mut bars);
|
||||
let bars = self
|
||||
.boss_overlay
|
||||
.bars(horizon_client::boss_overlay::now_millis());
|
||||
let draws = hud_overlays::boss_overlay(
|
||||
batch.width as i32,
|
||||
batch.height as i32,
|
||||
@@ -2182,14 +2170,26 @@ impl Game<'_> {
|
||||
);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
|
||||
/// with `setCreateWorldFog`).
|
||||
pub(super) fn dragon_bar_shown(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some()
|
||||
/// The dragon fight's bar for a player at `at`
|
||||
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
|
||||
/// and the player is alive within 192 blocks of (0, 128, 0)).
|
||||
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
|
||||
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
|
||||
return None;
|
||||
}
|
||||
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
|
||||
if !near {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.dragon
|
||||
.as_ref()
|
||||
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
|
||||
)
|
||||
}
|
||||
/// `music.dragon` while the boss bar plays music.
|
||||
/// `BossHealthOverlay.shouldPlayMusic`.
|
||||
pub(super) fn boss_music(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
|
||||
self.boss_overlay.should_play_music()
|
||||
}
|
||||
}
|
||||
pub(super) struct DragonFireball {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
Client,
|
||||
IntegratedServer,
|
||||
DedicatedServer,
|
||||
}
|
||||
|
||||
impl ExecutionRole {
|
||||
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
|
||||
pub fn is_client(self) -> bool {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded) in [
|
||||
(Client, true, true),
|
||||
(IntegratedServer, false, false),
|
||||
(DedicatedServer, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -26,7 +28,6 @@ use horizon_protocol::connection::Pipe;
|
||||
use horizon_protocol::{crypto::offline_uuid, text::translatable};
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
@@ -170,11 +171,6 @@ struct Launch {
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// Whether this world opens on a server thread: asked for.
|
||||
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
|
||||
options.server_thread && options.dedicated.is_none()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
|
||||
/// in the frame or on its server thread.
|
||||
@@ -215,9 +211,10 @@ impl Game<'_> {
|
||||
};
|
||||
let mut options = self.options.clone();
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.integrated = true;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
options.frames = None;
|
||||
@@ -287,13 +284,13 @@ impl Game<'_> {
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)?;
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
|
||||
@@ -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 {
|
||||
@@ -2452,6 +2449,32 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
|
||||
.to_vec();
|
||||
let facts = self.res.facts();
|
||||
// BlockItem.place: `playSound(player, ...)`, for the others only. The
|
||||
// placer's client plays it as its block appears.
|
||||
let placed: Vec<([f64; 3], String)> = changed
|
||||
.iter()
|
||||
.map(|&(x, y, z)| {
|
||||
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
|
||||
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
|
||||
(at, name.trim_start_matches("minecraft:").to_owned())
|
||||
})
|
||||
.collect();
|
||||
if let Some(capture) = self.audio.capture.as_mut() {
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
let (name, at, ..) = &capture[i];
|
||||
let place = placed
|
||||
.iter()
|
||||
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
|
||||
if place {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty()
|
||||
|| !changed.iter().all(|&(x, y, z)| {
|
||||
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
|
||||
|
||||
@@ -48,10 +48,9 @@ impl Game<'_> {
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
// startTrading with the guest's reputation. The session already
|
||||
// captured their Hero effect while they acted as the player.
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
|
||||
@@ -221,7 +221,7 @@ impl Game<'_> {
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.dedicated.is_none() {
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
if target != self.dim {
|
||||
@@ -261,7 +261,7 @@ impl Game<'_> {
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
if self.options.execution_role.is_client() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{Dim, Game};
|
||||
use horizon_client::mobs::villager as mobs_villager;
|
||||
use horizon_client::{
|
||||
effects::{EffectInstance, OmenTick},
|
||||
hud_overlays,
|
||||
mobs::{
|
||||
Mob, Species, raider,
|
||||
spawn_rules::{self, SpawnLevel},
|
||||
@@ -15,9 +14,9 @@ use horizon_client::{
|
||||
},
|
||||
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Patrol and raid state owned by the game.
|
||||
@@ -1063,37 +1062,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raid boss bars: red and notched in tens, with the raid's title.
|
||||
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for raid in self.raiding.raids.map.values() {
|
||||
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
|
||||
continue;
|
||||
}
|
||||
let title = self.translate("event.minecraft.raid", "Raid");
|
||||
let name = match raid.bar.name {
|
||||
BarName::Raid => title,
|
||||
BarName::Remaining(n) => format!(
|
||||
"{title} - {}",
|
||||
self.translate(
|
||||
"event.minecraft.raid.raiders_remaining",
|
||||
"Raiders Remaining: %s"
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
),
|
||||
BarName::Victory => {
|
||||
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
|
||||
}
|
||||
BarName::Defeat => {
|
||||
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
|
||||
}
|
||||
};
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress: raid.bar.progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
});
|
||||
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
|
||||
/// notched in tens. The name is the translate component the client draws.
|
||||
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
|
||||
if !alive || self.dim != Dim::Overworld {
|
||||
return None;
|
||||
}
|
||||
let block = block_of(Vec3::new(at[0], at[1], at[2]));
|
||||
let (id, raid) = self.raiding.raids.nearby(block)?;
|
||||
if !raid.bar.visible || raid.is_stopped() {
|
||||
return None;
|
||||
}
|
||||
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
|
||||
}
|
||||
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
|
||||
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
|
||||
@@ -1239,6 +1219,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
|
||||
/// the victory and defeat lines.
|
||||
fn raid_name_tag(name: BarName) -> Tag {
|
||||
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
|
||||
match name {
|
||||
BarName::Raid => key("event.minecraft.raid"),
|
||||
BarName::Victory => key("event.minecraft.raid.victory.full"),
|
||||
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
|
||||
BarName::Remaining(n) => Tag::compound([
|
||||
("translate", Tag::String("event.minecraft.raid".into())),
|
||||
(
|
||||
"extra",
|
||||
Tag::List(vec![
|
||||
Tag::String(" - ".into()),
|
||||
Tag::compound([
|
||||
(
|
||||
"translate",
|
||||
Tag::String("event.minecraft.raid.raiders_remaining".into()),
|
||||
),
|
||||
(
|
||||
"with",
|
||||
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -383,6 +383,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -465,6 +466,7 @@ impl Game<'_> {
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -612,6 +614,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())
|
||||
@@ -738,6 +755,9 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
if self.mods.running() {
|
||||
self.mods_chat(&text);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
@@ -834,7 +854,11 @@ impl Game<'_> {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
@@ -1496,6 +1520,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.move_remote_text_display(id, position, rotation);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
@@ -1601,6 +1626,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;
|
||||
@@ -1805,6 +1836,9 @@ impl Game<'_> {
|
||||
self.audio.play(&name, Some(at), p.volume, p.pitch);
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
.audio
|
||||
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
|
||||
C::AddEntity(p) => {
|
||||
let kind = self
|
||||
.remote
|
||||
@@ -1838,6 +1872,7 @@ impl Game<'_> {
|
||||
p.data,
|
||||
);
|
||||
}
|
||||
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
@@ -2691,6 +2726,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ClientPacketListener.setActionBarText, setTitleText,
|
||||
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
|
||||
C::BossEvent(p) => self.apply_boss_event(&p),
|
||||
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
|
||||
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
|
||||
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
|
||||
@@ -3064,6 +3100,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();
|
||||
@@ -3098,6 +3136,8 @@ impl Game<'_> {
|
||||
/// One client tick while connected (Minecraft.tick with a ClientLevel).
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -3123,6 +3163,7 @@ impl Game<'_> {
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
// the time only while the level runs normally.
|
||||
let running = self.clock.world_runs();
|
||||
self.boss_overlay.tick_darken(running);
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
self.remote_clock_tick();
|
||||
@@ -3407,6 +3448,9 @@ impl Game<'_> {
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
}
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
@@ -3969,11 +4013,17 @@ impl Game<'_> {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
|| held
|
||||
.as_ref()
|
||||
.is_some_and(|stack| self.remote_can_place(&hit, stack))
|
||||
{
|
||||
let placed = if reacts {
|
||||
None
|
||||
} else {
|
||||
held.as_ref()
|
||||
.and_then(|stack| self.remote_can_place(&hit, stack))
|
||||
};
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -4033,18 +4083,7 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
|
||||
// server sends it to the others only.
|
||||
for (pos, sound, volume, pitch) in sounds {
|
||||
@@ -4104,12 +4143,45 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level before predicting its swing.
|
||||
/// The real level supplies collision shapes and building-blocking entities;
|
||||
/// block writes, random draws, scheduled ticks and events stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
|
||||
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
|
||||
/// level at once, so the next click of a fast bridge aims at them rather
|
||||
/// than past them (the server would refuse it). Its place sound is the
|
||||
/// placer's own (`playSound(player, ...)`: the server sends it to the
|
||||
/// others only).
|
||||
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// set_block plays the place sound of the block at `placing`.
|
||||
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
}
|
||||
/// The blocks a use changed, set on this client's level and kept as
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level: where it placed and the
|
||||
/// blocks it set. The real level supplies collision shapes and
|
||||
/// building-blocking entities; random draws, scheduled ticks and events
|
||||
/// stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
|
||||
if !self.may_build() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let ctx = horizon_world::level::PlaceContext {
|
||||
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
@@ -4124,14 +4196,16 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
matches!(
|
||||
b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx
|
||||
),
|
||||
horizon_world::level::PlaceOutcome::Placed { .. }
|
||||
)
|
||||
match b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx,
|
||||
) {
|
||||
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
|
||||
Some((pos, level.changed.into_iter().collect()))
|
||||
}
|
||||
horizon_world::level::PlaceOutcome::Failed => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4233,11 +4307,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 +4327,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 +4648,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 +4720,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);
|
||||
@@ -5081,6 +5155,8 @@ impl Game<'_> {
|
||||
.collect::<Vec<_>>(),
|
||||
// The player's own model exists (third person and the inventory draw it).
|
||||
"player_model": self.player_model.is_some(),
|
||||
"text_display_vertices": self.text_display_vertices,
|
||||
"text_displays": self.text_displays.len(),
|
||||
"health": r.health.0,
|
||||
"food": r.health.1,
|
||||
"decode_errors": r.decode_errors,
|
||||
@@ -5180,11 +5256,12 @@ type DrySound = (
|
||||
f32,
|
||||
f32,
|
||||
);
|
||||
/// The blocks a use on the dry level set.
|
||||
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
|
||||
/// What a use on the dry level changed, and the sounds it played.
|
||||
type DryUse = (
|
||||
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
Vec<DrySound>,
|
||||
);
|
||||
type DryUse = (DryChanges, Vec<DrySound>);
|
||||
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
|
||||
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
|
||||
|
||||
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
|
||||
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
|
||||
@@ -5505,4 +5582,28 @@ mod tests {
|
||||
}
|
||||
assert_eq!(Game::menu_slot("inventory", 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
|
||||
for (menu, internal, index) in [
|
||||
("minecraft:generic_9x6", 18, 63),
|
||||
("minecraft:generic_9x6", 19, 64),
|
||||
("minecraft:generic_9x6", 0, 81),
|
||||
("minecraft:generic_9x6", 8, 89),
|
||||
("minecraft:generic_9x6", 353, 53),
|
||||
("minecraft:generic_9x5", 8, 80),
|
||||
("minecraft:generic_9x4", 8, 71),
|
||||
("minecraft:generic_9x3", 8, 62),
|
||||
("inventory", 504, 45),
|
||||
] {
|
||||
assert_eq!(
|
||||
Game::menu_slot_index(menu, internal),
|
||||
Some(index),
|
||||
"{menu}, game slot {internal}"
|
||||
);
|
||||
}
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
);
|
||||
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
let mut c = super::minecarts::Minecart::new(id, cart, at);
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
@@ -93,6 +98,10 @@ impl Game<'_> {
|
||||
|
||||
/// `handleSetEntityData` for an object: what changes how it looks.
|
||||
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(display) = self.text_displays.get_mut(&id) {
|
||||
display.metadata(values);
|
||||
return;
|
||||
}
|
||||
let id = id as u32;
|
||||
for (index, _, value) in values {
|
||||
if self.item_frames.iter().any(|f| f.id == id) {
|
||||
@@ -273,6 +282,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -281,6 +291,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -100,7 +100,10 @@ impl Game<'_> {
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: None,
|
||||
darkness: self.darkness_fog(),
|
||||
boss_world_fog: self.dragon_bar_shown(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,19 @@ use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
multiplayer, packs,
|
||||
java_text, multiplayer, packs,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
|
||||
text::{Text, named_color},
|
||||
toasts::SystemToast,
|
||||
ui,
|
||||
};
|
||||
use horizon_core::jmath;
|
||||
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
|
||||
/// about, whether the last one is required, and the server's message.
|
||||
@@ -75,6 +80,111 @@ pub(super) struct ServerPackState {
|
||||
user: (String, u128),
|
||||
/// The packs loaded on top of the player's own, bottom first.
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
struct PackNotifier {
|
||||
progress: Receiver<download::Progress>,
|
||||
total: usize,
|
||||
/// `count`: the requests started.
|
||||
count: usize,
|
||||
/// `failCount`.
|
||||
failed: usize,
|
||||
/// `totalBytes`: the Content-Length of the request in flight.
|
||||
size: Option<u64>,
|
||||
/// The bytes it has written so far: none before the first response.
|
||||
bytes: Option<u64>,
|
||||
/// The last request has finished.
|
||||
done: bool,
|
||||
toast: Option<SystemToast>,
|
||||
}
|
||||
impl PackNotifier {
|
||||
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
|
||||
Self {
|
||||
progress,
|
||||
total,
|
||||
count: 0,
|
||||
failed: 0,
|
||||
size: None,
|
||||
bytes: None,
|
||||
done: false,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
/// The batch's events since the last call, with the toast updated once
|
||||
/// for all of them. False once the batch has ended and its toast is gone.
|
||||
fn poll(&mut self) -> bool {
|
||||
let (mut changed, mut hide) = (false, false);
|
||||
let ended = loop {
|
||||
let event = match self.progress.try_recv() {
|
||||
Ok(event) => event,
|
||||
Err(e) => break e == TryRecvError::Disconnected,
|
||||
};
|
||||
changed |= match event {
|
||||
download::Progress::RequestStart => {
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
download::Progress::DownloadStart(size) => {
|
||||
self.size = size;
|
||||
self.bytes = Some(0);
|
||||
true
|
||||
}
|
||||
download::Progress::Bytes(bytes) => {
|
||||
self.bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
download::Progress::RequestFinished(ok) => {
|
||||
self.failed += usize::from(!ok);
|
||||
self.done = self.count == self.total;
|
||||
// Every pack came: forceHide; else the failure title.
|
||||
hide |= self.done && self.failed == 0;
|
||||
self.done && self.failed > 0
|
||||
}
|
||||
};
|
||||
};
|
||||
if changed {
|
||||
let (title, message) = self.text();
|
||||
let (title, message) = (title.plain(), message.map(|m| m.plain()));
|
||||
// SystemToast.addOrUpdate.
|
||||
match &mut self.toast {
|
||||
Some(toast) => toast.reset(title, message),
|
||||
None => self.toast = Some(SystemToast::new(title, message)),
|
||||
}
|
||||
}
|
||||
if hide && let Some(toast) = &mut self.toast {
|
||||
toast.force_hide();
|
||||
}
|
||||
!ended || self.toast.is_some()
|
||||
}
|
||||
/// The toast's title and message.
|
||||
fn text(&self) -> (Text, Option<Text>) {
|
||||
if self.done && self.failed > 0 {
|
||||
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
|
||||
return (failed, None);
|
||||
}
|
||||
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
|
||||
let message = self.bytes.map(|bytes| match self.size {
|
||||
Some(size) if size > 0 => {
|
||||
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
|
||||
}
|
||||
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
|
||||
});
|
||||
(title, message)
|
||||
}
|
||||
}
|
||||
/// `com.mojang.realmsclient.Unit.humanReadable`, for
|
||||
/// `download.pack.progress.bytes`.
|
||||
fn human_readable(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
|
||||
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
|
||||
let value = bytes as f64 / 1024f64.powi(exp);
|
||||
format!("{} {prefix}B", java_text::format_fixed(value, 1))
|
||||
}
|
||||
|
||||
fn saves() -> PathBuf {
|
||||
@@ -89,6 +199,7 @@ impl Game<'_> {
|
||||
let state = &mut self.server_packs;
|
||||
state.user = user;
|
||||
state.prompt = None;
|
||||
state.notifiers.clear();
|
||||
let Some(address) = address else {
|
||||
state.address = None;
|
||||
state.packs.local_world();
|
||||
@@ -103,6 +214,7 @@ impl Game<'_> {
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
self.server_packs.prompt = None;
|
||||
self.server_packs.notifiers.clear();
|
||||
self.server_packs.packs.disconnect();
|
||||
self.tick_server_packs();
|
||||
}
|
||||
@@ -197,6 +309,7 @@ impl Game<'_> {
|
||||
/// that finished, the ones to start, a resource reload when the active
|
||||
/// packs change, and the statuses for the server.
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
@@ -238,6 +351,15 @@ impl Game<'_> {
|
||||
Ok(queue) => state.queue = Some(queue),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to open the download queue: {e}");
|
||||
// Every request failed, as the notifier would hear it.
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
for _ in &requests {
|
||||
let _ = progress.send(download::Progress::RequestStart);
|
||||
let _ = progress.send(download::Progress::RequestFinished(false));
|
||||
}
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
let failed = requests.iter().map(|r| r.id).collect();
|
||||
state.packs.downloaded(horizon_client::server_packs::Batch {
|
||||
downloaded: Vec::new(),
|
||||
@@ -248,8 +370,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let headers = download::headers(&state.user.0, state.user.1);
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
if let Some(queue) = &state.queue {
|
||||
queue.download(requests, headers);
|
||||
queue.download(requests, headers, progress);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
@@ -297,6 +423,29 @@ impl Game<'_> {
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The download notifiers' toasts, each in the next 32-pixel slot
|
||||
/// below an advancement or recipe toast (`ToastManager`).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let mut slot = usize::from(!self.adv.toasts.is_empty());
|
||||
for notifier in &mut self.server_packs.notifiers {
|
||||
let Some(toast) = &mut notifier.toast else {
|
||||
continue;
|
||||
};
|
||||
let (sound, finished) = toast.update(multiplier);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
notifier.toast = None;
|
||||
continue;
|
||||
}
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
/// A left click on the prompt: its answer when it hit a button.
|
||||
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
@@ -314,3 +463,97 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use download::Progress::*;
|
||||
|
||||
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(*event).unwrap();
|
||||
}
|
||||
let mut notifier = PackNotifier::new(receive, total);
|
||||
assert!(notifier.poll());
|
||||
notifier
|
||||
}
|
||||
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
|
||||
let (title, message) = n.text();
|
||||
(title.component(), message.map(|m| m.component()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toast_counts_requests_and_shows_progress() {
|
||||
let n = notifier(2, &[RequestStart]);
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(title["translate"], "download.pack.title");
|
||||
assert_eq!(title["with"], serde_json::json!([1, 2]));
|
||||
assert_eq!(message, None);
|
||||
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap(),
|
||||
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
|
||||
);
|
||||
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap()["with"],
|
||||
serde_json::json!([{"text": "1.3 KB"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
|
||||
let events = [
|
||||
RequestStart,
|
||||
RequestFinished(false),
|
||||
RequestStart,
|
||||
DownloadStart(Some(3)),
|
||||
Bytes(3),
|
||||
];
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(event).unwrap();
|
||||
}
|
||||
let mut n = PackNotifier::new(receive, 2);
|
||||
assert!(n.poll());
|
||||
// Not the last request yet: still downloading.
|
||||
assert_eq!(texts(&n).0["translate"], "download.pack.title");
|
||||
send.send(RequestFinished(true)).unwrap();
|
||||
drop(send);
|
||||
assert!(n.poll(), "the failure toast stays until it slides out");
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(
|
||||
title,
|
||||
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
|
||||
);
|
||||
assert_eq!(message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_batch_without_a_toast_is_dropped() {
|
||||
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
|
||||
drop(send);
|
||||
assert!(!PackNotifier::new(receive, 1).poll());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_read_as_realms_unit_human_readable() {
|
||||
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
|
||||
for (bytes, text) in [
|
||||
(0, "0 B"),
|
||||
(1023, "1023 B"),
|
||||
(1024, "1.0 KB"),
|
||||
(1280, "1.3 KB"),
|
||||
(1535, "1.5 KB"),
|
||||
(10_000, "9.8 KB"),
|
||||
(1_048_575, "1024.0 KB"),
|
||||
(1_048_576, "1.0 MB"),
|
||||
(52_428_800, "50.0 MB"),
|
||||
(262_144_000, "250.0 MB"),
|
||||
(1_073_741_824, "1.0 GB"),
|
||||
] {
|
||||
assert_eq!(human_readable(bytes), text, "{bytes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Server text displays: metadata, interpolation, billboard transforms and world text.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
text_display::{Align, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
pub(super) struct RemoteTextDisplay {
|
||||
pub data: TextDisplayData,
|
||||
from: TextDisplayData,
|
||||
shown: TextDisplayData,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
target: glam::DVec3,
|
||||
rotation: [f32; 2],
|
||||
old_rotation: [f32; 2],
|
||||
target_rotation: [f32; 2],
|
||||
steps: i32,
|
||||
age: i64,
|
||||
start: i64,
|
||||
duration: i32,
|
||||
dirty: bool,
|
||||
start_dirty: bool,
|
||||
duration_dirty: bool,
|
||||
initialized: bool,
|
||||
/// `Display.lastProgress`: the progress the last frame drew, where a new
|
||||
/// interpolation starts from.
|
||||
last_progress: f32,
|
||||
/// `TextDisplay.clientDisplayCache`: the split lines, until the render
|
||||
/// state changes.
|
||||
lines: Option<Lines>,
|
||||
}
|
||||
|
||||
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
|
||||
struct Lines {
|
||||
lines: Vec<(Text, f32)>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
|
||||
/// data a render state is built from (not the view range, size or glow).
|
||||
fn renders(id: u8) -> bool {
|
||||
matches!(id, 11..=16 | 18 | 19 | 23..=27)
|
||||
}
|
||||
|
||||
impl RemoteTextDisplay {
|
||||
pub fn new(position: [f64; 3], yaw: f32) -> Self {
|
||||
Self {
|
||||
data: Default::default(),
|
||||
from: Default::default(),
|
||||
shown: Default::default(),
|
||||
position: position.into(),
|
||||
previous: position.into(),
|
||||
target: position.into(),
|
||||
rotation: [yaw, 0.0],
|
||||
old_rotation: [yaw, 0.0],
|
||||
target_rotation: [yaw, 0.0],
|
||||
steps: 0,
|
||||
age: 0,
|
||||
start: 0,
|
||||
duration: 0,
|
||||
dirty: true,
|
||||
start_dirty: false,
|
||||
duration_dirty: false,
|
||||
initialized: false,
|
||||
last_progress: 0.0,
|
||||
lines: None,
|
||||
}
|
||||
}
|
||||
fn progress(&self, partial: f32) -> f32 {
|
||||
if self.duration <= 0 {
|
||||
1.0
|
||||
} else {
|
||||
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
|
||||
self.start_dirty |= values.iter().any(|v| v.0 == 8);
|
||||
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
|
||||
self.dirty |= values.iter().any(|v| renders(v.0));
|
||||
self.data.apply(values);
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
self.age += 1;
|
||||
self.previous = self.position;
|
||||
self.old_rotation = self.rotation;
|
||||
if self.steps > 0 {
|
||||
self.position += (self.target - self.position) / f64::from(self.steps);
|
||||
for i in 0..2 {
|
||||
self.rotation[i] +=
|
||||
angle(self.target_rotation[i] - self.rotation[i]) / self.steps as f32;
|
||||
}
|
||||
self.steps -= 1;
|
||||
}
|
||||
if self.start_dirty {
|
||||
self.start = self.age + i64::from(self.data.interpolation_delay);
|
||||
}
|
||||
if self.duration_dirty {
|
||||
self.duration = self.data.interpolation_duration;
|
||||
}
|
||||
if self.dirty {
|
||||
self.from = if self.initialized && self.duration > 0 {
|
||||
self.shown.blend(&self.from, self.last_progress)
|
||||
} else {
|
||||
self.data.clone()
|
||||
};
|
||||
self.shown = self.data.clone();
|
||||
self.initialized = true;
|
||||
self.lines = None;
|
||||
}
|
||||
self.dirty = false;
|
||||
self.start_dirty = false;
|
||||
self.duration_dirty = false;
|
||||
}
|
||||
fn move_to(&mut self, position: [f64; 3], rotation: Option<(f32, f32)>) {
|
||||
self.target = position.into();
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
self.target_rotation = [yaw, pitch];
|
||||
}
|
||||
self.steps = self.data.teleport_duration.clamp(0, 59);
|
||||
if self.steps == 0 {
|
||||
self.position = self.target;
|
||||
self.previous = self.target;
|
||||
self.rotation = self.target_rotation;
|
||||
self.old_rotation = self.rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn angle(d: f32) -> f32 {
|
||||
(d + 180.0).rem_euclid(360.0) - 180.0
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn move_remote_text_display(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
rotation: Option<(f32, f32)>,
|
||||
) {
|
||||
if let Some(d) = self.text_displays.get_mut(&id) {
|
||||
d.move_to(position, rotation);
|
||||
}
|
||||
}
|
||||
pub(super) fn tick_text_displays(&mut self) {
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.tick();
|
||||
}
|
||||
}
|
||||
pub(super) fn text_display_geometry(
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
partial: f64,
|
||||
normal: &mut Vec<Vertex>,
|
||||
see_through: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
let atlas = &self.gui.atlas;
|
||||
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
|
||||
let angles = if front {
|
||||
[self.yaw + 180.0, -self.pitch]
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
display.old_rotation[i]
|
||||
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
|
||||
});
|
||||
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
|
||||
if !matrix.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
|
||||
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
|
||||
let lines: Vec<_> = atlas
|
||||
.font
|
||||
.split(&d.text, d.line_width as f32)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let width = atlas.font.width_text(&line).ceil();
|
||||
(line, width)
|
||||
})
|
||||
.collect();
|
||||
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
|
||||
Lines { lines, width }
|
||||
});
|
||||
let width = *width;
|
||||
let height = lines.len() as f32 * 10.0 - 1.0;
|
||||
matrix *=
|
||||
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let bg = if d.flags & 4 != 0 {
|
||||
self.settings.text_background(0.25)
|
||||
} else {
|
||||
d.background as u32
|
||||
};
|
||||
if bg != 0 {
|
||||
batch.fill(
|
||||
atlas,
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
height + 1.0,
|
||||
rgba(
|
||||
(bg >> 16) as u8,
|
||||
(bg >> 8) as u8,
|
||||
bg as u8,
|
||||
(bg >> 24) as u8,
|
||||
),
|
||||
);
|
||||
}
|
||||
let align = d.align();
|
||||
for (i, (line, line_width)) in lines.iter().enumerate() {
|
||||
let x = match align {
|
||||
Align::Left => 0.0,
|
||||
Align::Right => width - line_width,
|
||||
Align::Center => (width - line_width) / 2.0,
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x,
|
||||
i as f32 * 10.0,
|
||||
rgba(255, 255, 255, d.opacity as u8),
|
||||
d.flags & 1 != 0,
|
||||
);
|
||||
}
|
||||
let world_light = self.world.light(
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
);
|
||||
let light = if d.brightness == -1 {
|
||||
horizon_client::block_entity_render::vertex_light(world_light)
|
||||
} else {
|
||||
((d.brightness as u32 >> 20 & 15) * 16)
|
||||
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
};
|
||||
let through = d.flags & 2 != 0;
|
||||
let out = if through {
|
||||
&mut *see_through
|
||||
} else {
|
||||
&mut *normal
|
||||
};
|
||||
for quad in batch.vertices.as_chunks::<6>().0 {
|
||||
for i in [0, 1, 2, 5] {
|
||||
let v = quad[i];
|
||||
let point =
|
||||
matrix.transform_point3(glam::Vec3::new(v.pos[0], v.pos[1], v.depth));
|
||||
out.push(Vertex {
|
||||
pos: point.to_array(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
}
|
||||
self.text_display_vertices += batch.vertices.len() / 6 * 4;
|
||||
self.map_patches.extend(batch.patches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
|
||||
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
|
||||
d.tick();
|
||||
d.metadata(&[
|
||||
(8, 1, Value::Int(0)),
|
||||
(9, 1, Value::Int(4)),
|
||||
(10, 1, Value::Int(2)),
|
||||
(11, 0, Value::Floats(vec![4.0, 0.0, 0.0])),
|
||||
]);
|
||||
d.tick();
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.5)).translation[0], 0.5);
|
||||
d.move_to([2.0, 0.0, 0.0], Some((90.0, 0.0)));
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 1.0);
|
||||
assert_eq!(d.rotation[0], 45.0);
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 2.0);
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.0)).translation[0], 2.0);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
//! its boss bar, and its skulls hitting and exploding.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud_overlays,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -78,45 +76,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The wither boss bars (purple), within range.
|
||||
pub(super) fn wither_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for m in self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Wither && m.alive())
|
||||
{
|
||||
if m.position.distance_squared(p.position) > 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let progress = if m.extra.counter > 0 {
|
||||
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
|
||||
} else {
|
||||
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
|
||||
};
|
||||
let name = m
|
||||
.custom_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither"));
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
pub(super) fn wither_near(&self) -> bool {
|
||||
self.player.as_ref().is_some_and(|p| {
|
||||
self.mobs.iter().any(|m| {
|
||||
m.species() == Species::Wither
|
||||
&& m.alive()
|
||||
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
|
||||
/// when it kills), withering for 10 seconds, then a power 1 blast.
|
||||
pub(super) fn wither_skull_hit(
|
||||
|
||||
@@ -22,6 +22,23 @@ pub(super) struct PreparedWorld {
|
||||
}
|
||||
|
||||
impl WorldRequest {
|
||||
pub fn new(
|
||||
folder: &str,
|
||||
name: Option<&str>,
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
save_root: std::env::current_dir()?.join("saves").join(folder),
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
self,
|
||||
res: &Resources,
|
||||
@@ -250,7 +267,7 @@ pub(super) fn make_streaming(
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
// A server draws nothing: neither the integrated server nor the
|
||||
// dedicated one meshes sections.
|
||||
streaming.mesh = !options.integrated && options.dedicated.is_none();
|
||||
streaming.mesh = options.execution_role.is_client();
|
||||
Ok((streaming, save, locator))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -268,6 +268,8 @@ pub struct Frame<'a> {
|
||||
pub night_vision: f32,
|
||||
/// Torch flicker added to the block light factor of 1.4.
|
||||
pub block_flicker: f32,
|
||||
/// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar).
|
||||
pub boss_darken: f32,
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
@@ -325,6 +327,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 +2161,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 +2208,7 @@ impl Renderer {
|
||||
translucent,
|
||||
flat_on: flat.is_some(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
flat,
|
||||
sky,
|
||||
@@ -2453,6 +2458,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 +2588,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>()
|
||||
@@ -2778,6 +2795,7 @@ impl Renderer {
|
||||
gamma: f32,
|
||||
night_vision: f32,
|
||||
flicker: f32,
|
||||
boss_darken: f32,
|
||||
) {
|
||||
// lightmap.fsh: per (block, sky) level.
|
||||
let brightness = |level: f32| level / (4.0 - 3.0 * level);
|
||||
@@ -2795,6 +2813,8 @@ impl Renderer {
|
||||
let parabolic = (2.0 * block_level - 1.0).powi(2);
|
||||
let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic);
|
||||
color += block_color * block_brightness;
|
||||
// lightmap.fsh: a boss bar mixes the light toward 70/60/60%.
|
||||
color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken);
|
||||
color -= Vec3::splat(darkness);
|
||||
color = color.clamp(Vec3::ZERO, Vec3::ONE);
|
||||
let max = color.max_element().max(1e-6);
|
||||
@@ -2909,6 +2929,7 @@ impl Renderer {
|
||||
(self.gamma - frame.darkness.0).max(0.0),
|
||||
frame.night_vision,
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
if frame.title {
|
||||
@@ -3042,6 +3063,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 +3130,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 +3137,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 +3155,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 +3197,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 +3539,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 +4223,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 +4478,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));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] {
|
||||
uuid as i32,
|
||||
]
|
||||
}
|
||||
/// `UUIDUtil.uuidFromIntArray`.
|
||||
pub fn ints_uuid(ints: [i32; 4]) -> u128 {
|
||||
ints.iter()
|
||||
.fold(0, |acc, &i| acc << 32 | u128::from(i as u32))
|
||||
}
|
||||
|
||||
/// Whether a saved file exists for this player.
|
||||
pub fn exists(world: &Path, uuid: u128) -> bool {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod block_entity_render;
|
||||
pub mod block_transform;
|
||||
pub mod blocks;
|
||||
pub mod border_render;
|
||||
pub mod boss_overlay;
|
||||
pub mod bossbar;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
@@ -177,6 +178,7 @@ pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
|
||||
@@ -689,7 +689,7 @@ impl Lod {
|
||||
}
|
||||
let finer = finer.remove(&key).unwrap_or_default();
|
||||
let input = snapshot.mesh_input(key, finer);
|
||||
let mesh = mesher.mesh(
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
&covered,
|
||||
@@ -698,6 +698,7 @@ impl Lod {
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
}
|
||||
Done::Mesh {
|
||||
@@ -1727,10 +1728,6 @@ mod tests {
|
||||
let expected = (key.size() * key.size() - 16 * 16) as f32;
|
||||
assert_eq!(top_area(&mesh.solid, 70.0), expected);
|
||||
assert_eq!(top_area(&mesh.translucent, 80.0 - 1.0 / 9.0), expected);
|
||||
assert_eq!(
|
||||
mesh.faces.quads.iter().sum::<u32>() as usize,
|
||||
mesh.solid.len() / 4
|
||||
);
|
||||
assert!(mesh.translucent.iter().all(|v| v.light == 240));
|
||||
let side = |axis: usize, plane: f32, low: f32, high: f32| {
|
||||
let other = if axis == 0 { 2 } else { 0 };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use super::{Column, Span, TileKey};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mesher::{FaceGroups, OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
streaming::WorkerContext,
|
||||
};
|
||||
use horizon_assets::model::Direction;
|
||||
@@ -558,7 +558,6 @@ fn mesh_columns_with_edges(
|
||||
emit(&mut mesh, key, rect)
|
||||
}
|
||||
}
|
||||
mesh.faces = FaceGroups::group(&mut mesh.solid);
|
||||
mesh
|
||||
}
|
||||
|
||||
@@ -1003,7 +1002,6 @@ mod tests {
|
||||
}
|
||||
let mesh = mesh(&columns);
|
||||
assert_eq!(mesh.solid.len(), 6 * 4);
|
||||
assert_eq!(mesh.faces.quads.iter().sum::<u32>(), 6);
|
||||
assert!(mesh.translucent.is_empty());
|
||||
for q in mesh.solid.as_chunks::<4>().0.iter() {
|
||||
let center = q
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! `ModelBlockRenderer`/`BlockModelLighter` (smooth light, ambient occlusion,
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod facing;
|
||||
mod fluid;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
chunk::{Chunk, MIN_Y},
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
pub use facing::{FaceGroups, GROUPS as FACE_GROUPS};
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
@@ -22,6 +21,7 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use sides::Sides;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An entity, item and effect vertex (the entity pipelines), with a face
|
||||
@@ -116,8 +116,10 @@ mod vertex_tests {
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// `solid`'s quads by the side they face (`facing`).
|
||||
pub faces: FaceGroups,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
|
||||
/// order) are connected through non-opaque blocks.
|
||||
@@ -131,13 +133,20 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
faces: FaceGroups::default(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
@@ -692,12 +701,6 @@ pub struct Mesher<'a> {
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = self.tesselate(region, sy);
|
||||
out.faces = facing::group(&mut out.solid);
|
||||
out
|
||||
}
|
||||
/// The section's quads in model order, before `facing::group`.
|
||||
pub(crate) fn tesselate(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
@@ -715,6 +718,8 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
@@ -744,13 +749,15 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
let world = BlockPos::new(center.x * 16 + x, base_y + y, center.z * 16 + z);
|
||||
let Ok(models) = self.render.model_catalog.collect(BlockStateId(id), world)
|
||||
else {
|
||||
if catalog
|
||||
.models_at(BlockStateId(id), world, &mut models)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
}
|
||||
// ModelBlockRenderer: the first part decides smooth lighting
|
||||
// for all; light-emitting blocks are always lit flat.
|
||||
let Some((_, first)) = models.first() else {
|
||||
let Some(first) = models.first() else {
|
||||
continue;
|
||||
};
|
||||
let ao = self.smooth_lighting && state.emission == 0 && first.ambient_occlusion;
|
||||
@@ -780,7 +787,7 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
for (_, model) in &models {
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
continue;
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Solid quads grouped by the side they face, as Sodium's `ModelQuadFacing`
|
||||
//! does. A quad in the plane `x = p` facing +X is front-facing only while the
|
||||
//! eye is at `x > p`, and terrain culls back faces, so when the eye is on the
|
||||
//! back side of every quad of a group the renderer skips the group's draw:
|
||||
//! the GPU would have discarded all of it after shading its vertices. The
|
||||
//! picture is unchanged; the vertex work for those quads is gone.
|
||||
use super::TerrainVertex;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Groups in buffer order: the negative sides, quads not in an axis plane
|
||||
/// (rotated model elements, sloped fluid tops), then the positive sides.
|
||||
/// With the eye outside a section on every axis, one side per axis is
|
||||
/// visible, so the drawn groups form one to three runs.
|
||||
pub const GROUPS: usize = 7;
|
||||
const UNALIGNED: usize = 3;
|
||||
/// The group of a quad facing `-axis` (0 x, 1 y, 2 z) is `NEGATIVE[axis]`, of
|
||||
/// one facing `+axis` `GROUPS - 1 - NEGATIVE[axis]`.
|
||||
const NEGATIVE: [usize; 3] = [0, 2, 1];
|
||||
/// How far (blocks) the eye must be behind every plane of a group before
|
||||
/// the group is skipped: rounding on the GPU never makes such a quad
|
||||
/// front-facing.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
/// The solid quads' groups, filled by `group`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FaceGroups {
|
||||
/// Quads per group, in buffer order.
|
||||
pub quads: [u32; GROUPS],
|
||||
/// Per axis group, the plane nearest the side the group faces: the
|
||||
/// lowest plane of a positive group and the highest of a negative one.
|
||||
planes: [f32; GROUPS],
|
||||
}
|
||||
|
||||
impl Default for FaceGroups {
|
||||
/// Everything unaligned: always drawn.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quads: [0; GROUPS],
|
||||
planes: [0.0; GROUPS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceGroups {
|
||||
/// Group arbitrary-sized terrain quads, including distant LOD tiles.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> Self {
|
||||
group(solid)
|
||||
}
|
||||
|
||||
/// Whether group `g` can hold a front-facing quad for an eye at `eye`,
|
||||
/// relative to the section's minimum corner.
|
||||
pub fn visible(&self, g: usize, eye: Vec3) -> bool {
|
||||
if g == UNALIGNED {
|
||||
return true;
|
||||
}
|
||||
let (axis, positive) = axis(g);
|
||||
if positive {
|
||||
eye[axis] > self.planes[g] - MARGIN
|
||||
} else {
|
||||
eye[axis] < self.planes[g] + MARGIN
|
||||
}
|
||||
}
|
||||
/// The quad ranges (first quad, quad count) to draw for an eye at `eye`
|
||||
/// relative to the section's minimum corner, adjacent groups merged.
|
||||
pub fn ranges(&self, eye: Vec3, mut out: impl FnMut(u32, u32)) {
|
||||
let mut first = 0;
|
||||
let mut run: Option<(u32, u32)> = None;
|
||||
for g in 0..GROUPS {
|
||||
let quads = self.quads[g];
|
||||
if quads > 0 {
|
||||
if self.visible(g, eye) {
|
||||
run = Some(match run {
|
||||
Some((start, len)) => (start, len + quads),
|
||||
None => (first, quads),
|
||||
});
|
||||
} else if let Some((start, len)) = run.take() {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
first += quads;
|
||||
}
|
||||
if let Some((start, len)) = run {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis and sign of each axis group (`UNALIGNED`'s entry is unused).
|
||||
const AXES: [(usize, bool); GROUPS] = {
|
||||
let mut axes = [(0, false); GROUPS];
|
||||
let mut axis = 0;
|
||||
while axis < 3 {
|
||||
axes[NEGATIVE[axis]] = (axis, false);
|
||||
axes[GROUPS - 1 - NEGATIVE[axis]] = (axis, true);
|
||||
axis += 1;
|
||||
}
|
||||
axes
|
||||
};
|
||||
|
||||
/// The axis and sign of axis group `g`.
|
||||
fn axis(g: usize) -> (usize, bool) {
|
||||
debug_assert_ne!(g, UNALIGNED);
|
||||
AXES[g]
|
||||
}
|
||||
|
||||
/// The group of one quad, and its plane: an axis group only when all four
|
||||
/// corners lie exactly in one axis plane.
|
||||
fn facing(quad: &[TerrainVertex]) -> (usize, f32) {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0));
|
||||
for axis in 0..3 {
|
||||
let [a, b] = [(axis + 1) % 3, (axis + 2) % 3];
|
||||
let plane = quad[0].pos[axis];
|
||||
if normal[axis] != 0.0
|
||||
&& normal[a] == 0.0
|
||||
&& normal[b] == 0.0
|
||||
&& quad.iter().all(|v| v.pos[axis] == plane)
|
||||
{
|
||||
let negative = NEGATIVE[axis];
|
||||
let g = if normal[axis] > 0.0 {
|
||||
GROUPS - 1 - negative
|
||||
} else {
|
||||
negative
|
||||
};
|
||||
return (g, plane);
|
||||
}
|
||||
}
|
||||
(UNALIGNED, 0.0)
|
||||
}
|
||||
|
||||
/// Reorder `solid` by group, keeping the order of quads within a group (so
|
||||
/// coplanar quads such as a grass block's overlay still draw after their
|
||||
/// base), and return the groups.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> FaceGroups {
|
||||
let mut groups = FaceGroups::default();
|
||||
for (g, plane) in groups.planes.iter_mut().enumerate() {
|
||||
if g != UNALIGNED {
|
||||
*plane = if axis(g).1 { f32::MAX } else { f32::MIN };
|
||||
}
|
||||
}
|
||||
let faces: Vec<u8> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|quad| {
|
||||
let (g, plane) = facing(quad);
|
||||
groups.quads[g] += 1;
|
||||
if g != UNALIGNED {
|
||||
let nearest = &mut groups.planes[g];
|
||||
*nearest = if axis(g).1 {
|
||||
nearest.min(plane)
|
||||
} else {
|
||||
nearest.max(plane)
|
||||
};
|
||||
}
|
||||
g as u8
|
||||
})
|
||||
.collect();
|
||||
if faces.windows(2).all(|w| w[0] <= w[1]) {
|
||||
return groups;
|
||||
}
|
||||
let mut next = [0usize; GROUPS];
|
||||
let mut start = 0;
|
||||
for g in 0..GROUPS {
|
||||
next[g] = start;
|
||||
start += groups.quads[g] as usize;
|
||||
}
|
||||
let mut sorted = vec![solid[0]; solid.len()];
|
||||
for (quad, &g) in solid.as_chunks::<4>().0.iter().zip(&faces) {
|
||||
let at = next[g as usize] * 4;
|
||||
sorted[at..at + 4].copy_from_slice(quad);
|
||||
next[g as usize] += 1;
|
||||
}
|
||||
*solid = sorted;
|
||||
groups
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vertex(pos: [f32; 3], layer: u16) -> TerrainVertex {
|
||||
TerrainVertex::new(pos, [0.0; 2], layer, 0, 0)
|
||||
}
|
||||
/// A unit quad in the plane `axis = plane`, wound to face `sign`, tagged
|
||||
/// with `layer` so tests can follow it.
|
||||
fn quad(axis: usize, plane: f32, sign: f32, layer: u16) -> Vec<TerrainVertex> {
|
||||
let corner = |u: f32, v: f32| {
|
||||
let mut p = [0.0; 3];
|
||||
p[axis] = plane;
|
||||
p[(axis + 1) % 3] = u;
|
||||
p[(axis + 2) % 3] = v;
|
||||
vertex(p, layer)
|
||||
};
|
||||
let mut q = vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(1.0, 0.0),
|
||||
corner(1.0, 1.0),
|
||||
corner(0.0, 1.0),
|
||||
];
|
||||
if sign < 0.0 {
|
||||
q.reverse();
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_group_by_the_side_their_winding_faces() {
|
||||
for a in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let q = quad(a, 4.0, sign, 0);
|
||||
let (g, plane) = facing(&q);
|
||||
assert_eq!(axis(g), (a, sign > 0.0), "axis {a} sign {sign}");
|
||||
assert_eq!(plane, 4.0);
|
||||
let normal = (Vec3::from(q[1].pos) - Vec3::from(q[0].pos))
|
||||
.cross(Vec3::from(q[2].pos) - Vec3::from(q[0].pos));
|
||||
assert_eq!(normal[a].signum(), sign);
|
||||
}
|
||||
}
|
||||
// A cross plant's diagonal quad faces no axis.
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 0));
|
||||
assert_eq!(facing(&diagonal).0, UNALIGNED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_is_stable_and_skips_only_back_facing_groups() {
|
||||
// +X at x = 3 and x = 7 (layers 1, 2), -X at x = 5 (3), an unaligned
|
||||
// quad (4), a coplanar overlay on the x = 3 quad (5).
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 4));
|
||||
let mut solid = [
|
||||
quad(0, 3.0, 1.0, 1),
|
||||
quad(0, 5.0, -1.0, 3),
|
||||
diagonal.to_vec(),
|
||||
quad(0, 7.0, 1.0, 2),
|
||||
quad(0, 3.0, 1.0, 5),
|
||||
]
|
||||
.concat();
|
||||
let groups = group(&mut solid);
|
||||
let layers: Vec<u16> = solid.chunks(4).map(|q| q[0].layer).collect();
|
||||
assert_eq!(layers, [3, 4, 1, 2, 5]);
|
||||
let ranges = |eye: Vec3| {
|
||||
let mut out = Vec::new();
|
||||
groups.ranges(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
};
|
||||
// Between the planes: every group can face the eye.
|
||||
assert_eq!(ranges(Vec3::new(4.0, 8.0, 8.0)), [(0, 5)]);
|
||||
// Beyond x = 7: the -X quad at x = 5 faces away.
|
||||
assert_eq!(ranges(Vec3::new(20.0, 8.0, 8.0)), [(1, 4)]);
|
||||
// Before x = 3: every +X quad faces away.
|
||||
assert_eq!(ranges(Vec3::new(-10.0, 8.0, 8.0)), [(0, 2)]);
|
||||
// Within the margin of the nearest plane: still drawn.
|
||||
assert_eq!(ranges(Vec3::new(3.0, 8.0, 8.0)), [(0, 5)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! A mesh's solid quads sorted by the side they face, so that the renderer
|
||||
//! can leave out the quads turned away from the eye before the GPU shades
|
||||
//! their vertices.
|
||||
//!
|
||||
//! A quad in the plane `x = p` whose corners go counter-clockwise seen from
|
||||
//! +X faces +X. From an eye at `x < p` it is seen from behind, and at
|
||||
//! `x = p` edge on; the solid pipeline culls back faces, so it draws nothing
|
||||
//! from there. A bucket of such quads draws nothing at all while the eye is
|
||||
//! at or below the lowest of their planes, and likewise for the other five
|
||||
//! axis directions. The seventh bucket holds every other quad (sloped,
|
||||
//! rotated, degenerate, or whose two triangles wind opposite ways): it is
|
||||
//! drawn from everywhere.
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
/// common corner is then one run, or two.
|
||||
const BUCKETS: [Bucket; 7] = [
|
||||
Bucket::Side(0, true),
|
||||
Bucket::Side(2, true),
|
||||
Bucket::Other,
|
||||
Bucket::Side(1, true),
|
||||
Bucket::Side(2, false),
|
||||
Bucket::Side(0, false),
|
||||
Bucket::Side(1, false),
|
||||
];
|
||||
const OTHER: usize = 2;
|
||||
/// How far, in blocks, the eye must be behind a bucket's planes before the
|
||||
/// bucket is left out. The renderer computes both the eye and the section
|
||||
/// origins in `f32`, with errors far below this.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Bucket {
|
||||
/// Quads in a plane across `axis` facing its positive (`true`) or
|
||||
/// negative direction.
|
||||
Side(usize, bool),
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
|
||||
/// whether it can face the eye.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Sides {
|
||||
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
|
||||
ends: [u32; 7],
|
||||
/// Per bucket facing a positive direction, its lowest plane; facing a
|
||||
/// negative one, its highest (unused for the other bucket).
|
||||
planes: [f32; 7],
|
||||
}
|
||||
|
||||
impl Sides {
|
||||
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
|
||||
/// that were not sorted.
|
||||
pub fn unsorted(quads: u32) -> Self {
|
||||
let mut ends = [0; 7];
|
||||
ends[OTHER..].fill(quads);
|
||||
Self {
|
||||
ends,
|
||||
planes: [0.0; 7],
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[6]
|
||||
}
|
||||
|
||||
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
|
||||
/// (relative to the mesh's origin) can see the front of, nearest the
|
||||
/// start first. Empty buckets do not break a run.
|
||||
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
let mut run: Option<u32> = None;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = self.ends[i];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let seen = match *bucket {
|
||||
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
|
||||
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
|
||||
Bucket::Other => true,
|
||||
};
|
||||
match (seen, run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if let Some(first) = run {
|
||||
draw(first, start - first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket of a quad: the axis plane it lies in and the side its
|
||||
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
|
||||
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
let p = quad.map(|v| v.pos);
|
||||
for axis in 0..3 {
|
||||
if p.iter().any(|v| v[axis] != p[0][axis]) {
|
||||
continue;
|
||||
}
|
||||
// The normal's component along `axis`, twice each triangle's area.
|
||||
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
let area = |a: usize, b: usize, c: usize| {
|
||||
(p[b][u] - p[a][u]) * (p[c][w] - p[a][w]) - (p[b][w] - p[a][w]) * (p[c][u] - p[a][u])
|
||||
};
|
||||
let (first, second) = (area(0, 1, 2), area(0, 2, 3));
|
||||
let positive = first >= 0.0 && second >= 0.0;
|
||||
let negative = first <= 0.0 && second <= 0.0;
|
||||
if positive == negative {
|
||||
// Opposite windings, or no area at all.
|
||||
return OTHER;
|
||||
}
|
||||
return BUCKETS
|
||||
.iter()
|
||||
.position(|b| matches!(*b, Bucket::Side(a, s) if a == axis && s == positive))
|
||||
.expect("every side has a bucket");
|
||||
}
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let b = usize::from(b);
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
let p = quad[0].pos[axis];
|
||||
planes[b] = match (counts[b], positive) {
|
||||
(0, _) => p,
|
||||
(_, true) => planes[b].min(p),
|
||||
(_, false) => planes[b].max(p),
|
||||
};
|
||||
}
|
||||
counts[b] += 1;
|
||||
}
|
||||
let mut ends = [0u32; 7];
|
||||
let mut total = 0;
|
||||
for (end, count) in ends.iter_mut().zip(counts) {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let slot = &mut next[usize::from(b)];
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
|
||||
/// A unit block's six faces at `origin`, wound counter-clockwise seen
|
||||
/// from outside, as block models are.
|
||||
fn cube(origin: [f32; 3], layer: u16) -> Vec<TerrainVertex> {
|
||||
let [x, y, z] = origin;
|
||||
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
|
||||
[
|
||||
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]], // +X
|
||||
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]], // -X
|
||||
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]], // +Y
|
||||
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]], // -Y
|
||||
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]], // +Z
|
||||
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]], // -Z
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|q| quad(q, layer))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
sides.visible(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The quads of `solid` whose triangles face `eye`, by their layer.
|
||||
fn front_facing(solid: &[TerrainVertex], eye: [f32; 3]) -> Vec<u16> {
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
[[0, 1, 2], [0, 2, 3]].iter().any(|t| {
|
||||
let (e1, e2) = (sub(q[t[1]].pos, q[t[0]].pos), sub(q[t[2]].pos, q[t[0]].pos));
|
||||
let n = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let to_eye = sub(eye, q[t[0]].pos);
|
||||
n[0] * to_eye[0] + n[1] * to_eye[1] + n[2] * to_eye[2] > 0.0
|
||||
})
|
||||
})
|
||||
.map(|q| q[0].layer)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_faces_go_to_their_buckets_in_order() {
|
||||
let mut solid = cube([3.0, 4.0, 5.0], 0);
|
||||
for (i, v) in solid.iter_mut().enumerate() {
|
||||
v.layer = (i / 4) as u16;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
|
||||
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
|
||||
// +X, +Z, +Y, -Z, -X, -Y.
|
||||
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
|
||||
assert_eq!(sides.planes[0], 4.0); // +X at x = 4
|
||||
assert_eq!(sides.planes[5], 3.0); // -X at x = 3
|
||||
assert_eq!(sides.planes[3], 5.0); // +Y at y = 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drawn_runs_hold_every_quad_that_faces_the_eye() {
|
||||
// Blocks across a section, with a sloped quad and a bowtie.
|
||||
let mut solid = Vec::new();
|
||||
let mut layer = 0;
|
||||
for origin in [
|
||||
[0.0, 0.0, 0.0],
|
||||
[7.0, 3.0, 12.0],
|
||||
[15.0, 15.0, 15.0],
|
||||
[2.0, 9.0, 4.0],
|
||||
] {
|
||||
for q in cube(origin, 0).chunks(4) {
|
||||
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
|
||||
layer += 1;
|
||||
}
|
||||
}
|
||||
let slope = [
|
||||
[1.0, 1.0, 1.0],
|
||||
[2.0, 1.0, 1.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[1.0, 2.0, 2.0],
|
||||
];
|
||||
let bowtie = [
|
||||
[5.0, 1.0, 1.0],
|
||||
[5.0, 2.0, 2.0],
|
||||
[5.0, 1.0, 2.0],
|
||||
[5.0, 2.0, 1.0],
|
||||
];
|
||||
for corners in [slope, bowtie] {
|
||||
solid.extend(quad(corners, layer));
|
||||
layer += 1;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.quads(), u32::from(layer));
|
||||
let eyes = [-20.0f32, -0.5, 0.0, 3.5, 8.0, 15.99, 16.0, 30.0];
|
||||
for &x in &eyes {
|
||||
for &y in &eyes {
|
||||
for &z in &eyes {
|
||||
let eye = [x, y, z];
|
||||
let mut drawn = Vec::new();
|
||||
for (first, count) in runs(&sides, eye) {
|
||||
drawn.extend(
|
||||
solid[first as usize * 4..(first + count) as usize * 4]
|
||||
.iter()
|
||||
.step_by(4)
|
||||
.map(|v| v.layer),
|
||||
);
|
||||
}
|
||||
for layer in front_facing(&solid, eye) {
|
||||
assert!(drawn.contains(&layer), "{eye:?} misses quad {layer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_merge_across_hidden_and_empty_buckets() {
|
||||
let mut solid = cube([0.0; 3], 0);
|
||||
let sides = sort(&mut solid);
|
||||
// From above, +X and +Z: +X, +Z, (no other), +Y is one run.
|
||||
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
|
||||
// From below, -X and -Z: -Z, -X, -Y.
|
||||
assert_eq!(runs(&sides, [-8.0, -8.0, -8.0]), [(3, 3)]);
|
||||
// From above, -X and +Z: +Z, +Y, then -X.
|
||||
assert_eq!(runs(&sides, [-8.0, 8.0, 8.0]), [(1, 2), (4, 1)]);
|
||||
// Inside the block every face is seen from behind.
|
||||
assert!(runs(&sides, [0.5; 3]).is_empty());
|
||||
// Level with its top, the eye may see it: the margin keeps it.
|
||||
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_meshes_draw_everything() {
|
||||
let sides = Sides::unsorted(9);
|
||||
assert_eq!(sides.quads(), 9);
|
||||
assert_eq!(runs(&sides, [-100.0; 3]), [(0, 9)]);
|
||||
assert!(runs(&Sides::unsorted(0), [0.0; 3]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bucket_keeps_the_mesh_as_it_is() {
|
||||
let mut solid: Vec<_> = (0..3)
|
||||
.flat_map(|i| {
|
||||
quad(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
],
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let before = solid.clone();
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [3; 7]);
|
||||
assert_eq!(
|
||||
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,12 @@ pub struct BakedModel {
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
choices: Vec<CompiledChoices>,
|
||||
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
|
||||
baked: BTreeMap<VariantKey, BakedModel>,
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
@@ -85,7 +88,7 @@ impl StateModels {
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<Self> {
|
||||
let mut choices = Vec::new();
|
||||
let mut states = BTreeMap::new();
|
||||
let mut states = vec![None; registry.len()];
|
||||
let mut variants = BTreeSet::new();
|
||||
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -178,7 +181,7 @@ impl StateModels {
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states.insert(id, (choices.len(), selected));
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
@@ -211,7 +214,8 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let mut baked = BTreeMap::new();
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
@@ -233,16 +237,22 @@ impl StateModels {
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
baked.insert(
|
||||
(name, turns, lock),
|
||||
BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
},
|
||||
);
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
states,
|
||||
@@ -260,26 +270,47 @@ impl StateModels {
|
||||
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
|
||||
/// state's first variant (see `CompiledChoices::particle_variant`).
|
||||
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
|
||||
let (choice, selected) = self.states.get(&state)?;
|
||||
let variant = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked.get(&key(variant))?.particle
|
||||
let (choice, selected) = self.state(state)?;
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
) -> Result<Vec<(&Variant, &BakedModel)>> {
|
||||
let (choice, selected) = self
|
||||
.states
|
||||
.get(&state)
|
||||
.ok_or("unbound gallery block state")?;
|
||||
let mut models = Vec::new();
|
||||
self.each_seeded(state, seed, |(variant, model)| {
|
||||
models.push((variant, model))
|
||||
})?;
|
||||
Ok(models)
|
||||
}
|
||||
/// The models a block draws at `position`, into `out` (cleared first):
|
||||
/// `collect` for the mesher, which reuses one list for every block.
|
||||
pub fn models_at<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
position: BlockPos,
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
mut f: impl FnMut(&'a (Variant, BakedModel)),
|
||||
) -> Result<()> {
|
||||
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut variants = Vec::new();
|
||||
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
|
||||
variants
|
||||
.into_iter()
|
||||
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
|
||||
.collect()
|
||||
self.choices[*choice].for_each(selected, &mut random, |&model| {
|
||||
f(&self.baked[model as usize]);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
|
||||
self.states.get(state.0 as usize)?.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -517,7 +517,11 @@ fn build_overlay_uncached(
|
||||
}
|
||||
}
|
||||
} else if touched.contains(&child)
|
||||
|| (vanilla.join(&child).is_dir() && !filters.is_empty())
|
||||
// Windows cannot link a directory the way unix does (`link` above), so an
|
||||
// untouched one has to be mirrored: skipping it hid textures/font and the
|
||||
// title panorama from the overlay, and a font json that names those sheets
|
||||
// then failed to load (#1688).
|
||||
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
|
||||
{
|
||||
mirror(
|
||||
&child,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1913,7 +1913,8 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> {
|
||||
Some(stem) => path.with_file_name(format!("{stem}.dat_old")),
|
||||
None => path.with_file_name(format!("{file}_old")),
|
||||
};
|
||||
let _ = fs::rename(path, old);
|
||||
// Util.safeReplaceFile: no backup, no replacement.
|
||||
fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
fs::rename(temporary, path)?;
|
||||
Ok(())
|
||||
|
||||
@@ -34,7 +34,21 @@ pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
|
||||
]
|
||||
}
|
||||
|
||||
type Job = (Vec<Request>, Vec<(&'static str, String)>);
|
||||
/// `HttpUtil.DownloadProgressListener`, on the channel a batch was given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Progress {
|
||||
/// `requestStart`: the batch's next request begins.
|
||||
RequestStart,
|
||||
/// `downloadStart`: the response came, with its Content-Length if it
|
||||
/// said one.
|
||||
DownloadStart(Option<u64>),
|
||||
/// `downloadedBytes`: what this request has written so far.
|
||||
Bytes(u64),
|
||||
/// `requestFinished`: whether it succeeded.
|
||||
RequestFinished(bool),
|
||||
}
|
||||
|
||||
type Job = (Vec<Request>, Vec<(&'static str, String)>, Sender<Progress>);
|
||||
|
||||
/// `DownloadQueue`: batches download in order, on one thread.
|
||||
pub struct Queue {
|
||||
@@ -50,16 +64,24 @@ impl Queue {
|
||||
std::thread::Builder::new()
|
||||
.name("download-queue".into())
|
||||
.spawn(move || {
|
||||
for (requests, headers) in todo {
|
||||
if finished.send(run(&dir, &requests, &headers)).is_err() {
|
||||
for (requests, headers, progress) in todo {
|
||||
if finished
|
||||
.send(run(&dir, &requests, &headers, &progress))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self { jobs, done })
|
||||
}
|
||||
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
|
||||
let _ = self.jobs.send((requests, headers));
|
||||
pub fn download(
|
||||
&self,
|
||||
requests: Vec<Request>,
|
||||
headers: Vec<(&'static str, String)>,
|
||||
progress: Sender<Progress>,
|
||||
) {
|
||||
let _ = self.jobs.send((requests, headers, progress));
|
||||
}
|
||||
/// The batches finished since the last call.
|
||||
pub fn finished(&self) -> Vec<Batch> {
|
||||
@@ -68,11 +90,18 @@ impl Queue {
|
||||
}
|
||||
|
||||
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
|
||||
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
|
||||
fn run(
|
||||
dir: &Path,
|
||||
requests: &[Request],
|
||||
headers: &[(&'static str, String)],
|
||||
progress: &Sender<Progress>,
|
||||
) -> Batch {
|
||||
let mut batch = Batch::default();
|
||||
for request in requests {
|
||||
let id = horizon_protocol::crypto::uuid_string(request.id);
|
||||
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
|
||||
let _ = progress.send(Progress::RequestStart);
|
||||
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE, progress);
|
||||
let _ = progress.send(Progress::RequestFinished(result.is_ok()));
|
||||
// DownloadQueue.LogEntry, its fields in the codec's order.
|
||||
let mut entry = vec![
|
||||
("id", serde_json::Value::from(id)),
|
||||
@@ -165,6 +194,7 @@ pub fn download_file(
|
||||
request: &Request,
|
||||
headers: &[(&'static str, String)],
|
||||
max_size: u64,
|
||||
progress: &Sender<Progress>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
|
||||
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
|
||||
@@ -198,6 +228,7 @@ pub fn download_file(
|
||||
.header("Content-Length")
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let _ = progress.send(Progress::DownloadStart(size));
|
||||
if let Some(size) = size
|
||||
&& size > max_size
|
||||
{
|
||||
@@ -206,9 +237,13 @@ pub fn download_file(
|
||||
));
|
||||
}
|
||||
let mut input = response.into_reader();
|
||||
let mut report = |written: u64| {
|
||||
let _ = progress.send(Progress::Bytes(written));
|
||||
};
|
||||
match (&target, &request.sha1) {
|
||||
(Some(target), Some(sha1)) => {
|
||||
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
|
||||
let actual = save_hashed(&mut input, target, max_size, &mut report)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if &actual != sha1 {
|
||||
return Err(format!(
|
||||
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
|
||||
@@ -218,7 +253,7 @@ pub fn download_file(
|
||||
}
|
||||
_ => {
|
||||
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
|
||||
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
|
||||
let result = save_hashed(&mut input, &tmp, max_size, &mut report).and_then(|actual| {
|
||||
let path = dir.join(&actual);
|
||||
if matches(&path, &actual) {
|
||||
touch(&path);
|
||||
@@ -237,7 +272,12 @@ fn rand_suffix() -> u64 {
|
||||
RandomState::new().hash_one(std::time::Instant::now())
|
||||
}
|
||||
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
|
||||
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
|
||||
fn save_hashed(
|
||||
input: &mut dyn Read,
|
||||
path: &Path,
|
||||
max_size: u64,
|
||||
progress: &mut dyn FnMut(u64),
|
||||
) -> io::Result<String> {
|
||||
let mut out = fs::File::create(path)?;
|
||||
let mut hasher = Sha1::new();
|
||||
let mut buffer = [0u8; 8196];
|
||||
@@ -248,6 +288,7 @@ fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<S
|
||||
break;
|
||||
}
|
||||
read_so_far += n as u64;
|
||||
progress(read_so_far);
|
||||
if read_so_far > max_size {
|
||||
return Err(io::Error::other(format!(
|
||||
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
|
||||
@@ -366,7 +407,14 @@ mod tests {
|
||||
url,
|
||||
sha1: Some(sha1(&body)),
|
||||
};
|
||||
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
|
||||
let path = download_file(
|
||||
&dir,
|
||||
&request,
|
||||
&headers("Alex", 0xab),
|
||||
MAX_SIZE,
|
||||
&channel().0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, dir.join(sha1(&body)));
|
||||
assert_eq!(fs::read(&path).unwrap(), body);
|
||||
let sent = server.join().unwrap();
|
||||
@@ -377,7 +425,10 @@ mod tests {
|
||||
url: "http://127.0.0.1:9/gone.zip".into(),
|
||||
..request
|
||||
};
|
||||
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
|
||||
assert_eq!(
|
||||
download_file(&dir, &cached, &[], MAX_SIZE, &channel().0).unwrap(),
|
||||
path
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -390,7 +441,7 @@ mod tests {
|
||||
url,
|
||||
sha1: Some("0".repeat(40)),
|
||||
};
|
||||
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
|
||||
let err = download_file(&dir, &wrong, &[], MAX_SIZE, &channel().0).unwrap_err();
|
||||
assert!(err.contains("did not match requested"), "{err}");
|
||||
server.join().unwrap();
|
||||
let dead = Request {
|
||||
@@ -398,7 +449,7 @@ mod tests {
|
||||
url: "http://127.0.0.1:9/pack.zip".into(),
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
|
||||
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE, &channel().0).is_err());
|
||||
assert!(!dir.join("dead").exists());
|
||||
let (url, server) = serve("200 OK", vec![0; 100]);
|
||||
let large = Request {
|
||||
@@ -406,7 +457,7 @@ mod tests {
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
let err = download_file(&dir, &large, &[], 10).unwrap_err();
|
||||
let err = download_file(&dir, &large, &[], 10, &channel().0).unwrap_err();
|
||||
assert!(err.contains("bigger than maximum"), "{err}");
|
||||
let _ = server.join();
|
||||
let (url, server) = serve("404 Not Found", Vec::new());
|
||||
@@ -415,7 +466,7 @@ mod tests {
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
|
||||
assert!(download_file(&dir, &missing, &[], MAX_SIZE, &channel().0).is_err());
|
||||
server.join().unwrap();
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -425,6 +476,7 @@ mod tests {
|
||||
let dir = scratch();
|
||||
let (url, server) = serve("200 OK", b"zip".to_vec());
|
||||
let queue = Queue::open(dir.clone()).unwrap();
|
||||
let (progress, events) = channel();
|
||||
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
|
||||
queue.download(
|
||||
vec![Request {
|
||||
@@ -433,6 +485,7 @@ mod tests {
|
||||
sha1: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
progress,
|
||||
);
|
||||
let batch = loop {
|
||||
if let Some(b) = queue.finished().pop() {
|
||||
@@ -445,6 +498,16 @@ mod tests {
|
||||
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
|
||||
.join(sha1(b"zip"));
|
||||
assert_eq!(batch.downloaded, [(id, file)]);
|
||||
// HttpUtil.downloadFile's listener calls, in order.
|
||||
assert_eq!(
|
||||
events.iter().collect::<Vec<_>>(),
|
||||
[
|
||||
Progress::RequestStart,
|
||||
Progress::DownloadStart(Some(3)),
|
||||
Progress::Bytes(3),
|
||||
Progress::RequestFinished(true),
|
||||
]
|
||||
);
|
||||
let log = fs::read_to_string(dir.join("log.json")).unwrap();
|
||||
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
|
||||
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
|
||||
|
||||
@@ -504,7 +504,8 @@ impl Streaming {
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
let mut mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
@@ -637,7 +638,7 @@ impl Streaming {
|
||||
});
|
||||
self.shared.ready.notify_one();
|
||||
}
|
||||
fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) {
|
||||
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.decorating -= 1;
|
||||
self.decorated.insert((x, z));
|
||||
@@ -824,7 +825,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::Lit(x, z, sections, seq) => {
|
||||
@@ -1306,7 +1307,7 @@ impl Streaming {
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, region),
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Ok(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Ok(other) => self.deferred.push(other),
|
||||
@@ -1334,7 +1335,7 @@ impl Streaming {
|
||||
let started = std::time::Instant::now();
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
other => self.deferred.push(other),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
|
||||
//! and ClientboundSetEntityDataPacket's own stream codec.
|
||||
use crate::text::Text;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `TextDisplay.Align`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// `Display` and `TextDisplay`'s synced data (ids 8 to 27); the default is
|
||||
/// what their `defineSynchedData` starts with.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TextDisplayData {
|
||||
pub interpolation_delay: i32,
|
||||
pub interpolation_duration: i32,
|
||||
pub teleport_duration: i32,
|
||||
pub translation: [f32; 3],
|
||||
pub scale: [f32; 3],
|
||||
pub left_rotation: [f32; 4],
|
||||
pub right_rotation: [f32; 4],
|
||||
pub billboard: u8,
|
||||
pub brightness: i32,
|
||||
pub view_range: f32,
|
||||
pub shadow_radius: f32,
|
||||
pub shadow_strength: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub glow_color: i32,
|
||||
/// Shared: a frame's interpolated copy of the data must not copy it.
|
||||
#[serde(skip)]
|
||||
pub text: Arc<Text>,
|
||||
pub line_width: i32,
|
||||
pub background: i32,
|
||||
pub opacity: i8,
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
impl Default for TextDisplayData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interpolation_delay: 0,
|
||||
interpolation_duration: 0,
|
||||
teleport_duration: 0,
|
||||
translation: [0.0; 3],
|
||||
scale: [1.0; 3],
|
||||
left_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
right_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
billboard: 0,
|
||||
brightness: -1,
|
||||
view_range: 1.0,
|
||||
shadow_radius: 0.0,
|
||||
shadow_strength: 1.0,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
glow_color: -1,
|
||||
text: Arc::new(Text::empty()),
|
||||
line_width: 200,
|
||||
background: 0x4000_0000,
|
||||
opacity: -1,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextDisplayData {
|
||||
/// DisplayRenderer's billboard orientation followed by Transformation's T*L*S*R.
|
||||
pub fn matrix(&self, entity: [f32; 2], camera: [f32; 2]) -> glam::Mat4 {
|
||||
let [yaw, pitch] = match self.billboard {
|
||||
1 => [camera[0] - 180.0, entity[1]],
|
||||
2 => [entity[0], -camera[1]],
|
||||
3 => [camera[0] - 180.0, -camera[1]],
|
||||
_ => entity,
|
||||
};
|
||||
let orientation = glam::Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
-yaw * 0.017_453_292,
|
||||
pitch * 0.017_453_292,
|
||||
0.0,
|
||||
);
|
||||
glam::Mat4::from_quat(orientation)
|
||||
* glam::Mat4::from_translation(self.translation.into())
|
||||
* glam::Mat4::from_quat(glam::Quat::from_array(self.left_rotation))
|
||||
* glam::Mat4::from_scale(self.scale.into())
|
||||
* glam::Mat4::from_quat(glam::Quat::from_array(self.right_rotation))
|
||||
}
|
||||
|
||||
pub fn blend(&self, from: &Self, t: f32) -> Self {
|
||||
let mut out = self.clone();
|
||||
for i in 0..3 {
|
||||
out.translation[i] =
|
||||
from.translation[i] + t * (self.translation[i] - from.translation[i]);
|
||||
out.scale[i] = from.scale[i] + t * (self.scale[i] - from.scale[i]);
|
||||
}
|
||||
out.left_rotation = glam::Quat::from_array(from.left_rotation)
|
||||
.slerp(glam::Quat::from_array(self.left_rotation), t)
|
||||
.to_array();
|
||||
out.right_rotation = glam::Quat::from_array(from.right_rotation)
|
||||
.slerp(glam::Quat::from_array(self.right_rotation), t)
|
||||
.to_array();
|
||||
let int = |a: i32, b: i32| a + (t * (b as f32 - a as f32)).floor() as i32;
|
||||
out.opacity = int(i32::from(from.opacity), i32::from(self.opacity)) as i8;
|
||||
out.background = (0..4).fold(0, |color, channel| {
|
||||
color
|
||||
| (int(
|
||||
(from.background as u32 >> (channel * 8) & 255) as i32,
|
||||
(self.background as u32 >> (channel * 8) & 255) as i32,
|
||||
) << (channel * 8))
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Omitted fields retain their previous values.
|
||||
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
|
||||
for (id, _, value) in values {
|
||||
match (id, value) {
|
||||
(8, Value::Int(v)) => self.interpolation_delay = *v,
|
||||
(9, Value::Int(v)) => self.interpolation_duration = *v,
|
||||
(10, Value::Int(v)) => self.teleport_duration = *v,
|
||||
(11, Value::Floats(v)) if v.len() == 3 => self.translation.copy_from_slice(v),
|
||||
(12, Value::Floats(v)) if v.len() == 3 => self.scale.copy_from_slice(v),
|
||||
(13, Value::Floats(v)) if v.len() == 4 => self.left_rotation.copy_from_slice(v),
|
||||
(14, Value::Floats(v)) if v.len() == 4 => self.right_rotation.copy_from_slice(v),
|
||||
(15, Value::Byte(v)) => self.billboard = *v as u8,
|
||||
(16, Value::Int(v)) => self.brightness = *v,
|
||||
(17, Value::Float(v)) => self.view_range = *v,
|
||||
(18, Value::Float(v)) => self.shadow_radius = *v,
|
||||
(19, Value::Float(v)) => self.shadow_strength = *v,
|
||||
(20, Value::Float(v)) => self.width = *v,
|
||||
(21, Value::Float(v)) => self.height = *v,
|
||||
(22, Value::Int(v)) => self.glow_color = *v,
|
||||
(23, Value::Text(v)) => self.text = Arc::new(crate::entity_nbt::name_text(v)),
|
||||
(24, Value::Int(v)) => self.line_width = *v,
|
||||
(25, Value::Int(v)) => self.background = *v,
|
||||
(26, Value::Byte(v)) => self.opacity = *v,
|
||||
(27, Value::Byte(v)) => self.flags = *v as u8,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TextDisplay.getAlign`: the left flag wins over the right one.
|
||||
pub fn align(&self) -> Align {
|
||||
if self.flags & 8 != 0 {
|
||||
Align::Left
|
||||
} else if self.flags & 16 != 0 {
|
||||
Align::Right
|
||||
} else {
|
||||
Align::Center
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn partial_updates_retain_values_and_bad_vectors_do_not_panic() {
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&[(24, 1, Value::Int(80)), (27, 0, Value::Byte(24))]);
|
||||
d.apply(&[(11, 0, Value::Floats(vec![1.0]))]);
|
||||
assert_eq!(d.line_width, 80);
|
||||
assert_eq!(d.align(), Align::Left);
|
||||
assert_eq!(d.translation, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -1150,7 +1150,7 @@ impl Title {
|
||||
+ 30.0;
|
||||
// Toast.xPos: in from the right edge by the visible portion.
|
||||
let x = w - width * slide.portion;
|
||||
toast_background(atlas, batch, x, width);
|
||||
crate::toasts::background(atlas, batch, x, 0.0, width);
|
||||
batch.text(atlas, &title, x + 18.0, 7.0, rgba(255, 255, 0, 255), false);
|
||||
batch.text(atlas, &message, x + 18.0, 19.0, u32::MAX, false);
|
||||
}
|
||||
@@ -1553,36 +1553,6 @@ impl Title {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
|
||||
fn toast_background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, width: f32) {
|
||||
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
|
||||
return;
|
||||
};
|
||||
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
|
||||
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
|
||||
for col in 0..3 {
|
||||
for (sy, sh, y, h) in [
|
||||
(0.0, 28.0, 0.0, 28.0),
|
||||
(rect.h as f32 - 4.0, 4.0, 28.0, 4.0),
|
||||
] {
|
||||
let mut piece = rect;
|
||||
let du = (rect.u1 - rect.u0) / rect.w as f32;
|
||||
let dv = (rect.v1 - rect.v0) / rect.h as f32;
|
||||
piece.u0 = rect.u0 + source_x[col] * du;
|
||||
piece.u1 = rect.u0 + source_x[col + 1] * du;
|
||||
piece.v0 = rect.v0 + sy * dv;
|
||||
piece.v1 = rect.v0 + (sy + sh) * dv;
|
||||
batch.rect_uv(
|
||||
target_x[col],
|
||||
y,
|
||||
target_x[col + 1] - target_x[col],
|
||||
h,
|
||||
piece,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
|
||||
batch.text(
|
||||
atlas,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `ToastManager`'s slide, shared by the game's toasts and the title
|
||||
//! screens' system toasts.
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use std::time::Instant;
|
||||
|
||||
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
|
||||
@@ -56,3 +57,104 @@ impl Slide {
|
||||
self.update(origin.elapsed().as_millis() as i64, wants_show)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SystemToast`: a yellow title over a white message (or the title alone,
|
||||
/// lower down) on the system background. It stays until its display time
|
||||
/// has passed since its last change, or until it is hidden.
|
||||
pub struct SystemToast {
|
||||
pub slide: Slide,
|
||||
title: String,
|
||||
message: Option<String>,
|
||||
/// `changed`: the next update takes its time as `lastChanged`.
|
||||
changed: bool,
|
||||
last_changed: i64,
|
||||
force_hide: bool,
|
||||
}
|
||||
impl SystemToast {
|
||||
/// `SystemToastId.displayTime`'s default, in milliseconds.
|
||||
const DISPLAY_TIME: f64 = 5000.0;
|
||||
pub fn new(title: String, message: Option<String>) -> Self {
|
||||
Self {
|
||||
slide: Slide::default(),
|
||||
title,
|
||||
message,
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
force_hide: false,
|
||||
}
|
||||
}
|
||||
/// `reset`: new text, and the display time starts again.
|
||||
pub fn reset(&mut self, title: String, message: Option<String>) {
|
||||
self.title = title;
|
||||
self.message = message;
|
||||
self.changed = true;
|
||||
}
|
||||
pub fn force_hide(&mut self) {
|
||||
self.force_hide = true;
|
||||
}
|
||||
/// `ToastInstance.update` with `SystemToast.update`, the display time
|
||||
/// scaled by the Notification Time option: the visibility it switched
|
||||
/// to (the sound to play), and whether it has slid out.
|
||||
pub fn update(&mut self, multiplier: f64) -> (Option<bool>, bool) {
|
||||
self.slide.update_now(|fully| {
|
||||
if std::mem::take(&mut self.changed) {
|
||||
self.last_changed = fully;
|
||||
}
|
||||
!self.force_hide
|
||||
&& ((fully - self.last_changed) as f64) < Self::DISPLAY_TIME * multiplier
|
||||
})
|
||||
}
|
||||
/// `extractRenderState` at `y`, in from the right edge by the visible
|
||||
/// portion (`Toast.xPos`).
|
||||
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, y: f32) {
|
||||
let width = std::iter::once(&self.title)
|
||||
.chain(&self.message)
|
||||
.map(|line| HudBatch::text_width(atlas, line))
|
||||
.fold(0.0, f32::max)
|
||||
.ceil()
|
||||
.max(160.0)
|
||||
+ 30.0;
|
||||
let x = batch.width - width * self.slide.portion;
|
||||
background(atlas, batch, x, y, width);
|
||||
let yellow = crate::hud::rgba(255, 255, 0, 255);
|
||||
match &self.message {
|
||||
Some(message) => {
|
||||
batch.text(atlas, &self.title, x + 18.0, y + 7.0, yellow, false);
|
||||
batch.text(atlas, message, x + 18.0, y + 19.0, u32::MAX, false);
|
||||
}
|
||||
None => {
|
||||
batch.text(atlas, &self.title, x + 18.0, y + 12.0, yellow, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
|
||||
pub fn background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, y: f32, width: f32) {
|
||||
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
|
||||
return;
|
||||
};
|
||||
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
|
||||
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
|
||||
for col in 0..3 {
|
||||
for (sy, sh, y, h) in [
|
||||
(0.0, 28.0, y, 28.0),
|
||||
(rect.h as f32 - 4.0, 4.0, y + 28.0, 4.0),
|
||||
] {
|
||||
let mut piece = rect;
|
||||
let du = (rect.u1 - rect.u0) / rect.w as f32;
|
||||
let dv = (rect.v1 - rect.v0) / rect.h as f32;
|
||||
piece.u0 = rect.u0 + source_x[col] * du;
|
||||
piece.u1 = rect.u0 + source_x[col + 1] * du;
|
||||
piece.v0 = rect.v0 + sy * dv;
|
||||
piece.v1 = rect.v0 + (sy + sh) * dv;
|
||||
batch.rect_uv(
|
||||
target_x[col],
|
||||
y,
|
||||
target_x[col + 1] - target_x[col],
|
||||
h,
|
||||
piece,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,9 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
let fresh = mesher.mesh(&borrow(®ion), sy);
|
||||
// The workers sort solid quads by side, as uploads expect.
|
||||
let mut fresh = mesher.mesh(&borrow(®ion), sy);
|
||||
fresh.sort_solid();
|
||||
let bytes = |m: &SectionMesh| {
|
||||
[
|
||||
bytemuck::cast_slice::<_, u8>(&m.solid).to_vec(),
|
||||
|
||||
Generated
+1352
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "horizon-mod-registry"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.90"
|
||||
publish = false
|
||||
description = "The registry's submission service: GitHub sign-in, the file's checks, staging, and a pull request (docs/api/mod-index.md)"
|
||||
|
||||
# Its own workspace, as the telemetry server's: the game's workspace never builds a web server.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "io-util"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
# GitHub's API, blocking, on tokio's blocking pool: the launcher's HTTP client.
|
||||
ureq = { version = "2", default-features = false, features = ["json", "tls"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,14 @@
|
||||
# Built from the repository root: docker build -f crates/horizon-mod-registry/Dockerfile .
|
||||
FROM rust:1-slim AS build
|
||||
WORKDIR /src
|
||||
# The root manifest only for the shared crate's workspace settings (edition, lints).
|
||||
COPY Cargo.toml Cargo.toml
|
||||
COPY crates/horizon-mods crates/horizon-mods
|
||||
COPY crates/horizon-mod-registry crates/horizon-mod-registry
|
||||
RUN cargo build --release --locked --manifest-path crates/horizon-mod-registry/Cargo.toml
|
||||
|
||||
FROM debian:stable-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/crates/horizon-mod-registry/target/release/horizon-mod-registry /usr/local/bin/
|
||||
USER nobody
|
||||
CMD ["horizon-mod-registry"]
|
||||
@@ -0,0 +1,44 @@
|
||||
# horizon-mod-registry
|
||||
|
||||
The service behind the registry's submission form (`playhorizon.dev/mods/submit`). A creator signs in with GitHub and
|
||||
sends a file with its page; the service runs the file's own checks (`horizon_mods::index::inspect`, the signature, the
|
||||
entry's fields), uploads it to the `staging` release of
|
||||
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) as `<sha256>.<extension>`, and opens a pull
|
||||
request adding or updating `mods/<id>.toml`. That repository's CI checks it again, with a trial run of a mod in the
|
||||
sandbox; reviewers decide, and merging publishes. Formats and rules: [docs/api/mod-index.md](../../docs/api/mod-index.md).
|
||||
|
||||
It keeps no data: a session is a signed cookie holding the GitHub login, and the user's OAuth token is used once, to
|
||||
read that login, and dropped. The sign-in asks for no scope.
|
||||
|
||||
| Route | |
|
||||
| --- | --- |
|
||||
| `GET /api/mods/auth/login` | Starts the GitHub sign-in |
|
||||
| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` |
|
||||
| `POST /api/mods/auth/logout` | Ends it |
|
||||
| `GET /api/mods/me` | `{login, csrf}`, or 401 |
|
||||
| `POST /api/mods/submit` | Multipart: `entry` (JSON: `id kind name summary description authors license source icon key signature version game changelog`) and `file`; `{pull_request}` or `{error}` |
|
||||
| `GET /healthz` | `ok` |
|
||||
|
||||
A submission must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six per account and sixty
|
||||
in all per hour. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else
|
||||
is a pull request by hand.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cargo test # against a mock GitHub (tests/submit.rs)
|
||||
REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... \
|
||||
GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test cargo run
|
||||
```
|
||||
|
||||
| Variable | |
|
||||
| --- | --- |
|
||||
| `REGISTRY_PUBLIC_URL` | The site, `https://playhorizon.dev`: the OAuth callback is `<it>/api/mods/auth/callback` |
|
||||
| `REGISTRY_SESSION_KEY` | 32 characters or more, random: signs sessions |
|
||||
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app |
|
||||
| `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write |
|
||||
| `REGISTRY_REPO` | Default `ludovic111/horizon-mods` |
|
||||
| `PORT` | Default 8080 |
|
||||
|
||||
On Railway it is its own service (`railway.json`, built with the `Dockerfile` from the repository root); the website's
|
||||
Caddy sends `/api/mods/*` to it over Railway's private network.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://railway.com/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "crates/horizon-mod-registry/Dockerfile",
|
||||
"watchPatterns": [
|
||||
"/crates/horizon-mods/**",
|
||||
"/crates/horizon-mod-registry/**",
|
||||
"/Cargo.toml"
|
||||
]
|
||||
},
|
||||
"deploy": {
|
||||
"healthcheckPath": "/healthz",
|
||||
"healthcheckTimeout": 60,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 5,
|
||||
"multiRegionConfig": {
|
||||
"europe-west4-drams3a": { "numReplicas": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! What the service asks of GitHub: the sign-in (OAuth, no scope: only the
|
||||
//! public profile), and with the bot's token on the registry repository
|
||||
//! alone, an entry, the `staging` release, a branch, a file, a pull request.
|
||||
//! Blocking (ureq): called from tokio's blocking pool.
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use horizon_mods::index::registry::Entry;
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
/// Where GitHub is: the real one, or a test's mock.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Endpoints {
|
||||
/// `https://github.com`: OAuth.
|
||||
pub web: String,
|
||||
/// `https://api.github.com`.
|
||||
pub api: String,
|
||||
/// `https://uploads.github.com`: release assets.
|
||||
pub uploads: String,
|
||||
}
|
||||
|
||||
impl Default for Endpoints {
|
||||
fn default() -> Self {
|
||||
Endpoints {
|
||||
web: "https://github.com".into(),
|
||||
api: "https://api.github.com".into(),
|
||||
uploads: "https://uploads.github.com".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GitHub {
|
||||
pub at: Endpoints,
|
||||
/// `owner/name` of the registry repository.
|
||||
pub repo: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// Fine-grained, this repository only: contents and pull requests.
|
||||
pub bot_token: String,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
/// A failed call: `status` is GitHub's, 0 when it wasn't reached.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub status: u16,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, Failure>;
|
||||
|
||||
fn failure(e: ureq::Error) -> Failure {
|
||||
match e {
|
||||
ureq::Error::Status(status, response) => Failure {
|
||||
status,
|
||||
message: response
|
||||
.into_string()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect(),
|
||||
},
|
||||
other => Failure {
|
||||
status: 0,
|
||||
message: other.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn read(response: ureq::Response) -> Result<Value> {
|
||||
response.into_json().map_err(|e| Failure {
|
||||
status: 0,
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl GitHub {
|
||||
pub fn new(
|
||||
at: Endpoints,
|
||||
repo: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
bot_token: String,
|
||||
) -> GitHub {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.user_agent("horizon-mod-registry")
|
||||
.build();
|
||||
GitHub {
|
||||
at,
|
||||
repo,
|
||||
client_id,
|
||||
client_secret,
|
||||
bot_token,
|
||||
agent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the sign-in starts.
|
||||
pub fn authorize_url(&self, redirect: &str, state: &str) -> String {
|
||||
format!(
|
||||
"{}/login/oauth/authorize?client_id={}&redirect_uri={}&state={state}&allow_signup=true",
|
||||
self.at.web,
|
||||
self.client_id,
|
||||
encode(redirect)
|
||||
)
|
||||
}
|
||||
|
||||
/// The signed-in account (login, id) for an OAuth `code`. Its token is
|
||||
/// used once and dropped.
|
||||
pub fn sign_in(&self, code: &str, redirect: &str) -> Result<(String, u64)> {
|
||||
let token = self
|
||||
.agent
|
||||
.post(&format!("{}/login/oauth/access_token", self.at.web))
|
||||
.set("Accept", "application/json")
|
||||
.send_json(json!({
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect,
|
||||
}))
|
||||
.map_err(failure)
|
||||
.and_then(read)?;
|
||||
let Some(token) = token["access_token"].as_str() else {
|
||||
return Err(Failure {
|
||||
status: 401,
|
||||
message: "GitHub refused the sign-in".into(),
|
||||
});
|
||||
};
|
||||
let user = self
|
||||
.agent
|
||||
.get(&format!("{}/user", self.at.api))
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.set("Accept", "application/vnd.github+json")
|
||||
.call()
|
||||
.map_err(failure)
|
||||
.and_then(read)?;
|
||||
match (user["login"].as_str(), user["id"].as_u64()) {
|
||||
(Some(login), Some(id)) => Ok((login.to_owned(), id)),
|
||||
_ => Err(Failure {
|
||||
status: 0,
|
||||
message: "GitHub sent no login".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&self, method: &str, path: &str) -> ureq::Request {
|
||||
self.agent
|
||||
.request(
|
||||
method,
|
||||
&format!("{}/repos/{}{path}", self.at.api, self.repo),
|
||||
)
|
||||
.set("Authorization", &format!("Bearer {}", self.bot_token))
|
||||
.set("Accept", "application/vnd.github+json")
|
||||
.set("X-GitHub-Api-Version", "2022-11-28")
|
||||
}
|
||||
|
||||
/// `mods/<id>.toml` on main, with its blob sha; None when it isn't there.
|
||||
pub fn entry(&self, id: &str) -> Result<Option<(Entry, String)>> {
|
||||
let response = self
|
||||
.call("GET", &format!("/contents/mods/{id}.toml"))
|
||||
.query("ref", "main")
|
||||
.call();
|
||||
let file = match response {
|
||||
Ok(r) => read(r)?,
|
||||
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||
Err(e) => return Err(failure(e)),
|
||||
};
|
||||
let bytes = file["content"]
|
||||
.as_str()
|
||||
.map(|c| c.replace('\n', ""))
|
||||
.and_then(|c| STANDARD.decode(c).ok())
|
||||
.unwrap_or_default();
|
||||
let entry = toml::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| Failure {
|
||||
status: 0,
|
||||
message: format!("mods/{id}.toml on main: {e}"),
|
||||
})?;
|
||||
Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned())))
|
||||
}
|
||||
|
||||
/// Uploads `path` as asset `name` of the `staging` prerelease, unless it
|
||||
/// is already there (the same file submitted again).
|
||||
pub fn stage(&self, name: &str, path: &Path) -> Result<()> {
|
||||
let release = self
|
||||
.call("GET", "/releases/tags/staging")
|
||||
.call()
|
||||
.map_err(failure)
|
||||
.and_then(read)?;
|
||||
let there = release["assets"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().any(|x| x["name"] == name));
|
||||
if there {
|
||||
return Ok(());
|
||||
}
|
||||
let id = release["id"].as_u64().unwrap_or(0);
|
||||
let bytes = std::fs::read(path).map_err(|e| Failure {
|
||||
status: 0,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
self.agent
|
||||
.post(&format!(
|
||||
"{}/repos/{}/releases/{id}/assets",
|
||||
self.at.uploads, self.repo
|
||||
))
|
||||
.query("name", name)
|
||||
.set("Authorization", &format!("Bearer {}", self.bot_token))
|
||||
.set("Accept", "application/vnd.github+json")
|
||||
.set("Content-Type", "application/octet-stream")
|
||||
.send_bytes(&bytes)
|
||||
.map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes branch `name` from main; false when it already exists.
|
||||
pub fn branch(&self, name: &str) -> Result<bool> {
|
||||
let main = self
|
||||
.call("GET", "/git/ref/heads/main")
|
||||
.call()
|
||||
.map_err(failure)
|
||||
.and_then(read)?;
|
||||
let sha = main["object"]["sha"].as_str().unwrap_or("");
|
||||
match self
|
||||
.call("POST", "/git/refs")
|
||||
.send_json(json!({"ref": format!("refs/heads/{name}"), "sha": sha}))
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(ureq::Error::Status(422, _)) => Ok(false),
|
||||
Err(e) => Err(failure(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `path` on `branch` in one commit; `sha` is the blob it replaces.
|
||||
pub fn put(
|
||||
&self,
|
||||
path: &str,
|
||||
text: &str,
|
||||
branch: &str,
|
||||
sha: Option<&str>,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let mut body = json!({
|
||||
"message": message,
|
||||
"content": STANDARD.encode(text),
|
||||
"branch": branch,
|
||||
});
|
||||
if let Some(sha) = sha {
|
||||
body["sha"] = json!(sha);
|
||||
}
|
||||
self.call("PUT", &format!("/contents/{path}"))
|
||||
.send_json(body)
|
||||
.map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the pull request; its address.
|
||||
pub fn pull(&self, title: &str, head: &str, body: &str) -> Result<String> {
|
||||
let pr = self
|
||||
.call("POST", "/pulls")
|
||||
.send_json(json!({"title": title, "head": head, "base": "main", "body": body}))
|
||||
.map_err(failure)
|
||||
.and_then(read)?;
|
||||
Ok(pr["html_url"].as_str().unwrap_or("").to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encodes a query value.
|
||||
fn encode(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
//! `horizon-mod-registry`: the submission form's service at
|
||||
//! `playhorizon.dev/api/mods/*` (docs/api/mod-index.md). A creator signs in
|
||||
//! with GitHub, sends a file and its page; the service runs the file's own
|
||||
//! checks, uploads it to the registry's `staging` release and opens a pull
|
||||
//! request with the entry. Reviewers decide; merging publishes. It keeps no
|
||||
//! data: sessions are signed cookies, and GitHub holds everything else.
|
||||
pub mod github;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Multipart, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use github::GitHub;
|
||||
use hmac::{Hmac, Mac};
|
||||
use horizon_mods::index::{
|
||||
self, Kind,
|
||||
registry::{self, Entry, EntryVersion, Sources},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// How long a sign-in lasts.
|
||||
const SESSION: u64 = 12 * 3600;
|
||||
/// Submissions per account per hour, and from everyone.
|
||||
const PER_ACCOUNT: usize = 6;
|
||||
const PER_HOUR: usize = 60;
|
||||
/// The page's JSON.
|
||||
const MAX_FORM: usize = 64 << 10;
|
||||
|
||||
pub struct Config {
|
||||
/// `https://playhorizon.dev`: where the form is, and the only origin it
|
||||
/// takes submissions from.
|
||||
pub public_url: String,
|
||||
/// At least 32 bytes: signs sessions.
|
||||
pub session_key: Vec<u8>,
|
||||
pub github: GitHub,
|
||||
/// Where files wait during a submission.
|
||||
pub scratch: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`,
|
||||
/// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`.
|
||||
pub fn from_env() -> Result<Config, String> {
|
||||
let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set"));
|
||||
let key = var("REGISTRY_SESSION_KEY")?;
|
||||
if key.len() < 32 {
|
||||
return Err("REGISTRY_SESSION_KEY needs at least 32 characters".into());
|
||||
}
|
||||
Ok(Config {
|
||||
public_url: var("REGISTRY_PUBLIC_URL")?.trim_end_matches('/').to_owned(),
|
||||
session_key: key.into_bytes(),
|
||||
github: GitHub::new(
|
||||
github::Endpoints::default(),
|
||||
std::env::var("REGISTRY_REPO").unwrap_or_else(|_| "ludovic111/horizon-mods".into()),
|
||||
var("GITHUB_CLIENT_ID")?,
|
||||
var("GITHUB_CLIENT_SECRET")?,
|
||||
var("GITHUB_BOT_TOKEN")?,
|
||||
),
|
||||
scratch: std::env::temp_dir().join("horizon-mod-registry"),
|
||||
})
|
||||
}
|
||||
|
||||
fn callback(&self) -> String {
|
||||
format!("{}/api/mods/auth/callback", self.public_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct App(Arc<Inner>);
|
||||
|
||||
struct Inner {
|
||||
config: Config,
|
||||
recent: Mutex<HashMap<String, VecDeque<Instant>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(config: Config) -> App {
|
||||
App(Arc::new(Inner {
|
||||
config,
|
||||
recent: Mutex::new(HashMap::new()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(app: App) -> Router {
|
||||
Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/api/mods/auth/login", get(login))
|
||||
.route("/api/mods/auth/callback", get(callback))
|
||||
.route("/api/mods/auth/logout", post(logout))
|
||||
.route("/api/mods/me", get(me))
|
||||
.route(
|
||||
"/api/mods/submit",
|
||||
post(submit).layer(DefaultBodyLimit::max(
|
||||
index::MAX_FILE as usize + MAX_FORM * 2,
|
||||
)),
|
||||
)
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
/// An error players read in the form.
|
||||
#[derive(Debug)]
|
||||
pub struct Refusal(StatusCode, String);
|
||||
|
||||
impl Refusal {
|
||||
fn new(status: StatusCode, message: impl Into<String>) -> Refusal {
|
||||
Refusal(status, message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Refusal {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(message: impl Into<String>) -> Refusal {
|
||||
Refusal::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
fn github_down(e: github::Failure) -> Refusal {
|
||||
eprintln!("horizon-mod-registry: GitHub {}: {}", e.status, e.message);
|
||||
Refusal::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"GitHub didn't answer as expected: try again in a minute",
|
||||
)
|
||||
}
|
||||
|
||||
// Signed values: `<payload>.<HMAC-SHA256>`, both base64url.
|
||||
|
||||
fn sign(key: &[u8], payload: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("any key length");
|
||||
mac.update(payload.as_bytes());
|
||||
format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(payload),
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn unsign(key: &[u8], value: &str) -> Option<String> {
|
||||
let (payload, tag) = value.split_once('.')?;
|
||||
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
mac.verify_slice(&URL_SAFE_NO_PAD.decode(tag).ok()?).ok()?;
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn random_hex() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::fill(&mut bytes).expect("the system's random numbers");
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|c| c.trim().split_once('='))
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.to_owned())
|
||||
}
|
||||
|
||||
fn set_cookie(name: &str, value: &str, max_age: u64) -> String {
|
||||
format!("{name}={value}; Path=/api/mods; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}")
|
||||
}
|
||||
|
||||
/// The signed-in account: (login, id).
|
||||
fn session(app: &Inner, headers: &HeaderMap) -> Option<(String, u64)> {
|
||||
let payload = unsign(&app.config.session_key, &cookie(headers, "hm_session")?)?;
|
||||
let mut parts = payload.split('\n');
|
||||
let (login, id, until) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
(until.parse::<u64>().ok()? > now()).then(|| (login.to_owned(), id.parse().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// The form sends this with each submission: a page on another site can't
|
||||
/// read it, so it can't submit in a signed-in creator's name.
|
||||
fn csrf(app: &Inner, headers: &HeaderMap) -> Option<String> {
|
||||
let session = cookie(headers, "hm_session")?;
|
||||
let signed = sign(&app.config.session_key, &format!("csrf\n{session}"));
|
||||
Some(signed.rsplit('.').next()?.chars().take(32).collect())
|
||||
}
|
||||
|
||||
async fn login(State(app): State<App>) -> Response {
|
||||
let state = random_hex();
|
||||
let signed = sign(&app.0.config.session_key, &state);
|
||||
let url = app
|
||||
.0
|
||||
.config
|
||||
.github
|
||||
.authorize_url(&app.0.config.callback(), &state);
|
||||
(
|
||||
[(header::SET_COOKIE, set_cookie("hm_state", &signed, 600))],
|
||||
Redirect::to(&url),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Callback {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
async fn callback(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<Callback>,
|
||||
) -> Result<Response, Refusal> {
|
||||
let expected = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v));
|
||||
if expected.as_deref() != Some(q.state.as_str()) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"the sign-in expired or didn't start here: try again",
|
||||
));
|
||||
}
|
||||
let inner = app.0.clone();
|
||||
let (login, id) = tokio::task::spawn_blocking(move || {
|
||||
inner
|
||||
.config
|
||||
.github
|
||||
.sign_in(&q.code, &inner.config.callback())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, "sign-in failed"))?
|
||||
.map_err(github_down)?;
|
||||
let value = sign(
|
||||
&app.0.config.session_key,
|
||||
&format!("{login}\n{id}\n{}", now() + SESSION),
|
||||
);
|
||||
let back = format!("{}/mods/submit", app.0.config.public_url);
|
||||
let mut response = Redirect::to(&back).into_response();
|
||||
let headers = response.headers_mut();
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
set_cookie("hm_session", &value, SESSION).parse().unwrap(),
|
||||
);
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
set_cookie("hm_state", "", 0).parse().unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn logout() -> Response {
|
||||
(
|
||||
[(header::SET_COOKIE, set_cookie("hm_session", "", 0))],
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn me(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, Refusal> {
|
||||
let (login, _) = session(&app.0, &headers)
|
||||
.ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "not signed in"))?;
|
||||
Ok(Json(
|
||||
json!({"login": login, "csrf": csrf(&app.0, &headers)}),
|
||||
))
|
||||
}
|
||||
|
||||
/// The page a submission fills in: a project's fields, and the version's.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Form {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub license: String,
|
||||
pub source: String,
|
||||
/// `https://` only; an icon in the repository is a pull request by hand.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// A new project's minisign public key.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The `.minisig` text.
|
||||
#[serde(default)]
|
||||
pub signature: Option<String>,
|
||||
pub version: String,
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub changelog: String,
|
||||
}
|
||||
|
||||
fn take(window: &mut VecDeque<Instant>, max: usize) -> bool {
|
||||
while window
|
||||
.front()
|
||||
.is_some_and(|t| t.elapsed() > Duration::from_secs(3600))
|
||||
{
|
||||
window.pop_front();
|
||||
}
|
||||
window.len() < max
|
||||
}
|
||||
|
||||
async fn submit(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, Refusal> {
|
||||
let inner = app.0.clone();
|
||||
let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
|
||||
if origin != Some(inner.config.public_url.as_str()) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"submit from the registry's form",
|
||||
));
|
||||
}
|
||||
let (login, user_id) = session(&inner, &headers)
|
||||
.ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?;
|
||||
let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok());
|
||||
if sent.is_none() || sent.map(str::to_owned) != csrf(&inner, &headers) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"reload the form and try again",
|
||||
));
|
||||
}
|
||||
{
|
||||
let mut recent = inner.recent.lock().unwrap();
|
||||
let ok = take(recent.entry(String::new()).or_default(), PER_HOUR)
|
||||
&& take(recent.entry(login.to_lowercase()).or_default(), PER_ACCOUNT);
|
||||
if !ok {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"too many submissions this hour: try again later",
|
||||
));
|
||||
}
|
||||
let at = Instant::now();
|
||||
recent.entry(String::new()).or_default().push_back(at);
|
||||
recent
|
||||
.entry(login.to_lowercase())
|
||||
.or_default()
|
||||
.push_back(at);
|
||||
}
|
||||
let dir = inner.config.scratch.join(random_hex());
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let result = receive(&mut multipart, &dir).await;
|
||||
let result = match result {
|
||||
Ok((form, file)) => {
|
||||
let inner = inner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
publish(&inner.config, &login, user_id, &form, &file)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(Refusal::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the check crashed",
|
||||
))
|
||||
})
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result.map(|url| Json(json!({"pull_request": url})))
|
||||
}
|
||||
|
||||
/// Reads the form's two parts: `entry` (JSON) and `file`, streamed to `dir`.
|
||||
async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf), Refusal> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let (mut form, mut file) = (None, None);
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| bad(e.body_text()))?
|
||||
{
|
||||
match field.name() {
|
||||
Some("entry") => {
|
||||
let text = field.text().await.map_err(|e| bad(e.body_text()))?;
|
||||
if text.len() > MAX_FORM {
|
||||
return Err(bad("the page's text is too long"));
|
||||
}
|
||||
form = Some(serde_json::from_str::<Form>(&text).map_err(|e| bad(e.to_string()))?);
|
||||
}
|
||||
Some("file") => {
|
||||
let path = dir.join("upload");
|
||||
let mut out = tokio::fs::File::create(&path)
|
||||
.await
|
||||
.map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let mut size = 0u64;
|
||||
while let Some(chunk) = field.chunk().await.map_err(|e| bad(e.body_text()))? {
|
||||
size += chunk.len() as u64;
|
||||
if size > index::MAX_FILE {
|
||||
return Err(bad(format!(
|
||||
"the file is over {} MiB",
|
||||
index::MAX_FILE >> 20
|
||||
)));
|
||||
}
|
||||
out.write_all(&chunk).await.map_err(|e| {
|
||||
Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
}
|
||||
out.flush().await.ok();
|
||||
file = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match (form, file) {
|
||||
(Some(form), Some(file)) => Ok((form, file)),
|
||||
_ => Err(bad("send the page and the file")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` of a Unix time (UTC).
|
||||
fn date(secs: u64) -> String {
|
||||
// Howard Hinnant's days-to-civil.
|
||||
let z = (secs / 86400) as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// The submission, past its checks: the entry written on a new branch and a
|
||||
/// pull request opened. Returns the pull request's address.
|
||||
pub fn publish(
|
||||
config: &Config,
|
||||
login: &str,
|
||||
user_id: u64,
|
||||
form: &Form,
|
||||
file: &Path,
|
||||
) -> Result<String, Refusal> {
|
||||
let gh = &config.github;
|
||||
let kind = Kind::from(form.kind.clone());
|
||||
registry::open(&kind).map_err(bad)?;
|
||||
if !index::valid_id(&form.id) {
|
||||
return Err(bad("an id is 1 to 64 of a-z, 0-9 and _"));
|
||||
}
|
||||
if form
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|i| !i.starts_with("https://"))
|
||||
{
|
||||
return Err(bad("the icon must be an https:// address"));
|
||||
}
|
||||
if !form.source.trim().starts_with("https://") {
|
||||
return Err(bad("the source must be an https:// address of the code"));
|
||||
}
|
||||
if form.name.trim().is_empty() || form.license.trim().is_empty() {
|
||||
return Err(bad("a project needs a name and a licence"));
|
||||
}
|
||||
// The file's own checks first: a wrong file never reaches GitHub.
|
||||
let facts = index::inspect(&kind, file).map_err(bad)?;
|
||||
if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) {
|
||||
return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id)));
|
||||
}
|
||||
if let Some(v) = facts
|
||||
.version
|
||||
.as_ref()
|
||||
.filter(|v| **v != form.version.trim())
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"the file says version {v:?}, not {:?}",
|
||||
form.version.trim()
|
||||
)));
|
||||
}
|
||||
let sha256 = index::sha256_file(file).map_err(bad)?;
|
||||
let extension = kind.extension().unwrap_or("zip");
|
||||
let version = EntryVersion {
|
||||
version: form.version.trim().to_owned(),
|
||||
published: date(now()),
|
||||
game: form.game.trim().to_owned(),
|
||||
file: format!("{}-{}.{extension}", form.id, form.version.trim()),
|
||||
sha256: sha256.clone(),
|
||||
signature: form.signature.clone().filter(|s| !s.trim().is_empty()),
|
||||
changelog: form.changelog.clone(),
|
||||
};
|
||||
let existing = gh.entry(&form.id).map_err(github_down)?;
|
||||
let is_new = existing.is_none();
|
||||
let (entry, blob) = match existing {
|
||||
None => (
|
||||
Entry {
|
||||
id: form.id.clone(),
|
||||
kind: kind.clone(),
|
||||
name: form.name.trim().to_owned(),
|
||||
summary: form.summary.trim().to_owned(),
|
||||
description: form.description.clone(),
|
||||
authors: form.authors.clone(),
|
||||
owners: vec![login.to_owned()],
|
||||
license: form.license.trim().to_owned(),
|
||||
source: form.source.trim().to_owned(),
|
||||
icon: form.icon.clone(),
|
||||
key: form.key.clone().filter(|k| !k.trim().is_empty()),
|
||||
versions: vec![version.clone()],
|
||||
},
|
||||
None,
|
||||
),
|
||||
Some((mut entry, blob)) => {
|
||||
if !entry.owners.iter().any(|o| o.eq_ignore_ascii_case(login)) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
format!(
|
||||
"{} is {}'s: only its owners publish its updates",
|
||||
form.id,
|
||||
entry.owners.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
if entry.kind != kind {
|
||||
return Err(bad(format!("{} is a {}", form.id, entry.kind.name())));
|
||||
}
|
||||
if entry.versions.iter().any(|v| v.version == version.version) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"{} {} is already published: give this file a new version",
|
||||
form.id, version.version
|
||||
),
|
||||
));
|
||||
}
|
||||
let key = form.key.clone().filter(|k| !k.trim().is_empty());
|
||||
if key.is_some() && key != entry.key {
|
||||
return Err(bad(
|
||||
"changing a project's key is a pull request by hand, reviewed on its own",
|
||||
));
|
||||
}
|
||||
entry.name = form.name.trim().to_owned();
|
||||
entry.summary = form.summary.trim().to_owned();
|
||||
entry.description = form.description.clone();
|
||||
entry.authors = form.authors.clone();
|
||||
entry.license = form.license.trim().to_owned();
|
||||
entry.source = form.source.trim().to_owned();
|
||||
if form.icon.is_some() {
|
||||
entry.icon = form.icon.clone();
|
||||
}
|
||||
entry.versions.insert(0, version.clone());
|
||||
(entry, Some(blob))
|
||||
}
|
||||
};
|
||||
// Every check CI runs but the trial run: the file as its kind, its id and
|
||||
// version, its signature, the entry's fields. CI runs it all again.
|
||||
let files = file.parent().unwrap_or(Path::new("."));
|
||||
std::fs::copy(file, files.join(&sha256)).map_err(|e| bad(e.to_string()))?;
|
||||
let mut checked = entry.clone();
|
||||
checked.versions.truncate(1);
|
||||
if checked
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|i| !i.starts_with("https://"))
|
||||
{
|
||||
checked.icon = None;
|
||||
}
|
||||
let project = registry::project(
|
||||
&checked,
|
||||
&Sources {
|
||||
registry: files,
|
||||
files,
|
||||
release: index::FILE_HOST,
|
||||
site: "https://example.invalid/",
|
||||
generated: String::new(),
|
||||
},
|
||||
)
|
||||
.map_err(|errors| bad(errors.join("; ")))?;
|
||||
let text = entry.to_toml().map_err(bad)?;
|
||||
gh.stage(&format!("{sha256}.{extension}"), file)
|
||||
.map_err(github_down)?;
|
||||
let branch = format!("submit/{}-{}", form.id, version.version);
|
||||
if !gh.branch(&branch).map_err(github_down)? {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"{} {} was already submitted: its pull request is open",
|
||||
form.id, version.version
|
||||
),
|
||||
));
|
||||
}
|
||||
let what = if is_new { "Add" } else { "Update" };
|
||||
let message = format!(
|
||||
"{what} {} {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
|
||||
form.id, version.version
|
||||
);
|
||||
gh.put(
|
||||
&format!("mods/{}.toml", form.id),
|
||||
&text,
|
||||
&branch,
|
||||
blob.as_deref(),
|
||||
&message,
|
||||
)
|
||||
.map_err(github_down)?;
|
||||
let v = &project.versions[0];
|
||||
let capabilities: Vec<String> = v
|
||||
.capabilities
|
||||
.iter()
|
||||
.map(|c| format!("- may {}", c.describe()))
|
||||
.collect();
|
||||
let requires: Vec<String> = v
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| format!("- needs {} {}", r.id, r.version))
|
||||
.collect();
|
||||
let body = format!(
|
||||
"<!-- submitted-by: {login} -->\nSubmitted by @{login} through [the form]({public}/mods/submit).\n\n\
|
||||
| | |\n|---|---|\n| Project | `{id}` ({kind}, {new}) |\n| Version | {version} for {game} |\n| File | `{file}`, {size} bytes, SHA-256 `{sha256}` |\n| Signed | {signed} |\n| Source | {source} |\n\n{lists}\n\n\
|
||||
The checks below run the file's own checks again and, for a mod, a trial run in the sandbox. \
|
||||
Reviewers: read the source and the checks' report before you approve.",
|
||||
public = config.public_url,
|
||||
id = form.id,
|
||||
kind = kind.name(),
|
||||
new = if is_new { "new" } else { "update" },
|
||||
version = v.version,
|
||||
game = v.game,
|
||||
file = v.file.name,
|
||||
size = v.file.size,
|
||||
signed = if entry.key.is_some() {
|
||||
"yes, with the project's key"
|
||||
} else {
|
||||
"no"
|
||||
},
|
||||
source = entry.source,
|
||||
lists = [capabilities, requires].concat().join("\n"),
|
||||
);
|
||||
gh.pull(
|
||||
&format!("{what} {} {}", form.id, version.version),
|
||||
&branch,
|
||||
&body,
|
||||
)
|
||||
.map_err(github_down)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dates_and_signatures() {
|
||||
assert_eq!(date(0), "1970-01-01");
|
||||
assert_eq!(date(1_792_800_000), "2026-10-24");
|
||||
let key = b"0123456789abcdef0123456789abcdef";
|
||||
let signed = sign(key, "alice\n7\n99");
|
||||
assert_eq!(unsign(key, &signed).as_deref(), Some("alice\n7\n99"));
|
||||
assert_eq!(unsign(b"another key, another key, another", &signed), None);
|
||||
let forged = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode("mallory\n8\n99"),
|
||||
signed.split_once('.').unwrap().1
|
||||
);
|
||||
assert_eq!(unsign(key, &forged), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! `horizon-mod-registry`: serves on `PORT` (default 8080). Configured by the
|
||||
//! environment (see `Config::from_env`); keeps no data.
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use horizon_mod_registry::{App, Config, router};
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() {
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080);
|
||||
let config = Config::from_env().unwrap_or_else(|e| panic!("horizon-mod-registry: {e}"));
|
||||
let repo = config.github.repo.clone();
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("port {port}: {e}"));
|
||||
eprintln!("horizon-mod-registry: listening on {port}, submitting to {repo}");
|
||||
axum::serve(listener, router(App::new(config)))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! The whole submission against a mock GitHub: sign in, submit the sample
|
||||
//! signed mod (`crates/horizon-mods/tests/index`), and what lands on GitHub;
|
||||
//! then what is refused.
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, Bytes},
|
||||
extract::{Path as UrlPath, Query, State},
|
||||
http::{Request, StatusCode, header},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use horizon_mod_registry::{App, Config, github, router};
|
||||
use horizon_mods::index::registry::Entry;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
const PUBLIC: &str = "https://playhorizon.test";
|
||||
const HMOD_SHA: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mock {
|
||||
/// Path -> text on main.
|
||||
main: HashMap<String, String>,
|
||||
assets: Vec<(String, usize)>,
|
||||
branches: Vec<String>,
|
||||
/// (branch, path) -> (text, message, replaced blob).
|
||||
commits: HashMap<(String, String), (String, String, Option<String>)>,
|
||||
pulls: Vec<Value>,
|
||||
/// The account the next sign-in gets.
|
||||
login: String,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Mock>>;
|
||||
|
||||
async fn mock_github() -> (String, Shared) {
|
||||
let state: Shared = Arc::default();
|
||||
state.lock().unwrap().login = "alice".into();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/login/oauth/access_token",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
Json(if body["code"] == "good" {
|
||||
json!({"access_token": "gho_user"})
|
||||
} else {
|
||||
json!({"error": "bad_verification_code"})
|
||||
})
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/user",
|
||||
get(|State(s): State<Shared>| async move {
|
||||
Json(json!({"login": s.lock().unwrap().login, "id": 7}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/contents/mods/{file}",
|
||||
get(
|
||||
|State(s): State<Shared>, UrlPath(file): UrlPath<String>| async move {
|
||||
let s = s.lock().unwrap();
|
||||
match s.main.get(&format!("mods/{file}")) {
|
||||
Some(text) => Ok(Json(json!({"content": STANDARD.encode(text), "sha": "blob1"}))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
},
|
||||
)
|
||||
.put(
|
||||
|State(s): State<Shared>, UrlPath(file): UrlPath<String>, Json(b): Json<Value>| async move {
|
||||
let text = String::from_utf8(STANDARD.decode(b["content"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let key = (b["branch"].as_str().unwrap().to_owned(), format!("mods/{file}"));
|
||||
let value = (text, b["message"].as_str().unwrap().to_owned(), b["sha"].as_str().map(str::to_owned));
|
||||
s.lock().unwrap().commits.insert(key, value);
|
||||
Json(json!({}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/releases/tags/staging",
|
||||
get(|State(s): State<Shared>| async move {
|
||||
let names: Vec<Value> = s.lock().unwrap().assets.iter().map(|(n, _)| json!({"name": n})).collect();
|
||||
Json(json!({"id": 42, "assets": names}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/releases/42/assets",
|
||||
post(
|
||||
|State(s): State<Shared>, Query(q): Query<HashMap<String, String>>, body: Bytes| async move {
|
||||
s.lock().unwrap().assets.push((q["name"].clone(), body.len()));
|
||||
Json(json!({}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/git/ref/heads/main",
|
||||
get(|| async { Json(json!({"object": {"sha": "abc123"}})) }),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/git/refs",
|
||||
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
|
||||
assert_eq!(b["sha"], "abc123");
|
||||
let name = b["ref"].as_str().unwrap().trim_start_matches("refs/heads/").to_owned();
|
||||
let mut s = s.lock().unwrap();
|
||||
if s.branches.contains(&name) {
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
s.branches.push(name);
|
||||
Ok(Json(json!({})))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/pulls",
|
||||
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
|
||||
let mut s = s.lock().unwrap();
|
||||
s.pulls.push(b);
|
||||
Json(json!({"html_url": format!("https://github.com/o/r/pull/{}", s.pulls.len())}))
|
||||
}),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
(base, state)
|
||||
}
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../horizon-mods/tests/index")
|
||||
}
|
||||
|
||||
struct Client {
|
||||
app: Router,
|
||||
cookies: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn send(&mut self, request: Request<Body>) -> (StatusCode, Value, Option<String>) {
|
||||
let response = self.app.clone().oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
for c in response.headers().get_all(header::SET_COOKIE) {
|
||||
let (pair, _) = c.to_str().unwrap().split_once(';').unwrap();
|
||||
let (k, v) = pair.split_once('=').unwrap();
|
||||
self.cookies.insert(k.into(), v.into());
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.map(|l| l.to_str().unwrap().to_owned());
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
location,
|
||||
)
|
||||
}
|
||||
|
||||
fn cookie_header(&self) -> String {
|
||||
self.cookies
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_empty())
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn get(&self, uri: &str) -> Request<Body> {
|
||||
Request::get(uri)
|
||||
.header(header::COOKIE, self.cookie_header())
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn sign_in(&mut self) -> String {
|
||||
let (status, _, location) = self.send(self.get("/api/mods/auth/login")).await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
let location = location.unwrap();
|
||||
assert!(location.contains(
|
||||
"redirect_uri=https%3A%2F%2Fplayhorizon.test%2Fapi%2Fmods%2Fauth%2Fcallback"
|
||||
));
|
||||
let state = location
|
||||
.split("state=")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('&')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
// Another state than the one this browser started with is refused.
|
||||
let (status, ..) = self
|
||||
.send(self.get("/api/mods/auth/callback?code=good&state=other"))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let (status, _, location) = self
|
||||
.send(self.get(&format!("/api/mods/auth/callback?code=good&state={state}")))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
location.as_deref(),
|
||||
Some("https://playhorizon.test/mods/submit")
|
||||
);
|
||||
let (status, me, _) = self.send(self.get("/api/mods/me")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
me["csrf"].as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
async fn submit(&mut self, csrf: &str, form: &Value, file: &[u8]) -> (StatusCode, Value) {
|
||||
let boundary = "XhorizonX";
|
||||
let mut body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"entry\"\r\n\r\n{form}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
body.extend_from_slice(file);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
let request = Request::post("/api/mods/submit")
|
||||
.header(header::COOKIE, self.cookie_header())
|
||||
.header(header::ORIGIN, PUBLIC)
|
||||
.header("x-csrf-token", csrf)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (status, json, _) = self.send(request).await;
|
||||
(status, json)
|
||||
}
|
||||
}
|
||||
|
||||
fn form() -> Value {
|
||||
let key = std::fs::read_to_string(fixtures().join("test.pub")).unwrap();
|
||||
json!({
|
||||
"id": "coords_hud",
|
||||
"kind": "mod",
|
||||
"name": "Coordinates HUD",
|
||||
"summary": "Your coordinates in a corner.",
|
||||
"authors": ["Alice"],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/alice/coords",
|
||||
"key": key.lines().last().unwrap(),
|
||||
"signature": std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")).unwrap(),
|
||||
"version": "0.1.0",
|
||||
"game": "26.3",
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup() -> (Client, Shared) {
|
||||
let (base, mock) = mock_github().await;
|
||||
let scratch =
|
||||
std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id()));
|
||||
let config = Config {
|
||||
public_url: PUBLIC.into(),
|
||||
session_key: b"a test key of at least thirty-two bytes".to_vec(),
|
||||
github: github::GitHub::new(
|
||||
github::Endpoints {
|
||||
web: base.clone(),
|
||||
api: base.clone(),
|
||||
uploads: base,
|
||||
},
|
||||
"o/r".into(),
|
||||
"client".into(),
|
||||
"secret".into(),
|
||||
"bot".into(),
|
||||
),
|
||||
scratch,
|
||||
};
|
||||
let client = Client {
|
||||
app: router(App::new(config)),
|
||||
cookies: HashMap::new(),
|
||||
};
|
||||
(client, mock)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_submission_becomes_a_pull_request() {
|
||||
let (mut client, mock) = setup().await;
|
||||
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
|
||||
// Not signed in.
|
||||
let (status, _) = client.submit("x", &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let csrf = client.sign_in().await;
|
||||
let (status, _) = client.submit("wrong", &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["pull_request"], "https://github.com/o/r/pull/1");
|
||||
{
|
||||
let m = mock.lock().unwrap();
|
||||
assert_eq!(m.assets, [(format!("{HMOD_SHA}.hmod"), file.len())]);
|
||||
assert_eq!(m.branches, ["submit/coords_hud-0.1.0"]);
|
||||
let (text, message, replaced) = &m.commits[&(
|
||||
"submit/coords_hud-0.1.0".into(),
|
||||
"mods/coords_hud.toml".into(),
|
||||
)];
|
||||
let entry: Entry = toml::from_str(text).unwrap();
|
||||
assert_eq!(entry.owners, ["alice"]);
|
||||
assert_eq!(entry.versions[0].sha256, HMOD_SHA);
|
||||
assert_eq!(entry.versions[0].file, "coords_hud-0.1.0.hmod");
|
||||
assert!(entry.versions[0].signature.is_some() && entry.key.is_some());
|
||||
assert!(message.contains("Co-authored-by: alice <[email protected]>"));
|
||||
assert_eq!(replaced, &None);
|
||||
let body = m.pulls[0]["body"].as_str().unwrap();
|
||||
assert!(body.starts_with("<!-- submitted-by: alice -->"), "{body}");
|
||||
assert!(body.contains("may draw text on your screen"), "{body}");
|
||||
}
|
||||
// The same version again: its branch is there.
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::CONFLICT, "{json}");
|
||||
assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn what_the_form_refuses() {
|
||||
let (mut client, mock) = setup().await;
|
||||
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
|
||||
let csrf = client.sign_in().await;
|
||||
let refused = |status: StatusCode, json: &Value, part: &str| {
|
||||
assert!(
|
||||
json["error"].as_str().unwrap_or("").contains(part),
|
||||
"{status} {json}"
|
||||
);
|
||||
};
|
||||
// A file that isn't what the page says.
|
||||
let mut f = form();
|
||||
f["id"] = json!("other_mod");
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
refused(status, &json, "the file's id is \"coords_hud\"");
|
||||
// A broken signature.
|
||||
let mut f = form();
|
||||
f["signature"] = json!(
|
||||
std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig"))
|
||||
.unwrap()
|
||||
.replace("RUTV", "RUTW")
|
||||
);
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
|
||||
// Shader packs aren't open.
|
||||
let mut f = form();
|
||||
f["kind"] = json!("shader");
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
refused(status, &json, "shader packs open");
|
||||
// An update by someone who isn't an owner.
|
||||
let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n";
|
||||
mock.lock()
|
||||
.unwrap()
|
||||
.main
|
||||
.insert("mods/coords_hud.toml".into(), owned.into());
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
refused(status, &json, "bob's");
|
||||
// From another site.
|
||||
let mut request = Request::post("/api/mods/submit")
|
||||
.header(header::COOKIE, client.cookie_header())
|
||||
.header(header::ORIGIN, "https://evil.test")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
"multipart/form-data; boundary=x".parse().unwrap(),
|
||||
);
|
||||
let (status, ..) = client.send(request).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let m = mock.lock().unwrap();
|
||||
assert!(
|
||||
m.assets.is_empty() && m.pulls.is_empty(),
|
||||
"nothing reached GitHub"
|
||||
);
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::StopSound,
|
||||
};
|
||||
use std::{error::Error, io::Write};
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut reader = Reader::new(&bytes);
|
||||
let packet = StopSound::read(&mut reader)?;
|
||||
assert_eq!(reader.remaining(), 0);
|
||||
let mut writer = Writer::new();
|
||||
StopSound::write(&packet, &mut writer);
|
||||
assert_eq!(writer.data, bytes);
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::json!({"source":packet.source,"sound":packet.sound})
|
||||
)?;
|
||||
}
|
||||
assert!(StopSound::read(&mut Reader::new(&[1, 11])).is_err());
|
||||
assert!(StopSound::read(&mut Reader::new(&[3])).is_err());
|
||||
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)
|
||||
}
|
||||
@@ -1118,6 +1118,42 @@ packet!(SoundEntity {
|
||||
pitch: f32 => Float,
|
||||
seed: i64 => Long,
|
||||
});
|
||||
|
||||
/// ClientboundStopSoundPacket: bit 0 selects a source, bit 1 an event id.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StopSound {
|
||||
pub source: Option<i32>,
|
||||
pub sound: Option<String>,
|
||||
}
|
||||
impl Codec<StopSound> for StopSound {
|
||||
fn read(r: &mut Reader) -> crate::Result<Self> {
|
||||
let flags = r.u8()?;
|
||||
let source = if flags & 1 != 0 {
|
||||
let value = r.varint()?;
|
||||
if !(0..11).contains(&value) {
|
||||
return Err(crate::Error::new("invalid sound source"));
|
||||
}
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sound = if flags & 2 != 0 {
|
||||
Some(r.string(32767)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self { source, sound })
|
||||
}
|
||||
fn write(p: &Self, w: &mut Writer) {
|
||||
w.u8(u8::from(p.source.is_some()) | (u8::from(p.sound.is_some()) << 1));
|
||||
if let Some(source) = p.source {
|
||||
w.varint(source);
|
||||
}
|
||||
if let Some(sound) = &p.sound {
|
||||
w.string(sound);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One entry of ClientboundCommandsPacket: a literal or an argument node.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandNode {
|
||||
@@ -1580,6 +1616,125 @@ impl Codec<Vec<(i32, i32, i32)>> for StatList {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundBossEventPacket` (`boss_event`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BossAction {
|
||||
Add {
|
||||
name: Tag,
|
||||
progress: f32,
|
||||
/// `BossBarColor` ordinal.
|
||||
color: i32,
|
||||
/// `BossBarOverlay` ordinal.
|
||||
overlay: i32,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
},
|
||||
Remove,
|
||||
UpdateProgress {
|
||||
progress: f32,
|
||||
},
|
||||
UpdateName {
|
||||
name: Tag,
|
||||
},
|
||||
UpdateStyle {
|
||||
color: i32,
|
||||
overlay: i32,
|
||||
},
|
||||
UpdateProperties {
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BossEvent {
|
||||
pub id: u128,
|
||||
pub action: BossAction,
|
||||
}
|
||||
fn boss_flags(darken: bool, music: bool, fog: bool) -> u8 {
|
||||
u8::from(darken) | (u8::from(music) << 1) | (u8::from(fog) << 2)
|
||||
}
|
||||
fn split_flags(flags: u8) -> (bool, bool, bool) {
|
||||
(flags & 1 != 0, flags & 2 != 0, flags & 4 != 0)
|
||||
}
|
||||
impl Codec<BossEvent> for BossEvent {
|
||||
fn read(r: &mut Reader) -> Result<BossEvent> {
|
||||
let id = r.uuid()?;
|
||||
let action = match r.varint()? {
|
||||
0 => {
|
||||
let name = r.nbt()?;
|
||||
let progress = r.f32()?;
|
||||
let color = r.varint()?;
|
||||
let overlay = r.varint()?;
|
||||
let (darken, music, fog) = split_flags(r.u8()?);
|
||||
BossAction::Add {
|
||||
name,
|
||||
progress,
|
||||
color,
|
||||
overlay,
|
||||
darken,
|
||||
music,
|
||||
fog,
|
||||
}
|
||||
}
|
||||
1 => BossAction::Remove,
|
||||
2 => BossAction::UpdateProgress { progress: r.f32()? },
|
||||
3 => BossAction::UpdateName { name: r.nbt()? },
|
||||
4 => BossAction::UpdateStyle {
|
||||
color: r.varint()?,
|
||||
overlay: r.varint()?,
|
||||
},
|
||||
5 => {
|
||||
let (darken, music, fog) = split_flags(r.u8()?);
|
||||
BossAction::UpdateProperties { darken, music, fog }
|
||||
}
|
||||
op => return Err(Error::new(format!("unknown boss event operation {op}"))),
|
||||
};
|
||||
Ok(BossEvent { id, action })
|
||||
}
|
||||
fn write(v: &BossEvent, w: &mut Writer) {
|
||||
w.uuid(v.id);
|
||||
match &v.action {
|
||||
BossAction::Add {
|
||||
name,
|
||||
progress,
|
||||
color,
|
||||
overlay,
|
||||
darken,
|
||||
music,
|
||||
fog,
|
||||
} => {
|
||||
w.varint(0);
|
||||
w.nbt(name);
|
||||
w.f32(*progress);
|
||||
w.varint(*color);
|
||||
w.varint(*overlay);
|
||||
w.u8(boss_flags(*darken, *music, *fog));
|
||||
}
|
||||
BossAction::Remove => w.varint(1),
|
||||
BossAction::UpdateProgress { progress } => {
|
||||
w.varint(2);
|
||||
w.f32(*progress);
|
||||
}
|
||||
BossAction::UpdateName { name } => {
|
||||
w.varint(3);
|
||||
w.nbt(name);
|
||||
}
|
||||
BossAction::UpdateStyle { color, overlay } => {
|
||||
w.varint(4);
|
||||
w.varint(*color);
|
||||
w.varint(*overlay);
|
||||
}
|
||||
BossAction::UpdateProperties { darken, music, fog } => {
|
||||
w.varint(5);
|
||||
w.u8(boss_flags(*darken, *music, *fog));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
BundleDelimiter(Empty) = BUNDLE_DELIMITER,
|
||||
AddEntity(AddEntity) = ADD_ENTITY,
|
||||
@@ -1590,6 +1745,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
BlockEntityData(BlockEntityData) = BLOCK_ENTITY_DATA,
|
||||
BlockEvent(BlockEvent) = BLOCK_EVENT,
|
||||
BlockUpdate(BlockUpdate) = BLOCK_UPDATE,
|
||||
BossEvent(BossEvent) = BOSS_EVENT,
|
||||
ChangeDifficulty(ChangeDifficulty) = CHANGE_DIFFICULTY,
|
||||
ChunkBatchFinished(ChunkBatchFinished) = CHUNK_BATCH_FINISHED,
|
||||
ChunkBatchStart(Empty) = CHUNK_BATCH_START,
|
||||
@@ -1678,6 +1834,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
SetTitlesAnimation(TitlesAnimation) = SET_TITLES_ANIMATION,
|
||||
Sound(Sound) = SOUND,
|
||||
SoundEntity(SoundEntity) = SOUND_ENTITY,
|
||||
StopSound(StopSound) = STOP_SOUND,
|
||||
StartConfiguration(Empty) = START_CONFIGURATION,
|
||||
SwingAnimation(SwingAnimation) = SWING_ANIMATION,
|
||||
SystemChat(SystemChat) = SYSTEM_CHAT,
|
||||
@@ -2761,10 +2918,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unknown_packets_keep_their_payload() {
|
||||
let p = Clientbound::decode(crate::ids::play::clientbound::BOSS_EVENT, &[1, 2, 3]).unwrap();
|
||||
let p =
|
||||
Clientbound::decode(crate::ids::play::clientbound::PLAYER_LOOK_AT, &[1, 2, 3]).unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
Clientbound::Unknown(crate::ids::play::clientbound::BOSS_EVENT, vec![1, 2, 3])
|
||||
Clientbound::Unknown(crate::ids::play::clientbound::PLAYER_LOOK_AT, vec![1, 2, 3])
|
||||
);
|
||||
assert_eq!(p.encode()[1..], [1, 2, 3]);
|
||||
}
|
||||
@@ -2861,4 +3019,41 @@ mod tests {
|
||||
w.varint(101);
|
||||
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn boss_event_round_trips() {
|
||||
let add = Clientbound::BossEvent(BossEvent {
|
||||
id: 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210,
|
||||
action: BossAction::Add {
|
||||
name: Tag::String("Wither".into()),
|
||||
progress: 0.5,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken: true,
|
||||
music: false,
|
||||
fog: true,
|
||||
},
|
||||
});
|
||||
let body = add.encode();
|
||||
// Packet id 9, then the uuid, operation 0, and the property byte
|
||||
// (darken 1, fog 4).
|
||||
assert_eq!(body[0], 9);
|
||||
assert_eq!(*body.last().unwrap(), 5);
|
||||
round_trip(add, Clientbound::encode, Clientbound::decode);
|
||||
round_trip(
|
||||
Clientbound::BossEvent(BossEvent {
|
||||
id: 7,
|
||||
action: BossAction::UpdateProgress { progress: 0.25 },
|
||||
}),
|
||||
Clientbound::encode,
|
||||
Clientbound::decode,
|
||||
);
|
||||
round_trip(
|
||||
Clientbound::BossEvent(BossEvent {
|
||||
id: 7,
|
||||
action: BossAction::Remove,
|
||||
}),
|
||||
Clientbound::encode,
|
||||
Clientbound::decode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -781,6 +781,7 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-update-{name}-{}",
|
||||
|
||||
@@ -168,6 +168,7 @@ mod stored_consume_fixture;
|
||||
mod stored_effect_attributes_fixture;
|
||||
mod stored_effect_notifications_fixture;
|
||||
mod structure_density_fixture;
|
||||
mod structure_pieces;
|
||||
mod suffocation_fixture;
|
||||
mod swimming_fixture;
|
||||
mod synced_bytes_fixture;
|
||||
@@ -338,6 +339,7 @@ probes! {
|
||||
stored_effect_attributes_fixture,
|
||||
stored_effect_notifications_fixture,
|
||||
structure_density_fixture,
|
||||
structure_pieces,
|
||||
suffocation_fixture,
|
||||
swimming_fixture,
|
||||
synced_bytes_fixture,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The structure starts a chunk owns, piece by piece, as a saved start lists them.
|
||||
//!
|
||||
//! structure_pieces <client resource dir> <seed> <dimension> <cx,cz>...
|
||||
//! prints, for every start whose chunk is a target, `start ID CX CZ` and then
|
||||
//! one `piece TYPE ELEMENT ROTATION X Y Z GROUND_LEVEL_DELTA X0 Y0 Z0 X1 Y1 Z1`
|
||||
//! line per piece in placement order (jigsaw pieces: the element's template
|
||||
//! location; other pieces: `-` for element, rotation and position).
|
||||
use crate::support::common::tag;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
nbt::Tag,
|
||||
registry::BlockRegistry,
|
||||
worldgen::{
|
||||
generator::{GeneratorData, OverworldGenerator},
|
||||
structure::{Rotation, jigsaw::PoolElement, start::Piece},
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
|
||||
let facts: Value = serde_json::from_str(&fs::read_to_string(resources.join("facts.json"))?)?;
|
||||
let states = facts["states"].as_array().ok_or("facts states")?;
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(states.iter().map(|v| tag(&v[4])).collect()),
|
||||
),
|
||||
("defaults", tag(&facts["defaults"])),
|
||||
]))?;
|
||||
Ok(Box::leak(Box::new(registry)))
|
||||
}
|
||||
|
||||
fn element(element: &PoolElement) -> String {
|
||||
match element {
|
||||
PoolElement::Empty => "empty".into(),
|
||||
PoolElement::Single { template, .. } => template.location.to_string(),
|
||||
PoolElement::Feature { feature, .. } => format!("feature:{feature}"),
|
||||
PoolElement::List { elements, .. } => {
|
||||
let parts: Vec<_> = elements.iter().map(self::element).collect();
|
||||
format!("list:{}", parts.join("+"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotation(rotation: Rotation) -> &'static str {
|
||||
match rotation {
|
||||
Rotation::None => "NONE",
|
||||
Rotation::Clockwise90 => "CLOCKWISE_90",
|
||||
Rotation::Clockwise180 => "CLOCKWISE_180",
|
||||
Rotation::Counterclockwise90 => "COUNTERCLOCKWISE_90",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Error> {
|
||||
let mut args = crate::args().skip(1);
|
||||
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
|
||||
let seed: i64 = args.next().ok_or("seed")?.parse()?;
|
||||
let dimension = format!("minecraft:{}", args.next().ok_or("dimension")?);
|
||||
let data = GeneratorData::load(&resources.join("worldgen"), registry(&resources)?)?;
|
||||
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
|
||||
let mut worker = generator.worker();
|
||||
for target in args {
|
||||
let (x, z) = target.split_once(',').ok_or("target cx,cz")?;
|
||||
let chunk = ChunkPos::new(x.parse()?, z.parse()?);
|
||||
for start in worker.structure_starts(chunk) {
|
||||
if start.chunk != chunk {
|
||||
continue;
|
||||
}
|
||||
println!("start {} {} {}", start.name, chunk.x, chunk.z);
|
||||
for piece in &start.pieces {
|
||||
let b = piece.bounding_box();
|
||||
let placed = match piece {
|
||||
Piece::Pool(p) => format!(
|
||||
"{} {} {} {} {} {}",
|
||||
element(&p.element),
|
||||
rotation(p.rotation),
|
||||
p.position[0],
|
||||
p.position[1],
|
||||
p.position[2],
|
||||
p.ground_level_delta
|
||||
),
|
||||
Piece::Legacy(_) => "- - - - - -".into(),
|
||||
};
|
||||
println!(
|
||||
"piece {} {placed} {} {} {} {} {} {}",
|
||||
piece.type_name(),
|
||||
b.min[0],
|
||||
b.min[1],
|
||||
b.min[2],
|
||||
b.max[0],
|
||||
b.max[1],
|
||||
b.max[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -347,14 +347,16 @@ impl StructureResources {
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
|
||||
Some(Arc::new(StructureTemplate::from_nbt(
|
||||
Some(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.registry,
|
||||
&self.transforms,
|
||||
)))
|
||||
))
|
||||
});
|
||||
// StructureTemplateManager.getOrCreate: missing templates are empty.
|
||||
let loaded = loaded.or_else(|| Some(Arc::new(StructureTemplate::empty())));
|
||||
let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty);
|
||||
loaded.location = id.as_str().into();
|
||||
let loaded = Some(Arc::new(loaded));
|
||||
self.templates.write().unwrap().insert(id, loaded.clone());
|
||||
loaded
|
||||
}
|
||||
|
||||
@@ -122,7 +122,11 @@ impl PoolElement {
|
||||
};
|
||||
Ok(match kind {
|
||||
"minecraft:empty_pool_element" => Self::Empty,
|
||||
"minecraft:single_pool_element" | "minecraft:legacy_single_pool_element" => {
|
||||
// YUNG's API's element places what a single element places; its
|
||||
// `name` and `max_count` are the pool entry's (`Pool::limits`).
|
||||
"minecraft:single_pool_element"
|
||||
| "minecraft:legacy_single_pool_element"
|
||||
| "yungsapi:yung_single_element" => {
|
||||
let location = value["location"]
|
||||
.as_str()
|
||||
.ok_or(DecodeError::Field("location"))?;
|
||||
@@ -336,6 +340,9 @@ pub struct Pool {
|
||||
pub(super) templates: Vec<Arc<PoolElement>>,
|
||||
/// The elements with their weights, in file order (`rawTemplates`).
|
||||
pub(super) raw: Vec<(Arc<PoolElement>, i32)>,
|
||||
/// Per entry of `raw`: a YUNG element's `name` and `max_count`, the most
|
||||
/// pieces of that name in one start.
|
||||
limits: Vec<Option<(Arc<str>, i32)>>,
|
||||
pub(super) fallback: String,
|
||||
max_size: OnceLock<i32>,
|
||||
}
|
||||
@@ -354,6 +361,16 @@ impl Pool {
|
||||
shuffle(&mut copy, random);
|
||||
copy
|
||||
}
|
||||
/// YUNG's API's pick: `nextInt` of the entries' total weight, walked in
|
||||
/// `order` (indices into `raw`); the entry that takes it below zero.
|
||||
fn weighted(&self, order: &[usize], random: &mut impl RandomSource) -> Option<usize> {
|
||||
let total: i32 = order.iter().map(|&i| self.raw[i].1).sum();
|
||||
let mut pick = random.next_int_bound(total).ok()?;
|
||||
order.iter().position(|&i| {
|
||||
pick -= self.raw[i].1;
|
||||
pick < 0
|
||||
})
|
||||
}
|
||||
pub(super) fn max_size(&self) -> i32 {
|
||||
*self.max_size.get_or_init(|| {
|
||||
self.templates
|
||||
@@ -393,7 +410,7 @@ impl Pools {
|
||||
}
|
||||
let pool = self.raw.get(name).and_then(|json| {
|
||||
let value: Value = serde_json::from_str(json).ok()?;
|
||||
let (mut templates, mut raw) = (Vec::new(), Vec::new());
|
||||
let (mut templates, mut raw, mut limits) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for entry in value["elements"].as_array()? {
|
||||
let element =
|
||||
match PoolElement::decode(&entry["element"], resources, bindings, None) {
|
||||
@@ -410,10 +427,22 @@ impl Pools {
|
||||
templates.push(element.clone());
|
||||
}
|
||||
raw.push((element, weight as i32));
|
||||
let fields = &entry["element"];
|
||||
limits.push(
|
||||
match (fields["name"].as_str(), fields["max_count"].as_i64()) {
|
||||
(Some(name), Some(max))
|
||||
if fields["element_type"] == "yungsapi:yung_single_element" =>
|
||||
{
|
||||
Some((name.into(), max as i32))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(Arc::new(Pool {
|
||||
templates,
|
||||
raw,
|
||||
limits,
|
||||
fallback: value["fallback"]
|
||||
.as_str()
|
||||
.unwrap_or("minecraft:empty")
|
||||
@@ -587,6 +616,93 @@ pub trait JigsawContext {
|
||||
/// couldStructureExistInColumn over inclusive block Y bounds.
|
||||
fn column_has_valid_biome(&mut self, x: i32, z: i32, min_y: i32, max_y: i32) -> bool;
|
||||
}
|
||||
/// How a jigsaw structure draws its pieces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Draws {
|
||||
/// JigsawPlacement's.
|
||||
Vanilla,
|
||||
/// YUNG's API's `yungsapi:yung_jigsaw` (spec-yungsapi §2.1, §3.1): pools
|
||||
/// are drawn by weight from their file entries, without replacement, and
|
||||
/// an element at its `max_count` is skipped.
|
||||
Yung,
|
||||
}
|
||||
|
||||
/// The elements a jigsaw tries, in order.
|
||||
enum Candidates {
|
||||
/// Vanilla: the target pool's weight-expanded elements, then the
|
||||
/// fallback's, each shuffled up front.
|
||||
Listed(std::vec::IntoIter<Arc<PoolElement>>),
|
||||
/// YUNG's: weighted picks from each pool in turn (the fallback's only
|
||||
/// once the target's are spent), each pool's entries shuffled when it
|
||||
/// is reached.
|
||||
Weighted {
|
||||
pools: Vec<Arc<Pool>>,
|
||||
current: Option<(Arc<Pool>, Vec<usize>)>,
|
||||
},
|
||||
}
|
||||
impl Candidates {
|
||||
fn new(
|
||||
draws: Draws,
|
||||
target: &Arc<Pool>,
|
||||
fallback: &Arc<Pool>,
|
||||
below_max_depth: bool,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Self {
|
||||
let pools = if below_max_depth {
|
||||
vec![target.clone(), fallback.clone()]
|
||||
} else {
|
||||
vec![fallback.clone()]
|
||||
};
|
||||
match draws {
|
||||
Draws::Vanilla => Self::Listed(
|
||||
pools
|
||||
.iter()
|
||||
.flat_map(|pool| pool.shuffled(random))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter(),
|
||||
),
|
||||
Draws::Yung => Self::Weighted {
|
||||
pools: pools.into_iter().rev().collect(),
|
||||
current: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// The next element to try, with its YUNG name and limit.
|
||||
fn next(
|
||||
&mut self,
|
||||
counts: &HashMap<Arc<str>, i32>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Option<(Arc<PoolElement>, Option<Arc<str>>)> {
|
||||
match self {
|
||||
Self::Listed(elements) => elements.next().map(|e| (e, None)),
|
||||
Self::Weighted { pools, current } => loop {
|
||||
let (pool, order) = match current {
|
||||
Some((pool, order)) if !order.is_empty() => (pool, order),
|
||||
_ => {
|
||||
let pool = pools.pop()?;
|
||||
let mut order: Vec<usize> = (0..pool.raw.len()).collect();
|
||||
shuffle(&mut order, random);
|
||||
*current = Some((pool, order));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(at) = pool.weighted(order, random) else {
|
||||
order.clear();
|
||||
continue;
|
||||
};
|
||||
let entry = order.remove(at);
|
||||
let limit = pool.limits[entry].clone();
|
||||
if let Some((name, max)) = &limit
|
||||
&& counts.get(name).copied().unwrap_or(0) >= *max
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some((pool.raw[entry].0.clone(), limit.map(|(name, _)| name)));
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JigsawConfig<'a> {
|
||||
pub start_pool: &'a str,
|
||||
pub start_jigsaw: Option<&'a str>,
|
||||
@@ -628,6 +744,30 @@ pub fn assemble(
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<([i32; 3], Vec<PoolPiece>)> {
|
||||
assemble_drawing(
|
||||
Draws::Vanilla,
|
||||
config,
|
||||
context,
|
||||
pools,
|
||||
resources,
|
||||
bindings,
|
||||
random,
|
||||
position,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`assemble`] with the given draws.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn assemble_drawing(
|
||||
draws: Draws,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<([i32; 3], Vec<PoolPiece>)> {
|
||||
let alias = |name: &str| {
|
||||
config
|
||||
@@ -636,9 +776,24 @@ pub fn assemble(
|
||||
.cloned()
|
||||
.unwrap_or_else(|| name.to_owned())
|
||||
};
|
||||
let rotation = Rotation::random(random);
|
||||
let center_pool = pools.get(&alias(config.start_pool), resources, bindings)?;
|
||||
let element = center_pool.random_template(random);
|
||||
let (rotation, element, limit) = match draws {
|
||||
Draws::Vanilla => {
|
||||
let rotation = Rotation::random(random);
|
||||
(rotation, center_pool.random_template(random), None)
|
||||
}
|
||||
// The entries shuffled, then the rotation, then a weighted pick in
|
||||
// the shuffled order.
|
||||
Draws::Yung => {
|
||||
let mut order: Vec<usize> = (0..center_pool.raw.len()).collect();
|
||||
shuffle(&mut order, random);
|
||||
let rotation = Rotation::random(random);
|
||||
let entry = order[center_pool.weighted(&order, random)?];
|
||||
let (element, _) = ¢er_pool.raw[entry];
|
||||
let limit = center_pool.limits[entry].clone().map(|(name, _)| name);
|
||||
(rotation, element.clone(), limit)
|
||||
}
|
||||
};
|
||||
if matches!(element.as_ref(), PoolElement::Empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -691,7 +846,10 @@ pub fn assemble(
|
||||
let low_y = (cy - v).max(context.min_y() + config.padding[0]);
|
||||
let high_y = (cy + v + 1).min(context.max_y() + 1 - config.padding[1]);
|
||||
let container = BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]);
|
||||
add_children(
|
||||
let counts = limit.into_iter().map(|name| (name, 1)).collect();
|
||||
add_children_drawing(
|
||||
draws,
|
||||
counts,
|
||||
config,
|
||||
context,
|
||||
pools,
|
||||
@@ -719,6 +877,32 @@ pub(super) fn add_children(
|
||||
random: &mut impl RandomSource,
|
||||
pieces: &mut Vec<PoolPiece>,
|
||||
container: BoundingBox,
|
||||
) {
|
||||
add_children_drawing(
|
||||
Draws::Vanilla,
|
||||
HashMap::new(),
|
||||
config,
|
||||
context,
|
||||
pools,
|
||||
resources,
|
||||
bindings,
|
||||
random,
|
||||
pieces,
|
||||
container,
|
||||
);
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_children_drawing(
|
||||
draws: Draws,
|
||||
counts: HashMap<Arc<str>, i32>,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
pieces: &mut Vec<PoolPiece>,
|
||||
container: BoundingBox,
|
||||
) {
|
||||
let free = Rc::new(RefCell::new(Free {
|
||||
container,
|
||||
@@ -731,6 +915,8 @@ pub(super) fn add_children(
|
||||
bindings,
|
||||
pieces,
|
||||
queue: BTreeMap::new(),
|
||||
draws,
|
||||
counts,
|
||||
};
|
||||
placer.children(0, free, 0, context, random);
|
||||
while let Some(state) = placer.next() {
|
||||
@@ -745,6 +931,9 @@ struct Placer<'a, 'p> {
|
||||
pieces: &'p mut Vec<PoolPiece>,
|
||||
/// SequencedPriorityIterator: highest priority first, FIFO within one.
|
||||
queue: BTreeMap<i32, VecDeque<PieceState>>,
|
||||
draws: Draws,
|
||||
/// Pieces placed per YUNG element name.
|
||||
counts: HashMap<Arc<str>, i32>,
|
||||
}
|
||||
impl Placer<'_, '_> {
|
||||
fn next(&mut self) -> Option<PieceState> {
|
||||
@@ -816,12 +1005,14 @@ impl Placer<'_, '_> {
|
||||
} else {
|
||||
context_free.clone()
|
||||
};
|
||||
let mut targets = Vec::new();
|
||||
if depth != self.config.max_depth {
|
||||
targets.extend(target_pool.shuffled(random));
|
||||
}
|
||||
targets.extend(fallback.shuffled(random));
|
||||
for target in targets {
|
||||
let mut candidates = Candidates::new(
|
||||
self.draws,
|
||||
&target_pool,
|
||||
&fallback,
|
||||
depth != self.config.max_depth,
|
||||
random,
|
||||
);
|
||||
while let Some((target, name)) = candidates.next(&self.counts, random) {
|
||||
if matches!(target.as_ref(), PoolElement::Empty) {
|
||||
break;
|
||||
}
|
||||
@@ -924,6 +1115,9 @@ impl Placer<'_, '_> {
|
||||
}],
|
||||
waterlogging: self.config.waterlogging,
|
||||
});
|
||||
if let Some(name) = name {
|
||||
*self.counts.entry(name).or_default() += 1;
|
||||
}
|
||||
if depth < self.config.max_depth {
|
||||
self.queue
|
||||
.entry(jigsaw.placement_priority)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! references, terrain adaptation and per-chunk piece placement.
|
||||
use super::{
|
||||
BoundingBox, StructureResources,
|
||||
jigsaw::{JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble},
|
||||
jigsaw::{
|
||||
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble_drawing,
|
||||
},
|
||||
legacy::{LegacyPiece, PieceContext},
|
||||
};
|
||||
use crate::{
|
||||
@@ -59,6 +61,8 @@ pub struct JigsawDef {
|
||||
checks: super::start_checks::StartChecks,
|
||||
/// Laid out by YUNG's API's assembler (`"horizon:yungsapi"`).
|
||||
yung: Option<super::yung::Config>,
|
||||
/// Vanilla's draws, or YUNG's API's for `yungsapi:yung_jigsaw`.
|
||||
draws: super::jigsaw::Draws,
|
||||
/// Laid out by Repurposed Structures' and Moog's piece-limited
|
||||
/// assembler (`"horizon:piece_limited"`).
|
||||
piece_limited: Option<super::piece_limited::Config>,
|
||||
@@ -320,8 +324,13 @@ impl StructureRegistry {
|
||||
.flatten()
|
||||
.filter_map(|t| Some(t.as_str()?.to_owned())),
|
||||
);
|
||||
let draws = match v["type"].as_str() {
|
||||
Some("yungsapi:yung_jigsaw") => super::jigsaw::Draws::Yung,
|
||||
_ => super::jigsaw::Draws::Vanilla,
|
||||
};
|
||||
let kind = match v["type"].as_str().unwrap_or_default() {
|
||||
"minecraft:jigsaw" => StructureKind::Jigsaw(JigsawDef {
|
||||
"minecraft:jigsaw" | "yungsapi:yung_jigsaw" => StructureKind::Jigsaw(JigsawDef {
|
||||
draws,
|
||||
start_pool: v["start_pool"].as_str().unwrap_or_default().to_owned(),
|
||||
start_jigsaw: v["start_jigsaw_name"].as_str().map(str::to_owned),
|
||||
max_depth: v["size"].as_i64().unwrap_or(0) as i32,
|
||||
@@ -923,7 +932,8 @@ fn try_generate(
|
||||
&mut random,
|
||||
start,
|
||||
)?,
|
||||
(None, None) => assemble(
|
||||
(None, None) => assemble_drawing(
|
||||
jigsaw.draws,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
nbt::{Compound, Tag},
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockInfo {
|
||||
@@ -42,6 +42,9 @@ pub struct EntityInfo {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct StructureTemplate {
|
||||
/// The id it was loaded under (`StructureResources::template`); empty
|
||||
/// for a template read from elsewhere.
|
||||
pub location: Arc<str>,
|
||||
pub size: [i32; 3],
|
||||
palettes: Vec<Palette>,
|
||||
pub entities: Vec<EntityInfo>,
|
||||
@@ -129,6 +132,7 @@ impl StructureTemplate {
|
||||
/// A new, never-loaded template: zero size and no palettes.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
location: "".into(),
|
||||
size: [0; 3],
|
||||
palettes: Vec::new(),
|
||||
entities: Vec::new(),
|
||||
@@ -231,6 +235,7 @@ impl StructureTemplate {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
location: "".into(),
|
||||
size,
|
||||
palettes,
|
||||
entities,
|
||||
|
||||
@@ -90,6 +90,61 @@ 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"
|
||||
```
|
||||
|
||||
The submission form's service (`crates/horizon-mod-registry`) writes these entries: it signs the creator in with GitHub, runs the file's checks, uploads the file to the repository's `staging` release as `<sha256>.<extension>` and opens the pull request. Only an entry's `owners` publish updates through it.
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
+11
-5
@@ -32,11 +32,17 @@ stay in the host's dimension. That mode is the escape hatch, so it gets no multi
|
||||
|
||||
## One code path for both
|
||||
|
||||
The dedicated server and the server thread are the same code. The server thread runs a `Game` with
|
||||
`options.dedicated` set and a hub without a host player. The window joins it through memory as one
|
||||
more player. Everything above serves both, and `server_dimension_players` (vanilla clients on Horizon's
|
||||
dedicated server) and `lan_e2e --server-thread` (Horizon clients on the server thread) check it from
|
||||
the two sides.
|
||||
The dedicated server and the server thread are the same code. Each `Game` has an explicit
|
||||
`ExecutionRole`: `Client`, `IntegratedServer` or `DedicatedServer`, selected before construction in
|
||||
`Options.execution_role`. Startup, world loading and opening other dimensions use that role. Only
|
||||
clients mesh chunks and show loading progress; a client can still run its simulation in-frame.
|
||||
Connecting or disconnecting changes `remote`, independently of the execution role.
|
||||
|
||||
The server thread has a hub without a host player. The window joins it through memory as one
|
||||
more player. It still sets `options.dedicated` for gameplay checks outside the role migration's
|
||||
startup/world-loading scope. Everything above serves both servers, and `server_dimension_players`
|
||||
(vanilla clients on Horizon's dedicated server) and `lan_e2e --server-thread` (Horizon clients on the
|
||||
server thread) check it from the two sides.
|
||||
|
||||
## Cost of an extra level
|
||||
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user