Compare commits

...
Author SHA1 Message Date
Ludovic Marie c1e90297e2 Merge pull request #1728 from rdbtCVS/t3/7a98935a
Server thread: remove forgotten recipes from the client's book
2026-10-25 08:32:26 +01:00
Ludovic Marie 9dfdff9d62 Merge main into t3/7a98935a: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:25:09 +01:00
Ludovic Marie e56acaa7a9 Merge pull request #1727 from rdbtCVS/t3/9148b77e
Fix invisible shulker bullets on server clients
2026-10-25 08:24:27 +01:00
Ludovic Marie 65425da01d Merge pull request #1633 from some-du6e/fix/remote-trims 2026-10-25 08:23:48 +01:00
Ludovic Marie 6b88291009 Merge pull request #1631 from some-du6e/fix/item-components 2026-10-25 08:21:58 +01:00
Ludovic Marie e29a71e5e0 Merge pull request #1632 from some-du6e/fix/advancement-text 2026-10-25 08:20:14 +01:00
Ludovic Marie 80eafe84e9 Merge main into t3/9148b77e: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:16:44 +01:00
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 b165506d6e Recipe book: removeRecipes returns the display ids it removed, as vanilla
The /recipe take path no longer computes them a second time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 09e95ae5a1 Merge main: keep both appended lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:59 +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 eed98be9cf Merge main and retain both LAN regression checks
Combine the shulker bullet check with main's new falling-entity coverage.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:31:45 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 859236bd9d Fix recipe removal notifications for server players
Notify connected players of the display IDs forgotten by /recipe take.
Discard queued additions for recipes the player no longer knows.
Add LAN regression coverage for both single-player paths.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:25:56 +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 cc65eeec82 Make shulker bullets visible on server clients
Assign mob-fired projectiles IDs when they are added and recognize shulker bullet spawns on remote clients. Extend the live LAN checks for both hosting modes.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:11:09 +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 f66c257ae5 Advancement chat hover: normalise the title with canonical_component
scoreboard::canonical_component already re-encodes a component as vanilla
does, list form included, so decorate_name uses it instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:46:24 -06:00
some-du6eandClaude Opus 5.5 9f1a880951 Advancement chat hover: a title in list form keeps its first element's style
A component list is its first element with the rest as siblings, so
decorateName merges the frame colour under that element's style. The
parsed list moved a styled first element down a level, which gave the
description the frame colour instead of the title's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:43:58 -06:00
some-du6eandClaude Opus 5.5 7a7bfdde79 Advancement chat hover: the description takes the title's own style
Advancement.decorateName merges the frame colour under the title's own
style and appends the description after its siblings. Built from the
parsed Text, a styled root with siblings had its style moved down a level,
so the description showed in the frame colour instead of the title's
(a gold, bold title gave a green description). The hover is now built on
the title's JSON, and the local spans are decoded from that JSON, so an
in-frame player sees the description coloured as a client does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:39:57 -06: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 MarieandClaude Opus 5.5 a26297058a Trims: a Horizon host sends them too
The host wrote no trim component, so single player on its server thread,
LAN guests and dedicated-server players saw armour without its trim.
network_stack now takes the connection's synced-registry lookup for any
registry and writes Component::Trim when both names are in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:15:25 +02: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 f07d66c7cf Trims: their own arm in Component::read
The trim decode sat inside the read-past fallback and matched the kind
there; it now has a TRIM arm, and the fallback only reads past. Long
comment lines wrapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:44:21 +02:00
Ludovic MarieandClaude Opus 5.5 3dc14b6eda Advancement chat names: the description goes after the title's siblings
Advancement.decorateName appends a new line and the description to the title's copy;
adv_name replaced the title's extra list, so a title built from siblings (a
plugin's {"text":"","extra":[...]}) reached clients as just the description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:29:12 +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
some-du6eandClaude Opus 5.5 4ad05aa94a Show armour trims on items from a server
Decode the trim component as its material and pattern registry ids when both
are references (a direct holder stays read past) and name them through the
server's synced trim_material and trim_pattern registries, as enchantments
are, so worn and held trimmed armour draws its trim in multiplayer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:29 -06:00
some-du6eandClaude Opus 5.5 8c611e677b Advancement titles and descriptions show a whole text component
A server's custom advancements often name themselves with an empty text and
the words in its siblings ({"text":"","extra":[...]}); only the top-level
text or translate key was read, so their tooltips were blank. Read them as
the component they are (ComponentSerialization), with siblings, styles,
translation arguments and fallbacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:28 -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
some-du6eandClaude Opus 5.5 9223b401e7 Decode every item data component a server sends
Horizon decoded 25 of 26.3's 122 data component types. Item data has no
length prefix, so a stack with any other component (an armour trim,
banner patterns, pot sherds, a stew's effects, a plugin's item) made the
whole packet undecodable and it was dropped: on a Paper server the
inventory arrived empty until each slot was clicked, the advancements
screen said "There doesn't seem to be anything here..." and the recipe
book was empty.

Every other component is now read to its end with its vanilla
StreamCodec (item/skip.rs) and kept as its bytes, written back as they
came; components without a network codec go as their NBT. The client
leaves them out of the item's components, so the item keeps its
prototype's values for them.

tools/test_item_wire_parity.py checks it against vanilla: the oracle
encodes 374 stacks with ItemStack.OPTIONAL_STREAM_CODEC (every default
component value of every item, SNBT fixtures for the rest, direct
holders and removals built in Java), covering all 122 component types,
and the Rust probe must consume every byte, find the same component ids
and encode the read-past ones back to the same bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:20:45 -06: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
54 changed files with 4168 additions and 563 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()),
}
}
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
let mut extra = String::new();
match kind {
"give" => count = book.add_recipes(&index, &list()).0,
"take" => count = book.remove_recipes(&index, &list()),
"take" => count = book.remove_recipes(&index, &list()).len(),
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
"set" => {
let i = match op["type"].as_str().unwrap_or_default() {
+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),
+131 -29
View File
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
let Some(d) = &a.display else {
return Text::literal(a.id.clone());
};
let color = match d.frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
};
// Advancement.decorateName: the title in the frame's colour (styles
// merged into it), a new line and the description.
let coloured = text_of(&d.title).with_color(color);
let mut component = coloured.component();
if let Some(o) = component.as_object_mut() {
o.insert(
"extra".into(),
serde_json::json!(["\n", text_of(&d.description).component()]),
);
}
let tooltip = coloured
.append("\n")
.append(text_of(&d.description))
.with_component(component);
let color = frame_color(d.frame);
let title = text_of(&d.title).with_style(&Style {
hover: Some(Box::new(tooltip)),
hover: Some(Box::new(decorate_name(d))),
..Style::default()
});
Text::translate("chat.square_brackets", &[title]).with_color(color)
}
fn frame_color(frame: Frame) -> u32 {
match frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
}
}
/// `Advancement.decorateName`: a copy of the title with the frame's colour
/// under its own style (mergeStyles), then a new line and the description
/// after the title's own siblings, so both inherit the title's root style.
/// Built on the title's JSON (a list as its first element with the rest as
/// siblings): a parsed Text moves a styled root's style down a level once it
/// has siblings.
fn decorate_name(d: &adv::Display) -> Text {
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
Value::String(s) => serde_json::json!({ "text": s }),
c => c,
};
if let Some(o) = component.as_object_mut() {
let frame = Text::empty().with_color(frame_color(d.frame)).component();
o.entry("color").or_insert_with(|| frame["color"].clone());
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
if let Some(extra) = extra.as_array_mut() {
extra.extend(["\n".into(), d.description.clone()]);
}
}
text_of(&component).with_component(component)
}
impl Game<'_> {
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
/// its criteria): false when nothing changed.
@@ -1051,17 +1061,10 @@ impl Game<'_> {
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
Text::translate(key, &[])
} else if let Some(s) = v["text"].as_str() {
Text::literal(s)
} else if let Some(s) = v.as_str() {
Text::literal(s)
} else {
Text::empty()
}
horizon_client::scoreboard::comp_text(v)
}
#[cfg(test)]
@@ -1095,6 +1098,105 @@ mod tests {
(seen, None)
}
#[test]
fn titles_keep_their_siblings_and_styles() {
let title = text_of(&serde_json::json!({
"text": "",
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
}));
assert_eq!(title.plain(), "Quest Board");
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
assert_eq!(title.spans[1].style.color, None);
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
assert_eq!(
vanilla,
Text::translate("advancements.story.root.title", &[])
);
assert_eq!(
vanilla.translation_key(),
Some("advancements.story.root.title")
);
}
fn announced(title: Value, description: Value) -> adv::Advancement {
adv::Advancement {
id: "quests:board".into(),
parent: None,
display: Some(adv::Display {
icon: "minecraft:book".into(),
title,
description,
frame: Frame::Task,
background: None,
show_toast: true,
announce: true,
hidden: false,
}),
criteria: Vec::new(),
requirements: Vec::new(),
rewards: adv::Rewards::default(),
}
}
/// The chat hover: the JSON a client receives, and the spans drawn here.
fn hover(a: &adv::Advancement) -> (Value, Text) {
let local = decorate_name(a.display.as_ref().unwrap());
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
(sent, local)
}
#[test]
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
let a = announced(
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
serde_json::json!("Open the board"),
);
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
assert_eq!(colors(&sent), [gold, green, green, green]);
// The spans drawn here come from the same JSON a client receives.
assert_eq!(colors(&local), colors(&sent));
}
#[test]
fn chat_hover_description_takes_a_styled_titles_own_style() {
// mergeStyles keeps the title's gold; the description inherits it.
let object = announced(
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
serde_json::json!("Open the board"),
);
let list = announced(
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
serde_json::json!("Open the board"),
);
for a in [object, list] {
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let last = &sent.spans.last().unwrap().style;
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
assert_eq!(local.spans.last().unwrap().style, *last);
}
}
#[test]
fn chat_hover_of_a_vanilla_advancement() {
let a = announced(
serde_json::json!({"translate": "advancements.story.root.title"}),
serde_json::json!({"translate": "advancements.story.root.description"}),
);
assert_eq!(
hover(&a).0,
serde_json::json!({
"translate": "advancements.story.root.title",
"color": "green",
"extra": ["\n", {"translate": "advancements.story.root.description"}],
})
);
}
#[test]
fn toasts_slide_in_squared_and_out_after_five_seconds() {
let mut toast = recipes(1);
@@ -59,7 +59,15 @@ impl Game<'_> {
self.recipe_unlocked(&unlocked);
count as i32
} else {
self.level.recipe_book.remove_recipes(index, &recipes) as i32
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
tracker.track(&h, n);
}
+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)?)),
+3 -1
View File
@@ -978,7 +978,9 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::Projectile(p) => {
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
+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 {
+58 -11
View File
@@ -612,6 +612,21 @@ impl Game<'_> {
continue;
};
let value = match c {
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
Component::Trim { material, pattern } => {
let (Some(material), Some(pattern)) = (
r.registries.name("minecraft:trim_material", *material),
r.registries.name("minecraft:trim_pattern", *pattern),
) else {
continue;
};
Tag::compound([
("material", Tag::String(material.into())),
("pattern", Tag::String(pattern.into())),
])
}
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
@@ -1601,6 +1616,12 @@ impl Game<'_> {
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
@@ -3064,6 +3085,8 @@ impl Game<'_> {
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
@@ -4233,11 +4256,13 @@ impl Game<'_> {
return;
};
let stack = self.stack_in(slot).cloned();
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go (or another slot is picked).
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
.then_some((slot, ticks + 1));
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
let consumable = stack
@@ -4251,7 +4276,7 @@ impl Game<'_> {
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if slot != self.selected {
if !in_hand {
self.using = None;
return;
}
@@ -4572,12 +4597,10 @@ impl Game<'_> {
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => {
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
Some(s) => s as i16,
None => return false,
}
}
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
@@ -4646,7 +4669,7 @@ impl Game<'_> {
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
@@ -5505,4 +5528,28 @@ mod tests {
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
#[test]
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
for (menu, internal, index) in [
("minecraft:generic_9x6", 18, 63),
("minecraft:generic_9x6", 19, 64),
("minecraft:generic_9x6", 0, 81),
("minecraft:generic_9x6", 8, 89),
("minecraft:generic_9x6", 353, 53),
("minecraft:generic_9x5", 8, 80),
("minecraft:generic_9x4", 8, 71),
("minecraft:generic_9x3", 8, 62),
("inventory", 504, 45),
] {
assert_eq!(
Game::menu_slot_index(menu, internal),
Some(index),
"{menu}, game slot {internal}"
);
}
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
assert_eq!(Game::menu_slot_index("unknown", 0), None);
}
}
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
// Homing state stays on the server; the client interpolates its positions.
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
target: horizon_client::mobs::Who::Player,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
_ => return None,
})
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
];
impl Game<'_> {
+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),
+55 -4
View File
@@ -432,18 +432,21 @@ impl Guest {
}
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
/// `synced_id` names an entry of a synced registry (enchantments, trim
/// materials and patterns) by its id on this connection.
pub fn stack_to_network(
item_id: impl Fn(&str) -> i32,
enchantment_id: impl Fn(&str) -> Option<i32>,
synced_id: impl Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
network_stack(&item_id, &enchantment_id, s)
network_stack(&item_id, &synced_id, s)
}
fn network_stack(
item_id: &dyn Fn(&str) -> i32,
enchantment_id: &dyn Fn(&str) -> Option<i32>,
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
let s = s.as_ref()?;
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
if s.damage > 0 {
@@ -481,6 +484,25 @@ fn network_stack(
horizon_protocol::item::Component::Int(rgb),
));
}
// ArmorTrim: both holders as references into the synced registries.
if let Some((material, pattern)) = &d.trim {
let id = |registry: &str, name: &str| {
if name.contains(':') {
synced_id(registry, name)
} else {
synced_id(registry, &format!("minecraft:{name}"))
}
};
if let (Some(material), Some(pattern)) = (
id("minecraft:trim_material", material),
id("minecraft:trim_pattern", pattern),
) {
stack.added.push((
horizon_protocol::item::component::TRIM,
horizon_protocol::item::Component::Trim { material, pattern },
));
}
}
if let Some(id) = d.map_id {
stack.added.push((
horizon_protocol::item::component::MAP_ID,
@@ -504,7 +526,7 @@ fn network_stack(
let charged = d
.charged
.iter()
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
.collect();
stack.added.push((
horizon_protocol::item::component::CHARGED_PROJECTILES,
@@ -545,3 +567,32 @@ fn network_stack(
}
Some(stack)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::item::{Component, component};
#[test]
fn trims_go_as_synced_registry_references() {
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:trim_material", "minecraft:gold") => Some(4),
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
_ => None,
};
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
assert_eq!(
stack.component(component::TRIM),
Some(&Component::Trim {
material: 4,
pattern: 1
})
);
// A name the connection's registry lacks sends no trim.
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
assert_eq!(stack.component(component::TRIM), None);
}
}
+4 -2
View File
@@ -351,7 +351,7 @@ impl Hub {
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
let sync = self.sync.clone();
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
}
pub fn broadcast(&self, packet: &C) {
@@ -1827,7 +1827,9 @@ impl Hub {
fn flush_advancements(&mut self, i: usize, show: bool) {
let sync = self.sync.clone();
let g = &mut self.guests[i];
let unlocked = std::mem::take(&mut g.recipes_unlocked);
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
// A recipe given and taken before this flush must not reappear.
unlocked.retain(|id| g.recipe_book.knows(id));
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
+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<_>>()
);
}
}
+6 -4
View File
@@ -787,13 +787,15 @@ impl Settings {
}
(displays, added)
}
/// `ServerRecipeBook.removeRecipes`: the displays removed.
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> usize {
let mut displays = 0;
/// `ServerRecipeBook.removeRecipes`: the display ids removed, which its
/// `ClientboundRecipeBookRemovePacket` lists (a transmute recipe has one
/// per material count).
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays += index.displays(id);
displays.extend(index.display_ids(id));
}
}
displays
+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());
}
}
+4
View File
@@ -26,5 +26,9 @@ ipconfig = "0.3.4"
name = "protocol_probe"
path = "examples/probe.rs"
[[example]]
name = "item_wire_probe"
path = "examples/item_wire_probe.rs"
[lints]
workspace = true
@@ -0,0 +1,65 @@
//! The item wire harness's Rust side (tools/test_item_wire_parity.py): reads
//! the oracle's stacks (JSON lines with `label` and `hex`), decodes each with
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
//! patch's component ids (added, then removed), which of them Horizon
//! decoded into its own lossy types (the rest it reads past keeping their
//! bytes, or decodes into ids it writes back unchanged, like trims) and
//! whether encoding the decoded stack gives the same bytes back.
//!
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
use horizon_protocol::{
buf::{Codec, Reader, Writer},
item::{Component, Slot},
};
use serde_json::{Value, json};
use std::io::Write;
fn hex(s: &str) -> Option<Vec<u8>> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
.collect()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read_to_string(args.get(1).ok_or("usage: item_wire_probe IN OUT")?)?;
let mut out = std::io::BufWriter::new(std::fs::File::create(
args.get(2).ok_or("usage: item_wire_probe IN OUT")?,
)?);
for line in input.lines().filter(|l| !l.trim().is_empty()) {
let v: Value = serde_json::from_str(line)?;
let label = v["label"].clone();
let Some(bytes) = v["hex"].as_str().and_then(hex) else {
continue;
};
let mut r = Reader::new(&bytes);
let result = match Slot::read(&mut r) {
Err(e) => json!({"label": label, "error": e.to_string()}),
Ok(stack) => {
let mut w = Writer::new();
Slot::write(&stack, &mut w);
let (added, removed): (Vec<i32>, Vec<i32>) =
stack.as_ref().map_or_else(Default::default, |s| {
(s.added.iter().map(|(k, _)| *k).collect(), s.removed.clone())
});
let typed: Vec<i32> = stack
.iter()
.flat_map(|s| &s.added)
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
.map(|(k, _)| *k)
.collect();
json!({
"label": label,
"left": r.remaining(),
"added": added,
"removed": removed,
"typed": typed,
"same_bytes": w.data == bytes,
})
}
};
writeln!(out, "{result}")?;
}
Ok(())
}
+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();
+80 -3
View File
@@ -2,11 +2,13 @@
//! item's registry id and a data component patch, plus the hashed form the
//! client sends back in container clicks (HashedStack).
use crate::{
Error, Result,
Result,
buf::{Codec, Reader, Writer},
};
use horizon_world::nbt::Tag;
mod skip;
/// minecraft:data_component_type registry ids (26.3).
pub mod component {
pub const CUSTOM_DATA: i32 = 0;
@@ -35,6 +37,7 @@ pub mod component {
pub const CHARGED_PROJECTILES: i32 = 51;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
}
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
@@ -78,6 +81,15 @@ pub enum Component {
WrittenBook(WrittenBook),
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
Stacks(Vec<Stack>),
/// `ArmorTrim` naming its material and pattern by registry id (`trim_material`,
/// `trim_pattern`); a trim with a direct holder stays [`Component::Raw`].
Trim {
material: i32,
pattern: i32,
},
/// A component Horizon reads past without interpreting it: its encoded
/// bytes, written back as they came.
Raw(Vec<u8>),
}
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
@@ -224,11 +236,35 @@ impl Component {
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
}
}
other => return Err(Error::new(format!("unsupported item component {other}"))),
// ArmorTrim.STREAM_CODEC: two holders, each a reference's id plus
// one. A direct material or pattern has no registry name: read past.
TRIM => {
let start = r.position();
skip::component(TRIM, r)?;
let bytes = r.since(start);
let mut ids = Reader::new(bytes);
match (ids.varint(), ids.varint()) {
(Ok(material @ 1..), Ok(pattern @ 1..)) if ids.remaining() == 0 => Self::Trim {
material: material - 1,
pattern: pattern - 1,
},
_ => Self::Raw(bytes.to_vec()),
}
}
other => {
let start = r.position();
skip::component(other, r)?;
Self::Raw(r.since(start).to_vec())
}
})
}
fn write(&self, kind: i32, w: &mut Writer) {
match self {
Self::Raw(bytes) => w.bytes(bytes),
Self::Trim { material, pattern } => {
w.varint(material + 1);
w.varint(pattern + 1);
}
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
Self::Int(v) => w.varint(*v),
Self::Unit => {}
@@ -582,7 +618,8 @@ mod tests {
#[test]
fn unknown_components_are_errors() {
let bytes = [1, 5, 1, 0, 99];
// 122 is past DataComponents' last id (cushion_color, 121).
let bytes = [1, 5, 1, 0, 122];
assert!(Slot::read(&mut Reader::new(&bytes)).is_err());
}
@@ -631,4 +668,44 @@ mod tests {
[("textures".to_owned(), "x".to_owned(), None)]
);
}
#[test]
fn components_read_past_keep_their_bytes() {
// Vanilla 26.3's encoding (tools/test_item_wire_parity.py) of a chestplate's
// armour trim (decoded: both holders are references), a banner's patterns
// (one direct), a pot's sherds and a stew's effects: one server's
// inventory, recipe book and advancements had them.
for (hex, kind) in [
("01bd0801003a0502", 58),
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
("01a408010037020f6412a001", 55),
] {
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
let mut r = Reader::new(&bytes);
let stack = Slot::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0, "component {kind}");
let expected = |c: &Component| match kind {
58 => {
*c == Component::Trim {
material: 4,
pattern: 1,
}
}
_ => matches!(c, Component::Raw(_)),
};
assert!(
stack
.as_ref()
.and_then(|s| s.component(kind))
.is_some_and(expected)
);
let mut w = Writer::new();
Slot::write(&stack, &mut w);
assert_eq!(w.data, bytes, "component {kind}");
}
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Reading past the data components Horizon doesn't interpret: each
//! function consumes exactly what its vanilla StreamCodec writes
//! (DataComponents' `networkSynchronized` codecs; a component without one
//! goes as its persistent Codec's NBT, `ByteBufCodecs.fromCodecWithRegistries`).
//! Item data has no length prefix, so a component read short or long makes
//! everything after it unreadable.
use super::{Component, Template};
use crate::{
Error, Result,
buf::{Codec, Reader},
};
type Read = fn(&mut Reader) -> Result<()>;
/// DataComponents' registry order (26.3) for the ids `Component::read`
/// doesn't decode itself.
pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
match kind {
// Unit.STREAM_CODEC: glider, waxed.
34 | 120 => Ok(()),
// Components without a network codec go as their NBT: intangible_projectile,
// map_decorations, debug_stick_state, recipes, lock, container_loot.
22 | 49 | 59 | 68 | 81 | 82 => nbt(r),
// A VarInt (VAR_INT, an idMapper, a registry or holderRegistry id): damage_type,
// enchantable, additional_trade_cost, block_transformer, villager_food, dye,
// ominous_bottle_amplifier, base_color, the mob variants and colours, the pottery
// pattern and cushion_color.
8 | 31 | 42 | 43 | 44 | 46 | 65 | 75 | 88..=108 | 110..=117 | 121 => varint(r),
// FLOAT: minimum_attack_charge, potion_duration_scale.
7 | 54 => f32(r),
// Identifier.STREAM_CODEC: tooltip_style, note_block_sound.
35 | 73 => ident(r),
// UseEffects: canSprint, interactVibrations, speedMultiplier.
5 => {
r.bool()?;
r.bool()?;
f32(r)
}
// AdventureModePredicate: can_place_on, can_break.
14 | 15 => list(r, block_predicate),
16 => list(r, attribute_modifier),
// FoodProperties.DIRECT_STREAM_CODEC: nutrition, saturation, canAlwaysEat.
23 => {
varint(r)?;
f32(r)?;
boolean(r)
}
// Consumable: consumeSeconds, animation, sound, hasConsumeParticles, onConsumeEffects.
24 => {
f32(r)?;
varint(r)?;
sound(r)?;
boolean(r)?;
list(r, consume_effect)
}
// UseRemainder, SulfurCubeContent: one template.
25 | 80 => template(r),
// UseCooldown: seconds, cooldownGroup.
26 => {
f32(r)?;
optional(r, ident)
}
// DamageResistant, Repairable, provides_banner_patterns: a holder set.
27 | 33 | 67 => holder_set(r),
28 => tool(r),
// Weapon: itemDamagePerAttack, disableBlockingForSeconds.
29 => {
varint(r)?;
f32(r)
}
32 => equippable(r),
// DeathProtection: deathEffects.
36 => list(r, consume_effect),
37 => blocks_attacks(r),
// PiercingWeapon: dealsKnockback, dismounts, sound, hitSound.
38 => {
boolean(r)?;
boolean(r)?;
optional(r, sound)?;
optional(r, sound)
}
39 => kinetic_weapon(r),
// SwingAnimation: type, duration.
40 | 41 => {
varint(r)?;
varint(r)
}
// BundleContents: templates.
52 => list(r, template),
53 => potion_contents(r),
// SuspiciousStewEffects: effect, duration.
55 => list(r, |r| {
varint(r)?;
varint(r)
}),
// ArmorTrim: material, then Holder<TrimPattern> (assetId, description, decal).
58 => {
trim_material(r)?;
holder(r, |r| {
ident(r)?;
nbt(r)?;
boolean(r)
})
}
// TypedEntityData (entity_data, block_entity_data): the type id, then a compound.
60 | 62 => {
varint(r)?;
nbt(r)
}
// CustomData: bucket_entity_data.
61 => nbt(r),
// InstrumentComponent: Holder<Instrument> (soundEvent, useDuration, range,
// durabilityDamage, description).
63 => holder(r, |r| {
sound(r)?;
f32(r)?;
f32(r)?;
varint(r)?;
nbt(r)
}),
64 => trim_material(r),
// JukeboxPlayable: Holder<JukeboxSong> (soundEvent, description, lengthInSeconds,
// comparatorOutput).
66 => holder(r, |r| {
sound(r)?;
nbt(r)?;
f32(r)?;
varint(r)
}),
// LodestoneTracker: target (GlobalPos: dimension, BlockPos), tracked.
69 => {
optional(r, |r| {
ident(r)?;
r.i64().map(drop)
})?;
boolean(r)
}
70 => firework_explosion(r),
// Fireworks: flightDuration, explosions.
71 => {
varint(r)?;
list(r, firework_explosion)
}
// BannerPatternLayers: Holder<BannerPattern> (assetId, translationKey), colour.
74 => list(r, |r| {
holder(r, |r| {
ident(r)?;
string(r)
})?;
varint(r)
}),
// PotDecorations: back, left, right, front.
76 => (0..4).try_for_each(|_| optional(r, template)),
// ItemContainerContents: optional templates.
77 => list(r, |r| optional(r, template)),
// BlockItemStateProperties: a string to string map.
78 => list(r, |r| {
string(r)?;
string(r)
}),
// Bees: Occupant (entity type and data, ticksInHive, minTicksInHive).
79 => list(r, |r| {
varint(r)?;
nbt(r)?;
varint(r)?;
varint(r)
}),
83 => sound(r),
// Compostable: layers.
84 => resolvable(r, i32),
// CookingFuel (burnTime, speedMultiplier), BrewingFuel (uses, speedMultiplier).
85 | 86 => {
resolvable(r, i32)?;
resolvable(r, f32)
}
// MobVisibility: targetingEntityTypes, visibility.
87 => {
holder_set(r)?;
f32(r)
}
// Holder<PaintingVariant>: width, height, assetId, title, author.
109 => holder(r, |r| {
varint(r)?;
varint(r)?;
ident(r)?;
optional(r, nbt)?;
optional(r, nbt)
}),
// SignText: four messages, four optional filtered ones, colour, hasGlowingText.
118 | 119 => {
(0..4).try_for_each(|_| nbt(r))?;
optional(r, |r| (0..4).try_for_each(|_| nbt(r)))?;
varint(r)?;
boolean(r)
}
other => Err(Error::new(format!("unsupported item component {other}"))),
}
}
fn boolean(r: &mut Reader) -> Result<()> {
r.bool().map(drop)
}
fn varint(r: &mut Reader) -> Result<()> {
r.varint().map(drop)
}
fn f32(r: &mut Reader) -> Result<()> {
r.f32().map(drop)
}
fn i32(r: &mut Reader) -> Result<()> {
r.i32().map(drop)
}
/// ByteBufCodecs.STRING_UTF8.
fn string(r: &mut Reader) -> Result<()> {
r.string(32767).map(drop)
}
/// Identifier.STREAM_CODEC, ResourceKey.streamCodec.
fn ident(r: &mut Reader) -> Result<()> {
string(r)
}
/// A text component or compound (fromCodecWithRegistries, COMPOUND_TAG).
fn nbt(r: &mut Reader) -> Result<()> {
r.nbt().map(drop)
}
fn template(r: &mut Reader) -> Result<()> {
Template::read(r).map(drop)
}
/// ByteBufCodecs.optional: a flag, then the value.
fn optional(r: &mut Reader, value: Read) -> Result<()> {
if r.bool()? { value(r) } else { Ok(()) }
}
/// ByteBufCodecs.list and map: a VarInt count, then the elements (a map's
/// key and value each). Every element is at least a byte, so the count
/// can't pass what is left.
fn list(r: &mut Reader, element: Read) -> Result<()> {
let n = r.count(r.remaining())?;
(0..n).try_for_each(|_| element(r))
}
/// ByteBufCodecs.holder: 0 and the value itself, or the registry id plus one.
fn holder(r: &mut Reader, direct: Read) -> Result<()> {
if r.varint()? == 0 { direct(r) } else { Ok(()) }
}
/// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
fn holder_set(r: &mut Reader) -> Result<()> {
match r.varint()? {
0 => ident(r),
n => {
let n = usize::try_from(n - 1).map_err(|_| Error::new("negative holder set size"))?;
if n > r.remaining() {
return Err(Error::new("holder set past the end"));
}
(0..n).try_for_each(|_| varint(r))
}
}
}
/// SoundEvent.STREAM_CODEC: a holder of a location and an optional fixed range.
fn sound(r: &mut Reader) -> Result<()> {
holder(r, |r| {
ident(r)?;
optional(r, f32)
})
}
/// ResolvableInt, ResolvableFloat: Either a constant (true) or a context provider's key.
fn resolvable(r: &mut Reader, constant: Read) -> Result<()> {
if r.bool()? { constant(r) } else { ident(r) }
}
/// TrimMaterial.STREAM_CODEC: a holder of a palette id and a description.
fn trim_material(r: &mut Reader) -> Result<()> {
holder(r, |r| {
ident(r)?;
nbt(r)
})
}
/// MobEffectInstance: the effect id, then its Details.
fn mob_effect(r: &mut Reader) -> Result<()> {
varint(r)?;
effect_details(r)
}
/// MobEffectInstance.Details: amplifier, duration, ambient, showParticles, showIcon, then the
/// hidden effect's Details, recursively.
fn effect_details(r: &mut Reader) -> Result<()> {
varint(r)?;
varint(r)?;
boolean(r)?;
boolean(r)?;
boolean(r)?;
optional(r, effect_details)
}
/// ConsumeEffect.STREAM_CODEC: the consume_effect_type id, then that type's fields.
fn consume_effect(r: &mut Reader) -> Result<()> {
match r.varint()? {
// apply_effects: effects, probability.
0 => {
list(r, mob_effect)?;
f32(r)
}
// remove_effects: a mob effect holder set.
1 => holder_set(r),
// clear_all_effects.
2 => Ok(()),
// teleport_randomly: diameter, directionalParticles.
3 => {
f32(r)?;
boolean(r)
}
// play_sound.
4 => sound(r),
other => Err(Error::new(format!("unknown consume effect type {other}"))),
}
}
/// BlockPredicate: blocks, properties, nbt, then DataComponentMatchers (exact, partial).
fn block_predicate(r: &mut Reader) -> Result<()> {
optional(r, holder_set)?;
// StatePropertiesPredicate: PropertyMatchers, each a name and an
// Either<ExactMatcher (value), RangedMatcher (min, max)>.
optional(r, |r| {
list(r, |r| {
string(r)?;
if r.bool()? {
string(r)
} else {
optional(r, string)?;
optional(r, string)
}
})
})?;
optional(r, nbt)?;
// DataComponentExactPredicate: TypedDataComponents, a component type id and its value.
list(r, |r| {
let kind = r.varint()?;
Component::read(kind, r).map(drop)
})?;
// DataComponentPredicate.STREAM_CODEC: an Either of a predicate type id or a component
// type id, then the predicate as NBT (fromCodecWithRegistries).
list(r, |r| {
boolean(r)?;
varint(r)?;
nbt(r)
})
}
/// ItemAttributeModifiers.Entry: attribute, modifier (id, amount, operation), slot, display.
fn attribute_modifier(r: &mut Reader) -> Result<()> {
varint(r)?;
ident(r)?;
r.f64()?;
varint(r)?;
varint(r)?;
// Display.Type: default, hidden, or override and its text.
match r.varint()? {
0 | 1 => Ok(()),
2 => nbt(r),
other => Err(Error::new(format!(
"unknown attribute modifier display {other}"
))),
}
}
/// Tool: rules (blocks, speed, correctForDrops), defaultMiningSpeed, damagePerBlock,
/// canDestroyBlocksInCreative.
fn tool(r: &mut Reader) -> Result<()> {
list(r, |r| {
holder_set(r)?;
optional(r, f32)?;
optional(r, boolean)
})?;
f32(r)?;
varint(r)?;
boolean(r)
}
/// Equippable: slot, equipSound, assetId, cameraOverlay, allowedEntities, dispensable,
/// swappable, damageOnHurt, equipOnInteract, canBeSheared, shearingSound.
fn equippable(r: &mut Reader) -> Result<()> {
varint(r)?;
sound(r)?;
optional(r, ident)?;
optional(r, ident)?;
optional(r, holder_set)?;
(0..5).try_for_each(|_| boolean(r))?;
sound(r)
}
/// BlocksAttacks: blockDelaySeconds, disableCooldownScale, damageReductions (angle, type,
/// base, factor), itemDamage (threshold, base, factor), bypassedBy, blockSound, disableSound.
fn blocks_attacks(r: &mut Reader) -> Result<()> {
f32(r)?;
f32(r)?;
list(r, |r| {
f32(r)?;
optional(r, holder_set)?;
f32(r)?;
f32(r)
})?;
(0..3).try_for_each(|_| f32(r))?;
optional(r, holder_set)?;
optional(r, sound)?;
optional(r, sound)
}
/// KineticWeapon: contactCooldownTicks, delayTicks, the dismount, knockback and damage
/// conditions (maxDurationTicks, minSpeed, minRelativeSpeed), forwardMovement,
/// damageMultiplier, sound, hitSound.
fn kinetic_weapon(r: &mut Reader) -> Result<()> {
varint(r)?;
varint(r)?;
(0..3).try_for_each(|_| {
optional(r, |r| {
varint(r)?;
f32(r)?;
f32(r)
})
})?;
f32(r)?;
f32(r)?;
optional(r, sound)?;
optional(r, sound)
}
/// PotionContents: potion, customColor, customEffects, customName.
fn potion_contents(r: &mut Reader) -> Result<()> {
optional(r, varint)?;
optional(r, i32)?;
list(r, mob_effect)?;
optional(r, string)
}
/// FireworkExplosion: shape, colors, fadeColors, hasTrail, hasTwinkle.
fn firework_explosion(r: &mut Reader) -> Result<()> {
varint(r)?;
list(r, i32)?;
list(r, i32)?;
boolean(r)?;
boolean(r)
}
+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
@@ -0,0 +1,155 @@
package dev.horizonlab.parity;
import io.netty.buffer.Unpooled;
import it.unimi.dsi.fastutil.objects.Reference2ObjectMap;
import it.unimi.dsi.fastutil.objects.Reference2ObjectMaps;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Optional;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponentPatch;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.component.TypedDataComponent;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.world.entity.decoration.painting.PaintingVariant;
import net.minecraft.world.item.JukeboxPlayable;
import net.minecraft.world.item.JukeboxSong;
import net.minecraft.world.item.component.MapPostProcessing;
import net.minecraft.util.Unit;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/**
* Item stacks as a server sends them (ItemStack.OPTIONAL_STREAM_CODEC), for
* every data component: each default component value of every vanilla item
* set on a stick (so it is in the patch), the SNBT fixtures given in a file
* (one `label<TAB>stack SNBT` per line), and a few patches built directly.
* Prints one JSON line per stack: its label, the encoded bytes in hex and the
* patch's component type ids in encoding order (added, then removed), or
* the error.
*/
public final class VanillaItemWireOracle {
private static final int PER_TYPE = 12;
public static void main(String[] args) throws Exception {
ParityData.init();
List<String[]> fixtures = new ArrayList<>();
for (String line : Files.readAllLines(Path.of(args[0]))) {
if (line.isBlank() || line.startsWith("#")) continue;
fixtures.add(line.split("\t", 2));
}
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
// Each distinct default value, once per component type and
// encoding, and at most PER_TYPE of them for one type.
Set<String> seen = new HashSet<>();
Map<DataComponentType<?>, Integer> perType = new HashMap<>();
for (var item : BuiltInRegistries.ITEM) {
for (TypedDataComponent<?> c : item.components()) {
ItemStack stack = new ItemStack(Items.STICK);
if (!set(stack, c)) continue;
String hex = encode(stack);
if (hex == null || perType.getOrDefault(c.type(), 0) >= PER_TYPE) continue;
if (!seen.add(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()) + hex)) continue;
perType.merge(c.type(), 1, Integer::sum);
print(out, "default " + BuiltInRegistries.ITEM.getKey(item) + " " + BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()), stack);
}
}
for (String[] f : fixtures) {
try {
print(out, f[0], ParityData.stack(f[1]));
} catch (Exception e) {
out.println("{\"label\":" + json(f[0]) + ",\"error\":" + json(String.valueOf(e)) + "}");
}
}
// Components without a persistent codec, direct holders item SNBT
// can't name, and removals.
ItemStack direct = new ItemStack(Items.STICK);
direct.set(DataComponents.ADDITIONAL_TRADE_COST, 4);
direct.set(DataComponents.MAP_POST_PROCESSING, MapPostProcessing.SCALE);
direct.set(DataComponents.JUKEBOX_PLAYABLE, new JukeboxPlayable(Holder.direct(
new JukeboxSong(SoundEvents.ARMOR_EQUIP_CHAIN, Component.literal("S"), 10f, 3))));
direct.set(DataComponents.PAINTING_VARIANT, Holder.direct(new PaintingVariant(
2, 1, Identifier.withDefaultNamespace("kebab"), Optional.of(Component.literal("T")), Optional.empty())));
print(out, "non-persistent and direct", direct);
ItemStack locked = new ItemStack(Items.STONE);
locked.set(DataComponents.CREATIVE_SLOT_LOCK, Unit.INSTANCE);
print(out, "creative_slot_lock", locked);
ItemStack removed = new ItemStack(Items.APPLE, 3);
removed.remove(DataComponents.FOOD);
removed.remove(DataComponents.CONSUMABLE);
removed.set(DataComponents.DAMAGE, 4);
print(out, "removed food and consumable", removed);
print(out, "empty", ItemStack.EMPTY);
}
}
@SuppressWarnings("unchecked")
private static <T> boolean set(ItemStack stack, TypedDataComponent<T> c) {
if (stack.getPrototype().has(c.type()) && java.util.Objects.equals(stack.getPrototype().get(c.type()), c.value())) return false;
stack.set((DataComponentType<T>) c.type(), c.value());
return true;
}
private static String encode(ItemStack stack) {
var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), ParityData.access);
try {
ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, stack);
} catch (RuntimeException e) {
return null;
}
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private static void print(PrintWriter out, String label, ItemStack stack) {
String hex = encode(stack);
if (hex == null) {
out.println("{\"label\":" + json(label) + ",\"error\":\"encode failed\"}");
return;
}
// DataComponentPatch's encoding order: its map's fast iteration, added
// components, then removals.
StringBuilder kinds = new StringBuilder();
StringBuilder removed = new StringBuilder();
for (var e : Reference2ObjectMaps.fastIterable(patchMap(stack.getComponentsPatch()))) {
// Removed (package-private) marks a removal.
StringBuilder to = e.getValue().getClass().getName().equals("net.minecraft.core.component.Removed") ? removed : kinds;
to.append(to.isEmpty() ? "" : ",").append(id(e.getKey()));
}
out.println("{\"label\":" + json(label) + ",\"hex\":\"" + hex + "\",\"added\":[" + kinds + "],\"removed\":[" + removed + "]}");
}
@SuppressWarnings("unchecked")
private static Reference2ObjectMap<DataComponentType<?>, Object> patchMap(DataComponentPatch patch) {
try {
Field map = DataComponentPatch.class.getDeclaredField("map");
map.setAccessible(true);
return (Reference2ObjectMap<DataComponentType<?>, Object>) map.get(patch);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
private static int id(DataComponentType<?> type) {
return BuiltInRegistries.DATA_COMPONENT_TYPE.getId(type);
}
private static String json(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
}
}
+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 *'),
]
+449 -2
View File
@@ -87,7 +87,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
- commands: the guest's chat completes commands and their arguments over the server's command
tree (Tab), and runs what it completed;
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
places a log in the inventory's crafting grid (the server places it).
places a log in the inventory's crafting grid (the server places it).
- recipe_removal: the host and guest each forget all and individual recipes while the book
is open; it stays empty when reopened, and a later grant appears (#1495).
- feedback: the guest's command feedback names the guest and shows the numbers, and the host (an
operator) is told of it as `[Guest: ...]`.
- clicks: with real mouse presses (not click-slot, which skips the screen's press and drag
@@ -102,6 +104,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
pushes it, as in vanilla), not ever faster across the ground.
- eat: holding right click with bread, the guest's own screen plays the eating (the item in use
counts up) and the server eats one loaf and feeds the guest.
- consume-release: the host and the guest each cancel eating a golden apple and drinking milk from
either hand: use ends on client and server, the item and effects stay, and holding
use to completion still consumes the item (#1293).
- eating: each side sees the other player eat: the item in use (LivingEntity's flags) on the
player it draws, and no longer once the bread is eaten (#803);
- xp: experience orbs 4 blocks away fly to the guest, show on the guest's screen and give the
@@ -109,7 +114,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
- objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
the host has them; a falling block lands on both sides.
the host has them; a falling block lands on both sides.
- shulker_bullets: every shot a shulker fires has its own id and flies on both sides, following
the same trajectory; the bullets disappear on both sides when they hit blocks (#1494).
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
sides (#1363, #1391);
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
@@ -136,6 +143,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
server's game time after the guest's game stalls for a few seconds (#848).
- growth: far from the host, the chunks around the guest tick (#890): water placed there flows
and wheat on farmland grows (random ticks), however the host runs its world.
- farmland: the host and guest each trample planted farmland: the wheat breaks and drops its items,
and both clients see dirt/air (#1492).
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
- boats: the host (single player, on its server thread or not) and the guest each right-click a
@@ -4897,6 +4906,82 @@ CHECKS += ['helduse']
CHECK_FNS.update(helduse=check_held_use)
def check_consume_release(host, guest, out):
"""#1293: releasing a consumable in either hand cancels it on client and server, while
holding it to completion applies its effects and remainder (Consumable.onConsume)."""
apart(host, guest)
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = pos_of(game)
game.raw('look-at', x, y + 1.6, z + 2)
for hand in ('mainhand', 'offhand'):
for item in ('golden_apple', 'milk_bucket'):
case = f'{game.name}-{hand}-{item}'
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
if item == 'milk_bucket':
game('command', command='effect give @s minecraft:speed 60')
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:{item}')
game.wait(10)
assert game('looking_at')['kind'] == 'none', f'{case} is not aimed into the air'
before = set(effect_ids(game))
assert before == ({'speed'} if item == 'milk_bucket' else set()), f'{case}: {before}'
game.raw('mouse', 'right', 'down')
game.wait(5)
during = {'client': game('state').get('using'), 'server': server_using(host, game)}
game.raw('mouse', 'right', 'up')
game.wait(10)
released = {'client': game('state').get('using'), 'server': server_using(host, game)}
# Wait past the full use duration: the server must not finish eating after release.
game.wait(60)
effects = set(effect_ids(game))
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
cancelled = {'during': during, 'released': released,
'stack': stack, 'effects': sorted(effects)}
seen[case] = {'cancelled': cancelled}
assert during['client'] and during['server'], f'{case} did not start on both sides: {cancelled}'
assert released == {'client': None, 'server': None}, f'{case} did not cancel: {cancelled}'
assert stack and stack['item'] == f'minecraft:{item}' and stack['count'] == 1, \
f'{case} consumed the released item: {cancelled}'
assert effects == before, f'{case} applied consumption effects after release: {cancelled}'
game.shot(out, f'consume-release-{hand}-{item}')
# A sustained press must still consume normally, including the milk's empty bucket.
game.raw('mouse', 'right', 'down')
game.wait(40)
game.raw('mouse', 'right', 'up')
game.wait(10)
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
completed = {'stack': stack, 'effects': sorted(effect_ids(game)),
'using': (game('state').get('using'), server_using(host, game))}
seen[case]['completed'] = completed
assert completed['using'] == (None, None), \
f'{case} did not finish normally: {completed}'
if item == 'golden_apple':
assert inventory['counts'].get('minecraft:golden_apple', 0) == 0, \
f'{case} kept the eaten apple: {completed}'
assert set(completed['effects']) == {'regeneration', 'absorption'}, f'{case}: {completed}'
else:
assert not completed['effects'] and stack and stack['item'] == 'minecraft:bucket' \
and stack['count'] == 1, f'{case}: {completed}'
finally:
for game in (host, guest):
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
face_each_other(host, guest)
return seen
CHECKS += ['consume-release']
CHECK_FNS.update({'consume-release': check_consume_release})
def check_wind(host, guest, out):
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
@@ -5439,6 +5524,57 @@ CHECKS += ['knockback']
CHECK_FNS.update(knockback=check_knockback)
def check_spectator_travel(host, guest, out):
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
players = (host, guest) if SERVER_THREAD else (host,)
seen = {}
for game in players:
home = pos_of(game)
game('command', command='gamemode spectator')
game.wait(10)
try:
for dimension in (NETHER, OVERWORLD):
game('command', command=f'execute in {dimension} run tp @s .5 102 .5 0 0')
there = wait_dimension(game, dimension, 600)
assert there.get('dimension') == dimension, f'{game.name} stayed in {there.get("dimension")}'
# A replacement body arrives before its chunks; fill needs the whole corridor loaded.
for _ in range(30):
if all(game.raw('block', *pos).get('loaded') for pos in ((-2, 101, -1), (2, 105, 16))):
break
game.wait(20)
game('command', command='fill -2 101 -1 2 105 16 minecraft:air')
game('command', command='fill -2 101 3 2 105 3 minecraft:stone')
game.wait(10)
wall = game.raw('block', 0, 102, 3)
assert wall.get('loaded') and wall.get('block') == 'minecraft:stone', f'wall: {wall}'
label = f'spectator-{dimension.removeprefix("minecraft:")}'
game.shot(out, f'{label}-before')
game.raw('key', 'w', 'down')
game.wait(20)
game.raw('key', 'w', 'up')
game.wait(10)
state = game('state')
client = state['player']
server = server_player(host, game)
seen[f'{game.name}-{dimension}'] = {'client': client['position'], 'server': server['position']}
game.shot(out, f'{label}-after')
assert state.get('gamemode') == 'spectator' and client['flying'], f'{game.name} lost spectator flight'
assert client['position'][2] > 4 and server['position'][2] > 4, \
f'{game.name} collides after travel to {dimension}: {seen}'
game('command', command='fill -2 101 3 2 105 3 minecraft:air')
finally:
game.raw('key', 'w', 'up')
game('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
wait_dimension(game, OVERWORLD, 600)
game('command', command='gamemode survival')
game.wait(20)
return seen
CHECKS += ['spectator-travel']
CHECK_FNS.update({'spectator-travel': check_spectator_travel})
def check_villagers(host, guest, out):
"""Villager.DATA_VILLAGER_DATA and ZombieVillager's reach the guest: summoned villagers show
their type, profession and level, and an unemployed villager that takes a composter placed
@@ -5498,6 +5634,317 @@ CHECKS += ['villagers']
CHECK_FNS.update(villagers=check_villagers)
def check_recipe_removal(host, guest, out):
"""ServerRecipeBook.removeRecipes sends ClientboundRecipeBookRemovePacket to the player:
the open book updates immediately, reopening keeps it empty, and later grants still arrive."""
seen = {}
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
game('command', command='give @s minecraft:oak_log 4')
game('command', command='recipe give @s minecraft:oak_planks')
game('command', command='recipe give @s minecraft:stick')
game.wait(40)
game.raw('key', 'e', 'tap')
game.wait(5)
try:
book = game('screen').get('recipe_book')
assert book, f'{game.name}: the inventory has no recipe book'
if len(book['targets']) == 1:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
book = game('screen')['recipe_book']
known = book['known']
assert known > 1, f'{game.name}: recipes were not granted: {book}'
game.shot(out, 'recipe-removal-before')
game('command', command='recipe take @s *')
game.wait(20)
empty = game('screen')['recipe_book']['known']
game.shot(out, 'recipe-removal-empty')
assert empty == 0, f'{game.name}: forgotten recipes remain in the open book: {empty}'
game.raw('key', 'esc', 'tap')
game.wait(5)
game.raw('key', 'e', 'tap')
game.wait(5)
reopened = game('screen')['recipe_book']['known']
assert reopened == 0, f'{game.name}: reopening restored {reopened} forgotten recipes'
game('command', command='recipe give @s minecraft:oak_planks')
game('command', command='recipe give @s minecraft:stick')
game.wait(20)
given = game('screen')['recipe_book']['known']
assert given == 2, f'{game.name}: two later grants did not appear: {given}'
game('command', command='recipe take @s minecraft:oak_planks')
game.wait(20)
one = game('screen')['recipe_book']['known']
assert one == 1, f'{game.name}: taking one recipe left {one}, expected 1'
game('command', command='recipe take @s minecraft:stick')
game.wait(20)
assert game('screen')['recipe_book']['known'] == 0, f'{game.name}: the last recipe remains'
game('command', command='recipe give @s minecraft:oak_planks')
game.wait(20)
granted = game('screen')['recipe_book']['known']
assert granted == 1, f'{game.name}: a later grant did not appear: {granted}'
game.shot(out, 'recipe-removal-granted')
seen[game.name] = {'known': known, 'empty': empty, 'reopened': reopened,
'given': given, 'one': one, 'granted': granted}
finally:
back_to_game(game)
return seen
CHECKS += ['recipe_removal']
CHECK_FNS.update(recipe_removal=check_recipe_removal)
def check_shulker_bullets(host, guest, out):
"""ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing
projectile renderer and interpolation, with distinct ids from the server's entity counter."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'lan_shulker_bullets'
selector = f'@e[type=minecraft:shulker,tag={tag},limit=1]'
sound = 'minecraft:entity.shulker.shoot'
paths = {g.name: {} for g in (host, guest)}
before = {g.name: heard(g, sound) for g in (host, guest)}
shots = dict.fromkeys(before, 0)
first_shot = stopped = None
photographed = False
def bullets(status):
return [p for p in status['projectiles'] if p['kind'] == 'ShulkerBullet']
try:
host('command', command='difficulty normal')
host('command', command='gamemode creative')
guest('command', command='gamemode survival')
guest('command', command='effect give @s minecraft:resistance 120 4 true')
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
# A west wall supports the shulker above the floor, leaving room for a short flight.
host('command', command=f'fill {x - 1} {y} {z + 7} {x - 1} {y + 4} {z + 7} minecraft:stone')
host('command', command=f'tp @s {x + 4.5} {y} {z - 0.5}')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host('command', command=f'summon minecraft:shulker {x + 0.5} {y + 3} {z + 7.5} '
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
guest.wait(20)
host('command', command=f'data merge entity {selector} {{NoAI:0b}}')
for waited in range(2, 402, 2):
guest.wait(2)
for game in (host, guest):
status = game('status')
live = bullets(status)
ids = [p['id'] for p in live]
assert all(ids) and len(set(ids)) == len(ids), f'{game.name}: invalid bullet ids {ids}'
for p in live:
paths[game.name].setdefault(p['id'], []).append(p['position'])
shots[game.name] = status['heard'].get(sound, 0) - before[game.name]
if shots['host'] and first_shot is None:
first_shot = waited
if first_shot is not None and waited - first_shot >= 8 and not photographed:
host('command', command='tick freeze')
try:
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
game.raw('key', 'f1', 'tap')
try:
game.shot(out, 'shulker-bullets')
finally:
game.raw('key', 'f1', 'tap')
finally:
host('command', command='tick unfreeze')
photographed = True
if shots['host'] >= 2 and stopped is None:
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
stopped = waited
if stopped is not None and waited - stopped >= 20:
break
# Keep the survival target in range after a hit applies levitation.
if waited % 20 == 0:
guest('command', command='effect clear @s minecraft:levitation')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
lab.write_json(out / 'shulker-bullets.json', {'shots': shots, 'paths': paths})
assert shots['host'] >= 2, f'the shulker did not attack twice: {shots}'
for name, by_id in paths.items():
assert len(by_id) == shots[name], f'{name}: {shots[name]} shots but bullet ids {list(by_id)}'
for id, positions in by_id.items():
assert any(math.dist(positions[0], p) > 0.5 for p in positions), \
f'{name}: bullet {id} did not move: {positions}'
assert paths['host'].keys() == paths['guest'].keys(), f'different bullets: {paths}'
for id, positions in paths['guest'].items():
# The clients interpolate three ticks behind, and reads are not simultaneous.
assert all(min(math.dist(p, q) for q in paths['host'][id]) < 1.5 for p in positions), \
f'bullet {id} diverged from the host: {paths}'
# Put blocks in the flight volume to exercise server removal without depending on homing.
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command=f'tp @s {x + 0.5} {y + 16} {z + 0.5}')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:stone')
for _ in range(20):
guest.wait(2)
remaining = {g.name: bullets(g('status')) for g in (host, guest)}
if not any(remaining.values()):
break
assert not any(remaining.values()), f'bullets survived block hits: {remaining}'
finally:
host('command', command='tick unfreeze')
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
for game in (host, guest):
game('command', command='effect clear @s')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
face_each_other(host, guest)
return {'shots': shots, 'ids': sorted(paths['host']), 'removed': True}
CHECKS += ['shulker_bullets']
CHECK_FNS.update(shulker_bullets=check_shulker_bullets)
def check_farmland(host, guest, out):
"""FarmlandBlock.fallOn's base-block change breaks the unsupported crop (#1492):
the host and guest each land on mature wheat, get its drops, and both clients see dirt/air."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
y = max(hy + 12, 100)
starts = {g.name: pos_of(g) for g in (host, guest)}
seen = {}
host('command', command='gamerule random_tick_speed 0')
try:
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
x, z = hx + 2, hz + dz
soil, crop, marker = (x, y, z), (x, y + 1, z), (x + 4, y + 1, z)
for g in (game, other):
g('command', command='gamemode creative')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y + 1} {z - 2} {x + 4} {y + 7} {z + 2} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:farmland[moisture=7]')
host('command', command=f'setblock {x} {y + 1} {z} minecraft:wheat[age=7]')
other('command', command=f'tp @s {x + 3.5} {y + 1} {z + 0.5}')
game('command', command=f'tp @s {x + 2.5} {y + 1} {z + 0.5}')
game('command', command='clear @s')
game('command', command='gamemode survival')
game.wait(20)
before = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
assert all(v == ['minecraft:farmland', 'minecraft:wheat'] for v in before.values()), \
f'{game.name}: planted wheat missing before landing: {before}'
other.raw('look-at', x + 0.5, y + 1, z + 0.5)
other.shot(out, f'farmland-{game.name}-before')
# More than 1.5 blocks of fall distance guarantees the trampling random check.
game('command', command=f'tp @s {x + 0.5} {y + 6} {z + 0.5}')
game.wait(80)
after = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
# Test the authoritative blocks too, rather than only each client's view.
game('command', command=f'execute if block {x} {y} {z} minecraft:dirt '
f'if block {x} {y + 1} {z} minecraft:air run '
f'setblock {marker[0]} {marker[1]} {marker[2]} minecraft:gold_block')
game.wait(10)
counts = {item: server_count(game, item) for item in ('wheat', 'wheat_seeds')}
client_counts = game('inventory')['counts']
seen[game.name] = {'blocks': after, 'server': game.raw('block', *marker).get('block'),
'drops': counts,
'client_drops': {item: client_counts.get('minecraft:' + item, 0)
for item in counts}}
other.shot(out, f'farmland-{game.name}-after')
finally:
host('command', command='gamerule random_tick_speed 3')
host('command', command='kill @e[type=minecraft:item]')
for g in (host, guest):
x0, y0, z0 = starts[g.name]
g('command', command=f'tp @s {x0} {y0} {z0}')
g('command', command='gamemode survival')
for name, s in seen.items():
assert all(v == ['minecraft:dirt', 'minecraft:air'] for v in s['blocks'].values()), \
f'{name}: unsupported wheat after landing: {seen}'
assert s['server'] == 'minecraft:gold_block', f'{name}: server kept the unsupported wheat: {seen}'
assert s['drops']['wheat'] == 1 and 1 <= s['drops']['wheat_seeds'] <= 4, \
f'{name}: mature wheat drops missing: {seen}'
assert s['client_drops'] == s['drops'], f'{name}: crop drops did not synchronize: {seen}'
return seen
CHECKS += ['farmland']
CHECK_FNS.update(farmland=check_farmland)
def check_large_chest(host, guest, out):
"""Every player slot can be picked up or shift-clicked into a double chest (#1505, #1589).
Reopening checks the authoritative contents, with a single chest as the control."""
seen = {}
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(game))
chest, other = (x, y, z + 2), (x + 1, y, z + 2)
try:
for double in (False, True):
size = 54 if double else 27
# Double chest indices 54..89 include the old cutoff at 63/64 and every hotbar slot.
sources = list(range(9, 36)) + list(range(9)) if double else [19, 0, 8]
for shift in (False, True):
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
block = 'minecraft:chest[facing=north,type=left]' if double else 'minecraft:chest'
game('command', command=f'setblock {x} {y} {z + 2} {block}')
if double:
game('command', command=f'setblock {x + 1} {y} {z + 2} minecraft:chest[facing=north,type=right]')
for slot in sources:
name = f'hotbar.{slot}' if slot < 9 else f'inventory.{slot - 9}'
game('command', command=f'item replace entity @s {name} with minecraft:diamond')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
assert container_items(game)[1] == size, f'{game.name}: expected {size} chest slots'
label = f'{"double" if double else "single"}-chest-{"shift" if shift else "pickup"}'
for target, slot in enumerate(sources):
if shift:
game.raw('key', 'shift', 'down')
try:
mouse(game, slot)
finally:
if shift:
game.raw('key', 'shift', 'up')
if not shift:
held = game('inventory').get('carried') or {}
if held.get('item') != 'minecraft:diamond' or held.get('count') != 1:
game.shot(out, f'{label}-slot-{slot}-failed')
raise AssertionError(f'{game.name} {label}: slot {slot} picked up {held}')
mouse(game, 300 + target)
game.raw('key', 'esc', 'tap')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
contents = [s for s in game('screen')['slots'] if 300 <= s['slot'] < 300 + size and s.get('item')]
inv = game('inventory')
stored = sum(s['count'] for s in contents if s['item'] == 'minecraft:diamond')
assert stored == len(sources) and not inv['counts'].get('minecraft:diamond') and not inv.get('carried'), \
f'{game.name} {label}: chest {contents}, inventory {inv}'
game.shot(out, label)
seen[f'{game.name}-{label}'] = stored
game.raw('key', 'esc', 'tap')
finally:
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
return seen
CHECKS += ['large_chest']
CHECK_FNS.update(large_chest=check_large_chest)
def check_falling(host, guest, out):
"""ItemEntity and FallingBlockEntity tick on a client too (gravity, collisions, drag), and their
position packets snap them (Entity's NO_OP InterpolationHandler). The server sends an item's
+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'
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Item stacks on the wire: every data component, as vanilla 26.3 encodes it.
VanillaItemWireOracle encodes stacks with ItemStack.OPTIONAL_STREAM_CODEC
(what a server sends in inventories, recipe books and advancements): each
default component value of every vanilla item, the SNBT fixtures below
(direct holders, tags and lists, optional fields, every consume effect,
block predicates with component matchers, the mob variants) and patches
built in Java (components without a persistent codec, removals). The Rust
probe decodes each with horizon_protocol::item::Slot and must consume every
byte and find the same component ids in the same order; a stack whose
components Horizon only reads past must also encode back to the same bytes.
Every id in DataComponents must be covered."""
import argparse
import json
import os
import platform
import subprocess
from pathlib import Path
try:
from . import lab, test_rust_parity as base
except ImportError:
import lab
import test_rust_parity as base
COMPONENT_TYPES = 122
FIXTURES = [
('custom_data', '{custom_data:{a:1,b:"x",c:[1,2]}}'),
('unbreakable', '{unbreakable:{}}'),
('custom_name', '{custom_name:{text:"Hi",color:"red",bold:1b}}'),
('lore', '{lore:["a",{text:"b",italic:0b}]}'),
('enchantments', '{enchantments:{"minecraft:sharpness":5,"minecraft:unbreaking":3}}'),
('can_break tag', '{can_break:{blocks:"#minecraft:logs"}}'),
('can_break list state nbt', '{can_break:[{blocks:["minecraft:stone","minecraft:dirt"],state:{snowy:"true",age:{min:"1",max:"3"},level:{min:"2"}},nbt:"{a:1b}"}]}'),
('can_place_on components', '{can_place_on:{blocks:"minecraft:chest",components:{"minecraft:custom_name":"x","minecraft:damage":3},predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{durability:{min:1}}}}}'),
('can_place_on empty', '{can_place_on:[{}]}'),
('custom_model_data', '{custom_model_data:{floats:[1.0f,2.5f],flags:[1b,0b],strings:["a"],colors:[16711680]}}'),
('tooltip_display', '{tooltip_display:{hide_tooltip:1b,hidden_components:["minecraft:enchantments","minecraft:damage"]}}'),
('repair_cost', '{repair_cost:7}'),
('intangible_projectile', '{intangible_projectile:{}}'),
('tooltip_style', '{tooltip_style:"minecraft:fancy"}'),
('interact_animation', '{interact_animation:{type:"stab",duration:9}}'),
('dyed_color', '{dyed_color:16711935}'),
('map_id', '{map_id:42}'),
('written_book_content', '{written_book_content:{title:{raw:"T",filtered:"F"},author:"me",generation:1,pages:[{raw:"p1",filtered:"q1"},"p2"],resolved:1b}}'),
('trim registry', '{trim:{material:"minecraft:gold",pattern:"minecraft:coast"}}'),
('trim direct', '{trim:{material:{palette_id:"minecraft:x",description:"X"},pattern:{asset_id:"minecraft:y",description:"Y",decal:1b}}}'),
('block_entity_data', '{block_entity_data:{id:"minecraft:chest",Items:[]}}'),
('lodestone_tracker target', '{lodestone_tracker:{target:{dimension:"minecraft:the_nether",pos:[1,-64,3]},tracked:0b}}'),
('lodestone_tracker empty', '{lodestone_tracker:{}}'),
('firework_explosion', '{firework_explosion:{shape:"star",colors:[1,2],fade_colors:[3],has_trail:1b,has_twinkle:0b}}'),
('profile name', '{profile:{name:"Notch"}}'),
('profile full', '{profile:{name:"jeb_",id:[I;1,2,3,4],properties:[{name:"textures",value:"abc",signature:"sig"}]}}'),
('note_block_sound', '{note_block_sound:"minecraft:block.note_block.bell"}'),
('base_color', '{base_color:"lime"}'),
('sulfur_cube_content', '{sulfur_cube_content:{id:"minecraft:stone",count:1}}'),
('lock', '{lock:{items:"minecraft:diamond"}}'),
('container_loot', '{container_loot:{loot_table:"minecraft:chests/simple_dungeon",seed:5L}}'),
('waxed', '{waxed:{}}'),
('banner_patterns', '{banner_patterns:[{pattern:"minecraft:stripe_bottom",color:"red"},{pattern:{asset_id:"minecraft:x",translation_key:"y"},color:"blue"}]}'),
('pot_decorations', '{pot_decorations:{back:{id:"minecraft:brick"},left:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd",count:1}}}'),
('suspicious_stew_effects', '{suspicious_stew_effects:[{id:"minecraft:night_vision",duration:100},{id:"minecraft:poison"}]}'),
('potion_contents full', '{potion_contents:{potion:"minecraft:swiftness",custom_color:123,custom_effects:[{id:"minecraft:speed",amplifier:1b,duration:200,ambient:1b,show_particles:0b,show_icon:1b,hidden_effect:{id:"minecraft:speed",duration:100,hidden_effect:{id:"minecraft:speed",duration:50}}}],custom_name:"x"}}'),
('potion_contents bare', '{potion_contents:{}}'),
('container', '{container:[{slot:0,item:{id:"minecraft:stone",count:3}},{slot:5,item:{id:"minecraft:diamond_sword",components:{"minecraft:damage":5,"minecraft:trim":{material:"minecraft:gold",pattern:"minecraft:coast"}}}}]}'),
('bundle_contents', '{bundle_contents:[{id:"minecraft:stone",count:2},{id:"minecraft:potion",components:{"minecraft:potion_contents":{potion:"minecraft:water"}}}]}'),
('charged_projectiles', '{charged_projectiles:[{id:"minecraft:arrow"},{id:"minecraft:firework_rocket",components:{"minecraft:fireworks":{flight_duration:2b,explosions:[{shape:"burst",colors:[5]}]}}}]}'),
('block_state', '{block_state:{facing:"north",half:"top"}}'),
('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'),
('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'),
('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'),
('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'),
('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'),
('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'),
('sign_text_back', '{sign_text_back:{messages:["a","","",""]}}'),
('map_decorations', '{map_decorations:{a:{type:"minecraft:player",x:1.0d,z:2.0d,rotation:0f}}}'),
('debug_stick_state', '{debug_stick_state:{"minecraft:oak_stairs":"facing"}}'),
('recipes', '{recipes:["minecraft:stone_bricks"]}'),
('attribute_modifiers', '{attribute_modifiers:[{type:"minecraft:attack_damage",id:"minecraft:x",amount:1.5d,operation:"add_value",slot:"mainhand",display:{type:"override",value:"hi"}},{type:"minecraft:armor",id:"minecraft:y",amount:2d,operation:"add_multiplied_total",display:{type:"hidden"}}]}'),
('consumable all effects', '{consumable:{consume_seconds:2f,animation:"drink",sound:{sound_id:"minecraft:x",range:4f},has_consume_particles:0b,on_consume_effects:[{type:"apply_effects",effects:[{id:"minecraft:speed",duration:20}],probability:0.5f},{type:"remove_effects",effects:"minecraft:poison"},{type:"remove_effects",effects:["minecraft:poison","minecraft:wither"]},{type:"clear_all_effects"},{type:"teleport_randomly",diameter:8f},{type:"play_sound",sound:"minecraft:entity.generic.eat"}]}}'),
('death_protection', '{death_protection:{death_effects:[{type:"clear_all_effects"}]}}'),
('equippable full', '{equippable:{slot:"head",equip_sound:"minecraft:item.armor.equip_gold",asset_id:"minecraft:gold",camera_overlay:"minecraft:misc/pumpkinblur",allowed_entities:"#minecraft:skeletons",dispensable:0b,swappable:0b,damage_on_hurt:0b,equip_on_interact:1b,can_be_sheared:1b,shearing_sound:{sound_id:"minecraft:y"}}}'),
('equippable bare', '{equippable:{slot:"chest"}}'),
('tool rules', '{tool:{rules:[{blocks:"#minecraft:mineable/pickaxe",speed:4f,correct_for_drops:1b},{blocks:["minecraft:stone"]}],default_mining_speed:2f,damage_per_block:2,can_destroy_blocks_in_creative:0b}}'),
('blocks_attacks full', '{blocks_attacks:{block_delay_seconds:0.5f,disable_cooldown_scale:2f,damage_reductions:[{horizontal_blocking_angle:45f,type:"#minecraft:is_fire",base:1f,factor:0.5f},{base:0f,factor:1f}],item_damage:{threshold:3f,base:1f,factor:2f},bypassed_by:"#minecraft:bypasses_shield",block_sound:"minecraft:item.shield.block",disable_sound:{sound_id:"minecraft:z"}}}'),
('damage_resistant', '{damage_resistant:{types:"#minecraft:is_fire"}}'),
('repairable list', '{repairable:{items:["minecraft:diamond","minecraft:emerald"]}}'),
('use_cooldown group', '{use_cooldown:{seconds:1.5f,cooldown_group:"minecraft:x"}}'),
('use_remainder', '{use_remainder:{id:"minecraft:bowl",count:2}}'),
('fireworks', '{fireworks:{flight_duration:3b,explosions:[{shape:"creeper",colors:[1],fade_colors:[2,3],has_trail:1b},{shape:"small_ball"}]}}'),
('provides_banner_patterns', '{provides_banner_patterns:"#minecraft:pattern_item/globe"}'),
('wolf variant', '{"minecraft:wolf/variant":"minecraft:ashen","minecraft:wolf/sound_variant":"minecraft:angry","minecraft:wolf/collar":"red"}'),
('fox salmon parrot', '{"minecraft:fox/variant":"snow","minecraft:salmon/size":"large","minecraft:parrot/variant":"blue"}'),
('tropical fish', '{"minecraft:tropical_fish/pattern":"flopper","minecraft:tropical_fish/base_color":"white","minecraft:tropical_fish/pattern_color":"black"}'),
('mooshroom rabbit', '{"minecraft:mooshroom/variant":"brown","minecraft:rabbit/variant":"gold"}'),
('pig cow chicken', '{"minecraft:pig/variant":"minecraft:cold","minecraft:pig/sound_variant":"minecraft:big","minecraft:cow/variant":"minecraft:warm","minecraft:cow/sound_variant":"minecraft:classic","minecraft:chicken/variant":"minecraft:cold","minecraft:chicken/sound_variant":"minecraft:classic"}'),
('nautilus frog horse', '{"minecraft:zombie_nautilus/variant":"minecraft:warm","minecraft:frog/variant":"minecraft:cold","minecraft:horse/variant":"chestnut"}'),
('llama axolotl cat', '{"minecraft:llama/variant":"brown","minecraft:axolotl/variant":"blue","minecraft:cat/variant":"minecraft:tabby","minecraft:cat/sound_variant":"minecraft:classic","minecraft:cat/collar":"blue"}'),
('sheep shulker villager', '{"minecraft:sheep/color":"pink","minecraft:shulker/color":"purple","minecraft:villager/variant":"minecraft:taiga"}'),
('trim material direct provider', '{provides_trim_material:{palette_id:"minecraft:x",description:"X"}}'),
('everything at once', '{damage:3,unbreakable:{},custom_name:"n",enchantments:{"minecraft:mending":1},trim:{material:"minecraft:gold",pattern:"minecraft:coast"},banner_patterns:[{pattern:"minecraft:globe",color:"red"}],pot_decorations:{right:{id:"minecraft:brick"}},lore:["x"],rarity:"epic"}'),
]
# Fixtures on another item than a stick (the components need it, or read better there).
ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate',
'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot',
'everything at once': 'diamond_chestplate'}
def compare(vanilla, rust):
"""Failures, stacks checked, and the component ids covered."""
results = {r['label']: r for r in rust}
failures, covered = [], set()
for v in vanilla:
if 'error' in v:
failures.append(f"oracle {v['label']}: {v['error']}")
continue
covered.update(v['added'] + v['removed'])
r = results.get(v['label'])
if r is None or 'error' in r:
failures.append(f"{v['label']}: {r and r['error']}")
elif r['left'] or r['added'] != v['added'] or r['removed'] != v['removed']:
failures.append(f"{v['label']}: {r['left']} bytes left, ids {r['added']}/{r['removed']}, vanilla {v['added']}/{v['removed']}")
elif not r['same_bytes'] and not r['typed']:
failures.append(f"{v['label']}: read past, but encoded back differently")
missing = sorted(set(range(COMPONENT_TYPES)) - covered)
if missing:
failures.append(f'component ids no stack covers: {missing}')
return failures, len(vanilla), covered
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true')
args = parser.parse_args()
build = lab.LOCAL / 'item-wire-parity'
build.mkdir(parents=True, exist_ok=True)
report_path = build / 'report.json'
report_path.unlink(missing_ok=True)
if args.prepare:
lab.prepare()
lock, jars, libraries = base.verify_artifacts()
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java'
classes = build / 'classes'
classes.mkdir(exist_ok=True)
def run(command, name):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
dependency_path = os.pathsep.join(map(str, libraries))
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath',
lab.ROOT / 'mods/horizon/src/test/java', '-cp', str(jars['server']) + os.pathsep + dependency_path,
'-d', classes, oracle], 'compile-java')
fixtures = build / 'fixtures.tsv'
fixtures.write_text(''.join(f'{label}\t{{id:"minecraft:{ITEMS.get(label, "stick")}",count:1,components:{snbt}}}\n'
for label, snbt in FIXTURES), encoding='utf-8')
vanilla_path, rust_path = build / 'vanilla.jsonl', build / 'rust.jsonl'
run(['java', '-Xmx1G', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
'dev.horizonlab.parity.VanillaItemWireOracle', fixtures, vanilla_path], 'oracle')
run(['cargo', 'build', '--release', '-p', 'horizon-protocol', '--example', 'item_wire_probe'], 'compile-rust')
binary = lab.cargo_target() / 'release/examples' / ('item_wire_probe.exe' if os.name == 'nt' else 'item_wire_probe')
run([binary, vanilla_path, rust_path], 'rust')
read = lambda path: [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
failures, stacks, covered = compare(read(vanilla_path), read(rust_path))
for failure in failures[:40]:
print(failure)
if failures:
raise SystemExit(f'{len(failures)} item wire mismatches (of {stacks} stacks)')
lab.write_json(report_path, {
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(),
'stacks': stacks, 'component_types_covered': len(covered),
'oracle_sha256': lab.digest(vanilla_path, 'sha256'), 'rust_sha256': lab.digest(rust_path, 'sha256')})
print(f'Item wire parity passed: {stacks} stacks, all {len(covered)} component types: {report_path}')
if __name__ == '__main__':
main()
+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()