mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
181
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 | ||
|
|
4a675206b1 | ||
|
|
486df91b2d | ||
|
|
2c80009c70 | ||
|
|
41f8cfa3bc | ||
|
|
1ef5b82b7d | ||
|
|
5d19fcaa06 | ||
|
|
489b5a4ffa | ||
|
|
342cb6c500 | ||
|
|
eed98be9cf | ||
|
|
859236bd9d | ||
|
|
07519df062 | ||
|
|
b1eae8a5b8 | ||
|
|
cc65eeec82 | ||
|
|
792ac58f46 | ||
|
|
578f077347 | ||
|
|
b6ec56ac9b | ||
|
|
e334dd3d55 | ||
|
|
8948bf90c4 | ||
|
|
753a99b933 | ||
|
|
dc1e16f472 | ||
|
|
f2f58aca94 | ||
|
|
f9e3244281 | ||
|
|
c36e81916b | ||
|
|
87f377b29c | ||
|
|
6e86e07781 | ||
|
|
4bb7f96f81 | ||
|
|
89f426c48f | ||
|
|
961af30997 | ||
|
|
a96dccf691 | ||
|
|
6d1f6965d2 | ||
|
|
f66c257ae5 | ||
|
|
9f1a880951 | ||
|
|
7a7bfdde79 | ||
|
|
947d65e64d | ||
|
|
fe253fe534 | ||
|
|
96b6ed4916 | ||
|
|
e55b5b7c00 | ||
|
|
a93c1507f9 | ||
|
|
a26297058a | ||
|
|
149864c3b4 | ||
|
|
7c27b85fbd | ||
|
|
42afd721e7 | ||
|
|
75d70a1a74 | ||
|
|
856f0c2e40 | ||
|
|
75c009e3fc | ||
|
|
213f1bbb2e | ||
|
|
ba09b086a6 | ||
|
|
f07d66c7cf | ||
|
|
6d735c26d3 | ||
|
|
3dc14b6eda | ||
|
|
3e3ab7a413 | ||
|
|
0253d9bc47 | ||
|
|
fab71df185 | ||
|
|
76638aaf80 | ||
|
|
eaa9f3c203 | ||
|
|
395c256d11 | ||
|
|
75983739ec | ||
|
|
a4fa1a770d | ||
|
|
8a7831e58b | ||
|
|
3612f51812 | ||
|
|
d6e5fb5331 | ||
|
|
3b3590ea03 | ||
|
|
c41a053c15 | ||
|
|
0d1736da8f | ||
|
|
977ef7181f | ||
|
|
75b7812f68 | ||
|
|
e0344f1f79 | ||
|
|
6774b2b3e7 | ||
|
|
282ecf16a9 | ||
|
|
7072a40701 | ||
|
|
2a6347dc55 | ||
|
|
f1cfb3069f | ||
|
|
de840da0e5 | ||
|
|
8800d1878a | ||
|
|
7b56b59e7c | ||
|
|
7d76923a04 | ||
|
|
59281c5c73 | ||
|
|
d604409be2 | ||
|
|
2716ad41be | ||
|
|
0849283a9d | ||
|
|
40eb3e202a | ||
|
|
be34f4cd5f | ||
|
|
bffb68b0c5 | ||
|
|
4ad05aa94a | ||
|
|
8c611e677b | ||
|
|
c6980fb2b7 | ||
|
|
3f1a7b28af | ||
|
|
6c6c08f4d9 | ||
|
|
7d06aca17a | ||
|
|
0e607404d1 | ||
|
|
66070b918e | ||
|
|
891e1c1283 | ||
|
|
37c36b548a | ||
|
|
6e8a96a734 | ||
|
|
508cb2df9f | ||
|
|
4c3abd86de | ||
|
|
a5f5d8df34 | ||
|
|
adcfe810f2 | ||
|
|
3802a97dc9 | ||
|
|
90b5e162ae | ||
|
|
d742d725f4 | ||
|
|
7c1dd49810 | ||
|
|
352645b417 | ||
|
|
aee0b8b589 | ||
|
|
e5bfce3b37 | ||
|
|
0a1d3a7534 | ||
|
|
66235cc981 | ||
|
|
f80d00802e | ||
|
|
07e46daa59 | ||
|
|
9223b401e7 | ||
|
|
db758a622f | ||
|
|
d271176f23 | ||
|
|
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
|
||||
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
|
||||
@@ -39,6 +39,17 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
Generated
+15
@@ -1331,8 +1331,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1432,6 +1434,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1912,6 +1921,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
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"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
}
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
Weighted {
|
||||
variants: Vec<Variant>,
|
||||
variants: Vec<T>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice {
|
||||
impl Choice<Variant> {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,7 +41,23 @@ impl Choice {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -60,10 +76,11 @@ impl Choice {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -82,10 +99,19 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -95,14 +121,23 @@ impl CompiledChoices {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a Variant>,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -110,7 +145,7 @@ impl CompiledChoices {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
output.push(choice.select(random));
|
||||
f(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -119,7 +154,7 @@ impl CompiledChoices {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
output.push(self.multipart[*index].select(random));
|
||||
f(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! What a column costs to load, on real terrain: generates and lights the
|
||||
//! columns around a point with the vanilla generator, as a world being explored
|
||||
//! does, then meshes every section of the inner columns on this one thread, so a
|
||||
//! change to generation or meshing shows as time per column and per section.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 8);
|
||||
let rounds: usize = flag(&args, "--rounds", 3);
|
||||
let threads: usize = flag(
|
||||
&args,
|
||||
"--threads",
|
||||
thread::available_parallelism().map_or(2, |n| n.get()),
|
||||
);
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 100 {
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
let generated = started.elapsed() - Duration::from_millis(2 * 100);
|
||||
let columns = world.chunks.len();
|
||||
println!(
|
||||
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
|
||||
generated.as_secs_f64() * 1e3,
|
||||
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
|
||||
);
|
||||
|
||||
// Meshing every section of the columns whose neighbours are all loaded.
|
||||
let inner = radius - 1;
|
||||
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
|
||||
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
|
||||
.filter_map(|(x, z)| {
|
||||
let region: Region = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
region
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(Option::is_some)
|
||||
.then_some(region)
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"take" => count = book.remove_recipes(&index, &list()).len(),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
if let Some(angles) = input["angles"].as_array() {
|
||||
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Audio {
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -595,6 +598,7 @@ impl Audio {
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -825,6 +829,14 @@ impl Audio {
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
let selection = format!(
|
||||
"{} {}",
|
||||
source
|
||||
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
|
||||
.map_or("*", |s| s),
|
||||
event.as_deref().unwrap_or("*")
|
||||
);
|
||||
*self.stopped.entry(selection).or_default() += 1;
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boat_passengers;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -43,6 +44,7 @@ mod enchant_effects;
|
||||
mod end;
|
||||
mod entity_culling;
|
||||
mod events;
|
||||
mod execution_role;
|
||||
mod explosion;
|
||||
mod extras;
|
||||
mod falling_damage;
|
||||
@@ -153,6 +155,7 @@ mod storage;
|
||||
mod structures;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod text_displays;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -289,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
|
||||
@@ -347,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);
|
||||
@@ -361,6 +364,7 @@ impl Options {
|
||||
return Ok(None);
|
||||
}
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
o.dedicated = Some(args.by_ref().collect());
|
||||
break;
|
||||
}
|
||||
@@ -1421,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>,
|
||||
@@ -1565,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,
|
||||
@@ -4045,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();
|
||||
@@ -4063,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") {
|
||||
@@ -4259,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),
|
||||
@@ -4413,7 +4421,7 @@ impl Game<'_> {
|
||||
self.resolve_arrivals();
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan());
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
@@ -4438,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
|
||||
@@ -4818,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
|
||||
@@ -5034,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);
|
||||
@@ -6771,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() {
|
||||
@@ -7501,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,
|
||||
@@ -7520,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"),
|
||||
@@ -7549,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)
|
||||
}
|
||||
@@ -7962,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;
|
||||
@@ -10095,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
|
||||
{
|
||||
@@ -10166,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() {
|
||||
@@ -10556,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 {
|
||||
@@ -11883,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()
|
||||
@@ -11970,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(),
|
||||
@@ -12243,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()
|
||||
@@ -12697,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,
|
||||
}
|
||||
}
|
||||
@@ -12784,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);
|
||||
}
|
||||
@@ -13061,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)
|
||||
@@ -13236,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
|
||||
@@ -13252,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()
|
||||
@@ -13293,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);
|
||||
|
||||
@@ -472,6 +472,8 @@ impl Game<'_> {
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -320,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -401,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -477,6 +529,19 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
@@ -540,13 +605,14 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -556,9 +622,18 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -569,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -656,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -711,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -728,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -758,6 +830,7 @@ impl Game<'_> {
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -834,8 +834,11 @@ impl Game<'_> {
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
pub(super) fn tick_lan(&mut self) {
|
||||
/// Run the hub for this tick and apply what the guests did. While the
|
||||
/// single player game is `paused` only the connections run
|
||||
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
|
||||
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
|
||||
pub(super) fn tick_lan(&mut self, paused: bool) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -1000,7 +1003,9 @@ impl Game<'_> {
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan_living_flags(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
|
||||
@@ -1277,10 +1277,24 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let eye = {
|
||||
let g = &host.hub.guests[gi];
|
||||
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
|
||||
};
|
||||
let object = || {
|
||||
let (lo, hi, origin) = self.object_target(id)?;
|
||||
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
let Some(l) = location else {
|
||||
// A boat's mobs sit over its middle: aim low at its near
|
||||
// side, so the ray meets the boat before them (the server
|
||||
// attacks the entity it is sent).
|
||||
let carries = u32::try_from(id).is_ok_and(|v| {
|
||||
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
|
||||
});
|
||||
if carries {
|
||||
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
|
||||
return Some([near(0), lo[1] + 0.05, near(2)]);
|
||||
}
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
@@ -1311,7 +1325,6 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
|
||||
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
|
||||
@@ -1926,7 +1939,7 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
@@ -2023,20 +2036,17 @@ impl Game<'_> {
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let sounds = events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(sounds, marked.then_some(player.velocity))
|
||||
(events, marked.then_some(player.velocity))
|
||||
});
|
||||
let Some((sounds, marked)) = sounds else {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
for sound in sounds {
|
||||
self.player_hurt_sound(sound, at);
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
@@ -2439,6 +2449,32 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
|
||||
.to_vec();
|
||||
let facts = self.res.facts();
|
||||
// BlockItem.place: `playSound(player, ...)`, for the others only. The
|
||||
// placer's client plays it as its block appears.
|
||||
let placed: Vec<([f64; 3], String)> = changed
|
||||
.iter()
|
||||
.map(|&(x, y, z)| {
|
||||
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
|
||||
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
|
||||
(at, name.trim_start_matches("minecraft:").to_owned())
|
||||
})
|
||||
.collect();
|
||||
if let Some(capture) = self.audio.capture.as_mut() {
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
let (name, at, ..) = &capture[i];
|
||||
let place = placed
|
||||
.iter()
|
||||
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
|
||||
if place {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty()
|
||||
|| !changed.iter().all(|&(x, y, z)| {
|
||||
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
|
||||
|
||||
@@ -48,10 +48,9 @@ impl Game<'_> {
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
// startTrading with the guest's reputation. The session already
|
||||
// captured their Hero effect while they acted as the player.
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -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;
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +265,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +346,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +391,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +509,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +587,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -650,25 +673,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +860,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -379,6 +383,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -411,6 +416,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -460,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();
|
||||
}
|
||||
@@ -607,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())
|
||||
@@ -733,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;
|
||||
@@ -829,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) => {
|
||||
@@ -1382,7 +1411,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1461,6 +1490,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1472,11 +1518,9 @@ impl Game<'_> {
|
||||
steps: 3,
|
||||
},
|
||||
);
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.move_remote_text_display(id, position, rotation);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
@@ -1582,6 +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;
|
||||
@@ -1786,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
|
||||
@@ -1807,6 +1860,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
@@ -1817,6 +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 {
|
||||
@@ -2125,17 +2181,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2201,9 +2269,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2653,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)),
|
||||
@@ -2933,7 +3007,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2944,7 +3020,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2952,6 +3028,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
|
||||
return;
|
||||
@@ -3007,6 +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();
|
||||
@@ -3041,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) {
|
||||
@@ -3066,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();
|
||||
@@ -3149,6 +3247,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3176,6 +3277,7 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
@@ -3219,7 +3321,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3259,9 +3361,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3320,11 +3423,54 @@ impl Game<'_> {
|
||||
za *= f64::from(0.05_f32);
|
||||
push = Vec3::new(push.x - xa, push.y, push.z - za);
|
||||
}
|
||||
// AbstractBoat.push(player) from the boat's own tick (its box grown
|
||||
// by 0.2 sideways) and from the player's pushEntities (its box).
|
||||
let player_team = teams.of(Who::Player);
|
||||
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
|
||||
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
|
||||
let bb = b.bounds();
|
||||
let times = usize::from(
|
||||
bounds.intersects(bb.inflate(area))
|
||||
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
|
||||
) + usize::from(
|
||||
bounds.intersects(bb)
|
||||
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
|
||||
);
|
||||
if times > 0
|
||||
&& super::boat_passengers::boat_pushes(bb, bounds, false)
|
||||
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
|
||||
{
|
||||
push = push.add_vector(v.scale(times as f64));
|
||||
}
|
||||
}
|
||||
push
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
}
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3427,20 +3573,12 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
}
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
&& l.steps > 0
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (l.target[0] - d.position.x) / n,
|
||||
d.position.y + (l.target[1] - d.position.y) / n,
|
||||
d.position.z + (l.target[2] - d.position.z) / n,
|
||||
);
|
||||
l.steps -= 1;
|
||||
}
|
||||
d.tick(world, facts, collision, false);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
@@ -3875,11 +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:
|
||||
@@ -3939,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 {
|
||||
@@ -4010,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]),
|
||||
@@ -4030,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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4139,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
|
||||
@@ -4157,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;
|
||||
}
|
||||
@@ -4478,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));
|
||||
}
|
||||
@@ -4552,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);
|
||||
@@ -4987,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,
|
||||
@@ -5086,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
|
||||
@@ -5411,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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
// Homing state stays on the server; the client interpolates its positions.
|
||||
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
|
||||
target: horizon_client::mobs::Who::Player,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
|
||||
_ => return None,
|
||||
})
|
||||
@@ -185,11 +193,17 @@ impl Game<'_> {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
f.previous = f.position;
|
||||
f.age += 1;
|
||||
f.position = step(f.id, f.position);
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
|
||||
// Block.updateEntityMovementAfterFallOn, then the air drag.
|
||||
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
|
||||
@@ -88,8 +88,10 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
@@ -129,17 +131,20 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
let seat = b.position;
|
||||
self.boats[i] = b;
|
||||
Some(seat)
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
if let Some(v) = seat {
|
||||
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
|
||||
p.position = v;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
|
||||
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
|
||||
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
|
||||
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
|
||||
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PlayerHealth {
|
||||
self.hurt_animation_time -= 1;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
|
||||
/// tilt and red flash restart.
|
||||
pub fn animate_hurt(&mut self) {
|
||||
self.hurt.hurt_duration = 10;
|
||||
self.hurt.hurt_time = 10;
|
||||
self.hurt_animation_time = 10;
|
||||
}
|
||||
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
|
||||
/// health starts the hurt animation.
|
||||
pub fn flash_health_change(&mut self, health: f32) {
|
||||
let damage = self.health() - health;
|
||||
if damage > 0.0 {
|
||||
self.hurt.last_hurt = damage;
|
||||
self.animate_hurt();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn hurt(
|
||||
@@ -274,6 +290,9 @@ impl PlayerMotion {
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
) -> Result<BlockOutcome> {
|
||||
if self.client_side {
|
||||
return Ok(BlockOutcome::default());
|
||||
}
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(source, None, None, None)
|
||||
|
||||
@@ -55,6 +55,9 @@ impl PlayerMotion {
|
||||
Ok(())
|
||||
}
|
||||
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
|
||||
if self.client_side {
|
||||
return Ok(());
|
||||
}
|
||||
let h = &mut self.health;
|
||||
let mut world = TickWorld {
|
||||
resources,
|
||||
|
||||
@@ -432,18 +432,21 @@ impl Guest {
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
pub fn stack_to_network(
|
||||
item_id: impl Fn(&str) -> i32,
|
||||
enchantment_id: impl Fn(&str) -> Option<i32>,
|
||||
synced_id: impl Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &enchantment_id, s)
|
||||
network_stack(&item_id, &synced_id, s)
|
||||
}
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
enchantment_id: &dyn Fn(&str) -> Option<i32>,
|
||||
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
|
||||
if s.damage > 0 {
|
||||
@@ -481,6 +484,25 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(rgb),
|
||||
));
|
||||
}
|
||||
// ArmorTrim: both holders as references into the synced registries.
|
||||
if let Some((material, pattern)) = &d.trim {
|
||||
let id = |registry: &str, name: &str| {
|
||||
if name.contains(':') {
|
||||
synced_id(registry, name)
|
||||
} else {
|
||||
synced_id(registry, &format!("minecraft:{name}"))
|
||||
}
|
||||
};
|
||||
if let (Some(material), Some(pattern)) = (
|
||||
id("minecraft:trim_material", material),
|
||||
id("minecraft:trim_pattern", pattern),
|
||||
) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::TRIM,
|
||||
horizon_protocol::item::Component::Trim { material, pattern },
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -504,7 +526,7 @@ fn network_stack(
|
||||
let charged = d
|
||||
.charged
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
|
||||
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CHARGED_PROJECTILES,
|
||||
@@ -545,3 +567,32 @@ fn network_stack(
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::item::{Component, component};
|
||||
|
||||
#[test]
|
||||
fn trims_go_as_synced_registry_references() {
|
||||
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:trim_material", "minecraft:gold") => Some(4),
|
||||
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::TRIM),
|
||||
Some(&Component::Trim {
|
||||
material: 4,
|
||||
pattern: 1
|
||||
})
|
||||
);
|
||||
// A name the connection's registry lacks sends no trim.
|
||||
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ impl Hub {
|
||||
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
|
||||
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
|
||||
let sync = self.sync.clone();
|
||||
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
|
||||
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, packet: &C) {
|
||||
@@ -1827,7 +1827,9 @@ impl Hub {
|
||||
fn flush_advancements(&mut self, i: usize, show: bool) {
|
||||
let sync = self.sync.clone();
|
||||
let g = &mut self.guests[i];
|
||||
let unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
// A recipe given and taken before this flush must not reappear.
|
||||
unlocked.retain(|id| g.recipe_book.knows(id));
|
||||
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
|
||||
}
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -736,9 +736,13 @@ pub struct Extra {
|
||||
/// The sniffer's and the ghastling's brain.
|
||||
pub animal_brain: Option<Box<animal_brain::Brain>>,
|
||||
pub axolotl: axolotl::Animation,
|
||||
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
|
||||
/// The mob or boat this one rides, and the mob riding it (`getVehicle`,
|
||||
/// `getFirstPassenger`).
|
||||
pub mount: Option<u32>,
|
||||
pub rider: Option<u32>,
|
||||
/// `Entity.boardingCooldown`: ticks after leaving a boat before a boat
|
||||
/// can pick this mob up again.
|
||||
pub boarding_cooldown: i32,
|
||||
/// A chicken carrying a zombie (`IsChickenJockey`).
|
||||
pub jockey: bool,
|
||||
/// A skeleton horse waiting to spring its trap, and how long it has waited.
|
||||
|
||||
@@ -96,7 +96,8 @@ fn xp_reward(m: &Mob) -> i32 {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn is_animal(s: Species) -> bool {
|
||||
/// The mobs that extend `Animal`.
|
||||
pub fn is_animal(s: Species) -> bool {
|
||||
use Species as S;
|
||||
matches!(
|
||||
s,
|
||||
|
||||
@@ -41,9 +41,12 @@ pub struct BakedModel {
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
choices: Vec<CompiledChoices>,
|
||||
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
|
||||
baked: BTreeMap<VariantKey, BakedModel>,
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
@@ -85,7 +88,7 @@ impl StateModels {
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<Self> {
|
||||
let mut choices = Vec::new();
|
||||
let mut states = BTreeMap::new();
|
||||
let mut states = vec![None; registry.len()];
|
||||
let mut variants = BTreeSet::new();
|
||||
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -178,7 +181,7 @@ impl StateModels {
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states.insert(id, (choices.len(), selected));
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
@@ -211,7 +214,8 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let mut baked = BTreeMap::new();
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
@@ -233,16 +237,22 @@ impl StateModels {
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
baked.insert(
|
||||
(name, turns, lock),
|
||||
BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
},
|
||||
);
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
states,
|
||||
@@ -260,26 +270,47 @@ impl StateModels {
|
||||
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
|
||||
/// state's first variant (see `CompiledChoices::particle_variant`).
|
||||
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
|
||||
let (choice, selected) = self.states.get(&state)?;
|
||||
let variant = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked.get(&key(variant))?.particle
|
||||
let (choice, selected) = self.state(state)?;
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
) -> Result<Vec<(&Variant, &BakedModel)>> {
|
||||
let (choice, selected) = self
|
||||
.states
|
||||
.get(&state)
|
||||
.ok_or("unbound gallery block state")?;
|
||||
let mut models = Vec::new();
|
||||
self.each_seeded(state, seed, |(variant, model)| {
|
||||
models.push((variant, model))
|
||||
})?;
|
||||
Ok(models)
|
||||
}
|
||||
/// The models a block draws at `position`, into `out` (cleared first):
|
||||
/// `collect` for the mesher, which reuses one list for every block.
|
||||
pub fn models_at<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
position: BlockPos,
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
mut f: impl FnMut(&'a (Variant, BakedModel)),
|
||||
) -> Result<()> {
|
||||
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut variants = Vec::new();
|
||||
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
|
||||
variants
|
||||
.into_iter()
|
||||
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
|
||||
.collect()
|
||||
self.choices[*choice].for_each(selected, &mut random, |&model| {
|
||||
f(&self.baked[model as usize]);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
|
||||
self.states.get(state.0 as usize)?.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// A client's copy of its own player (`Level.isClientSide`): only the
|
||||
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
|
||||
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
|
||||
pub client_side: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -465,6 +469,7 @@ impl PlayerMotion {
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
client_side: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -2860,6 +2865,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A client's own fall (multiplayer, a LAN guest, single player on its
|
||||
/// server thread) plays its landing but never hurts it: only the server
|
||||
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
|
||||
/// leaves the screen still.
|
||||
#[test]
|
||||
fn a_clients_own_fall_does_not_hurt_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for client_side in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
|
||||
player.client_side = client_side;
|
||||
player.fall_distance = 30.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
|
||||
assert!(w.player.ground.on_ground);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
// LivingEntity.causeFallDamage's sounds play on both sides.
|
||||
assert!(w.player.events.iter().any(|e| matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::BigFall,
|
||||
..
|
||||
})
|
||||
)));
|
||||
let hurt = w.player.health.health() < 20.0;
|
||||
assert_eq!(hurt, !client_side);
|
||||
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
|
||||
// The server's health then starts the client's hurt animation.
|
||||
if client_side {
|
||||
w.player.health.flash_health_change(20.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 0);
|
||||
w.player.health.flash_health_change(12.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -147,7 +147,12 @@ impl Session {
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Send a play packet; silently dropped once the connection is gone.
|
||||
/// Send a play packet; silently dropped once the connection is gone,
|
||||
/// and while the server has the connection back in configuration (a
|
||||
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
|
||||
/// the level before it acknowledges, so no play packet follows the
|
||||
/// acknowledgement; one that did would reach the server as a
|
||||
/// configuration packet, and Velocity drops such a client.
|
||||
pub fn send(&self, packet: play::Serverbound) {
|
||||
if self.debug
|
||||
&& !matches!(
|
||||
@@ -159,7 +164,11 @@ impl Session {
|
||||
{
|
||||
eprintln!("[net] -> {packet:?}");
|
||||
}
|
||||
if let Some(w) = self.writer.lock().unwrap().as_mut()
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
if self.configuring.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&packet.encode()).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
@@ -686,6 +695,60 @@ mod tests {
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_packets_wait_out_a_reconfiguration() {
|
||||
let (client, server) = horizon_protocol::connection::Pipe::pair();
|
||||
let (mut reader, writer) = Connection::local(client).split();
|
||||
let (send, events) = channel();
|
||||
let session = Session {
|
||||
events,
|
||||
writer: Arc::new(Mutex::new(Some(writer))),
|
||||
configuring: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
address: "local".into(),
|
||||
name: "Player".into(),
|
||||
counters: Arc::new(Mutex::new(None)),
|
||||
debug: false,
|
||||
};
|
||||
let (thread_writer, thread_configuring) =
|
||||
(session.writer.clone(), session.configuring.clone());
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let tick_end = play::Serverbound::ClientTickEnd(Empty);
|
||||
server
|
||||
.send(&play::Clientbound::StartConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
play::Serverbound::decode(id, &body).unwrap(),
|
||||
play::Serverbound::ConfigurationAcknowledged(Empty)
|
||||
);
|
||||
// The game thread still ticks the old level: nothing of it goes out.
|
||||
session.send(tick_end.clone());
|
||||
server
|
||||
.send(&cfg::Clientbound::FinishConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
cfg::Serverbound::decode(id, &body).unwrap(),
|
||||
cfg::Serverbound::FinishConfiguration(Empty)
|
||||
);
|
||||
// Back in play, the game's packets flow again.
|
||||
session.send(tick_end.clone());
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(play::Serverbound::decode(id, &body).unwrap(), tick_end);
|
||||
drop(session);
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
|
||||
let (send, events) = channel();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -148,10 +148,11 @@ impl PerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.add_volume(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
self.add_volume::<true>(buffer, volume, xz_scale, y_scale, amplitude, None)
|
||||
}
|
||||
|
||||
fn add_volume(
|
||||
/// `BATCH` = false evaluates one Y at a time: the reference the batched loop is tested against.
|
||||
fn add_volume<const BATCH: bool>(
|
||||
&self,
|
||||
buffer: &mut [f32],
|
||||
volume: DensityVolume,
|
||||
@@ -207,7 +208,10 @@ impl PerlinNoise {
|
||||
let mut previous_y = i32::MIN;
|
||||
let mut xz_dots = [0.0_f32; 8];
|
||||
let mut y_gradients = [0.0_f32; 8];
|
||||
for (sample, out) in ys.iter().zip(&mut buffer[index..index + size_y]) {
|
||||
let column = &mut buffer[index..index + size_y];
|
||||
let mut y = 0;
|
||||
while y < size_y {
|
||||
let sample = &ys[y];
|
||||
if previous_y != sample.cell {
|
||||
let gradients = self.gradients_at(p, q, sample.cell, cell_z);
|
||||
for i in 0..8 {
|
||||
@@ -217,11 +221,31 @@ impl PerlinNoise {
|
||||
}
|
||||
previous_y = sample.cell;
|
||||
}
|
||||
// Four Ys in one cell share the corners: side by side, LLVM computes them
|
||||
// as vectors. Cell boundaries and the column's tail go one at a time.
|
||||
if BATCH
|
||||
&& y + 4 <= size_y
|
||||
&& ys[y + 1..y + 4].iter().all(|s| s.cell == sample.cell)
|
||||
{
|
||||
add_four(
|
||||
&mut column[y..y + 4],
|
||||
&ys[y..y + 4],
|
||||
xz_dots,
|
||||
y_gradients,
|
||||
fade_x,
|
||||
fade_z,
|
||||
amplitude,
|
||||
);
|
||||
y += 4;
|
||||
continue;
|
||||
}
|
||||
let out = &mut column[y];
|
||||
let dy = sample.offset;
|
||||
let values = std::array::from_fn(|i| {
|
||||
xz_dots[i] + y_gradients[i] * (dy - ((i >> 1) & 1) as f32)
|
||||
});
|
||||
*out += amplitude * lerp3([fade_x, sample.fade, fade_z], values);
|
||||
y += 1;
|
||||
}
|
||||
index += size_y;
|
||||
}
|
||||
@@ -293,7 +317,7 @@ impl SmearedPerlinNoise {
|
||||
y_scale: f64,
|
||||
amplitude: f32,
|
||||
) -> Result<(), DensityError> {
|
||||
self.base.add_volume(
|
||||
self.base.add_volume::<true>(
|
||||
buffer,
|
||||
volume,
|
||||
xz_scale,
|
||||
@@ -312,3 +336,97 @@ fn compute_fudge_y(input_y: f64, relative_y: f64, scale: f64) -> f64 {
|
||||
};
|
||||
f64::from(floor_i32(limited_y / scale + f64::from(1e-7_f32))) * scale
|
||||
}
|
||||
|
||||
/// Adds four Y samples of one cell, laid out lane by lane so LLVM vectorizes them (no intrinsics,
|
||||
/// no target feature). Each lane does the one-at-a-time loop's operations in the same order.
|
||||
#[inline]
|
||||
fn add_four(
|
||||
out: &mut [f32],
|
||||
ys: &[YSample],
|
||||
dots: [f32; 8],
|
||||
gradients: [f32; 8],
|
||||
fade_x: f32,
|
||||
fade_z: f32,
|
||||
amplitude: f32,
|
||||
) {
|
||||
let dy: [f32; 4] = std::array::from_fn(|lane| ys[lane].offset);
|
||||
let fy: [f32; 4] = std::array::from_fn(|lane| ys[lane].fade);
|
||||
let values: [[f32; 4]; 8] = std::array::from_fn(|i| {
|
||||
std::array::from_fn(|lane| dots[i] + gradients[i] * (dy[lane] - ((i >> 1) & 1) as f32))
|
||||
});
|
||||
for lane in 0..4 {
|
||||
out[lane] += amplitude * lerp3([fade_x, fy[lane], fade_z], values.map(|v| v[lane]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn batched_volume_is_bit_identical() {
|
||||
let scales = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.001,
|
||||
-0.001,
|
||||
0.125,
|
||||
-0.5,
|
||||
1.0,
|
||||
4.0,
|
||||
1e100,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NAN,
|
||||
];
|
||||
let amplitudes = [
|
||||
0.0,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NAN,
|
||||
f32::from_bits(1),
|
||||
];
|
||||
for seed in [0, -1, 1234, i64::MIN] {
|
||||
let noise = PerlinNoise::new(&mut LegacyRandom::new(seed));
|
||||
for height in [1, 2, 3, 4, 5, 7, 8, 9, 31, 64, 65, 97] {
|
||||
for (case, &scale) in scales.iter().enumerate() {
|
||||
let minimum = [
|
||||
[-17, -64, 49],
|
||||
[i32::MAX, i32::MIN, -16777216],
|
||||
[16777216, 33554431, -33554432],
|
||||
][case % 3];
|
||||
let volume = DensityVolume::new(
|
||||
[2, height, 2],
|
||||
minimum,
|
||||
[4, if case % 3 == 0 { i32::MAX } else { 1 }, 16],
|
||||
)
|
||||
.unwrap();
|
||||
for fudge in [None, Some(0.125), Some(-0.25), Some(0.0), Some(f64::NAN)] {
|
||||
let initial = [0.0, -0.0, 1.5, f32::from_bits(1)];
|
||||
let mut a: Vec<_> =
|
||||
(0..volume.size() + 3).map(|i| initial[i % 4]).collect();
|
||||
let mut b = a.clone();
|
||||
let amplitude = amplitudes[case % amplitudes.len()];
|
||||
noise
|
||||
.add_volume::<false>(&mut a, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
noise
|
||||
.add_volume::<true>(&mut b, volume, scale, scale, amplitude, fudge)
|
||||
.unwrap();
|
||||
for (i, (a, b)) in a.iter().zip(&b).enumerate() {
|
||||
assert_eq!(
|
||||
a.to_bits(),
|
||||
b.to_bits(),
|
||||
"seed={seed} height={height} scale={scale} fudge={fudge:?} index={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ wasmtime = { version = "36.0.18", default-features = false, features = ["runtime
|
||||
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
|
||||
wit-component = { version = "0.239", optional = true }
|
||||
wit-parser = { version = "0.239", optional = true }
|
||||
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
minisign-verify = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
@@ -24,6 +27,8 @@ wit-parser = { version = "0.239", optional = true }
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
|
||||
index = ["dep:sha2", "dep:minisign-verify"]
|
||||
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! The mod registry's index, `index.json` (docs/api/mod-index.md): every
|
||||
//! reviewed mod and modpack, its versions, the file of each with its size,
|
||||
//! SHA-256 and minisign signature, and the files taken down. The launcher
|
||||
//! reads it to browse and install, the website to show it, and the
|
||||
//! registry's CI writes it.
|
||||
//!
|
||||
//! [`check_file`] is what a file must pass before it is installed: its size
|
||||
//! and hash are the ones the index lists, its signature verifies against the
|
||||
//! project's pinned key, and what its own manifest says matches the entry.
|
||||
use crate::{
|
||||
Capability, Package, Side,
|
||||
modpack::{self, Modpack},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::Read, path::Path};
|
||||
|
||||
/// The index format this build reads and writes. A reader refuses a newer
|
||||
/// one rather than misread it.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
/// The biggest file the registry takes (a `.hmod` with content, a `.hpack`).
|
||||
pub const MAX_FILE: u64 = 64 << 20;
|
||||
/// Where files may be downloaded from: the registry's release assets.
|
||||
pub const FILE_HOST: &str = "https://github.com/ludovic111/horizon-mods/releases/download/";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Index {
|
||||
pub schema: u32,
|
||||
/// When CI built it, RFC 3339.
|
||||
#[serde(default)]
|
||||
pub generated: String,
|
||||
#[serde(default)]
|
||||
pub projects: Vec<Project>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revoked>,
|
||||
}
|
||||
|
||||
/// What a project is. New kinds (data packs, worlds, themes) can be added
|
||||
/// without a new schema: a reader keeps a kind it doesn't know as
|
||||
/// [`Kind::Other`] and doesn't offer it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
ResourcePack,
|
||||
/// A Horizon shader pack (the native WGSL format, not Iris or OptiFine
|
||||
/// packs). Listed by the schema; submissions open with its API.
|
||||
Shader,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
Kind::Other(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of its files; None for a kind this build doesn't know.
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack | Kind::Shader => Some("zip"),
|
||||
Kind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
_ => Kind::Other(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Kind> for String {
|
||||
fn from(kind: Kind) -> String {
|
||||
kind.name().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// The manifest's id, `[a-z0-9_]{1,64}`: unique across the index.
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
/// One line for lists.
|
||||
pub summary: String,
|
||||
/// Markdown, for the project's page.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The GitHub logins allowed to publish its updates.
|
||||
#[serde(default)]
|
||||
pub owners: Vec<String>,
|
||||
/// An SPDX expression.
|
||||
pub license: String,
|
||||
/// Where its source code is, `https://`.
|
||||
pub source: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// The author's minisign public key: every version must be signed with
|
||||
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Newest first.
|
||||
pub versions: Vec<Version>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
pub version: String,
|
||||
/// The day it was published, `YYYY-MM-DD`.
|
||||
#[serde(default)]
|
||||
pub published: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub changelog: String,
|
||||
/// A mod's API line (`0.1`), from its manifest; a modpack's format (`1`).
|
||||
pub api: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub side: Option<Side>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// A modpack's mods, as its manifest lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub requires: Vec<Requirement>,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Requirement {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct File {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
/// Lowercase hex.
|
||||
pub sha256: String,
|
||||
/// The `.minisig` file's text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// A file taken down: by hash, or every version of `id` when `sha256` is
|
||||
/// None. The launcher refuses to install it and turns installed copies off.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Revoked {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
/// How far a checked file is trusted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Trust {
|
||||
/// Signed with the project's pinned key.
|
||||
Verified,
|
||||
/// The project has no key: only the hash pins it.
|
||||
Unsigned,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses and checks an index (see [`Index::check`]).
|
||||
pub fn parse(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, false)
|
||||
}
|
||||
|
||||
/// [`Index::parse`] for a local test registry, whose files may be
|
||||
/// `file://` paths (trying a registry out before it is published).
|
||||
pub fn parse_local(text: &str) -> Result<Index, String> {
|
||||
Self::parse_with(text, true)
|
||||
}
|
||||
|
||||
fn parse_with(text: &str, local_files: bool) -> Result<Index, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
schema: u32,
|
||||
}
|
||||
let head: Head = serde_json::from_str(text).map_err(|e| format!("not a mod index: {e}"))?;
|
||||
if head.schema > SCHEMA {
|
||||
return Err(format!(
|
||||
"the mod index is schema {}; this build reads {SCHEMA}: update Horizon",
|
||||
head.schema
|
||||
));
|
||||
}
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("mod index: {e}"))?;
|
||||
index.check_with(local_files)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Every rule a reader relies on: unique valid ids, at least one
|
||||
/// version each, plain file names with the kind's extension, `https`
|
||||
/// URLs, hex hashes, keys and signatures that decode, and each
|
||||
/// modpack's mods present in the index with the same hash.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.check_with(false)
|
||||
}
|
||||
|
||||
fn check_with(&self, local_files: bool) -> Result<(), String> {
|
||||
for (i, p) in self.projects.iter().enumerate() {
|
||||
let at = |e: String| format!("{}: {e}", p.id);
|
||||
if !valid_id(&p.id) {
|
||||
return Err(format!("project id {:?} is not [a-z0-9_]{{1,64}}", p.id));
|
||||
}
|
||||
if self.projects[..i].iter().any(|q| q.id == p.id) {
|
||||
return Err(at("listed twice".into()));
|
||||
}
|
||||
if p.name.trim().is_empty() || p.license.trim().is_empty() {
|
||||
return Err(at("needs a name and a licence".into()));
|
||||
}
|
||||
https(&p.source).map_err(at)?;
|
||||
if let Some(icon) = &p.icon {
|
||||
https(icon).map_err(at)?;
|
||||
}
|
||||
if let Some(key) = &p.key {
|
||||
minisign_verify::PublicKey::from_base64(key.trim())
|
||||
.map_err(|e| at(format!("key: {e}")))?;
|
||||
}
|
||||
if p.versions.is_empty() {
|
||||
return Err(at("has no version".into()));
|
||||
}
|
||||
for (j, v) in p.versions.iter().enumerate() {
|
||||
let at = |e: String| format!("{} {}: {e}", p.id, v.version);
|
||||
if v.version.trim().is_empty()
|
||||
|| p.versions[..j].iter().any(|w| w.version == v.version)
|
||||
{
|
||||
return Err(at("empty or repeated version".into()));
|
||||
}
|
||||
let f = &v.file;
|
||||
let extension_ok = p
|
||||
.kind
|
||||
.extension()
|
||||
.is_none_or(|e| f.name.ends_with(&format!(".{e}")));
|
||||
if !plain_name(&f.name) || !extension_ok {
|
||||
return Err(at(format!(
|
||||
"{:?} is not a plain file name for a {}",
|
||||
f.name,
|
||||
p.kind.name()
|
||||
)));
|
||||
}
|
||||
if !(local_files && f.url.starts_with("file:///")) {
|
||||
https(&f.url).map_err(at)?;
|
||||
}
|
||||
hex64(&f.sha256).map_err(at)?;
|
||||
if f.size == 0 || f.size > MAX_FILE {
|
||||
return Err(at(format!(
|
||||
"size {} is not 1 byte to {} MiB",
|
||||
f.size,
|
||||
MAX_FILE >> 20
|
||||
)));
|
||||
}
|
||||
match (&p.key, &f.signature) {
|
||||
(Some(_), None) => {
|
||||
return Err(at("unsigned, but the project has a key".into()));
|
||||
}
|
||||
(None, Some(_)) => return Err(at("signed, but the project has no key".into())),
|
||||
(Some(_), Some(sig)) => {
|
||||
minisign_verify::Signature::decode(sig)
|
||||
.map_err(|e| at(format!("signature: {e}")))?;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for r in &v.requires {
|
||||
let found = self
|
||||
.project(&r.id)
|
||||
.and_then(|q| q.versions.iter().find(|w| w.version == r.version));
|
||||
match found {
|
||||
Some(w) if w.file.sha256 == r.sha256 => {}
|
||||
Some(_) => {
|
||||
return Err(at(format!(
|
||||
"{} {}: another file than the index's",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(at(format!(
|
||||
"needs {} {}, which is not in the index",
|
||||
r.id, r.version
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in &self.revoked {
|
||||
if let Some(h) = &r.sha256 {
|
||||
hex64(h).map_err(|e| format!("revoked {}: {e}", r.id))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project(&self, id: &str) -> Option<&Project> {
|
||||
self.projects.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Why the file of `id` with this hash was taken down, if it was.
|
||||
pub fn revoked(&self, id: &str, sha256: &str) -> Option<&Revoked> {
|
||||
self.revoked.iter().find(|r| {
|
||||
r.id == id
|
||||
&& r.sha256
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.eq_ignore_ascii_case(sha256))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9_]{1,64}`, the manifests' id rule.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len())
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
fn https(url: &str) -> Result<(), String> {
|
||||
match url.strip_prefix("https://") {
|
||||
Some(rest) if !rest.is_empty() && !url.chars().any(char::is_whitespace) => Ok(()),
|
||||
_ => Err(format!("{url:?} is not an https:// address")),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex64(h: &str) -> Result<(), String> {
|
||||
if h.len() == 64
|
||||
&& h.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{h:?} is not a lowercase hex SHA-256"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One plain file name: no folder, no hidden file, nothing a file system
|
||||
/// reads as something else.
|
||||
fn plain_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 128
|
||||
&& !name.starts_with('.')
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
||||
}
|
||||
|
||||
/// The SHA-256 of a file, lowercase hex.
|
||||
pub fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Checks a minisign signature (`.minisig` text) of `path` against `key`.
|
||||
pub fn verify_signature(key: &str, signature: &str, path: &Path) -> Result<(), String> {
|
||||
let key =
|
||||
minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("key: {e}"))?;
|
||||
let sig =
|
||||
minisign_verify::Signature::decode(signature).map_err(|e| format!("signature: {e}"))?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut verifier = key
|
||||
.verify_stream(&sig)
|
||||
.map_err(|e| format!("signature: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
verifier.update(&buf[..n]);
|
||||
}
|
||||
verifier
|
||||
.finalize()
|
||||
.map_err(|_| "the signature doesn't match the project's key".to_string())
|
||||
}
|
||||
|
||||
/// Checks the file at `path` against its entry before it is installed:
|
||||
/// size, SHA-256, revocation, signature, and that its manifest says what the
|
||||
/// index says (id, version, API, side, capabilities; a modpack's mods).
|
||||
pub fn check_file(
|
||||
index: &Index,
|
||||
project: &Project,
|
||||
version: &Version,
|
||||
path: &Path,
|
||||
) -> Result<Trust, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size != version.file.size {
|
||||
return Err(format!(
|
||||
"{} bytes, the index lists {}",
|
||||
size, version.file.size
|
||||
));
|
||||
}
|
||||
let hash = sha256_file(path)?;
|
||||
if hash != version.file.sha256 {
|
||||
return Err("its SHA-256 isn't the one the index lists: nothing was installed".into());
|
||||
}
|
||||
if let Some(r) = index.revoked(&project.id, &hash) {
|
||||
return Err(format!("{} was taken down: {}", project.name, r.reason));
|
||||
}
|
||||
let trust = match (&project.key, &version.file.signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
verify_signature(key, sig, path)?;
|
||||
Trust::Verified
|
||||
}
|
||||
(None, None) => Trust::Unsigned,
|
||||
_ => return Err("its signature and the project's key don't go together".into()),
|
||||
};
|
||||
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
|
||||
return Err(format!(
|
||||
"this Horizon can't install a {} yet",
|
||||
project.kind.name()
|
||||
));
|
||||
}
|
||||
inspect(&project.kind, path)?.matches(project, version)?;
|
||||
Ok(trust)
|
||||
}
|
||||
|
||||
/// What a file says about itself, read by its kind's own check
|
||||
/// ([`inspect`]). The registry's CI writes these into the index; the
|
||||
/// launcher compares them with the index before it installs.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
/// A mod's or a modpack's manifest id; None for a resource pack.
|
||||
pub id: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// A mod's API line, a modpack's format, a resource pack's pack format.
|
||||
pub api: String,
|
||||
pub side: Option<Side>,
|
||||
/// In the manifest's order.
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// The Minecraft version, when the file names one (a modpack does).
|
||||
pub game: Option<String>,
|
||||
pub requires: Vec<Requirement>,
|
||||
}
|
||||
|
||||
impl Facts {
|
||||
/// Whether the file is the one `version` of `project` describes.
|
||||
pub fn matches(&self, project: &Project, version: &Version) -> Result<(), String> {
|
||||
let (mut caps, mut listed) = (self.capabilities.clone(), version.capabilities.clone());
|
||||
caps.sort();
|
||||
listed.sort();
|
||||
let differs = self.id.as_ref().is_some_and(|id| *id != project.id)
|
||||
|| self.version.as_ref().is_some_and(|v| *v != version.version)
|
||||
|| self.api != version.api
|
||||
|| self.side != version.side
|
||||
|| caps != listed
|
||||
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|
||||
|| self.requires != version.requires;
|
||||
if !differs {
|
||||
return Ok(());
|
||||
}
|
||||
Err(match project.kind {
|
||||
Kind::Mod => format!(
|
||||
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
|
||||
self.id.as_deref().unwrap_or("?"),
|
||||
self.version.as_deref().unwrap_or("?"),
|
||||
self.api,
|
||||
self.side,
|
||||
names(&self.capabilities)
|
||||
),
|
||||
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
|
||||
_ => format!("its pack format {} isn't what the index lists", self.api),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and checks a file as its kind: a mod's manifest and content, a
|
||||
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
|
||||
/// paths. Shader packs and kinds this build doesn't know are refused: their
|
||||
/// checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::Mod => {
|
||||
let m = Package::read(path)?.manifest;
|
||||
crate::Content::read(path, &m)?;
|
||||
Ok(Facts {
|
||||
capabilities: m.capabilities,
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
api: m.api,
|
||||
side: Some(m.side),
|
||||
..Facts::default()
|
||||
})
|
||||
}
|
||||
Kind::Modpack => {
|
||||
let m = Modpack::read(path)?.manifest;
|
||||
Ok(Facts {
|
||||
requires: m.mods.iter().map(modpack::PackMod::requirement).collect(),
|
||||
id: Some(m.id),
|
||||
version: Some(m.version),
|
||||
api: m.format.to_string(),
|
||||
game: Some(m.game),
|
||||
..Facts::default()
|
||||
})
|
||||
}
|
||||
Kind::ResourcePack => Ok(Facts {
|
||||
api: check_resource_pack(path)?,
|
||||
..Facts::default()
|
||||
}),
|
||||
Kind::Shader => Err(
|
||||
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
|
||||
),
|
||||
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
|
||||
/// object, and only plain paths. Returns its pack format (`pack_format`, or
|
||||
/// `min_format` in newer packs), empty when it names none.
|
||||
fn check_resource_pack(path: &Path) -> Result<String, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
for i in 0..zip.len() {
|
||||
let name = zip
|
||||
.by_index_raw(i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.name()
|
||||
.to_owned();
|
||||
if name.starts_with('/') || name.split('/').any(|part| part == "..") || name.contains('\\')
|
||||
{
|
||||
return Err(format!("{name:?} is not a plain path"));
|
||||
}
|
||||
}
|
||||
let mut meta = String::new();
|
||||
zip.by_name("pack.mcmeta")
|
||||
.map_err(|_| "pack.mcmeta: missing".to_string())?
|
||||
.take(1 << 20)
|
||||
.read_to_string(&mut meta)
|
||||
.map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
|
||||
let pack = &json["pack"];
|
||||
if !pack.is_object() {
|
||||
return Err("pack.mcmeta: no \"pack\" object".into());
|
||||
}
|
||||
let format = [&pack["pack_format"], &pack["min_format"]]
|
||||
.into_iter()
|
||||
.find(|f| !f.is_null())
|
||||
.map(|f| match f {
|
||||
serde_json::Value::Array(parts) => parts
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("."),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// The kinds the registry takes submissions for. Shader packs wait for
|
||||
/// Horizon's own format; other kinds for theirs.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
|
||||
Kind::Shader => Err(
|
||||
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
|
||||
),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(caps: &[Capability]) -> String {
|
||||
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
pub mod registry;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,415 @@
|
||||
//! The registry repository (`ludovic111/horizon-mods`, docs/api/mod-index.md):
|
||||
//! one `mods/<id>.toml` per project, written by the submission form or by
|
||||
//! hand and reviewed in a pull request, plus `revoked.toml`. Its CI turns
|
||||
//! them into `index.json` with [`build`], reading every listed file, and
|
||||
//! checks a pull request's changes with [`review`].
|
||||
//!
|
||||
//! What an entry can't say for itself (a mod's API, side and capabilities,
|
||||
//! a modpack's mods, a file's size) is read from the file, never typed.
|
||||
pub use super::open;
|
||||
use super::{File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The folder of entries in the registry repository.
|
||||
pub const ENTRIES: &str = "mods";
|
||||
/// The list of files taken down.
|
||||
pub const REVOKED: &str = "revoked.toml";
|
||||
/// Ids the site uses for its own pages (`/mods/submit`).
|
||||
const RESERVED: [&str; 1] = ["submit"];
|
||||
/// The biggest icon, a PNG in `icons/`.
|
||||
const MAX_ICON: u64 = 256 << 10;
|
||||
|
||||
/// `mods/<id>.toml`.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// GitHub logins allowed to publish updates.
|
||||
pub owners: Vec<String>,
|
||||
pub license: String,
|
||||
pub source: String,
|
||||
/// `icons/<id>.png` in the registry repository, or an `https://` address.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// The author's minisign public key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Newest first.
|
||||
pub versions: Vec<EntryVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EntryVersion {
|
||||
pub version: String,
|
||||
/// `YYYY-MM-DD`.
|
||||
pub published: String,
|
||||
/// The Minecraft version it is for.
|
||||
pub game: String,
|
||||
/// The name players download, with the kind's extension.
|
||||
pub file: String,
|
||||
/// Lowercase hex: pins the reviewed file.
|
||||
pub sha256: String,
|
||||
/// The `.minisig` text, when the project has a key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub changelog: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RevokedFile {
|
||||
#[serde(default)]
|
||||
revoked: Vec<Revoked>,
|
||||
}
|
||||
|
||||
/// Where [`build`] reads and what it writes into the index.
|
||||
pub struct Sources<'a> {
|
||||
/// A checkout of the registry repository.
|
||||
pub registry: &'a Path,
|
||||
/// Every listed file, named by its SHA-256 (CI downloads them there).
|
||||
pub files: &'a Path,
|
||||
/// Where the files are published: `<files>/<id>-<version>/<file>`
|
||||
/// ([`super::FILE_HOST`]; `file:///` for a local test registry).
|
||||
pub release: &'a str,
|
||||
/// Where the registry's site is (its icons): `https://.../horizon-mods/`.
|
||||
pub site: &'a str,
|
||||
/// RFC 3339.
|
||||
pub generated: String,
|
||||
}
|
||||
|
||||
/// The release tag a version's file is published under.
|
||||
pub fn tag(id: &str, version: &str) -> String {
|
||||
format!("{id}-{version}")
|
||||
}
|
||||
|
||||
/// Reads `mods/*.toml` and `revoked.toml`: every problem found, not only the
|
||||
/// first, so a pull request's check lists them all.
|
||||
pub fn load(registry: &Path) -> Result<(Vec<Entry>, Vec<Revoked>), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
let dir = registry.join(ENTRIES);
|
||||
let mut paths: Vec<PathBuf> = match std::fs::read_dir(&dir) {
|
||||
Ok(read) => read.filter_map(|e| e.ok().map(|e| e.path())).collect(),
|
||||
Err(e) => return Err(vec![format!("{}: {e}", dir.display())]),
|
||||
};
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let Some(stem) = name.strip_suffix(".toml") else {
|
||||
errors.push(format!("{ENTRIES}/{name}: only <id>.toml files go here"));
|
||||
continue;
|
||||
};
|
||||
let parsed = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| toml::from_str::<Entry>(&text).map_err(|e| e.to_string()));
|
||||
match parsed {
|
||||
Ok(entry) if entry.id == stem => entries.push(entry),
|
||||
Ok(entry) => errors.push(format!(
|
||||
"{ENTRIES}/{name}: its id is {:?}; the file must be {ENTRIES}/{}.toml",
|
||||
entry.id, entry.id
|
||||
)),
|
||||
Err(e) => errors.push(format!("{ENTRIES}/{name}: {e}")),
|
||||
}
|
||||
}
|
||||
let revoked = match std::fs::read_to_string(registry.join(REVOKED)) {
|
||||
Ok(text) => match toml::from_str::<RevokedFile>(&text) {
|
||||
Ok(file) => file.revoked,
|
||||
Err(e) => {
|
||||
errors.push(format!("{REVOKED}: {e}"));
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
errors.push(format!("{REVOKED}: {e}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if errors.is_empty() {
|
||||
Ok((entries, revoked))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// A version string that is also a plain release tag and file name part.
|
||||
fn plain_version(v: &str) -> bool {
|
||||
!v.is_empty()
|
||||
&& v.len() <= 64
|
||||
&& v.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& v.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
|
||||
}
|
||||
|
||||
fn plain_date(d: &str) -> bool {
|
||||
let b = d.as_bytes();
|
||||
b.len() == 10
|
||||
&& b[4] == b'-'
|
||||
&& b[7] == b'-'
|
||||
&& b.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// One project's entry and its files, as the index lists them.
|
||||
pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let id = &entry.id;
|
||||
if !valid_id(id) {
|
||||
return Err(vec![format!("{id:?} is not [a-z0-9_]{{1,64}}")]);
|
||||
}
|
||||
if RESERVED.contains(&id.as_str()) {
|
||||
return Err(vec![format!("{id:?} is reserved: pick another id")]);
|
||||
}
|
||||
open(&entry.kind).map_err(|e| vec![format!("{id}: {e}")])?;
|
||||
if entry.owners.is_empty() {
|
||||
errors.push(format!("{id}: needs at least one owner (a GitHub login)"));
|
||||
}
|
||||
if entry.summary.chars().count() > 160 {
|
||||
errors.push(format!("{id}: the summary is over 160 characters"));
|
||||
}
|
||||
let icon = match entry.icon.as_deref() {
|
||||
None => None,
|
||||
Some(url) if url.starts_with("https://") => Some(url.to_owned()),
|
||||
Some(path) => {
|
||||
let ok = path
|
||||
.strip_prefix("icons/")
|
||||
.is_some_and(|name| super::plain_name(name) && name.ends_with(".png"));
|
||||
let size = std::fs::metadata(src.registry.join(path)).map(|m| m.len());
|
||||
match (ok, size) {
|
||||
(true, Ok(n)) if n <= MAX_ICON => Some(format!("{}{path}", src.site)),
|
||||
(true, Ok(_)) => {
|
||||
errors.push(format!("{id}: {path} is over {} KiB", MAX_ICON >> 10));
|
||||
None
|
||||
}
|
||||
(true, Err(e)) => {
|
||||
errors.push(format!("{id}: {path}: {e}"));
|
||||
None
|
||||
}
|
||||
(false, _) => {
|
||||
errors.push(format!(
|
||||
"{id}: icon {path:?} is neither icons/<name>.png nor https://"
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if entry.versions.is_empty() {
|
||||
errors.push(format!("{id}: has no version"));
|
||||
}
|
||||
let mut versions = Vec::new();
|
||||
for v in &entry.versions {
|
||||
match version(entry, v, src) {
|
||||
Ok(v) => versions.push(v),
|
||||
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
Ok(Project {
|
||||
id: id.clone(),
|
||||
kind: entry.kind.clone(),
|
||||
name: entry.name.clone(),
|
||||
summary: entry.summary.clone(),
|
||||
description: entry.description.clone(),
|
||||
authors: entry.authors.clone(),
|
||||
owners: entry.owners.clone(),
|
||||
license: entry.license.clone(),
|
||||
source: entry.source.clone(),
|
||||
icon,
|
||||
key: entry.key.as_ref().map(|k| k.trim().to_owned()),
|
||||
versions,
|
||||
})
|
||||
}
|
||||
|
||||
fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, String> {
|
||||
if !plain_version(&v.version) {
|
||||
return Err("a version is letters, digits, '.', '-' and '_'".into());
|
||||
}
|
||||
if !plain_date(&v.published) {
|
||||
return Err(format!("published {:?} is not YYYY-MM-DD", v.published));
|
||||
}
|
||||
hex64(&v.sha256)?;
|
||||
let path = src.files.join(&v.sha256);
|
||||
let size = std::fs::metadata(&path)
|
||||
.map_err(|e| format!("its file {}: {e}", v.sha256))?
|
||||
.len();
|
||||
if super::sha256_file(&path)? != v.sha256 {
|
||||
return Err("the file isn't the one its sha256 names".into());
|
||||
}
|
||||
match (&entry.key, &v.signature) {
|
||||
(Some(key), Some(sig)) => super::verify_signature(key, sig, &path)?,
|
||||
(Some(_), None) => return Err("the project has a key: sign the file with it".into()),
|
||||
(None, Some(_)) => return Err("signed, but the project has no key".into()),
|
||||
(None, None) => {}
|
||||
}
|
||||
let facts = inspect(&entry.kind, &path)?;
|
||||
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
|
||||
return Err(format!(
|
||||
"the file's id is {:?}, not {:?}",
|
||||
facts.id.unwrap_or_default(),
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
if facts.version.as_ref().is_some_and(|x| *x != v.version) {
|
||||
return Err(format!(
|
||||
"the file says version {:?}",
|
||||
facts.version.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if facts.game.as_ref().is_some_and(|g| *g != v.game) {
|
||||
return Err(format!(
|
||||
"the file is for {:?}, the entry says {:?}",
|
||||
facts.game.unwrap_or_default(),
|
||||
v.game
|
||||
));
|
||||
}
|
||||
Ok(Version {
|
||||
version: v.version.clone(),
|
||||
published: v.published.clone(),
|
||||
changelog: v.changelog.clone(),
|
||||
api: facts.api,
|
||||
side: facts.side,
|
||||
capabilities: facts.capabilities,
|
||||
game: v.game.clone(),
|
||||
requires: facts.requires,
|
||||
file: File {
|
||||
url: format!("{}{}/{}", src.release, tag(&entry.id, &v.version), v.file),
|
||||
name: v.file.clone(),
|
||||
size,
|
||||
sha256: v.sha256.clone(),
|
||||
signature: v.signature.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The entry as `mods/<id>.toml` holds it (the submission form writes it).
|
||||
pub fn to_toml(&self) -> Result<String, String> {
|
||||
toml::to_string(self).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the whole registry: every entry and file checked, then the
|
||||
/// index's own rules ([`Index::check`]: names, a modpack's mods present).
|
||||
pub fn build(src: &Sources) -> Result<Index, Vec<String>> {
|
||||
let (mut entries, revoked) = load(src.registry)?;
|
||||
entries.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut errors = Vec::new();
|
||||
let mut projects = Vec::new();
|
||||
for entry in &entries {
|
||||
match project(entry, src) {
|
||||
Ok(p) => projects.push(p),
|
||||
Err(e) => errors.extend(e),
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
let index = Index {
|
||||
schema: SCHEMA,
|
||||
generated: src.generated.clone(),
|
||||
projects,
|
||||
revoked,
|
||||
};
|
||||
let local = src.release.starts_with("file:///");
|
||||
index.check_with(local).map_err(|e| vec![e])?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// What a pull request changes, for its reviewers.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Review {
|
||||
/// Changes the registry refuses whoever reviews them.
|
||||
pub errors: Vec<String>,
|
||||
/// What reviewers should look at: new projects, new owners or keys, a
|
||||
/// change by someone who isn't an owner.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
|
||||
/// a published file never changes and is never removed (take it down in
|
||||
/// `revoked.toml`), a project's kind never changes, and changes by someone
|
||||
/// who isn't an owner, or to owners and keys, are pointed out.
|
||||
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
|
||||
let mut r = Review::default();
|
||||
for old in base {
|
||||
let Some(new) = head.iter().find(|e| e.id == old.id) else {
|
||||
r.errors.push(format!(
|
||||
"{}: removed; take its files down in {REVOKED} instead",
|
||||
old.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if new == old {
|
||||
continue;
|
||||
}
|
||||
if !old.owners.iter().any(|o| o.eq_ignore_ascii_case(author)) {
|
||||
r.notes.push(format!(
|
||||
"{}: changed by {author}, who isn't one of its owners ({})",
|
||||
old.id,
|
||||
old.owners.join(", ")
|
||||
));
|
||||
}
|
||||
if new.kind != old.kind {
|
||||
r.errors.push(format!("{}: its kind can't change", old.id));
|
||||
}
|
||||
for v in &old.versions {
|
||||
match new.versions.iter().find(|w| w.version == v.version) {
|
||||
None => r.errors.push(format!(
|
||||
"{} {}: removed; take it down in {REVOKED} instead",
|
||||
old.id, v.version
|
||||
)),
|
||||
Some(w) if w.sha256 != v.sha256 || w.file != v.file => r.errors.push(format!(
|
||||
"{} {}: a published file never changes: publish a new version",
|
||||
old.id, v.version
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if new.owners != old.owners {
|
||||
r.notes.push(format!(
|
||||
"{}: owners {} -> {}",
|
||||
old.id,
|
||||
old.owners.join(", "),
|
||||
new.owners.join(", ")
|
||||
));
|
||||
}
|
||||
if new.key != old.key {
|
||||
r.notes.push(format!(
|
||||
"{}: its signing key changes; updates signed with the old one stop working",
|
||||
old.id
|
||||
));
|
||||
}
|
||||
let added = new.versions.len().saturating_sub(old.versions.len());
|
||||
if added > 0 {
|
||||
r.notes.push(format!("{}: {added} new version(s)", old.id));
|
||||
}
|
||||
}
|
||||
for new in head {
|
||||
if !base.iter().any(|e| e.id == new.id) {
|
||||
r.notes.push(format!(
|
||||
"{}: a new {} by {}",
|
||||
new.id,
|
||||
new.kind.name(),
|
||||
new.owners.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,237 @@
|
||||
//! A registry checkout holding the sample index's mod and modpack builds the
|
||||
//! sample index (`tests/index/index-v1.json`) exactly.
|
||||
use super::*;
|
||||
|
||||
const HMOD: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
|
||||
const HPACK: &str = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65";
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
|
||||
}
|
||||
|
||||
fn sample() -> Index {
|
||||
Index::parse(&std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn hud_entry() -> String {
|
||||
let p = sample();
|
||||
let sig = p.project("coords_hud").unwrap().versions[0]
|
||||
.file
|
||||
.signature
|
||||
.clone()
|
||||
.unwrap();
|
||||
format!(
|
||||
r#"id = "coords_hud"
|
||||
kind = "mod"
|
||||
name = "Coordinates HUD"
|
||||
summary = "Your coordinates, facing and the time of day in a corner of the screen."
|
||||
description = "`/where` puts them in chat, `/coords` hides or shows the panel."
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/ludovic111/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud"
|
||||
icon = "icons/coords_hud.png"
|
||||
key = "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-24"
|
||||
game = "26.3"
|
||||
file = "coords_hud-0.1.0.hmod"
|
||||
sha256 = "{HMOD}"
|
||||
signature = """{sig}"""
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
const COZY: &str = r#"id = "cozy"
|
||||
kind = "modpack"
|
||||
name = "Cozy"
|
||||
summary = "Coordinates and a calm render distance."
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/ludovic111/horizon-mods"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-24"
|
||||
game = "26.3"
|
||||
file = "cozy-1.0.0.hpack"
|
||||
sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
|
||||
"#;
|
||||
|
||||
const REVOKED_TOML: &str = r#"[[revoked]]
|
||||
id = "bad_mod"
|
||||
sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
reason = "Sent chat messages it didn't declare."
|
||||
date = "2026-10-24"
|
||||
"#;
|
||||
|
||||
/// A registry checkout and its downloaded files, in a scratch folder.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Scratch {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-registry-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for sub in ["repo/mods", "repo/icons", "files"] {
|
||||
std::fs::create_dir_all(dir.join(sub)).unwrap();
|
||||
}
|
||||
let s = Scratch(dir);
|
||||
s.write("mods/coords_hud.toml", &hud_entry());
|
||||
s.write("mods/cozy.toml", COZY);
|
||||
s.write("revoked.toml", REVOKED_TOML);
|
||||
s.write("icons/coords_hud.png", "png");
|
||||
for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] {
|
||||
std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn write(&self, path: &str, text: &str) {
|
||||
std::fs::write(self.0.join("repo").join(path), text).unwrap();
|
||||
}
|
||||
|
||||
fn build(&self) -> Result<Index, Vec<String>> {
|
||||
let (repo, files) = (self.0.join("repo"), self.0.join("files"));
|
||||
build(&Sources {
|
||||
registry: &repo,
|
||||
files: &files,
|
||||
release: super::super::FILE_HOST,
|
||||
site: "https://ludovic111.github.io/horizon-mods/",
|
||||
generated: "2026-10-24T18:00:00Z".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_the_sample_index() {
|
||||
let s = Scratch::new("sample");
|
||||
let built = s.build().unwrap();
|
||||
let sample = sample();
|
||||
assert_eq!(built.projects, sample.projects[..2]);
|
||||
assert_eq!(built.revoked, sample.revoked);
|
||||
assert_eq!(built.generated, sample.generated);
|
||||
// What it writes reads back.
|
||||
Index::parse(&serde_json::to_string_pretty(&built).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_every_problem_of_a_registry() {
|
||||
let s = Scratch::new("bad");
|
||||
// A file that isn't the one its hash names, an unsigned version of a
|
||||
// signed project, a shader pack, and a mod whose file is another mod.
|
||||
std::fs::write(s.0.join("files").join(HPACK), b"other bytes").unwrap();
|
||||
s.write(
|
||||
"mods/coords_hud.toml",
|
||||
&hud_entry().replace("signature = \"\"\"", "changelog = \"\"\""),
|
||||
);
|
||||
s.write(
|
||||
"mods/glow.toml",
|
||||
&COZY
|
||||
.replace("\"cozy\"", "\"glow\"")
|
||||
.replace("modpack", "shader"),
|
||||
);
|
||||
s.write(
|
||||
"mods/other.toml",
|
||||
&hud_entry()
|
||||
.replace("\"coords_hud\"", "\"other\"")
|
||||
.replace("key = ", "# key = ")
|
||||
.replace("signature = \"\"\"", "changelog = \"\"\""),
|
||||
);
|
||||
let errors = s.build().unwrap_err();
|
||||
let has = |part: &str| errors.iter().any(|e| e.contains(part));
|
||||
assert!(
|
||||
has("cozy 1.0.0: the file isn't the one its sha256 names"),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}");
|
||||
assert!(has("glow: shader packs open when"), "{errors:?}");
|
||||
assert!(
|
||||
has("other 0.1.0: the file's id is \"coords_hud\""),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert_eq!(errors.len(), 4, "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_is_named_by_its_id_and_has_only_known_fields() {
|
||||
let s = Scratch::new("names");
|
||||
s.write("mods/hud.toml", &hud_entry());
|
||||
s.write("mods/notes.txt", "");
|
||||
s.write("mods/cozy.toml", &format!("{COZY}homepage = \"x\"\n"));
|
||||
let errors = load(&s.0.join("repo")).unwrap_err();
|
||||
assert_eq!(errors.len(), 3, "{errors:?}");
|
||||
assert!(errors[0].contains("cozy.toml") && errors[0].contains("homepage"));
|
||||
assert!(errors[1].contains("must be mods/coords_hud.toml"));
|
||||
assert!(errors[2].contains("only <id>.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_written_reads_back() {
|
||||
let entry: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
let text = entry.to_toml().unwrap();
|
||||
assert_eq!(toml::from_str::<Entry>(&text).unwrap(), entry);
|
||||
assert!(text.contains("[[versions]]"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_review_protects_published_files() {
|
||||
let base: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
let cozy: Entry = toml::from_str(COZY).unwrap();
|
||||
let one = std::slice::from_ref(&base);
|
||||
let same = review(one, one, "someone");
|
||||
assert_eq!(same, Review::default());
|
||||
|
||||
let mut update = base.clone();
|
||||
let mut next = update.versions[0].clone();
|
||||
next.version = "0.2.0".into();
|
||||
update.versions.insert(0, next);
|
||||
let r = review(one, std::slice::from_ref(&update), "ludovic111");
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert_eq!(r.notes, ["coords_hud: 1 new version(s)"]);
|
||||
let r = review(one, &[update], "stranger");
|
||||
assert!(r.notes[0].contains("isn't one of its owners"), "{r:?}");
|
||||
|
||||
let mut swapped = base.clone();
|
||||
swapped.versions[0].sha256 = HPACK.into();
|
||||
swapped.kind = Kind::ResourcePack;
|
||||
swapped.key = None;
|
||||
let r = review(&[base.clone(), cozy.clone()], &[swapped], "ludovic111");
|
||||
assert_eq!(r.errors.len(), 3, "{r:?}");
|
||||
assert!(r.errors.iter().any(|e| e.contains("cozy: removed")));
|
||||
assert!(r.errors.iter().any(|e| e.contains("never changes")));
|
||||
assert!(r.errors.iter().any(|e| e.contains("kind can't change")));
|
||||
assert!(r.notes.iter().any(|n| n.contains("signing key")));
|
||||
|
||||
let r = review(&[], &[cozy], "newcomer");
|
||||
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_open_kinds_are_taken() {
|
||||
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
|
||||
assert!(open(&Kind::Shader).unwrap_err().contains("Iris"));
|
||||
assert!(open(&Kind::Other("world".into())).is_err());
|
||||
let mut submit: Entry = toml::from_str(COZY).unwrap();
|
||||
submit.id = "submit".into();
|
||||
let dir = std::env::temp_dir();
|
||||
let src = Sources {
|
||||
registry: &dir,
|
||||
files: &dir,
|
||||
release: super::super::FILE_HOST,
|
||||
site: "https://x.test/",
|
||||
generated: String::new(),
|
||||
};
|
||||
assert!(project(&submit, &src).unwrap_err()[0].contains("reserved"));
|
||||
assert!(plain_version("1.0.0-beta.2") && !plain_version("../1") && !plain_version("1 0"));
|
||||
assert!(plain_date("2026-10-24") && !plain_date("24/10/2026"));
|
||||
}
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user