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 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 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 ba8bdc7179 Merge remote-tracking branch 'origin/main' into fix/st2-chat 2026-10-23 23:07:44 +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 c19418b55d Merge remote-tracking branch 'origin/main' into fix/one-handed-maps 2026-10-23 20:53:25 +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 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
33 changed files with 805 additions and 146 deletions

No files matched your search

+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.
+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),
+23 -8
View File
@@ -8846,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;
}
@@ -8922,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,
}
@@ -1422,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,
@@ -1431,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);
}
}
@@ -1822,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;
}
};
+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
@@ -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],