Merge branch 'main' into native/menu-quick-move

This commit is contained in:
levzzz authored and GitHub committed 2026-11-02 13:16:01 +05:00
commit dd98a7bfa0
79 files changed
+3545 -551

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
Generated
+9
View File
@@ -1399,6 +1399,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1543,6 +1544,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1647,6 +1655,7 @@ dependencies = [
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
+2
View File
@@ -17,6 +17,8 @@ members = [
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+2
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -389,6 +389,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -729,6 +732,20 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+65 -1
View File
@@ -525,6 +525,9 @@ const COMPACT_BUDGET: u64 = 32 << 20;
/// A large translucent page starts to shrink (`Renderer::shrink_translucent`)
/// this long after terrain last arrived, and `SHRINK_RETRY` after a try found
/// no room.
/// `Renderer::remove_columns` filters each store once rather than looking up every
/// section key from this many keys, or from as many as there are resident sections.
const BULK_REMOVAL_KEYS: usize = 256;
const SHRINK_SETTLE: std::time::Duration = std::time::Duration::from_secs(1);
const SHRINK_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
/// Not before then may a large translucent page start to shrink.
@@ -1024,6 +1027,8 @@ pub struct Renderer {
pack_atlas: Option<pack_atlas::PackAtlas>,
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -3857,6 +3862,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_terrain_flat: false,
separate_ao: false,
hud,
menu_blur: None,
post_effects: None,
@@ -4730,6 +4736,18 @@ impl Renderer {
}
}
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
let columns: Vec<(i32, i32)> = columns.into_iter().collect();
// Most of the view at once (a teleport at render distance 32 drops some 3700
// columns): a pass over each store instead of a lookup per section key, which
// took 10 ms of one frame. Far terrain fades the sections out instead.
let keys = columns.len() * self.layout.sections();
if self.lods.is_empty()
&& self.lod_cutout.is_empty()
&& keys >= self.sections.len().max(BULK_REMOVAL_KEYS)
{
self.remove_columns_at_once(&columns);
return;
}
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
@@ -4759,6 +4777,35 @@ impl Renderer {
if block_entities {
self.block_entity_revision = next_block_entity_revision();
}
self.release_meshes(released);
}
/// `remove_columns` for a large share of the resident sections: each store filtered
/// once by column.
fn remove_columns_at_once(&mut self, columns: &[(i32, i32)]) {
let gone: crate::fast_hash::FxHashSet<(i32, i32)> = columns.iter().copied().collect();
let complete = self.complete_column();
for &(x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
if self.block_entities.len() != before {
self.block_entity_revision = next_block_entity_revision();
}
let before = self.light_blocks.len();
self.light_blocks.retain(|key, _| kept(key));
self.light_revision += (before - self.light_blocks.len()) as u64;
self.section_uploaded.retain(|key, _| kept(key));
let released = self.sections.remove_columns(&gone);
self.release_meshes(released);
}
/// Gives the removed sections' quads back to their arenas; the sections themselves
/// (their sort centres) drop on a thread of their own when there are many.
fn release_meshes(&mut self, released: Vec<SectionGpu>) {
if let Some(flat) = &mut self.flat {
for id in released.iter().filter_map(|s| s.flat_id) {
flat.remove(id);
@@ -4775,6 +4822,9 @@ impl Renderer {
if let Some(arena) = &mut self.translucent_arena {
arena.release_all(released.iter().filter_map(|s| s.vertex_ranges(true).1));
}
if released.len() >= BULK_REMOVAL_KEYS {
arena::free_later(released);
}
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
@@ -5075,6 +5125,16 @@ impl Renderer {
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
self.separate_ao = false;
}
/// Sets whether shader pack terrain inputs pass vertex alpha to `hz_Color.a` (`separateAo=true`).
pub fn set_separate_ao(&mut self, separate_ao: bool) {
if self.separate_ao != separate_ao {
self.separate_ao = separate_ao;
if let Some(atlas) = &mut self.pack_atlas {
atlas.separate_ao = separate_ao;
}
}
}
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
@@ -5157,7 +5217,7 @@ impl Renderer {
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
self.pack_atlas = pack_atlas::PackAtlas::new(
let mut atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
@@ -5165,6 +5225,10 @@ impl Renderer {
layers,
self.sprite_textures[0].width(),
);
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_atlas = atlas;
self.queue.submit([encoder.finish()]);
}
// Flat draws whenever the device has them (`render` keeps them on with a pack).
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
pub sprite: u32,
/// Layers per sprite array.
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
}
impl PackAtlas {
@@ -57,6 +59,7 @@ impl PackAtlas {
columns,
sprite,
split,
separate_ao: false,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
@@ -179,7 +182,12 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
"hz_v_color.a"
} else {
"1.0"
};
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
@@ -189,7 +197,7 @@ impl PackAtlas {
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -313,4 +321,50 @@ mod tests {
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let mut atlas = PackAtlas {
texture,
view,
columns: 1,
sprite: 16,
split: 1,
separate_ao: false,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
inputs_default
.body
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
);
atlas.separate_ao = true;
let inputs_separate = atlas.terrain_inputs(false);
assert!(
inputs_separate
.body
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
);
}
}
+104 -23
View File
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
enum Source {
Color(usize),
Depth,
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
Depth1,
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
Depth2,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
Normals,
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
Specular,
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
/// `.mcmeta` says.
Custom {
@@ -81,10 +87,18 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
@@ -97,9 +111,12 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
}
}
@@ -387,6 +404,8 @@ struct Targets {
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -493,6 +512,8 @@ pub struct PackPost {
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -535,6 +556,8 @@ pub struct PackPost {
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
dummy_black: wgpu::TextureView,
dummy_normal: wgpu::TextureView,
dummy_3d: wgpu::TextureView,
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
@@ -649,11 +672,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
match name {
"depthtex0" | "gdepthtex" => Source::Depth,
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
"depthtex1" | "depthtex2" => Source::Depth1,
"depthtex1" => Source::Depth1,
"depthtex2" => Source::Depth2,
"noisetex" => Source::Noise,
"texture" | "gtexture" | "tex" => Source::Atlas,
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1075,7 +1100,7 @@ fn bindings(
Source::Atlas => geometry.is_none(),
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 => group == Group::Gbuffers,
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
_ => false,
};
@@ -1086,7 +1111,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
@@ -2080,13 +2105,20 @@ impl PackPost {
}
}
}
let depth1 = water.is_some()
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
let every_pass = || {
(passes
.iter()
@@ -2161,7 +2193,7 @@ impl PackPost {
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
@@ -2250,6 +2282,8 @@ impl PackPost {
});
}
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
passes,
@@ -2268,6 +2302,7 @@ impl PackPost {
weather_generation: 0,
hand,
depth1,
depth2,
shadow_targets,
shadow_terrain,
shadow_water,
@@ -2302,6 +2337,14 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
device,
queue,
"Shader pack normal stand-in",
1,
&flat_normal,
),
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
@@ -2507,6 +2550,27 @@ impl PackPost {
}
_ => &targets.depth,
},
Source::Depth2 => match &targets.depth2 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => match &targets.depth1 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => &targets.depth,
},
},
Source::Noise => &self.noise,
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
&self.dummy_3d
@@ -2514,6 +2578,8 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -2685,16 +2751,6 @@ impl PackPost {
return;
};
let translucent = list.translucent();
if list == TerrainList::Translucent
&& let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
@@ -2972,6 +3028,22 @@ impl PackPost {
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
self.draw_entities(device, queue, encoder, size, false);
self.draw_clouds(device, queue, encoder, size);
if let Some(t) = &self.targets {
if let Some((copy, _)) = &t.depth1 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
if let Some((copy, _)) = &t.depth2 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
}
self.draw_hand(device, queue, encoder, size, frame);
}
@@ -3362,6 +3434,11 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let depth2 = self.depth2.then(|| {
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
let view = t.create_view(&Default::default());
(t, view)
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -3395,6 +3472,7 @@ impl PackPost {
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
depth2,
mips,
});
self.forget_groups();
@@ -4078,7 +4156,10 @@ mod tests {
let main = |n| source(n, false, false, false);
assert!(main("gaux1") == Source::Color(4));
assert!(main("gdepthtex") == Source::Depth);
assert!(main("depthtex2") == Source::Depth1);
assert!(main("depthtex1") == Source::Depth1);
assert!(main("depthtex2") == Source::Depth2);
assert!(main("normals") == Source::Normals);
assert!(main("specular") == Source::Specular);
assert!(main("noisetex") == Source::Noise);
// Without the shadow pass, the shadow buffers are stand-ins.
assert!(main("shadowcolor0") == Source::Dummy);
@@ -5,7 +5,10 @@
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
streaming::in_view_distance,
};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -141,6 +144,24 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
let c = self.column_index[&(key.0, key.2)] as usize;
@@ -512,7 +533,19 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(3) {
if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
} else {
r.insert(key, step);
+23 -7
View File
@@ -1347,6 +1347,8 @@ pub struct Mesher<'a> {
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1434,7 +1436,11 @@ impl Mesher<'_> {
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let shade = (self.nether_shading, self.shader_pack_lighting);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
self.separate_ao,
);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
}
if !state.model {
@@ -1582,19 +1588,23 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
(gray, coords)
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
};
(gray, coords, brightness)
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
};
([lighting::gray(directional); 4], [coords; 4])
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
@@ -1637,12 +1647,18 @@ impl Mesher<'_> {
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
color,
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
uv[i],
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting): (bool, bool),
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
if separate_ao {
pack_rgb(rgb)
} else {
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
sprite_layers.push(layer);
}
let color = fluid_face(color, face);
let color = if separate_ao {
color
} else {
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
@@ -72,6 +72,7 @@ fn mesh(
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
@@ -132,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -158,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
// Place stone with neighbouring blocks to create AO variations.
chunk.set(8, MIN_Y + 8, 8, stone);
chunk.set(9, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: true,
separate_ao: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
assert!(!mesh.solid.is_empty());
for v in &mesh.solid {
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
// Alpha holds the AO factor, which must be non-zero.
assert!(v.color >> 24 > 0);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -209,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -234,6 +234,7 @@ impl<G: ChunkGenerator> Saves for G {
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
separate_ao: bool,
zoom: BiomeZoom,
blend_radius: i32,
smooth_lighting: bool,
@@ -465,6 +466,9 @@ pub struct Streaming {
/// A shader pack lights the faces: sections mesh without face shading
/// (`Mesher::shader_pack_lighting`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
/// (`Mesher::separate_ao`).
pub separate_ao: bool,
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
pub biome_zoom: BiomeZoom,
pub biome_blend: i32,
@@ -878,6 +882,7 @@ impl<G: ChunkGenerator> Jobs<G> {
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
separate_ao: settings.separate_ao,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
@@ -979,6 +984,7 @@ impl Streaming {
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
biome_zoom: BiomeZoom::new(0),
biome_blend: 2,
smooth_lighting: true,
@@ -1920,6 +1926,7 @@ impl Streaming {
MeshSettings {
nether: self.nether_shading,
shader_pack_lighting: self.shader_pack_lighting,
separate_ao: self.separate_ao,
zoom: self.biome_zoom,
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
@@ -219,6 +219,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
@@ -129,6 +129,7 @@ fn scene(
let mut mesher = Mesher {
nether_shading: row["nether"].as_bool().unwrap_or(false),
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables,
@@ -4,20 +4,14 @@
//! shared table, run through the same `Session` as the CLI.
use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
}
fn tools() -> Vec<Value> {
@@ -143,15 +137,10 @@ impl Session {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -171,52 +160,13 @@ impl Session {
}
pub fn serve(session: &mut Session) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| session.handle(method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -255,28 +205,66 @@ mod tests {
#[test]
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
"tools/call",
&json!({ "name": "goto", "arguments": { "x": 1 } }),
let mut app = |method: &str, params: &Value| s.handle(method, params);
let server = server();
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
// A client that opens with the handshake revision.
let init = server
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
// A modern client: stateless, and the same tools.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let params = json!({ "_meta": Value::Object(meta) });
let list = server
.handle_line(&line("tools/list", params), &mut app)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
let r = server
.handle_line(
&line(
"tools/call",
json!({ "name": "goto", "arguments": { "x": 1 } }),
),
&mut app,
)
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(
r["content"][0]["text"]
r["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("y is required")
);
// No game: a connection error, reported as a tool error.
let r = s
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
let r = server
.handle_line(
&line("tools/call", json!({ "name": "state", "arguments": {} })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(r["result"]["isError"], true);
// Methods this server does not have are the protocol's to refuse.
assert_eq!(
server
.handle_line(&line("nope/nope", json!({})), &mut app)
.unwrap()["error"]["code"],
json!(-32601)
);
}
}
@@ -29,6 +29,17 @@ pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
Some((block_entity_nbt::id(data)?, data))
}
/// `JukeboxBlock.setPlacedBy`: the item's `minecraft:block_entity_data`
/// carries a `RecordItem`, so the placed jukebox holds a record and its
/// `has_record` state has to say so. Vanilla reads the item's data, not the
/// block entity the placement just filled.
pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
@@ -602,6 +613,40 @@ mod tests {
assert!(!op_warning(&chest));
}
/// `JukeboxBlock.setPlacedBy` looks for a `RecordItem` in the placed
/// item's block entity data, whatever the type it names: only that key
/// turns the box's `has_record` on.
#[test]
fn a_jukebox_item_with_a_record_in_its_data_says_so() {
let jukebox = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:jukebox", 1)
}
};
// A plain jukebox, and one whose data is only the type id: no record.
assert!(!carries_record(&crate::items::ItemStack::new(
"minecraft:jukebox",
1
)));
assert!(!carries_record(&jukebox("{id:\"minecraft:jukebox\"}")));
assert!(!carries_record(&jukebox(
"{id:\"minecraft:jukebox\",Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}"
)));
// Creative's Ctrl + Pick Block on a jukebox holding a disc.
assert!(carries_record(&jukebox(
"{id:\"minecraft:jukebox\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
// The key alone counts: `TypedEntityData.contains` never checks the
// type, and the state it sets is the one the block is placed as.
assert!(carries_record(&jukebox(
"{id:\"minecraft:chest\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
+100 -21
View File
@@ -2384,26 +2384,37 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
const THROWABLE_ITEMS: [&str; 5] = [
"minecraft:snowball",
"minecraft:egg",
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:lingering_potion",
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
if let ProjectileKind::Thrown(t) = kind {
return Some(t.item);
/// The item a throwable flies as: its stack (a potion's), the mod type's
/// item, or the item its entity type is named after.
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
match kind {
ProjectileKind::Thrown(t) => Some(t.item),
ProjectileKind::Potion(stack) => Some(&stack.item),
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
}
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
/// A potion flies as the item its entity type names, which is the item a
/// summoned one has when it names none (`getDefaultItem`).
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
match THROWABLES.iter().find(|(_, i)| *i == id) {
Some((kind, _)) => Some(kind.clone()),
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
if !THROWABLE_ITEMS.contains(&id) {
return crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown);
}
match id {
"minecraft:snowball" => Some(ProjectileKind::Snowball),
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
_ => Some(ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new(id, 1),
))),
}
}
@@ -2441,13 +2452,14 @@ pub fn throwable_to_nbt(
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
// the loaded item stays (a blue egg's, a renamed one's, a potion's
// effects); when there is none, `getItem`'s default is written.
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
let stack = match &p.kind {
ProjectileKind::Potion(stack) => (**stack).clone(),
_ => crate::items::ItemStack::new(item, 1),
};
put(&mut tag, "Item", item_to_nbt(&stack));
}
Some(tag)
}
@@ -2455,7 +2467,14 @@ pub fn throwable_to_nbt(
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = throwable_kind(&kind(tag)?)?;
let mut kind = throwable_kind(&kind(tag)?)?;
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
// and all; a tag without one keeps `getItem`'s default.
if let ProjectileKind::Potion(stack) = &mut kind
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
{
**stack = item;
}
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
@@ -4789,6 +4808,66 @@ mod fireball_nbt_tests {
assert!(back.get("Item").is_none());
}
#[test]
fn a_summoned_potion_flies_as_the_item_its_compound_names() {
// `ThrowableItemProjectile.getDefaultItem`: the item its entity type is
// named after, so a bare summon is a splash potion with no effects and
// the chunk saves it (ThrowableProjectile is not transient).
for id in ["minecraft:splash_potion", "minecraft:lingering_potion"] {
let bare = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
]);
let p = throwable_from_nbt(&bare, None).unwrap();
assert_eq!(p.kind.id(), id);
assert_eq!(
p.kind,
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(item.potion(), None);
}
// `readAdditionalSaveData`: the `Item` it flies as, `potion_contents`
// and all, so what it splashes is the potion's.
let harming = Tag::compound([
("id", Tag::String("minecraft:splash_potion".into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:splash_potion", 1)
.with_potion("minecraft:harming"),
),
),
]);
let p = throwable_from_nbt(&harming, None).unwrap();
let ProjectileKind::Potion(stack) = &p.kind else {
panic!("a potion");
};
assert_eq!(stack.potion(), Some("minecraft:harming"));
// The stack stays in the tag it was loaded from.
let tag = throwable_to_nbt(&p, [1; 4], None, Some(&harming)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(item.potion(), Some("minecraft:harming"));
// A lingering potion is its own entity type.
let lingering = Tag::compound([
("id", Tag::String("minecraft:lingering_potion".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:lingering_potion", 1)
.with_potion("minecraft:poison"),
),
),
]);
let p = throwable_from_nbt(&lingering, None).unwrap();
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
+3
View File
@@ -207,6 +207,7 @@ mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_inventories;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
mod native_menus;
@@ -396,6 +397,7 @@ pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
@@ -6599,6 +6601,7 @@ impl Game<'_> {
Some(BlockEntity::StructureBlock(_)) => self.structure_block_placed(key),
Some(BlockEntity::JigsawBlock(_)) => self.jigsaw_block_placed(key),
Some(BlockEntity::TestBlock(_)) => self.test_block_placed(key),
Some(BlockEntity::Jukebox(_)) => self.jukebox_placed(target, &stack),
Some(BlockEntity::Sign(sign)) if !sign.waxed => self.open_sign_editor(key, true),
_ => {}
}
+5 -2
View File
@@ -1369,11 +1369,14 @@ impl Game<'_> {
let count = Self::int(ctx, "count").unwrap_or(1);
let targets = self.entities(ctx, "targets", true)?;
let max_stack = self.items().get(&id).map_or(64, |i| i.max_stack) as i32;
if count > max_stack * 100 {
// A native mod's item can stack far past vanilla's 99: saturate rather
// than overflow on a size near i32::MAX.
let limit = max_stack.saturating_mul(100);
if count > limit {
return Err(CommandError::new(Text::translate(
"commands.give.failed.toomanyitems",
&[
Text::literal((max_stack * 100).to_string()),
Text::literal(limit.to_string()),
self.item_display_name(&id),
],
)));
+30 -77
View File
@@ -4638,84 +4638,37 @@ impl Game<'_> {
self.combat.explosion_in_water = false;
result?;
}
(
ProjectileKind::Potion {
effect,
ticks,
amplifier,
..
},
_,
) => {
// AbstractThrownPotion.applySplash: within 4 blocks, scaled by distance.
if let Some(pv) = self.player_view() {
let d2 = (pv.position.x - at.x).powi(2)
+ (pv.position.y - at.y).powi(2)
+ (pv.position.z - at.z).powi(2);
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
match *effect {
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
}
// AbstractThrownPotion.onHit, then onHitAsPotion from the stack it
// flies as: the splash happens where the hit was, a lingering
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
// and the potion that lands names the hurt it deals.
(ProjectileKind::Potion(stack), hit) => {
let block = match hit {
Hit::Block { pos, face, .. } => Some((
pos,
Direction::approximate_nearest(Vec3::new(
f64::from(face[0]),
f64::from(face[1]),
f64::from(face[2]),
)),
)),
_ => None,
};
let mob = match hit {
Hit::Entity {
who: Who::Player, ..
} => self.player.as_ref().map(|pv| pv.position),
Hit::Entity {
who: Who::Mob(id), ..
} => self.mobs.iter().find(|m| m.id == id).map(|m| m.position),
_ => None,
};
let owner = self.potion_owner(p.owner);
self.splash(stack, at, mob, block, p.age, &owner);
if stack.item.as_ref() != "minecraft:experience_bottle" {
let item = stack.item.to_string();
self.native_potion_splash(&item, [at.x, at.y, at.z]);
}
// A witch's healing potions reach the raiders around the splash.
if matches!(
*effect,
"minecraft:instant_health" | "minecraft:regeneration"
) {
let center = at;
for m in &mut self.mobs {
let d2 = m.position.distance_squared(center);
if !m.alive()
|| d2 >= 16.0
|| !horizon_client::mobs::raider::is_raider(m.species())
{
continue;
}
let scale = 1.0 - d2.sqrt() / 4.0;
if *effect == "minecraft:instant_health" {
let heal = (4 << *amplifier) as f64 * scale;
m.health = (m.health + heal as f32).min(m.attrs.max_health);
} else {
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if t > 20 {
m.add_effect(effect, t, *amplifier);
}
}
}
}
self.audio.play(
"minecraft:entity.splash_potion.break",
Some(atp),
1.0,
self.world.random.next_float() * 0.1 + 0.9,
);
}
(ProjectileKind::LlamaSpit, Hit::Entity { who, .. }) => match who {
Who::Player => {
+11
View File
@@ -4,6 +4,7 @@
//! signal while playing, the comparator output, and saving.
use super::Game;
use horizon_client::{
block_entity_item,
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
@@ -42,6 +43,16 @@ impl Game<'_> {
});
}
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
/// `block_entity_data` carries a `RecordItem` keeps that record, so
/// `has_record` turns on: the model draws the disc and `useWithoutItem`
/// pops it out. Vanilla reads the item's data, after
/// `updateCustomBlockEntityTag` has filled the block entity.
pub(super) fn jukebox_placed(&mut self, pos: [i32; 3], stack: &ItemStack) {
if block_entity_item::carries_record(stack) {
self.set_has_record(pos, true);
}
}
/// `onSongChanged`: neighbours (the signal) and comparators.
fn jukebox_changed(&mut self, pos: [i32; 3]) {
let p = BlockPos::new(pos[0], pos[1], pos[2]);
@@ -2645,12 +2645,15 @@ impl Game<'_> {
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
/// handleSetCreativeModeSlot's `validData` then ignores a stack beyond its own stack size.
/// A native mod can raise an item's stack size past 99 (`item_defaults::MAX_STACK_SIZE`), so
/// the count is held to what the wire carries instead. handleSetCreativeModeSlot's `validData`
/// then ignores a stack beyond its own stack size.
fn creative_stack_valid(&self, host: &mut LanHost, gi: usize, s: &ItemStack) -> bool {
if s.count > 99 {
let max = horizon_world::item_defaults::MAX_STACK_SIZE as u32;
if s.count > max {
let g = &host.hub.guests[gi];
let conn = g.conn;
let reason = format!("Invalid packet: count {} is not in 1..99", s.count);
let reason = format!("Invalid packet: count {} is not in 1..={max}", s.count);
eprintln!("{} sent an invalid creative stack: {reason}", g.name);
host.hub
.kick(conn, horizon_protocol::text::literal(&reason));
@@ -623,6 +623,17 @@ impl Game<'_> {
K::WitherSkull { dangerous: true } => {
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
}
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion's
// `potion_contents`, whose effects the guest's splash and
// whose colour its drawing need. A plain splash potion is the
// client's default and is not sent.
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some((**stack).clone()))),
));
}
_ => {}
}
out.push(e);
+47 -28
View File
@@ -450,6 +450,7 @@
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::native_data_maps::NativeDataMapType;
use super::native_item_energy::ItemEnergyBuffer;
use super::{
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
@@ -2194,6 +2195,22 @@ impl Game<'_> {
pub(super) fn native_item_fluid_tank(&self, item: &str, capacity: &mut Option<i32>) {
mod_api::dispatch_item_fluid_tank(self, mod_api::mods(), item, capacity);
}
/// Item `item` (`mod_id:name`) holds energy (`Capabilities.Energy.ITEM`):
/// set `buffer` when the item is yours, as `SimpleEnergyHandler` is built:
/// its capacity and the most one insert and one extract move. A capacity
/// under 1 leaves the buffer out, and the limits are clamped to
/// 0..=capacity; vanilla's items have none. Asked at each call of the item
/// energy helpers (`native_item_energy`, `native_item_energy_insert`,
/// `native_item_energy_extract`, in `native_item_energy.rs`). The amount
/// is kept in the stack's `minecraft:custom_data`, as the int
/// `horizon_energy`. API @API:item_energy_buffer@.
pub(super) fn native_item_energy_buffer(
&self,
item: &str,
buffer: &mut Option<ItemEnergyBuffer>,
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -7765,37 +7782,39 @@ impl Game<'_> {
return false;
};
let atlas = &self.gui.atlas;
let (w, h) = (s.screen.width, s.screen.height);
batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255));
batch.fill(
atlas,
ox + 1.0,
oy + 1.0,
w - 2.0,
h - 2.0,
rgba(198, 198, 198, 255),
);
let container = self.native_screen_slots().unwrap_or_default();
for i in (0..36).chain(container) {
let (x, y) = self.slot_position(i);
let (x, y) = (ox + x - 1.0, oy + y - 1.0);
batch.fill(atlas, x, y, 18.0, 18.0, rgba(55, 55, 55, 255));
if !s.screen.own_background {
let (w, h) = (s.screen.width, s.screen.height);
batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
17.0,
17.0,
rgba(255, 255, 255, 255),
);
batch.fill(
atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(139, 139, 139, 255),
ox + 1.0,
oy + 1.0,
w - 2.0,
h - 2.0,
rgba(198, 198, 198, 255),
);
let container = self.native_screen_slots().unwrap_or_default();
for i in (0..36).chain(container) {
let (x, y) = self.slot_position(i);
let (x, y) = (ox + x - 1.0, oy + y - 1.0);
batch.fill(atlas, x, y, 18.0, 18.0, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
17.0,
17.0,
rgba(255, 255, 255, 255),
);
batch.fill(
atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(139, 139, 139, 255),
);
}
}
self.native_screen_draw(&s.block, &s.data, batch, [ox, oy]);
if s.screen.own_labels {
@@ -0,0 +1,341 @@
//! Energy held by item stacks for native mods (docs/native-mods.md): a mod's
//! batteries and charged tools (`native_item_energy_buffer`), read, filled
//! and drained. Vanilla has no energy; the contract is NeoForge's
//! `Capabilities.Energy.ITEM`, an `EnergyHandler` reached through an
//! `ItemAccess`: it acts on one item of a stack at a time and may change it.
//! So do these, as the item fluid helpers (`native_item_fluids.rs`) do: they
//! take a stack and answer without changing it, and the caller shrinks its
//! stack by one and hands out the changed item they return. They hold no
//! world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::{Compound, JavaString, Tag};
/// The buffer a mod item declares (`native_item_energy_buffer`), as
/// NeoForge's `SimpleEnergyHandler` is built: its capacity and the most one
/// insert and one extract move.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ItemEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
}
/// What one item of a stack holds (`native_item_energy`).
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeItemEnergy {
pub(crate) amount: i32,
pub(crate) capacity: i32,
/// The most one insert moves.
pub(crate) max_insert: i32,
/// The most one extract moves.
pub(crate) max_extract: i32,
}
const CUSTOM_DATA: &str = "minecraft:custom_data";
/// The int under `minecraft:custom_data` that keeps the amount, as a mod
/// block's buffer keeps it under `energy`.
const ENERGY: &str = "horizon_energy";
/// The buffer as declared, or none when its capacity is under 1; the limits
/// are clamped to 0..=capacity, as a block's are.
fn checked(buffer: ItemEnergyBuffer) -> Option<ItemEnergyBuffer> {
(buffer.capacity >= 1).then(|| ItemEnergyBuffer {
capacity: buffer.capacity,
max_insert: buffer.max_insert.clamp(0, buffer.capacity),
max_extract: buffer.max_extract.clamp(0, buffer.capacity),
})
}
/// The amount a stack keeps in its `custom_data`: anything but an int, or a
/// negative, reads as empty.
fn read_energy(stack: &ItemStack) -> i32 {
let Some(Tag::Compound(data)) = stack.data().and_then(|d| d.other(CUSTOM_DATA)) else {
return 0;
};
match data.get(&JavaString::from(ENERGY)) {
Some(Tag::Int(energy)) => (*energy).max(0),
_ => 0,
}
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `energy`, its other data kept. An empty item
/// carries nothing, so empty batteries stack with new ones.
fn with_energy(stack: &ItemStack, energy: i32) -> ItemStack {
let mut one = one_of(stack);
let mut data = match one.data().and_then(|d| d.other(CUSTOM_DATA)) {
Some(Tag::Compound(data)) => data.clone(),
_ => Compound::new(),
};
if energy > 0 {
data.insert(JavaString::from(ENERGY), Tag::Int(energy));
} else {
data.remove(&JavaString::from(ENERGY));
}
let value = (!data.is_empty()).then_some(Tag::Compound(data));
one.data_mut().set_other(CUSTOM_DATA, value);
// What a server sent for the stack no longer describes it; kept, an
// emptied battery would not stack with a new one.
one.data_mut().wire = Default::default();
one.tidy();
one
}
/// What one item of `stack` holds, if it has a buffer.
fn contents(stack: &ItemStack, buffer: Option<ItemEnergyBuffer>) -> Option<NativeItemEnergy> {
let buffer = buffer?;
(stack.count > 0).then(|| NativeItemEnergy {
amount: read_energy(stack),
capacity: buffer.capacity,
max_insert: buffer.max_insert,
max_extract: buffer.max_extract,
})
}
/// `SimpleEnergyHandler.insert` on one item of `stack`: the room left (none
/// when it holds more than its capacity), the call's limit and what was
/// offered, whichever is least; and the item it becomes.
fn insert_one(
stack: &ItemStack,
buffer: Option<ItemEnergyBuffer>,
offered: i32,
) -> Option<(i32, ItemStack)> {
let held = contents(stack, buffer)?;
let took = (held.capacity.saturating_sub(held.amount))
.min(offered)
.min(held.max_insert)
.max(0);
if took == 0 {
return Some((0, one_of(stack)));
}
Some((took, with_energy(stack, held.amount + took)))
}
/// `SimpleEnergyHandler.extract` on one item of `stack`: what it holds, the
/// call's limit and what was asked, whichever is least; and the item it
/// becomes.
fn extract_one(
stack: &ItemStack,
buffer: Option<ItemEnergyBuffer>,
asked: i32,
) -> Option<(i32, ItemStack)> {
let held = contents(stack, buffer)?;
let gave = held.amount.min(asked).min(held.max_extract).max(0);
if gave == 0 {
return Some((0, one_of(stack)));
}
Some((gave, with_energy(stack, held.amount - gave)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The buffer a mod gave item `item`, checked; none for any other item.
fn native_item_energy_of(&self, item: &str) -> Option<ItemEnergyBuffer> {
if item.starts_with("minecraft:") {
return None;
}
let mut buffer = None;
self.native_item_energy_buffer(item, &mut buffer);
buffer.and_then(checked)
}
/// `Capabilities.Energy.ITEM`, read: the amount, capacity and limits per
/// call of one item of `stack`, which keeps the amount in its
/// `custom_data`. None for an item without a buffer, and for an empty
/// stack.
pub(in crate::game) fn native_item_energy(
&self,
stack: &ItemStack,
) -> Option<NativeItemEnergy> {
contents(stack, self.native_item_energy_of(&stack.item))
}
/// `EnergyHandler.insert`: up to `amount` into one item of `stack`, no
/// more than its room and its limit per call. The amount that went in,
/// and the single item it becomes (the item itself when it took
/// nothing). The stack given is not changed: shrink it by one and hand
/// out the result, or leave the result unused to simulate. None where
/// the item has no buffer.
pub(in crate::game) fn native_item_energy_insert(
&self,
stack: &ItemStack,
amount: i32,
) -> Option<(i32, ItemStack)> {
insert_one(stack, self.native_item_energy_of(&stack.item), amount)
}
/// `EnergyHandler.extract`: up to `amount` out of one item of `stack`,
/// no more than it holds and its limit per call. The amount that came
/// out, and the single item it becomes. The stack given is not changed,
/// as with `native_item_energy_insert`. None where the item has no
/// buffer.
pub(in crate::game) fn native_item_energy_extract(
&self,
stack: &ItemStack,
amount: i32,
) -> Option<(i32, ItemStack)> {
extract_one(stack, self.native_item_energy_of(&stack.item), amount)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn battery(count: u32) -> ItemStack {
ItemStack::new("mymod:battery", count)
}
fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> Option<ItemEnergyBuffer> {
checked(ItemEnergyBuffer {
capacity,
max_insert,
max_extract,
})
}
#[test]
fn an_insert_stops_at_the_room_the_limit_and_the_offer() {
let b = buffer(1000, 300, 1000);
let (took, one) = insert_one(&battery(5), b, 200).unwrap();
assert_eq!((took, one.count, read_energy(&one)), (200, 1, 200));
let (took, one) = insert_one(&one, b, 5000).unwrap();
assert_eq!((took, read_energy(&one)), (300, 500));
let (_, one) = insert_one(&one, b, 300).unwrap();
let (_, one) = insert_one(&one, b, 300).unwrap();
assert_eq!(read_energy(&one), 1000);
assert_eq!(insert_one(&one, b, 1).unwrap().0, 0);
// Nothing offered, or a negative offer, moves nothing.
assert_eq!(insert_one(&battery(1), b, 0).unwrap(), (0, battery(1)));
assert_eq!(insert_one(&battery(1), b, -50).unwrap(), (0, battery(1)));
}
#[test]
fn an_extract_stops_at_what_is_held_the_limit_and_the_ask() {
let b = buffer(1000, 1000, 150);
let (_, full) = insert_one(&battery(1), b, 400).unwrap();
let (gave, rest) = extract_one(&full, b, 100).unwrap();
assert_eq!((gave, read_energy(&rest)), (100, 300));
let (gave, rest) = extract_one(&rest, b, 1000).unwrap();
assert_eq!((gave, read_energy(&rest)), (150, 150));
let (gave, empty) = extract_one(&rest, b, 1000).unwrap();
assert_eq!(gave, 150);
// Nothing left, so it is a new battery again and stacks with one.
assert_eq!(empty, battery(1));
assert_eq!(extract_one(&empty, b, 10).unwrap(), (0, battery(1)));
}
#[test]
fn a_zero_limit_closes_that_way_only() {
// A generator's output cell: it gives, and takes nothing.
let source = buffer(500, 0, 500);
assert_eq!(insert_one(&battery(1), source, 100).unwrap().0, 0);
let sink = buffer(500, 500, 0);
let (_, charged) = insert_one(&battery(1), sink, 100).unwrap();
assert_eq!(extract_one(&charged, sink, 100).unwrap().0, 0);
assert_eq!(extract_one(&charged, source, 100).unwrap().0, 100);
}
#[test]
fn a_buffer_is_checked_as_a_blocks_is() {
assert_eq!(buffer(0, 10, 10), None);
assert_eq!(buffer(-5, 10, 10), None);
assert_eq!(
buffer(100, 500, -3),
Some(ItemEnergyBuffer {
capacity: 100,
max_insert: 100,
max_extract: 0,
})
);
}
#[test]
fn items_without_a_buffer_hold_nothing() {
assert_eq!(contents(&battery(1), None), None);
assert_eq!(insert_one(&battery(1), None, 10), None);
assert_eq!(extract_one(&battery(1), None, 10), None);
assert_eq!(contents(&battery(0), buffer(100, 10, 10)), None);
}
#[test]
fn what_a_stack_holds_reads_back_with_its_limits() {
let b = buffer(2000, 50, 75);
let (_, one) = insert_one(&battery(1), b, 50).unwrap();
assert_eq!(
contents(&one, b),
Some(NativeItemEnergy {
amount: 50,
capacity: 2000,
max_insert: 50,
max_extract: 75,
})
);
// A damaged or foreign value reads as empty.
let mut odd = battery(1);
odd.data_mut().set_other(
CUSTOM_DATA,
Some(Tag::compound([(
ENERGY,
Tag::String(JavaString::from("9")),
)])),
);
assert_eq!(read_energy(&odd), 0);
odd.data_mut()
.set_other(CUSTOM_DATA, Some(Tag::compound([(ENERGY, Tag::Int(-40))])));
assert_eq!(read_energy(&odd), 0);
}
#[test]
fn more_than_the_capacity_takes_nothing_and_still_gives() {
// A mod that shrank its capacity after the item was charged.
let mut over = battery(1);
over.data_mut()
.set_other(CUSTOM_DATA, Some(Tag::compound([(ENERGY, Tag::Int(900))])));
let b = buffer(500, 500, 500);
assert_eq!(insert_one(&over, b, 100).unwrap().0, 0);
let (gave, rest) = extract_one(&over, b, 1000).unwrap();
assert_eq!((gave, read_energy(&rest)), (500, 400));
}
#[test]
fn an_emptied_battery_from_a_server_stacks_with_a_new_one() {
let b = buffer(100, 100, 100);
let mut sent = battery(2);
sent.data_mut().wire =
horizon_client::item_data::Wire::new(battery(2), Arc::from(vec![1u8]));
let (_, charged) = insert_one(&sent, b, 40).unwrap();
let (_, emptied) = extract_one(&charged, b, 40).unwrap();
assert!(emptied.same_item_same_components(&battery(1)));
}
#[test]
fn a_battery_keeps_the_rest_of_its_custom_data() {
let b = buffer(1000, 1000, 1000);
let mut stack = battery(1);
stack.data_mut().set_other(
CUSTOM_DATA,
Some(Tag::compound([(
"owner",
Tag::String(JavaString::from("Alex")),
)])),
);
let (_, charged) = insert_one(&stack, b, 10).unwrap();
let Some(Tag::Compound(data)) = charged.data().and_then(|d| d.other(CUSTOM_DATA)) else {
panic!("no custom data");
};
assert!(data.contains_key(&JavaString::from("owner")));
assert_eq!(data.get(&JavaString::from(ENERGY)), Some(&Tag::Int(10)));
let (_, emptied) = extract_one(&charged, b, 10).unwrap();
assert_eq!(emptied, stack);
}
}
+68 -16
View File
@@ -117,6 +117,21 @@ impl PotionCloud {
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// `Projectile.getOwner` of a thrown potion: what its `indirectMagic` damage
/// is named after and who it counts against. Nothing for a dispenser's or a
/// summoned potion nobody owns, the potion itself then naming the death
/// message.
#[derive(Clone, Debug, Default)]
pub(super) struct PotionOwner {
/// The thrower's name, when it has one.
name: Option<Text>,
/// The mob that threw it (a witch): the hurt is its kill credit, as
/// `indirectMagic(potion, owner)` gives it. A player's throw is the
/// potion's own and only `name`s the message.
attacker: Option<u32>,
/// A player threw it: the water damage `indirectMagic` deals is theirs.
player: bool,
}
/// A mob's effect ids carry the `minecraft:` namespace; a mod's has its own.
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
brewing::effect(id).map(|e| brewing::namespaced(e.id))
@@ -1166,7 +1181,13 @@ impl Game<'_> {
let thrower = owner.is_some().then_some(Who::Player);
self.projectile_event("minecraft:projectile_land", thrower, heard);
let item = stack.item.to_string();
self.splash(&stack, at, mob, block, age, owner);
// A potion a dispenser threw has nobody to name.
let owner = PotionOwner {
player: owner.is_some(),
name: owner,
attacker: None,
};
self.splash(&stack, at, mob, block, age, &owner);
if item != "minecraft:experience_bottle" {
self.native_potion_splash(&item, [at.x, at.y, at.z]);
}
@@ -1305,6 +1326,12 @@ impl Game<'_> {
at: [f64; 3],
source: &Text,
) {
// `indirectMagic(cloud, owner)` names the cloud in the death message
// (it gives no kill credit).
let cloud_owner = PotionOwner {
name: Some(source.clone()),
..Default::default()
};
// MobEffectInstance.withScaledDuration: floor(duration * scale), at least 1; an infinite
// effect stays infinite.
let scaled = |duration: i32| {
@@ -1322,7 +1349,7 @@ impl Game<'_> {
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
g.apply_indirect_effect_action(a, &cloud_owner);
}
} else {
let mut instance = EffectInstance::new(e.id, scaled(*duration), *amp);
@@ -1480,11 +1507,22 @@ impl Game<'_> {
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
/// potion or a cloud): indirectMagic, not plain magic, credited to the
/// mob that threw it when there is one.
fn apply_indirect_effect_action(&mut self, a: EffectAction, owner: &PotionOwner) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
match (owner.attacker, &owner.name) {
(Some(id), Some(name)) => {
self.note_named_attacker("minecraft:indirect_magic", id, name.clone())
}
_ => {
let name = owner.name.clone().unwrap_or_else(|| {
Text::translate("entity.minecraft.splash_potion", &[])
});
self.note_direct_entity("minecraft:indirect_magic", name);
}
}
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
@@ -1497,7 +1535,7 @@ impl Game<'_> {
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
age: u32,
source: &Text,
owner: &PotionOwner,
) {
if let Some(p) = self.player.as_ref() {
let w = f64::from(p.dimensions.width) / 2.0;
@@ -1515,7 +1553,7 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_indirect_effect_action(a, source);
self.apply_indirect_effect_action(a, owner);
}
} else {
let d = (intensity * f64::from(*duration) + 0.5) as i32;
@@ -1529,18 +1567,36 @@ impl Game<'_> {
}
}
}
/// `Projectile.getOwner` of a thrown potion, as [`splash`](Self::splash)
/// names it: a player's, a mob's (a witch keeps the kill credit), or
/// nobody's (a dispenser's, a summoned potion's).
pub(super) fn potion_owner(&self, owner: Option<Who>) -> PotionOwner {
match owner {
Some(Who::Player) => PotionOwner {
name: Some(self.player_display_text()),
attacker: None,
player: true,
},
Some(Who::Mob(id)) => PotionOwner {
name: self.mob_display_name(id),
attacker: Some(id),
player: false,
},
None => PotionOwner::default(),
}
}
/// `AbstractThrownPotion.onHit` with `ThrownSplashPotion.onHitAsPotion`, or
/// `ThrownLingeringPotion.onHitAsPotion` for a lingering potion, read from
/// the stack's `potion_contents`; `mob` is where it hit a mob, `hit` the
/// block and face it hit, `age` its age.
fn splash(
pub(super) fn splash(
&mut self,
stack: &ItemStack,
at: Vec3,
mob: Option<Vec3>,
hit: Option<([i32; 3], Direction)>,
age: u32,
owner: Option<Text>,
owner: &PotionOwner,
) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
@@ -1559,7 +1615,7 @@ impl Game<'_> {
if let Some((cell, face)) = hit {
self.potion_hit_block(stack, cell, face);
}
self.affect_entities_around(stack, at, owner.is_some());
self.affect_entities_around(stack, at, owner.player);
let lingering = &*stack.item == "minecraft:lingering_potion";
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
@@ -1623,7 +1679,7 @@ impl Game<'_> {
radius_per_tick: -3.0 / 600.0,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
source: owner.name.clone().unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
@@ -1633,11 +1689,7 @@ impl Game<'_> {
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, owner));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.stations().enchantments.entity_tags;
let mut hits = Vec::new();
+27 -11
View File
@@ -1712,7 +1712,17 @@ impl Game<'_> {
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
@@ -1902,6 +1912,9 @@ impl Game<'_> {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
@@ -2128,28 +2141,37 @@ impl Game<'_> {
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
self.forget_remote_columns(&mut gone);
}
/// Forgets the `gone` columns (and empties the set): their chunks (freed on a thread
/// of their own when there are many), their sections in the renderer and their block
/// entities, each store once for all of them.
fn forget_remote_columns(
&mut self,
gone: &mut horizon_client::fast_hash::FxHashSet<(i32, i32)>,
) {
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in &gone {
for &(x, z) in gone.iter() {
freed.extend(self.streaming.forget(&mut self.world, x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
}
horizon_client::chunk::free_columns(freed, gone.len());
// One pass for all of them.
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
gone.clear();
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
@@ -3023,15 +3045,9 @@ impl Game<'_> {
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ChunksBiomes(p) => self.remote_chunks_biomes(&p),
// `poll_remote` forgets a run of them at once.
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns([(x, z)]);
}
self.forget_remote_columns(&mut [(p.pos[0], p.pos[1])].into_iter().collect());
}
C::ChunkBatchFinished(_) => {
// ChunkBatchSizeCalculator's ceiling; meshing runs on workers.
@@ -918,11 +918,13 @@ mod tests {
#[test]
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99F
// (`getAirDrag`, a float widened to the double it scales by).
let drag = f64::from(0.99_f32);
let mut p = thrown(ProjectileKind::Egg);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
assert!((p.velocity.x - 1.5 * drag).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * drag)).abs() < 1e-9, "{p:?}");
// A wind charge keeps its speed: no gravity and no drag
// (AbstractWindCharge: acceleration 0, inertia 1).
let mut p = thrown(ProjectileKind::WindCharge);
@@ -140,6 +140,8 @@ pub(super) struct PackIds {
vignette: bool,
/// `oldLighting`: `true` keeps the game's face shading in the block colours.
old_lighting: bool,
/// `separateAo`: `true` passes AO factor in vertex alpha and keeps pure tint in RGB.
pub(super) separate_ao: bool,
}
impl Game<'_> {
@@ -158,6 +160,11 @@ impl Game<'_> {
self.streaming.shader_pack_lighting = pack_lighting;
self.all_sections_changed();
}
let separate_ao = self.pack_ids.as_ref().is_some_and(|ids| ids.separate_ao);
if self.streaming.separate_ao != separate_ao {
self.streaming.separate_ao = separate_ao;
self.all_sections_changed();
}
let picked = picked_pack(&self.settings).to_owned();
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
@@ -209,6 +216,10 @@ impl Game<'_> {
note.message
);
}
let separate_ao = properties
.get("separateAo")
.is_some_and(|v| v.trim() == "true");
renderer.set_separate_ao(separate_ao);
let folder = dimension_folder(&pack, dimension);
let inputs = renderer.pack_inputs();
let mut post = load_post(&pack, &folder, &properties, &macros, &inputs);
@@ -246,6 +257,7 @@ impl Game<'_> {
old_lighting: properties
.get("oldLighting")
.is_some_and(|v| v.trim() == "true"),
separate_ao,
entities: entities.plain(),
missing_entity: if text.is_some() { 65535 } else { 0 },
missing_block: if ids.is_empty() { 0 } else { 65535 },
+6 -4
View File
@@ -1471,7 +1471,9 @@ impl Menu {
Role::Plain if self.kind == Kind::Lectern && self.slots[s].container == MAIN => 1,
Role::Fuel if Self::is_bucket(stack) => return 1,
Role::Mod | Role::ModResult => self.mod_slot(s, stack).1,
_ => CONTAINER_MAX,
// `Container.getMaxStackSize` is vanilla's 99, which no data pack's
// item reaches; a native mod's item may, and its own size is the cap.
_ => u32::MAX,
};
own.min(Self::max_stack(env, stack))
}
@@ -1489,7 +1491,7 @@ impl Menu {
if (slot.container == MAIN || slot.role == Role::LoomResult)
&& let Some(st) = stack.as_mut()
{
st.count = st.count.min(CONTAINER_MAX.min(Self::max_stack(env, st)));
st.count = st.count.min(Self::max_stack(env, st));
// The beacon's container holds one item (getMaxStackSize 1).
if self.kind == Kind::Beacon {
st.count = st.count.min(1);
@@ -1873,7 +1875,7 @@ impl Menu {
inv[i].as_ref().is_some_and(|have| {
have.same_item_same_components(stack)
&& Self::stackable(env, have)
&& have.count < CONTAINER_MAX.min(Self::max_stack(env, have))
&& have.count < Self::max_stack(env, have)
})
})
}
@@ -1891,7 +1893,7 @@ impl Menu {
empty.count = 0;
empty
});
let room = CONTAINER_MAX.min(Self::max_stack(env, cell)) - cell.count;
let room = Self::max_stack(env, cell).saturating_sub(cell.count);
let add = stack.count.min(room);
cell.count += add;
tidy(&mut self.containers[PLAYER][slot]);
+1 -3
View File
@@ -580,9 +580,7 @@ impl Menu {
count: 0,
..stack.clone()
});
let room = super::CONTAINER_MAX
.min(Self::max_stack(env, cell))
.saturating_sub(cell.count);
let room = Self::max_stack(env, cell).saturating_sub(cell.count);
let add = stack.count.min(room);
cell.count += add;
stack.count -= add;
+12 -13
View File
@@ -204,30 +204,29 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
Who::Player => cx.w.player.as_ref().is_some_and(|p| p.has_effect(effect)),
Who::Mob(id) => cx.w.seen(id).is_some_and(|s| s.has_effect(effect)),
};
let (effect, ticks, amplifier) = if heals {
// The potion it throws: `PotionContents.createItemStack(Items.SPLASH_POTION,
// potion)` for the one `Witch.performRangedAttack` picks.
let potion = if heals {
m.ai.target = None;
if health <= 4.0 {
("minecraft:instant_health", 1, 0)
"minecraft:healing"
} else {
("minecraft:regeneration", 900, 0)
"minecraft:regeneration"
}
} else if dist >= 8.0 && !has("slowness") {
("minecraft:slowness", 1800, 0)
"minecraft:slowness"
} else if health >= 8.0 && !has("poison") {
("minecraft:poison", 900, 0)
"minecraft:poison"
} else if dist <= 3.0 && !has("weakness") && m.random.next_float() < 0.25 {
("minecraft:weakness", 1800, 0)
"minecraft:weakness"
} else {
("minecraft:instant_damage", 1, 0)
"minecraft:harming"
};
let start = Vec3::new(m.position.x, m.eye_y() - 0.1, m.position.z);
let mut p = Projectile::new(
ProjectileKind::Potion {
effect,
ticks,
amplifier,
color: 0,
},
ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new("minecraft:splash_potion", 1).with_potion(potion),
)),
start,
Some(Who::Mob(m.id)),
);
+7 -4
View File
@@ -16,10 +16,10 @@ pub use crate::first_person::Hand;
pub use crate::fog::{FogData, FogType};
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
pub use crate::game::{
NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeDataMapType,
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayerRender,
NativeShake,
ItemEnergyBuffer, NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks,
NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem,
NativeHit, NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent,
NativePlayerRender, NativeShake,
};
pub use crate::gfx::native_render::{
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
@@ -185,6 +185,9 @@ pub struct NativeScreen {
/// The mod draws the title and the "Inventory" label itself
/// (`native_screen_draw`); Horizon leaves them out.
pub own_labels: bool,
/// The mod draws its own background (`native_screen_draw`), so Horizon
/// leaves out the plain panel and the slot frames.
pub own_background: bool,
}
/// A native mod as Options > Mods lists it (`native_mods`).
+48 -5
View File
@@ -1199,11 +1199,12 @@ pub trait Hooks: Send + Sync {
/// with those of `horizon-client/src/game/native_hooks.rs`): `components` holds
/// `item`'s default components in `items.json`'s form (`"minecraft:max_stack_size": 16`,
/// `"minecraft:food": {"nutrition": 4, "saturation": 2.4}`); insert or replace one to
/// set it, remove it to take it away (a stack size outside 1 to 99 is brought back
/// into it). Asked for every item each time the game reads
/// the defaults, from any thread, several times as resources load and again when a
/// world's data packs load: answer the same each time. Client and server each ask
/// their own mods, as NeoForge does, so both sides need the mod.
/// set it, remove it to take it away (a stack size outside 1 to `i32::MAX` is
/// brought back into it, and one that is not a number leaves the data pack's
/// own). Asked for every item each time the game reads the defaults, from any
/// thread, several times as resources load and again when a world's data packs
/// load: answer the same each time. Client and server each ask their own mods,
/// as NeoForge does, so both sides need the mod.
/// 79.
///
/// Every mod gets `event.components` in order and may add, change or remove components.
@@ -2953,6 +2954,26 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Item `item` (`mod_id:name`) holds energy (`Capabilities.Energy.ITEM`):
/// set `buffer` when the item is yours, as `SimpleEnergyHandler` is built:
/// its capacity and the most one insert and one extract move. A capacity
/// under 1 leaves the buffer out, and the limits are clamped to
/// 0..=capacity; vanilla's items have none. Asked at each call of the item
/// energy helpers (`native_item_energy`, `native_item_energy_insert`,
/// `native_item_energy_extract`, in `native_item_energy.rs`). The amount
/// is kept in the stack's `minecraft:custom_data`, as the int
/// `horizon_energy`. API @API:item_energy_buffer@.
///
/// Return `Some` to set `buffer`; the first mod returning `Some` wins.
fn item_energy_buffer(
&self,
host: &dyn Host,
event: &mut ItemEnergyBufferEvent<'_>,
) -> Option<ItemEnergyBuffer> {
let _ = host;
let _ = event;
None
}
/// A dropped item entity was successfully added to the server level.
/// @API:native_item_spawn@.
fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) {
@@ -9803,6 +9824,28 @@ pub fn dispatch_item_crafted(
mod_hook.item_crafted(host, &mut event);
}
}
/// What [`Hooks::item_energy_buffer`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemEnergyBufferEvent<'a> {
pub item: &'a str,
}
pub fn dispatch_item_energy_buffer(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
item: &str,
buffer: &mut Option<ItemEnergyBuffer>,
) {
if buffer.is_some() {
return;
}
let mut event = ItemEnergyBufferEvent { item };
for mod_hook in mods {
if let Some(replacement) = mod_hook.item_energy_buffer(host, &mut event) {
*buffer = Some(replacement);
break;
}
}
}
/// What [`Hooks::item_spawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemSpawnEvent<'a> {
+41 -29
View File
@@ -105,13 +105,11 @@ pub enum ProjectileKind {
WitherSkull {
dangerous: bool,
},
/// Splash potion thrown by witches: effect id, duration.
Potion {
effect: &'static str,
ticks: i32,
amplifier: i32,
color: u32,
},
/// `ThrownSplashPotion` or `ThrownLingeringPotion`: the potion item it
/// flies as (`ThrowableItemProjectile.getItem`). What it splashes is that
/// stack's `potion_contents`, so a witch's potion and a summoned one are
/// the same thing; its entity type is the stack's item.
Potion(Box<crate::items::ItemStack>),
/// a content mod's type.
Thrown(&'static ThrownType),
}
@@ -137,7 +135,7 @@ impl ProjectileKind {
| Self::Egg
| Self::EnderPearl
| Self::LlamaSpit
| Self::Potion { .. }
| Self::Potion(_)
| Self::Thrown(_) => 0.25,
Self::SmallFireball
| Self::WindCharge
@@ -162,11 +160,29 @@ impl ProjectileKind {
Self::BreezeWindCharge => "minecraft:breeze_wind_charge",
Self::ShulkerBullet { .. } => "minecraft:shulker_bullet",
Self::WitherSkull { .. } => "minecraft:wither_skull",
Self::Potion { .. } => "minecraft:splash_potion",
// `ThrownSplashPotion`/`ThrownLingeringPotion`: the item names it.
Self::Potion(stack) => potion_entity_type(&stack.item),
Self::Thrown(t) => t.id,
}
}
}
/// The entity type a thrown potion's item names: `ThrownSplashPotion` for a
/// splash potion, `ThrownLingeringPotion` for a lingering one.
fn potion_entity_type(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
"minecraft:lingering_potion"
} else {
"minecraft:splash_potion"
}
}
/// `ThrownItemRenderer`'s sprite for a thrown potion.
fn potion_sprite(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
"item/lingering_potion"
} else {
"item/splash_potion"
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Projectile {
/// The entity id a server tracks it by (0 until it is tracked).
@@ -438,7 +454,7 @@ impl Projectile {
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
ProjectileKind::LlamaSpit => 0.06,
ProjectileKind::Potion { .. } => 0.05,
ProjectileKind::Potion(_) => 0.05,
_ => 0.03,
}
}
@@ -839,9 +855,11 @@ impl Projectile {
}
}
_ => {
// ThrowableProjectile: gravity, then inertia.
// ThrowableProjectile: gravity, then inertia. Its drag is a
// float in vanilla (0.8F in water, `getAirDrag` 0.99F), so
// widens to the double that scales the motion.
self.velocity.y -= self.gravity();
let inertia = if water { 0.8 } else { 0.99 };
let inertia = f64::from(if water { 0.8_f32 } else { 0.99_f32 });
self.velocity = Vec3::new(
self.velocity.x * inertia,
self.velocity.y * inertia,
@@ -945,31 +963,24 @@ impl Projectile {
damage as f32
}
/// the item a thrown potion or a mod's type is drawn as with its model
/// (`ThrownItemRenderer`): the splash potion `Witch.performRangedAttack`
/// makes for the effect, or the type's item.
/// (`ThrownItemRenderer`): the potion stack itself, or the type's item.
pub fn drawn_item(&self) -> Option<crate::items::ItemStack> {
let effect = match self.kind {
ProjectileKind::Potion { effect, .. } => effect,
ProjectileKind::Thrown(t) => return Some(crate::items::ItemStack::new(t.item, 1)),
_ => return None,
};
let potion = match effect {
"minecraft:instant_health" => "minecraft:healing",
"minecraft:instant_damage" => "minecraft:harming",
other => other,
};
Some(crate::items::ItemStack::new("minecraft:splash_potion", 1).with_potion(potion))
match &self.kind {
ProjectileKind::Potion(stack) => Some((**stack).clone()),
ProjectileKind::Thrown(t) => Some(crate::items::ItemStack::new(t.item, 1)),
_ => None,
}
}
/// The item sprite drawn for thrown projectiles (ThrownItemRenderer).
pub fn item_sprite(&self) -> Option<&'static str> {
Some(match self.kind {
Some(match &self.kind {
ProjectileKind::Snowball => "item/snowball",
ProjectileKind::Egg => "item/egg",
ProjectileKind::EnderPearl => "item/ender_pearl",
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
"item/fire_charge"
}
ProjectileKind::Potion { .. } => "item/splash_potion",
ProjectileKind::Potion(stack) => potion_sprite(&stack.item),
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
@@ -1016,8 +1027,9 @@ mod tests {
let w = Empty(vec![]);
p.tick(&w);
// ThrowableProjectile: gravity before inertia, then move.
// `getAirDrag` is 0.99F, widened to double: 0.9900000095367432.
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9);
assert!((p.position.x - 0.99).abs() < 1e-9);
assert!((p.position.x - f64::from(0.99_f32)).abs() < 1e-9);
}
/// A wall of blocks across x = 1.
struct Wall(Vec<(Who, Aabb)>);
@@ -1091,7 +1103,7 @@ mod tests {
let (keep, none) = through.tick_through(&world);
assert!(keep && none == Hit::None);
// Gravity and drag of the tick, then the whole of its motion.
assert!((through.position.x - 1.5 * 0.99).abs() < 1e-9);
assert!((through.position.x - 1.5 * f64::from(0.99_f32)).abs() < 1e-9);
}
#[test]
fn a_snowball_with_no_gravity_stays_where_it_was_summoned() {
@@ -217,6 +217,7 @@ fn relit_columns_keep_every_mesh_current() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(0),
blend_radius: 2,
smooth_lighting: true,
@@ -129,6 +129,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(0),
blend_radius: 2,
smooth_lighting: true,
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "horizon-mcp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The Model Context Protocol, one implementation for Horizon's three servers (horizon-ctl mcp, horizon-server mcp, horizon-launcher mcp)"
[dependencies]
serde_json = "1"
[lints]
workspace = true
+476
View File
@@ -0,0 +1,476 @@
//! The Model Context Protocol, as Horizon's three servers speak it (`horizon-ctl mcp`,
//! `horizon-server mcp`, `horizon-launcher mcp`: docs/agent-control.md, docs/server.md section 8):
//! JSON-RPC 2.0 over stdin and stdout, one message per line.
//!
//! Two revisions, newest first. `2026-07-28` is stateless: every request carries its version,
//! its capabilities and who it is in `_meta`, servers answer `server/discover`, every result
//! says `resultType`, and the list results carry `ttlMs` and `cacheScope`. `2025-06-18` opens
//! with `initialize` and is kept for the clients that still do that (the launcher's `game_*`
//! tools talk to whichever `horizon-ctl` version the player has installed, which may be older
//! than this crate). A request carrying `_meta` is served statelessly whatever the process
//! answered before it, as the revision requires.
//!
//! Each server supplies its identity, its instructions and the two tool methods; the protocol
//! methods (`server/discover`, `initialize`), the framing and the line loop live here, so the
//! three cannot drift apart.
use serde_json::{Map, Value, json};
use std::io::{BufRead, Write};
/// The newest revision: no handshake, every request carries `_meta`.
pub const MODERN: &str = "2026-07-28";
/// The handshake revision, still served for the clients that call `initialize`.
pub const LEGACY: &str = "2025-06-18";
/// Every revision served here, newest first: what `server/discover` advertises.
pub const VERSIONS: &[&str] = &[MODERN, LEGACY];
/// The revisions `initialize` may select: the stateless one has no handshake to open.
pub const LEGACY_VERSIONS: &[&str] = &[LEGACY];
/// `_meta` key: the protocol version the request uses (required on every modern request).
pub const VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
/// `_meta` key: the client's capabilities (required on every modern request).
pub const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
/// `_meta` key: who the server is, in every modern result.
pub const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
/// `UnsupportedProtocolVersion`: the request named a revision this server does not serve.
pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
/// `ttlMs` for `tools/list`: starting a game or a server adds tools to the launcher's list, and
/// no `notifications/tools/list_changed` follows it (that needs `subscriptions/listen`), so the
/// hint stays short rather than hiding the new tools until it expires.
pub const LIST_TTL_MS: i64 = 30_000;
/// `ttlMs` for `server/discover`: versions, capabilities and identity are fixed per process.
pub const DISCOVER_TTL_MS: i64 = 3_600_000;
/// Neither list holds anything specific to one caller.
pub const CACHE_SCOPE: &str = "public";
/// One server: who it says it is, and what an agent reads before its first call.
#[derive(Clone, Copy)]
pub struct Server<'a> {
pub name: &'a str,
pub version: &'a str,
pub instructions: &'a str,
}
/// A failed request: the JSON-RPC code, its message, and the `data` some errors carry.
struct Fault {
code: i64,
message: String,
data: Option<Value>,
}
impl Fault {
fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
fn with(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
impl From<(i64, String)> for Fault {
fn from((code, message): (i64, String)) -> Self {
Self::new(code, message)
}
}
/// `initialize` (the handshake revision): the client's version when it is one we serve,
/// otherwise ours. The client has no fall-forward, so never claim one we do not implement.
pub fn negotiate_legacy(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
LEGACY_VERSIONS
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(LEGACY_VERSIONS[0])
}
impl<'a> Server<'a> {
pub const fn new(name: &'a str, version: &'a str, instructions: &'a str) -> Self {
Server {
name,
version,
instructions,
}
}
/// Serve the process's stdin and stdout until the client closes them.
///
/// `app` answers the two tool methods (`tools/list` and `tools/call`); the protocol
/// methods and the framing are this module's.
pub fn serve<F>(&self, mut app: F) -> Result<(), String>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
if let Some(reply) = self.handle_line(&line, &mut app) {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// One line of stdin in, one line of stdout out; none for a notification.
pub fn handle_line<F>(&self, line: &str, app: &mut F) -> Option<Value>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
match serde_json::from_str::<Value>(line) {
Ok(message) => self.handle(&message, app),
Err(e) => Some(error(Value::Null, -32700, e.to_string(), None)),
}
}
fn handle<F>(&self, message: &Value, app: &mut F) -> Option<Value>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
// A request has an id, a notification has none and gets no reply.
let id = match message.get("id") {
None => return None,
Some(Value::Null) => {
return Some(error(
Value::Null,
-32600,
"A request's id is a string or an integer, not null".into(),
None,
));
}
Some(id) => id.clone(),
};
let method = message
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
Some(match self.dispatch(method, &params, app) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(Fault {
code,
message,
data,
}) => error(id, code, message, data),
})
}
fn dispatch<F>(&self, method: &str, params: &Value, app: &mut F) -> Result<Value, Fault>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
// `initialize` opens the handshake revision, whatever else the request carries.
if method == "initialize" {
return Ok(self.initialize(params));
}
// `server/discover` is always answered modern: it is how a client learns the versions
// this server serves, and the probe a client that speaks both revisions sends first.
if method == "server/discover" {
return Ok(self.discover());
}
// The revision comes from the request itself. Without `_meta` the client is on the
// handshake revision; with it, the version it names must be one we serve.
let version = match meta(params, VERSION_KEY) {
None => None,
Some(requested) => match VERSIONS.iter().copied().find(|v| *v == requested) {
Some(version) => Some(version),
None => {
return Err(Fault::new(
UNSUPPORTED_PROTOCOL_VERSION,
"Unsupported protocol version",
)
.with(json!({ "supported": VERSIONS, "requested": requested })));
}
},
};
// A modern request without the client's capabilities is malformed (-32602), and the
// server must not rely on capabilities the client did not declare.
if version == Some(MODERN) && meta_value(params, CLIENT_CAPABILITIES_KEY).is_none() {
return Err(Fault::new(
-32602,
format!("_meta.{CLIENT_CAPABILITIES_KEY} is required"),
));
}
let result = match method {
"tools/list" | "tools/call" => app(method, params).map_err(Fault::from)?,
// `ping` was removed in 2026-07-28: only the handshake revision still has it.
"ping" if version != Some(MODERN) => json!({}),
_ => return Err(Fault::new(-32601, format!("Method not found: {method}"))),
};
Ok(self.frame(version, method, result))
}
/// `initialize`: the handshake revision's answer, unchanged since 2025-06-18.
fn initialize(&self, params: &Value) -> Value {
json!({
"protocolVersion": negotiate_legacy(params),
"capabilities": self.capabilities(),
"serverInfo": self.info(),
"instructions": self.instructions,
})
}
/// `server/discover`: the versions, capabilities and identity of this server.
fn discover(&self) -> Value {
json!({
"resultType": "complete",
"supportedVersions": VERSIONS,
"capabilities": self.capabilities(),
"_meta": meta_object(self.info()),
"instructions": self.instructions,
"ttlMs": DISCOVER_TTL_MS,
"cacheScope": CACHE_SCOPE,
})
}
/// What every revision since 2026-07-28 puts around a result: its type, the cache hints on
/// the cacheable ones, and who answered. The handshake revision's results carry none of it.
fn frame(&self, version: Option<&'static str>, method: &str, mut result: Value) -> Value {
if version != Some(MODERN) {
return result;
}
let Some(object) = result.as_object_mut() else {
return result;
};
object.insert("resultType".into(), json!("complete"));
if method == "tools/list" {
object.insert("ttlMs".into(), json!(LIST_TTL_MS));
object.insert("cacheScope".into(), json!(CACHE_SCOPE));
}
let meta = object.entry("_meta").or_insert_with(|| json!({}));
if let Some(meta) = meta.as_object_mut() {
meta.insert(SERVER_INFO_KEY.into(), self.info());
}
result
}
fn info(&self) -> Value {
json!({ "name": self.name, "version": self.version })
}
/// Only tools: no resources, prompts, sampling or logging to declare.
fn capabilities(&self) -> Value {
json!({ "tools": {} })
}
}
/// A header of the request's `params`, whatever its type.
fn meta_value<'v>(params: &'v Value, key: &str) -> Option<&'v Value> {
params.get("_meta")?.get(key)
}
/// A header of the request's `params`, when it is a string.
fn meta<'v>(params: &'v Value, key: &str) -> Option<&'v str> {
meta_value(params, key)?.as_str()
}
/// A result's `_meta`: who answered.
fn meta_object(info: Value) -> Value {
let mut meta = Map::new();
meta.insert(SERVER_INFO_KEY.to_owned(), info);
Value::Object(meta)
}
fn error(id: Value, code: i64, message: String, data: Option<Value>) -> Value {
let mut error = json!({ "code": code, "message": message });
if let Some(data) = data {
error["data"] = data;
}
json!({ "jsonrpc": "2.0", "id": id, "error": error })
}
#[cfg(test)]
mod tests {
use super::*;
const SERVER: Server = Server::new("test-server", "1.2.3", "Do the thing.");
/// The answer to one request, with a tool list and a tool result the way a server answers them.
fn answer(request: Value) -> Value {
let mut app = |method: &str, _params: &Value| match method {
"tools/list" => Ok(json!({ "tools": [{ "name": "state" }] })),
"tools/call" => {
Ok(json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false }))
}
other => Err((-32601, format!("Method not found: {other}"))),
};
SERVER.handle_line(&request.to_string(), &mut app).unwrap()
}
/// A modern request's `params`: the version and the capabilities `_meta` must carry.
fn modern(mut params: Value, version: &str) -> Value {
let mut meta = Map::new();
meta.insert(VERSION_KEY.to_owned(), json!(version));
meta.insert(CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
params["_meta"] = Value::Object(meta);
params
}
fn request(id: i64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
#[test]
fn initialize_answers_a_version_we_serve() {
let reply = answer(request(
1,
"initialize",
json!({ "protocolVersion": "2025-06-18" }),
));
assert_eq!(reply["result"]["protocolVersion"], LEGACY);
assert_eq!(reply["result"]["serverInfo"]["name"], "test-server");
assert_eq!(reply["result"]["serverInfo"]["version"], "1.2.3");
assert_eq!(reply["result"]["instructions"], "Do the thing.");
assert_eq!(reply["result"]["capabilities"]["tools"], json!({}));
// The handshake revision's results carry no `resultType`.
assert!(reply["result"].get("resultType").is_none());
}
#[test]
fn initialize_never_claims_a_version_we_do_not_serve() {
for wanted in [
json!({ "protocolVersion": MODERN }),
json!({ "protocolVersion": "1999-01-01" }),
json!({ "protocolVersion": 7 }),
json!({}),
] {
let reply = answer(request(1, "initialize", wanted));
assert_eq!(reply["result"]["protocolVersion"], LEGACY);
}
}
#[test]
fn discover_lists_what_this_server_serves() {
// No `_meta`: the stdio probe a client that speaks both revisions sends first.
let reply = answer(request(1, "server/discover", json!({})));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["supportedVersions"], json!(VERSIONS));
assert_eq!(result["capabilities"]["tools"], json!({}));
assert_eq!(result["_meta"][SERVER_INFO_KEY]["name"], "test-server");
assert_eq!(result["instructions"], "Do the thing.");
assert_eq!(result["ttlMs"], json!(DISCOVER_TTL_MS));
assert_eq!(result["cacheScope"], CACHE_SCOPE);
}
#[test]
fn an_unsupported_version_is_an_error_that_lists_ours() {
let reply = answer(request(1, "tools/list", modern(json!({}), "1900-01-01")));
assert_eq!(
reply["error"]["code"],
json!(UNSUPPORTED_PROTOCOL_VERSION),
"{reply}"
);
assert_eq!(reply["error"]["data"]["requested"], "1900-01-01");
assert_eq!(reply["error"]["data"]["supported"], json!(VERSIONS));
}
#[test]
fn a_modern_request_without_capabilities_is_malformed() {
let mut params = json!({});
params["_meta"] = json!({ VERSION_KEY: MODERN });
let reply = answer(request(1, "tools/list", params));
assert_eq!(reply["error"]["code"], json!(-32602));
assert!(
reply["error"]["message"]
.as_str()
.unwrap()
.contains(CLIENT_CAPABILITIES_KEY)
);
}
#[test]
fn tools_list_is_cacheable_and_identified() {
let reply = answer(request(1, "tools/list", modern(json!({}), MODERN)));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["ttlMs"], json!(LIST_TTL_MS));
assert_eq!(result["cacheScope"], CACHE_SCOPE);
assert_eq!(result["_meta"][SERVER_INFO_KEY]["name"], "test-server");
assert_eq!(result["tools"][0]["name"], "state");
}
#[test]
fn tools_call_says_it_completed_but_is_not_cacheable() {
let reply = answer(request(
1,
"tools/call",
modern(json!({ "name": "state" }), MODERN),
));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["isError"], false);
assert_eq!(result["_meta"][SERVER_INFO_KEY]["version"], "1.2.3");
assert!(result.get("ttlMs").is_none(), "{result}");
}
#[test]
fn the_handshake_revisions_results_stay_as_they_were() {
// A legacy client sends no `_meta`, and its results carry no modern framing.
let list = answer(request(1, "tools/list", json!({})));
assert_eq!(list["result"], json!({ "tools": [{ "name": "state" }] }));
let call = answer(request(2, "tools/call", json!({ "name": "state" })));
assert_eq!(
call["result"],
json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false })
);
}
#[test]
fn ping_belongs_to_the_handshake_revision_only() {
assert_eq!(answer(request(1, "ping", json!({})))["result"], json!({}));
assert_eq!(
answer(request(1, "ping", modern(json!({}), MODERN)))["error"]["code"],
json!(-32601)
);
}
#[test]
fn unknown_methods_and_notifications() {
let reply = answer(request(1, "nope/nope", json!({})));
assert_eq!(reply["error"]["code"], json!(-32601));
// A notification (no id) gets no reply at all.
let mut app = |_: &str, _: &Value| Ok(json!({}));
let note = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
assert!(SERVER.handle_line(&note.to_string(), &mut app).is_none());
}
#[test]
fn a_request_whose_id_is_null_is_invalid() {
let mut app = |_: &str, _: &Value| Ok(json!({}));
let line = json!({ "jsonrpc": "2.0", "id": null, "method": "tools/list" });
let reply = SERVER.handle_line(&line.to_string(), &mut app).unwrap();
assert_eq!(reply["error"]["code"], json!(-32600));
}
#[test]
fn a_broken_line_is_a_parse_error() {
let mut app = |_: &str, _: &Value| Ok(json!({}));
let reply = SERVER.handle_line("{not json", &mut app).unwrap();
assert_eq!(reply["error"]["code"], json!(-32700));
assert_eq!(reply["id"], Value::Null);
}
#[test]
fn an_app_error_stays_a_json_rpc_error() {
let mut app = |_: &str, _: &Value| Err((-32602, "name is required".to_string()));
let reply = SERVER
.handle_line(&request(1, "tools/call", json!({})).to_string(), &mut app)
.unwrap();
assert_eq!(reply["error"]["code"], json!(-32602));
assert_eq!(reply["error"]["message"], "name is required");
}
}
+2
View File
@@ -8,6 +8,8 @@ description = "Host a Horizon server: server.toml, the CLI and the supervisor th
[dependencies]
horizon-server-files = { path = "../horizon-server-files" }
# `horizon-server mcp`: the Model Context Protocol, shared with horizon-ctl and the launcher.
horizon-mcp = { path = "../horizon-mcp" }
# server.toml's [anticheat], which the game reads: checked here before every start.
horizon-anticheat = { path = "../horizon-anticheat" }
# server.toml's [chat_filter], which the game reads too: checked here before every start.
+37 -74
View File
@@ -5,23 +5,15 @@
use crate::api::Client;
use serde_json::{Map, Value, json};
use std::{
io::{BufRead, Write},
path::Path,
};
use std::path::Path;
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Run and moderate a Horizon (Minecraft Java Edition 26.3) server. Start with status and players. Moderation tools (kick, ban, pardon, ban_ip, op, whitelist...) run vanilla's console commands and answer `changed` and the console's lines; events and logs read what happened since a number they return. Destructive tools that can't be undone need confirm: true.";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-server", crate::VERSION, INSTRUCTIONS)
}
/// One tool: its name, what it does, its arguments (name, JSON type, what it is, required), and whether it
@@ -397,15 +389,10 @@ fn call(dir: &Path, name: &str, args: &Value) -> (Value, bool) {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(dir: &Path, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-server", "version": crate::VERSION },
"instructions": "Run and moderate a Horizon (Minecraft Java Edition 26.3) server. Start with status and players. Moderation tools (kick, ban, pardon, ban_ip, op, whitelist...) run vanilla's console commands and answer `changed` and the console's lines; events and logs read what happened since a number they return. Destructive tools that can't be undone need confirm: true.",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -425,51 +412,13 @@ fn handle(dir: &Path, method: &str, params: &Value) -> Result<Value, (i64, Strin
}
pub fn serve(dir: &Path) -> Result<(), String> {
let mut stdout = std::io::stdout();
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(message) => {
let method = message
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = message.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
message.get("id").cloned().map(|id| match handle(dir, method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|()| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| handle(dir, method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn every_tool_has_a_schema_and_a_call() {
@@ -523,20 +472,34 @@ mod tests {
fn the_protocol_answers_without_a_server() {
let dir = std::env::temp_dir().join(format!("horizon-server-mcp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let init = handle(
&dir,
"initialize",
&json!({ "protocolVersion": "2025-06-18" }),
)
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(
handle(&dir, "tools/list", &json!({})).unwrap()["tools"]
.as_array()
.unwrap()
.len()
> 20
// A client that opens with the handshake revision, as the launcher's game_* tools do.
let mut app = |method: &str, params: &Value| handle(&dir, method, params);
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
let init = server()
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-server");
// A modern client gets the same tools with the framing that revision requires.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let list = server()
.handle_line(
&line("tools/list", json!({ "_meta": Value::Object(meta) })),
&mut app,
)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 20);
let reply = handle(
&dir,
"tools/call",
@@ -1841,6 +1841,20 @@ mod tests {
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// A non-void function whose only `return` has unreachable code after it and no trailing
/// return at the end of the block (which naga would otherwise end with `return;` without a value).
#[test]
fn early_return_without_trailing_return_translates() {
let fsh = "#version 120\n\
vec3 exposure(vec3 color) {\n return color * 3.5;\n int x = 1;\n}\n\
void main() { gl_FragColor = vec4(exposure(vec3(1.0)), 1.0); }\n";
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// GLSL's `dot` and `normalize` take a `float` too (genType); naga's take vectors only. A
/// pack's own `dot` is kept.
#[test]
+104 -16
View File
@@ -9,15 +9,30 @@
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fs, io, path::Path};
/// The largest stack size a native mod may set: `ItemStack.count` is a `u32`,
/// but the count is a VarInt on the wire and is saved as an `int`, so a
/// larger one would turn negative (and lose its item) on the way out.
pub const MAX_STACK_SIZE: i64 = i32::MAX as i64;
/// Vanilla's `ExtraCodecs.intRange(1, 99)` on a data pack's own value.
const VANILLA_MAX_STACK_SIZE: i64 = 99;
const STACK_SIZE: &str = "minecraft:max_stack_size";
/// `value` as a stack size in `1..=max`, or None when it is not a number.
fn stack_size(value: &Value, max: i64) -> Option<i64> {
value.as_f64().map(|n| (n as i64).clamp(1, max))
}
/// Hook point for native mods (docs/native-mods.md, listed in `native-hooks.json`
/// with those of `horizon-client/src/game/native_hooks.rs`): `components` holds
/// `item`'s default components in `items.json`'s form (`"minecraft:max_stack_size": 16`,
/// `"minecraft:food": {"nutrition": 4, "saturation": 2.4}`); insert or replace one to
/// set it, remove it to take it away (a stack size outside 1 to 99 is brought back
/// into it). Asked for every item each time the game reads
/// the defaults, from any thread, several times as resources load and again when a
/// world's data packs load: answer the same each time. Client and server each ask
/// their own mods, as NeoForge does, so both sides need the mod.
/// set it, remove it to take it away (a stack size outside 1 to `i32::MAX` is
/// brought back into it, and one that is not a number leaves the data pack's
/// own). Asked for every item each time the game reads the defaults, from any
/// thread, several times as resources load and again when a world's data packs
/// load: answer the same each time. Client and server each ask their own mods,
/// as NeoForge does, so both sides need the mod.
/// 79.
#[inline(never)]
pub fn native_modify_default_components(item: &str, components: &mut Map<String, Value>) {
@@ -25,21 +40,43 @@ pub fn native_modify_default_components(item: &str, components: &mut Map<String,
}
/// Every item's default components at `root`, by item id, with the mods'
/// changes applied and the stack size kept to vanilla's 1 to 99
/// (`ExtraCodecs.intRange`).
/// changes applied. A data pack's own stack size is kept to vanilla's 1 to 99
/// (`ExtraCodecs.intRange`); a mod's, which no codec decodes, to 1 to
/// [`MAX_STACK_SIZE`], the count the wire and the save carry.
pub fn read(root: &Path) -> io::Result<BTreeMap<String, Value>> {
let mut items: BTreeMap<String, Value> =
serde_json::from_slice(&fs::read(root.join("items.json"))?)?;
for (item, components) in &mut items {
if let Value::Object(components) = components {
native_modify_default_components(item, components);
// ExtraCodecs.intRange(1, 99): nothing decodes a mod's change, and a
// size of 0 would break stack arithmetic.
// A data pack's own value goes through vanilla's codec.
if let Some(size) = components
.get_mut("minecraft:max_stack_size")
.filter(|size| size.is_number())
.get(STACK_SIZE)
.and_then(|size| stack_size(size, VANILLA_MAX_STACK_SIZE))
{
*size = size.as_f64().map_or(64, |n| (n as i64).clamp(1, 99)).into();
components.insert(STACK_SIZE.into(), size.into());
}
let pack = components.get(STACK_SIZE).cloned();
native_modify_default_components(item, components);
// The hook bypasses the codec, and a size of 0 would break stack
// arithmetic; keep whatever it leaves a number every reader can
// use. A mod that takes the component away keeps it away, and a
// value that is not a number falls back to the data pack's (each
// table would otherwise read its own default from it).
match components.get(STACK_SIZE).cloned() {
None => {}
Some(size) => match stack_size(&size, MAX_STACK_SIZE) {
Some(size) => {
components.insert(STACK_SIZE.into(), size.into());
}
None => match pack {
Some(pack) => {
components.insert(STACK_SIZE.into(), pack);
}
None => {
components.remove(STACK_SIZE);
}
},
},
}
}
}
@@ -50,10 +87,11 @@ pub fn read(root: &Path) -> io::Result<BTreeMap<String, Value>> {
mod tests {
use std::fs;
/// A stack size outside vanilla's codec range is kept to 1..=99 for every
/// reader, so stack arithmetic never sees a 0.
/// A data pack's stack size is kept to vanilla's 1..=99, so stack
/// arithmetic never sees a 0, and a mod may raise it above 99 (up to the
/// count the wire and the save carry).
#[test]
fn stack_sizes_stay_in_vanilla_range() {
fn data_pack_sizes_stay_in_vanilla_range() {
let root = std::env::temp_dir().join(format!("horizon-stack-sizes-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let items = r#"{"a:none": {"minecraft:max_stack_size": 0},
@@ -75,4 +113,54 @@ mod tests {
]
);
}
/// A mod's own size is kept to 1..=i32::MAX, a value that is not a number
/// leaves the data pack's, and a removed component stays removed.
#[test]
fn mod_sizes_are_sanitised() {
let _ = crate::native_load_hooks::install(crate::native_load_hooks::NativeLoadHooks {
loot_table_load: |_, _| {},
modify_default_components: |item, components| match item {
"a:raised" => {
components.insert(super::STACK_SIZE.into(), 500.into());
}
"a:zero" => {
components.insert(super::STACK_SIZE.into(), 0.into());
}
"a:huge" => {
components.insert(super::STACK_SIZE.into(), u64::MAX.into());
}
"a:string" => {
components.insert(super::STACK_SIZE.into(), "500".into());
}
"a:removed" => {
components.remove(super::STACK_SIZE);
}
_ => {}
},
loot_function: |_, _, _, _| {},
loot_condition: |_, _, _, _| {},
});
let root = std::env::temp_dir().join(format!("horizon-mod-sizes-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let items = r#"{"a:raised": {"minecraft:max_stack_size": 64},
"a:zero": {"minecraft:max_stack_size": 16},
"a:huge": {"minecraft:max_stack_size": 64},
"a:string": {"minecraft:max_stack_size": 16},
"a:removed": {"minecraft:max_stack_size": 64}}"#;
fs::write(root.join("items.json"), items).unwrap();
let items = super::read(&root).unwrap();
fs::remove_dir_all(&root).unwrap();
let size = |id: &str| items[id].get("minecraft:max_stack_size").cloned();
assert_eq!(
["a:raised", "a:zero", "a:huge", "a:string", "a:removed"].map(size),
[
Some(500.into()),
Some(1.into()),
Some(super::MAX_STACK_SIZE.into()),
Some(16.into()),
None
]
);
}
}
+22
View File
@@ -50,6 +50,28 @@ agent's client needs one entry and one approval. The version that plays is alway
launcher selected, so the game's tools always match the game. `horizon-ctl mcp` stays available
on its own, for a game started by hand or by a script.
## Protocol revisions
All three servers share one implementation, `crates/horizon-mcp`, so they advertise and answer
the same revisions. There are two of them.
- `2026-07-28` is stateless: there is no handshake, and every request carries its version and
its capabilities in `_meta` (`io.modelcontextprotocol/protocolVersion`,
`io.modelcontextprotocol/clientCapabilities`). Every result says `resultType: "complete"`,
`tools/list` carries `ttlMs` and `cacheScope`, and each result names the server in
`_meta["io.modelcontextprotocol/serverInfo"]`. `server/discover` reports the versions,
capabilities and identity. `ping` no longer exists in this revision.
- `2025-06-18` is the handshake revision, kept because the launcher's `game_*` tools talk to
whichever `horizon-ctl` the player has installed: an older one does not know the stateless
revision. A request with no `_meta` is answered in that revision's shapes, and `initialize`
still negotiates it.
A server that serves both eras decides from the request itself, not from what came before it.
A version the server does not serve is refused with `UnsupportedProtocolVersionError` (`-32022`)
listing the ones it does, and a modern request missing its capabilities is malformed (`-32602`).
A client that speaks both probes with `server/discover` first on stdio, then falls back to
`initialize` when the answer is not a modern one.
## Inventory, before and after
Before is `main` at 2026-10-23, before #1342, #1343, #1344, #1345 and #1353. After is with
+11
View File
@@ -44,6 +44,17 @@ Remove an installed version.
MCP: `remove`, needs `confirm: true`.
## `horizon-launcher import-build`
Install a game program you have, such as a horizon-client built on your fork's Actions, as its own version on an installed release or Nightly's game files, and select it. Nothing signed it: it runs with your rights, and updates never replace it.
horizon-launcher import-build <PATH>
- `PATH`: The program (horizon-client, horizon-client.exe on Windows), or a zip holding it
- `--trust-unsigned`: You picked this build yourself and trust it with full access to this computer
MCP: none: the player's switch.
## `horizon-launcher prepare`
Prepare a version's resources from your own Minecraft install, or from Mojang.
+33 -3
View File
@@ -345,6 +345,33 @@ a version. Helpers (`native_text`, `native_player`…) have no hook entry; the c
the generated `Host` offers, and they are kept stable as the hook points are. A test (`catalog_matches_hook_points`) keeps the
catalog and the hook points' sources (`native_hooks.rs`, `gfx/native_render.rs`, `horizon-world`'s `loot.rs`) in step.
### The build's warnings
Before it builds, the launcher reads the mods' `[[injection]]` lists and says three things in `native/build.log` that
horizon-mixins only implies by its order. Two mods injecting at one place — the same function, kind, selector and
ordinal; the places are the manifest's, horizon-mixins is the one that resolves a selector against the source:
```
warning: mods beta, alpha both inject at hook `frame` HEAD; beta runs first (priority descending, mod id ascending).
```
Two mods wrapping one function at its head and its return, which run in that order whatever their priorities:
```
warning: mods alpha (HEAD), beta (RETURN) inject into hook `tick`: HEAD runs on entry, RETURN on return, whatever their priorities.
```
And a `cancellable = true` `HEAD` on a hook point:
```
warning: alpha's `stop` can cancel at hook `frame` HEAD: a Break skips the later injections and the function's body.
```
A raw `target` is not matched against the catalog — whether it names a hook point's function only the source knows —
so the third warning names a `hook`, and `hook = "frame"` and `target = "…::native_frame"` are not told apart. Only
`HEAD` is named: the interior kinds may carry `cancellable` too, but what a `Break` skips there is less than the body.
A mod's `priority` defaults to 0, as horizon-mixins defaults it. Neither warning stops the build.
### The mod's crate
A `[[crate]]` is a Cargo package of the bundle's, staged as `native-crates/<mod>/<crate>/` and added to the
@@ -721,7 +748,8 @@ API 79 adds `native_modify_default_components(item, components)`, in `horizon-wo
components in `items.json`'s form, for any item, vanilla's included (NeoForge's `ModifyDefaultComponentsEvent`). Insert
or replace a component to set it (`"minecraft:max_stack_size": 16`), remove it to take it away. The item table,
loot, enchanting and item models all read the changed defaults, and stacks are saved and sent against them as
against vanilla's. Asked for every item each time the game reads the defaults: answer the same each time. Client and
against vanilla's. A `minecraft:max_stack_size` above vanilla's 99 is kept, up to `i32::MAX`. Asked for every item
each time the game reads the defaults: answer the same each time. Client and
server each ask their own mods, so both need the mod. Bundles that use it need `"hooks": 79`.
API 80 adds the open screen hears of a mouse button going down (`native_screen_mouse_button_pressed_pre`) or up (`native_screen_mouse_button_released_pre`) before it acts, as vanilla's `ScreenEvent.MouseButtonPressed.Pre` and `.MouseButtonReleased.Pre`: the screen, the GLFW button (0 left, 1 right, 2 middle, 3 back, 4 forward; a button past those is the platform's own number, which differs between systems) and GLFW's modifier bits (shift 1, control 2, alt 4, super 8), with the pointer in `screen.mouse`. A cancelled press goes no further (no slot, screen button, drag or hotbar key); cancel a release only when its press was cancelled, or a drag it ended stays held. They fire on the window only, never on the title screen itself (Options and the pack list opened from it do fire), the connect and loading screens, the prompts, books and sign editors drawn over gameplay, or while a world loads or saves, and never for a gamepad or keyboard-focus click. Bundles need "hooks": 80.
@@ -2001,8 +2029,10 @@ bundle targets as `horizon_world::item_defaults::native_modify_default_component
insert or replace a key to set a component (`"minecraft:max_stack_size": 16`), remove it to take the component away.
Any item can be changed, vanilla's included. Write values as vanilla's codec writes them: nothing decodes them with it.
Each table reads the fields it understands and takes a default for a field it can't read, but keeps the component, so a
malformed `minecraft:food` still makes the item edible, with no nutrition. Only the stack size is kept to vanilla's
1 to 99.
malformed `minecraft:food` still makes the item edible, with no nutrition. A stack size above vanilla's 99 is kept: the
data pack's own is held to `ExtraCodecs.intRange(1, 99)`, a mod's to 1 to `i32::MAX` (the count the wire and the save
carry), and a value that is not a number leaves the data pack's own. Keep a stack above 99 to a server whose clients run
the mod: a vanilla client decodes the count with vanilla's 1 to 99 and cannot show it.
- Every table that reads items' defaults reads them through it: the item table, loot, enchanting, item models,
banners, pot sherds, item rarity in chat. Stacks are saved and sent as their difference from the changed defaults, as from vanilla's.
+8
View File
@@ -2684,6 +2684,7 @@ dependencies = [
"horizon-bench",
"horizon-core",
"horizon-friends",
"horizon-mcp",
"horizon-mods",
"horizon-port-pack",
"horizon-prepare",
@@ -2718,6 +2719,13 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
+2
View File
@@ -37,6 +37,8 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
horizon-theme = { path = "../crates/horizon-theme" }
horizon-core = { path = "../crates/horizon-core" }
# `horizon-launcher mcp`: the Model Context Protocol, shared with horizon-ctl and horizon-server.
horizon-mcp = { path = "../crates/horizon-mcp" }
horizon-prepare = { path = "../crates/horizon-prepare" }
# Join a friend: invite codes (the game does the joining).
horizon-friends = { path = "../crates/horizon-friends" }
+7 -2
View File
@@ -15,7 +15,10 @@ The opening, then Play ([clip](docs/opening.webm)):
- **Versions**: lists the published builds of horizon-rust/horizon (the current Nightly, the last
week's, then the releases from before), installs the build for this platform into its own
folder, and removes it again.
folder, and removes it again. **Import a game build…** takes a `horizon-client` you have (one
built on your fork's Actions, or the zip holding it) and installs it as `imported-<date>-<sha>`:
a copy of the selected release or Nightly's folder, game files included, with that program as the
game. Imported builds are listed apart as unsigned, and no update replaces them.
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
26.3 server jar from that version's own json, and Java 25) and copies its sounds from your
launcher's asset store (`horizon-prepare sounds`; a launcher install without them only
@@ -229,7 +232,8 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
- **Updates**: Horizon ships only the Nightly (since 2026-10-28, docs/release-flow.md). The
launcher installs the current one (the `nightly` pre-release, checksums signed for the Nightly
channel) as `nightly-<date>-<commit>` and selects it when the player was on a Nightly, on the
newest release or on nothing; a version picked by hand or pinned by an instance stays. Older
newest release or on nothing; a version picked by hand or pinned by an instance stays, and so
does an imported build. Older
Nightlies are removed once nothing plays them. Worlds are never touched: they live in the game's
folder, not in a version's. The last week's Nightlies (`nightly-<date>`), the `vX.Y.Z`
releases and the old Alpha pre-releases stay on the Versions page to install by hand, never as
@@ -333,6 +337,7 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher versions [--installed] releases on GitHub, or what is installed (* = selected)
horizon-launcher install <tag|latest>
horizon-launcher remove <tag>
horizon-launcher import-build <path> [--version <tag>] --trust-unsigned a game program you have, as its own version
horizon-launcher prepare [--minecraft <dir>] [--version <tag>]
horizon-launcher play [--version <tag>] [--instance <name>] [--arg <ARG>]... start the client; --arg passes an extra argument through (repeatable)
horizon-launcher server [--dir <dir>] [--version <tag>] [--accept-eula]
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} ist kein Ordner, in den gebaut wird.",
"Could not copy the build: {error}": "Build konnte nicht kopiert werden: {error}",
"Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Native Mods können dein Mikrofon für Sprachchat verwenden, wenn du es aktivierst."
"Native mods can use your microphone for voice chat when you enable it.": "Native Mods können dein Mikrofon für Sprachchat verwenden, wenn du es aktivierst.",
"Import a game build…": "Spiel-Build importieren…",
"Importing…": "Wird importiert…",
"Imported builds": "Importierte Builds",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Unsigniert: Du hast diese Programme selbst ausgewählt, und Horizon kann nicht prüfen, woher sie stammen. Updates ersetzen sie nie.",
"Game files from {base}": "Spieldateien von {base}",
"Imported · {date}": "Importiert · {date}",
"{file} is not a Horizon game for this computer.": "{file} ist kein Horizon-Spiel für diesen Computer.",
"Install a release or Nightly first: an imported build plays with its game files.": "Installiere zuerst ein Release oder eine Nightly: Ein importierter Build nutzt deren Spieldateien.",
"{file} holds no {program}.": "{file} enthält kein {program}.",
"Could not import the build: {error}": "Der Build konnte nicht importiert werden: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} no es una carpeta para compilar.",
"Could not copy the build: {error}": "No se pudo copiar la compilación: {error}",
"Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Los mods nativos pueden usar tu micrófono para el chat de voz cuando lo actives."
"Native mods can use your microphone for voice chat when you enable it.": "Los mods nativos pueden usar tu micrófono para el chat de voz cuando lo actives.",
"Import a game build…": "Importar una compilación del juego…",
"Importing…": "Importando…",
"Imported builds": "Compilaciones importadas",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Sin firmar: elegiste estos programas tú mismo, y Horizon no puede comprobar de dónde vienen. Las actualizaciones nunca los reemplazan.",
"Game files from {base}": "Archivos del juego de {base}",
"Imported · {date}": "Importada · {date}",
"{file} is not a Horizon game for this computer.": "{file} no es un juego de Horizon para este ordenador.",
"Install a release or Nightly first: an imported build plays with its game files.": "Instala primero una versión o una Nightly: una compilación importada usa sus archivos del juego.",
"{file} holds no {program}.": "{file} no contiene ningún {program}.",
"Could not import the build: {error}": "No se pudo importar la compilación: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} n'est pas un dossier dans lequel compiler.",
"Could not copy the build: {error}": "Impossible de copier la compilation : {error}",
"Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Les mods natifs peuvent utiliser votre micro pour le chat vocal lorsque vous l'activez."
"Native mods can use your microphone for voice chat when you enable it.": "Les mods natifs peuvent utiliser votre micro pour le chat vocal lorsque vous l'activez.",
"Import a game build…": "Importer une compilation du jeu…",
"Importing…": "Importation…",
"Imported builds": "Compilations importées",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Non signées : vous avez choisi ces programmes vous-même, et Horizon ne peut pas vérifier d'où ils viennent. Les mises à jour ne les remplacent jamais.",
"Game files from {base}": "Fichiers du jeu de {base}",
"Imported · {date}": "Importée · {date}",
"{file} is not a Horizon game for this computer.": "{file} n'est pas un jeu Horizon pour cet ordinateur.",
"Install a release or Nightly first: an imported build plays with its game files.": "Installez d'abord une version ou une Nightly : une compilation importée utilise les fichiers du jeu de celle-ci.",
"{file} holds no {program}.": "{file} ne contient aucun {program}.",
"Could not import the build: {error}": "Impossible d'importer la compilation : {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} はビルド先のフォルダーではありません。",
"Could not copy the build: {error}": "ビルドをコピーできませんでした: {error}",
"Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "ネイティブモッドは、有効にした場合、音声チャットにマイクを使用できます。"
"Native mods can use your microphone for voice chat when you enable it.": "ネイティブモッドは、有効にした場合、音声チャットにマイクを使用できます。",
"Import a game build…": "ゲームのビルドをインポート…",
"Importing…": "インポート中…",
"Imported builds": "インポートしたビルド",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "署名なし: これらのプログラムはあなた自身が選んだもので、Horizon は入手元を確認できません。アップデートで置き換えられることはありません。",
"Game files from {base}": "{base} のゲームファイル",
"Imported · {date}": "インポート · {date}",
"{file} is not a Horizon game for this computer.": "{file} はこのコンピューター用の Horizon のゲームではありません。",
"Install a release or Nightly first: an imported build plays with its game files.": "先にリリースか Nightly をインストールしてください。インポートしたビルドはそのゲームファイルを使います。",
"{file} holds no {program}.": "{file} に {program} がありません。",
"Could not import the build: {error}": "ビルドをインポートできませんでした: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} nie jest folderem do kompilacji.",
"Could not copy the build: {error}": "Nie można skopiować kompilacji: {error}",
"Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Native mody mogą użyć Twojego mikrofonu do czatu głosowego, gdy to włączysz."
"Native mods can use your microphone for voice chat when you enable it.": "Native mody mogą użyć Twojego mikrofonu do czatu głosowego, gdy to włączysz.",
"Import a game build…": "Importuj kompilację gry…",
"Importing…": "Importowanie…",
"Imported builds": "Zaimportowane kompilacje",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Niepodpisane: te programy wybrano samodzielnie i Horizon nie może sprawdzić, skąd pochodzą. Aktualizacje nigdy ich nie zastępują.",
"Game files from {base}": "Pliki gry z {base}",
"Imported · {date}": "Zaimportowano · {date}",
"{file} is not a Horizon game for this computer.": "{file} nie jest grą Horizon dla tego komputera.",
"Install a release or Nightly first: an imported build plays with its game files.": "Najpierw zainstaluj wydanie lub Nightly: zaimportowana kompilacja korzysta z jego plików gry.",
"{file} holds no {program}.": "{file} nie zawiera {program}.",
"Could not import the build: {error}": "Nie udało się zaimportować kompilacji: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} não é uma pasta para criação de build.",
"Could not copy the build: {error}": "Não foi possível copiar o build: {error}",
"Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Mods nativos podem usar seu microfone para bate-papo por voz quando você ativar isso."
"Native mods can use your microphone for voice chat when you enable it.": "Mods nativos podem usar seu microfone para bate-papo por voz quando você ativar isso.",
"Import a game build…": "Importar uma compilação do jogo…",
"Importing…": "Importando…",
"Imported builds": "Compilações importadas",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Sem assinatura: você mesmo escolheu esses programas, e o Horizon não consegue verificar de onde eles vieram. As atualizações nunca os substituem.",
"Game files from {base}": "Arquivos do jogo de {base}",
"Imported · {date}": "Importada · {date}",
"{file} is not a Horizon game for this computer.": "{file} não é um jogo do Horizon para este computador.",
"Install a release or Nightly first: an imported build plays with its game files.": "Instale primeiro uma versão ou uma Nightly: uma compilação importada usa os arquivos do jogo dela.",
"{file} holds no {program}.": "{file} não contém nenhum {program}.",
"Could not import the build: {error}": "Não foi possível importar a compilação: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} не является папкой для сборки.",
"Could not copy the build: {error}": "Не удалось скопировать сборку: {error}",
"Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Нативные моды могут использовать ваш микрофон для голосового чата, когда вы это включите."
"Native mods can use your microphone for voice chat when you enable it.": "Нативные моды могут использовать ваш микрофон для голосового чата, когда вы это включите.",
"Import a game build…": "Импортировать сборку игры…",
"Importing…": "Импорт…",
"Imported builds": "Импортированные сборки",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Без подписи: вы сами выбрали эти программы, и Horizon не может проверить, откуда они. Обновления никогда их не заменяют.",
"Game files from {base}": "Файлы игры из {base}",
"Imported · {date}": "Импортировано · {date}",
"{file} is not a Horizon game for this computer.": "{file} — не игра Horizon для этого компьютера.",
"Install a release or Nightly first: an imported build plays with its game files.": "Сначала установите релиз или Nightly: импортированная сборка использует его файлы игры.",
"{file} holds no {program}.": "В {file} нет {program}.",
"Could not import the build: {error}": "Не удалось импортировать сборку: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} derleme yapılacak klasör değil.",
"Could not copy the build: {error}": "Build kopyalanamadı: {error}",
"Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Yerel modlar, bunu etkinleştirdiğinizde sesli sohbet için mikrofonunuzu kullanabilir."
"Native mods can use your microphone for voice chat when you enable it.": "Yerel modlar, bunu etkinleştirdiğinizde sesli sohbet için mikrofonunuzu kullanabilir.",
"Import a game build…": "Oyun derlemesini içe aktar…",
"Importing…": "İçe aktarılıyor…",
"Imported builds": "İçe aktarılan derlemeler",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "İmzasız: bu programları kendiniz seçtiniz ve Horizon nereden geldiklerini doğrulayamaz. Güncellemeler bunların yerini asla almaz.",
"Game files from {base}": "Oyun dosyaları: {base}",
"Imported · {date}": "İçe aktarıldı · {date}",
"{file} is not a Horizon game for this computer.": "{file}, bu bilgisayar için bir Horizon oyunu değil.",
"Install a release or Nightly first: an imported build plays with its game files.": "Önce bir sürüm veya Nightly yükleyin: içe aktarılan derleme onun oyun dosyalarını kullanır.",
"{file} holds no {program}.": "{file} içinde {program} yok.",
"Could not import the build: {error}": "Derleme içe aktarılamadı: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} не є папкою для збірки.",
"Could not copy the build: {error}": "Не вдалося скопіювати збірку: {error}",
"Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}",
"Native mods can use your microphone for voice chat when you enable it.": "Нативні моди можуть використовувати ваш мікрофон для голосового чату, коли ви це увімкнете."
"Native mods can use your microphone for voice chat when you enable it.": "Нативні моди можуть використовувати ваш мікрофон для голосового чату, коли ви це увімкнете.",
"Import a game build…": "Імпортувати збірку гри…",
"Importing…": "Імпорт…",
"Imported builds": "Імпортовані збірки",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Без підпису: ви самі вибрали ці програми, і Horizon не може перевірити, звідки вони. Оновлення ніколи їх не замінюють.",
"Game files from {base}": "Файли гри з {base}",
"Imported · {date}": "Імпортовано · {date}",
"{file} is not a Horizon game for this computer.": "{file} — не гра Horizon для цього комп'ютера.",
"Install a release or Nightly first: an imported build plays with its game files.": "Спершу встановіть реліз або Nightly: імпортована збірка використовує його файли гри.",
"{file} holds no {program}.": "У {file} немає {program}.",
"Could not import the build: {error}": "Не вдалося імпортувати збірку: {error}"
}
+11 -1
View File
@@ -795,5 +795,15 @@
"{path} is not a folder to build into.": "{path} 不是可供构建的文件夹。",
"Could not copy the build: {error}": "无法复制构建:{error}",
"Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}",
"Native mods can use your microphone for voice chat when you enable it.": "原生模组在您启用时可使用您的麦克风进行语音聊天。"
"Native mods can use your microphone for voice chat when you enable it.": "原生模组在您启用时可使用您的麦克风进行语音聊天。",
"Import a game build…": "导入游戏构建…",
"Importing…": "正在导入…",
"Imported builds": "导入的构建",
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "未签名:这些程序是你自己选择的,Horizon 无法确认它们的来源。更新永远不会替换它们。",
"Game files from {base}": "游戏文件来自 {base}",
"Imported · {date}": "已导入 · {date}",
"{file} is not a Horizon game for this computer.": "{file} 不是适用于这台电脑的 Horizon 游戏。",
"Install a release or Nightly first: an imported build plays with its game files.": "请先安装一个正式版或 Nightly:导入的构建会使用它的游戏文件。",
"{file} holds no {program}.": "{file} 中没有 {program}。",
"Could not import the build: {error}": "无法导入构建:{error}"
}
+17 -3
View File
@@ -829,8 +829,7 @@ pub(crate) fn install_as(
fs::rename(&top, &target).map_err(|e| e.to_string())?;
fs::write(target.join(COMPLETE), "").map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&partial);
reuse_prepared(&root, tag);
complete_assets(tag);
reuse_prepared_for(tag);
progress(1.0);
Ok(())
}
@@ -1177,6 +1176,13 @@ fn reuse_prepared(versions: &Path, tag: &str) -> Option<String> {
None
}
/// What an install does once its folder is in place (an imported build's too, `build_import`):
/// another installed version's resources if it has none, and whatever its resources lack.
pub(crate) fn reuse_prepared_for(tag: &str) {
reuse_prepared(&versions_dir(), tag);
complete_assets(tag);
}
/// `reuse_prepared` for every installed version that is not prepared, oldest first, so a
/// version prepared this way can serve the next. Launchers before 0.1.4 installed new versions
/// without it; this catches those up. Returns each (version, where its resources came from).
@@ -2623,11 +2629,13 @@ pub fn check_updates() -> Result<Updates, String> {
/// Whether a version installed as an update becomes the selected one: when the player had none,
/// was on a Nightly, or on the newest installed release (so a player of the release channel moves
/// to the Nightly at the first check, docs/release-flow.md). An older release picked by hand
/// stays; an instance pinned to a version keeps it either way.
/// stays, and so does an imported build (`build_import`); an instance pinned to a version keeps it
/// either way.
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
match selected {
None => true,
Some(s) if crate::nightly::date_of(s).is_some() => true,
Some(s) if crate::build_import::is_imported(s) => false,
Some(s) => installed_before
.iter()
.find(|t| is_game_tag(t))
@@ -3115,6 +3123,8 @@ pub enum Msg {
NativeProgress(String),
/// The native-mods build ended: the version built, or why not.
Native(Result<String, String>),
/// An imported game build is installed (`build_import::import`): its version, or why not.
Imported(Result<String, String>),
/// A Java instance's game is ready to start (`java_game`, wrapped): the instance's id and
/// game folder, and the command or why not.
/// A Java instance's game ready to start, and its variables for the post-exit command.
@@ -4349,6 +4359,10 @@ pub fn {f}("
assert!(select_after_update(Some("v2026.10.12"), &installed));
assert!(!select_after_update(Some("v2026.10.1"), &installed));
assert!(select_after_update(None, &installed));
assert!(!select_after_update(
Some("imported-2026-11-01-abcdef0"),
&["imported-2026-11-01-abcdef0".to_string()]
));
}
/// The Nightly is the only build Horizon ships (docs/release-flow.md): every launcher takes the
+579
View File
@@ -0,0 +1,579 @@
//! Game builds the player imports: a `horizon-client` they already have (one built on their
//! fork's Actions, to try a merged change before the next Nightly), installed as its own
//! version beside the releases. The program alone can't start: it needs a version folder's
//! game files. So an import is a copy of an installed release or Nightly's folder (its
//! research-lock.json, preparer sources and prepared resources) with the picked program in
//! place of that version's own, as a native build is (`native_mods::install`).
//!
//! Nothing signed it: the Versions page says so. Its tag (`imported-<date>-<sha>`) is no
//! release's, so no update replaces it, prunes it or moves the selection off it.
use crate::backend::{self, CLIENT};
use crate::i18n::{tr, trf};
use crate::native_mods::{self, Target};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{Cursor, Read, Seek};
use std::path::Path;
const PREFIX: &str = "imported-";
/// What an import was made from, in its folder.
const RECORD: &str = "imported.json";
/// The most a program may take: a game build is ~100 MB.
const MAX_PROGRAM: u64 = 1 << 30;
/// Whether an installed version is an imported build.
pub fn is_imported(version: &str) -> bool {
version.starts_with(PREFIX)
}
/// An imported build as a player reads it: `Imported · 2026-11-01`, or None for any other version.
pub fn label(version: &str) -> Option<String> {
let date = version.strip_prefix(PREFIX)?.get(..10)?;
Some(trf("Imported · {date}", &[("date", date)]))
}
/// What an imported build was made from: its `imported.json`.
#[derive(Clone, Debug, PartialEq)]
pub struct About {
/// The release or Nightly whose game files it plays with.
pub base: String,
/// The file the player picked.
pub file: String,
}
/// What installed imported build `version` was made from; None for any other version.
pub fn about(version: &str) -> Option<About> {
if !is_imported(version) {
return None;
}
let json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(backend::version_dir(version).join(RECORD)).ok()?)
.ok()?;
let field = |name: &str| json[name].as_str().map(str::to_owned);
Some(About {
base: field("base")?,
file: field("file")?,
})
}
/// A version Horizon published and signed: a release or a Nightly.
fn official(version: &str) -> bool {
backend::is_game_tag(version) || crate::nightly::date_of(version).is_some()
}
/// The installed version an import takes its game files from: `requested` (`--version`), else
/// `selected` when Horizon published it, else the newest installed release or Nightly.
fn base_for(
installed: &[String],
requested: Option<&str>,
selected: Option<&str>,
) -> Result<String, String> {
let usable = |v: &str| official(v) && installed.iter().any(|t| t == v);
match requested {
Some(b) if usable(b) => Ok(b.to_owned()),
Some(b) => Err(format!("{b} is not an installed release or Nightly.")),
None => {
selected
.filter(|s| usable(s))
.map(str::to_owned)
.or_else(|| native_mods::newest_installed(installed).cloned())
.ok_or_else(|| {
tr("Install a release or Nightly first: an imported build plays with its game \
files.")
.to_string()
})
}
}
}
/// Installs the game program at `path` (or the one inside an archive there) as a new version on
/// the game files of `requested` (default: [`base_for`]), prepared from another version where it
/// can be, and selects it. Importing the same program again selects the version it made before.
pub fn import(path: &Path, requested: Option<&str>) -> Result<String, String> {
let file = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
let program = read_program(path, &file)?;
if !is_game(&program) {
return Err(not_a_game(&file));
}
let selected = backend::Settings::load().selected;
let base = base_for(&backend::installed(), requested, selected.as_deref())?;
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let tag = install_in(&backend::version_dir(""), &base, &program, &file, &date)?;
if let Some(made) = about(&tag).filter(|a| requested.is_some() && a.base != base) {
return Err(format!(
"{tag} is this program already, on the game files of {}: remove it to import it \
on {base}.",
made.base
));
}
backend::reuse_prepared_for(&tag);
// Read again: the window may have saved other settings during the copy.
let mut settings = backend::Settings::load();
settings.selected = Some(tag.clone());
settings.save();
Ok(tag)
}
fn not_a_game(file: &str) -> String {
trf(
"{file} is not a Horizon game for this computer.",
&[("file", file)],
)
}
/// The program at `path`: the file itself when it is named as the game is (`horizon-client`,
/// "horizon-client (1).exe" as a browser saves a second copy), or from a zip or a .tar.gz, its
/// entry named as the game is on this platform (`CLIENT`), in any folder. A GitHub Actions
/// artifact is a zip around the release's own archive: one archive inside is looked into too.
/// The launcher and horizon-ctl carry the mod API mark too: the name tells them apart.
fn read_program(path: &Path, file: &str) -> Result<Vec<u8>, String> {
let open = || fs::File::open(path).map_err(|e| format!("{file}: {e}"));
let lower = file.to_ascii_lowercase();
if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
return from_tar(open()?, file);
}
let zip = match zip::ZipArchive::new(open()?) {
Ok(zip) => zip,
// A download cut short, say.
Err(e) if lower.ends_with(".zip") => return Err(format!("{file}: {e}")),
Err(_) if !lower.starts_with("horizon-client") => return Err(not_a_game(file)),
Err(_) => return read_at_most(open()?, file),
};
from_zip(zip, file, true)
}
fn holds_no_game(file: &str) -> String {
trf(
"{file} holds no {program}.",
&[("file", file), ("program", CLIENT)],
)
}
/// The game in `zip`, else (`nested`) in the first zip or .tar.gz it holds.
fn from_zip<R: Read + Seek>(
mut zip: zip::ZipArchive<R>,
file: &str,
nested: bool,
) -> Result<Vec<u8>, String> {
let mut inner = None;
for i in 0..zip.len() {
let entry = zip.by_index(i).map_err(|e| format!("{file}: {e}"))?;
let name = entry
.enclosed_name()
.and_then(|p| Some(p.file_name()?.to_string_lossy().into_owned()))
.unwrap_or_default();
if !entry.is_file() {
continue;
}
if name == CLIENT {
return read_at_most(entry, file);
}
if nested
&& inner.is_none()
&& [".zip", ".tar.gz", ".tgz"]
.iter()
.any(|x| name.ends_with(x))
{
inner = Some((i, name));
}
}
let Some((i, name)) = inner else {
return Err(holds_no_game(file));
};
let entry = zip.by_index(i).map_err(|e| format!("{file}: {e}"))?;
let archive = read_at_most(entry, file)?;
if !name.ends_with(".zip") {
return from_tar(&archive[..], file);
}
let zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|e| format!("{name}: {e}"))?;
from_zip(zip, file, false)
}
/// The game in a .tar.gz (the Linux release's archive).
fn from_tar(from: impl Read, file: &str) -> Result<Vec<u8>, String> {
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(from));
for entry in tar.entries().map_err(|e| format!("{file}: {e}"))? {
let entry = entry.map_err(|e| format!("{file}: {e}"))?;
let named = entry
.path()
.is_ok_and(|p| p.file_name() == Some(std::ffi::OsStr::new(CLIENT)));
if named && entry.header().entry_type().is_file() {
return read_at_most(entry, file);
}
}
Err(holds_no_game(file))
}
/// All of `from`, refused past [`MAX_PROGRAM`] rather than cut off.
fn read_at_most(from: impl Read, file: &str) -> Result<Vec<u8>, String> {
let mut program = Vec::new();
from.take(MAX_PROGRAM + 1)
.read_to_end(&mut program)
.map_err(|e| format!("{file}: {e}"))?;
if program.len() as u64 > MAX_PROGRAM {
return Err(not_a_game(file));
}
Ok(program)
}
/// Whether `program` is a Horizon game for this computer: it starts as this platform's programs
/// do (PE on Windows, Mach-O on macOS, ELF elsewhere: a Linux build picked on Windows would
/// only fail to start) and carries the mod API mark every game since 0.2.69 does.
fn is_game(program: &[u8]) -> bool {
let magic: &[&[u8]] = if cfg!(windows) {
&[b"MZ"]
} else if cfg!(target_os = "macos") {
// 64-bit Mach-O, and a universal binary.
&[&[0xcf, 0xfa, 0xed, 0xfe], &[0xca, 0xfe, 0xba, 0xbe]]
} else {
&[b"\x7fELF"]
};
magic.iter().any(|m| program.starts_with(m))
&& horizon_mods::api_level::read_mark(program).is_some()
}
/// Installs `program` in `versions` as a copy of installed version `base` with it in place of
/// `base`'s game, and returns its tag. Copied beside it and renamed when complete, as an
/// install is: an interrupted import is never installed.
fn install_in(
versions: &Path,
base: &str,
program: &[u8],
file: &str,
date: &str,
) -> Result<String, String> {
let sha256: String = Sha256::digest(program)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let short = &sha256[..7];
// The same program imported on another day is the version made then.
let made = fs::read_dir(versions)
.into_iter()
.flatten()
.flatten()
.find_map(|e| {
let name = e.file_name().into_string().ok()?;
(is_imported(&name)
&& name.ends_with(&format!("-{short}"))
&& e.path().join(".installed").exists())
.then_some(name)
});
if let Some(tag) = made {
return Ok(tag);
}
let tag = format!("{PREFIX}{date}-{short}");
let dir = versions.join(&tag);
// This call's own, where no version is listed (`backend::installed` reads the folders of
// `versions` itself): the window and the CLI may import the same program at once.
static IMPORTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = IMPORTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let partial = versions
.join(".importing")
.join(format!("{tag}-{}-{n}", std::process::id()));
let record = serde_json::json!({ "base": base, "file": file, "sha256": sha256 });
let made = fs::create_dir_all(&partial)
.map_err(|e| e.to_string())
.and_then(|()| native_mods::copy_base(&versions.join(base), &partial, Target::Client))
.and_then(|()| finish(&partial, &dir, program, &record).map_err(|e| e.to_string()));
let _ = retried(|| fs::remove_dir_all(&partial).or_else(|_| gone(&partial)));
if let Err(e) = made {
return Err(trf("Could not import the build: {error}", &[("error", &e)]));
}
Ok(tag)
}
/// Puts `program`, its record and the installed mark into the copied folder `partial`, then
/// moves it into place as version `dir` in one step: no other call ever sees it half made, and a
/// version already there (the same program, imported meanwhile) stays.
fn finish(
partial: &Path,
dir: &Path,
program: &[u8],
record: &serde_json::Value,
) -> std::io::Result<()> {
fs::write(partial.join(CLIENT), program)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(partial.join(CLIENT), fs::Permissions::from_mode(0o755))?;
}
fs::write(partial.join(RECORD), record.to_string())?;
fs::write(partial.join(".installed"), "")?;
retried(|| match fs::rename(partial, dir) {
Err(_) if dir.join(".installed").exists() => Ok(()),
Err(e) => {
// No mark: not a version, but what a removal left while its game still ran.
let _ = fs::remove_dir_all(dir);
Err(e)
}
done => done,
})
}
/// `step`, tried for a second: Windows refuses to rename or delete a folder while a scan still
/// holds a file just written in it (`backend::remove` waits the same).
fn retried(mut step: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
let mut attempt = 1;
loop {
match step() {
Err(_) if attempt < 5 => std::thread::sleep(std::time::Duration::from_millis(250)),
done => return done,
}
attempt += 1;
}
}
/// Ok when `path` is gone.
fn gone(path: &Path) -> std::io::Result<()> {
match path.symlink_metadata() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
Ok(_) => Err(std::io::ErrorKind::DirectoryNotEmpty.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("horizon-import-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
/// The entries of `dir`, the copies' own `.importing` left out.
fn listed(dir: &Path) -> usize {
fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| e.file_name() != ".importing")
.count()
}
/// A program as this platform's are, carrying the mod API mark the game's do.
fn program(body: &[u8]) -> Vec<u8> {
let magic: &[u8] = if cfg!(windows) {
b"MZ"
} else if cfg!(target_os = "macos") {
&[0xcf, 0xfa, 0xed, 0xfe]
} else {
b"\x7fELF"
};
[magic, body, &horizon_mods::api_level::MARK[..]].concat()
}
#[test]
fn an_import_is_its_own_version_on_its_bases_game_files() {
let versions = dir("install");
let base = versions.join("nightly-2026-11-01-c9d474d");
fs::create_dir_all(base.join(".local/horizon-client/26.3")).unwrap();
fs::write(
base.join(".local/horizon-client/26.3/horizon-resources.json"),
b"{}",
)
.unwrap();
fs::write(base.join("research-lock.json"), b"lock").unwrap();
fs::write(base.join(CLIENT), b"signed").unwrap();
fs::write(base.join(".installed"), b"").unwrap();
fs::create_dir_all(base.join("Horizon.app")).unwrap();
let built = program(b"fork build");
let tag = install_in(
&versions,
"nightly-2026-11-01-c9d474d",
&built,
CLIENT,
"2026-11-01",
)
.unwrap();
assert!(
tag.starts_with("imported-2026-11-01-") && tag.len() == 27,
"{tag}"
);
assert!(is_imported(&tag) && !official(&tag));
assert_eq!(label(&tag).as_deref(), Some("Imported · 2026-11-01"));
let dir = versions.join(&tag);
assert_eq!(fs::read(dir.join(CLIENT)).unwrap(), built);
assert_eq!(fs::read(base.join(CLIENT)).unwrap(), b"signed");
assert!(dir.join(".installed").is_file());
assert!(dir
.join(".local/horizon-client/26.3/horizon-resources.json")
.is_file());
assert_eq!(fs::read(dir.join("research-lock.json")).unwrap(), b"lock");
// The macOS app is made again from the imported program (game_app.rs).
assert!(!dir.join("Horizon.app").exists());
let record: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join(RECORD)).unwrap()).unwrap();
assert_eq!(record["base"], "nightly-2026-11-01-c9d474d");
// The same program again is the same version, left as it is.
let again = install_in(
&versions,
"nightly-2026-11-01-c9d474d",
&built,
"x",
"2026-11-02",
);
assert_eq!(again.unwrap(), tag);
assert_eq!(listed(&versions), 2);
let _ = fs::remove_dir_all(versions);
}
#[test]
fn a_failed_import_leaves_nothing_behind() {
let versions = dir("failed");
// A folder from an import that never finished is not that program's version.
let built = program(b"build");
let unfinished = install_in(&versions, "v0.2.95", &built, CLIENT, "2026-10-01");
assert!(unfinished.is_err(), "no base to copy");
assert_eq!(listed(&versions), 0);
assert_eq!(listed(&versions.join(".importing")), 0);
let short: String = Sha256::digest(&built)[..4]
.iter()
.map(|b| format!("{b:02x}"))
.collect();
fs::create_dir_all(versions.join(format!("imported-2026-10-01-{}", &short[..7]))).unwrap();
assert!(install_in(&versions, "v0.2.95", &built, CLIENT, "2026-10-02").is_err());
// What a removal left while its game ran (no mark) gives way to the import.
fs::create_dir_all(versions.join("v0.2.95")).unwrap();
fs::write(versions.join("v0.2.95/research-lock.json"), b"lock").unwrap();
let left = versions.join(format!("imported-2026-10-01-{}", &short[..7]));
fs::write(left.join(CLIENT), b"old").unwrap();
let tag = install_in(&versions, "v0.2.95", &built, CLIENT, "2026-10-01").unwrap();
assert_eq!(versions.join(tag), left);
assert_eq!(fs::read(left.join(CLIENT)).unwrap(), built);
assert!(left.join(".installed").is_file());
let _ = fs::remove_dir_all(versions);
}
#[test]
fn only_a_horizon_game_for_this_computer_is_imported() {
assert!(is_game(&program(b"")));
// Another program of this platform, without the game's mark.
assert!(!is_game(&program(b"")[..program(b"").len() - 27]));
let other = if cfg!(windows) {
&b"\x7fELF"[..]
} else {
&b"MZ"[..]
};
assert!(!is_game(
&[other, &horizon_mods::api_level::MARK[..]].concat()
));
let d = dir("zip");
let zip_path = d.join("horizon-client-windows-x86_64.zip");
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("README.md", options).unwrap();
std::io::Write::write_all(&mut zip, b"notes").unwrap();
zip.start_file(format!("horizon/{CLIENT}"), options)
.unwrap();
std::io::Write::write_all(&mut zip, &program(b"zipped")).unwrap();
zip.finish().unwrap();
assert_eq!(
read_program(&zip_path, "a.zip").unwrap(),
program(b"zipped")
);
let name = "horizon-client (1)";
let plain = d.join(name);
fs::write(&plain, program(b"plain")).unwrap();
assert_eq!(read_program(&plain, name).unwrap(), program(b"plain"));
// The launcher carries the mark too.
let launcher = d.join("horizon-launcher");
fs::write(&launcher, program(b"launcher")).unwrap();
assert!(read_program(&launcher, "horizon-launcher").is_err());
let empty = d.join("empty.zip");
let mut zip = zip::ZipWriter::new(fs::File::create(&empty).unwrap());
zip.start_file("README.md", options).unwrap();
zip.finish().unwrap();
assert!(read_program(&empty, "empty.zip").is_err());
let _ = fs::remove_dir_all(d);
}
/// An Actions artifact: a zip around the release's own archive, a zip (Windows, macOS) or a
/// .tar.gz (Linux) with the game in its top folder.
#[test]
fn the_game_is_found_inside_an_actions_artifact() {
use std::io::Write;
let d = dir("artifact");
let options = zip::write::SimpleFileOptions::default();
let mut inner = zip::ZipWriter::new(Cursor::new(Vec::new()));
inner
.start_file(format!("horizon-windows-x64/{CLIENT}"), options)
.unwrap();
inner.write_all(&program(b"inner zip")).unwrap();
let inner = inner.finish().unwrap().into_inner();
let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
Vec::new(),
flate2::Compression::fast(),
));
let game = program(b"inner tar");
let mut header = tar::Header::new_gnu();
header.set_size(game.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(
&mut header,
format!("horizon-linux-x86_64/{CLIENT}"),
&game[..],
)
.unwrap();
let tar = tar.into_inner().unwrap().finish().unwrap();
for (archive, bytes, body) in [
("horizon-windows-x64.zip", inner, &b"inner zip"[..]),
(
"horizon-linux-x86_64.tar.gz",
tar.clone(),
&b"inner tar"[..],
),
] {
let artifact = d.join("artifact.zip");
let mut zip = zip::ZipWriter::new(fs::File::create(&artifact).unwrap());
zip.start_file(archive, options).unwrap();
zip.write_all(&bytes).unwrap();
zip.finish().unwrap();
assert_eq!(
read_program(&artifact, "artifact.zip").unwrap(),
program(body)
);
}
let plain_tar = d.join("horizon-linux-x86_64.tar.gz");
fs::write(&plain_tar, &tar).unwrap();
let name = "horizon-linux-x86_64.tar.gz";
assert_eq!(read_program(&plain_tar, name).unwrap(), game);
let _ = fs::remove_dir_all(d);
}
#[test]
fn the_base_is_the_selected_release_or_the_newest_one() {
let installed = [
"nightly-2026-11-01-c9d474d".to_string(),
"v0.2.95".to_string(),
"native-c9d474d-0123456789ab".to_string(),
"imported-2026-10-31-abcdef0".to_string(),
];
let newest = Ok("nightly-2026-11-01-c9d474d".to_string());
assert_eq!(
base_for(&installed, None, Some("v0.2.95")),
Ok("v0.2.95".to_string())
);
for selected in [None, Some("imported-2026-10-31-abcdef0"), Some("v0.1.0")] {
assert_eq!(base_for(&installed, None, selected), newest);
}
assert!(base_for(&installed[2..], None, None).is_err());
// --version: an installed release or Nightly, never another local build.
let asked = |b| base_for(&installed, Some(b), Some("v0.2.95"));
assert_eq!(asked("nightly-2026-11-01-c9d474d"), newest);
for b in [
"v0.1.0",
"native-c9d474d-0123456789ab",
"imported-2026-10-31-abcdef0",
] {
assert!(asked(b).is_err(), "{b}");
}
}
}
+33
View File
@@ -3,6 +3,7 @@
use clap::{Parser, Subcommand};
use horizon_launcher::account::{self, Accounts};
use horizon_launcher::backend::{self, Release};
use horizon_launcher::build_import;
use horizon_launcher::github_mods;
use horizon_launcher::i18n::{tr, trf};
use horizon_launcher::instances;
@@ -708,6 +709,20 @@ enum Cmd {
Install { tag: String },
/// Remove an installed version
Remove { tag: String },
/// Install a game program you have, such as a horizon-client built on your fork's Actions,
/// as its own version on an installed release or Nightly's game files, and select it.
/// Nothing signed it: it runs with your rights, and updates never replace it
ImportBuild {
/// The program (horizon-client, horizon-client.exe on Windows), or a zip holding it
path: PathBuf,
/// The installed release or Nightly whose game files it plays with (default: the
/// selected one, else the newest)
#[arg(long)]
version: Option<String>,
/// You picked this build yourself and trust it with full access to this computer
#[arg(long)]
trust_unsigned: bool,
},
/// Prepare a version's resources from your own Minecraft install, or from Mojang
Prepare {
/// The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App,
@@ -1337,6 +1352,24 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
backend::remove_version(&tag)?;
println!("Removed {tag}.");
}
Cmd::ImportBuild {
path,
version,
trust_unsigned,
} => {
if !trust_unsigned {
return Err("An imported build is unsigned: Horizon can't check where it came from. If you trust it, pass --trust-unsigned.".into());
}
let tag = build_import::import(&path, version.as_deref())?;
let prepared = backend::is_prepared(&tag);
if as_json {
json(serde_json::json!({ "imported": tag, "prepared": prepared }));
} else if prepared {
println!("Imported {tag} and selected it. It is prepared already, from another installed version.");
} else {
println!("Imported {tag} and selected it. Prepare it next: horizon-launcher prepare --version {tag}");
}
}
Cmd::Prepare {
minecraft,
download,
+2
View File
@@ -15,6 +15,8 @@ const NOT_TOOLS: &[&str] = &[
"agent-config",
"no-ai",
"native-build",
// It installs a program nothing signed: the player's decision.
"import-build",
// They install native code nobody reviewed, and say it first: the player's decision.
"mods add-github",
"mods update-github",
+3 -1
View File
@@ -80,8 +80,10 @@ impl GameTools {
tools: Vec::new(),
};
tools.rpc(
// The child is whichever `horizon-ctl` the player has installed, so open with the
// handshake revision: older versions do not know the stateless one.
"initialize",
json!({ "protocolVersion": crate::mcp::PROTOCOL, "capabilities": {},
json!({ "protocolVersion": horizon_mcp::LEGACY, "capabilities": {},
"clientInfo": { "name": "horizon-launcher", "version": backend::VERSION } }),
)?;
tools.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }))?;
+2
View File
@@ -37,6 +37,7 @@ impl Launcher {
what.push(format!(
"Horizon {}",
horizon_launcher::nightly::label(tag)
.or_else(|| horizon_launcher::build_import::label(tag))
.unwrap_or_else(|| tag.trim_start_matches('v').to_string())
));
}
@@ -317,6 +318,7 @@ impl Launcher {
.font_weight(FontWeight::BOLD)
.child(
horizon_launcher::nightly::label(tag)
.or_else(|| horizon_launcher::build_import::label(tag))
.unwrap_or_else(|| tag.trim_start_matches('v').to_string()),
),
)
+1
View File
@@ -3,6 +3,7 @@ pub mod account;
pub mod agents;
pub mod backend;
pub mod browser;
pub mod build_import;
pub mod datapacks;
pub mod desktop;
pub mod desktop_seams;
+148 -28
View File
@@ -64,6 +64,7 @@ use gpui::{
};
use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates};
use horizon_launcher::build_import;
use horizon_launcher::i18n::{self, tr, trf};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
@@ -192,6 +193,10 @@ struct Launcher {
disk: Vec<(String, u64)>,
disk_for: Vec<String>,
install_error: Option<(String, String)>,
/// Import a game build (`build_import`, on the Versions page): whether one is being copied in,
/// and why the last one failed.
importing_build: bool,
import_error: Option<String>,
/// Native mods (`native_mods`, on the Mods page): the build's step while it runs, why it
/// failed, and the warning before a build while it is open, with the mods it names.
native_step: Option<String>,
@@ -469,6 +474,8 @@ impl Launcher {
disk: Vec::new(),
disk_for: Vec::new(),
install_error: None,
importing_build: false,
import_error: None,
native_step: None,
native_started: None,
native_done: None,
@@ -1030,6 +1037,15 @@ impl Launcher {
}
}
Msg::DiskUse(disk) => self.disk = disk,
Msg::Imported(result) => {
self.importing_build = false;
self.installed = backend::installed();
match result {
// `build_import::import` saved the selection; this keeps the window's copy.
Ok(tag) => self.settings.selected = Some(tag),
Err(e) => self.import_error = Some(e),
}
}
Msg::Pruned(gone) => {
if !gone.is_empty() {
self.installed = backend::installed();
@@ -1302,6 +1318,43 @@ impl Launcher {
});
}
/// Asks for a game program (or a zip holding one) and installs it as its own version on a
/// thread: a version is thousands of files. `Msg::Imported` ends it.
fn import_build(&mut self, cx: &mut Context<Self>) {
// A removal still running could take the folder of the same program imported again.
if self.importing_build || !self.removing.is_empty() {
return;
}
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some(tr("Import").into()),
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(mut paths))) = paths.await else {
return;
};
let Some(path) = paths.pop() else {
return;
};
let _ = this.update(cx, |this, cx| {
// Another picker's import, or a removal, may have started meanwhile.
if this.importing_build || !this.removing.is_empty() {
return;
}
this.importing_build = true;
this.import_error = None;
let tx = this.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(Msg::Imported(build_import::import(&path, None)));
});
cx.notify();
});
})
.detach();
}
/// Prepares the selected version, its Minecraft files from the player's install or, for an
/// owner who asks, from Mojang.
fn prepare(&mut self, from: backend::MinecraftFrom) {
@@ -1694,6 +1747,7 @@ fn version_tag(tag: &str) -> Div {
.font_weight(FontWeight::MEDIUM)
.child(
horizon_launcher::nightly::label(tag)
.or_else(|| build_import::label(tag))
.unwrap_or_else(|| tag.trim_start_matches('v').to_string()),
)
}
@@ -2611,7 +2665,11 @@ impl Launcher {
// "Nightly · ba6344a", a release's tag: the menu lists the full tags.
let label = self.selected().map_or_else(
|| tr("No version").into(),
|tag| horizon_launcher::nightly::label(tag).unwrap_or_else(|| tag.clone()),
|tag| {
horizon_launcher::nightly::label(tag)
.or_else(|| build_import::label(tag))
.unwrap_or_else(|| tag.clone())
},
);
// As tall as Play: "Version" over the tag, and a chevron.
let n = t.night;
@@ -3994,8 +4052,9 @@ impl Launcher {
)
}
/// One installed native build on the Versions page: what it was built on, and Use and Remove.
fn native_build_row(
/// One installed build made here on the Versions page, a native build or an imported one:
/// what it was made from, and Use and Remove.
fn local_build_row(
&self,
tag: &str,
on_disk: &dyn Fn(&String) -> Option<u64>,
@@ -4007,6 +4066,11 @@ impl Launcher {
if let Some(about) = native_mods::about(tag) {
meta.push(trf("Built on {base}", &[("base", &about.base)]));
}
if let Some(about) = build_import::about(tag) {
let base = horizon_launcher::nightly::label(&about.base).unwrap_or(about.base);
meta.push(trf("Game files from {base}", &[("base", &base)]));
meta.push(about.file);
}
if let Some(n) = on_disk(&tag.to_string()) {
meta.push(trf("{size} on disk", &[("size", &megabytes(n))]));
}
@@ -4065,6 +4129,37 @@ impl Launcher {
.children(error.map(|e| notice(e, &t)))
}
/// The installed builds of one kind made here (`is`: native or imported) under their caption,
/// with a notice above them; None when there are none.
fn local_builds(
&self,
title: &str,
is: fn(&str) -> bool,
notice_: Option<&str>,
on_disk: &dyn Fn(&String) -> Option<u64>,
cx: &mut Context<Self>,
) -> Option<Div> {
let t = self.theme;
let tags: Vec<&String> = self
.installed
.iter()
.chain(&self.removing)
.filter(|v| is(v))
.collect();
(!tags.is_empty()).then(|| {
div()
.flex()
.flex_col()
.gap(px(8.))
.child(caps(tr(title)).text_color(t.muted))
.children(notice_.map(|n| notice(n.to_string(), &t)))
.children(
tags.into_iter()
.map(|tag| self.local_build_row(tag, on_disk, cx)),
)
})
}
fn versions_page(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let header = page_header(
@@ -4091,39 +4186,64 @@ impl Launcher {
.map(|(_, n)| *n)
};
let used: u64 = self.installed.iter().filter_map(on_disk).sum();
// The list's own action at the end of the line above it: a build the player has.
let import: AnyElement = if self.importing_build {
div()
.flex()
.items_center()
.gap(px(10.))
.w(px(180.))
.child(progress_bar(None, &t))
.child(
text(theme::MICRO)
.text_color(t.muted)
.child(tr("Importing…")),
)
.into_any_element()
} else {
button("import-build", "Import a game build…", Kind::Secondary, &t)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| this.import_build(cx)))
.into_any_element()
};
let page = div()
.flex()
.flex_col()
.gap(px(32.))
.child(header)
.children((used > 0).then(|| {
text(theme::UI).text_color(t.muted).child(trf(
"Installed versions use {size}.",
&[("size", &megabytes(used))],
))
}));
// Native-mods builds are made here, not downloaded: they are listed apart from the releases.
let native: Vec<String> = self
.installed
.iter()
.chain(&self.removing)
.filter(|v| native_mods::is_native(v))
.cloned()
.collect();
let page = page.when(!native.is_empty(), |page| {
page.child(
.child(
div()
.flex()
.flex_col()
.gap(px(8.))
.child(caps(tr("Native mod builds")).text_color(t.muted))
.children(
native
.iter()
.map(|tag| self.native_build_row(tag, &on_disk, cx)),
),
.items_center()
.justify_between()
.gap(px(16.))
.child(text(theme::UI).text_color(t.muted).when(used > 0, |d| {
d.child(trf(
"Installed versions use {size}.",
&[("size", &megabytes(used))],
))
}))
.child(import),
)
});
.children(self.import_error.clone().map(|e| notice(e, &t)));
// Builds made here, not downloaded, are listed apart from the releases.
let page = page
.children(self.local_builds(
"Native mod builds",
native_mods::is_native,
None,
&on_disk,
cx,
))
.children(self.local_builds(
"Imported builds",
build_import::is_imported,
Some(tr(
"Unsigned: you picked these programs yourself, and Horizon can't \
check where they came from. Updates never replace them.",
)),
&on_disk,
cx,
));
let releases = match &self.releases {
Load::Loading => return page.child(centered(tr("Loading releases…"), &t)),
Load::Failed(e) => {
+28 -61
View File
@@ -8,24 +8,16 @@ use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
use horizon_launcher::backend::{self, Job, Logged};
use horizon_launcher::instances;
use serde_json::{json, Value};
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::time::Duration;
pub const PROTOCOL: &str = SUPPORTED[0];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...";
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-launcher", backend::VERSION, INSTRUCTIONS)
}
struct Session {
@@ -273,15 +265,10 @@ impl Session {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-launcher", "version": backend::VERSION },
"instructions": "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...",
})),
"ping" => Ok(json!({})),
"tools/list" => {
let mut list = tools();
if let Some(game) = self.game() {
@@ -334,41 +321,12 @@ pub fn serve() -> Result<(), String> {
sign_in: None,
game: None,
};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
let served = server().serve(|method, params| session.handle(method, params));
// The session is over: the server it started goes with it.
if let Some(mut server) = session.server.take() {
server.stop(Duration::from_secs(30));
if let Some(mut started) = session.server.take() {
started.stop(Duration::from_secs(30));
}
Ok(())
served
}
#[cfg(test)]
@@ -376,12 +334,21 @@ mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
fn the_server_advertises_what_it_serves() {
let reply = server()
.handle_line(
&json!({ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {} })
.to_string(),
&mut |_: &str, _: &Value| Ok(json!({})),
)
.unwrap();
assert_eq!(
reply["result"]["supportedVersions"],
json!(horizon_mcp::VERSIONS)
);
assert_eq!(
reply["result"]["_meta"][horizon_mcp::SERVER_INFO_KEY]["name"],
"horizon-launcher"
);
}
}
+514 -17
View File
@@ -20,6 +20,7 @@ use crate::i18n::{tr, trf};
use crate::instances::{self, Instance};
use crate::rust_toolchain::{self, Toolchain};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
@@ -474,6 +475,81 @@ pub struct Bundle {
pub api: Option<String>,
/// True when the manifest has content for horizon-mixins.
pub mixins_content: bool,
/// Its `[[injection]]` entries, for the build's warnings. Read leniently:
/// an entry the launcher cannot make sense of is left out, horizon-mixins
/// refuses it when it applies.
pub injections: Vec<Injection>,
}
/// One `[[injection]]` of a bundle's manifest, read for the build's
/// warnings: where it sits and whether it can cancel.
#[derive(Clone, Debug, PartialEq)]
pub struct Injection {
pub id: String,
/// Where it injects: a native hook point by name, or a raw function path.
pub site: InjectionSite,
/// Its `kind` (`HEAD`, `RETURN`, …) as written.
pub kind: Option<String>,
/// Its `selector` and `ordinal`, which with the kind tell two interior
/// sites of one function apart.
pub selector: Option<String>,
pub ordinal: Option<i64>,
/// The `[mod]` `priority` unless the entry overrides it. horizon-mixins
/// defaults it to 0 (`#[serde(default)] priority: i32`).
pub priority: i64,
/// `cancellable = true` (a `HEAD`, `BEFORE_CALL`, `AFTER_CALL` or
/// `AFTER_ASSIGN` may carry it): a `Break` skips the later injections
/// at the site.
pub cancellable: bool,
}
/// An injection's site: `hook = "frame"` names a native hook point from the
/// catalog, `target = "game::tick"` a raw function path.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum InjectionSite {
Hook(String),
Target(String),
}
impl InjectionSite {
fn as_text(&self) -> String {
match self {
Self::Hook(name) => format!("hook `{name}`"),
Self::Target(path) => format!("`{path}`"),
}
}
}
impl Injection {
/// The place it sits at, as the manifest tells it: the function, the
/// kind, and the selector and ordinal of interior hooks. horizon-mixins
/// resolves the same place against the source (`plan.rs` sorts by file,
/// node, call site, constant site, then priority descending, mod id
/// ascending, injection id ascending): same manifest place, and its
/// tie-break tail is the run order.
fn place(&self) -> (&InjectionSite, Option<&str>, Option<&str>, Option<i64>) {
(
&self.site,
self.kind.as_deref(),
self.selector.as_deref(),
self.ordinal,
)
}
/// The place as a message names it.
fn place_as_text(&self) -> String {
let mut text = self.site.as_text();
if let Some(kind) = &self.kind {
text.push(' ');
text.push_str(kind);
}
if let Some(selector) = &self.selector {
text.push_str(&format!(" selector `{selector}`"));
}
if let Some(ordinal) = self.ordinal {
text.push_str(&format!(" ordinal {ordinal}"));
}
text
}
}
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
@@ -549,6 +625,43 @@ fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
let need = |name: &str| {
field("mod", name).ok_or_else(|| format!("{} has no [mod] {name}.", path.display()))
};
// The `[mod]` `priority` the injections fall back to. horizon-mixins
// defaults it to 0 (`#[serde(default)] priority: i32`), not to a README
// example's 100.
let mod_priority = value
.get("mod")
.and_then(|m| m.get("priority"))
.and_then(|v| v.as_integer())
.unwrap_or(0);
// Read leniently: an entry without an id and a site (`hook` or `target`)
// is left out — horizon-mixins refuses it when it applies.
let injections = value
.get("injection")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|entry| {
let text = |name: &str| entry.get(name).and_then(|v| v.as_str()).map(str::to_owned);
let id = text("id")?;
let site = match (text("hook"), text("target")) {
(Some(hook), _) => InjectionSite::Hook(hook),
(None, Some(target)) => InjectionSite::Target(target),
(None, None) => return None,
};
Some(Injection {
id,
site,
kind: text("kind"),
selector: text("selector"),
ordinal: entry.get("ordinal").and_then(|v| v.as_integer()),
priority: entry
.get("priority")
.and_then(|v| v.as_integer())
.unwrap_or(mod_priority),
cancellable: entry.get("cancellable").and_then(|v| v.as_bool()) == Some(true),
})
})
.collect();
let api = value
.get("api")
.map(|table| api::folder(dir, table))
@@ -569,6 +682,7 @@ fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
horizon: field("compatibility", "horizon_revision"),
mixins,
hooks: None,
injections,
api,
mixins_content,
})
@@ -578,6 +692,122 @@ fn is_revision(text: &str) -> bool {
text.len() == 40 && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// The build's warnings about the mods' injections (#6490): two mods on one
/// place, mods wrapping one function at its head and its return, and a
/// cancelling injection on a native hook point. Neither stops the build —
/// they say what the player and the author could not otherwise see. The
/// places are the manifest's: whether two selectors pick the same call, or
/// a raw `target` names a hook point's function, only horizon-mixins
/// resolves, so the warnings stay to what the manifest says for sure.
fn warn_conflicts(mods: &[Bundle]) -> Vec<String> {
let mut out = Vec::new();
// One place (the function, the kind, the selector, the ordinal), the
// injections of at least two mods on it, ordered as horizon-mixins runs
// same-place injections: priority descending, mod id ascending,
// injection id ascending — the tail of its own sort, which the place
// prefixes already fixed.
let mut places: BTreeMap<_, Vec<(&str, &Injection)>> = BTreeMap::new();
for b in mods {
for i in &b.injections {
places.entry(i.place()).or_default().push((&b.id, i));
}
}
for (_, list) in places {
let mut list = list;
list.sort_by_key(|(mod_id, i)| {
(
std::cmp::Reverse(i.priority),
(*mod_id).to_owned(),
i.id.clone(),
)
});
let mut names: Vec<&str> = Vec::new();
for (mod_id, _) in &list {
if !names.contains(mod_id) {
names.push(mod_id);
}
}
if names.len() < 2 {
continue;
}
let first = list[0].1;
let both = if names.len() == 2 { "both" } else { "all" };
out.push(format!(
"mods {} {both} inject at {}; {} runs first (priority descending, mod id ascending).",
names.join(", "),
first.place_as_text(),
names[0]
));
}
// A function's head and its return: two mods wrapping the whole
// function, which run in that order whatever their priorities. Said
// without a "runs first".
let mut functions: BTreeMap<&InjectionSite, Vec<(&str, &Injection)>> = BTreeMap::new();
for b in mods {
for i in &b.injections {
if i.kind.as_deref() == Some("HEAD") || i.kind.as_deref() == Some("RETURN") {
functions.entry(&i.site).or_default().push((&b.id, i));
}
}
}
for (site, list) in functions {
let kinds: std::collections::BTreeSet<&str> =
list.iter().filter_map(|(_, i)| i.kind.as_deref()).collect();
let mut names: Vec<&str> = Vec::new();
for (mod_id, _) in &list {
if !names.contains(mod_id) {
names.push(mod_id);
}
}
// Both sides, and more than one mod on the function: a single mod
// wrapping its own function at both ends is no one's conflict.
if kinds.len() < 2 || names.len() < 2 {
continue;
}
let sides: Vec<String> = list
.iter()
.map(|(mod_id, i)| format!("{mod_id} ({})", i.kind.as_deref().unwrap_or("")))
.collect();
out.push(format!(
"mods {} inject into {}: HEAD runs on entry, RETURN on return, whatever their priorities.",
sides.join(", "),
site.as_text()
));
}
// A cancelling `HEAD` on a hook point: a `Break` skips the later
// injections at the place and the function's body. A raw `target` is
// only known to be a hook point through the catalog, which the launcher
// does not read; a `hook` name is one by definition. Only `HEAD` is
// said: the interior kinds' cancellation skips less than the body.
for b in mods {
for i in &b.injections {
if i.cancellable
&& i.kind.as_deref() == Some("HEAD")
&& matches!(i.site, InjectionSite::Hook(_))
{
out.push(format!(
"{}'s `{}` can cancel at {}: a Break skips the later injections and the function's body.",
b.id,
i.id,
i.place_as_text()
));
}
}
}
out
}
/// Appends `text` to the build log, the way a compiler warning reads.
fn log_warning(text: &str) {
if let Ok(mut log) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path())
{
let _ = writeln!(log, "warning: {text}");
}
}
/// The one value the bundles pin for `which` (each mod's `get`), else `fallback`; mods that pin
/// different ones can't be built together, and the error says which.
fn agreed(
@@ -879,7 +1109,7 @@ fn about_in(dir: &Path) -> Option<About> {
}
/// The newest installed release or Nightly in `installed` (newest first).
fn newest_installed(installed: &[String]) -> Option<&String> {
pub(crate) fn newest_installed(installed: &[String]) -> Option<&String> {
installed
.iter()
.find(|t| backend::is_game_tag(t) || t.starts_with("nightly-"))
@@ -888,8 +1118,10 @@ fn newest_installed(installed: &[String]) -> Option<&String> {
/// The installed version a build for an instance that plays `current` starts from: `current`
/// itself, so a rebuild stays on the Horizon it plays (a native build's own revision, [`about`]).
/// With `update`, or when it plays nothing installed, the newest installed release or Nightly.
/// An imported build is no commit to build on: its game files' version is.
pub fn base_for(current: Option<&str>, installed: &[String], update: bool) -> Option<String> {
match current {
let imported = current.and_then(crate::build_import::about).map(|a| a.base);
match imported.as_deref().or(current) {
Some(v) if !update && installed.iter().any(|t| t == v) => Some(v.to_owned()),
_ => newest_installed(installed).cloned(),
}
@@ -1520,6 +1752,11 @@ fn pipeline(
let incoming = root().join("mods.incoming");
let _ = fs::remove_dir_all(&incoming);
let found = bundles(folder)?;
// #6490: said before anything runs, so a build that fails later still
// shows them.
for warning in warn_conflicts(&found) {
log_warning(&warning);
}
// A native build starts from the release or Nightly it was made from, on its own revision.
let built_on = about(base);
let origin = built_on.as_ref().map_or(base, |a| a.base.as_str());
@@ -1978,21 +2215,7 @@ fn install(
let partial = target.directory(&format!(".{version}.partial"));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
for entry in fs::read_dir(&from).map_err(|e| e.to_string())?.flatten() {
let name = entry.file_name();
if !carried_over(&name, target) {
continue;
}
let to = partial.join(&name);
let copied = if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &to)
} else {
// A clone is a new file: horizon-ctl and horizon-server keep their permissions.
horizon_prepare::clone_or_copy(&entry.path(), &to)
.and_then(|()| fs::set_permissions(&to, entry.metadata()?.permissions()))
};
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
}
copy_base(&from, &partial, target)?;
let installed = partial.join(target.binary());
fs::copy(binary, &installed).map_err(|e| format!("The built game: {e}"))?;
fs::write(partial.join("native-mods.json"), about.to_string()).map_err(|e| e.to_string())?;
@@ -2006,6 +2229,27 @@ fn install(
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
}
/// Copies version folder `from` into `to` but for what a new build of `target` makes its own
/// ([`carried_over`]), cloned where the disk can. Also an imported build's start (`build_import`).
pub(crate) fn copy_base(from: &Path, to: &Path, target: Target) -> Result<(), String> {
for entry in fs::read_dir(from).map_err(|e| e.to_string())?.flatten() {
let name = entry.file_name();
if !carried_over(&name, target) {
continue;
}
let to = to.join(&name);
let copied = if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &to)
} else {
// A clone is a new file: horizon-ctl and horizon-server keep their permissions.
horizon_prepare::clone_or_copy(&entry.path(), &to)
.and_then(|()| fs::set_permissions(&to, entry.metadata()?.permissions()))
};
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
}
Ok(())
}
/// Whether a file of the version a build starts from goes into the new build. The game (the
/// target's own binary), its record, and the mods' post-build data (`native-data/`, which the
/// new build's steps write again) are the new build's own: a mod turned off leaves none of its
@@ -2148,6 +2392,255 @@ mod tests {
);
}
/// A bundle whose manifest only needs to parse, with the injections of
/// `toml`'s `[[injection]]` list read as the build's warnings read them.
fn bundle_of(id: &str, toml: &str) -> Bundle {
// Each call its own folder: the tests run in parallel.
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let at = dir(&format!("warn-{id}-{n}"));
write_bundle(&at, id, "fn hook() {}");
// A fragment may carry its own `[mod]` (a priority, say).
let manifest = if toml.starts_with("[mod]") {
toml.to_owned()
} else {
format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n{toml}")
};
fs::write(at.join(MANIFEST), manifest).unwrap();
bundles(&at).unwrap().remove(0)
}
#[test]
fn two_mods_on_one_hook_warn_in_run_order() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"frame\"\nkind = \"HEAD\"\n",
),
bundle_of(
"beta",
"[mod]\nid = \"beta\"\nversion = \"0.1.0\"\npriority = 200\n\
[[injection]]\nid = \"b\"\nhook = \"frame\"\nkind = \"HEAD\"\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods beta, alpha both inject at hook `frame` HEAD; beta runs first (priority descending, mod id ascending)."]
);
}
/// horizon-mixins defaults a mod's priority to 0, not to the README
/// example's 100: a mod without one runs after `priority = 50`.
#[test]
fn the_default_priority_is_zero() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"frame\"\nkind = \"HEAD\"\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"frame\"\nkind = \"HEAD\"\npriority = 50\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods beta, alpha both inject at hook `frame` HEAD; beta runs first (priority descending, mod id ascending)."]
);
}
#[test]
fn a_priority_tie_orders_by_mod_id() {
let mods = [
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"HEAD\"\n",
),
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"tick\"\nkind = \"HEAD\"\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods alpha, beta both inject at hook `tick` HEAD; alpha runs first (priority descending, mod id ascending)."]
);
}
#[test]
fn an_injections_priority_overrides_its_mods() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"tick\"\nkind = \"HEAD\"\npriority = 50\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"HEAD\"\n",
),
];
// beta's is 0 (the default), alpha's 50: alpha first.
assert_eq!(
warn_conflicts(&mods),
["mods alpha, beta both inject at hook `tick` HEAD; alpha runs first (priority descending, mod id ascending)."]
);
}
#[test]
fn two_mods_on_one_raw_target_warn_too() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\ntarget = \"game::calls::compute\"\nkind = \"HEAD\"\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\ntarget = \"game::calls::compute\"\nkind = \"HEAD\"\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods alpha, beta both inject at `game::calls::compute` HEAD; alpha runs first (priority descending, mod id ascending)."]
);
}
/// HEAD and RETURN wrap the whole function: HEAD runs on entry, RETURN
/// on return, whatever the priorities, so no "runs first".
#[test]
fn head_and_return_on_one_function_warn_without_a_runs_first() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"tick\"\nkind = \"HEAD\"\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"RETURN\"\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods alpha (HEAD), beta (RETURN) inject into hook `tick`: HEAD runs on entry, RETURN on return, whatever their priorities."]
);
}
/// Two interior injections of one function with different selectors are
/// different places: horizon-mixins resolves the selector against the
/// source, the launcher does not guess it.
#[test]
fn different_selectors_are_different_places() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"tick\"\nkind = \"BEFORE_CALL\"\n\
selector = \"x\"\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"BEFORE_CALL\"\n\
selector = \"y\"\n",
),
];
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn same_selector_and_ordinal_is_one_place() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"tick\"\nkind = \"BEFORE_CALL\"\n\
selector = \"x\"\nordinal = 1\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"BEFORE_CALL\"\n\
selector = \"x\"\nordinal = 1\n",
),
];
assert_eq!(
warn_conflicts(&mods),
["mods alpha, beta both inject at hook `tick` BEFORE_CALL selector `x` ordinal 1; alpha runs first (priority descending, mod id ascending)."]
);
}
#[test]
fn different_sites_stay_quiet() {
let mods = [
bundle_of(
"alpha",
"[[injection]]\nid = \"a\"\nhook = \"frame\"\nkind = \"HEAD\"\n",
),
bundle_of(
"beta",
"[[injection]]\nid = \"b\"\nhook = \"tick\"\nkind = \"HEAD\"\n",
),
];
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn a_cancelling_head_on_a_hook_point_warns() {
let mods = [bundle_of(
"alpha",
"[[injection]]\nid = \"stop\"\nhook = \"frame\"\nkind = \"HEAD\"\ncancellable = true\n",
)];
assert_eq!(
warn_conflicts(&mods),
["alpha's `stop` can cancel at hook `frame` HEAD: a Break skips the later injections and the function's body."]
);
}
/// Cancellation is allowed on the interior kinds too, but what a Break
/// skips there is less than the body: only `HEAD` is said.
#[test]
fn a_cancelling_interior_kind_stays_quiet() {
let mods = [bundle_of(
"alpha",
"[[injection]]\nid = \"stop\"\nhook = \"tick\"\nkind = \"BEFORE_CALL\"\n\
cancellable = true\n",
)];
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn a_cancelling_injection_on_a_raw_target_stays_quiet() {
// Whether a raw target fires a hook point only the catalog knows,
// which the launcher does not read.
let mods = [bundle_of(
"alpha",
"[[injection]]\nid = \"stop\"\ntarget = \"game::tick\"\nkind = \"HEAD\"\n\
cancellable = true\n",
)];
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn one_mod_with_two_injections_on_one_site_stays_quiet() {
let mods = [bundle_of(
"alpha",
"[[injection]]\nid = \"first\"\nhook = \"frame\"\nkind = \"HEAD\"\n\
[[injection]]\nid = \"second\"\nhook = \"frame\"\nkind = \"HEAD\"\n",
)];
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn a_manifest_without_injections_builds_an_empty_list() {
let mods = [bundle_of("alpha", "")];
assert!(mods[0].injections.is_empty());
assert!(warn_conflicts(&mods).is_empty());
}
#[test]
fn an_entry_without_a_site_is_left_out() {
let mods = [bundle_of(
"alpha",
"[[injection]]\nid = \"broken\"\nkind = \"HEAD\"\n",
)];
assert!(mods[0].injections.is_empty());
}
#[test]
fn a_folder_is_one_bundle_or_a_folder_of_them() {
let one = dir("one");
@@ -2333,6 +2826,7 @@ mod tests {
horizon: horizon.map(str::to_owned),
mixins: None,
hooks: None,
injections: Vec::new(),
api: None,
mixins_content: false,
};
@@ -2367,6 +2861,7 @@ mod tests {
horizon: horizon.map(str::to_owned),
mixins: mixins.map(str::to_owned),
hooks: None,
injections: Vec::new(),
api: None,
mixins_content: false,
};
@@ -2472,6 +2967,7 @@ mod tests {
horizon: Some(horizon.into()),
mixins: None,
hooks,
injections: Vec::new(),
api: None,
mixins_content: false,
};
@@ -2530,6 +3026,7 @@ mod tests {
horizon: Some(horizon.into()),
mixins: None,
hooks,
injections: Vec::new(),
api: None,
mixins_content: false,
};
+1
View File
@@ -27,6 +27,7 @@ class RustChangesTests(unittest.TestCase):
with self.subTest(path=path):
self.assertFalse(needs_launcher([path]))
for path in ["launcher/src/main.rs", "launcher/Cargo.lock", "crates/horizon-port-pack/src/net.rs",
"crates/horizon-mcp/src/lib.rs",
"crates/horizon-theme/src/lib.rs", "Cargo.toml", "Cargo.lock",
".github/workflows/ci.yml"]:
with self.subTest(path=path):
+6
View File
@@ -368,11 +368,17 @@ struct DeviceId;
enum DeviceEvent { MouseMotion { delta: (f64, f64) }, Other }
#[derive(PartialEq)] enum Screen { Playing, Paused }
struct Options { offscreen: bool }
mod input_trace {
pub enum InputOrigin { User }
pub enum TraceKind { Motion { x: f64, y: f64 } }
}
struct Game {
pending_save: Option<()>, captured: bool, focused: bool, keyboard_mouse: bool, split_window: bool,
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant,
}
impl Game {
// The input trace records the motion; it does not change the turn.
fn note_input(&mut self, _: input_trace::InputOrigin, _: input_trace::TraceKind) {}
METHOD
}
fn main() {
+73
View File
@@ -1578,6 +1578,78 @@ def experience_orb_stuck():
]
def summoned_potions():
"""`ThrowableItemProjectile.getItem`/`readAdditionalSaveData` and
`AbstractThrownPotion.getDefaultGravity`: a summoned splash or lingering
potion is the projectile its type is, not an entity nothing ticks. It
flies with the gravity 0.05 of its type (a bare one as a splash potion
with no effects), `Motion` is in its compound, and what it splashes on
landing are the effects of its `Item`'s `potion_contents` (#6642)."""
ops = [*setup('/kill @e[type=minecraft:splash_potion]',
'/kill @e[type=minecraft:lingering_potion]',
'/fill 0 196 0 20 200 20 minecraft:stone')]
# A bare summon flies as the default splash potion: no effects, and the
# tick's gravity and drag move it off where it was placed.
ops += [
cmd('/summon minecraft:splash_potion 4.5 206 4.5'),
cmd('/data get entity @e[type=minecraft:splash_potion,limit=1] Item'),
cmd('/data get entity @e[type=minecraft:splash_potion,limit=1] Motion'),
cmd('/data get entity @e[type=minecraft:splash_potion,limit=1] Pos'),
{'op': 'tick', 'n': 3},
cmd('/data get entity @e[type=minecraft:splash_potion,limit=1] Pos'),
cmd('/data get entity @e[type=minecraft:splash_potion,limit=1] Motion'),
{'op': 'tick', 'n': 30},
cmd('/execute if entity @e[type=minecraft:splash_potion]'),
]
# A harming potion landing on a pig hurts it for the potion's 6, and the
# potion is gone: ThrownSplashPotion.onHitAsPotion over the stack. The pig
# stands on the stone at y 201 (a summon at y 200 is inside it).
ops += [
*setup('/kill @e[type=!player]'),
{'op': 'tick', 'n': 25},
*setup('/summon minecraft:pig 4.5 201 4.5 {NoAI:1b,Silent:1b,PersistenceRequired:1b}'),
{'op': 'tick', 'n': 5},
cmd('/data get entity @e[type=minecraft:pig,limit=1] Health'),
*setup('/summon minecraft:splash_potion 4.5 206 4.5 '
'{Item:{id:"minecraft:splash_potion",count:1,components:'
'{"minecraft:potion_contents":{potion:"minecraft:harming"}}}}'),
{'op': 'tick', 'n': 40},
cmd('/data get entity @e[type=minecraft:pig,limit=1] Health'),
cmd('/execute if entity @e[type=minecraft:splash_potion]'),
]
# A lingering potion leaves its cloud where it lands, and the cloud puts
# the potion's poison on the pig standing in it
# (ThrownLingeringPotion.onHitAsPotion). The duration is left out of the
# check: how the cloud's age and the mob's effect tick interleave is not
# what this covers.
ops += [
*setup('/kill @e[type=!player]'),
{'op': 'tick', 'n': 25},
*setup('/summon minecraft:pig 8.5 201 8.5 '
'{Tags:["poisoned"],NoAI:1b,Silent:1b,PersistenceRequired:1b}',
'/summon minecraft:lingering_potion 8.5 206 8.5 '
'{Item:{id:"minecraft:lingering_potion",count:1,components:'
'{"minecraft:potion_contents":{potion:"minecraft:poison"}}}}'),
cmd('/data get entity @e[type=minecraft:lingering_potion,limit=1] Motion'),
{'op': 'tick', 'n': 40},
cmd('/execute if entity @e[type=minecraft:lingering_potion]'),
cmd('/execute if entity @e[tag=poisoned,limit=1,nbt='
'{active_effects:[{id:"minecraft:poison"}]}]'),
# A bare lingering potion has empty contents, so its cloud, ten blocks
# from the poison one, puts nothing on its own pig.
*setup('/summon minecraft:pig 18.5 201 18.5 '
'{Tags:["bare"],NoAI:1b,Silent:1b,PersistenceRequired:1b}',
'/summon minecraft:lingering_potion 18.5 206 18.5'),
{'op': 'tick', 'n': 40},
cmd('/execute if entity @e[tag=poisoned,limit=1,nbt='
'{active_effects:[{id:"minecraft:poison"}]}]'),
cmd('/execute if entity @e[tag=bare,limit=1,nbt='
'{active_effects:[{id:"minecraft:poison"}]}]'),
]
ops += [*setup('/kill @e[type=!player]'), {'op': 'tick', 'n': 25}]
return ops
def groups():
return [
('setup', group_setup()),
@@ -1612,4 +1684,5 @@ def groups():
('experience orb fall', experience_orb_fall()),
('piglin pickups', piglin_pickups()),
('experience orb stuck', experience_orb_stuck()),
('summoned potions', summoned_potions()),
]
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "item_energy_buffer",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_energy_buffer",
"neoforge": "Capabilities.Energy.ITEM",
"mod_api": {
"contract": "replace",
"out": "buffer"
}
}
],
"rust_summary": "a mod item holding energy (`native_item_energy_buffer`), and the item energy helpers in\n`native_item_energy.rs`: `native_item_energy`, `native_item_energy_insert`, `native_item_energy_extract`.",
"doc_summary": "energy held by items (`Capabilities.Energy.ITEM`). `native_item_energy_buffer(item, buffer)` gives a mod item a buffer as `SimpleEnergyHandler` is built: its capacity and the most one insert and one extract move (a capacity under 1 leaves it out; the limits are clamped to 0..=capacity). The amount lives in the stack's `minecraft:custom_data` as the int `horizon_energy`, so it is saved and sent with the stack; an empty item carries nothing, so it stacks with a new one. `native_item_energy(stack)` reads one item of a stack (amount, capacity, limits); `native_item_energy_insert(stack, amount)` and `native_item_energy_extract(stack, amount)` answer the amount one item takes or gives and the single item it becomes, as `EnergyHandler.insert`/`extract` through an `ItemAccess`; the stack given is not changed, so the caller shrinks it by one and hands out the result, or leaves the result unused to simulate. They work on any side. Bundles need \"hooks\": @API:item_energy_buffer@.",
"comments": []
}
+1 -1
View File
@@ -16,7 +16,7 @@ JigsawBlock.rotate port crates/horizon-world/src/worldgen/structure.rs::StateTra
JigsawBlock.mirror port crates/horizon-world/src/worldgen/structure.rs::StateTransforms::mirror orientation property through the mirror table
JigsawBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class orientation from clicked face and horizontal facing
JigsawBlock.useWithoutItem port crates/horizon-world/src/level/interact.rs::Sim::use_without_item a game master gets the jigsaw editor (game/jigsaw_edit.rs)
JukeboxBlock.setPlacedBy missing - minor: a jukebox item carrying a disc (creative Ctrl+pick) fills the block entity in Game::apply_placed_item but HAS_RECORD stays false, so no disc model shows
JukeboxBlock.setPlacedBy port crates/horizon-client/src/game/jukebox.rs::Game::jukebox_placed the item's block_entity_data RecordItem turns has_record on, after apply_placed_item filled the block entity
JukeboxBlock.useWithoutItem port crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox takes the disc out through eject_record when one is in
JukeboxBlock.ownSignal port crates/horizon-client/src/game/jukebox.rs::jukebox_signals 15 while a song plays
JukeboxBlock.getAnalogOutputSignal port crates/horizon-client/src/game/jukebox.rs::jukebox_signals comparator output of the song
1 HoneyBlock.doesEntityDoHoneyBlockSlideEffects inline crates/horizon-client/src/motion.rs::MotionWorld::honey_block_inside gap: minor: only the player slides here; boats, minecarts, TNT and mobs get no slide sound or event
16 JigsawBlock.mirror port crates/horizon-world/src/worldgen/structure.rs::StateTransforms::mirror orientation property through the mirror table
17 JigsawBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class orientation from clicked face and horizontal facing
18 JigsawBlock.useWithoutItem port crates/horizon-world/src/level/interact.rs::Sim::use_without_item a game master gets the jigsaw editor (game/jigsaw_edit.rs)
19 JukeboxBlock.setPlacedBy missing port - crates/horizon-client/src/game/jukebox.rs::Game::jukebox_placed minor: a jukebox item carrying a disc (creative Ctrl+pick) fills the block entity in Game::apply_placed_item but HAS_RECORD stays false, so no disc model shows the item's block_entity_data RecordItem turns has_record on, after apply_placed_item filled the block entity
20 JukeboxBlock.useWithoutItem port crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox takes the disc out through eject_record when one is in
21 JukeboxBlock.ownSignal port crates/horizon-client/src/game/jukebox.rs::jukebox_signals 15 while a song plays
22 JukeboxBlock.getAnalogOutputSignal port crates/horizon-client/src/game/jukebox.rs::jukebox_signals comparator output of the song
+3 -3
View File
@@ -11,8 +11,8 @@ Varint21FrameDecoder.decode port crates/horizon-protocol/src/frame.rs::split gap
ChatType.bind port crates/horizon-client/src/lan/hub/chat.rs::Hub::handle_chat the bound takes the chat type and the sender's display name
ChatTypeDecoration.incomingDirectMessage skip:data chat_type registry bootstrap; the msg decorations come from the chat_type data
ChatTypeDecoration.outgoingDirectMessage skip:data chat_type registry bootstrap; the msg decorations come from the chat_type data
ChatTypeDecoration.decorate port crates/horizon-client/src/game/remote.rs::Game::format_chat the chat type's translation with its style, on the client when a PlayerChat or DisguisedChat arrives
ChatTypeDecoration.resolveParameters port crates/horizon-client/src/game/remote.rs::Game::format_chat the parameter list (sender, target, content) built in format_chat
ChatTypeDecoration.decorate port crates/horizon-client/src/game/remote.rs::chat_type_decorate the chat type's translation with its style, on the client when a PlayerChat or DisguisedChat arrives
ChatTypeDecoration.resolveParameters port crates/horizon-client/src/game/remote.rs::chat_type_decorate the parameter list (sender, target, content) built in format_chat
ClickEvent.Action.filterForSerialization port crates/horizon-client-core/src/component.rs::object_style gap: minor: a disallowed action (open_file) is dropped; vanilla refuses the whole component
CommonComponents.optionStatus port crates/horizon-client-render/src/options_ui.rs::on_off options.on / options.off text
CommonComponents.joinForNarration skip:other screen-reader narration text; Horizon has no narrator output (the narrator option is only stored)
@@ -45,7 +45,7 @@ FilterMask.hashCode skip:java hashing
FormattedText.of skip:java adapter from a String to FormattedText
FormattedText.getString port crates/horizon-client-core/src/text.rs::Text::plain
HoverEvent.Action.filterForSerialization port crates/horizon-client-core/src/component.rs::object_style gap: minor: show_entity is accepted by vanilla and dropped here
HoverEvent.EntityTooltipInfo.getTooltipLines missing player: hovering a player or entity name in chat shows no tooltip with name, type and UUID; show_entity hovers are dropped
HoverEvent.EntityTooltipInfo.getTooltipLines port crates/horizon-client/src/game/chat.rs::Game::entity_tooltip_lines the name when the event has one, then the entity type and the uuid, for a show_entity hover
HoverEvent.EntityTooltipInfo.equals skip:java value equality
HoverEvent.EntityTooltipInfo.hashCode skip:java hashing
LastSeenMessages.updateSignature inline crates/horizon-protocol/src/secure_chat.rs::signed_payload the last-seen list written into the signed payload
1 ProtocolSwapHandler.handleInboundTerminalPacket port crates/horizon-client/src/net.rs::configure the configuration phase ends on FinishConfiguration and play starts in run; no Netty handler swap
11 ChatType.bind port crates/horizon-client/src/lan/hub/chat.rs::Hub::handle_chat the bound takes the chat type and the sender's display name
12 ChatTypeDecoration.incomingDirectMessage skip:data chat_type registry bootstrap; the msg decorations come from the chat_type data
13 ChatTypeDecoration.outgoingDirectMessage skip:data chat_type registry bootstrap; the msg decorations come from the chat_type data
14 ChatTypeDecoration.decorate port crates/horizon-client/src/game/remote.rs::Game::format_chat crates/horizon-client/src/game/remote.rs::chat_type_decorate the chat type's translation with its style, on the client when a PlayerChat or DisguisedChat arrives
15 ChatTypeDecoration.resolveParameters port crates/horizon-client/src/game/remote.rs::Game::format_chat crates/horizon-client/src/game/remote.rs::chat_type_decorate the parameter list (sender, target, content) built in format_chat
16 ClickEvent.Action.filterForSerialization port crates/horizon-client-core/src/component.rs::object_style gap: minor: a disallowed action (open_file) is dropped; vanilla refuses the whole component
17 CommonComponents.optionStatus port crates/horizon-client-render/src/options_ui.rs::on_off options.on / options.off text
18 CommonComponents.joinForNarration skip:other screen-reader narration text; Horizon has no narrator output (the narrator option is only stored)
45 FormattedText.of skip:java adapter from a String to FormattedText
46 FormattedText.getString port crates/horizon-client-core/src/text.rs::Text::plain
47 HoverEvent.Action.filterForSerialization port crates/horizon-client-core/src/component.rs::object_style gap: minor: show_entity is accepted by vanilla and dropped here
48 HoverEvent.EntityTooltipInfo.getTooltipLines missing port crates/horizon-client/src/game/chat.rs::Game::entity_tooltip_lines player: hovering a player or entity name in chat shows no tooltip with name, type and UUID; show_entity hovers are dropped the name when the event has one, then the entity type and the uuid, for a show_entity hover
49 HoverEvent.EntityTooltipInfo.equals skip:java value equality
50 HoverEvent.EntityTooltipInfo.hashCode skip:java hashing
51 LastSeenMessages.updateSignature inline crates/horizon-protocol/src/secure_chat.rs::signed_payload the last-seen list written into the signed payload
+1
View File
@@ -70,6 +70,7 @@ LAUNCHER_PATHS = [
"launcher/",
"crates/horizon-bench/",
"crates/horizon-friends/",
"crates/horizon-mcp/",
"crates/horizon-mods/",
"crates/horizon-port-pack/",
"crates/horizon-prepare/",
+2
View File
@@ -1025,6 +1025,8 @@ mod tests {
pub(super) struct NativeLootDrops<'a>(pub &'a ());
pub(super) struct NativeScreenView<'a>(pub &'a ());
pub(super) struct Corners;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct ItemEnergyBuffer;
pub(super) struct Suggestion;
pub(super) struct HudBatch;
pub(super) struct BiomeColors;