Compare commits

...
Author SHA1 Message Date
Ludovic Marie 6017830623 Merge pull request #1719 from rdbtCVS/t3/13ed5f77
Fix crops remaining after server-side farmland trampling
2026-10-25 08:16:06 +01:00
Ludovic MarieandClaude Sonnet 5.5 fb34815ce1 Merge main into server-farmland-trampling: keep both appended lan_e2e checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:07:28 +01:00
Ludovic Marie f49e3a2d24 Merge pull request #1726 from rdbtCVS/t3/9a51354a
Fix offhand eating and drinking cancellation
2026-10-25 08:07:06 +01:00
Ludovic Marie cb0865e242 Merge pull request #1724 from rdbtCVS/t3/19c12e83
Fix spectator collision after dimension travel
2026-10-25 08:07:03 +01:00
Ludovic Marie a27322f250 Merge pull request #1716 from rdbtCVS/t3/395312eb
Fix double-chest inventory and hotbar clicks
2026-10-25 08:05:50 +01:00
Ludovic Marie ed3243ca62 Merge pull request #1730 from rdbtCVS/fix/launcher-java-pairing
Launcher: use one compatible JDK for resource preparation
2026-10-25 08:05:45 +01:00
Ludovic Marie f59bb8b8d5 Merge pull request #1738 from rdbtCVS/fix/pre-push-ref-history
Scope pre-push history checks to pushed refs
2026-10-25 07:54:48 +01:00
Ludovic Marie 00705d8431 Merge pull request #1630 from some-du6e/fix/ipv4-first 2026-10-25 07:10:05 +01:00
Ludovic Marie 0564ab0bcd Merge pull request #1748 from ludovic111/fix/server-player-targets-barrier
server_player_targets harness: end each step by the server's clock, not 2.5 s
2026-10-25 06:39:50 +01:00
Ludovic Marie eb1fdf5fb4 Merge pull request #1745 from ludovic111/fix/command-exec-random-count
command_exec harness: start the script without random sequences rolled before the setup
2026-10-25 06:35:31 +01:00
Ludovic Marie 2ee008f8fa Merge pull request #1646 from ludovic111/feat/mod-registry-tools 2026-10-25 06:34:00 +01:00
Ludovic Marie 8a0303ed62 Merge pull request #1642 from ludovic111/feat/mod-check 2026-10-25 06:32:11 +01:00
Ludovic Marie e988b9b833 Merge pull request #1641 from ludovic111/perf/face-cull-cr1 2026-10-25 06:22:47 +01:00
Ludovic MarieandClaude Opus 5.5 08491bcda3 Java selection: keep Windows JDK homes free of the \\?\ prefix
fs::canonicalize returns verbatim paths on Windows, and the chosen home
goes into JAVA_HOME and the PATH of external preparers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:59:21 +01:00
Ludovic MarieandClaude Opus 5.5 5a0e650e63 Offhand use: one in-hand check for held and consumed items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:40 +01:00
Ludovic MarieandClaude Opus 5.5 3b586ff903 Server players: one helper finishes a landing's block updates, turtle eggs too
The server's player tick dropped FallEvent::Destroy, so an egg a guest
broke by landing left its neighbours unupdated. Both paths now share the
same helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 6acf7fb6ab server_player_targets harness: end each step by the server's clock
Each step waited a fixed 2.5 s. When the machine is loaded the vanilla server stalls for longer than that (CI on
#1571 and #1585: after Bob's trip to the Nether, vanilla's `tp Bob 4.5 -60 0.5` from the console ran a step late),
so its packets were filed under the next step and the transcripts differed although both servers sent the same.

A step now ends once the server has done its work:
- the console's own `time query gametime` comes back, so the step's console commands have run (the console runs
  its queue in order);
- LocatorBarClient's new `sync NAME` runs `time query gametime` as that player and waits for the reply (not
  recorded), so that player's commands have run and every packet sent before the reply is recorded;
- the server's game time has advanced STEP_TICKS (40) more ticks, then each player sends its position reminder
  and syncs once more.

game_time() moves out of settle() in test_dedicated_server_parity.py so both use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:46:22 +01:00
Ludovic MarieandClaude Opus 5.5 afb3ae79a2 command_exec harness: start the script without the random sequences of mobs that died before it
/random reset * in the ticking group counted 12 sequences in vanilla against Horizon's 11 on one CI run (#1585).
Locally 1 oracle run in 24 did the same, and its extra sequence was minecraft:entities/bat. Before the setup's
gamerules (spawn_mobs and mob_drops off), the oracle ticks until the entities around the player have loaded, a
number of ticks that depends on the machine; mobs spawn meanwhile, and a bat that died then rolled its loot table.
Once mob_drops is off no mob death rolls one (LivingEntity.shouldDropLoot), so the setup now ends with an
unrecorded /random reset *: every run starts the script with no sequence, and the later count holds only what the
corpus itself rolled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:44:02 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 2c80009c70 Scope pre-push history checks to pushed refs
Read Git's pre-push ref updates and inspect the full reachable history of each non-deleted local object ID. Ignore unrelated refs while rejecting private artifacts in deleted files, merge-only paths, and stored history masked by local replacements.

Add regression coverage for branches, tags, raw revisions, multiple updates, deletion-only pushes, and artifact detection.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 04:15:14 +00:00
rdbtCVSandOpenCode gpt-6.1-sol 342cb6c500 Fix Java compiler/runtime selection during preparation
Pair PATH tools from a compatible JDK and always forward its home to external preparers so Java 25 exporters run with a compatible runtime.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 03:32:23 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol b1eae8a5b8 Tests: keep spectator regression separate from new LAN checks
Move the spectator-travel check before the existing villagers check so it merges cleanly with the newly added falling check on main.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:13:12 +00:00
rdbtCVSandOpenCode GPT-6.1-sol 578f077347 Merge main and retain both LAN regression checks
Resolve the concurrent farmland and falling check additions in tools/lan_e2e.py.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 03:06:23 +00:00
rdbtCVSandOpenCode b6ec56ac9b Merge origin/main into double-chest click fix
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 03:00:14 +00:00
rdbtCVSandOpenCode gpt-6.1-sol e334dd3d55 Fix offhand eating and drinking cancellation
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 02:59:33 +00:00
rdbtCVSandOpenCode GPT-6.1-sol 753a99b933 Fix crop updates after server-side farmland trampling
Forward landing block-write events to notify_written so unsupported crops break, drop their items, and synchronize to clients. Add LAN regression coverage for the host and guest in both hosting paths.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 02:55:30 +00:00
rdbtCVSandOpenCode c36e81916b Fix double-chest inventory and hotbar clicks
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 02:24:16 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 6e86e07781 Multiplayer: keep spectator collision state after dimension travel
Restore the current game mode's spectator flag when remote_teleport creates a replacement player body. Add an end-to-end wall traversal regression for Nether travel and return, covering the integrated-server host, LAN guest and in-frame host.

Fixes #1299

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 02:22:41 +00:00
some-du6eandClaude Opus 5.5 96b6ed4916 Test that a connection reaches IPv4 first and report the first address's error
A loopback test listens on both [::1] and 127.0.0.1 and checks the
connection lands on IPv4 although IPv6 is listed first; it fails without
the sort. Another checks the fallback to IPv6 when IPv4 refuses. When every
address fails, the error is now the first address's (the one vanilla tries)
rather than an IPv6 fallback's, which is often only "no route to host".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:33:22 -06:00
Ludovic Marie 149864c3b4 Merge remote-tracking branch 'origin/main' into perf/face-cull-cr1
# Conflicts:
#	crates/horizon-client/src/mesher/leaf_culling_tests.rs
2026-10-25 02:15:14 +02:00
Ludovic MarieandClaude Opus 5.5 3e3ab7a413 Merge main into fix/ipv4-first: IPv4 first for SRV targets too
Main's SRV resolver (#1578) added Connection::connect_server; both connect paths now
go through connect_addresses, which puts IPv4 first, as ServerAddressResolver.SYSTEM's
InetAddress.getByName does for the original host and the SRV target alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:24:52 +02:00
Ludovic MarieandClaude Opus 5.5 fab71df185 Mesher: sort solid quads in the meshing workers, not in Mesher::mesh
Mesher::mesh keeps model order, which the leaf-culling test pairs with
the baked faces; the section and tile workers sort what they upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:21:23 +02:00
Ludovic MarieandClaude Opus 5.5 7d76923a04 Mod registry tools: the registry's entries, build and review
- `horizon_mods::index::registry`: the registry repository's
  `mods/<id>.toml` and `revoked.toml`, `build` (every entry and file
  checked, every problem listed, the index written) and `review` (a
  published file never changes or disappears, a kind never changes;
  non-owner, owner and key changes pointed out).
- `cargo horizon-mod registry build|review|check|list` for the registry's CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:57 +02:00
Ludovic MarieandClaude Opus 5.5 59281c5c73 Mod registry: cargo horizon-mod check, each kind's own checks
- `horizon_mods::index::inspect`: each kind's own check (a mod's manifest
  and content, a modpack's manifest and overrides, a resource pack's
  pack.mcmeta), returning what the file says; `check_file` now compares
  those facts with the index. Shader packs and unknown kinds are refused,
  and `index::open` says which kinds the registry takes: shader
  submissions stay closed until Horizon's own format exists.
- `cargo horizon-mod check <file>`: those checks plus a trial run of a mod
  in the sandbox (ticks, frames, a chat line, each command), text or JSON.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 2716ad41be Renderer: Metal's flat draws and occlusion culling switch on by sections in view
Both switched on past 4,096 section draws, measured when a section had
2.2 to 2.5 draws. A section's draw count now depends on the sides it shows
the eye, so the bars count sections in view: 1,600, where 4,096 draws fell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:16:05 +02:00
some-du6eandClaude Opus 5.5 be34f4cd5f Connect to a server's IPv4 address first, as vanilla does
Vanilla resolves a server with InetAddress.getByName, and Java's default
(java.net.preferIPv6Addresses=false) puts IPv4 addresses first. Horizon
tried addresses in the system resolver's order, which on macOS puts IPv6
first: a server whose IPv6 address resets the connection showed
"Connection reset by peer (os error 54)" where vanilla joined.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:04:45 -06:00
Ludovic MarieandClaude Opus 5.5 7c1dd49810 Renderer: leave out solid quads that face away from the eye
Meshing sorts each section's and distant tile's solid quads into seven
buckets: one per axis direction (all four corners in one axis plane, both
triangles wound the same way) and one for everything else, and keeps each
axis bucket's extreme plane. A quad facing +X at x = p is a back face from
an eye at x <= p, which the solid pipeline culls anyway, so the frame draws
only the buckets that can face the eye, merged into runs of adjacent
buckets: one to three indirect draws per section on the existing page-run,
occlusion-culling and flat paths. The eye comes from the frame's
view-projection, so view bobbing is exact. The picture does not change;
`horizon-ctl section-sides on|off` switches it for A/B checks.

Written from scratch from the general back-face technique; no other
renderer's code or Horizon's removed code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:13:00 +02:00
Ludovic Marie db758a622f Merge remote-tracking branch 'origin/main' into pa1/remove-face-groups
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-24 18:54:45 +02:00
Ludovic MarieandClaude Opus 5.5 7a1638a190 Renderer: remove the section face groups modelled on another renderer
#503 grouped each section's solid quads by the axis side they face and
skipped the groups facing away from the eye. Its code and PR named an
internal class of another project as the model. Horizon doesn't build on
another project's code, so the grouping goes and every section draws its
solid quads in one draw, as vanilla does; the GPU's back-face culling
discards the back faces, so the picture is unchanged.

This costs frame time, measured on the Mac mini in the PR. A clean-room
version can come back later, designed from vanilla and from first
principles only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:24:46 +02:00
40 changed files with 2850 additions and 516 deletions

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --history
exec python3 tools/check_public_tree.py --pre-push
+2
View File
@@ -39,6 +39,8 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
+2 -2
View File
@@ -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]
+268
View File
@@ -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, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, 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());
}
}
+40 -1
View File
@@ -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")
+159
View File
@@ -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()),
}
}
+15 -17
View File
@@ -4046,6 +4046,20 @@ impl Game<'_> {
self.capture_loaded_world();
Ok(())
}
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
/// farmland breaks. The local player's and every server player's landings.
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
use horizon_client::landing::FallEvent;
let (position, flags) = match *event {
FallEvent::Write {
position, flags, ..
} => (position, flags as i32),
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
_ => return,
};
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
self.tick_debug_keys()?;
self.tick_debug_charts();
@@ -4260,23 +4274,7 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
position,
flags,
..
}) => {
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, flags as i32)
});
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
position, ..
}) => {
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
});
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
+10
View File
@@ -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)?)),
+9 -12
View File
@@ -1939,7 +1939,7 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let sounds = self.as_guest(host, conn, |game| {
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let Some(player) = game.player.as_mut() else {
@@ -2036,20 +2036,17 @@ impl Game<'_> {
// A hurt since the last tick marked the velocity (Entity.markHurt):
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let sounds = events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>();
(sounds, marked.then_some(player.velocity))
(events, marked.then_some(player.velocity))
});
let Some((sounds, marked)) = sounds else {
let Some((events, marked)) = events else {
return;
};
for sound in sounds {
self.player_hurt_sound(sound, at);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
_ => {}
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
+43 -11
View File
@@ -1601,6 +1601,12 @@ impl Game<'_> {
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
@@ -3064,6 +3070,8 @@ impl Game<'_> {
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
@@ -4233,11 +4241,13 @@ impl Game<'_> {
return;
};
let stack = self.stack_in(slot).cloned();
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go (or another slot is picked).
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
.then_some((slot, ticks + 1));
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
let consumable = stack
@@ -4251,7 +4261,7 @@ impl Game<'_> {
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if slot != self.selected {
if !in_hand {
self.using = None;
return;
}
@@ -4572,12 +4582,10 @@ impl Game<'_> {
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => {
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
Some(s) => s as i16,
None => return false,
}
}
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
@@ -4646,7 +4654,7 @@ impl Game<'_> {
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
@@ -5505,4 +5513,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);
}
}
@@ -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<'_> {
+82 -20
View File
@@ -6,7 +6,7 @@ use crate::{
environment::Sky,
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{SectionMesh, TerrainVertex, Vertex},
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
sprites::SpriteAnimation,
};
use glam::{DVec3, Mat4, Vec3, Vec4};
@@ -156,9 +156,9 @@ struct SectionGpu {
page: u32,
start: u64,
solid: u32,
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
sides: Sides,
translucent: u32,
/// The solid quads by facing: back-facing groups are skipped.
faces: crate::mesher::FaceGroups,
sort: translucency::SortState,
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
@@ -325,6 +325,8 @@ pub struct Renderer {
/// counts reach a multi-draw's entries (Vulkan, Metal).
cull: Option<cull::Cull>,
cull_on: bool,
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
@@ -2157,7 +2159,7 @@ impl Renderer {
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
cull.always = cull_env.as_deref() == Ok("all");
if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
cull.min_draws = cull::TILED_MIN_DRAWS;
cull.min_sections = cull::TILED_MIN_SECTIONS;
}
cull
});
@@ -2204,6 +2206,7 @@ impl Renderer {
translucent,
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
cull,
flat,
sky,
@@ -2453,6 +2456,14 @@ impl Renderer {
self.flat_on = on;
Ok(())
}
/// Whether solid quads facing away from the eye are left out.
pub fn section_sides(&self) -> bool {
self.sides_on
}
/// Switches leaving out solid quads facing away, for A/B checks.
pub fn set_section_sides(&mut self, on: bool) {
self.sides_on = on;
}
/// Whether solid sections are occlusion culled (`cull`).
pub fn section_cull(&self) -> bool {
self.cull_on
@@ -2575,8 +2586,12 @@ impl Renderer {
page,
start,
solid,
sides: if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
},
translucent,
faces: mesh.faces,
sort: translucency::SortState::new(
mesh.translucent
.as_chunks::<4>()
@@ -3042,6 +3057,9 @@ impl Renderer {
};
let camera = frame.camera;
let camera_section = camera_section(camera);
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(view_proj).filter(|_| self.sides_on);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -3106,10 +3124,6 @@ impl Renderer {
// as one list of indirect draws over the section arena.
self.draws.clear();
self.draw_pages.clear();
// The eye, camera-relative: view bobbing moves it off the camera.
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
.inverse()
.transform_point3(Vec3::ZERO);
// Occlusion culling leaves the sections its last result did not see
// to phase 2, after the others, with instance counts of 0. Whether
// the frame has draws enough goes by the last one's.
@@ -3117,7 +3131,7 @@ impl Renderer {
.cull
.as_mut()
.filter(|c| {
self.cull_on && !frame.title && self.multi_draw && self.section_draws >= c.min_draws
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
self.phase2_draws.clear();
@@ -3135,12 +3149,11 @@ impl Renderer {
let start = draws.len();
let instances = u32::from(first_phase);
let mut indices = 0;
// Only the solid groups that can face the eye.
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
let first = s.start + u64::from(first) * 4;
draws.push([quads * 6, instances, 0, first as u32, i as u32]);
pages.push((s.page, None));
facing_runs(&s.sides, eye, v.origin, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
pages.push((s.page, None));
});
let runs = if first_phase {
&mut self.solid_runs
@@ -3178,10 +3191,10 @@ impl Renderer {
for (i, v) in visible_lods.iter().enumerate() {
let s = &self.lods[&v.key].mesh;
let start = self.draws.len();
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
let first = s.start + u64::from(first) * 4;
self.draws
.push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]);
let instance = (visible.len() + i) as u32;
facing_runs(&s.sides, eye, v.origin, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
});
self.solid_runs
@@ -3520,7 +3533,9 @@ impl Renderer {
}
let flat_frame = match &mut self.flat {
Some(flat)
if self.flat_on && !self.draws.is_empty() && self.draws.len() >= flat.min_draws =>
if self.flat_on
&& !self.draws.is_empty()
&& self.drawn_sections >= flat.min_sections =>
{
let sections = &self.sections;
let lods = &self.lods;
@@ -4202,6 +4217,34 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
}
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
/// space: the point whose clip x, y and w are all 0. `None` for a
/// projection without one (orthographic).
fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let m = view_proj.as_dmat4();
let rows = [m.row(0), m.row(1), m.row(3)];
let a = glam::DMat3::from_cols(
DVec3::new(rows[0].x, rows[1].x, rows[2].x),
DVec3::new(rows[0].y, rows[1].y, rows[2].y),
DVec3::new(rows[0].z, rows[1].z, rows[2].z),
);
let det = a.determinant();
if !det.is_normal() {
return None;
}
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
match eye {
Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
None if sides.quads() > 0 => draw(0, sides.quads()),
None => {}
}
}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
@@ -4429,6 +4472,25 @@ mod tests {
assert_eq!(super::outline_width(3840), 5.0);
}
#[test]
fn the_projection_center_follows_the_view_bob() {
use glam::{Mat4, Vec3};
let projection = super::world_projection(70.0, 16.0 / 9.0, 512.0);
let view = crate::camera::view_matrix(30.0, 20.0);
let eye = super::projection_center(projection * view).unwrap();
assert!(eye.length() < 1e-5, "{eye}");
// A pose translated by the bob: the eye is where the pose's inverse
// takes the view's origin.
let bob = Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.03, -0.08, 0.0));
let expected = (bob * view).inverse().transform_point3(Vec3::ZERO);
let eye = super::projection_center(projection * bob * view).unwrap();
assert!(eye.distance(expected) < 1e-5, "{eye} {expected}");
assert!(expected.length() > 0.05);
assert!(
super::projection_center(Mat4::orthographic_rh(-1.0, 1.0, -1.0, 1.0, 0.0, 1.0))
.is_none()
);
}
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends)
+12 -10
View File
@@ -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,
}
}
+12 -10
View File
@@ -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),
+2 -5
View File
@@ -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 };
+1 -3
View File
@@ -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
+14 -11
View File
@@ -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 = &center.sections[sy as usize];
-267
View File
@@ -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)]);
}
}
+346
View File
@@ -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<_>>()
);
}
}
+2 -1
View File
@@ -504,7 +504,8 @@ impl Streaming {
biomes: &context.biomes,
visibility,
};
let mesh = mesher.mesh(&borrow(&region), sy);
let mut mesh = mesher.mesh(&borrow(&region), sy);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
@@ -221,7 +221,9 @@ fn relit_columns_keep_every_mesh_current() {
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
let fresh = mesher.mesh(&borrow(&region), sy);
// The workers sort solid quads by side, as uploads expect.
let mut fresh = mesher.mesh(&borrow(&region), sy);
fresh.sort_solid();
let bytes = |m: &SectionMesh| {
[
bytemuck::cast_slice::<_, u8>(&m.solid).to_vec(),
+126 -51
View File
@@ -448,60 +448,109 @@ pub fn check_file(
(None, None) => Trust::Unsigned,
_ => return Err("its signature and the project's key don't go together".into()),
};
match project.kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
let mut caps = m.capabilities.clone();
caps.sort();
let mut listed = version.capabilities.clone();
listed.sort();
let differs = m.id != project.id
|| m.version != version.version
|| m.api != version.api
|| Some(m.side) != version.side
|| caps != listed;
if differs {
return Err(format!(
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
m.id,
m.version,
m.api,
m.side,
names(&caps)
));
}
}
Kind::ResourcePack => check_resource_pack(path)?,
Kind::Shader | Kind::Other(_) => {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
));
}
Kind::Modpack => {
let pack = Modpack::read(path)?;
let m = &pack.manifest;
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
if m.id != project.id
|| m.version != version.version
|| m.format.to_string() != version.api
|| m.game != version.game
|| mods != version.requires
{
return Err(format!(
"its {} isn't what the index lists",
modpack::MANIFEST
));
}
}
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
));
}
inspect(&project.kind, path)?.matches(project, version)?;
Ok(trust)
}
/// What a file says about itself, read by its kind's own check
/// ([`inspect`]). The registry's CI writes these into the index; the
/// launcher compares them with the index before it installs.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Facts {
/// A mod's or a modpack's manifest id; None for a resource pack.
pub id: Option<String>,
pub version: Option<String>,
/// A mod's API line, a modpack's format, a resource pack's pack format.
pub api: String,
pub side: Option<Side>,
/// In the manifest's order.
pub capabilities: Vec<Capability>,
/// The Minecraft version, when the file names one (a modpack does).
pub game: Option<String>,
pub requires: Vec<Requirement>,
}
impl Facts {
/// Whether the file is the one `version` of `project` describes.
pub fn matches(&self, project: &Project, version: &Version) -> Result<(), String> {
let (mut caps, mut listed) = (self.capabilities.clone(), version.capabilities.clone());
caps.sort();
listed.sort();
let differs = self.id.as_ref().is_some_and(|id| *id != project.id)
|| self.version.as_ref().is_some_and(|v| *v != version.version)
|| self.api != version.api
|| self.side != version.side
|| caps != listed
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|| self.requires != version.requires;
if !differs {
return Ok(());
}
Err(match project.kind {
Kind::Mod => format!(
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
self.id.as_deref().unwrap_or("?"),
self.version.as_deref().unwrap_or("?"),
self.api,
self.side,
names(&self.capabilities)
),
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
_ => format!("its pack format {} isn't what the index lists", self.api),
})
}
}
/// Reads and checks a file as its kind: a mod's manifest and content, a
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
/// paths. Shader packs and kinds this build doesn't know are refused: their
/// checks come with their formats.
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
match kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
Ok(Facts {
capabilities: m.capabilities,
id: Some(m.id),
version: Some(m.version),
api: m.api,
side: Some(m.side),
..Facts::default()
})
}
Kind::Modpack => {
let m = Modpack::read(path)?.manifest;
Ok(Facts {
requires: m.mods.iter().map(modpack::PackMod::requirement).collect(),
id: Some(m.id),
version: Some(m.version),
api: m.format.to_string(),
game: Some(m.game),
..Facts::default()
})
}
Kind::ResourcePack => Ok(Facts {
api: check_resource_pack(path)?,
..Facts::default()
}),
Kind::Shader => Err(
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
),
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
}
}
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
/// object, and only plain paths.
fn check_resource_pack(path: &Path) -> Result<(), String> {
/// object, and only plain paths. Returns its pack format (`pack_format`, or
/// `min_format` in newer packs), empty when it names none.
fn check_resource_pack(path: &Path) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
for i in 0..zip.len() {
@@ -523,10 +572,35 @@ fn check_resource_pack(path: &Path) -> Result<(), String> {
.map_err(|e| format!("pack.mcmeta: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
if !json["pack"].is_object() {
let pack = &json["pack"];
if !pack.is_object() {
return Err("pack.mcmeta: no \"pack\" object".into());
}
Ok(())
let format = [&pack["pack_format"], &pack["min_format"]]
.into_iter()
.find(|f| !f.is_null())
.map(|f| match f {
serde_json::Value::Array(parts) => parts
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
other => other.to_string(),
})
.unwrap_or_default();
Ok(format)
}
/// The kinds the registry takes submissions for. Shader packs wait for
/// Horizon's own format; other kinds for theirs.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
Kind::Shader => Err(
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
fn names(caps: &[Capability]) -> String {
@@ -534,5 +608,6 @@ fn names(caps: &[Capability]) -> String {
format!("[{}]", names.join(", "))
}
pub mod registry;
#[cfg(test)]
mod tests;
+415
View File
@@ -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"));
}
+4 -1
View File
@@ -151,7 +151,10 @@ fn resource_packs_need_a_pack_mcmeta() {
path
};
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
assert_eq!(
check_resource_pack(&pack(&[("pack.mcmeta", meta)])),
Ok("75".into())
);
let e = check_resource_pack(&pack(&[("assets/x.png", "")])).unwrap_err();
assert!(e.contains("missing"), "{e}");
let e = check_resource_pack(&pack(&[("pack.mcmeta", "{}")])).unwrap_err();
+102 -34
View File
@@ -36,41 +36,82 @@ fn executable(dir: &Path, name: &str) -> PathBuf {
})
}
fn on_path(name: &str) -> Option<PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| executable(&dir, name))
.find(|p| p.is_file())
/// `fs::canonicalize`, without the `\\?\` prefix it gives a Windows drive path: the
/// home goes into `JAVA_HOME` and the PATH of preparers, whose lookups don't take it.
fn real_path(path: PathBuf) -> PathBuf {
let real = fs::canonicalize(&path).unwrap_or(path);
if cfg!(windows) {
without_verbatim(real)
} else {
real
}
}
/// The major version `javac -version` reports ("javac 25.0.1" → 25; "javac 1.8.0" → 8).
fn javac_major(javac: &Path) -> Result<u32, String> {
let output = command(javac)
fn without_verbatim(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
_ => path,
}
}
/// Resolve compiler symlinks before picking its sibling runtime. The first `java` on the
/// PATH may be an older, runtime-only install; it must not be paired with a newer `javac`.
fn path_jdks(path: &OsStr) -> Vec<PathBuf> {
std::env::split_paths(path)
.map(|dir| executable(&dir, "javac"))
.filter(|javac| javac.is_file())
.map(real_path)
.filter_map(|javac| {
let bin = javac.parent()?;
// macOS's system tools are dispatchers, not a JDK home: JAVA_HOME=/usr
// makes them call themselves. /usr/libexec/java_home finds the actual JDK.
if bin.file_name()? != "bin"
|| (cfg!(target_os = "macos") && bin == Path::new("/usr/bin"))
{
return None;
}
Some(bin.parent()?.to_path_buf())
})
.collect()
}
/// The major version `javac -version` or `java -version` reports, including Java 8's `1.8`.
fn tool_major(program: &Path, name: &str) -> Result<u32, String> {
let output = command(program)
.arg("-version")
.stdin(Stdio::null())
.output()
.map_err(|e| format!("{}: {e}", javac.display()))?;
.map_err(|e| format!("{}: {e}", program.display()))?;
let text = String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr);
// macOS ships /usr/bin/java and /usr/bin/javac as placeholders.
if text.contains("Unable to locate a Java Runtime") {
return Err(format!(
"{} is macOS's placeholder (no Java installed)",
program.display()
));
}
if !output.status.success() {
return Err(format!(
"{} -version failed ({}): {}",
program.display(),
output.status,
text.trim()
));
}
let marker = if name == "javac" { "javac" } else { "version" };
let version = text
.split_whitespace()
.skip_while(|w| *w != "javac")
.skip_while(|w| *w != marker)
.nth(1)
.ok_or_else(|| {
// macOS ships /usr/bin/javac as a placeholder that only says no Java is installed.
if text.contains("Unable to locate a Java Runtime") {
format!(
"{} is macOS's placeholder (no Java installed)",
javac.display()
)
} else {
format!(
"{} -version printed no version: {}",
javac.display(),
text.trim()
)
}
format!(
"{} -version printed no version: {}",
program.display(),
text.trim()
)
})?;
let mut parts = version.split(['.', '-', '+', '_']);
let mut parts = version.trim_matches('"').split(['.', '-', '+', '_']);
let first: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Ok(if first == 1 {
parts.next().and_then(|p| p.parse().ok()).unwrap_or(0)
@@ -175,11 +216,10 @@ pub fn minecraft_runtimes(runtime: &Path) -> Vec<PathBuf> {
homes
}
/// Finds a JDK with `javac` of at least `major`, the version research-lock.json pins:
/// `JAVA_HOME` when it holds one, else `javac` and `java` on the PATH (as the Python preparer
/// ran them), else, on macOS, the JDK `/usr/libexec/java_home -v <major>` reports, else the JDK
/// homes in `more` (the launcher's pick, Minecraft's own runtimes), else a JDK in the folders
/// installers put them in (`jdk_parents`).
/// Finds a JDK whose compiler and runtime are both at least `major`, the version
/// research-lock.json pins: `JAVA_HOME`, else JDKs on the PATH, else, on macOS, the JDK
/// `/usr/libexec/java_home -v <major>` reports, else the homes in `more` (the launcher's pick,
/// Minecraft's own runtimes), else the folders installers use (`jdk_parents`).
pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
let need = format!(
"Java {major} is needed (a JDK with javac, e.g. Eclipse Temurin {major}). \
@@ -194,8 +234,11 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
"JAVA_HOME",
));
}
if let (Some(javac), Some(java)) = (on_path("javac"), on_path("java")) {
candidates.push((javac, java, "PATH"));
if let Some(path) = std::env::var_os("PATH") {
for home in path_jdks(&path) {
let bin = home.join("bin");
candidates.push((executable(&bin, "javac"), executable(&bin, "java"), "PATH"));
}
}
if cfg!(target_os = "macos") {
if let Ok(out) = command("/usr/libexec/java_home")
@@ -239,10 +282,19 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
));
continue;
}
match javac_major(&javac) {
Ok(found) if i64::from(found) >= major => return Ok(Jdk { javac, java }),
Ok(found) => problems.push(format!("{from} has Java {found} ({})", javac.display())),
Err(e) => problems.push(e),
let checked = [(&javac, "javac"), (&java, "java")]
.into_iter()
.try_for_each(|(program, name)| {
let found = tool_major(program, name)?;
if i64::from(found) >= major {
Ok(())
} else {
Err(format!("{name} {found} ({})", program.display()))
}
});
match checked {
Ok(()) => return Ok(Jdk { javac, java }),
Err(e) => problems.push(format!("{from}: {e}")),
}
}
if problems.is_empty() {
@@ -328,6 +380,22 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn verbatim_drive_paths_lose_their_prefix() {
let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe";
assert_eq!(
without_verbatim(PathBuf::from(format!(r"\\?\{jdk}"))),
PathBuf::from(jdk)
);
// A UNC share keeps its verbatim form: stripping it would leave no valid path.
let unc = r"\\?\UNC\server\jdk\bin\javac.exe";
assert_eq!(without_verbatim(PathBuf::from(unc)), PathBuf::from(unc));
assert_eq!(
without_verbatim(PathBuf::from("/usr/lib/jvm/bin/javac")),
PathBuf::from("/usr/lib/jvm/bin/javac")
);
}
#[test]
fn installed_jdks_prefer_the_pinned_major() {
let dir = std::env::temp_dir().join(format!("horizon-prepare-jdks-{}", std::process::id()));
@@ -0,0 +1,133 @@
//! Each scenario runs in its own process so Java discovery sees a controlled environment.
use horizon_prepare::java;
use std::{env, fs, path::Path, process::Command};
const TEST: &str = "selects_a_compatible_compiler_and_runtime";
const CHILD: &str = "HORIZON_JAVA_SELECTION_CHILD";
const ROOT: &str = "HORIZON_JAVA_SELECTION_ROOT";
fn tool(bin: &Path, name: &str) -> std::path::PathBuf {
bin.join(format!("{name}{}", env::consts::EXE_SUFFIX))
}
#[test]
fn selects_a_compatible_compiler_and_runtime() {
if let Ok(scenario) = env::var(CHILD) {
let root = std::path::PathBuf::from(env::var_os(ROOT).unwrap());
if scenario.ends_with("error") {
// Use a future compiler version to keep real installed JDKs out of the result.
let error = java::find(255, &[]).err().expect("accepted an old runtime");
let runtime = if scenario == "legacy-runtime-error" {
8
} else {
21
};
assert!(error.contains(&format!("java {runtime} (")), "{error}");
return;
}
let jdk = java::find(25, &[]).unwrap();
let expected = root.join(if scenario == "target-release" {
"newer"
} else {
"jdk25"
});
for (actual, name) in [(&jdk.javac, "javac"), (&jdk.java, "java")] {
assert_eq!(
fs::canonicalize(actual).unwrap(),
fs::canonicalize(tool(&expected.join("bin"), name)).unwrap()
);
}
assert_eq!(
fs::canonicalize(jdk.home()).unwrap(),
fs::canonicalize(expected).unwrap()
);
let log_path = root.join(format!("{scenario}.log"));
let log = fs::File::create(&log_path).unwrap();
jdk.java(&[], None, &log).unwrap();
assert_eq!(fs::read_to_string(log_path).unwrap().trim(), "java 25.0.1");
return;
}
let root = env::temp_dir().join(format!("horizon-java-selection-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let probe = tool(&root, "probe");
let output = Command::new("rustc")
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/java_probe.rs"))
.arg("-o")
.arg(&probe)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
for (home, compiler, runtime) in [
("jre21", None, "21.0.1"),
("jdk21", Some("21.0.1"), "21.0.1"),
("jdk25", Some("25.0.1"), "25.0.1"),
("mixed", Some("25.0.1"), "21.0.1"),
("failing", Some("25.0.1"), "25.0.1"),
("newer", Some("26-ea+1"), "25.0.1"),
("future", Some("255.0.1"), "21.0.1"),
("legacy", Some("255.0.1"), "1.8.0_442"),
] {
let bin = root.join(home).join("bin");
fs::create_dir_all(&bin).unwrap();
for (name, version) in [("javac", compiler), ("java", Some(runtime))] {
if let Some(version) = version {
fs::copy(&probe, tool(&bin, name)).unwrap();
fs::write(bin.join(format!("{name}.version")), version).unwrap();
}
}
}
fs::write(root.join("failing/bin/java.exit"), "7").unwrap();
let scenarios = vec![
("older-runtime", None, vec!["jre21", "jdk25"]),
("older-jdk", None, vec!["jdk21", "jdk25"]),
("incompatible-home", Some("mixed"), vec!["jdk25"]),
("failed-runtime", Some("failing"), vec!["jdk25"]),
("target-release", Some("newer"), vec![]),
("runtime-error", Some("future"), vec![]),
("legacy-runtime-error", Some("legacy"), vec![]),
];
#[cfg(unix)]
let scenarios = {
let mut scenarios = scenarios;
let bin = root.join("shims/bin");
fs::create_dir_all(&bin).unwrap();
std::os::unix::fs::symlink(tool(&root.join("jdk25/bin"), "javac"), tool(&bin, "javac"))
.unwrap();
std::os::unix::fs::symlink(tool(&root.join("jre21/bin"), "java"), tool(&bin, "java"))
.unwrap();
scenarios.push(("symlinked-path", None, vec!["shims"]));
scenarios
};
for (scenario, home, path) in scenarios {
let mut child = Command::new(env::current_exe().unwrap());
child
.args(["--exact", TEST, "--nocapture"])
.env(CHILD, scenario)
.env(ROOT, &root)
.env(
"PATH",
env::join_paths(path.iter().map(|home| root.join(home).join("bin"))).unwrap(),
)
.env("HOME", &root)
.env("USERPROFILE", &root)
.env("ProgramFiles", &root)
.env("ProgramW6432", &root)
.env_remove("JAVA_HOME");
if let Some(home) = home {
child.env("JAVA_HOME", root.join(home));
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{scenario}:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fs::remove_dir_all(root).unwrap();
}
@@ -0,0 +1,21 @@
//! A fake Java tool for process-level JDK selection tests; no installed Java is needed.
use std::{env, fs, process};
fn main() {
let executable = env::current_exe().unwrap();
let bin = executable.parent().unwrap();
let tool = executable.file_stem().unwrap().to_str().unwrap();
let version = fs::read_to_string(bin.join(format!("{tool}.version"))).unwrap();
if env::args().nth(1).as_deref() == Some("-version") {
if tool == "javac" {
println!("javac {version}");
} else {
eprintln!("openjdk version \"{version}\"");
}
} else {
println!("{tool} {version}");
}
if let Ok(status) = fs::read_to_string(bin.join(format!("{tool}.exit"))) {
process::exit(status.parse().unwrap());
}
}
+82 -10
View File
@@ -6,7 +6,7 @@
use crate::{Error, Result, buf::Reader, crypto::Cfb8, frame};
use std::{
io::{Read, Write},
net::{Shutdown, TcpStream, ToSocketAddrs},
net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicI64, Ordering},
@@ -180,6 +180,13 @@ pub struct ConnWriter {
pub threshold: Option<usize>,
counters: Arc<Counters>,
}
/// `InetAddress.getByName` under Java's default `java.net.preferIPv6Addresses=false`: IPv4
/// addresses first, whatever order the system resolver gives (macOS lists IPv6 first). Vanilla
/// connects to that first address only; the rest stay here as fallbacks.
fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
addrs.sort_by_key(SocketAddr::is_ipv6);
addrs
}
/// Both halves before the split.
pub struct Connection {
pub reader: ConnReader,
@@ -199,19 +206,19 @@ impl Connection {
let addrs = crate::resolver::resolve(host, port)?;
Self::connect_addresses(host, addrs, timeout)
}
fn connect_addresses(
host: &str,
addrs: Vec<std::net::SocketAddr>,
timeout: Duration,
) -> Result<Self> {
let mut last = Error::new(format!("Unknown host {host}"));
for addr in addrs {
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
// Report the first address's failure: it is the one vanilla tries, and the IPv6
// fallbacks after it often fail only because the client has no IPv6 route.
let mut first = None;
for addr in ipv4_first(addrs) {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(stream) => return Self::from_stream(stream),
Err(e) => last = Error::new(format!("Connection refused: {e}")),
Err(e) => {
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
}
}
}
Err(last)
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
}
pub fn from_stream(stream: TcpStream) -> Result<Self> {
stream.set_nodelay(true)?;
@@ -328,6 +335,71 @@ mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn ipv4_first_keeps_each_familys_order() {
let a = |s: &str| s.parse::<SocketAddr>().unwrap();
let resolved = vec![
a("[2001:db8::1]:25565"),
a("192.0.2.1:25565"),
a("[2001:db8::2]:25565"),
a("10.0.0.1:25565"),
];
assert_eq!(
ipv4_first(resolved),
[
a("192.0.2.1:25565"),
a("10.0.0.1:25565"),
a("[2001:db8::1]:25565"),
a("[2001:db8::2]:25565"),
]
);
}
fn peer(c: &Connection) -> SocketAddr {
match &c.reader.stream {
Stream::Tcp(s) => s.peer_addr().unwrap(),
Stream::Memory(_) => panic!("not a socket"),
}
}
/// Binds `[::1]`, or `None` where the machine has no IPv6 loopback.
fn ipv6_loopback() -> Option<TcpListener> {
let listener = TcpListener::bind("[::1]:0");
if listener.is_err() {
eprintln!("skipped: no IPv6 loopback");
}
listener.ok()
}
#[test]
fn connects_to_ipv4_although_the_resolver_lists_ipv6_first() {
// Both listen, as with a server whose IPv6 address accepts and then resets.
let Some(v6_listener) = ipv6_loopback() else {
return;
};
let v4_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let v6 = v6_listener.local_addr().unwrap();
let v4 = v4_listener.local_addr().unwrap();
let c =
Connection::connect_addresses("test", vec![v6, v4], Duration::from_secs(5)).unwrap();
assert_eq!(peer(&c), v4);
}
#[test]
fn falls_back_to_ipv6_when_ipv4_refuses() {
let Some(v6_listener) = ipv6_loopback() else {
return;
};
let v6 = v6_listener.local_addr().unwrap();
let closed = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
let c = Connection::connect_addresses("test", vec![v6, closed], Duration::from_secs(5))
.unwrap();
assert_eq!(peer(&c), v6);
}
#[test]
fn loopback_with_compression_and_encryption() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+53
View File
@@ -90,6 +90,59 @@ Unknown keys are refused. `overrides/` may hold only `options.txt`, `horizon-gra
capability `storage`), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
them into the profile's game folder.
## The registry repository
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) holds one `mods/<id>.toml` per project, written by
the submission form or by hand, and `revoked.toml`. What an entry can't vouch for is read from its file by the kind's
own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mods, the size. Types and rules:
`horizon_mods::index::registry`.
```toml
id = "coords_hud" # the file's name, and the manifest's id
kind = "mod" # mod | modpack | resourcepack; shader once its format exists
name = "Coordinates HUD"
summary = "One line, at most 160 characters."
description = """Markdown.""" # optional
authors = ["Horizon"]
owners = ["ludovic111"] # GitHub logins who may publish updates
license = "MIT"
source = "https://github.com/..."
icon = "icons/coords_hud.png" # optional: a PNG of at most 256 KiB in the repository, or https://
key = "RWT..." # optional: the minisign public key every version is signed with
[[versions]] # newest first
version = "0.1.0" # letters, digits, '.', '-', '_': the release tag is <id>-<version>
published = "2026-10-24"
game = "26.3"
file = "coords_hud-0.1.0.hmod" # the name players download
sha256 = "e113..." # pins the reviewed file
signature = """untrusted comment: ...""" # when the project has a key
changelog = "" # optional
```
```toml
# revoked.toml
[[revoked]]
id = "bad_mod"
sha256 = "1111..." # optional: without it, every version
reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
```
Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon-mod`):
| Command | What it does |
| --- | --- |
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above), its signature with `--key` and `--signature`, and for a mod a trial run in the sandbox (40 ticks, 120 frames, a chat line, each command). |
| `cargo horizon-mod registry check <base> <head> --files <dir>` | `check` on each file `head` lists and `base` doesn't: a pull request's new files. |
| `cargo horizon-mod registry list <checkout>` | `<sha256> <id> <version> <file>` per version: the files to download. |
| `cargo horizon-mod registry build <checkout> --files <dir> --out index.json` | Reads every entry and its file (`<dir>/<sha256>`), lists every problem, and writes the index with URLs `<release>/<id>-<version>/<file>`. |
| `cargo horizon-mod registry review <base> <head> --author <login>` | A pull request's changes: a published file never changes and is never removed (take it down in `revoked.toml`), a kind never changes; changes by a non-owner and to owners or keys are pointed out for the reviewers. |
Kinds open to submissions: `mod`, `modpack`, `resourcepack`. `shader` is in the schema and stays closed until
Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile
the pack in a headless Horizon. A new kind gets its own check before it opens.
## Changelog
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
+13 -1
View File
@@ -16,7 +16,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run` |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run / check`, and the registry's CI (`registry build / review / list`) |
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, modpacks |
| Registry index (`index.json`, `.hpack`), signatures | `horizon_mods::index`, [api/mod-index.md](api/mod-index.md) | schema 1; launcher and website next |
@@ -226,6 +226,18 @@ always on, and the game checks its file once a second: rebuild and it restarts
with "Mods reloaded" in chat. Its in-memory state starts over; whatever it
saved with capability `storage` survives the reload.
Before you submit it to the registry, run what its CI runs:
```sh
cargo horizon-mod check target/horizon-mod/my_mod.hmod # --json for the report as JSON
```
It reads the manifest and content, refuses imports the capabilities don't
cover, then plays the mod in the sandbox for 40 ticks, 120 frames, a chat line
and each of its commands: it must start and never trap or overrun. It checks a
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
key> --signature <file>.minisig`.
### Template layout
```
+16 -19
View File
@@ -1022,21 +1022,18 @@ fn download_for_owner(
}
}
/// A JDK of at least `major` for the preparation. `None` when horizon-prepare finds one by
/// itself (JAVA_HOME, the PATH, an installed JDK): the preparation then runs as before, in an
/// unchanged environment (a JDK found on the PATH can be a stub such as macOS's
/// /usr/bin/javac, whose folder is no Java home: `JAVA_HOME=/usr` makes it call itself
/// forever). Else the home of the Java the official launcher downloaded into one of the
/// `games` folders, else of Minecraft's own Java downloaded from Mojang into Horizon's game
/// folder, the runtime the version JSON at `manifest` names (Mojang's runtimes are whole JDKs).
/// The home of a JDK whose compiler and runtime support `major`. Forward even an installed
/// JDK to external preparers: older versions may otherwise rediscover incompatible PATH tools.
/// Else try the official launcher's Java in `games`, then download the runtime named by
/// `manifest` into Horizon's game folder (Mojang's runtimes are whole JDKs).
fn java(
major: i64,
games: &[PathBuf],
manifest: &Path,
log: &dyn Fn(String),
) -> Result<Option<PathBuf>, String> {
if horizon_prepare::java::find(major, &[]).is_ok() {
return Ok(None);
) -> Result<PathBuf, String> {
if let Ok(jdk) = horizon_prepare::java::find(major, &[]) {
return Ok(jdk.home());
}
let mut runtimes: Vec<PathBuf> = Vec::new();
for home in games
@@ -1048,7 +1045,7 @@ fn java(
}
}
let missing = match horizon_prepare::java::find(major, &runtimes) {
Ok(jdk) => return Ok(Some(jdk.home())),
Ok(jdk) => return Ok(jdk.home()),
Err(e) => e,
};
let component = read_json(manifest)
@@ -1073,7 +1070,7 @@ fn java(
let home = mojang::java_runtime(&component, &runtime, 8, &fetch, log).map_err(|e| {
format!("{missing}\n • Horizon also tried to download Minecraft's own Java from Mojang, but: {e}")
})?;
horizon_prepare::java::find(major, &[home]).map(|jdk| Some(jdk.home()))
horizon_prepare::java::find(major, &[home]).map(|jdk| jdk.home())
}
/// The dynamic loader glibc programs on x86-64 Linux start through.
@@ -1196,14 +1193,12 @@ pub fn preparer(
import: !reused,
minecraft_dir: Some(minecraft.clone()),
server_jar: (!reused).then_some(jar),
java_home: java_home.clone(),
java_home: Some(java_home.clone()),
};
return Ok((Preparer::InProcess(options), minecraft));
}
let mut cmd = Command::new(binary);
if let Some(home) = &java_home {
with_java(&mut cmd, home);
}
with_java(&mut cmd, &java_home);
cmd.current_dir(&dir).arg("--root").arg(&dir);
if !reused {
cmd.args(["--prepare", "--server-jar"]).arg(&jar);
@@ -1212,9 +1207,7 @@ pub fn preparer(
return Ok((Preparer::Process(cmd), minecraft));
}
let mut cmd = Command::new(python.unwrap());
if let Some(home) = &java_home {
with_java(&mut cmd, home);
}
with_java(&mut cmd, &java_home);
cmd.current_dir(&dir)
.args(["-u", "tools/prepare_native_client.py"]);
if !reused {
@@ -2338,6 +2331,10 @@ impl Settings {
#[path = "backend/release_loading_tests.rs"]
mod release_loading_tests;
#[cfg(test)]
#[path = "backend/java_tests.rs"]
mod java_tests;
#[cfg(test)]
mod tests {
use super::*;
+85
View File
@@ -0,0 +1,85 @@
use super::*;
#[test]
fn selected_jdk_reaches_external_preparers() {
const CHILD: &str = "HORIZON_LAUNCHER_JAVA_TEST";
if let Some(root) = std::env::var_os(CHILD) {
let root = PathBuf::from(root);
let home = java(25, &[], &root.join("missing.json"), &|_| {}).unwrap();
assert_eq!(
fs::canonicalize(&home).unwrap(),
fs::canonicalize(root.join("jdk25")).unwrap()
);
// An older preparer invokes Java tools by name; both must now resolve to this JDK.
for name in ["java", "javac"] {
let mut cmd = crate::process::command(name);
with_java(&mut cmd, &home);
let output = cmd.arg("-version").output().unwrap();
assert!(output.status.success());
let text = String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr);
assert!(text.contains("25.0.1"), "{name}: {text}");
assert_eq!(
cmd.get_envs()
.find(|(key, _)| *key == "JAVA_HOME")
.unwrap()
.1,
Some(home.as_os_str())
);
}
return;
}
let root = std::env::temp_dir().join(format!("horizon-launcher-java-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let executable =
|bin: &Path, name: &str| bin.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
let probe = executable(&root, "probe");
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../crates/horizon-prepare/tests/support/java_probe.rs");
let output = crate::process::command("rustc")
.arg(source)
.arg("-o")
.arg(&probe)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
for (home, compiler, runtime) in [
("jre21", None, "21.0.1"),
("jdk25", Some("25.0.1"), "25.0.1"),
] {
let bin = root.join(home).join("bin");
fs::create_dir_all(&bin).unwrap();
for (name, version) in [("javac", compiler), ("java", Some(runtime))] {
if let Some(version) = version {
fs::copy(&probe, executable(&bin, name)).unwrap();
fs::write(bin.join(format!("{name}.version")), version).unwrap();
}
}
}
let output = crate::process::command(std::env::current_exe().unwrap())
.args([
"--exact",
"backend::java_tests::selected_jdk_reaches_external_preparers",
"--nocapture",
])
.env(CHILD, &root)
.env_remove("JAVA_HOME")
.env(
"PATH",
std::env::join_paths([root.join("jre21/bin"), root.join("jdk25/bin")]).unwrap(),
)
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
fs::remove_dir_all(root).unwrap();
}
@@ -5,9 +5,12 @@ import io.netty.buffer.Unpooled;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
@@ -78,7 +81,10 @@ import net.minecraft.world.phys.Vec3;
* on|off} (the shift key, as ServerboundPlayerInputPacket), {@code fly NAME
* on|off} (ServerboundPlayerAbilitiesPacket), {@code still NAME} (the
* position reminder LocalPlayer.sendPosition sends every 20 ticks standing
* still), {@code quit NAME}, {@code
* still), {@code sync NAME} (runs {@code time query gametime}, whose reply is
* not recorded, and prints {@code synced NAME} once it is back: the server has
* handled everything this player sent before, and everything the server sent
* this player before the reply has been recorded), {@code quit NAME}, {@code
* players on|off} (whether each player also records the other players added
* to and removed from its level, and its own respawns into a dimension),
* {@code chat on|off} (whether the system chat each player receives is
@@ -95,6 +101,8 @@ public final class LocatorBarClient {
private static final Map<UUID, String> NAMES = new LinkedHashMap<>();
public static void main(String[] args) throws Exception {
// Bootstrap sends System.out to the logger: `synced` lines go to the real stdout.
PrintStream stdout = new PrintStream(new FileOutputStream(FileDescriptor.out), true, StandardCharsets.UTF_8);
ParityData.init();
String host = args[0];
int port = Integer.parseInt(args[1]);
@@ -122,6 +130,13 @@ public final class LocatorBarClient {
players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities));
}
case "still" -> players.get(w[1]).still();
case "sync" -> {
try {
players.get(w[1]).sync();
} finally {
stdout.println("synced " + w[1]);
}
}
case "quit" -> players.get(w[1]).close();
case "players" -> LocatorBarClient.players = w[1].equals("on");
case "chat" -> chat = w[1].equals("on");
@@ -189,6 +204,7 @@ public final class LocatorBarClient {
private final Map<Integer, String> seen = new java.util.HashMap<>();
private final Object replyLock = new Object();
private int replies;
private int syncs;
Player(String name) {
this.name = name;
@@ -211,6 +227,23 @@ public final class LocatorBarClient {
}
}
/** A round trip through the server thread, its reply left out of the transcript. */
void sync() throws Exception {
synchronized (replyLock) {
int before = syncs;
send(new ServerboundChatCommandPacket("time query gametime"));
long until = System.nanoTime() + 30_000_000_000L;
while (syncs == before) {
long left = (until - System.nanoTime()) / 1_000_000L;
if (left <= 0) {
record("error no reply to sync");
return;
}
replyLock.wait(left);
}
}
}
void record(String line) {
synchronized (lines) {
lines.add("step=" + step + " " + line);
@@ -312,6 +345,13 @@ public final class LocatorBarClient {
seen.clear();
} else if (p instanceof ClientboundTrackedWaypointPacket) {
record(waypoint(r.payload));
} else if (p instanceof ClientboundSystemChatPacket c
&& c.content().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t
&& t.getKey().equals("commands.time.query.gametime")) {
synchronized (replyLock) {
syncs++;
replyLock.notifyAll();
}
} else if (p instanceof ClientboundSystemChatPacket c) {
if (chat) {
record("chat " + (c.overlay() ? "overlay " : "") + net.minecraft.network.chat.ComponentSerialization.CODEC
+147
View File
@@ -0,0 +1,147 @@
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
ZERO_OID = "0" * 40
@unittest.skipUnless(shutil.which("sh") and shutil.which("python3"), "Git hook needs sh and python3")
class PrePushTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.repo = Path(temporary.name)
self.env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
self.git("init", "-b", "main")
(self.repo / "tools").mkdir()
shutil.copyfile(ROOT / "tools/check_public_tree.py", self.repo / "tools/check_public_tree.py")
(self.repo / ".gitignore").write_text("/.local/\n")
(self.repo / "README.md").write_text("Public fixture\n")
self.git("add", ".gitignore", "README.md")
self.git("commit", "-m", "Public root")
self.base = self.git("rev-parse", "HEAD")
def git(self, *args):
return subprocess.check_output(
["git", "-c", f"core.hooksPath={self.repo / 'no-hooks'}", "-c", "user.name=Test",
"-c", "[email protected]", "-c", "commit.gpgsign=false",
"-c", "tag.gpgsign=false", *args],
cwd=self.repo, env=self.env, text=True, stderr=subprocess.PIPE).strip()
def commit_file(self, path, content="Public fixture\n"):
destination = self.repo / path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content)
self.git("add", "-f", path)
self.git("commit", "-m", f"Add {path}")
return self.git("rev-parse", "HEAD")
def private_commit(self):
return self.commit_file(".local/artifact.txt", "Private fixture, not game data\n")
def update(self, local_ref, local_oid, remote_ref=None, remote_oid=ZERO_OID):
return f"{local_ref} {local_oid} {remote_ref or local_ref} {remote_oid}\n"
def push(self, *updates):
return subprocess.run(["sh", str(ROOT / ".githooks/pre-push"), "origin", "unused"],
cwd=self.repo, env=self.env, input="".join(updates),
text=True, capture_output=True)
def assert_passed(self, result):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Public tree check passed.", result.stdout)
def assert_private_rejected(self, result):
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn(".local/artifact.txt", result.stderr)
def test_clean_branch_ignores_unpushed_refs_and_head(self):
self.git("checkout", "-b", "local-only")
private = self.private_commit()
for ref in ["refs/remotes/archive/private", "refs/original/refs/heads/main", "refs/tags/private"]:
self.git("update-ref", ref, private)
self.git("branch", "clean", self.base)
for remote_oid in [ZERO_OID, private]:
with self.subTest(remote_oid=remote_oid):
self.assert_passed(self.push(self.update("refs/heads/clean", self.base, remote_oid=remote_oid)))
# The standalone audit still checks every local ref.
audit = subprocess.run([sys.executable, "tools/check_public_tree.py", "--history"],
cwd=self.repo, env=self.env, text=True, capture_output=True)
self.assert_private_rejected(audit)
def test_raw_revision_push_uses_supplied_oid(self):
self.private_commit()
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
def test_unpushed_replacement_does_not_taint_clean_history(self):
private = self.private_commit()
tree = self.git("rev-parse", f"{private}^{{tree}}")
replacement = self.git("commit-tree", tree, "-m", "Private replacement root")
self.git("replace", self.base, replacement)
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
def test_replacement_cannot_hide_private_history(self):
private = self.private_commit()
self.git("replace", private, self.base)
self.assert_private_rejected(self.push(self.update("refs/heads/main", private)))
def test_all_pushed_refs_are_checked(self):
private = self.private_commit()
clean_update = self.update("refs/heads/clean", self.base)
private_update = self.update("refs/heads/main", private)
for updates in [(clean_update, private_update), (private_update, clean_update)]:
with self.subTest(updates=updates):
self.assert_private_rejected(self.push(*updates))
def test_private_ancestors_are_rejected_even_after_removal_and_an_earlier_push(self):
self.private_commit()
self.git("rm", ".local/artifact.txt")
self.git("commit", "-m", "Remove private fixture")
remote = self.git("rev-parse", "HEAD")
tip = self.commit_file("public.txt")
for remote_oid in [ZERO_OID, remote]:
with self.subTest(remote_oid=remote_oid):
self.assert_private_rejected(self.push(self.update("refs/heads/main", tip, remote_oid=remote_oid)))
def test_annotated_tags_check_their_target_history(self):
self.private_commit()
self.git("rm", ".local/artifact.txt")
self.git("commit", "-m", "Remove private fixture")
self.git("tag", "-a", "clean", self.base, "-m", "Clean tag")
self.git("tag", "-a", "private", "-m", "Private history tag")
clean = self.git("rev-parse", "refs/tags/clean")
private = self.git("rev-parse", "refs/tags/private")
self.assert_passed(self.push(self.update("refs/tags/clean", clean)))
self.assert_private_rejected(self.push(self.update("refs/tags/private", private)))
def test_empty_push_and_deletions_do_not_scan_unpushed_history(self):
private = self.private_commit()
deletion = self.update("(delete)", ZERO_OID, "refs/heads/private", private)
clean_update = self.update(self.base, self.base, "refs/heads/public")
for updates in [(), (deletion,), (deletion, clean_update)]:
with self.subTest(updates=updates):
self.assert_passed(self.push(*updates))
def test_private_file_introduced_only_by_merge_resolution_is_rejected(self):
self.git("checkout", "-b", "feature")
self.commit_file("feature.txt")
self.git("checkout", "main")
self.commit_file("main.txt")
self.git("merge", "--no-ff", "--no-commit", "feature")
merge = self.private_commit()
self.assert_private_rejected(self.push(self.update("refs/heads/main", merge)))
def test_invalid_pushed_oid_fails_closed(self):
result = self.push(self.update("refs/heads/missing", "f" * 40))
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("Public tree check passed.", result.stdout)
if __name__ == "__main__":
unittest.main()
+25 -4
View File
@@ -22,19 +22,40 @@ def prohibited(path):
return bool(parsed.parts and parsed.parts[0] in PRIVATE_ROOTS) or parsed.suffix.lower() in PRIVATE_EXTENSIONS
def paths_in_repository(history=False):
def paths_in_repository(history=False, revisions=None):
if not history:
result = subprocess.check_output(["git", "ls-files", "-z"])
return result.decode("utf-8").split("\0")
result = subprocess.check_output(["git", "log", "--all", "--format=", "--name-only", "-z"])
# An empty push (including deletions only) must not fall back to HEAD or --all.
if revisions is not None and not revisions:
return []
roots = ["--all"] if revisions is None else revisions
# Include diffs against merge parents so merge-resolution-only artifacts are caught.
# Git pushes the stored objects, not the history presented by local replace refs.
result = subprocess.check_output(["git", "--no-replace-objects", "log", "--format=", "--name-only",
"-z", "-m", *roots, "--"])
return [path.lstrip("\n") for path in result.decode("utf-8").split("\0")]
def pushed_revisions(stream):
"""Read Git's pre-push ref updates and retain the non-deleted local object IDs."""
revisions = []
for line in stream:
_local_ref, local_oid, _remote_ref, _remote_oid = line.split()
if local_oid.strip("0"):
revisions.append(local_oid)
return revisions
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--history", action="store_true", help="Also inspect paths in all local Git history")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--history", action="store_true", help="Inspect paths in all local Git history")
mode.add_argument("--pre-push", action="store_true", help="Inspect history reachable from Git's ref updates on stdin")
args = parser.parse_args()
rejected = sorted({path for path in paths_in_repository(args.history) if path and prohibited(path)})
revisions = pushed_revisions(sys.stdin) if args.pre_push else None
rejected = sorted({path for path in paths_in_repository(args.history or args.pre_push, revisions)
if path and prohibited(path)})
if rejected:
print("Private game/profiling artifacts must stay outside Git:", file=sys.stderr)
for path in rejected:
+5
View File
@@ -36,6 +36,11 @@ def group_setup():
*setup('/kill @e[type=!player]'),
{'op': 'tick', 'n': 25},
*setup('/fill 0 200 0 15 228 15 air', '/time set 1000', '/weather clear', '/difficulty normal', '/clear @s'),
# Before the gamerules above, while the oracle waits for its entities to load (a number of ticks that
# depends on the machine), mobs spawn and some die with their drops on: a bat's death rolled
# minecraft:entities/bat on 1 vanilla run in 24, one more sequence for the ticking group's
# /random reset * to count. Every run starts the script without them.
*setup('/random reset *'),
]
+271
View File
@@ -102,6 +102,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
pushes it, as in vanilla), not ever faster across the ground.
- eat: holding right click with bread, the guest's own screen plays the eating (the item in use
counts up) and the server eats one loaf and feeds the guest.
- consume-release: the host and the guest each cancel eating a golden apple and drinking milk from
either hand: use ends on client and server, the item and effects stay, and holding
use to completion still consumes the item (#1293).
- eating: each side sees the other player eat: the item in use (LivingEntity's flags) on the
player it draws, and no longer once the bread is eaten (#803);
- xp: experience orbs 4 blocks away fly to the guest, show on the guest's screen and give the
@@ -136,6 +139,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
server's game time after the guest's game stalls for a few seconds (#848).
- growth: far from the host, the chunks around the guest tick (#890): water placed there flows
and wheat on farmland grows (random ticks), however the host runs its world.
- farmland: the host and guest each trample planted farmland: the wheat breaks and drops its items,
and both clients see dirt/air (#1492).
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
- boats: the host (single player, on its server thread or not) and the guest each right-click a
@@ -4897,6 +4902,82 @@ CHECKS += ['helduse']
CHECK_FNS.update(helduse=check_held_use)
def check_consume_release(host, guest, out):
"""#1293: releasing a consumable in either hand cancels it on client and server, while
holding it to completion applies its effects and remainder (Consumable.onConsume)."""
apart(host, guest)
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = pos_of(game)
game.raw('look-at', x, y + 1.6, z + 2)
for hand in ('mainhand', 'offhand'):
for item in ('golden_apple', 'milk_bucket'):
case = f'{game.name}-{hand}-{item}'
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
if item == 'milk_bucket':
game('command', command='effect give @s minecraft:speed 60')
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:{item}')
game.wait(10)
assert game('looking_at')['kind'] == 'none', f'{case} is not aimed into the air'
before = set(effect_ids(game))
assert before == ({'speed'} if item == 'milk_bucket' else set()), f'{case}: {before}'
game.raw('mouse', 'right', 'down')
game.wait(5)
during = {'client': game('state').get('using'), 'server': server_using(host, game)}
game.raw('mouse', 'right', 'up')
game.wait(10)
released = {'client': game('state').get('using'), 'server': server_using(host, game)}
# Wait past the full use duration: the server must not finish eating after release.
game.wait(60)
effects = set(effect_ids(game))
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
cancelled = {'during': during, 'released': released,
'stack': stack, 'effects': sorted(effects)}
seen[case] = {'cancelled': cancelled}
assert during['client'] and during['server'], f'{case} did not start on both sides: {cancelled}'
assert released == {'client': None, 'server': None}, f'{case} did not cancel: {cancelled}'
assert stack and stack['item'] == f'minecraft:{item}' and stack['count'] == 1, \
f'{case} consumed the released item: {cancelled}'
assert effects == before, f'{case} applied consumption effects after release: {cancelled}'
game.shot(out, f'consume-release-{hand}-{item}')
# A sustained press must still consume normally, including the milk's empty bucket.
game.raw('mouse', 'right', 'down')
game.wait(40)
game.raw('mouse', 'right', 'up')
game.wait(10)
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
completed = {'stack': stack, 'effects': sorted(effect_ids(game)),
'using': (game('state').get('using'), server_using(host, game))}
seen[case]['completed'] = completed
assert completed['using'] == (None, None), \
f'{case} did not finish normally: {completed}'
if item == 'golden_apple':
assert inventory['counts'].get('minecraft:golden_apple', 0) == 0, \
f'{case} kept the eaten apple: {completed}'
assert set(completed['effects']) == {'regeneration', 'absorption'}, f'{case}: {completed}'
else:
assert not completed['effects'] and stack and stack['item'] == 'minecraft:bucket' \
and stack['count'] == 1, f'{case}: {completed}'
finally:
for game in (host, guest):
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
face_each_other(host, guest)
return seen
CHECKS += ['consume-release']
CHECK_FNS.update({'consume-release': check_consume_release})
def check_wind(host, guest, out):
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
@@ -5439,6 +5520,57 @@ CHECKS += ['knockback']
CHECK_FNS.update(knockback=check_knockback)
def check_spectator_travel(host, guest, out):
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
players = (host, guest) if SERVER_THREAD else (host,)
seen = {}
for game in players:
home = pos_of(game)
game('command', command='gamemode spectator')
game.wait(10)
try:
for dimension in (NETHER, OVERWORLD):
game('command', command=f'execute in {dimension} run tp @s .5 102 .5 0 0')
there = wait_dimension(game, dimension, 600)
assert there.get('dimension') == dimension, f'{game.name} stayed in {there.get("dimension")}'
# A replacement body arrives before its chunks; fill needs the whole corridor loaded.
for _ in range(30):
if all(game.raw('block', *pos).get('loaded') for pos in ((-2, 101, -1), (2, 105, 16))):
break
game.wait(20)
game('command', command='fill -2 101 -1 2 105 16 minecraft:air')
game('command', command='fill -2 101 3 2 105 3 minecraft:stone')
game.wait(10)
wall = game.raw('block', 0, 102, 3)
assert wall.get('loaded') and wall.get('block') == 'minecraft:stone', f'wall: {wall}'
label = f'spectator-{dimension.removeprefix("minecraft:")}'
game.shot(out, f'{label}-before')
game.raw('key', 'w', 'down')
game.wait(20)
game.raw('key', 'w', 'up')
game.wait(10)
state = game('state')
client = state['player']
server = server_player(host, game)
seen[f'{game.name}-{dimension}'] = {'client': client['position'], 'server': server['position']}
game.shot(out, f'{label}-after')
assert state.get('gamemode') == 'spectator' and client['flying'], f'{game.name} lost spectator flight'
assert client['position'][2] > 4 and server['position'][2] > 4, \
f'{game.name} collides after travel to {dimension}: {seen}'
game('command', command='fill -2 101 3 2 105 3 minecraft:air')
finally:
game.raw('key', 'w', 'up')
game('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
wait_dimension(game, OVERWORLD, 600)
game('command', command='gamemode survival')
game.wait(20)
return seen
CHECKS += ['spectator-travel']
CHECK_FNS.update({'spectator-travel': check_spectator_travel})
def check_villagers(host, guest, out):
"""Villager.DATA_VILLAGER_DATA and ZombieVillager's reach the guest: summoned villagers show
their type, profession and level, and an unemployed villager that takes a composter placed
@@ -5498,6 +5630,145 @@ CHECKS += ['villagers']
CHECK_FNS.update(villagers=check_villagers)
def check_farmland(host, guest, out):
"""FarmlandBlock.fallOn's base-block change breaks the unsupported crop (#1492):
the host and guest each land on mature wheat, get its drops, and both clients see dirt/air."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
y = max(hy + 12, 100)
starts = {g.name: pos_of(g) for g in (host, guest)}
seen = {}
host('command', command='gamerule random_tick_speed 0')
try:
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
x, z = hx + 2, hz + dz
soil, crop, marker = (x, y, z), (x, y + 1, z), (x + 4, y + 1, z)
for g in (game, other):
g('command', command='gamemode creative')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y + 1} {z - 2} {x + 4} {y + 7} {z + 2} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:farmland[moisture=7]')
host('command', command=f'setblock {x} {y + 1} {z} minecraft:wheat[age=7]')
other('command', command=f'tp @s {x + 3.5} {y + 1} {z + 0.5}')
game('command', command=f'tp @s {x + 2.5} {y + 1} {z + 0.5}')
game('command', command='clear @s')
game('command', command='gamemode survival')
game.wait(20)
before = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
assert all(v == ['minecraft:farmland', 'minecraft:wheat'] for v in before.values()), \
f'{game.name}: planted wheat missing before landing: {before}'
other.raw('look-at', x + 0.5, y + 1, z + 0.5)
other.shot(out, f'farmland-{game.name}-before')
# More than 1.5 blocks of fall distance guarantees the trampling random check.
game('command', command=f'tp @s {x + 0.5} {y + 6} {z + 0.5}')
game.wait(80)
after = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
# Test the authoritative blocks too, rather than only each client's view.
game('command', command=f'execute if block {x} {y} {z} minecraft:dirt '
f'if block {x} {y + 1} {z} minecraft:air run '
f'setblock {marker[0]} {marker[1]} {marker[2]} minecraft:gold_block')
game.wait(10)
counts = {item: server_count(game, item) for item in ('wheat', 'wheat_seeds')}
client_counts = game('inventory')['counts']
seen[game.name] = {'blocks': after, 'server': game.raw('block', *marker).get('block'),
'drops': counts,
'client_drops': {item: client_counts.get('minecraft:' + item, 0)
for item in counts}}
other.shot(out, f'farmland-{game.name}-after')
finally:
host('command', command='gamerule random_tick_speed 3')
host('command', command='kill @e[type=minecraft:item]')
for g in (host, guest):
x0, y0, z0 = starts[g.name]
g('command', command=f'tp @s {x0} {y0} {z0}')
g('command', command='gamemode survival')
for name, s in seen.items():
assert all(v == ['minecraft:dirt', 'minecraft:air'] for v in s['blocks'].values()), \
f'{name}: unsupported wheat after landing: {seen}'
assert s['server'] == 'minecraft:gold_block', f'{name}: server kept the unsupported wheat: {seen}'
assert s['drops']['wheat'] == 1 and 1 <= s['drops']['wheat_seeds'] <= 4, \
f'{name}: mature wheat drops missing: {seen}'
assert s['client_drops'] == s['drops'], f'{name}: crop drops did not synchronize: {seen}'
return seen
CHECKS += ['farmland']
CHECK_FNS.update(farmland=check_farmland)
def check_large_chest(host, guest, out):
"""Every player slot can be picked up or shift-clicked into a double chest (#1505, #1589).
Reopening checks the authoritative contents, with a single chest as the control."""
seen = {}
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(game))
chest, other = (x, y, z + 2), (x + 1, y, z + 2)
try:
for double in (False, True):
size = 54 if double else 27
# Double chest indices 54..89 include the old cutoff at 63/64 and every hotbar slot.
sources = list(range(9, 36)) + list(range(9)) if double else [19, 0, 8]
for shift in (False, True):
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
block = 'minecraft:chest[facing=north,type=left]' if double else 'minecraft:chest'
game('command', command=f'setblock {x} {y} {z + 2} {block}')
if double:
game('command', command=f'setblock {x + 1} {y} {z + 2} minecraft:chest[facing=north,type=right]')
for slot in sources:
name = f'hotbar.{slot}' if slot < 9 else f'inventory.{slot - 9}'
game('command', command=f'item replace entity @s {name} with minecraft:diamond')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
assert container_items(game)[1] == size, f'{game.name}: expected {size} chest slots'
label = f'{"double" if double else "single"}-chest-{"shift" if shift else "pickup"}'
for target, slot in enumerate(sources):
if shift:
game.raw('key', 'shift', 'down')
try:
mouse(game, slot)
finally:
if shift:
game.raw('key', 'shift', 'up')
if not shift:
held = game('inventory').get('carried') or {}
if held.get('item') != 'minecraft:diamond' or held.get('count') != 1:
game.shot(out, f'{label}-slot-{slot}-failed')
raise AssertionError(f'{game.name} {label}: slot {slot} picked up {held}')
mouse(game, 300 + target)
game.raw('key', 'esc', 'tap')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
contents = [s for s in game('screen')['slots'] if 300 <= s['slot'] < 300 + size and s.get('item')]
inv = game('inventory')
stored = sum(s['count'] for s in contents if s['item'] == 'minecraft:diamond')
assert stored == len(sources) and not inv['counts'].get('minecraft:diamond') and not inv.get('carried'), \
f'{game.name} {label}: chest {contents}, inventory {inv}'
game.shot(out, label)
seen[f'{game.name}-{label}'] = stored
game.raw('key', 'esc', 'tap')
finally:
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
return seen
CHECKS += ['large_chest']
CHECK_FNS.update(large_chest=check_large_chest)
def check_falling(host, guest, out):
"""ItemEntity and FallingBlockEntity tick on a client too (gravity, collisions, drag), and their
position packets snap them (Entity's NO_OP InterpolationHandler). The server sends an item's
+16 -11
View File
@@ -181,20 +181,25 @@ def settle(server, ticks=600, timeout=600):
break
time.sleep(2)
def game_time():
start = len(server.lines)
server.console('time query gametime')
while time.time() < deadline:
for line in server.lines[start:]:
if m := re.search(r'The (?:game )?time is (\d+)', line):
return int(m.group(1))
time.sleep(0.2)
raise AssertionError(f'{server.log_path.name}: no game time in {timeout} s')
until = game_time() + ticks
while game_time() < until:
until = game_time(server, deadline - time.time()) + ticks
while game_time(server, deadline - time.time()) < until:
time.sleep(2)
def game_time(server, timeout=300):
"""The server's game time, asked from its console: the console runs its queue in order, so every console
command sent before has run."""
deadline = time.time() + timeout
start = len(server.lines)
server.console('time query gametime')
while time.time() < deadline:
for line in server.lines[start:]:
if m := re.search(r'The (?:game )?time is (\d+)', line):
return int(m.group(1))
time.sleep(0.05)
raise AssertionError(f'{server.log_path.name}: no game time in {timeout:.0f} s')
def legacy_ping(port):
"""The reply to a 1.6 client's 0xFE 0x01 0xFA MC|PingHost query (LegacyQueryHandler)."""
host = 'localhost'
+34 -12
View File
@@ -10,7 +10,11 @@ A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`)
vanilla generated first, with Alice and Bob as operators in creative. LocatorBarClient joins them both (players made
of vanilla's protocol code) and records, step by step, the system chat each receives and what the server changes
about it: game mode events, teleports, inventory slots, effects, its death (with the death message), the other
players it is sent or loses and the levels it respawns into. Each player's lines in each step are compared exactly,
players it is sent or loses and the levels it respawns into. A step ends by the server's clock, not the wall's: its
console commands have run (the console's `time query gametime` comes back), each player's commands have run (a
`sync` round trip), the server has ticked STEP_TICKS more times, and each client holds what was sent until then (a
last `sync`), so a server that stalls under load still has each packet recorded in its own step (a fixed 2.5 s
filed a stalled vanilla's teleports under the next step). Each player's lines in each step are compared exactly,
in sorted order (the packets of one step may leave in another order within the tick), with the locator bar's
waypoints as each step leaves them (one manager per level: a player in another level is untracked). What a death does besides its messages
is left to its own harness.
@@ -25,12 +29,12 @@ import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server
from .test_dedicated_server_parity import Server, game_time
from .test_locator_bar_parity import generate_world, server_dir
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server
from test_dedicated_server_parity import Server, game_time
from test_locator_bar_parity import generate_world, server_dir
NAME = 'server-player-targets-parity'
@@ -70,7 +74,8 @@ SCRIPT = [
# What the players' locator bars show has its own harness (test_locator_bar_parity), and what a death does
# besides its messages (drops, the body leaving) the player combat ones: here only the messages of `kill`.
DEATH_STEP = 'step=console-kill'
STEP_SECONDS = 2.5
STEP_TICKS = 40
PLAYERS = ('Alice', 'Bob')
def run_side(label, server_command, client_command, build, world):
@@ -83,32 +88,49 @@ def run_side(label, server_command, client_command, build, world):
server.wait_for(r'Done \(')
with (build / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)],
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT,
text=True)
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
def tell(line):
client.stdin.write(line + '\n')
client.stdin.flush()
def sync():
for name in PLAYERS:
tell(f'sync {name}')
for name in PLAYERS:
while (line := client.stdout.readline()) != f'synced {name}\n':
if not line:
raise AssertionError(f'{label}: the client exited (see {build}/{label}-client.log)')
log.write(line)
def finish_step():
sync()
until = game_time(server) + STEP_TICKS
while game_time(server) < until:
time.sleep(0.2)
for name in PLAYERS:
tell(f'still {name}')
sync()
try:
for name, x in (('Alice', 0.5), ('Bob', 4.5)):
for name, x in zip(PLAYERS, (0.5, 4.5)):
tell(f'join {name}')
server.wait_for(rf'{name} joined the game', timeout=120)
tell(f'fly {name} on')
tell(f'cmd {name} tp @s {x} -60 0.5 0 0')
time.sleep(STEP_SECONDS)
finish_step()
for flag in ('chat', 'state', 'players'):
tell(f'{flag} on')
for step, console, actions in SCRIPT:
tell(f'step {step}')
for command in console:
server.console(command)
game_time(server) # the console runs its queue in order: these commands have run
for action in actions:
tell(action)
time.sleep(STEP_SECONDS)
for name in ('Alice', 'Bob'):
tell(f'still {name}')
finish_step()
tell('end')
client.wait(timeout=60)
log.write(client.communicate(timeout=60)[0])
finally:
if client.poll() is None:
client.kill()