Compare commits

..
Author SHA1 Message Date
Ludovic Marie d8dd2023dd Merge pull request #1447 from ludovic111/chore/client-mods-feature
🤖 Agent: merged after verify and CI parity passed, built merged with current main. Code mods become horizon-client's `mods` feature: CI's two test commands run with `--features horizon-client/mods,horizon-mods/pack`; tests pass with and without it.
2026-10-24 03:01:51 +02:00
Ludovic Marie d84d7a7e61 Merge pull request #1522 from ludovic111/fix/prepare-netty-natives
🤖 Agent: merged after verify and CI parity passed, built merged with current main (launcher-ship-1). #1521: generated docs keep LF line endings on Windows, so the launcher builds there again (dry run 40035909909 green on all three OSes). #1522: Prepare no longer requires Netty's optional native transport jar, which Prism never downloads (fixes #1487; test plus a real Prism-layout prepare).
2026-10-24 02:53:32 +02:00
Ludovic Marie 1cd0dd8c5d Merge pull request #1529 from ludovic111/fix/ds-parity-flake
🤖 Agent: merged after verify and CI parity passed, built merged with current main. The dedicated_server flake was the harness client never resending its position, so a villager whose chunk went out after its only move was never shown to it (0/21 → 21/21 with the server's tick thread stalled after the join). Failing harnesses now print the reason in CI.
2026-10-24 02:52:06 +02:00
Ludovic Marie 7f60a55ca3 Merge pull request #1468 from ludovic111/fix/one-handed-maps
🤖 Agent: merged after verify and CI parity passed, built merged with current main (reports-gate-2: each claim checked against vanilla 26.3, in-frame and --server-thread; evidence on the issue and in the PR body).
2026-10-24 02:40:52 +02:00
Ludovic Marie 16db2f44ae Merge pull request #1420 from ludovic111/fix/st2-chat
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st2: entity use with either hand and server chat components; lan_e2e checks pass in both modes. The hands check's only flake was a vanilla crit arrow killing the cow, fixed in c89695dd9: 40 of 40 runs pass).
2026-10-24 02:36:33 +02:00
Ludovic Marie 0b56d62273 Merge pull request #1521 from ludovic111/fix/launcher-doc-crlf
🤖 Agent: merged after verify and CI parity passed, built merged with current main (launcher-ship-1). #1521: generated docs keep LF line endings on Windows, so the launcher builds there again (dry run 40035909909 green on all three OSes). #1522: Prepare no longer requires Netty's optional native transport jar, which Prism never downloads (fixes #1487; test plus a real Prism-layout prepare).
2026-10-24 02:16:48 +02:00
Ludovic MarieandClaude Opus 5.5 574e3ca567 dedicated_server parity: the client re-sends its position as vanilla's does
The harness failed about 1 run in 3 on CI with "play-trade villager false" on
Horizon's side: the protocol client never saw the villager it trades with.

A server pairs a player with an entity only while the entity's chunk has been
sent to it, and checks again when either moves (ChunkMap.move / updatePlayers).
VanillaProtocolClient sent its position once, at the spawn teleport, and never
again. Horizon's first chunk batch goes out two ticks after the player is placed
(after the first tick's packets, as vanilla orders them); when a busy machine
held the tick thread back long enough for the client's move to be handled one
tick after placement, the villager's chunk wasn't sent yet, and with no later
move the villager was never paired.

The 26.3 client re-sends its position every 20 ticks standing still
(LocalPlayer.sendPosition, after a ServerboundClientTickEnd each tick). The
parity client now does the same from the time it is loaded until its death, so
an entity whose chunk comes late is paired at the next reminder, on either
server.

On failure the harness now adds the servers' and clients' warnings and errors to
its message and the client log's tail when the client fails, and parity.py's
summary prints the end of the first failing harnesses' logs, so CI shows why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:33:16 +02:00
Ludovic Marie 2a2b4acb5c Merge remote-tracking branch 'origin/main' into fix/st2-chat 2026-10-24 01:07:00 +02:00
Ludovic MarieandClaude Opus 5.5 52bc81ca5b Launcher: the command reference is the same on Windows
launcher.yml's Windows build has failed since the reference test landed: Git for Windows checks
text out with CRLF (core.autocrlf), so docs/launcher-commands.md never equalled what markdown()
generates. .gitattributes now pins LF for the generated files tests compare byte for byte, and
the launcher's check reads the file whatever line endings a clone gave it, with a test of a
CRLF checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:48:08 +02:00
Ludovic MarieandClaude Opus 5.5 e1fff77a48 Prepare: Netty's native transports are optional, as in the game
Prism Launcher never downloads io.netty:netty-transport-native-kqueue (or the epoll one on
Linux), so preparing from a Prism install stopped on a jar the game runs without: vanilla's
EventLoopGroupHolder.remote takes KQueue/Epoll only when isAvailable(), else Netty NIO.
Preparing now imports a native transport when the launcher has it (checked against the
version JSON's sha1, as before) and goes on without it otherwise; every other library stays
required. Fixes #1487.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:38:13 +02:00
Ludovic Marie 265461f2eb Merge pull request #1467 from ludovic111/fix/bundle-clicks
🤖 Agent: merged after verify and CI parity passed, built merged with current main (reports-gate-2: each claim checked against vanilla 26.3, in-frame and --server-thread; evidence on the issue and in the PR body).
2026-10-24 00:32:56 +02:00
Ludovic Marie ba8bdc7179 Merge remote-tracking branch 'origin/main' into fix/st2-chat 2026-10-23 23:07:44 +02:00
Ludovic Marie cbc469249e Merge remote-tracking branch 'origin/main' into fix/bundle-clicks 2026-10-23 23:06:24 +02:00
Ludovic Marie 4d9baf60d4 Merge remote-tracking branch 'origin/fix/st2-entity-hands' into mm1-1420
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 21:29:13 +02:00
Ludovic Marie 0b0e4a8aae Merge remote-tracking branch 'origin/main' into fix/bundle-clicks 2026-10-23 20:53:33 +02:00
Ludovic Marie c19418b55d Merge remote-tracking branch 'origin/main' into fix/one-handed-maps 2026-10-23 20:53:25 +02:00
Ludovic MarieandClaude Opus 5.5 8acb46756b Bundle clicks: the open menu's slotsChanged for the player's inventory
BundleItem.broadcastChangesOnContainerMenu calls slotsChanged(player
inventory) after a bundle click. The menus that don't check which container
changed recompute from their inputs (crafting grids, loom, stonecutter,
cartography, merchant, smithing's hasRecipeError). The menu click harness
found it on a loom with a fixture result: 8000/8000 cases now match (seeds 7
and 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 20:52:41 +02:00
Ludovic Marie ee79b8f0cd Merge remote-tracking branch 'origin/main' into mm1-1447 2026-10-23 20:38:10 +02:00
Ludovic Marie 8667cef3a1 Merge remote-tracking branch 'origin/main' into mm1-1420 2026-10-23 20:37:55 +02:00
Ludovic Marie fe1d544d02 Merge remote-tracking branch 'origin/fix/st2-entity-hands' into mm1-1420
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 20:37:55 +02:00
Ludovic Marie 0cc3b4a057 Merge remote-tracking branch 'origin/main' into mm1-1447
# Conflicts:
#	.github/workflows/ci.yml
2026-10-23 20:21:46 +02:00
Ludovic MarieandClaude Opus 5.5 7dac46ec3c Bundles take and give items in inventories and containers
A carried bundle clicked on a stack took its place instead of taking it in
(#1456): Horizon's menus had no BundleItem click overrides. The PICKUP click
now runs them first, as AbstractContainerMenu.tryItemClickBehaviourOverride:

- a carried bundle takes as much of a clicked stack as its weight allows
  (tryTransfer), and a right click on an empty slot puts its first stack
  there (removeOne, the rest going back in);
- a stack clicked onto a bundle in a slot goes in as far as it fits, and a
  right click with an empty hand takes the bundle's first stack out.

BundleContents.Mutable is in horizon_client::bundle (exact Fraction
weights: 1/max stack, 1 for a beehive with bees, a nested bundle's contents
plus 1/16; shulker boxes don't fit), used by the menu engine and the
LAN/server-thread container engine. The insert, insert-fail and remove-one
sounds play at the player.

In single player the inventory screen's empty-hand press went to an older
click path instead of the menu engine; it now goes to the engine, as on a
server, so the right click on a bundle works there too.

The menu click harness now has bundles (empty, loaded, nested; often in
hand) and compares the player's sounds: 3000/3000 bundle-heavy cases match,
2796/3000 without the overrides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 8ef418e223 Maps in one hand: the off hand, or the main hand beside an item, draws the map
FirstPersonHandsAndItemsRenderer.submitArmWithItem draws a stack with a
map_id two-handed only from the main hand with an empty off hand; any other
map is renderOneHandedMap in its own hand. Horizon only had the two-handed
case and drew every other held map as its item model (#1459).

MapHold picks the case from the hand renderer's visible items, and
one_handed_map_pose is renderOneHandedMap's transform, checked against
vanilla's PoseStack. renderMap (background, map, decorations) is shared by
both cases. The arms under held maps are still not drawn, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:47 +02:00
Ludovic MarieandClaude Opus 5.5 b15e5e893e Code mods are the client's mods feature: tests and probes skip wasmtime
horizon-mods' runtime is no longer a default feature, and its sandbox test
requires runtime and pack instead of a dev-dependency on itself (that
dev-dependency turned the runtime on in every workspace build with examples,
the parity probe build included). The client gets a mods feature that turns
the runtime on; without it game/mods.rs keeps content mods and logs why code
mods don't start, and every hook does nothing.

Release builds (client and server) pass the feature, CI's clippy and tests
pass it (and pack, for the sandbox test), and pr_ready does where horizon-mods
is checked. horizon_mods::find moves to manifest.rs: content mods use it
without the runtime.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:18:01 +02:00
Ludovic Marie 63baec3ac8 Merge branch 'fix/st2-entity-hands' into fix/st2-chat
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 09:45:17 +02:00
Ludovic Marie 5d2f1804e7 Merge branch 'fix/st2-entity-hands' into fix/st2-chat
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 08:39:57 +02:00
Ludovic MarieandClaude Opus 5.5 c7abc8c718 Chat: styled server components, click and hover events, legacy codes, message order
- A connection's chat keeps the server's component: SystemChat goes
  through the same translation and styles as the action bar, and player
  and disguised chat are decorated from their chat type's translation,
  parameters and style (ChatType.Bound.decorate), so colours, bold,
  hex colours, and the client's language are kept (#1372, #1389).
- Components parse `click_event` (suggest_command, copy_to_clipboard; never
  open_file from a server) and `hover_event` show_text (#1377), and the
  chat screen draws the hovered part's tooltip (#1381).
- `§` codes in chat colour the text with Chat Colors on and are stripped
  with it off (ComponentRenderUtils.wrapComponents) (#1379).
- A command's operator broadcast skips the player whose command it was on
  the integrated server too (#1373), and /give fires InventoryChangeTrigger
  before its message, whose announcement is sent first (#1375).
- The control socket's `messages` gives the chat lines as components;
  lan_e2e `styles` checks a styled tellraw, the advancement order and the
  missing echo for the host and the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:46 +02:00
44 changed files with 1443 additions and 160 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
+4 -3
View File
@@ -61,7 +61,8 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
@@ -69,8 +70,8 @@ jobs:
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --workspace --doc
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
parity:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
+5 -3
View File
@@ -133,7 +133,8 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
@@ -255,8 +256,9 @@ jobs:
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
Generated
-1
View File
@@ -1493,7 +1493,6 @@ dependencies = [
name = "horizon-mods"
version = "0.1.0"
dependencies = [
"horizon-mods",
"log",
"serde",
"serde_json",
+5 -1
View File
@@ -18,7 +18,8 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-server-files = { path = "../horizon-server-files" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
horizon-mods = { path = "../horizon-mods" }
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
winit = "0.30.13"
pollster = "0.4"
@@ -78,6 +79,9 @@ default = ["audio", "controllers"]
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
@@ -306,6 +306,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
height_focused: option("chatHeightFocused"),
height_unfocused: option("chatHeightUnfocused"),
line_spacing: option("chatLineSpacing"),
colors: c["chatColors"].as_bool().unwrap_or(true),
};
for m in c["messages"].as_array().into_iter().flatten() {
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
@@ -58,11 +58,12 @@ fn state(menu: &Menu) -> String {
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
.collect();
let line = format!(
"{} | {} |{data} | {} | {}",
"{} | {} |{data} | {} | {} | {}",
slots.join(" "),
canon_stack(menu.carried.as_ref()),
menu.drops.join(";"),
equipped.join(";")
equipped.join(";"),
menu.player_sounds.join(";")
);
if menu.kind() != Kind::Lectern {
return line;
@@ -171,6 +172,7 @@ pub fn main() -> horizon_client::Result<()> {
});
menu.drops.clear();
menu.equipped.clear();
menu.player_sounds.clear();
out += &format!("init {}\n", state(&menu));
for op in case["ops"].as_array().ok_or("ops")? {
let slot = op[0].as_i64().unwrap_or(0) as i32;
+192
View File
@@ -0,0 +1,192 @@
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
//! overrides on it.
use crate::items::{ItemStack, Items};
use horizon_world::nbt::Tag;
/// A bundle weight: vanilla's `Fraction`, exact and kept reduced.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Weight {
n: i128,
d: i128,
}
fn gcd(a: i128, b: i128) -> i128 {
let (mut a, mut b) = (a.abs(), b.abs());
while b != 0 {
(a, b) = (b, a % b);
}
a.max(1)
}
impl Weight {
const ZERO: Self = Self { n: 0, d: 1 };
const ONE: Self = Self { n: 1, d: 1 };
fn new(n: i128, d: i128) -> Self {
let g = gcd(n, d);
Self { n: n / g, d: d / g }
}
fn add(self, o: Self) -> Self {
Self::new(self.n * o.d + o.n * self.d, self.d * o.d)
}
fn sub(self, o: Self) -> Self {
self.add(Self { n: -o.n, d: o.d })
}
fn times(self, count: u32) -> Self {
Self::new(self.n * i128::from(count), self.d)
}
/// `this.divideBy(item).intValue()` (truncated), at least 0.
fn how_many(self, item: Self) -> u32 {
let q = (self.n * item.d) / (self.d * item.n);
u32::try_from(q.max(0)).unwrap_or(u32::MAX)
}
}
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
/// insert at 1, 1.
pub const INSERT: &str = "minecraft:item.bundle.insert";
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
}
/// The stack's `bundle_contents`, when it has the component.
fn contents_of<'a>(items: &Items, stack: &'a ItemStack) -> Option<&'a [ItemStack]> {
let inside = stack.data().map_or(&[][..], |d| &d.bundle_contents[..]);
let bundle = items.get(&stack.item).is_some_and(|i| i.bundle);
(bundle || !inside.is_empty()).then_some(inside)
}
/// `BundleContents.getWeight`: a nested bundle weighs its contents plus
/// 1/16, a beehive with bees 1, the rest 1 / max stack size.
fn item_weight(items: &Items, stack: &ItemStack) -> Weight {
if let Some(inside) = contents_of(items, stack) {
return contents_weight(items, inside).add(Weight::new(1, 16));
}
let bees = stack
.data()
.and_then(|d| d.other("minecraft:bees"))
.is_some_and(|b| matches!(b, Tag::List(l) if !l.is_empty()));
if bees {
Weight::ONE
} else {
Weight::new(1, i128::from(max_stack(items, stack)))
}
}
fn contents_weight(items: &Items, inside: &[ItemStack]) -> Weight {
inside.iter().fold(Weight::ZERO, |w, s| {
w.add(item_weight(items, s).times(s.count))
})
}
/// `BundleContents.canItemBeInBundle`: shulker boxes don't fit.
pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
stack.count > 0 && !stack.item.ends_with("shulker_box")
}
/// `BundleContents.Mutable`.
pub struct Contents {
items: Vec<ItemStack>,
weight: Weight,
}
impl Contents {
/// The bundle's contents; None when the stack is no bundle.
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
let inside = contents_of(items, stack)?.to_vec();
let weight = contents_weight(items, &inside);
Some(Self {
items: inside,
weight,
})
}
/// `getMaxAmountToAdd` for `stack`'s kind of item.
pub fn room_for(&self, items: &Items, stack: &ItemStack) -> u32 {
Weight::ONE
.sub(self.weight)
.how_many(item_weight(items, stack))
}
/// `tryInsert`: what fits moves to the front, merged with its kind.
pub fn try_insert(&mut self, items: &Items, stack: &mut ItemStack) -> u32 {
if !can_be_in_bundle(stack) {
return 0;
}
let weight = item_weight(items, stack);
let amount = stack
.count
.min(Weight::ONE.sub(self.weight).how_many(weight));
if amount == 0 {
return 0;
}
self.weight = self.weight.add(weight.times(amount));
// ItemStack.isStackable: a damaged stack is damageable.
let stackable = max_stack(items, stack) > 1 && stack.damage == 0;
let same = self
.items
.iter()
.position(|s| stackable && s.same_item_same_components(stack));
let added = match same {
Some(i) => {
let mut merged = self.items.remove(i);
merged.count += amount;
stack.count -= amount;
merged
}
None => {
let mut part = stack.clone();
part.count = amount;
stack.count -= amount;
part
}
};
self.items.insert(0, added);
amount
}
/// `removeOne`: the first stack (Horizon keeps no selected item).
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
if self.items.is_empty() {
return None;
}
let stack = self.items.remove(0);
self.weight = self
.weight
.sub(item_weight(items, &stack).times(stack.count));
Some(stack)
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
bundle.tidy();
}
}
#[cfg(test)]
mod tests {
use super::Weight;
#[test]
fn weights_add_up_exactly() {
let w = Weight::new(1, 64)
.times(32)
.add(Weight::new(1, 16).times(4));
assert_eq!(w, Weight::new(3, 4));
assert_eq!(Weight::ONE.sub(w).how_many(Weight::new(1, 64)), 16);
assert_eq!(Weight::ONE.sub(Weight::ONE).how_many(Weight::ONE), 0);
// An over-full bundle (made by a command) has no room.
let over = Weight::ONE.sub(Weight::new(5, 4));
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
}
}
+40 -2
View File
@@ -29,10 +29,14 @@ pub struct ChatOptions {
pub height_focused: f64,
pub height_unfocused: f64,
pub line_spacing: f64,
/// Chat Colors (`chatColors`): legacy `§` codes format the text, or are
/// left out.
pub colors: bool,
}
impl Default for ChatOptions {
fn default() -> Self {
Self {
colors: true,
opacity: 1.0,
background_opacity: 0.5,
scale: 1.0,
@@ -136,7 +140,8 @@ impl Chat {
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
let width = self.max_width();
for line in atlas.font.wrap_components(&message.content, width) {
let content = self.displayed(&message.content);
for line in atlas.font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -152,6 +157,16 @@ impl Chat {
}
self.lines.truncate(100);
}
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
/// without Chat Colors its `§` codes are stripped (stripColor); with
/// them the font draws them as formatting.
fn displayed(&self, content: &Text) -> Text {
if self.options.colors {
content.resolve_legacy()
} else {
content.strip_legacy()
}
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.scroll = 0;
@@ -308,6 +323,21 @@ impl Chat {
x: f64,
y: f64,
) -> Option<ClickEvent> {
self.style_at(font, height, x, y)?.click
}
/// The hover text under a GUI-space cursor while chat is focused
/// (ChatScreen.render's `renderComponentHoverEffect`).
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
self.style_at(font, height, x, y)?.hover.map(|h| *h)
}
/// ChatComponent.getClickedComponentStyleAt.
fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
let scale = self.options.scale as f32;
let mut x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
@@ -324,7 +354,7 @@ impl Chat {
for (c, style) in content.chars() {
x -= f64::from(font.advance(c, style));
if x < 0.0 {
return style.click.clone();
return Some(style.clone());
}
}
None
@@ -337,6 +367,14 @@ impl Chat {
.map(|m| m.content.plain())
.collect()
}
/// The newest messages as components, styles included (control socket).
pub fn recent_components(&self, n: usize) -> Vec<serde_json::Value> {
self.messages
.iter()
.take(n)
.map(|m| m.content.component())
.collect()
}
}
/// `CommandSuggestions.SuggestionsList`.
+42
View File
@@ -285,3 +285,45 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
#[test]
fn legacy_codes_colour_the_line_or_vanish_without_chat_colours() {
let mut chat = Chat::default();
let red = Text::literal("\u{a7}cRED");
let shown = chat.displayed(&red);
assert_eq!(shown.plain(), "RED");
assert!(shown.chars().all(|(_, s)| s.color == Some(0xFF5555)));
chat.options.colors = false;
let shown = chat.displayed(&red);
assert_eq!(shown.plain(), "RED");
assert!(shown.chars().all(|(_, s)| s.color.is_none()));
// A lone section sign or an unknown code stays as written.
assert_eq!(
chat.displayed(&Text::literal("a\u{a7}zb")).plain(),
"a\u{a7}zb"
);
}
#[test]
fn a_server_component_keeps_its_click_and_hover_events() {
let font = font();
let json = serde_json::json!({
"text": "CLICK", "color": "green", "bold": true,
"click_event": {"action": "suggest_command", "command": "/say clicked"},
"hover_event": {"action": "show_text", "value": {"text": "Tip", "color": "aqua"}},
});
let line = crate::component::to_text(&json, &mut |s| Text::literal(s));
let mut chat = Chat::default();
add(&mut chat, &font, line);
let x = 4.0 + f64::from(font.width("CL"));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
panic!("the line must suggest its command");
};
assert_eq!(command, "/say clicked");
let hover = chat.hover_at(&font, 360, x, 315.0).expect("a hover text");
assert_eq!(hover.plain(), "Tip");
assert_eq!(hover.spans[0].style.color, Some(text::AQUA));
// Past the (bold, so wider) text, nothing.
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
}
+19
View File
@@ -168,6 +168,25 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.font = o.get("font").and_then(crate::text::font_id);
// ClickEvent.CODEC: the actions a chat line acts on here. A component
// from a server never opens a file.
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
_ => None,
};
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
+120
View File
@@ -137,6 +137,48 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
m
}
/// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both
/// hands when it is the main hand's and the off hand is empty
/// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MapHold {
TwoHanded,
OneHanded,
}
impl MapHold {
pub fn of(hand: Hand, off_hand_empty: bool) -> Self {
if hand == Hand::Main && off_hand_empty {
Self::TwoHanded
} else {
Self::OneHanded
}
}
}
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
/// the `left` or right arm (the arm under it is not drawn).
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let invert = if left { -1.0 } else { 1.0 };
let pi = std::f32::consts::PI;
let root = attack.sqrt();
let x_swing = (root * pi).msin();
Mat4::from_translation(Vec3::new(invert * 0.125, -0.125, 0.0))
* Mat4::from_translation(Vec3::new(
invert * 0.51,
-0.08 + inverse_height * -1.2,
-0.75,
))
* Mat4::from_translation(Vec3::new(
invert * -0.5 * x_swing,
0.4 * (root * std::f32::consts::TAU).msin() - 0.3 * x_swing,
-0.3 * (attack * pi).msin(),
))
* Mat4::from_rotation_x((x_swing * -45.0).to_radians())
* Mat4::from_rotation_y((invert * x_swing * -30.0).to_radians())
}
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
/// and the off hand show, from what each holds (item and whether it is a
/// charged crossbow) and the item in use with its hand. A bow or crossbow
@@ -819,6 +861,84 @@ mod tests {
mod item_pose_tests {
use super::*;
#[test]
fn a_map_is_two_handed_only_in_the_main_hand_with_an_empty_off_hand() {
assert_eq!(MapHold::of(Hand::Main, true), MapHold::TwoHanded);
assert_eq!(MapHold::of(Hand::Main, false), MapHold::OneHanded);
assert_eq!(MapHold::of(Hand::Off, true), MapHold::OneHanded);
assert_eq!(MapHold::of(Hand::Off, false), MapHold::OneHanded);
}
#[test]
fn one_handed_maps_follow_render_one_handed_map() {
// PoseStack.last().pose() from vanilla's renderOneHandedMap
// transforms (column-major), for (left, inverse height, attack).
let cases: [(bool, f32, f32, [f32; 16]); 3] = [
(
false,
0.0,
0.0,
[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.635, -0.205,
-0.75, 1.0,
],
),
(
true,
0.25,
0.3,
[
0.86894435,
-0.34686038,
-0.35302067,
0.0,
0.0,
0.713303,
-0.70085573,
0.0,
0.4949098,
0.6090046,
0.61982065,
0.0,
-0.1406033,
-0.9197687,
-0.9926984,
1.0,
],
),
(
false,
0.6,
0.85,
[
0.9919303,
0.024030307,
0.12448644,
0.0,
0.0,
0.9818737,
-0.18953651,
0.0,
-0.12678458,
0.18800701,
0.97395027,
0.0,
0.5136029,
-1.1862278,
-0.88621765,
1.0,
],
),
];
for (left, inverse_height, attack, want) in cases {
let got = one_handed_map_pose(left, inverse_height, attack).to_cols_array();
for (g, w) in got.iter().zip(want) {
assert!(
(g - w).abs() < 1e-5,
"{left} {inverse_height} {attack}: {got:?}"
);
}
}
}
#[test]
fn ranged_draw_uses_native_charge_curves_and_caps() {
for (ticks, stretch) in [
(0.0, 1.0),
+53 -8
View File
@@ -5499,6 +5499,20 @@ impl Game<'_> {
Some(true)
})
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// The open menu as the verified engine (`horizon_client::menu`): the
/// game's slots are copied in, `run` acts on it, and the slots, carried
/// stack, menu data and drops are copied back when it returns Some(true)
@@ -5888,6 +5902,11 @@ impl Game<'_> {
let carried = menu.carried.take();
let dropped = std::mem::take(&mut menu.dropped);
let sounds = std::mem::take(&mut menu.sounds);
// A bundle's sounds are the player's own (`Entity.playSound`): the
// client plays them as it clicks, predicting or not.
for sound in std::mem::take(&mut menu.player_sounds) {
self.player_menu_sound(sound);
}
let crafted = std::mem::take(&mut menu.crafted);
let equipped = std::mem::take(&mut menu.equipped);
for (i, stack) in after.into_iter().enumerate() {
@@ -6132,6 +6151,17 @@ impl Game<'_> {
}
return;
}
// mouseClicked: an empty hand's PICKUP is the menu's click at once
// (a right click on a bundle takes its first stack out).
if let (Some(input), Some(_)) = (input, slot)
&& self.remote.is_none()
&& self.menu_click(slot, i32::from(right), input)
{
if let Some(key) = self.container {
self.container_changed(key);
}
return;
}
self.click_inventory(right);
}
/// Mouse movement while a quick craft drag is running.
@@ -8816,20 +8846,15 @@ impl Game<'_> {
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let deg = |d: f32| d.to_radians();
if self.held_map_geometry(to_world, attacks[0], inverse_heights[0], packed, &mut out) {
return out;
}
let shown = self.hands_to_render();
let used_hand = self.used_hand();
let spinning = self
.player
.as_ref()
.is_some_and(|p| p.auto_spin_attack_ticks > 0);
for (i, (hand, item)) in Hand::BOTH
.into_iter()
.zip(self.visible_hand_items())
.enumerate()
{
let items = self.visible_hand_items();
let off_hand_empty = items[1].is_none();
for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() {
if !shown[i] {
continue;
}
@@ -8892,6 +8917,26 @@ impl Game<'_> {
);
continue;
};
if let Some(id) = maps::map_id(&stack) {
use horizon_client::first_person::{MapHold, one_handed_map_pose};
match MapHold::of(hand, off_hand_empty) {
MapHold::TwoHanded => self.two_handed_map_geometry(
to_world,
attack,
inverse_height,
id,
packed,
&mut out,
),
MapHold::OneHanded => self.held_map(
to_world * one_handed_map_pose(left, inverse_height, attack),
id,
packed,
&mut out,
),
}
continue;
}
let mut owner = self.player_owner(Some(&stack));
// The other hand may hold an equal stack: only the used
// hand's item is in use, only the main hand's selected.
+2
View File
@@ -678,6 +678,8 @@ impl Game<'_> {
json!({
"chat": self.remote_chat(n).unwrap_or_else(|| self.ui.chat.recent_lines(n)),
"order": "newest first",
// The chat lines as drawn: colours, click and hover events.
"styled": self.ui.chat.recent_components(n),
"title": (titles.time > 0).then(|| titles.title.as_ref().map(|t| t.plain())).flatten(),
"subtitle": (titles.time > 0).then(|| titles.subtitle.as_ref().map(|t| t.plain())).flatten(),
"actionbar": (titles.overlay_time > 0).then(|| titles.overlay.as_ref().map(|t| t.plain())).flatten(),
+36 -3
View File
@@ -450,11 +450,11 @@ impl Game<'_> {
// Connected to a server (single player's own included), the chat on
// screen is the connection's: the game's own messages join it.
if self.remote.is_some() {
self.chat_line(text.plain());
self.chat_line(text);
return;
}
let focused = self.screen == Screen::Chat;
let heard = self.mods.mods.is_running().then(|| text.plain());
let heard = self.mods.running().then(|| text.plain());
self.ui.chat.add(
&self.gui.atlas,
text,
@@ -583,7 +583,7 @@ impl Game<'_> {
&[Text::literal(PLAYER_NAME), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.mods.is_running().then(|| line.plain());
let heard = self.mods.running().then(|| line.plain());
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
@@ -681,6 +681,16 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
batch.height as i32,
f64::from(self.cursor.0),
f64::from(self.cursor.1),
) {
let lines = hover_rows(&hover);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
@@ -949,3 +959,26 @@ fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// A hover text's rows for the tooltip: split at line breaks, each in the
/// colour of its first styled character (white without one).
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
let mut rows = vec![(String::new(), None)];
for (c, style) in hover.resolve_legacy().chars() {
if c == '\n' {
rows.push((String::new(), None));
continue;
}
let row = rows.last_mut().expect("one row");
if row.0.is_empty() {
row.1 = style.color;
}
row.0.push(c);
}
rows.into_iter()
.map(|(text, color)| {
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
(text, horizon_client::hud::rgba(r, g, b, 255))
})
.collect()
}
@@ -1039,6 +1039,11 @@ impl Game<'_> {
.play("minecraft:entity.item.pickup", Some(p), 0.2, pitch);
}
}
// containerMenu.broadcastChanges: InventoryChangeTrigger
// (and its announcement) comes before the command's message.
if g.player.is_some() {
g.check_inventory_trigger();
}
});
}
// The prototype stack: one item.
+1 -1
View File
@@ -237,7 +237,7 @@ impl Game<'_> {
/// the caller). The top left corner gives way to the debug screen.
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let e = &self.settings.extras;
let mod_panels = self.mods.mods.panels().next().is_some();
let mod_panels = self.mods.has_panels();
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
return;
}
+3 -1
View File
@@ -468,8 +468,10 @@ impl Game<'_> {
host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
// A teleport into another level happens before its feedback is sent.
// A teleport into another level happens before its feedback is
// sent, and what the command earned is announced before it too.
self.run_level_teleports(&mut host);
self.announce_guest_advancements(&mut host);
for line in lines {
out.push(line.plain());
host.hub.send_to(
@@ -87,7 +87,7 @@ pub(super) struct GuestCommand {
conn: u32,
command: String,
permission: u8,
/// An integrated server's: no admin feedback or level teleports (yet).
/// An integrated server's: no level teleports (yet).
integrated: bool,
}
@@ -160,6 +160,9 @@ impl GuestPlay {
struct ItemRules<'a>(&'a horizon_client::items::Items);
impl Rules for ItemRules<'_> {
fn items(&self) -> &horizon_client::items::Items {
self.0
}
fn max_stack(&self, item: &str) -> u32 {
self.0.get(item).map_or(64, |i| i.max_stack)
}
@@ -1419,6 +1422,8 @@ impl Game<'_> {
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
host.hub.send_to(
c.conn,
@@ -1428,9 +1433,8 @@ impl Game<'_> {
}),
);
}
if !c.integrated {
self.send_admin_feedback(&mut host, Some(c.conn));
}
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
}
}
@@ -1819,7 +1823,7 @@ impl Game<'_> {
/// `PlayerList.broadcastSystemMessage` of the advancements guests made
/// (their own actions, commands and ticks).
fn announce_guest_advancements(&mut self, host: &mut LanHost) {
pub(super) fn announce_guest_advancements(&mut self, host: &mut LanHost) {
for text in std::mem::take(&mut self.adv.announced) {
host.hub
.system_message(horizon_client::lan::data::component_nbt(&text.component()));
+13 -12
View File
@@ -918,21 +918,17 @@ impl Game<'_> {
);
}
}
/// `renderTwoHandedMap`: the held map (main hand, empty off hand) in view space.
pub(super) fn held_map_geometry(
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
/// arms under it are not drawn).
pub(super) fn two_handed_map_geometry(
&mut self,
to_world: glam::Mat4,
attack: f32,
inverse_height: f32,
id: i32,
light: u32,
out: &mut Vec<Vertex>,
) -> bool {
let Some(id) = self.inventory[self.selected].as_ref().and_then(map_id) else {
return false;
};
if self.offhand.is_some() {
return false;
}
) {
let deg = |d: f32| d.to_radians();
let sqrt_attack = attack.sqrt();
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
@@ -949,10 +945,16 @@ impl Game<'_> {
))
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
let swing = (sqrt_attack * std::f32::consts::PI).sin();
// renderMap: background (with 7-pixel border) then the map.
let m = base
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
* glam::Mat4::from_scale(glam::Vec3::splat(2.0))
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
self.held_map(m, id, light, out);
}
/// `renderMap`: map `id` at pose `m`, its background (with a 7-pixel
/// border) then the map.
pub(super) fn held_map(&mut self, m: glam::Mat4, id: i32, light: u32, out: &mut Vec<Vertex>) {
let deg = |d: f32| d.to_radians();
let m = m
* glam::Mat4::from_rotation_y(deg(180.0))
* glam::Mat4::from_rotation_z(deg(180.0))
* glam::Mat4::from_scale(glam::Vec3::splat(0.38))
@@ -984,7 +986,6 @@ impl Game<'_> {
if has_data {
self.map_vertices(id, m, light, out);
}
true
}
}
+70 -13
View File
@@ -2,43 +2,76 @@
//! their tick, frame, chat and command hooks, their HUD panels and what
//! they print. With no mod on, `horizon_mods::Mods` holds no engine and
//! every hook here returns after one check.
use super::{Game, Screen};
//!
//! Code mods run only in builds with the `mods` feature (release builds):
//! without it the game links no wasmtime, and every hook does nothing.
//! Content mods (`content_root`) load in every build.
use super::Game;
use horizon_client::{
command::{Dispatcher, vanilla},
extras::Corner,
extras_hud::{self, Corners},
extras_hud::Corners,
hud::HudBatch,
mod_list::{self, Installed},
text::{self, Text},
mod_list,
};
use horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status};
use std::{
path::PathBuf,
time::{Duration, Instant},
use std::{path::PathBuf, time::Duration};
#[cfg(feature = "mods")]
use {
super::Screen,
horizon_client::{
extras::Corner,
extras_hud,
mod_list::Installed,
text::{self, Text},
},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
std::time::Instant,
};
#[derive(Default)]
pub(super) struct ModsState {
pub(super) mods: Mods,
#[cfg(feature = "mods")]
mods: Mods,
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
pub(super) dev: Vec<PathBuf>,
/// When dev mods were last checked for a rebuild.
#[cfg(feature = "mods")]
checked: Option<Instant>,
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
}
impl ModsState {
/// The chat's command tree: vanilla's, plus the running mods' commands.
pub(super) fn dispatcher(&self) -> &Dispatcher {
self.tree
.as_ref()
.unwrap_or_else(|| vanilla::game_dispatcher())
#[cfg(feature = "mods")]
if let Some(tree) = &self.tree {
return tree;
}
vanilla::game_dispatcher()
}
/// Whether a mod runs: the chat keeps its lines for them.
pub(super) fn running(&self) -> bool {
#[cfg(feature = "mods")]
if self.mods.is_running() {
return true;
}
false
}
/// Whether a mod draws a HUD panel.
pub(super) fn has_panels(&self) -> bool {
#[cfg(feature = "mods")]
if self.mods.panels().next().is_some() {
return true;
}
false
}
}
/// How often dev mods are checked for a rebuild.
#[cfg(feature = "mods")]
const RELOAD_CHECK: Duration = Duration::from_secs(1);
/// A vanilla command name: mods may not take it.
#[cfg(feature = "mods")]
fn vanilla_command(name: &str) -> bool {
let d = vanilla::game_dispatcher();
d.nodes[0]
@@ -112,6 +145,30 @@ pub(super) fn content_root(
}
}
/// A build without the `mods` feature: no mod runs.
#[cfg(not(feature = "mods"))]
impl Game<'_> {
/// Says why the installed and dev mods don't run.
pub(super) fn start_mods(&mut self) {
let dir = Self::options_path().with_file_name(mod_list::DIR);
let found = horizon_mods::find(&dir).len() + self.mods.dev.len();
if found > 0 {
log::warn!(
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
);
}
}
pub(super) fn sync_mods(&mut self) {}
pub(super) fn tick_mods(&mut self) {}
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
false
}
pub(super) fn mods_chat(&mut self, _line: &str) {}
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// Reads `horizon-mods/` and the dev mods, and starts the ones turned on.
pub(super) fn start_mods(&mut self) {
+46 -49
View File
@@ -38,7 +38,7 @@ use horizon_protocol::{
play::{self, Clientbound as C, Serverbound as S},
},
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
text::{Language, format_java, plain},
text::{Language, plain},
};
use horizon_world::{
nbt::Tag,
@@ -694,11 +694,14 @@ impl Game<'_> {
}
Some(stack)
}
pub(super) fn chat_line(&mut self, text: String) {
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage).
pub(super) fn chat_line(&mut self, content: horizon_client::text::Text) {
let text = content.plain();
log::info!("[chat] {text}");
self.ui.chat.add(
&self.gui.atlas,
horizon_client::text::Text::literal(text.clone()),
content,
None,
self.ticks,
self.screen == Screen::Chat,
@@ -2381,42 +2384,27 @@ impl Game<'_> {
}
}
C::SystemChat(p) => {
let text = plain(
&p.content,
&self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default(),
);
// ChatListener.handleOverlay.
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
let text = remote_text(&p.content);
if p.overlay {
self.titles.set_overlay(remote_text(&p.content), false);
self.titles.set_overlay(text, false);
} else {
self.chat_line(text);
}
}
C::PlayerChat(p) => {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
use horizon_client::scoreboard::tag_to_json;
let content = p
.unsigned_content
.as_ref()
.map_or_else(|| p.content.clone(), |t| plain(t, &lang));
let line = self.format_chat(&p.chat_type, &content);
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line);
}
C::DisguisedChat(p) => {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
let content = plain(&p.message, &lang);
let line = self.format_chat(&p.chat_type, &content);
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line);
}
C::PlayerInfoUpdate(p) => {
@@ -2848,11 +2836,17 @@ impl Game<'_> {
true
}
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
fn format_chat(&self, bound: &play::ChatTypeBound, content: &str) -> String {
/// ChatType.Bound.decorate: the chat type's translation, with the
/// sender, the target and the message as its parameters, in its style.
fn format_chat(
&self,
bound: &play::ChatTypeBound,
content: serde_json::Value,
) -> horizon_client::text::Text {
use horizon_client::scoreboard::{comp_text, tag_to_json};
let Some(r) = self.remote.as_ref() else {
return content.to_owned();
return comp_text(&content);
};
let sender = plain(&bound.name, &r.lang);
let decoration = r
.registries
.data("minecraft:chat_type", bound.chat_type)
@@ -2861,30 +2855,31 @@ impl Game<'_> {
Some(Tag::String(s)) => s.to_string_lossy(),
_ => "chat.type.text".into(),
};
let params: Vec<String> = match decoration.and_then(|d| d.get("parameters")) {
let parameter = |name: &str| match name {
"sender" => tag_to_json(&bound.name),
"target" => bound
.target
.as_ref()
.map_or_else(|| serde_json::Value::from(""), tag_to_json),
_ => content.clone(),
};
let with: Vec<serde_json::Value> = match decoration.and_then(|d| d.get("parameters")) {
Some(Tag::List(list)) => list
.iter()
.map(|p| match p {
Tag::String(s) => match s.to_string_lossy().as_str() {
"sender" => sender.clone(),
"target" => bound
.target
.as_ref()
.map(|t| plain(t, &r.lang))
.unwrap_or_default(),
_ => content.to_owned(),
},
_ => String::new(),
Tag::String(s) => parameter(&s.to_string_lossy()),
_ => serde_json::Value::from(""),
})
.collect(),
_ => vec![sender, content.to_owned()],
_ => vec![parameter("sender"), parameter("content")],
};
let pattern = r
.lang
.get(&key)
.cloned()
.unwrap_or_else(|| "<%s> %s".into());
format_java(&pattern, &params)
let mut component = match decoration.and_then(|d| d.get("style")) {
Some(style @ Tag::Compound(_)) => tag_to_json(style),
_ => serde_json::json!({}),
};
component["translate"] = key.into();
component["with"] = with.into();
comp_text(&component)
}
fn remote_item_data(&mut self, id: i32, stack: &Option<Stack>) {
let Some(stack) = self.to_stack(stack) else {
@@ -4692,7 +4687,9 @@ impl Game<'_> {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
self.chat_line(format!("(Horizon cannot show {menu} screens yet)"));
self.chat_line(horizon_client::text::Text::literal(format!(
"(Horizon cannot show {menu} screens yet)"
)));
return;
}
};
+3
View File
@@ -120,6 +120,8 @@ pub struct ItemInfo {
pub dye: Option<String>,
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
pub block_transformer: Option<String>,
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
pub bundle: bool,
}
/// `Item.Properties.craftRemainder` (not a data component, so not in the
/// default-component report): what an item leaves in the grid when crafted with.
@@ -593,6 +595,7 @@ impl Items {
jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned),
dye: c["minecraft:dye"].as_str().map(str::to_owned),
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
bundle: c.get("minecraft:bundle_contents").is_some(),
crafting_remainder: CRAFT_REMAINDERS
.iter()
.find(|(item, _)| *item == name)
+200 -1
View File
@@ -4,10 +4,15 @@
//! clone, throw, quick craft (drag) and pickup all; items thrown out of a
//! menu are returned for the game to drop.
use super::guest::Guest;
use crate::{items::ItemStack, world::World};
use crate::{
bundle::{Contents, can_be_in_bundle},
items::{ItemStack, Items},
world::World,
};
/// What the menus need to know about items.
pub trait Rules {
fn items(&self) -> &Items;
fn max_stack(&self, item: &str) -> u32;
/// The armor slot (0 head .. 3 feet) an item can be worn in.
fn armor_index(&self, item: &str) -> Option<usize>;
@@ -364,6 +369,9 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
return;
}
let right = button == 1;
if bundle_click(view, i, right, rules) {
return;
}
let (slot_item, carried) = (view.get(i), view.guest.carried.clone());
match (slot_item, carried) {
(None, None) => {}
@@ -410,6 +418,84 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
}
}
/// `tryItemClickBehaviourOverride`: the carried stack's
/// `BundleItem.overrideStackedOnOther`, then the clicked one's
/// `overrideOtherStackedOnMe`. True when a bundle took the click.
fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bool {
let items = rules.items();
let other = view.get(i);
if let Some(mut carried) = view.guest.carried.clone()
&& let Some(mut contents) = Contents::of(items, &carried)
&& other.is_some() != right
{
match other {
// tryTransfer: as much as fits, through `safeTake`.
Some(mut other) => {
if can_be_in_bundle(&other) {
let amount = other.count.min(contents.room_for(items, &other));
if amount > 0 {
let mut taken = other.clone();
taken.count = amount;
other.count -= amount;
view.set(i, (other.count > 0).then_some(other));
contents.try_insert(items, &mut taken);
}
}
}
// removeOne into the empty slot (`safeInsert`); the rest goes back.
None => {
if let Some(mut stack) = contents.remove_one(items) {
if view.may_place(i, &stack, rules) {
let amount = stack.count.min(view.slot_max(i, &stack, rules));
view.set(
i,
Some(ItemStack {
count: amount,
..stack.clone()
}),
);
stack.count -= amount;
}
if stack.count > 0 {
contents.try_insert(items, &mut stack);
}
}
}
}
contents.store(&mut carried);
view.guest.carried = Some(carried);
return true;
}
let Some(mut bundle) = other else {
return false;
};
let Some(mut contents) = Contents::of(items, &bundle) else {
return false;
};
// Slot.allowModification: mayPickup and mayPlace of its item.
let allowed = view.may_place(i, &bundle, rules);
match (view.guest.carried.take(), right) {
(Some(mut carried), false) => {
if allowed {
contents.try_insert(items, &mut carried);
}
view.guest.carried = (carried.count > 0).then_some(carried);
}
(None, true) => {
if allowed {
view.guest.carried = contents.remove_one(items);
}
}
(carried, _) => {
view.guest.carried = carried;
return false;
}
}
contents.store(&mut bundle);
view.set(i, Some(bundle));
true
}
fn swap(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
let Ok(i) = usize::try_from(slot) else {
return;
@@ -538,3 +624,116 @@ fn quick_craft(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lan::{Joined, PlayerSender};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
struct ItemRules(Items);
impl Rules for ItemRules {
fn items(&self) -> &Items {
&self.0
}
fn max_stack(&self, item: &str) -> u32 {
self.0.get(item).map_or(64, |i| i.max_stack)
}
fn armor_index(&self, _: &str) -> Option<usize> {
None
}
}
fn guest() -> Guest {
let (tx, _rx) = mpsc::channel();
Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "Alex".into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
2,
[0.0, 64.0, 0.0],
8,
)
}
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
let inside = stack.as_ref().and_then(|s| s.data());
inside.map_or(Vec::new(), |d| {
d.bundle_contents
.iter()
.map(|s| (s.item.to_string(), s.count))
.collect()
})
}
/// A guest's chest clicks run `BundleItem`'s overrides: a carried
/// bundle takes a slot's stack and drops its first one into an empty
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
#[test]
fn guest_chest_clicks_fill_and_empty_bundles() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("items.json").exists() {
eprintln!("skipping resource-backed bundle check: {}", root.display());
return;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, &registry).unwrap();
let rules = ItemRules(Items::load(&root, &registry, &facts).unwrap());
let mut g = guest();
let mut chest = vec![
Some(ItemStack::new("minecraft:stone", 48)),
None,
Some(ItemStack::new("minecraft:bundle", 1)),
];
let mut drag = None;
let mut view = View {
guest: &mut g,
container: Some(&mut chest),
};
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
// Left click with the bundle: the stone goes in.
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
assert_eq!(view.get(0), None);
assert_eq!(
contents(&view.guest.carried),
[("minecraft:stone".into(), 48)]
);
// Right click on an empty slot: the stone comes back out.
click(&mut view, &mut drag, 1, 1, input::PICKUP, &rules);
assert_eq!(view.get(1).map(|s| s.count), Some(48));
assert_eq!(contents(&view.guest.carried), []);
// Ender pearls (1/16 each) onto the bundle in the chest: 16 fit.
view.guest.carried = Some(ItemStack::new("minecraft:ender_pearl", 16));
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
assert_eq!(view.guest.carried, None);
let bundle = view.get(2);
assert_eq!(contents(&bundle), [("minecraft:ender_pearl".into(), 16)]);
// Full: more stone stays in hand.
view.guest.carried = Some(ItemStack::new("minecraft:stone", 5));
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(5));
// Right click on it with an empty hand takes the pearls out.
view.guest.carried = None;
click(&mut view, &mut drag, 2, 1, input::PICKUP, &rules);
assert_eq!(
view.guest.carried.as_ref().map(|s| (&*s.item, s.count)),
Some(("minecraft:ender_pearl", 16))
);
assert_eq!(contents(&view.get(2)), []);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod border_render;
pub mod bossbar;
pub mod breaking;
pub mod brewing;
pub mod bundle;
pub mod camera;
pub mod cauldron;
pub mod chat;
+27 -3
View File
@@ -16,6 +16,7 @@ use crate::{
};
use std::sync::Arc;
mod bundle;
pub mod place;
/// What a menu needs to know about items and recipes.
@@ -276,6 +277,9 @@ pub struct Menu {
pub loom_patterns: Vec<Arc<str>>,
/// Sounds the menu plays (`ui.loom.take_result`), for the caller.
pub sounds: Vec<&'static str>,
/// Sounds the player makes (a bundle's insert and remove), for the
/// caller to play at the player as `Entity.playSound`.
pub player_sounds: Vec<&'static str>,
/// Recipes whose result was taken (`awardUsedRecipes`), for the caller.
pub crafted: Vec<String>,
/// `LivingEntity.onEquipItem` calls that change the item: the
@@ -671,6 +675,7 @@ impl Menu {
quick_slots: Vec::new(),
loom_patterns: Vec::new(),
sounds: Vec::new(),
player_sounds: Vec::new(),
crafted: Vec::new(),
equipped: Vec::new(),
merchant: None,
@@ -931,6 +936,23 @@ impl Menu {
}
}
/// `slotsChanged(player.getInventory())`, which a bundle click calls
/// (`BundleItem.broadcastChangesOnContainerMenu`): the menus that don't
/// check which container changed recompute from their inputs.
fn inventory_slots_changed(&mut self, env: &Env) {
match self.kind {
Kind::Inventory | Kind::Crafting => self.recompute(env),
Kind::Loom => self.loom_changed(env),
Kind::Stonecutter => self.stone_changed(env),
Kind::Cartography => self.carto_changed(env),
Kind::Merchant => self.merchant_update(env),
// ItemCombinerMenu.slotsChanged only for its inputs; the
// smithing table's hasRecipeError always.
Kind::Smithing => self.smith_error(),
_ => {}
}
}
/// `EnchantmentMenu.clickMenuButton`'s checks: the lapis, the cost and
/// the levels (creative pays nothing). The enchanting itself needs a
/// level and is the caller's (`enchant_button`).
@@ -2536,10 +2558,12 @@ impl Menu {
Ok(())
}
/// The PICKUP branch of `doClick` (without the bundle overrides), ending
/// with `slot.setChanged()`.
/// The PICKUP branch of `doClick`: a bundle's override, else the
/// plain click, ending with `slot.setChanged()`.
fn pickup(&mut self, env: &Env, s: usize, primary: bool) {
self.pickup_inner(env, s, primary);
if !self.bundle_click(env, s, primary) {
self.pickup_inner(env, s, primary);
}
self.slot_changed(env, s);
}
+102
View File
@@ -0,0 +1,102 @@
//! `BundleItem`'s click overrides in a PICKUP click
//! (`tryItemClickBehaviourOverride`).
use super::{Env, Menu, tidy};
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle};
impl Menu {
/// The carried stack's `overrideStackedOnOther`, then the clicked
/// one's `overrideOtherStackedOnMe`. True when a bundle took the click.
pub(super) fn bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
let took = self.carried_bundle_click(env, s, primary)
|| self.clicked_bundle_click(env, s, primary);
if took {
// broadcastChangesOnContainerMenu: the open menu's
// slotsChanged for the player's inventory.
self.inventory_slots_changed(env);
}
took
}
/// `BundleItem.overrideStackedOnOther`: the carried bundle on a slot.
fn carried_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
let Some(mut contents) = self
.carried
.as_ref()
.and_then(|c| Contents::of(env.items, c))
else {
return false;
};
match (self.slot(s).cloned(), primary) {
(Some(other), true) => {
// tryTransfer: what fits, taken by the slot's rules.
let mut moved = 0;
if can_be_in_bundle(&other) {
let room = contents.room_for(env.items, &other);
if let Some(mut taken) = self.safe_take(env, s, other.count, room) {
moved = contents.try_insert(env.items, &mut taken);
}
}
self.player_sounds
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
}
(None, false) => {
if let Some(stack) = contents.remove_one(env.items) {
let count = stack.count;
let mut remainder = Some(stack);
self.safe_insert(env, s, &mut remainder, count);
match remainder {
Some(mut rest) => {
contents.try_insert(env.items, &mut rest);
}
None => self.player_sounds.push(REMOVE_ONE),
}
}
}
_ => return false,
}
if let Some(carried) = self.carried.as_mut() {
contents.store(carried);
}
true
}
/// `BundleItem.overrideOtherStackedOnMe`: a click on a bundle in a slot.
fn clicked_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
let Some(mut bundle) = self.slot(s).cloned() else {
return false;
};
let Some(mut contents) = Contents::of(env.items, &bundle) else {
return false;
};
let allowed = self.allow_modification(env, s);
match (self.carried.take(), primary) {
(Some(mut other), true) => {
let moved = if allowed {
contents.try_insert(env.items, &mut other)
} else {
0
};
self.player_sounds
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
self.carried = Some(other);
tidy(&mut self.carried);
}
(None, false) => {
if allowed && let Some(removed) = contents.remove_one(env.items) {
self.player_sounds.push(REMOVE_ONE);
self.carried = Some(removed);
}
}
(carried, _) => {
// toggleSelectedItem(-1): Horizon keeps no selected item.
self.carried = carried;
return false;
}
}
// `self.set(BUNDLE_CONTENTS, ...)` changes the slot's stack in place.
contents.store(&mut bundle);
let slot = self.slots[s];
self.containers[slot.container][slot.index] = Some(bundle);
true
}
}
+1
View File
@@ -589,6 +589,7 @@ impl Settings {
height_focused: f64::from(self.chat_height_focused),
height_unfocused: f64::from(self.chat_height_unfocused),
line_spacing: f64::from(self.chat_line_spacing),
colors: self.chat_colors,
}
}
}
+28
View File
@@ -428,6 +428,34 @@ impl Text {
}
out
}
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
pub fn strip_legacy(&self) -> Text {
if !self.spans.iter().any(|s| s.text.contains('§')) {
return self.clone();
}
let code =
|c: char| matches!(c.to_ascii_lowercase(), '0'..='9' | 'a'..='f' | 'k'..='o' | 'r');
let mut out = Text::default();
for span in &self.spans {
let mut text = String::with_capacity(span.text.len());
let mut chars = span.text.chars().peekable();
while let Some(c) = chars.next() {
if c == '§' && chars.peek().is_some_and(|&n| code(n)) {
chars.next();
} else {
text.push(c);
}
}
if !text.is_empty() {
out.spans.push(Span {
text,
style: span.style.clone(),
});
}
}
out
}
/// Characters with their effective styles.
pub fn chars(&self) -> impl Iterator<Item = (char, &Style)> {
self.spans
+7 -4
View File
@@ -19,13 +19,16 @@ wit-component = { version = "0.239", optional = true }
wit-parser = { version = "0.239", optional = true }
[features]
default = ["runtime"]
# Loads and runs mods; without it only manifests and packaging (cargo horizon-mod).
# Loads and runs mods (the game's `mods` feature); without it only manifests,
# content and packaging. Off by default so builds that don't run mods (the
# game's tests and probes) skip wasmtime and cranelift.
runtime = ["dep:wasmtime"]
pack = ["dep:wit-component", "dep:wit-parser"]
[dev-dependencies]
horizon-mods = { path = ".", features = ["pack"] }
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
[[test]]
name = "sandbox"
required-features = ["runtime", "pack"]
[lints]
workspace = true
+1 -1
View File
@@ -11,6 +11,6 @@ pub mod manifest;
mod runtime;
pub use content::Content;
pub use manifest::{Capability, Manifest, Package, Side};
pub use manifest::{Capability, Manifest, Package, Side, find};
#[cfg(feature = "runtime")]
pub use runtime::*;
+19 -1
View File
@@ -1,6 +1,10 @@
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
use serde::Deserialize;
use std::{fmt, io::Read, path::Path};
use std::{
fmt,
io::Read,
path::{Path, PathBuf},
};
/// The manifest's file name, beside `mod.wasm`.
pub const MANIFEST: &str = "horizon-mod.toml";
@@ -282,6 +286,20 @@ impl Package {
}
}
/// The `.hmod` files and mod folders in `dir`, by name.
pub fn find(dir: &Path) -> Vec<PathBuf> {
let Ok(read) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = read
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "hmod") || p.join(MANIFEST).is_file())
.collect();
found.sort();
found
}
/// Turns a `wasm32-unknown-unknown` cdylib built with the SDK into the
/// component the game loads.
#[cfg(feature = "pack")]
-17
View File
@@ -131,23 +131,6 @@ pub struct Mods {
out: Vec<Output>,
}
/// The `.hmod` files and mod folders in `dir`, by name.
pub fn find(dir: &Path) -> Vec<PathBuf> {
let Ok(read) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = read
.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension().is_some_and(|e| e == "hmod")
|| p.join(crate::manifest::MANIFEST).is_file()
})
.collect();
found.sort();
found
}
impl Mods {
/// Reads every mod at `paths` and starts those `enabled` says are on.
/// A command that `reserved` (vanilla's commands) or an earlier mod
+159 -4
View File
@@ -372,6 +372,19 @@ fn client_library_artifacts(manifest: &Value) -> Result<Vec<(&str, &Value)>, Str
Ok(artifacts)
}
/// Netty's native transports (`io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64`,
/// the epoll ones on Linux): the game uses one when it loads and Netty NIO otherwise
/// (`EventLoopGroupHolder.remote` asks `KQueue.isAvailable()`, then `Epoll.isAvailable()`), and
/// Prism Launcher never downloads them (#1487). Preparing imports one when the launcher has it.
fn runs_without(name: &str) -> bool {
let mut parts = name.split(':');
parts.next() == Some("io.netty")
&& parts
.next()
.is_some_and(|artifact| artifact.starts_with("netty-transport-native-"))
&& parts.nth(1).is_some()
}
/// A relative path with only normal components and forward slashes, from a manifest or bundle.
pub(crate) fn safe_relative(path: &str) -> Option<PathBuf> {
if path.contains('\\') || path.starts_with('/') {
@@ -547,9 +560,18 @@ pub fn import(
for (name, artifact) in client_library_artifacts(&manifest)? {
let (path, sha1) = artifact_fields(artifact)?;
let relative = safe_relative(path).ok_or("Unsafe library path")?;
let source = launcher_library(&minecraft, name, &relative);
let destination = lock.directory.join("libraries/client").join(&relative);
if runs_without(name) && !source.is_file() && !destination.exists() {
log(&format!(
"No {name} in {}: the game runs without it",
minecraft.display()
));
continue;
}
client_libraries.push(import_copy(
&launcher_library(&minecraft, name, &relative),
&lock.directory.join("libraries/client").join(&relative),
&source,
&destination,
sha1,
"sha1",
&hint(LAUNCHER_HINT, version),
@@ -783,13 +805,17 @@ pub fn verify(lock: &Lock) -> Result<Artifacts, String> {
}
/// The client libraries, checked against the version JSON (tools/test_model_baking_parity.py's
/// `client_libraries`); the client-side exporters compile against them.
/// `client_libraries`); the client-side exporters compile against them. A native transport the
/// player's launcher did not have is left out.
pub fn client_libraries(lock: &Lock, manifest: &Value) -> Result<Vec<PathBuf>, String> {
let mut libraries = Vec::new();
for (_, artifact) in client_library_artifacts(manifest)? {
for (name, artifact) in client_library_artifacts(manifest)? {
let (path, sha1) = artifact_fields(artifact)?;
let relative = safe_relative(path).ok_or("Unsafe client library path")?;
let library = lock.directory.join("libraries/client").join(&relative);
if runs_without(name) && !library.exists() {
continue;
}
if !library.exists() || digest(&library, "sha1")? != sha1 {
return Err(format!("Client dependency checksum changed: {path}"));
}
@@ -1071,6 +1097,135 @@ mod tests {
}
}
/// scanfee's Prism Launcher 26.3 install on a Mac (#1487): Prism keeps natives under its own
/// names and never downloads Netty's native transport, which the game runs without. Preparing
/// still checks every other library, and the transport whenever the launcher has it.
#[test]
fn a_prism_install_without_netty_natives_prepares() {
let fixture = Imports::new();
let old = fixture.imported(&fixture.source);
let prism = fixture.directory.join("PrismLauncher");
let put = |path: &str, data: &[u8]| {
let path = prism.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
};
put(
"versions/9.9/9.9.jar",
&fs::read(old.join("jars/client.jar")).unwrap(),
);
put("libraries/org/lib/lib/1/lib-1.jar", b"lib");
put(
"libraries/com/mojang/jtracy-natives-test/1/jtracy-natives-test-1.jar",
b"jtracy natives",
);
let sha1 = |data: &[u8]| {
let file = fixture.directory.join("sha1");
fs::write(&file, data).unwrap();
digest(&file, "sha1").unwrap()
};
let library = |name: &str, path: &str, sha1: &str, rules: &str| {
format!(
r#"{{"name":"{name}","downloads":{{"artifact":{{"path":"{path}","sha1":"{sha1}"}}}}{rules}}}"#
)
};
let netty = |arch: &str| {
library(
&format!("io.netty:netty-transport-native-kqueue:1:osx-{arch}"),
&format!("io/netty/netty-transport-native-kqueue/1/netty-transport-native-kqueue-1-osx-{arch}.jar"),
&sha1(b"kqueue"),
&format!(r#","rules":[{{"action":"allow","os":{{"name":"{}"}}}}]"#, rule_system()),
)
};
let client_sha1 = digest(&old.join("jars/client.jar"), "sha1").unwrap();
let server_sha1 = digest(&old.join("jars/server.jar"), "sha1").unwrap();
let libraries = [
library(
"org.lib:lib:1",
"org/lib/lib/1/lib-1.jar",
&sha1(b"lib"),
"",
),
library(
"com.mojang:jtracy:1:natives-test",
"com/mojang/jtracy/1/jtracy-1-natives-test.jar",
&sha1(b"jtracy natives"),
"",
),
netty("aarch_64"),
netty("x86_64"),
];
put(
"versions/9.9/9.9.json",
format!(
r#"{{"id":"9.9","javaVersion":{{"majorVersion":25}},"downloads":{{"client":{{"sha1":"{client_sha1}"}},"server":{{"sha1":"{server_sha1}"}}}},"libraries":[{}]}}"#,
libraries.join(",")
)
.as_bytes(),
);
let manifest_sha1 = digest(&prism.join("versions/9.9/9.9.json"), "sha1").unwrap();
fs::write(
fixture.target.join("research-lock.json"),
format!(
r#"{{"minecraft":{{"version":"9.9","java_major":25,"manifest_sha1":"{manifest_sha1}","client_sha1":"{client_sha1}","server_sha1":"{server_sha1}"}}}}"#
),
)
.unwrap();
let lock = Lock::read(&fixture.target).unwrap();
let server = old.join("jars/server.jar");
let prepare = || import(&fixture.target, &lock, Some(&prism), Some(&server), &|_| {});
let imported = || {
let manifest = verify(&lock).unwrap().manifest;
client_libraries(&lock, &manifest).unwrap().len()
};
prepare().unwrap();
assert_eq!(imported(), 2);
let prepared = read_json(&lock.directory.join("prepared.json")).unwrap();
let listed = prepared.get("libraries").and_then(|l| l.get("client"));
assert_eq!(
listed.and_then(Value::as_array).map(<[Value]>::len),
Some(2)
);
assert!(reuse_imported(&fixture.target, &|_| {}).unwrap());
// The launcher's copy is used, and checked, when it has one.
let manifest = read_json(&prism.join("versions/9.9/9.9.json")).unwrap();
let (_, transport) = client_library_artifacts(&manifest)
.unwrap()
.into_iter()
.find(|(name, _)| runs_without(name))
.unwrap();
let path = transport.get("path").and_then(Value::as_str).unwrap();
put(&format!("libraries/{path}"), b"not kqueue");
assert!(prepare().unwrap_err().contains("is not the pinned file"));
put(&format!("libraries/{path}"), b"kqueue");
prepare().unwrap();
assert_eq!(imported(), 3);
// Other libraries stay required.
fs::remove_dir_all(lock.directory.join("libraries/client/org")).unwrap();
fs::remove_file(prism.join("libraries/org/lib/lib/1/lib-1.jar")).unwrap();
assert!(prepare().unwrap_err().starts_with("Missing "));
}
#[test]
fn runs_without_only_netty_native_transports() {
assert!(runs_without(
"io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64"
));
assert!(runs_without(
"io.netty:netty-transport-native-epoll:4.2.16.Final:linux-x86_64"
));
for required in [
"io.netty:netty-transport-native-unix-common:4.2.16.Final",
"io.netty:netty-transport-classes-kqueue:4.2.16.Final",
"org.lwjgl:lwjgl:3.3.3:natives-macos-arm64",
"com.mojang:jtracy:1.14.38:natives-windows",
] {
assert!(!runs_without(required), "{required}");
}
}
#[test]
fn natives_follow_the_architecture() {
let names = ["a:b:1:natives-macos", "a:b:1:natives-macos-arm64"];
+10 -2
View File
@@ -137,10 +137,17 @@ proposal, no profiling. Its cranelift backend generates native code for x86-64
and ARM64 on Windows, macOS and Linux, which is why one `.hmod` serves them
all.
The game links it only with its `mods` feature: every release build turns it
on (`release.yml`), and a build from source needs
`cargo build --release -p horizon-client --features mods` to run code mods.
Without it (the tests and probes) wasmtime and cranelift aren't built, a
quarter of a clean build; content mods still load, and the game logs why the
code mods don't start.
### Performance
Measured on the Mac mini (M4 Pro), release build, `cargo test -p
horizon-mods --release --test sandbox -- --ignored --nocapture`:
horizon-mods --features runtime,pack --release --test sandbox -- --ignored --nocapture`:
| | per call |
|---|---|
@@ -180,7 +187,8 @@ chat (`Mod Test Guest stopped: it took too long (an endless loop?)`), marks it
on the Mods page and carries on; the other mods keep running. Host functions
clip what a mod hands back (chat lines per call, HUD lines and lengths). Tests:
`crates/horizon-mods/tests/sandbox.rs` (an endless loop, a panic and a 1 GiB
allocation, next to a healthy mod).
allocation, next to a healthy mod; `cargo test -p horizon-mods --features
runtime,pack`).
## Playing with mods
+15 -1
View File
@@ -419,6 +419,20 @@ mod tests {
);
}
/// Whether the reference on disk says `want`, whatever line endings the checkout gave it: Git
/// for Windows checks text out with CRLF (core.autocrlf) where .gitattributes does not pin LF.
fn up_to_date(have: &str, want: &str) -> bool {
have.replace("\r\n", "\n") == want
}
#[test]
fn a_windows_checkout_of_the_reference_is_up_to_date() {
let want = markdown();
assert!(up_to_date(&want, &want));
assert!(up_to_date(&want.replace('\n', "\r\n"), &want));
assert!(!up_to_date(&want.replacen("## ", "### ", 1), &want));
}
#[test]
fn the_launcher_reference_is_up_to_date() {
let path =
@@ -429,7 +443,7 @@ mod tests {
}
let have = std::fs::read_to_string(&path).unwrap_or_default();
assert!(
have == want,
up_to_date(&have, &want),
"docs/launcher-commands.md is out of date: HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference"
);
for tool in all() {
@@ -37,6 +37,9 @@ public final class VanillaMenuOracle {
static final List<String> drops = new ArrayList<>();
/** onEquipItem calls that change the item (its sound and game event): slot and new stack. */
static final List<String> equips = new ArrayList<>();
/** The sounds the player played ({@code Entity.playSound}: a bundle's insert and remove). */
static final List<String> sounds = new ArrayList<>();
static final net.minecraft.util.RandomSource soundRandom = net.minecraft.util.RandomSource.create(0L);
/** The block entity behind a "crafter_be" menu (slot state packets go to it). */
static net.minecraft.world.level.block.entity.CrafterBlockEntity crafter;
/** The block entity behind a "lectern" menu. */
@@ -99,6 +102,8 @@ public final class VanillaMenuOracle {
@Override public void scheduleTick(BlockPos pos, net.minecraft.world.level.block.Block block, int delay) { }
@Override public void updateNeighborsAt(BlockPos pos, net.minecraft.world.level.block.Block block, net.minecraft.world.level.redstone.Orientation o) { }
@Override public void playSound(net.minecraft.world.entity.Entity e, BlockPos pos, net.minecraft.sounds.SoundEvent s, net.minecraft.sounds.SoundSource c, float v, float pitch) { }
// A bundle sound's pitch: 0.8 + level.getRandom().nextFloat() * 0.4.
@Override public net.minecraft.util.RandomSource getRandom() { return soundRandom; }
}
/** The maps of the case's level, by id, and the next free id. */
@@ -127,6 +132,9 @@ public final class VanillaMenuOracle {
if (!ItemStack.isSameItemSameComponents(old, now)) equips.add(slot.getName() + ":" + ParityData.canon(now));
}
@Override public boolean hasDisconnected() { return false; }
@Override public void playSound(net.minecraft.sounds.SoundEvent sound, float volume, float pitch) {
sounds.add(sound.location().toString());
}
@Override public ItemEntity drop(ItemStack stack, boolean thrown, net.minecraft.util.Prediction p) {
if (!stack.isEmpty()) drops.add(ParityData.canon(stack));
return null;
@@ -251,9 +259,9 @@ public final class VanillaMenuOracle {
// No inventory slots: the inventory Take Book fills.
StringBuilder items = new StringBuilder();
for (int i = 0; i < 36; i++) items.append(' ').append(ParityData.canon(inv.getItem(i)));
return state(menu, inv) + " | " + String.join(";", equips) + " | inv" + items;
return state(menu, inv) + " | " + String.join(";", equips) + " | " + String.join(";", sounds) + " | inv" + items;
}
return state(menu, inv) + " | " + String.join(";", equips);
return state(menu, inv) + " | " + String.join(";", equips) + " | " + String.join(";", sounds);
}
public static void main(String[] args) throws Exception {
@@ -271,6 +279,7 @@ public final class VanillaMenuOracle {
try {
drops.clear();
equips.clear();
sounds.clear();
var world = make(World.class);
world.server = make(Server.class);
var player = make(FakePlayer.class);
@@ -325,6 +334,7 @@ public final class VanillaMenuOracle {
for (int i = 0; i < dataJson.size(); i++) menu.setData(i, dataJson.get(i).getAsInt());
drops.clear();
equips.clear();
sounds.clear();
out.println("init " + clickState(menu, inv));
for (var opJson : f.getAsJsonArray("ops")) {
var op = opJson.getAsJsonArray();
@@ -65,6 +65,7 @@ import net.minecraft.network.protocol.game.ServerboundAcceptTeleportationPacket;
import net.minecraft.network.protocol.game.ServerboundChatCommandPacket;
import net.minecraft.network.protocol.game.ServerboundChatPacket;
import net.minecraft.network.protocol.game.ServerboundChunkBatchReceivedPacket;
import net.minecraft.network.protocol.game.ServerboundClientTickEndPacket;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket;
import net.minecraft.network.protocol.handshake.ClientIntent;
@@ -484,6 +485,8 @@ public final class VanillaProtocolClient {
// When the dead player sent a command (ServerGamePacketListenerImpl.handleChatCommand runs it
// whether the player is alive or not); it respawns once the reply came, or 3 s later.
long deadCommandAt = 0;
// When the client last sent where it is.
long positionAt = 0;
while (System.currentTimeMillis() < deadline) {
Received r;
try {
@@ -536,6 +539,7 @@ public final class VanillaProtocolClient {
// acceptance alone, as the 26.3 client sends.
if (!respawning && step < ENTITIES) {
send(new ServerboundMovePlayerPacket.PosRot(position.position(), position.yRot(), position.xRot(), false, false));
positionAt = now;
}
if (!loaded) {
send(new ServerboundPlayerLoadedPacket());
@@ -635,6 +639,17 @@ public final class VanillaProtocolClient {
commandTree(p);
}
}
if (entities == null && loaded && deadCommandAt == 0 && now - positionAt >= 1000) {
// LocalPlayer.sendPosition: once its level is loaded the 26.3 client sends where it is
// every 20 ticks even standing still, and the server then checks again which entities the
// player sees (ChunkMap.move). An entity whose chunk was sent after the player's last move,
// such as the villager when the first chunks come late, is paired at the next one. The tick
// end first (Minecraft.tick sends one every tick): a server takes one position per client tick.
// Until the death: from then on the steps wait only on entities spawned for the player.
send(ServerboundClientTickEndPacket.INSTANCE);
send(new ServerboundMovePlayerPacket.Pos(position.position(), false, false));
positionAt = now;
}
if (entities != null && loaded && lastChunk > 0 && now - lastChunk > 3000 && step == 0) {
// Entity mode: the harness's console summons the scene once it reads this
// line; everything tracked from then on is recorded until the client leaves.
+3 -2
View File
@@ -9,6 +9,7 @@ PACKAGES = {"horizon-core": "crates/horizon-core", "horizon-client": "crates/hor
"horizon-theme": "crates/horizon-theme", "xtask": "xtask"}
LIBRARIES = set(PACKAGES) - {"xtask"}
DEPENDENCIES = {"horizon-client": {"horizon-core"}}
MODS = " --features horizon-mods/runtime,horizon-mods/pack,horizon-client/mods"
def args(host="local", quick=False):
@@ -58,9 +59,9 @@ class PrReadyTests(unittest.TestCase):
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, PACKAGES, LIBRARIES, DEPENDENCIES)
by_name = {step.name: step for step in steps}
self.assertEqual(by_name["clippy"].command,
"cargo clippy --workspace --all-targets --locked -- -D warnings")
"cargo clippy --workspace" + MODS + " --all-targets --locked -- -D warnings")
self.assertEqual(by_name["tests: workspace"].command,
"cargo test --locked --workspace --tests")
"cargo test --locked --workspace" + MODS + " --tests")
self.assertEqual(by_name["doc tests"].command, "cargo test --locked --workspace --doc")
def test_non_rust_changes_skip_workspace_checks(self):
+70 -4
View File
@@ -68,6 +68,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
- feedback: the guest's command feedback names the guest and shows the numbers;
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
server thread the host window's too;
- styles: a styled tellraw reaches the host and the guest (each its own) with its colour, bold, click and hover
events; each player's /give tells them of the Diamonds! advancement before the give's
own message, and never echoes their command back to them as `[Name: ...]`;
- effects: a beacon's power reaches every player in range, the host and the guest;
- distance: (--server-thread) the server follows the host's render distance, past vanilla's 32 too
(IntegratedServer.tickServer), and the guest's chunks follow the server's (#804)
@@ -221,7 +224,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
class Game:
@@ -1462,8 +1465,8 @@ def check_loom(host, guest, out):
def own_reply(event):
"""A chat event that answers the guest's own command. On the server thread the guest also gets
its own command's `[Guest: ...]` operator broadcast, a moment later, which an answer to the next
command must not be taken for."""
its own command's `[Guest: ...]` operator broadcast before #1373, a moment later, which an answer
to the next command must not be taken for."""
return event.get('event') == 'chat' and not event.get('text', '').startswith(f'[{GUEST_NAME}: ')
@@ -2028,6 +2031,69 @@ def check_chat(host, guest, out):
return seen
def styled_parts(component):
"""Every component in a chat line's tree (the line, its `extra` and `with` parts)."""
if isinstance(component, dict):
yield component
for key in ('extra', 'with'):
for part in component.get(key) or []:
yield from styled_parts(part)
elif isinstance(component, list):
for part in component:
yield from styled_parts(part)
def check_styles(host, guest, out):
# ChatComponent keeps what the server sent: the style, the click and the hover event.
stamp = f'STYLE {int(time.time())}'
line = {'text': stamp, 'color': 'green', 'bold': True,
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
'hover_event': {'action': 'show_text', 'value': 'Tip'}}
seen = {}
for g in (host, guest):
# Each to itself: tellraw reaches only the player whose command it is here.
since = g.raw('events')['last']
g('command', command='tellraw @s ' + json.dumps(line))
g.raw('wait-for', 'chat', stamp, '--timeout', 10, '--since', since)
part = next((p for c in g.raw('messages', 10)['styled'] for p in styled_parts(c)
if stamp in str(p.get('text', ''))), None) or {}
seen[g.name] = {k: part.get(k) for k in ('color', 'bold', 'click_event', 'hover_event')}
hover = seen[g.name]['hover_event'] or {}
seen[g.name]['hover_event'] = [hover.get('action'), 'Tip' in str(hover.get('value'))]
# InventoryChangeTrigger fires inside /give, before its message; the operator echo
# (broadcastToAdmins) skips the player whose command it was.
order = {}
for g in (host, guest):
for command in ('advancement revoke @s everything', 'clear @s'):
g('command', command=command)
g.wait(5)
since = g.raw('events')['last']
g('command', command='give @s minecraft:diamond')
g.raw('wait-for', 'chat', 'Diamonds!', '--timeout', 10, '--since', since)
g.wait(20)
chat = g.raw('messages', 8)['chat']
name = GUEST_NAME if g is guest else host.player
gave = next((i for i, l in enumerate(chat) if l.startswith('Gave 1 [Diamond]')), None)
made = next((i for i, l in enumerate(chat) if '[Diamonds!]' in l), None)
echo = [l for l in chat if l.startswith(f'[{name}: ')]
# Newest first: the give's message is newer than the advancement's.
order[g.name] = {'gave': gave, 'advancement': made, 'echo': echo}
guest.raw('key', 't', 'tap')
guest.wait(5)
guest.shot(out, 'styles')
guest.raw('key', 'esc', 'tap')
want = {'color': 'green', 'bold': True,
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
'hover_event': ['show_text', True]}
for name, got in seen.items():
assert got == want, f'{name} styled line: {got}'
for name, o in order.items():
assert o['gave'] is not None and o['advancement'] is not None, f'{name} chat: {o}'
assert o['gave'] < o['advancement'], f'{name}: the give came before the advancement: {o}'
assert not o['echo'], f'{name} heard its own command: {o}'
return {'styles': seen, 'order': order}
def png_rows(path):
"""The rows of an 8-bit RGB or RGBA PNG, unfiltered, as bytes of 4 (or 3) per pixel."""
data = Path(path).read_bytes()
@@ -4303,7 +4369,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn,
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles,
'chat': check_chat, 'machines': check_machines, 'brewing': check_brewing,
'chat': check_chat, 'styles': check_styles, 'machines': check_machines, 'brewing': check_brewing,
'loom': check_loom, 'stonecutter': check_stonecutter, 'grindstone': check_grindstone,
'anvil': check_anvil, 'smithing': check_smithing, 'enchanting': check_enchanting,
'beacon': check_beacon, 'crafter': check_crafter, 'lectern': check_lectern,
+12 -2
View File
@@ -65,6 +65,7 @@ SHARED = Path(os.environ.get("PARITY_SHARED") or (LOCAL / "minecraft").resolve()
MACHINE_JOBS = int(os.environ.get("PARITY_MACHINE_JOBS") or max(1, (os.cpu_count() or 4) // 2))
HOST = socket.gethostname().split(".")[0]
DEFAULT_MEMORY = 3 << 30 # assumed peak for a harness that never ran here
FAILURE_TAILS, FAILURE_TAIL_LINES = 10, 30 # the summary prints the end of the first failures' logs
def harness_settings(path):
@@ -1361,8 +1362,17 @@ def main():
failed = sorted(name for name, record in results.items() if record["status"] != "pass")
finished_run(failed)
print(f"\n{len(results) - len(failed)} passed, {len(failed)} failed in {elapsed / 60:.1f} min.")
for name in failed:
print(f" {name}: {STATE_DIR / 'logs' / (name + '.log')}")
for index, name in enumerate(failed):
log = STATE_DIR / 'logs' / (name + '.log')
print(f" {name}: {log}")
# The end of a harness's output says why it failed, and CI shows only this output.
if index < FAILURE_TAILS:
try:
lines = log.read_text(encoding='utf-8', errors='replace').splitlines()[-FAILURE_TAIL_LINES:]
except OSError:
lines = []
for line in lines:
print(f" {line[:300]}")
return 1 if failed else 0
+12 -2
View File
@@ -274,6 +274,16 @@ def parity_summary(output):
return text
def mod_features(crates):
"""Code mods are off by default (docs/mods.md): on wherever horizon-mods is checked, as in CI."""
if crates is not None and "horizon-mods" not in crates:
return ""
features = ["horizon-mods/runtime", "horizon-mods/pack"]
if crates is None or "horizon-client" in crates:
features.append("horizon-client/mods")
return f" --features {','.join(features)}"
def plan(args, base, changed, crates_of, libraries, dependencies):
"""The checks for these changed files, in the order they run."""
rust = any(workspace_rust(path) for path in changed)
@@ -297,10 +307,10 @@ def plan(args, base, changed, crates_of, libraries, dependencies):
Step("Python tests", "python3 -m unittest discover -s tests", where="here",
skip=None if python else "no Python change", summary=unittest_passed),
Step("rustfmt", "cargo fmt --all -- --check", skip=None if rust else no_rust),
Step("clippy", f"cargo clippy {clippy_selection} --all-targets --locked -- -D warnings",
Step("clippy", f"cargo clippy {clippy_selection}{mod_features(clippy_crates)} --all-targets --locked -- -D warnings",
skip=None if rust else no_rust),
Step("tests" + (": workspace" if crates is None else f": {', '.join(crates)}" if crates else ""),
f"cargo test --locked {selection} --tests",
f"cargo test --locked {selection}{mod_features(crates)} --tests",
skip=test_skip, summary=tests_passed),
Step("doc tests", f"cargo test --locked {doc_selection} --doc",
skip=test_skip or (None if doc_crates else "no library touched"), summary=tests_passed),
+27 -5
View File
@@ -7,7 +7,9 @@ respawn_radius 0) and stops; a vanilla dedicated server and Horizon's
server.properties, server-icon.png and lists. VanillaProtocolClient, a
headless client made of vanilla's own codecs (any byte vanilla would not
read is an error), pings each server, joins it in offline mode through
login, configuration and play, accepts the spawn teleport, receives the
login, configuration and play, accepts the spawn teleport (then, standing
still until its death, sends its position every 20 ticks as LocalPlayer
does: the server checks then which entities it sees), receives the
chunks around the spawn (their bytes are compared: the world is loaded from
the same files), chats, runs /list and commands that fail (`/random value
7..7`, `/random value x`, `/trigger nope`), trades, is made an operator by
@@ -244,6 +246,20 @@ def console_lines(lines):
return out
# What a failure's diff doesn't say: warnings, errors, panics and why a player left.
NOTABLE = re.compile(r'/(WARN|ERROR)\]|Exception|panicked|lost connection')
def notable_lines(path, limit=20):
"""A server's notable lines from its `Done (` on (the startup warnings are every run's), or a client's."""
try:
lines = path.read_text(encoding='utf-8', errors='replace').splitlines()
except OSError:
return []
start = next((i for i, l in enumerate(lines) if 'Done (' in l), 0)
return [f'{path.name}: {l[:300]}' for l in lines[start:] if NOTABLE.search(l)][-limit:]
def server_files(directory):
out = []
for name in ['server.properties', 'eula.txt', 'ops.json', 'whitelist.json', 'banned-players.json', 'banned-ips.json',
@@ -420,9 +436,13 @@ def main():
(world / 'playerdata').mkdir(exist_ok=True)
def client(port, out):
oh.run_logged(['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaProtocolClient', '127.0.0.1', port, PLAYER, out], build,
out.stem, timeout=300)
try:
oh.run_logged(['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaProtocolClient', '127.0.0.1', port, PLAYER, out], build,
out.stem, timeout=300)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
log = (build / f'{out.stem}.log').read_text(encoding='utf-8', errors='replace').splitlines()
raise AssertionError(f'{out.stem}: {error}:\n' + '\n'.join(log[-30:])) from None
results = {}
for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]:
@@ -448,8 +468,10 @@ def main():
if a != b:
differences.append(f'chunk {key}: vanilla {a} horizon {b}')
if differences or errors:
notes = [line for name in ('vanilla', 'vanilla-client', 'horizon', 'horizon-client')
for line in notable_lines(build / f'{name}.log')]
raise AssertionError(f'{len([d for d in differences if d[:1] in "+-" and d[:3] not in ("+++", "---")])} differing lines '
f'(see {build}/vanilla.txt and horizon.txt):\n' + '\n'.join(differences[:80]))
f'(see {build}/vanilla.txt and horizon.txt):\n' + '\n'.join(differences[:80] + notes))
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
+44 -5
View File
@@ -22,8 +22,10 @@ results, and the scaled and locked copies a take makes), the lectern (over the
block entity: its page buttons, clamped to the book's pages, and Take Book,
with the inventory compared too) and the
server's furnace menus over their block entities (`*_be`: a new input restarts
cooking, other clicks keep its progress). After
every click the slots, carried stack, data slots and drops are compared.
cooking, other clicks keep its progress). Bundles (empty, partly and fully
loaded, nested) are among the stacks and often in hand, for BundleItem's click
overrides. After every click the slots, carried stack, data slots, drops and
the player's sounds are compared.
"""
import argparse
import json
@@ -37,6 +39,8 @@ except ImportError:
MENUS = {'inventory': 46, 'crafting': 46, 'chest1': 45, 'chest2': 54, 'chest3': 63, 'chest4': 72, 'chest5': 81,
'chest6': 90, 'hopper': 41, 'dispenser': 45, 'shulker': 63, 'furnace': 39, 'blast_furnace': 39, 'smoker': 39, 'brewing': 41, 'crafter': 46,
'crafter_be': 46, 'loom': 40, 'stonecutter': 38, 'grindstone': 39, 'anvil': 39, 'smithing': 40, 'enchanting': 38, 'beacon': 37, 'cartography': 39, 'lectern': 1, 'furnace_be': 39, 'blast_furnace_be': 39, 'smoker_be': 39}
# Where a carried bundle is common: the containers that hold anything.
BUNDLE_MENUS = {'inventory', 'chest1', 'chest3', 'shulker', 'hopper', 'dispenser'}
DATA = {'furnace': 4, 'blast_furnace': 4, 'smoker': 4, 'brewing': 4, 'crafter': 10, 'crafter_be': 10, 'loom': 1,
'stonecutter': 1, 'enchanting': 10, 'beacon': 3, 'lectern': 1,
'furnace_be': 4, 'blast_furnace_be': 4, 'smoker_be': 4}
@@ -91,8 +95,10 @@ def snbt(item, count, damage=0, curse=False, potion=None):
class Gen:
def __init__(self, rng, items):
def __init__(self, rng, items, bundles=None):
self.rng = rng
# Bundles come from their own generator, so the other stacks stay as they were.
self.bundles = bundles or random.Random(0)
self.items = items
self.pool = ['minecraft:' + c for c in COMMON if 'minecraft:' + c in items]
self.all = [k for k, v in items.items() if k != 'minecraft:air' and 'bundle' not in k
@@ -108,7 +114,37 @@ class Gen:
damage = rng.randrange(1, info['minecraft:max_damage'])
count = 1 if top == 1 else rng.choice([1, 1, 2, top, top, top - 1, rng.randrange(1, top + 1)])
potion = rng.choice(['minecraft:water', 'minecraft:awkward', 'minecraft:healing', 'minecraft:thick']) if item.endswith('potion') and rng.random() < 0.8 else None
return snbt(item, max(1, count), damage, curse=rng.random() < 0.15 and 'equippable' in json.dumps(info), potion=potion)
stack = snbt(item, max(1, count), damage, curse=rng.random() < 0.15 and 'equippable' in json.dumps(info), potion=potion)
return self.bundle() if self.bundles.random() < 0.06 else stack
def bundle(self, room=64):
"""A bundle, empty or holding up to its weight (in 64ths: 1 per item of a
64-stack, 4 of a 16-stack, 64 of a tool), now and then another bundle."""
return self.loaded_bundle(room)[0]
def loaded_bundle(self, room):
rng = self.bundles
color = rng.choice(['', '', 'red_', 'light_blue_'])
inside, used = [], 0
for _ in range(rng.choice([0, 0, 1, 1, 2, 3, 5])):
if room == 64 and rng.random() < 0.1:
# A nested bundle weighs its contents plus 1/16.
if room - used > 4:
nested, weight = self.loaded_bundle(min(room - used - 4, 16))
inside.append(nested)
used += weight + 4
continue
item = rng.choice(self.pool)
top = self.items[item].get('minecraft:max_stack_size', 64)
unit = 64 // top
if unit > room - used or 'shulker_box' in item:
continue
most = min(top, (room - used) // unit)
count = min(most, rng.choice([1, 1, 2, rng.randrange(1, most + 1)]))
used += count * unit
inside.append(snbt(item, count))
body = ',components:{"minecraft:bundle_contents":[%s]}' % ','.join(inside) if inside else ''
return '{id:"minecraft:%sbundle",count:1%s}' % (color, body), used
def slots(self, size, density):
return [self.stack() if self.rng.random() < density else '-' for _ in range(size)]
@@ -333,7 +369,7 @@ def main():
vanilla.compile('ParityData', 'VanillaMenuOracle')
items = json.loads((lib.RESOURCES / 'items.json').read_text())
rng = random.Random(args.seed)
g = Gen(rng, items)
g = Gen(rng, items, random.Random(args.seed + 1))
cases = []
chosen = args.menus.split(',') if args.menus else list(MENUS)
for n in range(args.cases):
@@ -442,6 +478,9 @@ def main():
'anvil': g.anvil_stack, 'smithing': g.smith_stack}.get(menu, g.stack)() if rng.random() < 0.5 else '-'
if carried_food is not None:
carried = carried_food
elif menu in BUNDLE_MENUS and g.bundles.random() < 0.3:
# A bundle in hand, emptied into slots and filled from them.
carried = g.bundle()
if menu == 'lectern':
carried = '-'
extra = {}