Merge main into claude/kind-dijkstra-aaxkhw

Conflict: main's horizon_clients_only beside owner_profile in NetConfig.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-30 19:57:26 +01:00
commit faf7d52f20
71 files changed
+5891 -227

No files matched your search

+4 -4
View File
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
</details>
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
<details><summary>Technical</summary>
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
Waits for native mods to settle: a script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
+75 -6
View File
@@ -46,6 +46,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
stand: stays on the ground where the server puts it (/tp), for a survival test.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
@@ -59,6 +60,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
@@ -67,12 +71,14 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Stand,
Walk,
Explore,
FloodMove,
@@ -91,6 +97,7 @@ impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::FloodMove => "flood-move",
@@ -117,9 +124,14 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
flood: usize,
no_read: bool,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
}
@@ -134,10 +146,13 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
attack: None,
attack_after: 5.0,
flood: 100,
no_read: false,
name: "Bot".into(),
report: None,
brand: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -167,16 +182,20 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
const BEHAVIOURS: [&str; 14] = [
const BEHAVIOURS: [&str; 15] = [
"idle",
"walk",
"explore",
"mixed",
"stand",
"flood-move",
"flood-acks",
"flood-packets",
@@ -219,6 +238,11 @@ struct Bot {
joined_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<(Option<u128>, Option<i32>)>,
attacks: AtomicU64,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -398,6 +422,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
.encode(),
)
.map_err(err)?;
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
if let Some(brand) = &o.brand {
let mut w = Writer::new();
w.string(brand);
c.send(
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
channel: "minecraft:brand".into(),
data: w.data,
})
.encode(),
)
.map_err(err)?;
}
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
@@ -425,8 +462,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
configuration::Clientbound::Disconnect(p) => {
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
return Err(format!("disconnected during configuration: {reason}"));
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
@@ -531,6 +569,21 @@ fn read(
}
Ok(())
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
let mut target = lock(&bot.target);
if target.0 == Some(p.uuid) {
target.1 = Some(p.id);
}
}
Ok(())
}
cb::SET_HEALTH => {
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
*lock(&bot.health) = Some(p.health);
}
Ok(())
}
cb::CHUNK_BATCH_START => {
batch.started = Instant::now();
Ok(())
@@ -618,6 +671,7 @@ fn run_bot(
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
@@ -665,6 +719,7 @@ fn run_bot(
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
let (joined, mut tick) = (Instant::now(), 0u64);
loop {
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
@@ -675,7 +730,9 @@ fn run_bot(
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::FloodMove {
if behaviour == Behaviour::Stand {
// Where the server put it, on the ground.
} else if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
@@ -711,7 +768,7 @@ fn run_bot(
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
@@ -776,12 +833,21 @@ fn run_bot(
send(body);
}
}
let target = lock(&bot.target).1;
if let Some(id) = target
&& joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
{
send(play::Serverbound::Attack(play::Attack { id }).encode());
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
tick += 1;
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
flags: u8::from(behaviour == Behaviour::Stand),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -805,6 +871,7 @@ fn run_bot(
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"flood-move" => Behaviour::FloodMove,
@@ -918,6 +985,8 @@ fn main() {
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *lock(&b.error),
"attacks": b.attacks.load(Ordering::Relaxed),
"health": *lock(&b.health),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
@@ -3673,6 +3673,57 @@ mod tests {
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
// #3539: Zoom's key set to R showed another key.
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
s.mods.installed.push(crate::mod_list::Installed {
id: "zoom".into(),
title: "Zoom 2.0.0".into(),
description: String::new(),
problem: None,
dev: false,
native: Some(crate::mod_list::NativePage {
keys: vec![["zoom".into(), "Zoom key".into(), "key.keyboard.z".into()]],
buttons: Vec::new(),
}),
});
ui.show(Page::NativeMod(0));
let key_label = |ui: &mut OptionsUi, s: &Settings| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets.into_iter().find_map(|w| match w.kind {
WKind::Button {
label,
act: Act::NativeKey(0, 0),
..
} => Some((label.plain(), w.x, w.y)),
_ => None,
})
};
let (label, x, y) = key_label(&mut ui, &s).expect("the key's row");
assert_eq!(label, "Z");
ui.click(
&mut s,
&atlas,
x + 1.0,
y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyR, Some("r")),
vec![Event::Changed]
);
assert_eq!(key_label(&mut ui, &s).unwrap().0, "R");
let saved = crate::mod_list::ModList::parse(&s.mods.serialize());
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
@@ -27,6 +27,10 @@ pub struct Span {
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
/// Whether spans are being recorded.
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
+47 -8
View File
@@ -159,9 +159,11 @@ mod music;
mod names;
mod native_hooks;
mod native_lifecycle;
mod native_menus;
mod native_panel;
mod native_panel_recipes;
mod native_recipes;
mod native_server;
mod native_shaders;
mod native_world_read;
mod natural_spawning;
@@ -767,6 +769,15 @@ struct GameLevel<'w> {
client_side: bool,
/// Only the integrated or dedicated server dispatches native observations.
native_authoritative: bool,
native_particles: &'w mut Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<BlockStateId>,
[f64; 3],
[f64; 3],
f64,
i32,
)>,
}
struct PlayerCollision {
y: f64,
@@ -3299,9 +3310,13 @@ impl Game<'_> {
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
native_particles: &mut self.creatures.lan_particles,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
if self.lan.is_some() {
self.flush_lan_particles();
}
result
}
/// Level.setBlock(pos, state, UPDATE_ALL) with neighbour/shape updates.
@@ -4539,9 +4554,16 @@ impl Game<'_> {
{
return Ok(());
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
// ServerPlayerGameMode.destroyBlock.
let state = self.world.block(pos[0], pos[1], pos[2]);
// Native mods, once the game allowed the break: a canceled one keeps
// the block, and a guest's acknowledgement puts it back in its world.
let mut canceled = false;
self.native_destroy_block(pos, state, &mut canceled);
if canceled {
return Ok(());
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
@@ -4951,6 +4973,21 @@ impl Game<'_> {
{
return Ok(false);
}
// Native mods, where ItemStack.useOn and Item.use start: the block
// aimed at had its turn, and the item is off cooldown. A guest's
// UseItemOn reaches only useOn, its UseItem only use.
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
let mut claimed = false;
if let Some(hit) = block_hit {
self.native_use_item_on(&stack, hit.pos, hit.face, &mut claimed);
}
if !claimed && !on_block {
self.native_use_item(&stack, &mut claimed);
}
if claimed {
self.swing_arm();
return Ok(true);
}
// ItemStack.useOn: after the block's own useItemOn/useWithoutItem
// passed. DebugStickItem.useOn cycles the selected property.
if let Some(hit) = block_hit
@@ -4974,7 +5011,6 @@ impl Game<'_> {
}
// A guest's UseItemOn stops at ItemStack.useOn: what Item.use does
// (throw, drink, eat, raise, equip) waits for its UseItem.
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
let spawn_egg = stack.item.ends_with("_spawn_egg");
use horizon_client::first_person::UseResult;
let swings = self.swings_started;
@@ -5164,6 +5200,7 @@ impl Game<'_> {
// BlockItem.place: the used statistic and the placed_block trigger.
self.stats.add("used", &stack.item, 1);
self.trigger_block("minecraft:placed_block", target, &stack);
self.native_place_block(&stack, target, state);
self.check_wither_spawn(target)?;
self.check_golem_spawn(target)?;
self.consume_held();
@@ -5785,10 +5822,10 @@ impl Game<'_> {
self.ticks += 1;
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_lifecycle_server_tick_start(running);
}
let running = match self.level_pass.running {
Some(running) => running,
None => self.clock.world_runs(),
};
// GameRenderer.tick: a wither's bar darkens the world, then fades;
// the activated item counts down.
self.boss_overlay.tick_darken(running);
@@ -9337,7 +9374,7 @@ impl Game<'_> {
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&arc, at, spread, 0.0, 0);
self.lan_send_particles(self.dim.id(), &arc, None, at, spread, 0.0, 0);
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
@@ -9567,11 +9604,13 @@ impl Game<'_> {
);
// The players a server serves are sent them too.
self.lan_send_particles(
self.dim.id(),
&indicator,
None,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
(actual * 0.5) as u32 as i32,
);
}
self.last_hurt_by_player
+14 -6
View File
@@ -92,11 +92,14 @@ impl Game<'_> {
.unwrap_or_default()
}
/// Runs `f` on the heart at `key` with the game's mobs and level random,
/// then applies what the heart reported.
/// then applies what the heart reported. `others`: whether `f` reads the
/// other mobs through the mob world (without, it holds none: building them
/// costs a pass over every mob).
fn with_heart(
&mut self,
key: Pos,
heart: &mut CreakingHeart,
others: bool,
f: impl FnOnce(
&mut CreakingHeart,
&horizon_client::mobs::MobWorld,
@@ -112,7 +115,11 @@ impl Game<'_> {
let spawning = self.spawning_monsters();
let creaking = self.res.mob_types().get("minecraft:creaking").cloned();
let player_uuid = self.level.player_uuid;
let snapshot: Vec<_> = mobs.iter().map(|m| seen(m, player_uuid)).collect();
let snapshot: Vec<_> = if others {
mobs.iter().map(|m| seen(m, player_uuid)).collect()
} else {
Vec::new()
};
let mut events = Vec::new();
let added = {
let w = self.mob_world_base(&snapshot);
@@ -168,7 +175,7 @@ impl Game<'_> {
else {
return;
};
let _ = self.with_heart(key, &mut heart, |h, w, lr, host, out| {
let _ = self.with_heart(key, &mut heart, true, |h, w, lr, host, out| {
creaking_heart::creaking_hurt(h, home, w, lr, host, out);
});
self.world
@@ -232,7 +239,7 @@ impl Game<'_> {
else {
return;
};
let _ = self.with_heart(key, &mut heart, |h, _, _, host, out| {
let _ = self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
creaking_heart::remove_protector(h, host, true, out);
});
let state = self.world.block(key.0, key.1, key.2);
@@ -270,7 +277,7 @@ impl Game<'_> {
if &*self.res.facts().get(state).name != "minecraft:creaking_heart" {
// The block is gone: preRemoveSideEffects.
self.world.block_entities.forget(&key);
self.with_heart(key, &mut heart, |h, _, _, host, out| {
self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
creaking_heart::remove_protector(h, host, false, out);
})?;
return Ok(());
@@ -278,7 +285,8 @@ impl Game<'_> {
// getTicker: none once uprooted.
if self.heart_state_name(state) != "uprooted" {
let pos = [key.0, key.1, key.2];
self.with_heart(key, &mut heart, |h, w, lr, host, out| {
let others = creaking_heart::tick_updates(&heart);
self.with_heart(key, &mut heart, others, |h, w, lr, host, out| {
creaking_heart::server_tick(h, pos, w, lr, host, out);
})?;
}
+3 -1
View File
@@ -71,11 +71,13 @@ pub(super) struct Creatures {
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<horizon_world::registry::BlockStateId>,
[f64; 3],
[f64; 3],
f64,
u32,
i32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
+54 -1
View File
@@ -34,6 +34,7 @@ use std::{
time::{Duration, Instant},
};
mod report;
mod status;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
@@ -505,6 +506,11 @@ struct ServerState {
broadcast_console: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
/// `horizon-report`: the ticks being recorded.
report: Option<report::Recording>,
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -734,6 +740,10 @@ impl Game<'_> {
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
levels: self.level_shapes,
horizon_clients_only: props
.raw
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
@@ -806,6 +816,12 @@ impl Game<'_> {
paused: false,
broadcast_console: props.broadcast_console_to_ops,
op_level: props.op_permission_level,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
props.simulation_distance.clamp(2, 32) as u32,
),
max_players: props.max_players.max(0) as usize,
};
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
@@ -848,7 +864,17 @@ impl Game<'_> {
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
self.tick_server_timed(paused)?;
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
})?,
None => self.tick_server_timed(paused)?,
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
{
self.finish_report(recording, state.view, state.max_players);
}
if idle_timeout > 0 {
self.kick_idle(idle_timeout);
}
@@ -1046,6 +1072,33 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.stop.stopping", &[]));
state.stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Ok(60),
Some(Ok(s)) if (1..=report::MAX_SECONDS).contains(&s) => Ok(s),
Some(_) => Err(()),
};
match (seconds, &state.report) {
(Err(()), _) => log(
"INFO",
&format!(
"horizon-report [SECONDS]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
(Ok(_), Some(_)) => {
log("INFO", "A performance report is already being recorded")
}
(Ok(seconds), None) => {
state.report = Some(report::Recording::start(seconds));
log(
"INFO",
&format!("Recording a performance report for {seconds} s (reports/)"),
);
}
}
}
"save-all" => {
self.console_reply(Text::translate("commands.save.saving", &[]));
match self.save_game() {
@@ -0,0 +1,333 @@
//! `horizon-report [SECONDS]` on the dedicated server's console (and `horizon-server report`): records every
//! tick's time and spans for a window (60 s unless told), then writes a performance report to share,
//! `reports/perf-<time>.json` and a short `.txt` beside it in the server's folder. It says where the ticks'
//! time went (the spans of `HORIZON_SPIKES`), shows the worst ticks with their spans, counts the entities by
//! type and the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player
//! names, UUIDs or addresses, no seed and no world name.
use super::super::{Dim, Game};
use horizon_client::spans::{self, Span};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
/// The longest window a report records.
pub(super) const MAX_SECONDS: u32 = 600;
/// The worst ticks a report lists with their spans.
const WORST: usize = 10;
struct Tick {
wall_ms: f32,
cpu_ms: Option<f32>,
spans: Vec<Span>,
}
/// A report being recorded.
pub(super) struct Recording {
seconds: u32,
started: Instant,
cpu_s: Option<f64>,
/// Whether spans were on before (`HORIZON_SPIKES`): they stay on after.
spans_were_on: bool,
ticks: Vec<Tick>,
}
impl Recording {
pub(super) fn start(seconds: u32) -> Self {
let spans_were_on = spans::enabled();
spans::enable(true);
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
}
}
pub(super) fn add(&mut self, wall_ms: f32, cpu_ms: Option<f32>, spans: Vec<Span>) {
self.ticks.push(Tick {
wall_ms,
cpu_ms,
spans,
});
}
pub(super) fn done(&self) -> bool {
self.started.elapsed() >= Duration::from_secs(u64::from(self.seconds))
}
}
fn pct(sorted: &[f32], q: f64) -> f64 {
let i = ((sorted.len() as f64 * q) as usize).min(sorted.len().saturating_sub(1));
sorted.get(i).map_or(0.0, |&v| f64::from(v))
}
fn round(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
/// The tick times and spans of a recording, as the report's `ticks` and `spans`.
fn tick_summary(r: &Recording, window_s: f64) -> (Value, Value, Value) {
let mut walls: Vec<f32> = r.ticks.iter().map(|t| t.wall_ms).collect();
walls.sort_by(f32::total_cmp);
let n = r.ticks.len().max(1) as f64;
let mean = |f: &dyn Fn(&Tick) -> f64| r.ticks.iter().map(f).sum::<f64>() / n;
let ticks = json!({
"count": r.ticks.len(),
"tps": round(r.ticks.len() as f64 / window_s.max(1e-9)),
"mspt": {
"average": round(mean(&|t| f64::from(t.wall_ms))),
"p50": round(pct(&walls, 0.5)),
"p95": round(pct(&walls, 0.95)),
"p99": round(pct(&walls, 0.99)),
"max": round(walls.last().copied().map_or(0.0, f64::from)),
},
"cpu_ms_average": round(mean(&|t| f64::from(t.cpu_ms.unwrap_or(t.wall_ms)))),
"over_50_ms": walls.iter().filter(|&&w| w > 50.0).count(),
"over_100_ms": walls.iter().filter(|&&w| w > 100.0).count(),
});
let mut totals: BTreeMap<&str, (f64, f64, usize)> = BTreeMap::new();
for t in &r.ticks {
for s in &t.spans {
let e = totals.entry(s.name).or_default();
e.0 += f64::from(s.ms);
e.1 += f64::from(s.cpu_ms);
e.2 += 1;
}
}
let mut by_time: Vec<_> = totals.into_iter().collect();
by_time.sort_by(|a, b| b.1.0.total_cmp(&a.1.0));
let tick_ms = mean(&|t| f64::from(t.wall_ms)).max(1e-9);
let spans = Value::Array(
by_time
.iter()
.map(|(name, (ms, cpu, seen))| {
json!({
"name": name,
"ms_per_tick": round(ms / n),
"cpu_ms_per_tick": round(cpu / n),
"share_of_tick": round(ms / n / tick_ms),
"ticks_seen": seen,
})
})
.collect(),
);
let mut worst: Vec<&Tick> = r.ticks.iter().collect();
worst.sort_by(|a, b| b.wall_ms.total_cmp(&a.wall_ms));
let worst = Value::Array(
worst
.into_iter()
.take(WORST)
.map(|t| {
json!({
"ms": round(f64::from(t.wall_ms)),
"cpu_ms": t.cpu_ms.map(|c| round(f64::from(c))),
"spans": t.spans.iter().take(8).map(|s| json!([s.name, round(f64::from(s.ms))])).collect::<Vec<_>>(),
})
})
.collect(),
);
(ticks, spans, worst)
}
impl Game<'_> {
/// The level in place and the parked ones: their chunks and their entities by type.
fn report_levels(&self) -> Value {
let mut levels = Vec::new();
let here = |dim: Dim,
world: &horizon_client::world::World,
mobs: &[horizon_client::mobs::Mob]| {
let mut by_type: BTreeMap<&str, usize> = BTreeMap::new();
for m in mobs.iter().filter(|m| !m.removed) {
*by_type.entry(&m.kind.id).or_default() += 1;
}
let mut by_type: Vec<_> = by_type.into_iter().collect();
by_type.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
json!({
"dimension": dim.id(),
"chunks": world.chunks.len(),
"block_entities": world.block_entities.len(),
"mobs": mobs.iter().filter(|m| !m.removed).count(),
"mobs_by_type": by_type.into_iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
};
let mut current = here(self.dim, &self.world, &self.mobs);
current["item_entities"] = json!(self.dropped.len());
current["experience_orbs"] = json!(self.orbs.len());
current["projectiles"] = json!(self.creatures.projectiles.len());
current["minecarts"] = json!(self.carts.len());
current["boats"] = json!(self.boats.len());
levels.push(current);
for dim in self.levels.parked_dims() {
if let (Some(world), Some(mobs)) =
(self.levels.parked_world(dim), self.levels.parked_mobs(dim))
{
levels.push(here(dim, world, mobs));
}
}
Value::Array(levels)
}
/// The report of a finished recording: written to `reports/` in the server's folder, its summary logged.
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
if !r.spans_were_on {
spans::enable(false);
}
let window_s = r.started.elapsed().as_secs_f64();
let (ticks, spans, worst) = tick_summary(&r, window_s);
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
let report = json!({
"format": 1,
"horizon": horizon_client::crash::version(),
"minecraft": horizon_core::VANILLA_VERSION,
"window_s": round(window_s),
"ticks": ticks,
"spans": spans,
"worst_ticks": worst,
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"cpu_cores_used": cpu,
"config": {
"view_distance": view.0,
"simulation_distance": view.1,
"max_players": max_players,
},
});
let text = summary(&report);
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
"Horizon: performance report written to {}\n{text}",
path.display()
),
Err(e) => log::error!("Horizon: could not write the performance report: {e}"),
}
});
}
}
/// A few lines for the console: the tick rate and times, the five costliest spans, the worst tick.
fn summary(r: &Value) -> String {
let t = &r["ticks"];
let m = &t["mspt"];
let mut out = format!(
"{} players, TPS {}, MSPT average {} ms, P50 {}, P95 {}, P99 {}, max {} ({} ticks over 50 ms, {} over 100 ms)\n",
r["players"],
t["tps"],
m["average"],
m["p50"],
m["p95"],
m["p99"],
m["max"],
t["over_50_ms"],
t["over_100_ms"],
);
let spans: Vec<String> = r["spans"]
.as_array()
.into_iter()
.flatten()
.take(5)
.map(|s| {
format!(
"{} {} ms",
s["name"].as_str().unwrap_or(""),
s["ms_per_tick"]
)
})
.collect();
out += &format!("per tick: {}\n", spans.join(", "));
if let Some(w) = r["worst_ticks"].get(0) {
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
}
let mobs: u64 = r["levels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|l| l["mobs"].as_u64())
.sum();
let chunks: u64 = r["levels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|l| l["chunks"].as_u64())
.sum();
out += &format!(
"{mobs} mobs, {chunks} chunks, {} MB resident, {} cores of CPU",
r["memory"]["rss_mb"], r["cpu_cores_used"]
);
out
}
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
let path = dir.join(format!("perf-{stamp}.json"));
std::fs::write(&path, serde_json::to_string_pretty(report)? + "\n")?;
std::fs::write(path.with_extension("txt"), format!("{text}\n"))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn span(name: &'static str, ms: f32) -> Span {
Span {
name,
ms,
cpu_ms: ms,
}
}
#[test]
fn ticks_and_spans_are_summed_per_tick() {
let mut r = Recording {
seconds: 1,
started: Instant::now(),
cpu_s: None,
spans_were_on: true,
ticks: Vec::new(),
};
for i in 0..20 {
let wall = if i == 7 { 120.0 } else { 10.0 };
r.add(
wall,
Some(wall - 1.0),
vec![span("tick_mobs", wall / 2.0), span("tick_lan", 2.0)],
);
}
let (ticks, spans, worst) = tick_summary(&r, 1.0);
assert_eq!(ticks["count"], 20);
assert_eq!(ticks["tps"], 20.0);
assert_eq!(ticks["over_100_ms"], 1);
assert_eq!(ticks["mspt"]["max"], 120.0);
assert_eq!(spans[0]["name"], "tick_mobs");
assert_eq!(spans[0]["ms_per_tick"], 7.75);
assert_eq!(spans[1]["ms_per_tick"], 2.0);
assert_eq!(worst[0]["ms"], 120.0);
assert_eq!(worst[0]["spans"][0], json!(["tick_mobs", 60.0]));
let text = summary(
&json!({"ticks": ticks, "spans": spans, "worst_ticks": worst, "players": 3,
"levels": [{"mobs": 5, "chunks": 9}], "memory": {"rss_mb": 300}, "cpu_cores_used": 1.5}),
);
assert!(text.starts_with("3 players, TPS 20.0"), "{text}");
assert!(
text.contains("per tick: tick_mobs 7.75 ms, tick_lan 2.0 ms"),
"{text}"
);
assert!(
text.ends_with("5 mobs, 9 chunks, 300 MB resident, 1.5 cores of CPU"),
"{text}"
);
}
}
+9 -1
View File
@@ -476,7 +476,15 @@ impl Game<'_> {
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
self.lan_send_particles(
self.dim.id(),
&options,
None,
at,
spread,
speed,
count as i32,
);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
+74
View File
@@ -93,7 +93,63 @@ pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) ->
}
}
/// `AttackRange.isInRange(attacker, box, buffer)` with the reach of
/// `attack_reach` (or `AttackRange.defaultFor`'s, from the entity interaction
/// range): the eye's distance to the box, inclusive.
fn in_attack_range(distance: f64, (min, max, margin): (f32, f32, f32), buffer: f64) -> bool {
distance >= f64::from(min - margin) - buffer && distance <= f64::from(max + margin) + buffer
}
impl Game<'_> {
/// `getAttributeValue` of an interaction range of the player in place (a
/// guest swapped in on a server): creative's modifiers are on it
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
let otherwise = if self.creative { creative } else { survival };
self.player
.as_mut()
.and_then(|p| p.attribute(attribute).ok())
.unwrap_or(otherwise)
}
/// ServerGamePacketListenerImpl.handleAttack's
/// `Player.isWithinAttackRange(mainHandItem, box, 3.0)` for the player in
/// place: the held item's `attack_range`, else `AttackRange.defaultFor`
/// (0 to the entity interaction range, no margin). Out of range, vanilla
/// drops the attack without a word.
pub(super) fn within_attack_range(&mut self, target: &Aabb) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let range = self.held().and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
let reach = match &range {
Some(r) => attack_reach(r, self.creative),
None => {
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
(0.0, range as f32, 0.0)
}
};
let distance = target.distance_squared_to_point(v3(eye)).sqrt();
in_attack_range(distance, reach, 3.0)
}
/// `Player.isWithinEntityInteractionRange(box, buffer)` for the player in
/// place (ServerGamePacketListenerImpl.handleInteract, with 3).
pub(super) fn within_entity_interaction_range(&mut self, target: &Aabb, buffer: f64) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0) + buffer;
target.distance_squared_to_point(v3(eye)) < range * range
}
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
/// a new stack: the hand holding it compares it as another `ItemStack`
/// (`shows_held_stack`) until the hand shows the held stack again.
@@ -717,6 +773,24 @@ mod tests {
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
#[test]
fn the_server_takes_attacks_within_vanilla_reach_only() {
// handleAttack: AttackRange.defaultFor (0 to the entity interaction
// range, no margin) plus 3, inclusive: 6 in survival, 8 in creative.
let survival = (0.0, 3.0, 0.0);
assert!(in_attack_range(6.0, survival, 3.0));
assert!(!in_attack_range(6.01, survival, 3.0));
assert!(!in_attack_range(50.0, survival, 3.0));
let creative = (0.0, 5.0, 0.0);
assert!(in_attack_range(8.0, creative, 3.0));
assert!(!in_attack_range(8.01, creative, 3.0));
// A spear's own range, with its margin, from the server's side too.
let spear = serde_json::json!({"min_reach": 2.0, "max_reach": 4.5, "hitbox_margin": 0.125});
let reach = attack_reach(&spear, false);
assert!(in_attack_range(7.625, reach, 3.0));
assert!(!in_attack_range(7.7, reach, 3.0));
}
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
+13
View File
@@ -93,6 +93,9 @@ pub(super) struct LanHost {
pub commands: Vec<super::lan_actions::GuestCommand>,
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
/// Native mods' commands and the level each needs, once asked
/// (game/native_server.rs).
pub native_commands: Option<HashMap<String, u8>>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
@@ -1239,6 +1242,7 @@ impl Game<'_> {
border_sent: None,
commands: Vec::new(),
quiet: std::collections::VecDeque::new(),
native_commands: None,
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
@@ -1615,6 +1619,7 @@ impl Game<'_> {
};
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
self.register_native_commands(&mut host);
// The host's own commands' (and command blocks') players moving
// to another level, and their broadcastToAdmins lines.
if !self.level_teleports.is_empty() {
@@ -1846,6 +1851,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
.lan
.take()
.expect("the hub is back after the menu events");
}
self.lan_flush_effects(&mut host);
if !paused {
self.tick_guests(&mut host);
+92 -4
View File
@@ -954,6 +954,8 @@ impl Game<'_> {
};
horizon_client::containers::display_name(custom(k), key)
}
// Opened by a mod (`native_open_menu`), never here.
Slots::Native => return false,
};
let Some(items) = host
.hub
@@ -1255,12 +1257,29 @@ impl Game<'_> {
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
// handleUseItemOn: within reach, and the hit point within the block.
// handleUseItemOn: within reach, then the hit point within
// a block of the block's centre (logged when it isn't).
if !host.hub.guests[gi].may_reach_block(p.pos) {
return;
}
let inside = p
.cursor
.iter()
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
if !inside {
let d = horizon_client::java_text::double_to_string;
let at = |k: usize| d(f64::from(p.pos[k]) + f64::from(p.cursor[k]));
let line = format!(
"Rejecting UseItemOnPacket from {}: Location ({}, {}, {}) too far away from hit block BlockPos{{x={}, y={}, z={}}}.",
host.hub.guests[gi].name,
at(0),
at(1),
at(2),
p.pos[0],
p.pos[1],
p.pos[2]
);
self.lan_log_event(horizon_client::lan::hub::HostEvent::Warn(line));
return;
}
let face = direction(p.face);
@@ -1341,6 +1360,15 @@ impl Game<'_> {
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
// handleInteract: within the entity interaction range plus 3
// of the target's box, or nothing happens.
if let Some(target) = self.guest_target_box(host, gi, p.id)
&& self.as_guest(host, conn, |g| {
g.within_entity_interaction_range(&target, 3.0)
}) != Some(true)
{
return;
}
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
@@ -1928,6 +1956,42 @@ impl Game<'_> {
}
}
/// `Level.getEntityOrPart(id).getBoundingBox()` for a guest's attack or
/// interaction: the host, another player, a mob, a vehicle or object, a
/// dragon part or crystal, a projectile. None for an id the level doesn't
/// have (the handlers then find nothing either).
fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let conn = host.hub.guests[gi].conn;
if id == host.host_entity {
return self.player.as_ref().map(|p| p.bounds());
}
if let Some(g) = host
.hub
.guests
.iter()
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g))
{
return Some(g.bounds());
}
if let Some(m) = self.mobs.iter().find(|m| m.id as i32 == id) {
return Some(m.bounds());
}
if let Some((lo, hi, _)) = self.object_target(id) {
return Some(Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
));
}
if let Some(b) = self.tracked_special_box(id) {
return Some(b);
}
self.creatures
.projectiles
.iter()
.find(|p| p.id != 0 && p.id as i32 == id)
.map(|p| p.bounds())
}
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
@@ -1935,6 +1999,13 @@ impl Game<'_> {
if host.hub.guests[gi].spectator() {
return;
}
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
// anything else, players, mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
{
return;
}
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
@@ -2025,11 +2096,26 @@ impl Game<'_> {
);
return;
}
// A native mod's command, for every player its level allows,
// whether the world allows commands or not (game/native_server.rs).
let integrated = host.hub.config.integrated;
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
&& permission >= level
{
host.commands.push(GuestCommand {
conn,
command: command.trim_start_matches('/').to_owned(),
permission,
integrated,
quiet: false,
});
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`); a LAN world
// follows its Allow Commands, and a dedicated server leaves it to
// the ops list (the guest's permission level).
let integrated = host.hub.config.integrated;
let allowed = self.options.dedicated.is_some() || self.allow_commands();
if !integrated && (!host.settings.guest_commands || !allowed) {
host.hub.send_to(
@@ -2083,7 +2169,9 @@ impl Game<'_> {
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
let ok = if c.quiet {
let ok = if self.perform_native_command(c.conn, c.permission, &c.command) {
true
} else if c.quiet {
self.run_command_quietly(&c.command)
} else {
self.run_command(&c.command)
+25 -11
View File
@@ -367,24 +367,32 @@ impl Game<'_> {
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
#[allow(clippy::too_many_arguments)]
pub(super) fn lan_send_particles(
&mut self,
dimension: &'static str,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
count: i32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
if self.serving.is_some() || self.options.dedicated.is_some() {
self.creatures.lan_particles.push((
dimension,
options.clone(),
state,
at,
spread,
speed,
count,
));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
let Some(particle) = self.network_particle(options, state) else {
return;
};
let Some(host) = self.lan.as_ref() else {
@@ -397,11 +405,16 @@ impl Game<'_> {
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
count,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
for g in host
.hub
.guests
.iter()
.filter(|g| g.placed && g.dimension == dimension)
{
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
@@ -415,9 +428,10 @@ impl Game<'_> {
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
for (dimension, options, state, at, spread, speed, count) in
std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
self.lan_send_particles(dimension, &options, state, at, spread, speed, count);
}
}
+45 -11
View File
@@ -89,11 +89,14 @@ pub(super) struct Parked {
/// Which passes of the server tick the level in place runs: the first
/// reads the network (`Hub::poll`), the last ends the tick for every player
/// (`Hub::end_tick`). A tick with one level is both.
/// (`Hub::end_tick`). A tick with one level is both. `running` is the
/// tick rate manager's answer for this server tick, asked once before the
/// levels (`MinecraftServer.tickServer`); `None` outside `tick_server`.
#[derive(Clone, Copy)]
pub(super) struct Pass {
pub first: bool,
pub last: bool,
pub running: Option<bool>,
}
impl Default for Pass {
@@ -101,6 +104,7 @@ impl Default for Pass {
Self {
first: true,
last: true,
running: None,
}
}
}
@@ -122,6 +126,11 @@ impl Levels {
self.parked.get_mut(&dim)
}
/// The mobs of a parked level.
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
self.parked.get(&dim).map(|level| level.mobs.as_slice())
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
@@ -339,8 +348,19 @@ impl Game<'_> {
/// and loaded and its world ticked. The first pass reads the network and runs the
/// tick's shared parts (the clock, the weather, functions, the autosave);
/// the next ones only their level. While `paused`, only the connections
/// run.
/// run. The native server tick hook brackets it (`ServerTickStart` before
/// the first level, `ServerTickEnd` after the last).
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
// `tickRateManager.tick()` once per server tick, before any level
// (each `ServerLevel.tick` only reads `runsNormally`), and the native
// `ServerTickStart` before that level work, where NeoForge fires
// `ServerTickEvent.Pre`. A paused pass and a held server tick nothing.
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
if let Some(running) = running
&& (self.serving.is_some() || self.options.dedicated.is_some())
{
self.native_lifecycle_server_tick_start(running);
}
let dims = self.levels_to_tick();
let n = dims.len();
let mut result = Ok(());
@@ -352,6 +372,7 @@ impl Game<'_> {
self.level_pass = Pass {
first: k == 0,
last: k + 1 == n,
running,
};
self.update_dedicated_streaming();
self.update_world();
@@ -361,7 +382,7 @@ impl Game<'_> {
} else if k == 0 {
self.tick()
} else {
let running = self.clock.world_runs();
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
@@ -389,26 +410,39 @@ impl Game<'_> {
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
/// tick longer than MS is logged with its thread CPU time and spans.
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
let Some(threshold) = self.options.spikes else {
self.tick_server_spans(paused, |_, _, _| {})
}
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
/// and spans (milliseconds) to `record` while spans are on: a dedicated
/// server's performance report turns them on to record its ticks.
pub(super) fn tick_server_spans(
&mut self,
paused: bool,
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>),
) -> Result<()> {
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
return self.tick_server(paused);
};
}
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
let result = self.tick_server(paused);
let wall = started.elapsed().as_secs_f32() * 1000.0;
let cpu = horizon_client::spans::thread_cpu()
.zip(cpu)
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
// Taken every tick: nothing else takes this thread's spans.
let spans = horizon_client::spans::take();
if wall > threshold {
let cpu = horizon_client::spans::thread_cpu()
.zip(cpu)
.map_or_else(String::new, |(end, start)| {
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
});
if let Some(threshold) = self.options.spikes
&& wall > threshold
{
let cpu = cpu.map_or_else(String::new, |c| format!(", cpu {c:.1} ms"));
log::info!(
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
self.mobs.len(),
horizon_client::spans::describe(&spans)
);
}
record(wall, cpu, spans);
result
}
+184 -4
View File
@@ -6,6 +6,25 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 14 adds breaking and placing a block (`native_destroy_block`,
//! `native_place_block`).
//!
//! API 13 adds using an item (`native_use_item_on`, `native_use_item`).
//!
//! API 12 adds item tooltips (`native_tooltip`).
//!
//! API 11 adds server particle emission (`native_send_particles`).
//!
//! API 10 adds server menus (`native_menu_clicked`, `native_menu_closed`,
//! and `native_open_menu`, `native_exchange_items`... in `native_server.rs`).
//!
//! API 9 adds the acting player's plain items (`native_held`,
//! `native_count_items`, `native_take_items`, `native_give_items`... in
//! `native_server.rs`).
//!
//! API 8 adds server commands and players (`native_register_commands`,
//! `native_perform_command`, and the helpers in `native_server.rs`).
//!
//! API 7 adds block-change observations (`native_block_changed`).
//!
//! API 6 adds neighbour-change observations (`native_neighbor_changed`).
@@ -31,16 +50,20 @@
//! either (`tests::hook_points_stay_hookable`).
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
use super::{
Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole,
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
native_panel_recipes::RecipePage,
native_server::{NativeCommand, NativeServerPlayer},
};
use horizon_client::{
chat::ChatSource,
extras::Corner,
extras_hud::Corners,
hud::{HudBatch, rgba},
items::ItemStack,
keybinds,
mod_list::{self, Installed, NativePage},
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::position::BlockPos;
@@ -51,7 +74,7 @@ use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 7;
pub(crate) const NATIVE_HOOKS_API: u32 = 14;
impl GameLevel<'_> {
/// An authoritative server committed a changed state through `Level.setBlock`.
@@ -326,8 +349,11 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The authoritative server tick's phases, read-only. A paused pass
/// fires nothing; a frozen world carries `world_running` false except
/// The authoritative server tick's phases: `ServerTickStart` before any
/// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd`
/// after the last (`.Post`). Read blocks with `native_server_block`, by
/// dimension: which level `self.world` holds here is not defined. A paused
/// pass fires nothing; a frozen world carries `world_running` false except
/// during a requested step.
pub(super) fn native_server_tick(
&mut self,
@@ -386,9 +412,163 @@ impl Game<'_> {
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
let _ = (mod_id, event);
}
/// The commands native mods add to the server's command tree: push
/// yours. Asked once when a world opens to players (a dedicated
/// server, a LAN host, single player's server thread); every player's
/// chat box then completes them as vanilla's, within their level. A
/// name vanilla has is left out. API 8.
pub(super) fn native_register_commands(&mut self, commands: &mut Vec<NativeCommand>) {
let _ = commands;
}
/// A player's command on the server (`Commands.performCommand`),
/// without its slash, for a command a mod registered and the player's
/// level allows: set `ran` when it is yours. The player's state is
/// swapped in for the call, so `native_player` and the other helpers
/// read that player; `native_feedback` answers them. API 8.
pub(super) fn native_perform_command(
&mut self,
player: &NativeServerPlayer,
command: &str,
ran: &mut bool,
) {
let _ = (player, command, ran);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
/// and the click's `mode` (`ClickType`'s id) of the player `uuid`.
/// No player's state is swapped in: the menu helpers take the player's
/// UUID. API 10.
pub(super) fn native_menu_clicked(
&mut self,
uuid: u128,
window: i32,
slot: i32,
button: i32,
mode: i32,
) {
let _ = (uuid, window, slot, button, mode);
}
/// A menu a mod opened closed: the player closed it, the server or a mod
/// opened another, or the player left. API 10.
pub(super) fn native_menu_closed(&mut self, uuid: u128, window: i32) {
let _ = (uuid, window);
}
/// An item's tooltip, wherever the game shows one (slots, the creative
/// tabs, the recipe book, chat's hover): the stack, with its components
/// (`stack.data()`: a shulker box's contents...), and its lines, the name
/// first, each with a colour as `rgba` packs it (`u32::MAX` keeps the
/// text's own). Add, change or take out lines; `Text::legacy` reads `§`
/// codes. Asked each frame the tooltip shows, never for one the stack
/// hides. API 12.
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
let _ = (stack, lines);
}
/// A player uses `stack` (right click) with `self.hand` on the block at
/// `pos`, `face` the face clicked (`[0, 1, 0]` is the top), once the
/// block itself let the click through (a chest still opens): where
/// `ItemStack.useOn` starts. Set `claimed` to take the use: the hand
/// swings and neither the item's `useOn` nor its `use` runs.
/// Server-side, for every player of the world (the helpers read the
/// acting one); an item on cooldown is never used. API 13.
pub(super) fn native_use_item_on(
&mut self,
stack: &ItemStack,
pos: [i32; 3],
face: [i32; 3],
claimed: &mut bool,
) {
let _ = (stack, pos, face, claimed);
}
/// A player uses `stack` (right click) with `self.hand`: in the air, or
/// on a block once `native_use_item_on` let it through, where `Item.use`
/// starts. Each click reaches it once. Set `claimed` to take the use: the
/// hand swings and the item's own use doesn't run. Server-side, for every
/// player of the world; an item on cooldown is never used. API 13.
pub(super) fn native_use_item(&mut self, stack: &ItemStack, claimed: &mut bool) {
let _ = (stack, claimed);
}
/// A player breaks the block `state` at `pos`, once the game allowed it
/// (the held item can break blocks, the debug stick didn't take the
/// click): where `ServerPlayerGameMode.destroyBlock` starts. Set
/// `canceled` to keep the block: nothing is destroyed or dropped, and a
/// guest sees it back. Server-side, for every player of the world (the
/// helpers read the acting one). API 14.
pub(super) fn native_destroy_block(
&mut self,
pos: [i32; 3],
state: BlockStateId,
canceled: &mut bool,
) {
let _ = (pos, state, canceled);
}
/// A player placed `stack` as the block `state` at `pos`: where
/// `BlockItem.place` set it, before the stack shrinks. It only hears; a
/// mod stops a placement by claiming `native_use_item_on`, which runs
/// before it. Server-side, for every player of the world. API 14.
pub(super) fn native_place_block(
&mut self,
stack: &ItemStack,
pos: [i32; 3],
state: BlockStateId,
) {
let _ = (stack, pos, state);
}
}
/// Helpers for native mods, kept as stable as the hook points.
/// Server-level helpers for native mods.
#[allow(dead_code)]
impl GameLevel<'_> {
/// Queue particles in this authoritative level. A zero count sends one
/// directional particle. The return value means accepted, not delivered.
/// Block particle types require their block state; item, trail and
/// vibration particles are not supported by the current network dispatch.
pub(super) fn native_send_particles(
&mut self,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: i32,
) -> bool {
use ParticleType as T;
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(|id| self.registry.state(id).is_some())
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
if !self.native_authoritative
|| !supported
|| !at.into_iter().all(f64::is_finite)
|| !spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|| !speed.is_finite()
|| speed.abs() > f32::MAX as f64
|| !options.color.into_iter().all(f32::is_finite)
|| !options.to_color.into_iter().all(f32::is_finite)
|| !options.scale.is_finite()
{
return false;
}
let dimension = match self.dimension {
LevelDimension::Overworld => "minecraft:overworld",
LevelDimension::Nether => "minecraft:the_nether",
LevelDimension::End => "minecraft:the_end",
};
self.native_particles
.push((dimension, options.clone(), state, at, spread, speed, count));
true
}
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
@@ -394,55 +394,6 @@ mod tests {
assert!(lifecycle.world_close_final().is_none());
}
#[test]
fn dedicated_open_and_close_sites_bound_the_server_lifecycle() {
let source = include_str!("dedicated.rs")
.split("#[cfg(test)]")
.next()
.unwrap_or_default();
let open = source
.find("self.native_lifecycle_open_started();")
.unwrap();
let load = source.find("self.load_world(WorldRequest::new(").unwrap();
let ready = source.find("self.native_lifecycle_world_ready();").unwrap();
let tick = source.find("self.tick_server_timed(paused)?;").unwrap();
assert!(open < load && load < ready && ready < tick);
let stop = source.split("fn stop_dedicated").nth(1).unwrap();
let begin = stop.find("self.native_lifecycle.begin_close();").unwrap();
let save = stop.find("self.save_game()?;").unwrap();
let close = stop.find("self.native_lifecycle_world_closed();").unwrap();
let release = stop.find("self.lan_close(false);").unwrap();
assert!(begin < save && save < close && close < release);
}
#[test]
fn failed_server_ticks_cancel_before_end() {
let tick = include_str!("levels.rs")
.split("fn tick_server(&mut self, paused: bool)")
.nth(1)
.unwrap()
.split("fn tick_server_timed")
.next()
.unwrap();
let first_cancel = tick
.find("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let result = tick.find("result?;").unwrap();
let close = tick
.find("if let Err(error) = self.close_idle_levels()")
.unwrap();
let second_cancel = tick
.rfind("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let end = tick
.find("self.native_lifecycle_server_tick_end(paused);")
.unwrap();
assert!(
first_cancel < result && result < close && close < second_cancel && second_cancel < end
);
}
#[test]
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
let game = include_str!("../game.rs")
@@ -0,0 +1,365 @@
//! Server menus for native mods (docs/native-mods.md, "Server menus", API
//! 10): a vanilla chest menu (`generic_9x1` to `generic_9x6`) a mod opens
//! for a player and fills with items to look at. No click moves anything:
//! each reaches the mod (`native_menu_clicked`), as does the menu's closing
//! (`native_menu_closed`), and the client shows the menu as the server sends
//! it again. A stock client shows it; nothing on the player's side.
//! `native_exchange_items` swaps two players' stacks in one step, checked
//! first, so a trade built on these menus never moves an item before both
//! sides are settled.
use super::Game;
use horizon_client::{
items::ItemStack,
lan::{data::component_nbt, menu::NativeMenuEvent},
text::Text,
};
/// The inventory slot (0..9 the hotbar, 9..36 the rest) a click on `slot`
/// of a native menu of `size` slots landed on, when it is the player's
/// inventory below the menu (`ChestMenu`: the menu's slots, then the
/// inventory's 27, then the hotbar's 9).
#[allow(dead_code)]
pub(crate) fn native_menu_inventory_slot(size: usize, slot: i32) -> Option<usize> {
let n = usize::try_from(slot).ok()?.checked_sub(size)?;
match n {
0..27 => Some(n + 9),
27..36 => Some(n - 27),
_ => None,
}
}
/// Adds every stack of `gets` to the 36 slots of `inventory`, onto matching
/// stacks first, then into empty slots, hotbar first; false when one doesn't
/// fit whole. `max` is an item's stack size.
fn fit(
inventory: &mut [Option<ItemStack>],
gets: &[ItemStack],
max: &impl Fn(&ItemStack) -> u32,
) -> bool {
for stack in gets {
let mut left = stack.count;
let max = max(stack).max(1);
for slot in inventory.iter_mut().take(36).flatten() {
if left > 0 && slot.same_item_same_components(stack) && slot.count < max {
let n = (max - slot.count).min(left);
slot.count += n;
left -= n;
}
}
for slot in inventory.iter_mut().take(36) {
if left > 0 && slot.is_none() {
let n = max.min(left);
*slot = Some(ItemStack {
count: n,
..stack.clone()
});
left -= n;
}
}
if left > 0 {
return false;
}
}
true
}
/// Two players' offers swapped, or nothing changed: each offer is the
/// inventory slots it takes with the stack each must still hold, exactly.
/// The stacks leave their slots, then go to the other side where they fit
/// whole; an Err (which side, why) leaves both inventories as they were.
fn exchange(
a: &mut Vec<Option<ItemStack>>,
a_offer: &[(usize, ItemStack)],
b: &mut Vec<Option<ItemStack>>,
b_offer: &[(usize, ItemStack)],
max: impl Fn(&ItemStack) -> u32,
) -> Result<(), String> {
fn take(
inv: &[Option<ItemStack>],
offer: &[(usize, ItemStack)],
who: &str,
) -> Result<(Vec<Option<ItemStack>>, Vec<ItemStack>), String> {
let mut left = inv.to_vec();
let mut taken = Vec::new();
for (slot, expected) in offer {
match left.get_mut(*slot).filter(|_| *slot < 36) {
Some(s) if s.as_ref() == Some(expected) => taken.push(s.take().expect("checked")),
_ => return Err(format!("{who} no longer holds their offer")),
}
}
Ok((left, taken))
}
let (mut a_after, a_gives) = take(a, a_offer, "the first player")?;
let (mut b_after, b_gives) = take(b, b_offer, "the second player")?;
if !fit(&mut a_after, &b_gives, &max) {
return Err("the first player has no room".into());
}
if !fit(&mut b_after, &a_gives, &max) {
return Err("the second player has no room".into());
}
*a = a_after;
*b = b_after;
Ok(())
}
/// Server menus, for server mods (API 10). The players are named by UUID;
/// none of these runs while a player's state is swapped in (inside
/// `native_perform_command`, the menus can be opened and filled, but
/// `native_player_stacks` and `native_exchange_items` refuse: call them from
/// `native_menu_clicked` or `native_server_tick`).
#[allow(dead_code)]
impl Game<'_> {
fn native_conn(&self, uuid: u128) -> Option<u32> {
let host = self.lan.as_ref()?;
let g = host
.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)?;
Some(g.conn)
}
/// Opens a chest menu of `rows` rows (1 to 6) titled `title` for the
/// player, showing `items` (by slot, padded with empty ones), in place of
/// the menu they had open: its window id.
pub(super) fn native_open_menu(
&mut self,
uuid: u128,
rows: u8,
title: Text,
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let conn = self.native_conn(uuid)?;
let host = self.lan.as_mut()?;
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
}
/// Replaces what the player's open native menu shows; sent with the
/// tick's container sync.
pub(super) fn native_set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
if let Some(conn) = self.native_conn(uuid)
&& let Some(host) = self.lan.as_mut()
{
host.hub.set_native_menu(conn, items);
}
}
/// Closes the player's native menu (a `native_menu_closed` follows).
pub(super) fn native_close_menu(&mut self, uuid: u128) {
if let Some(conn) = self.native_conn(uuid)
&& let Some(host) = self.lan.as_mut()
&& host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref())
.is_some_and(|m| m.slots == horizon_client::lan::menu::Slots::Native)
{
host.hub.close_container(conn);
}
}
/// The player's 36 inventory slots (0..9 the hotbar), to show or offer
/// them; None for a player not here, dead, or whose state is swapped in.
pub(super) fn native_player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
let host = self.lan.as_ref()?;
let g = host
.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)?;
(!g.dead && self.lan_acting != Some(g.conn)).then(|| {
let mut stacks = g.inventory.clone();
stacks.resize(36, None);
stacks
})
}
/// Swaps two players' offers (inventory slots, each with the stack it
/// must still hold, exactly) in one step: the stacks leave their slots
/// and go to the other player where they fit whole, or nothing moves and
/// the Err says why. Both must be here and alive, neither with their
/// state swapped in. Their screens follow with the tick's sync.
pub(super) fn native_exchange_items(
&mut self,
a: u128,
a_offer: &[(usize, ItemStack)],
b: u128,
b_offer: &[(usize, ItemStack)],
) -> Result<(), String> {
if a == b {
return Err("a player can't trade with themselves".into());
}
let acting = self.lan_acting;
let items = self.res.items();
let Some(host) = self.lan.as_mut() else {
return Err("no server".into());
};
let find = |uuid: u128| {
host.hub
.guests
.iter()
.position(|g| g.placed && !g.dead && g.uuid == uuid)
.filter(|&i| acting != Some(host.hub.guests[i].conn))
};
let (Some(ia), Some(ib)) = (find(a), find(b)) else {
return Err("both players must be here".into());
};
let mut inv_a = std::mem::take(&mut host.hub.guests[ia].inventory);
let mut inv_b = std::mem::take(&mut host.hub.guests[ib].inventory);
let result = exchange(&mut inv_a, a_offer, &mut inv_b, b_offer, |s| {
items.get(&s.item).map_or(64, |i| i.max_stack)
});
host.hub.guests[ia].inventory = inv_a;
host.hub.guests[ib].inventory = inv_b;
result
}
}
/// Where the game calls the menu hook points.
impl Game<'_> {
/// The clicks on and closings of native menus the hub collected, each
/// told to the mods with the hub back in `self.lan`, no player's state
/// swapped in.
pub(super) fn run_native_menu_events(&mut self) {
loop {
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.native_menu_events.is_empty() {
return;
}
match host.hub.native_menu_events.remove(0) {
NativeMenuEvent::Click {
uuid,
window,
slot,
button,
mode,
} => self.native_menu_clicked(uuid, window, slot, button, mode),
NativeMenuEvent::Closed { uuid, window } => self.native_menu_closed(uuid, window),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
}
}
fn inventory(stacks: &[(usize, ItemStack)]) -> Vec<Option<ItemStack>> {
let mut inv = vec![None; 36];
for (i, s) in stacks {
inv[*i] = Some(s.clone());
}
inv
}
fn max(s: &ItemStack) -> u32 {
if &*s.item == "minecraft:diamond_sword" {
1
} else {
64
}
}
#[test]
fn menu_clicks_map_to_the_inventory_below() {
// A 6-row chest: 54 slots, then the inventory's 27, then the hotbar.
assert_eq!(native_menu_inventory_slot(54, 3), None);
assert_eq!(native_menu_inventory_slot(54, 54), Some(9));
assert_eq!(native_menu_inventory_slot(54, 80), Some(35));
assert_eq!(native_menu_inventory_slot(54, 81), Some(0));
assert_eq!(native_menu_inventory_slot(54, 89), Some(8));
assert_eq!(native_menu_inventory_slot(54, 90), None);
assert_eq!(native_menu_inventory_slot(54, -999), None);
assert_eq!(native_menu_inventory_slot(9, 9), Some(9));
}
#[test]
fn an_exchange_swaps_both_offers() {
let mut a = inventory(&[
(0, stack("minecraft:diamond", 5)),
(1, stack("minecraft:dirt", 64)),
]);
let mut b = inventory(&[
(4, stack("minecraft:diamond_sword", 1)),
(5, stack("minecraft:diamond", 60)),
]);
let a_offer = [(0, stack("minecraft:diamond", 5))];
let b_offer = [(4, stack("minecraft:diamond_sword", 1))];
exchange(&mut a, &a_offer, &mut b, &b_offer, max).unwrap();
// A gets the sword in the slot their diamonds left; B's diamonds
// fill their stack to 64, the rest in the first empty slot.
assert_eq!(a[0], Some(stack("minecraft:diamond_sword", 1)));
assert_eq!(a[1], Some(stack("minecraft:dirt", 64)));
assert_eq!(b[0], Some(stack("minecraft:diamond", 1)));
assert_eq!(b[4], None);
assert_eq!(b[5], Some(stack("minecraft:diamond", 64)));
let count = |inv: &[Option<ItemStack>], item: &str| -> u32 {
inv.iter()
.flatten()
.filter(|s| &*s.item == item)
.map(|s| s.count)
.sum()
};
// Nothing made, nothing lost.
assert_eq!(
count(&a, "minecraft:diamond") + count(&b, "minecraft:diamond"),
65
);
assert_eq!(
count(&a, "minecraft:diamond_sword") + count(&b, "minecraft:diamond_sword"),
1
);
}
#[test]
fn an_exchange_that_cant_complete_moves_nothing() {
let a0 = inventory(&[(0, stack("minecraft:diamond", 5))]);
let b0 = inventory(&[(2, stack("minecraft:emerald", 3))]);
// The offer changed since it was made (moved, dropped, used).
let (mut a, mut b) = (a0.clone(), b0.clone());
let stale = [(0, stack("minecraft:diamond", 6))];
assert!(exchange(&mut a, &stale, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &b0));
// An empty or out-of-range slot.
assert!(
exchange(
&mut a,
&[(7, stack("minecraft:diamond", 5))],
&mut b,
&[],
max
)
.is_err()
);
assert!(
exchange(
&mut a,
&[(36, stack("minecraft:diamond", 5))],
&mut b,
&[],
max
)
.is_err()
);
assert_eq!((&a, &b), (&a0, &b0));
// No room: B's inventory is full of other things.
let full: Vec<_> = (0..36).map(|_| Some(stack("minecraft:dirt", 64))).collect();
let mut b = full.clone();
let offer = [(0, stack("minecraft:diamond", 5))];
assert!(exchange(&mut a, &offer, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &full));
// The same slot offered twice is the second time empty.
let twice = [
(0, stack("minecraft:diamond", 5)),
(0, stack("minecraft:diamond", 5)),
];
let mut b = b0.clone();
assert!(exchange(&mut a, &twice, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &b0));
}
}
@@ -0,0 +1,697 @@
//! Server commands and players for native mods (docs/native-mods.md,
//! "Server commands and players", API 8): the commands mods add to the
//! server's command tree (`native_register_commands`), a player's command
//! reaching them (`native_perform_command`), and the helpers a server mod
//! answers with: the players online, a line to one of them or to all, the
//! command's feedback and the world's folder; and (API 9) the acting
//! player's plain items: count, take, give. They act on the server's own
//! players (the hub's: a dedicated server's, a LAN host's, single player's
//! server thread's), never on a client's.
use super::{Game, lan::LanHost};
use horizon_client::{items::ItemStack, lan::data::component_nbt, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use serde_json::{Map, Value, json};
use std::{collections::HashMap, path::PathBuf};
/// A player on the server, as a server hook sees them.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeServerPlayer {
pub(crate) uuid: u128,
pub(crate) name: String,
/// The operator level, 0 to 4 (`ops.json`; single player's owner with
/// cheats on is 4).
pub(crate) permission: u8,
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub(crate) dimension: String,
pub(crate) position: [f64; 3],
/// 0 survival, 1 creative, 2 adventure, 3 spectator.
pub(crate) game_mode: i32,
}
/// A command a native mod adds, as one usage: words, then arguments
/// `<name:kind>`, optional ones last in brackets `[name:kind]`:
/// `pay <player:player> <amount:integer>`, `balance [player:player]`,
/// `eco give <player:player> <amount:integer>`. Kinds: `word`, `text` (the
/// rest of the line, last), `integer`, `number`, `boolean`, `player`,
/// `item`, `block_pos`. Usages of one command share their words; the
/// server merges them into one tree.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeCommand {
pub(crate) usage: String,
/// The operator level a player needs to see and run it (0: everyone).
pub(crate) level: u8,
}
#[allow(dead_code)]
impl NativeCommand {
/// A usage every player may run.
pub(crate) fn new(usage: &str) -> Self {
Self {
usage: usage.to_owned(),
level: 0,
}
}
/// The same, for operators of `level` and above.
pub(crate) fn op(usage: &str, level: u8) -> Self {
Self {
usage: usage.to_owned(),
level: level.min(4),
}
}
}
/// The brigadier parser of an argument kind, and its properties.
fn parser(kind: &str) -> Option<(&'static str, Value)> {
Some(match kind {
"word" => ("brigadier:string", json!({"type": "word"})),
"text" => ("brigadier:string", json!({"type": "greedy"})),
"integer" => ("brigadier:integer", Value::Null),
"number" => ("brigadier:double", Value::Null),
"boolean" => ("brigadier:bool", Value::Null),
"player" => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
"item" => ("minecraft:item_stack", Value::Null),
"block_pos" => ("minecraft:block_pos", Value::Null),
_ => return None,
})
}
/// The report's name of an operator level (`commands.json`'s
/// `permissions`).
const LEVELS: [&str; 5] = ["", "moderators", "gamemasters", "admins", "owners"];
/// One parsed word of a usage: its key in the tree and its node, without
/// children.
fn usage_nodes(usage: &str) -> Result<Vec<(String, Value, bool)>, String> {
let mut out = Vec::new();
let mut optional_seen = false;
let mut greedy_seen = false;
for word in usage.split_whitespace() {
if greedy_seen {
return Err(format!("`{usage}`: a text argument must be last"));
}
let (inner, optional) = if let Some(w) = word.strip_prefix('<') {
(w.strip_suffix('>'), false)
} else if let Some(w) = word.strip_prefix('[') {
(w.strip_suffix(']'), true)
} else {
if optional_seen {
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
}
let literal = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c));
if !literal {
return Err(format!("`{usage}`: `{word}` is not a command word"));
}
out.push((word.to_owned(), json!({"type": "literal"}), false));
continue;
};
let Some((name, kind)) = inner.and_then(|w| w.split_once(':')) else {
return Err(format!("`{usage}`: `{word}` is not `<name:kind>`"));
};
if optional_seen && !optional {
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
}
let Some((parser, properties)) = parser(kind) else {
return Err(format!("`{usage}`: unknown kind `{kind}`"));
};
optional_seen |= optional;
greedy_seen |= kind == "text";
let mut node = json!({"type": "argument", "parser": parser});
if !properties.is_null() {
node["properties"] = properties;
}
out.push((name.to_owned(), node, optional));
}
if out.first().is_none_or(|n| n.1["type"] != "literal") {
return Err(format!("`{usage}`: a command starts with its name"));
}
Ok(out)
}
/// Native mods' commands as a root in the commands report's format
/// (`lan::commands`, which sends each player what their level allows), and
/// each command's level: the lowest of its usages. A node is runnable where
/// only optional arguments follow it. A command vanilla has (`vanilla`
/// answers) or a usage that doesn't parse is left out, with why.
pub(super) fn command_tree(
commands: &[NativeCommand],
vanilla: impl Fn(&str) -> bool,
) -> (Value, HashMap<String, u8>, Vec<String>) {
let mut root = Map::new();
let mut levels: HashMap<String, u8> = HashMap::new();
let mut problems = Vec::new();
for command in commands {
let nodes = match usage_nodes(&command.usage) {
Ok(n) => n,
Err(e) => {
problems.push(e);
continue;
}
};
if vanilla(&nodes[0].0) {
problems.push(format!("`{}`: vanilla has /{}", command.usage, nodes[0].0));
continue;
}
let mut children = &mut root;
for (i, (name, node, _)) in nodes.iter().enumerate() {
let runnable = nodes.get(i + 1).is_none_or(|n| n.2);
let entry = children.entry(name.clone()).or_insert_with(|| {
let mut n = node.clone();
n["executable"] = false.into();
n["level"] = 4.into();
n
});
if entry["type"] != node["type"] || entry["parser"] != node["parser"] {
problems.push(format!(
"`{}`: `{name}` is another kind in an earlier usage",
command.usage
));
break;
}
if runnable {
entry["executable"] = true.into();
}
let level = entry["level"]
.as_u64()
.unwrap_or(4)
.min(command.level.into());
entry["level"] = level.into();
if !entry["children"].is_object() {
entry["children"] = Value::Object(Map::new());
}
children = entry["children"].as_object_mut().expect("made above");
}
let level = levels.entry(nodes[0].0.clone()).or_insert(4);
*level = (*level).min(command.level);
}
let mut root = Value::Object(root);
finish(&mut root);
let tree = if levels.is_empty() {
Value::Null
} else {
json!({"type": "root", "children": root})
};
(tree, levels, problems)
}
/// Turns the working `level` into the report's `permissions`, and drops
/// empty child lists.
fn finish(children: &mut Value) {
let Some(map) = children.as_object_mut() else {
return;
};
for node in map.values_mut() {
let level = node["level"].as_u64().unwrap_or(0) as usize;
let object = node.as_object_mut().expect("a node");
object.remove("level");
if level > 0 {
object.insert(
"permissions".into(),
json!({"type": "require", "permission": {"level": LEVELS[level.min(4)]}}),
);
}
if object
.get("children")
.is_some_and(|c| c.as_object().is_some_and(Map::is_empty))
{
object.remove("children");
} else if let Some(c) = object.get_mut("children") {
finish(c);
}
}
}
/// Helpers for server mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The players on this server, in the order they joined. Empty on a
/// client and in a world nobody else can join.
pub(super) fn native_server_players(&self) -> Vec<NativeServerPlayer> {
let Some(host) = &self.lan else {
return Vec::new();
};
host.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| NativeServerPlayer {
uuid: g.uuid,
name: g.name.clone(),
permission: g.permission,
dimension: g.dimension.clone(),
position: g.position,
game_mode: g.game_mode,
})
.collect()
}
/// A system line in that player's chat; false when they aren't on.
pub(super) fn native_tell(&self, uuid: u128, line: Text) -> bool {
let Some(host) = &self.lan else {
return false;
};
let Some(g) = host.hub.guests.iter().find(|g| g.placed && g.uuid == uuid) else {
return false;
};
host.hub.send_to(g.conn, &system_chat(&line));
true
}
/// A system line in every player's chat and the server's log.
pub(super) fn native_broadcast(&self, line: Text) {
let plain = line.plain();
if self.options.dedicated.is_some() {
super::dedicated::log("INFO", &plain);
} else {
log::info!("{plain}");
}
let Some(host) = &self.lan else {
return;
};
let packet = system_chat(&line);
for g in host.hub.guests.iter().filter(|g| g.placed) {
host.hub.send_to(g.conn, &packet);
}
}
/// A line of the command's answer, inside `native_perform_command`:
/// sent to the player whose command it is after the call, as vanilla
/// commands' feedback is.
pub(super) fn native_feedback(&mut self, line: Text) {
match &mut self.chat_capture {
Some(lines) => lines.push(line),
None => self.system_message(line),
}
}
/// The open world's folder (`world/`, beside its `level.dat`): a server
/// mod keeps its data in a folder of its own there, so the world's
/// backups carry it. None when no world is open.
pub(super) fn native_world_dir(&self) -> Option<PathBuf> {
(!self.world_root.as_os_str().is_empty()).then(|| self.world_root.clone())
}
}
/// Plain stacks: no components beyond the count (no damage, name,
/// enchantment...), the only ones the item helpers count, take or give, so
/// a mod never prices a worn or enchanted item as a new one.
fn plain(stack: &ItemStack) -> bool {
stack.damage == 0 && stack.extra.is_none()
}
/// The slots the item helpers look at, in `Inventory.add`'s order: the
/// selected slot, the off hand, then the hotbar and the main inventory.
fn order(selected: usize) -> impl Iterator<Item = usize> {
std::iter::once(selected)
.chain(std::iter::once(OFFHAND))
.chain((0..36).filter(move |&i| i != selected))
}
const OFFHAND: usize = 36;
/// The 36 inventory slots and the off hand as one list ([`OFFHAND`] last).
fn slots<'a>(
inventory: &'a mut [Option<ItemStack>],
offhand: &'a mut Option<ItemStack>,
) -> Vec<&'a mut Option<ItemStack>> {
inventory
.iter_mut()
.take(36)
.chain(std::iter::once(offhand))
.collect()
}
/// How many of plain `item` the slots hold.
fn count_in(slots: &[&mut Option<ItemStack>], item: &str) -> u32 {
slots
.iter()
.filter_map(|s| s.as_ref())
.filter(|s| &*s.item == item && plain(s))
.map(|s| s.count)
.sum()
}
/// Takes up to `count` plain `item`, selected slot first; what it took.
fn take_from(
mut slots: Vec<&mut Option<ItemStack>>,
selected: usize,
item: &str,
count: u32,
) -> u32 {
let mut taken = 0;
for i in order(selected) {
let slot = &mut *slots[i];
if let Some(s) = slot
&& &*s.item == item
&& plain(s)
{
let n = s.count.min(count - taken);
s.count -= n;
taken += n;
if s.count == 0 {
*slot = None;
}
}
if taken == count {
break;
}
}
taken
}
/// How many plain `item` (stacking to `max`) still fit.
fn room_in(slots: &[&mut Option<ItemStack>], item: &str, max: u32) -> u32 {
slots
.iter()
.enumerate()
.map(|(i, s)| match s.as_ref() {
// getFreeSlot: never into an empty off hand.
None if i == OFFHAND => 0,
None => max,
Some(s) if &*s.item == item && plain(s) => max.saturating_sub(s.count),
Some(_) => 0,
})
.sum()
}
/// Adds up to `count` plain `item`, onto its stacks first
/// (`getSlotWithRemainingSpace`: the off hand's too), then into empty slots
/// of the inventory (`getFreeSlot`), in [`order`]; what it added. Nothing
/// is dropped.
fn add_to(
mut slots: Vec<&mut Option<ItemStack>>,
selected: usize,
item: &str,
max: u32,
count: u32,
) -> u32 {
let mut added = 0;
for pass in 0..2 {
for i in order(selected) {
let slot = &mut *slots[i];
let n = match slot {
Some(s) if pass == 0 && &*s.item == item && plain(s) => {
let n = max.saturating_sub(s.count).min(count - added);
s.count += n;
n
}
None if pass == 1 && i != OFFHAND => {
let n = max.min(count - added);
*slot = Some(ItemStack {
item: item.into(),
count: n,
damage: 0,
extra: None,
});
n
}
_ => 0,
};
added += n;
if added == count {
return added;
}
}
}
added
}
/// The acting player's items, for server mods (API 9): the player whose
/// action or command a hook hears (`native_perform_command`), as their
/// state is swapped in. Only plain stacks count; the inventory's 36 slots
/// and the off hand, never the armour. Each change reaches the player's
/// screen as any change to their inventory does.
#[allow(dead_code)]
impl Game<'_> {
/// The main hand's stack: its item, count, and whether it is plain.
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
let s = self.inventory.get(self.selected)?.as_ref()?;
Some((s.item.to_string(), s.count, plain(s)))
}
/// How many of plain `item` (`minecraft:diamond`) the player carries.
pub(super) fn native_count_items(&mut self, item: &str) -> u32 {
count_in(&slots(&mut self.inventory, &mut self.offhand), item)
}
/// Every plain item the player carries, with how many, by id.
pub(super) fn native_carried_items(&self) -> Vec<(String, u32)> {
let mut out: Vec<(String, u32)> = Vec::new();
let held = self
.inventory
.iter()
.take(36)
.chain(std::iter::once(&self.offhand));
for s in held.flatten().filter(|s| plain(s)) {
match out.iter_mut().find(|(i, _)| *i == *s.item) {
Some((_, n)) => *n += s.count,
None => out.push((s.item.to_string(), s.count)),
}
}
out
}
/// Takes up to `count` plain `item`, the main hand first; what it took.
pub(super) fn native_take_items(&mut self, item: &str, count: u32) -> u32 {
if self.player.is_none() {
return 0;
}
let selected = self.selected;
take_from(
slots(&mut self.inventory, &mut self.offhand),
selected,
item,
count,
)
}
/// How many plain `item` still fit; 0 for an unknown item.
pub(super) fn native_room_for(&mut self, item: &str) -> u32 {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return 0;
};
room_in(
&slots(&mut self.inventory, &mut self.offhand),
item,
max.max(1),
)
}
/// Gives up to `count` plain `item` where they fit, as `Inventory.add`
/// places them; what it gave (nothing is dropped). 0 for an unknown item.
pub(super) fn native_give_items(&mut self, item: &str, count: u32) -> u32 {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return 0;
};
if self.player.is_none() {
return 0;
}
let selected = self.selected;
add_to(
slots(&mut self.inventory, &mut self.offhand),
selected,
item,
max.max(1),
count,
)
}
}
fn system_chat(line: &Text) -> C {
C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
overlay: false,
})
}
/// Where the game calls the server hook points.
impl Game<'_> {
/// Asks the mods for their commands once per hub, and puts them in every
/// player's command tree.
pub(super) fn register_native_commands(&mut self, host: &mut LanHost) {
if host.native_commands.is_some() {
return;
}
let mut commands = Vec::new();
self.native_register_commands(&mut commands);
let (tree, levels, problems) =
command_tree(&commands, |name| host.hub.vanilla_command(name));
for p in problems {
log::warn!("Horizon: a native mod's command was left out: {p}");
}
host.hub.set_mod_commands(0, tree);
host.native_commands = Some(levels);
}
/// The level a guest needs for a mod's command (`line`, without its
/// slash), when a mod registered it.
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
let name = line.split(' ').next().unwrap_or_default();
host.native_commands.as_ref()?.get(name).copied()
}
/// A guest's command for the mods, inside their swap
/// (`run_guest_command`): true when one ran it.
pub(super) fn perform_native_command(&mut self, conn: u32, permission: u8, line: &str) -> bool {
let Some(host) = &self.lan else {
return false;
};
if Self::native_command_level(host, line).is_none_or(|level| level > permission) {
return false;
}
let Some(g) = host.hub.guest(conn) else {
return false;
};
let player = NativeServerPlayer {
uuid: g.uuid,
name: g.name.clone(),
permission,
dimension: g.dimension.clone(),
position: g.position,
game_mode: g.game_mode,
};
let mut ran = false;
self.native_perform_command(&player, line, &mut ran);
if !ran {
// Registered, but no mod took it: vanilla's unknown command.
self.native_feedback(
Text::translate("command.unknown.command", &[])
.with_color(horizon_client::text::RED),
);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(commands: &[NativeCommand]) -> (Value, HashMap<String, u8>, Vec<String>) {
command_tree(commands, |name| name == "give")
}
fn stack(item: &str, count: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
})
}
#[test]
fn items_are_taken_from_the_hand_first_and_only_plain() {
let mut inv = vec![None; 36];
inv[0] = stack("minecraft:diamond", 10);
inv[4] = stack("minecraft:diamond", 5);
inv[8] = stack("minecraft:diamond", 7);
inv[8].as_mut().unwrap().damage = 3;
let mut off = stack("minecraft:diamond", 2);
assert_eq!(
count_in(&slots(&mut inv, &mut off), "minecraft:diamond"),
17
);
assert_eq!(
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 6),
6
);
assert!(inv[4].is_none(), "the selected slot first");
assert_eq!(off.as_ref().unwrap().count, 1, "then the off hand");
assert_eq!(
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 100),
11
);
assert_eq!(inv[8].as_ref().unwrap().count, 7, "a damaged stack stays");
assert!(inv[0].is_none() && off.is_none());
}
#[test]
fn items_are_given_only_where_they_fit() {
let mut inv = vec![stack("minecraft:dirt", 64); 36];
inv[3] = stack("minecraft:diamond", 60);
inv[9] = None;
let mut off = None;
assert_eq!(
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
4 + 64
);
assert_eq!(
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 200),
68
);
assert_eq!(inv[3].as_ref().unwrap().count, 64, "onto its stack first");
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
assert_eq!(inv[9].as_ref().unwrap().count, 64);
assert_eq!(
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
0
);
assert_eq!(
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 1),
0
);
}
#[test]
fn usages_merge_into_one_tree_with_runnable_nodes() {
let (t, levels, problems) = tree(&[
NativeCommand::new("balance [player:player]"),
NativeCommand::new("pay <player:player> <amount:integer>"),
NativeCommand::op("eco give <player:player> <amount:integer>", 3),
NativeCommand::op("eco take <player:player> <amount:integer>", 3),
]);
assert!(problems.is_empty(), "{problems:?}");
let c = &t["children"];
assert_eq!(c["balance"]["executable"], true);
assert_eq!(c["balance"]["children"]["player"]["executable"], true);
assert_eq!(
c["balance"]["children"]["player"]["parser"],
"minecraft:entity"
);
assert_eq!(c["pay"]["executable"], false);
assert_eq!(c["pay"]["children"]["player"]["executable"], false);
let amount = &c["pay"]["children"]["player"]["children"]["amount"];
assert_eq!(amount["executable"], true);
assert_eq!(amount["parser"], "brigadier:integer");
assert!(amount.get("children").is_none());
assert_eq!(c["eco"]["permissions"]["permission"]["level"], "admins");
assert!(c["eco"]["children"]["give"].is_object());
assert!(c["eco"]["children"]["take"].is_object());
assert!(c["pay"].get("permissions").is_none());
assert_eq!(levels["eco"], 3);
assert_eq!(levels["pay"], 0);
}
#[test]
fn a_command_takes_its_lowest_level() {
let (t, levels, _) = tree(&[
NativeCommand::new("warp [name:word]"),
NativeCommand::op("warp set <name:word>", 2),
]);
assert_eq!(levels["warp"], 0);
assert!(t["children"]["warp"].get("permissions").is_none());
let set = &t["children"]["warp"]["children"]["set"];
assert_eq!(set["permissions"]["permission"]["level"], "gamemasters");
}
#[test]
fn bad_usages_and_vanilla_names_are_left_out() {
let (t, levels, problems) = tree(&[
NativeCommand::new("give <x:integer>"),
NativeCommand::new("a [x:integer] <y:integer>"),
NativeCommand::new("b <x:text> <y:word>"),
NativeCommand::new("c <x:colour>"),
NativeCommand::new("<x:integer>"),
NativeCommand::new("Bad"),
NativeCommand::new("d <x:integer>"),
NativeCommand::new("d <x:word>"),
]);
assert_eq!(problems.len(), 7, "{problems:?}");
assert_eq!(levels.keys().collect::<Vec<_>>(), ["d"]);
assert_eq!(
t["children"]["d"]["children"]["x"]["parser"],
"brigadier:integer"
);
let (none, levels, _) = tree(&[]);
assert!(none.is_null() && levels.is_empty());
}
}
@@ -2332,6 +2332,11 @@ impl Game<'_> {
));
}
}
// Native mods last, once getTooltipLines has built every line; a
// tooltip the stack hides stays hidden.
if !nested && !lines.is_empty() {
self.native_tooltip(stack, &mut lines);
}
lines
}
/// Menu state for the control socket.
@@ -84,6 +84,11 @@ impl CommandTree {
})
}
/// Whether vanilla has a command of this name.
pub fn has(&self, name: &str) -> bool {
self.root["children"].get(name).is_some()
}
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
self.packet_with(level, dedicated, builtin, &Value::Null)
+15
View File
@@ -861,6 +861,21 @@ fn login_and_configure(
_ => {}
}
};
// horizon-clients-only: the brand the client sent ("vanilla" when it sent
// none) must be Horizon's.
if shared.config.horizon_clients_only && brand != "horizon" {
let text = super::HORIZON_CLIENTS_ONLY_REASON;
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
if let Some(log) = &shared.config.log {
let line = format!(
"Disconnecting {name}: its client is \"{brand}\", not Horizon (horizon-clients-only)"
);
(log.write)("Server thread", "INFO", &line);
}
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
return Ok(None);
}
// A Horizon client hears that this server runs its mods' quiet commands
// (horizon_protocol::quiet); a vanilla one is told nothing.
if brand == "horizon" {
+83 -2
View File
@@ -2,7 +2,7 @@
//! level events, animations and block containers with their clicks.
use super::{
hub::Hub,
menu::{self, Menu, Rules, Slots, View},
menu::{self, Menu, NativeMenuEvent, Rules, Slots, View},
};
use crate::{containers::BlockEntity, items::ItemStack, world::World};
use horizon_protocol::{
@@ -245,6 +245,62 @@ impl Hub {
self.send_menu_content(i, items);
}
/// Opens a native mod's menu of `rows` rows (1 to 6, a chest's
/// `generic_9xN`) showing `items` (padded to the rows' slots), over the
/// guest's inventory: no click moves anything, each is a
/// [`NativeMenuEvent`]. The window's id, or None for an unknown guest.
pub fn open_native_menu(
&mut self,
conn: u32,
rows: u8,
title: Tag,
mut items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let rows = rows.clamp(1, 6);
let kind = self
.sync
.builtin
.get("minecraft:menu")
.and_then(|m| m.id(&format!("minecraft:generic_9x{rows}")))?;
let i = self.guests.iter().position(|g| g.conn == conn)?;
self.close_container_at(i);
let size = usize::from(rows) * 9;
items.resize(size, None);
let g = &mut self.guests[i];
g.native_menu = items.clone();
g.next_window = g.next_window % 100 + 1;
let window = g.next_window;
g.send(&C::OpenScreen(play::OpenScreen {
container: window,
menu: kind,
title,
}));
g.menu = Some(Menu {
window,
kind,
pos: None,
slots: Slots::Native,
size,
state: 0,
sent: Vec::new(),
drag: None,
sent_data: Vec::new(),
});
self.send_menu_content(i, items);
Some(window)
}
/// Replaces what a guest's open native menu shows; the changed slots go
/// out with the tick's container sync.
pub fn set_native_menu(&mut self, conn: u32, mut items: Vec<Option<ItemStack>>) {
if let Some(g) = self.guest_mut(conn)
&& let Some(m) = g.menu.as_ref().filter(|m| m.slots == Slots::Native)
{
items.resize(m.size, None);
g.native_menu = items;
}
}
/// Open a trading window for a guest (`Merchant.openTradingScreen`):
/// its id, or None when the menu type is unknown.
pub fn open_merchant_window(&mut self, conn: u32, title: Tag) -> Option<i32> {
@@ -336,6 +392,12 @@ impl Hub {
g.send(&C::ContainerClose(play::ContainerClose {
container: menu.window,
}));
if menu.slots == Slots::Native {
g.native_menu.clear();
let (uuid, window) = (g.uuid, menu.window);
self.native_menu_events
.push(NativeMenuEvent::Closed { uuid, window });
}
}
}
@@ -354,7 +416,13 @@ impl Hub {
if let Some(g) = self.guest_mut(conn)
&& g.menu.as_ref().is_some_and(|m| m.window == window)
{
g.menu = None;
let native = g.menu.take().is_some_and(|m| m.slots == Slots::Native);
if native {
g.native_menu.clear();
let uuid = g.uuid;
self.native_menu_events
.push(NativeMenuEvent::Closed { uuid, window });
}
}
}
@@ -406,6 +474,19 @@ impl Hub {
self.resend_menu(conn, p.container, world);
return Vec::new();
}
// A native mod's menu: the click is the mod's to answer, and the
// whole menu and the cursor go back as they were.
if in_menu && self.guests[i].menu.as_ref().map(|m| m.slots) == Some(Slots::Native) {
self.native_menu_events.push(NativeMenuEvent::Click {
uuid: self.guests[i].uuid,
window: p.container,
slot: i32::from(p.slot),
button: i32::from(p.button),
mode: p.input,
});
self.resend_menu(conn, p.container, world);
return Vec::new();
}
// A menu that is no longer valid takes no clicks.
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
+12 -3
View File
@@ -191,6 +191,8 @@ pub struct Guest {
pub craft_result: Option<ItemStack>,
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
pub ender_items: Vec<Option<ItemStack>>,
/// What a native mod's menu shows (`Slots::Native`), while one is open.
pub native_menu: Vec<Option<ItemStack>>,
pub health: f32,
/// The saved `AbsorptionAmount`, until the player's first body on the
/// server takes it (the body holds the live amount).
@@ -347,6 +349,7 @@ impl Guest {
craft_grid: Default::default(),
craft_result: None,
ender_items: vec![None; 27],
native_menu: Vec::new(),
health: 20.0,
saved_absorption: None,
food: 20,
@@ -494,13 +497,19 @@ impl Guest {
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
/// guest's body in its pose (crouching, swimming, gliding).
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
/// guest's body in its pose (crouching, swimming, gliding), with its
/// `block_interaction_range` (4.5, 5 in creative, more with `/attribute`).
pub fn may_reach_block(&mut self, pos: [i32; 3]) -> bool {
let eye = self
.motion
.as_ref()
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
let range = if self.creative() { 5.0 } else { 4.5 };
let otherwise = if self.creative() { 5.0 } else { 4.5 };
let range = self
.motion
.as_mut()
.and_then(|m| m.attribute("minecraft:block_interaction_range").ok())
.unwrap_or(otherwise);
within_block_range(
[self.position[0], self.position[1] + eye, self.position[2]],
range + 1.0,
+19
View File
@@ -170,6 +170,8 @@ pub enum HostEvent {
}
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
pub lan: Lan,
pub config: HubConfig,
pub encoder: ChunkEncoder,
@@ -395,6 +397,7 @@ impl Hub {
entities: Default::default(),
entity_out: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
teams: Vec::new(),
}
}
@@ -962,6 +965,11 @@ impl Hub {
}
}
/// Whether vanilla's command tree has a command of this name.
pub fn vanilla_command(&self, name: &str) -> bool {
self.sync.commands.as_ref().is_some_and(|t| t.has(name))
}
/// The key [`Hub::set_mod_commands`] last had: the game compares it
/// with its server mods' each tick.
pub fn mod_commands_key(&self) -> Option<u64> {
@@ -1344,6 +1352,17 @@ impl Hub {
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
let mut g = self.guests.remove(i);
if let Some(m) = g
.menu
.as_ref()
.filter(|m| m.slots == super::menu::Slots::Native)
{
self.native_menu_events
.push(super::menu::NativeMenuEvent::Closed {
uuid: g.uuid,
window: m.window,
});
}
if g.left_reason.is_empty() {
g.left_reason = reason.to_owned();
}
+33 -1
View File
@@ -37,6 +37,24 @@ pub struct Menu {
pub sent_data: Vec<i16>,
}
/// What a guest did with a native mod's menu (`Slots::Native`), for the game
/// to tell the mod once the hub is back in its hands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NativeMenuEvent {
/// A click (`ServerboundContainerClickPacket`): the menu's slot (its own
/// first, then the player's 36; -999 outside), the button and the mode.
Click {
uuid: u128,
window: i32,
slot: i32,
button: i32,
mode: i32,
},
/// The menu closed: by the player, by the server, another opened, or
/// the player left.
Closed { uuid: u128, window: i32 },
}
/// The y of the key a chest minecart's or chest boat's items are kept under
/// while a menu shows them.
pub const VEHICLE_KEY_Y: i32 = -100_000;
@@ -51,6 +69,9 @@ pub enum Slots {
Double((i32, i32, i32), (i32, i32, i32)),
/// The player's own ender chest inventory (`PlayerEnderChestContainer`).
Ender,
/// A native mod's menu (`Guest::native_menu`): items to look at, which no
/// click moves; the clicks go to the mod.
Native,
}
impl Slots {
@@ -58,7 +79,7 @@ impl Slots {
match self {
Slots::Block(k) => vec![k],
Slots::Double(a, b) => vec![a, b],
Slots::Ender => Vec::new(),
Slots::Ender | Slots::Native => Vec::new(),
}
}
/// Whether the block entities behind the slots are still there
@@ -74,6 +95,7 @@ impl Slots {
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
let blocks = match self {
Slots::Ender => at.into_iter().collect(),
Slots::Native => Vec::new(),
_ => self.halves(),
};
// A vehicle's container is kept under a key, not a block: the game
@@ -88,6 +110,9 @@ impl Slots {
if self == Slots::Ender {
return guest.ender_items.get(n).cloned().flatten();
}
if self == Slots::Native {
return guest.native_menu.get(n).cloned().flatten();
}
for k in self.halves() {
let slots = world.block_entities.get(&k)?.slots();
if n < slots.len() {
@@ -102,6 +127,9 @@ impl Slots {
if self == Slots::Ender {
return Some(guest.ender_items.clone());
}
if self == Slots::Native {
return Some(guest.native_menu.clone());
}
let mut items = Vec::new();
for k in self.halves() {
items.extend_from_slice(world.block_entities.get(&k)?.slots());
@@ -114,6 +142,10 @@ impl Slots {
guest.ender_items = items;
return;
}
if self == Slots::Native {
// Only the mod changes what it shows.
return;
}
let mut rest = items.into_iter();
for k in self.halves() {
if let Some(e) = world.block_entities.get_mut(&k) {
+11
View File
@@ -132,6 +132,12 @@ pub struct NetLog {
pub log_ips: bool,
}
/// The server.properties key that lets only the Horizon client join (off unless "true").
pub const HORIZON_CLIENTS_ONLY: &str = "horizon-clients-only";
/// What another client is told.
pub const HORIZON_CLIENTS_ONLY_REASON: &str =
"This server is for the Horizon client: playhorizon.dev";
/// How the server side of the network behaves: an integrated server's LAN
/// uses the defaults, a dedicated server takes them from server.properties.
#[derive(Clone)]
@@ -176,6 +182,10 @@ pub struct NetConfig {
/// An integrated server's owner profile: the properties the window's own
/// (memory) connection logs in with.
pub owner_profile: Option<Arc<OwnerProfile>>,
/// `horizon-clients-only` (Horizon's own server.properties key, off): a
/// client whose `minecraft:brand` isn't Horizon's is turned away during
/// configuration. A brand can be spoofed: a gate, not security.
pub horizon_clients_only: bool,
/// The shapes of the world's levels: their dimension types go to
/// clients when the world's data packs changed them.
pub levels: crate::chunk::LevelShapes,
@@ -207,6 +217,7 @@ impl NetConfig {
log: None,
resource_pack: None,
server_links: Vec::new(),
horizon_clients_only: false,
levels: crate::chunk::LevelShapes::VANILLA,
owner_profile: None,
}
@@ -491,6 +491,13 @@ fn liquid_in(w: &MobWorld, b: Aabb) -> bool {
false
}
/// Whether this tick of the heart reaches its update (`updateCreakingState`
/// and the protector checks), the only part of [`server_tick`] that reads the
/// level's other mobs: about once a second, so a host may build them only then.
pub fn tick_updates(heart: &CreakingHeart) -> bool {
heart.ticker < 0
}
/// `CreakingHeartBlockEntity.serverTick` (hearts that are not uprooted).
pub fn server_tick(
heart: &mut CreakingHeart,
+246 -1
View File
@@ -15,6 +15,7 @@
//! The loaders need no mod code, vanilla state ids never move, and with no
//! content mod the root is the base root itself.
use crate::{Result, blocks::MAP_COLORS};
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
use horizon_mods::content::{Block, Content, Rarity};
use horizon_world::nbt::{self, JavaString, Limits, Tag};
use serde::{Deserialize, Serialize, de};
@@ -228,12 +229,99 @@ impl Rename {
}
}
// DiscreteVoxelShape.forAllEdges: merge consecutive boundary edges.
fn shape_edges(shape: &VoxelShape) -> Vec<J> {
let sizes = shape.sizes();
let mut result = Vec::new();
for axis in [2, 0, 1] {
let a = (axis + 1) % 3;
let b = (axis + 2) % 3;
for u in 0..=sizes[a] {
for v in 0..=sizes[b] {
let mut start = None;
for t in 0..=sizes[axis] {
let mut count = 0;
let mut odd = 0;
for du in 0..=1 {
for dv in 0..=1 {
let mut p = [0i32; 3];
p[axis] = t as i32;
p[a] = u as i32 + du - 1;
p[b] = v as i32 + dv - 1;
if shape.is_full(p[0], p[1], p[2]) {
count += 1;
odd ^= du ^ dv;
}
}
}
let edge = count == 1 || count == 3 || (count == 2 && odd == 0);
if edge {
if start.is_none() {
start = Some(t);
}
} else if let Some(first) = start.take() {
let mut from = [0.0; 3];
let mut to = [0.0; 3];
from[a] = shape.coordinates(Axis::ALL[a])[u];
from[b] = shape.coordinates(Axis::ALL[b])[v];
to[a] = from[a];
to[b] = from[b];
from[axis] = shape.coordinates(Axis::ALL[axis])[first];
to[axis] = shape.coordinates(Axis::ALL[axis])[t];
result.push(J::Arr(from.into_iter().chain(to).map(J::f64).collect()));
}
}
}
}
}
result
}
fn encode_shape(shape: &VoxelShape) -> Tag {
let mut c = nbt::Compound::new();
let (min, max) = shape.discrete_bounds();
c.insert("canonical".into(), Tag::Byte(0));
c.insert("cube".into(), Tag::Byte(i8::from(shape.is_cube())));
let axes = shape.cube_coordinate_axes();
c.insert(
"cube_axes".into(),
Tag::Byte((axes[0] as i8) | ((axes[1] as i8) << 1) | ((axes[2] as i8) << 2)),
);
for (name, axis) in ["x", "y", "z"].into_iter().zip(Axis::ALL) {
c.insert(
name.into(),
Tag::LongArray(
shape
.coordinates(axis)
.iter()
.map(|n| n.to_bits() as i64)
.collect(),
),
);
}
let [x, y, z] = shape.sizes();
let mut occupied = Vec::with_capacity(x * y * z);
for i in 0..x {
for j in 0..y {
for k in 0..z {
occupied.push(u8::from(shape.is_full(i as i32, j as i32, k as i32)));
}
}
}
c.insert("occupied".into(), Tag::ByteArray(occupied));
c.insert("discrete_min".into(), Tag::IntArray(min.to_vec()));
c.insert("discrete_max".into(), Tag::IntArray(max.to_vec()));
Tag::Compound(c)
}
/// A block being added: its declaration and where its states go.
struct NewBlock<'a> {
decl: &'a Block,
rename: Rename,
/// The template's item, if it has one.
item: Option<String>,
shapes: Option<Vec<VoxelShape>>,
default: u32,
}
/// An item being added (declared, or a block's).
struct NewItem {
@@ -323,6 +411,104 @@ impl<'a> Plan<'a> {
let states = range(&decl.template).ok_or_else(|| {
format!("{}: template {} is not a block", decl.id, decl.template)
})?;
let shapes = if let Some(spec) = &decl.shapes {
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
let mut used = BTreeSet::new();
let mut out = Vec::new();
for id in states.clone() {
let J::Arr(row) = &rows[id as usize] else {
unreachable!()
};
let Some(J::Str(value)) = row
.get(4)
.and_then(|x| x.get("properties"))
.and_then(|x| x.get(&spec.property))
else {
return Err(format!(
"{}: template state lacks property {}",
decl.id, spec.property
)
.into());
};
let boxes = spec.values.get(value).ok_or_else(|| {
format!("{}: no shape for {}={value}", decl.id, spec.property)
})?;
used.insert(value.as_str());
let mut shape = VoxelShape::empty();
for b in boxes {
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))?;
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
}
out.push(shape);
}
if used.len() != spec.values.len() {
return Err(format!(
"{}: shape values include an unknown {} value",
decl.id, spec.property
)
.into());
}
Some(out)
} else {
None
};
let mut default = *defaults.get(&decl.template).ok_or_else(|| {
format!(
"{}: template {} has no default state",
decl.id, decl.template
)
})?;
if !decl.default_state.is_empty() {
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
let properties = |id: u32| -> Option<&J> {
let J::Arr(row) = rows.get(id as usize)? else {
return None;
};
row.get(4)?.get("properties")
};
let mut wanted = properties(default).cloned().ok_or_else(|| {
format!("{}: template default has no state properties", decl.id)
})?;
for (key, value) in &decl.default_state {
if wanted.get(key).is_none() {
return Err(format!(
"{}: unknown state property {key} on {}",
decl.id, decl.template
)
.into());
}
wanted.set(key, J::Str(value.clone()));
}
let matches: Vec<u32> = states
.clone()
.filter(|id| {
properties(*id).is_some_and(|p| {
let (J::Obj(a), J::Obj(b)) = (p, &wanted) else {
return false;
};
a.len() == b.len() && b.iter().all(|(k, v)| p.get(k) == Some(v))
})
})
.collect();
if matches.len() != 1 {
return Err(format!(
"{}: default_state {:?} matches {} states of {}",
decl.id,
decl.default_state,
matches.len(),
decl.template
)
.into());
}
default = matches[0];
}
let mut rename = Rename::new(&decl.template, &decl.id);
rename.first = next;
next += states.len() as u32;
@@ -337,6 +523,8 @@ impl<'a> Plan<'a> {
decl,
rename,
item: None,
shapes,
default,
});
}
}
@@ -445,6 +633,34 @@ impl<'a> Plan<'a> {
row.set("item", J::Str("minecraft:air".into()));
}
self.override_gameplay(b, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[i];
let boxes = shape
.boxes()
.iter()
.map(|b| {
J::Arr(
[
b.min(Axis::X),
b.min(Axis::Y),
b.min(Axis::Z),
b.max(Axis::X),
b.max(Axis::Y),
b.max(Axis::Z),
]
.into_iter()
.map(J::f64)
.collect(),
)
})
.collect();
row.set("outline", J::Arr(boxes));
row.set("outline_edges", J::Arr(shape_edges(shape)));
row.set(
"full_collision",
J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?),
);
}
}
list.push(row);
}
@@ -546,7 +762,7 @@ impl<'a> Plan<'a> {
states(doc, "states", false, false)?;
let defaults = doc.at("defaults").ok_or("no defaults")?;
for b in &self.blocks {
let default = self.defaults[&b.decl.template];
let default = b.default;
let id = b.rename.id(default).ok_or("a default outside its block")?;
defaults.set(&b.decl.id, J::Num(id.into()));
}
@@ -649,6 +865,32 @@ impl<'a> Plan<'a> {
Ok(())
}
fn collision_shapes(&self, tag: &mut Tag) -> Result<()> {
let Tag::Compound(root) = tag else {
return Err("collision table is not a compound".into());
};
let mut indices = Vec::new();
let Some(Tag::List(entries)) = root.get_mut(&JavaString::from("shapes")) else {
return Err("collision table has no shapes".into());
};
for block in &self.blocks {
if let Some(shapes) = &block.shapes {
for (i, shape) in shapes.iter().enumerate() {
let id = entries.len() as i32;
entries.push(encode_shape(shape));
indices.push((block.rename.first as usize + i, id));
}
}
}
let Some(Tag::IntArray(mapping)) = root.get_mut(&JavaString::from("cached_shapes")) else {
return Err("collision table has no mapping".into());
};
for (state, id) in indices {
mapping[state] = id;
}
Ok(())
}
/// One NBT table: `states` lists, `cached_shapes` arrays and `blocks`
/// compounds, wherever they are.
fn nbt(&self, tag: &mut Tag) {
@@ -1113,6 +1355,9 @@ fn tables(base: &Path, plan: &Plan, overlay: &Overlay<'_>) -> Result<()> {
let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables())
.map_err(|e| format!("{name}: {e}"))?;
plan.nbt(&mut named.tag);
if name == "cached-collision.nbt" || name == "worldgen/blocks/cached-collision.nbt" {
plan.collision_shapes(&mut named.tag)?;
}
nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))?
};
overlay.write(name, &bytes)
@@ -1081,3 +1081,195 @@ fn a_replacing_mod_tag_reaches_the_root() {
);
let _ = fs::remove_dir_all(&scratch);
}
#[test]
#[ignore = "full content root: needs prepared vanilla data"]
fn content_shapes_drive_outline_and_collision() {
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
};
use horizon_world::{collision::CachedCollisionRegistry, registry::BlockStateId};
let Some(base) = prepared() else {
return;
};
let scratch =
std::env::temp_dir().join(format!("horizon-content-shapes-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let m = folder_mod(
&scratch.join("mod"),
&[
("horizon-mod.toml", &manifest("shapetest", "")),
(
"data/shapetest/horizon/block/rod.json",
r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[[0,0.25,0.25,1,0.75,0.75]],"y":[[0.25,0,0.25,0.75,1,0.75]],"z":[[0.25,0.25,0,0.75,0.75,1]]}}}"#,
),
(
"data/shapetest/horizon/block/plain.json",
r#"{"template":"minecraft:oak_log"}"#,
),
],
);
let root = resource_root(&base, &[m], &scratch.join("cache")).unwrap();
let assets = Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).unwrap();
let (named, _) = nbt::read_named(
&fs::read(root.join("cached-collision.nbt")).unwrap(),
Limits::tables(),
)
.unwrap();
let collision = CachedCollisionRegistry::from_nbt(&named.tag, registry).unwrap();
for axis in ["x", "y", "z"] {
let state = (0..registry.len())
.map(|i| BlockStateId(i as u32))
.find(|id| {
let s = registry.state(*id).unwrap();
s.name == "shapetest:rod" && s.properties.get("axis").is_some_and(|v| v == axis)
})
.unwrap();
let shape = collision.cached_shape(state).unwrap().unwrap();
let f = facts.get(state);
assert_eq!(shape.boxes().len(), 1);
assert_eq!(f.outline.len(), 1);
assert_eq!(f.outline_edges.len(), 12);
let expected = match axis {
"x" => [0., 0.25, 0.25, 1., 0.75, 0.75],
"y" => [0.25, 0., 0.25, 0.75, 1., 0.75],
_ => [0.25, 0.25, 0., 0.75, 0.75, 1.],
};
assert_eq!(f.outline[0], expected);
let start = Vec3::new(-1., 0.5, 0.5);
let end = Vec3::new(2., 0.5, 0.5);
let target = BlockPos::new(0, 0, 0);
assert!(shape.clip(start, end, target).is_some());
let bounds = f.outline[0];
let outline = Aabb::new(
Vec3::new(bounds[0], bounds[1], bounds[2]),
Vec3::new(bounds[3], bounds[4], bounds[5]),
);
assert_eq!(
shape.clip(start, end, target),
Aabb::clip_boxes([outline], start, end, target)
);
let player = Aabb::new(Vec3::new(-1., 0.4, 0.4), Vec3::new(-0.2, 0.6, 0.6));
assert!((shape.collide(Axis::X, player, 2.) - (expected[0] + 0.2)).abs() < 1e-9);
let miss = Aabb::new(Vec3::new(-1., 0.05, 0.05), Vec3::new(-0.2, 0.15, 0.15));
assert_eq!(shape.collide(Axis::X, miss, 2.), 2.);
}
let plain = registry.default_state("shapetest:plain").unwrap();
let oak = registry.default_state("minecraft:oak_log").unwrap();
assert_eq!(facts.get(plain).outline, facts.get(oak).outline);
assert_eq!(
collision.cached_shape(plain).unwrap().unwrap().boxes(),
collision.cached_shape(oak).unwrap().unwrap().boxes()
);
fs::remove_dir_all(scratch).unwrap();
}
#[test]
fn content_block_default_state_is_validated_and_overlaid() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-default-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let base = scratch.join("base");
fs::create_dir_all(&base).unwrap();
let facts = r#"{"states":[
["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"up","extended":"false"}}],
["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"false"}}],
["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"true"}}]],
"defaults":{"minecraft:piston":0}}"#;
fs::write(base.join("facts.json"), facts).unwrap();
fs::write(base.join("gameplay-facts.json"), r#"{"states":[{"item":"minecraft:piston"},{"item":"minecraft:piston"},{"item":"minecraft:piston"}]}"#).unwrap();
fs::write(base.join("items.json"), r#"{"minecraft:piston":{}}"#).unwrap();
fs::write(base.join("registries.json"), r#"{"minecraft:item":{"entries":{"minecraft:piston":{"protocol_id":0}}},"minecraft:block":{"entries":{"minecraft:piston":{"protocol_id":0}}}}"#).unwrap();
for (declaration, expected) in [
(r#"{"template":"minecraft:piston"}"#, Some(3)),
(
r#"{"template":"minecraft:piston","default_state":{"facing":"north"}}"#,
Some(4),
),
(
r#"{"template":"minecraft:piston","default_state":{"facing":"north","extended":"true"}}"#,
Some(5),
),
(
r#"{"template":"minecraft:piston","default_state":{"facing":"sideways"}}"#,
None,
),
(
r#"{"template":"minecraft:piston","default_state":{"direction":"north"}}"#,
None,
),
] {
let m = folder_mod(
&scratch.join("mod"),
&[
("horizon-mod.toml", &manifest("testmod", "")),
("data/testmod/horizon/block/test.json", declaration),
],
);
let mods = [m];
match (Plan::new(&base, &mods), expected) {
(Ok(plan), Some(id)) => {
let mut doc: J = serde_json::from_str(facts).unwrap();
plan.json("facts.json", &mut doc).unwrap();
assert_eq!(
doc.get("defaults").unwrap().get("testmod:test"),
Some(&J::Num(id.into()))
);
}
(Err(e), None) => assert!(e.to_string().contains("testmod:test"), "{e}"),
_ => panic!("unexpected result for {declaration}"),
}
}
// A malformed registry with duplicate states must not choose one silently.
let duplicate = facts.replace(
r#"["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"true"}}]"#,
r#"["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"false"}}]"#,
);
fs::write(base.join("facts.json"), duplicate).unwrap();
let m = folder_mod(
&scratch.join("mod"),
&[
("horizon-mod.toml", &manifest("testmod", "")),
(
"data/testmod/horizon/block/test.json",
r#"{"template":"minecraft:piston","default_state":{"facing":"north"}}"#,
),
],
);
let error = match Plan::new(&base, &[m]) {
Err(e) => e.to_string(),
Ok(_) => panic!("duplicate states accepted"),
};
assert!(error.contains("matches 2 states"), "{error}");
fs::remove_dir_all(scratch).unwrap();
}
#[test]
fn shapes_reject_unknown_or_missing_template_values() {
let Some(base) = prepared() else { return };
let scratch =
std::env::temp_dir().join(format!("horizon-shape-invalid-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
for declaration in [
r#"{"template":"minecraft:oak_log","shapes":{"property":"typo","values":{"x":[]}}}"#,
r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[]}}}"#,
r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[],"y":[],"z":[],"bad":[]}}}"#,
] {
let m = folder_mod(
&scratch.join("mod"),
&[
("horizon-mod.toml", &manifest("shapetest", "")),
("data/shapetest/horizon/block/rod.json", declaration),
],
);
let mods = [m];
let e = match Plan::new(&base, &mods) {
Ok(_) => panic!("accepted {declaration}"),
Err(e) => e.to_string(),
};
assert!(e.contains("shapetest:rod"), "{e}");
}
fs::remove_dir_all(scratch).unwrap();
}
+24
View File
@@ -29,6 +29,29 @@ submissions per account and sixty in all per hour; thirty ratings per account an
rate it, and only projects on the registry's `main` can be rated. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else
is a pull request by hand.
## Owner updates
A reviewer's approval is not needed for an *owner update*: the submitter's login is in the entry's `owners` on the
registry's `main`, and the only change to the entry is one new version (key, owners, icon, kind, id, name, summary,
description, authors, licence and source as they were; a signature when the entry has a key). Then, and only when
`REGISTRY_UPDATE_KEY` is set, the pull request's body ends with a trailer and it gets the label `owner-update`:
```
<!-- owner-update
id: coords_hud
version: 0.1.0
sha256: <hex>
file: coords_hud-0.1.0.hmod
submitter: alice
hmac: <hex HMAC-SHA256 with REGISTRY_UPDATE_KEY of "owner-update-v1\n<id>\n<version>\n<sha256>\n<file>\n<submitter>">
-->
```
Everything else (a new project, a changed page or key, no secret) opens the pull request as before, for a reviewer.
The trailer is the only thing that marks an update as the form's own: the horizon-mods repository's `owner-update`
workflow verifies it with the same secret, re-checks the diff, waits for the checks and merges with `REGISTRY_MERGE_TOKEN`
(its README says how). Without the secret on both sides, nothing is merged by itself.
A refused submission is always `{error}` JSON, sent once the upload is over (a browser still sending when the answer
comes reports a network error instead): 413 for a file over 64 MiB, 504 when GitHub doesn't answer in time (30 s a
call, 5 minutes for the file's upload), 502 for GitHub's other failures.
@@ -47,6 +70,7 @@ REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT
| `REGISTRY_SESSION_KEY` | 32 characters or more, random: signs sessions |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app |
| `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write |
| `REGISTRY_UPDATE_KEY` | Optional, random, 32 characters or more: signs owner updates' trailers (below). Unset: every update waits for a reviewer. The same value is the secret `REGISTRY_UPDATE_KEY` of horizon-mods |
| `REGISTRY_REPO` | Default `horizon-rust/horizon-mods` |
| `REGISTRY_DB` | The ratings' SQLite file: default `/data/registry.sqlite3`, the volume |
| `PORT` | Default 8080 |
+14 -3
View File
@@ -288,14 +288,25 @@ impl GitHub {
Ok(())
}
/// Opens the pull request; its address.
pub fn pull(&self, title: &str, head: &str, body: &str) -> Result<String> {
/// Opens the pull request; its address and number.
pub fn pull(&self, title: &str, head: &str, body: &str) -> Result<(String, u64)> {
let pr = self
.call("POST", "/pulls")
.send_json(json!({"title": title, "head": head, "base": "main", "body": body}))
.map_err(failure)
.and_then(read)?;
Ok(pr["html_url"].as_str().unwrap_or("").to_owned())
Ok((
pr["html_url"].as_str().unwrap_or("").to_owned(),
pr["number"].as_u64().unwrap_or(0),
))
}
/// Adds `label` to pull request `number` (GitHub creates it when new).
pub fn label(&self, number: u64, label: &str) -> Result<()> {
self.call("POST", &format!("/issues/{number}/labels"))
.send_json(json!({"labels": [label]}))
.map_err(failure)?;
Ok(())
}
}
+80 -7
View File
@@ -60,6 +60,9 @@ pub struct Config {
/// At least 32 bytes: signs sessions.
pub session_key: Vec<u8>,
pub github: GitHub,
/// `REGISTRY_UPDATE_KEY`: signs an owner update's trailer; None turns the
/// feature off (every update waits for a reviewer).
pub update_key: Option<Vec<u8>>,
/// Where files wait during a submission.
pub scratch: PathBuf,
/// The ratings' SQLite file (a volume): `:memory:` keeps them in memory.
@@ -69,7 +72,8 @@ pub struct Config {
impl Config {
/// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`,
/// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`,
/// `REGISTRY_DB` (default `/data/registry.sqlite3`).
/// `REGISTRY_UPDATE_KEY` (optional), `REGISTRY_DB` (default
/// `/data/registry.sqlite3`).
pub fn from_env() -> Result<Config, String> {
let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set"));
let key = var("REGISTRY_SESSION_KEY")?;
@@ -87,6 +91,10 @@ impl Config {
var("GITHUB_CLIENT_SECRET")?,
var("GITHUB_BOT_TOKEN")?,
),
update_key: std::env::var("REGISTRY_UPDATE_KEY")
.ok()
.filter(|k| !k.is_empty())
.map(String::into_bytes),
scratch: std::env::temp_dir().join("horizon-mod-registry"),
database: std::env::var_os("REGISTRY_DB")
.map_or_else(|| PathBuf::from("/data/registry.sqlite3"), PathBuf::from),
@@ -701,6 +709,32 @@ fn date(secs: u64) -> String {
format!("{y:04}-{m:02}-{d:02}")
}
/// The trailer of an owner update's pull request: a comment holding what the
/// update is and an HMAC-SHA256 (hex, with `key`) over it, which the registry
/// repository's `owner-update` workflow verifies with the same secret
/// (`scripts/owner_update.py` there builds the same message).
pub fn update_trailer(
key: &[u8],
id: &str,
version: &str,
sha256: &str,
file: &str,
submitter: &str,
) -> String {
let message = format!("owner-update-v1\n{id}\n{version}\n{sha256}\n{file}\n{submitter}");
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes any key length");
mac.update(message.as_bytes());
let hmac: String = mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
format!(
"<!-- owner-update\nid: {id}\nversion: {version}\nsha256: {sha256}\nfile: {file}\nsubmitter: {submitter}\nhmac: {hmac}\n-->"
)
}
/// The submission, past its checks: the entry written on a new branch and a
/// pull request opened. Returns the pull request's address.
pub fn publish(
@@ -757,6 +791,9 @@ pub fn publish(
};
let existing = gh.entry(&form.id).map_err(github_down)?;
let is_new = existing.is_none();
// An owner update: an owner's new version and nothing else of the entry
// changes (key and owners included).
let mut owner_update = false;
let (entry, blob) = match existing {
None => (
Entry {
@@ -804,6 +841,7 @@ pub fn publish(
"changing a project's key is a pull request by hand, reviewed on its own",
));
}
let before = entry.clone();
entry.name = form.name.trim().to_owned();
entry.summary = form.summary.trim().to_owned();
entry.description = form.description.clone();
@@ -814,6 +852,15 @@ pub fn publish(
entry.icon = form.icon.clone();
}
entry.versions.insert(0, version.clone());
let (mut a, mut b) = (entry.clone(), before);
a.versions.clear();
b.versions.clear();
owner_update = a == b
&& (entry.key.is_none() || version.signature.is_some())
&& version
.version
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b".-+_".contains(&c));
(entry, Some(blob))
}
};
@@ -904,12 +951,38 @@ pub fn publish(
source = entry.source,
lists = [capabilities, requires].concat().join("\n"),
);
gh.pull(
&format!("{what} {} {}", form.id, version.version),
&branch,
&body,
)
.map_err(github_down)
let trailer = config
.update_key
.as_deref()
.filter(|_| owner_update)
.map(|key| {
update_trailer(
key,
&form.id,
&version.version,
&sha256,
&v.file.name,
login,
)
});
let body = match &trailer {
Some(t) => format!("{body}\n\n{t}"),
None => body,
};
let (url, number) = gh
.pull(
&format!("{what} {} {}", form.id, version.version),
&branch,
&body,
)
.map_err(github_down)?;
if trailer.is_some() {
// The pull request is open: a label that fails only leaves it to a reviewer.
if let Err(e) = gh.label(number, "owner-update") {
eprintln!("labelling {url}: {} {}", e.status, e.message);
}
}
Ok(url)
}
#[cfg(test)]
+130 -1
View File
@@ -32,6 +32,8 @@ struct Mock {
/// (branch, path) -> (text, message, replaced blob).
commits: HashMap<(String, String), (String, String, Option<String>)>,
pulls: Vec<Value>,
/// (pull request number, labels added).
labels: Vec<(u64, Value)>,
/// The account the next sign-in gets.
login: String,
/// GitHub takes this long to find the staging release.
@@ -125,7 +127,15 @@ async fn mock_github() -> (String, Shared) {
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
let mut s = s.lock().unwrap();
s.pulls.push(b);
Json(json!({"html_url": format!("https://github.com/o/r/pull/{}", s.pulls.len())}))
let n = s.pulls.len();
Json(json!({"html_url": format!("https://github.com/o/r/pull/{n}"), "number": n}))
}),
)
.route(
"/repos/o/r/issues/{n}/labels",
post(|State(s): State<Shared>, UrlPath(n): UrlPath<u64>, Json(b): Json<Value>| async move {
s.lock().unwrap().labels.push((n, b["labels"].clone()));
Json(json!([]))
}),
)
.with_state(state.clone());
@@ -269,6 +279,15 @@ async fn setup() -> (Client, Shared) {
}
async fn setup_with(change: impl FnOnce(&mut github::GitHub)) -> (Client, Shared) {
setup_keyed(change, Some(UPDATE_KEY)).await
}
const UPDATE_KEY: &[u8] = b"a test update key";
async fn setup_keyed(
change: impl FnOnce(&mut github::GitHub),
update_key: Option<&[u8]>,
) -> (Client, Shared) {
let (base, mock) = mock_github().await;
let scratch =
std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id()));
@@ -288,6 +307,7 @@ async fn setup_with(change: impl FnOnce(&mut github::GitHub)) -> (Client, Shared
public_url: PUBLIC.into(),
session_key: b"a test key of at least thirty-two bytes".to_vec(),
github,
update_key: update_key.map(<[u8]>::to_vec),
scratch,
database: ":memory:".into(),
};
@@ -341,6 +361,115 @@ async fn a_submission_becomes_a_pull_request() {
assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once");
}
/// `mods/coords_hud.toml` on main as `alice` owns it: what the form's page
/// says, a dummy first version.
fn published(owner: &str, key: bool) -> String {
let f = form();
let text = format!(
"id = \"coords_hud\"\nkind = \"mod\"\nname = \"Coordinates HUD\"\nsummary = \"Your coordinates in a corner.\"\nauthors = [\"Alice\"]\nowners = [\"{owner}\"]\nlicense = \"MIT\"\nsource = \"https://github.com/alice/coords\"\n{}\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"{}\"\n",
if key {
format!("key = {}\n", f["key"])
} else {
String::new()
},
"0".repeat(64)
);
toml::from_str::<Entry>(&text).unwrap();
text
}
/// What an update by `alice` leaves on GitHub: (the pull request's body, its labels).
async fn update(
main: String,
change: impl FnOnce(&mut Value),
key: Option<&[u8]>,
) -> (String, Vec<(u64, Value)>) {
let (mut client, mock) = setup_keyed(|_| {}, key).await;
mock.lock()
.unwrap()
.main
.insert("mods/coords_hud.toml".into(), main);
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let csrf = client.sign_in().await;
let mut f = form();
change(&mut f);
let (status, json) = client.submit(&csrf, &f, &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
let m = mock.lock().unwrap();
(
m.pulls[0]["body"].as_str().unwrap().to_owned(),
m.labels.clone(),
)
}
#[tokio::test(flavor = "multi_thread")]
async fn an_owners_update_is_signed_for_the_repository() {
use hmac::{Hmac, Mac};
let (body, labels) = update(published("alice", true), |_| {}, Some(UPDATE_KEY)).await;
let trailer = body.split("<!-- owner-update\n").nth(1).expect(&body);
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(UPDATE_KEY).unwrap();
mac.update(
format!("owner-update-v1\ncoords_hud\n0.1.0\n{HMOD_SHA}\ncoords_hud-0.1.0.hmod\nalice")
.as_bytes(),
);
let hmac: String = mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(
trailer,
format!(
"id: coords_hud\nversion: 0.1.0\nsha256: {HMOD_SHA}\nfile: coords_hud-0.1.0.hmod\nsubmitter: alice\nhmac: {hmac}\n-->"
),
);
assert_eq!(labels, [(1, json!(["owner-update"]))]);
}
#[tokio::test(flavor = "multi_thread")]
async fn everything_else_waits_for_a_reviewer() {
let unsigned = |body: &str, labels: &[(u64, Value)]| {
assert!(!body.contains("owner-update"), "{body}");
assert!(labels.is_empty());
};
// A new project, not an update (alice isn't in a missing entry's owners).
let (mut client, mock) = setup().await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let csrf = client.sign_in().await;
client.submit(&csrf, &form(), &file).await;
{
let m = mock.lock().unwrap();
unsigned(m.pulls[0]["body"].as_str().unwrap(), &m.labels);
}
// The feature is off without the secret.
let (body, labels) = update(published("alice", true), |_| {}, None).await;
unsigned(&body, &labels);
// The page's text changes with the version.
let (body, labels) = update(
published("alice", true),
|f| f["summary"] = json!("Another."),
Some(UPDATE_KEY),
)
.await;
unsigned(&body, &labels);
// A new icon.
let (body, labels) = update(
published("alice", true),
|f| f["icon"] = json!("https://x.test/i.png"),
Some(UPDATE_KEY),
)
.await;
unsigned(&body, &labels);
// A project with no key (and so no signature) is signed as well.
let unkeyed = |f: &mut Value| {
f.as_object_mut().unwrap().remove("key");
f.as_object_mut().unwrap().remove("signature");
};
let (body, _) = update(published("alice", false), unkeyed, Some(UPDATE_KEY)).await;
assert!(body.contains("<!-- owner-update"));
}
#[tokio::test(flavor = "multi_thread")]
async fn what_the_form_refuses() {
let (mut client, mock) = setup().await;
+41
View File
@@ -75,12 +75,23 @@ pub struct BlockProperties {
pub sounds: Option<String>,
}
/// Per-state outline and collision boxes, selected by one state property.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BlockShapes {
pub property: String,
pub values: BTreeMap<String, Vec<[f64; 6]>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BlockFile {
template: String,
#[serde(default)]
properties: BlockProperties,
shapes: Option<BlockShapes>,
#[serde(default)]
default_state: BTreeMap<String, String>,
#[serde(default = "yes")]
item: bool,
}
@@ -97,6 +108,9 @@ pub struct Block {
/// The vanilla block it copies, `minecraft:<path>`.
pub template: String,
pub properties: BlockProperties,
pub shapes: Option<BlockShapes>,
/// State properties overlaid on the template default.
pub default_state: BTreeMap<String, String>,
/// Also add a block item with the same id.
pub item: bool,
}
@@ -333,6 +347,8 @@ impl Content {
id: block,
template: file.template,
properties: file.properties,
shapes: file.shapes,
default_state: file.default_state,
item: file.item,
});
}
@@ -393,6 +409,20 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
file.template
));
}
if let Some(shapes) = &file.shapes {
if shapes.property.is_empty() || shapes.values.is_empty() {
return Err("shapes needs a property and values".into());
}
for boxes in shapes.values.values() {
if boxes.len() > 16
|| boxes
.iter()
.any(|b| b.iter().any(|n| !n.is_finite()) || (0..3).any(|i| b[i] > b[i + 3]))
{
return Err("shape needs at most 16 finite boxes with ordered bounds".into());
}
}
}
let p = &file.properties;
let finite = |v: Option<f32>| v.is_none_or(f32::is_finite);
if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) {
@@ -499,6 +529,8 @@ capabilities = ["registries"]
[Block {
id: "ruby:ruby_block".into(),
template: "minecraft:iron_block".into(),
shapes: None,
default_state: BTreeMap::new(),
properties: BlockProperties {
hardness: Some(4.0),
light: Some(3),
@@ -591,6 +623,15 @@ capabilities = ["registries"]
br#"{"template": "minecraft:stone", "glow": true}"#,
)],
);
refused("shape-reversed", &[("data/ruby/horizon/block/a.json", br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{"x":[[1,0,0,0,1,1]]}}}"#)]);
refused("shape-malformed", &[("data/ruby/horizon/block/a.json", br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{"x":[[0,0,0,1,1]]}}}"#)]);
refused(
"shape-no-values",
&[(
"data/ruby/horizon/block/a.json",
br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{}}}"#,
)],
);
let e = refused(
"clash",
&[
+65 -2
View File
@@ -1,10 +1,10 @@
//! The commands that manage a server in the background (docs/server.md, section 5): `start`, `stop`,
//! `restart`, `status`, `cmd` and `logs`. They talk to the running supervisor through the control API.
//! `restart`, `status`, `cmd`, `report` and `logs`. They talk to the running supervisor through the control API.
use crate::{api::Client, backup, signals, supervisor};
use serde_json::{Value, json};
use std::{
path::Path,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
@@ -158,6 +158,69 @@ pub fn command(dir: &Path, words: &[String]) -> Result<(), String> {
Ok(())
}
/// `report [SECONDS]`: the running game records a performance report for that long (60 s by default,
/// at most 600), writes it to `reports/` (a JSON file and a short text summary, nothing about who
/// plays) and this prints the summary.
pub fn report(dir: &Path, words: &[String]) -> Result<(), String> {
let seconds = match words.first() {
None => 60,
Some(word) => word
.parse::<u64>()
.ok()
.filter(|s| (1..=600).contains(s))
.ok_or("report takes the seconds to record, 1 to 600 (60 by default)")?,
};
let client = running(dir)?;
wait_running(&client, None)?;
let reports = dir.join("reports");
let before = report_files(&reports);
let reply = client.call(
"POST",
"/api/v1/command",
Some(&json!({ "line": format!("horizon-report {seconds}") })),
)?;
for line in reply["output"].as_array().into_iter().flatten() {
println!("{}", line.as_str().unwrap_or_default());
}
println!("Recording for {seconds} s...");
// The report is written a moment after the last recorded tick; a slow server gets a minute more.
let deadline = Instant::now() + Duration::from_secs(seconds + 60);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(500));
if let Some(text) = report_files(&reports)
.into_iter()
.find(|p| !before.contains(p))
{
let summary =
std::fs::read_to_string(&text).map_err(|e| format!("{}: {e}", text.display()))?;
print!("{summary}");
println!("Saved: {} (and the .json beside it)", text.display());
return Ok(());
}
}
Err(format!(
"no report in {} after {} s: is the game on a release with `horizon-report`?",
reports.display(),
seconds + 60
))
}
/// The text summaries in `reports/`.
fn report_files(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension().is_some_and(|e| e == "txt")
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("perf-"))
})
.collect()
}
/// `panel`: a one-time login link for the web panel.
pub fn panel(dir: &Path) -> Result<(), String> {
let reply = running(dir)?.call("POST", "/api/v1/panel/login", None)?;
+4
View File
@@ -44,6 +44,9 @@ Usage: horizon-server [--dir DIR] <command>
Up or not, TPS, MSPT, players, RAM and CPU
cmd COMMAND...
Run a console command on the running server and print what it answers
report [SECONDS]
Record a performance report for SECONDS (default 60): where the ticks' time goes,
entities, chunks, network, memory; saved in reports/ (JSON and text, no player data)
logs [-f] [-n N]
The last N lines of the log (default 50); -f follows it
backup [NAME]
@@ -106,6 +109,7 @@ fn main() -> ExitCode {
Some("restart") => cli::stop(&dir, true),
Some("status") => cli::status(&dir, args[1..].iter().any(|a| a == "--json")),
Some("cmd") => cli::command(&dir, &args[1..]),
Some("report") => cli::report(&dir, &args[1..]),
Some("logs") => logs(&dir, &args[1..]),
Some("panel") => cli::panel(&dir),
Some("backup") => cli::backup(&dir, args.get(1).map(String::as_str)),
+1
View File
@@ -9,6 +9,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
- [Host a server](hosting.md): install `horizon-server`, prepare the game files, first run, `server.toml`, the commands, the web panel, backups, services and Docker; what works today and what doesn't yet.
- [Hosting a server: design](server.md): `horizon-server`, the design and the plan: install, `server.toml`, the CLI, the web panel, backups, services, Docker, and the bot benchmark against vanilla.
- [Survival multiplayer](smp.md): the official server's `smp` native mod (economy, market, trades, games of chance, spawn, homes, teleport requests, warps, land claims) and the server hook points it needs; not part of Horizon's own server.
- [For server owners](owners.md): every `server.properties` key next to its `server.toml` field.
- [Agent control](agent-control.md): what AI agents can drive in the launcher and the game, and the design of the control socket, `horizon-ctl` and MCP.
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
+4 -6
View File
@@ -22,7 +22,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
"license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...", "downloads": 1234,
"versions": [{
"version": "0.1.0", "published": "2026-10-24", "changelog": "",
"api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "api_level": 0, "game": "26.3",
"api": "0.1", "side": "client", "capabilities": ["registries"], "game": "26.3",
"file": { "name": "coords_hud-0.1.0.hmod", "url": "https://github.com/horizon-rust/horizon-mods/releases/download/...",
"size": 438, "sha256": "e113...", "signature": "untrusted comment: ...\n...\ntrusted comment: ...\n...\n" }
}]
@@ -45,14 +45,12 @@ Level: preview ([stability.md](stability.md)). Schema 1.
| `downloads` | How many times its files were downloaded, every version together: GitHub's count of each release asset, summed by the publish job, which runs every hour. Absent until a file is downloaded. Read-only: never in an entry. |
| `api` | A mod's API line (its manifest's `api`); a native mod's pinned revisions (`native-1/<horizon>/<mixins>`); a modpack's manifest format (`1`). |
| `side`, `capabilities` | A mod's, read from its manifest by CI, never typed by hand. |
| `api_level` | For a WebAssembly mod published before they were removed: the mod API level its code needs, read from the file by CI. Absent for content mods and native mods. The launcher doesn't install such a mod alone (it "needs a native port"); it stays in the index for the modpacks that require it. |
| `api_level` | A legacy field in old entries; the launcher ignores it. |
| `game` | The Minecraft version it is for. |
| `requires` | A modpack's mods: `{ id, version, sha256 }`, each a version in this index with that hash. |
| `file` | A plain name with the kind's extension (`.hmod`, `.hnmod`, `.hpack`, `.zip`, `.hshader`), an `https://` URL, the size (at most 64 MiB), the lowercase hex SHA-256, and the `.minisig` text when the project has a key. |
| `revoked` | Files taken down: one file by `sha256`, or every version of `id` without it. The launcher refuses to install them and turns installed copies off with the reason. |
The sample's `coords_hud` is a WebAssembly mod from before they were removed, which is why it has an `api_level`.
Readers ignore fields they don't know, so an optional field can be added to schema 1. Renaming or removing a field, or
changing what one means, makes schema 2.
@@ -141,7 +139,7 @@ Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon
| Command | What it does |
| --- | --- |
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above) and its signature with `--key` and `--signature`. It refuses an `.hmod` with WebAssembly code, and runs no mod's code: a native mod's source is read by the reviewers. |
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above) and its signature with `--key` and `--signature`. It runs no mod's code: a native mod's source is read by the reviewers. |
| `cargo horizon-mod registry check <base> <head> --files <dir>` | `check` on each file `head` lists and `base` doesn't: a pull request's new files. |
| `cargo horizon-mod registry list <checkout>` | `<sha256> <id> <version> <file>` per version: the files to download. |
| `cargo horizon-mod registry build <checkout> --files <dir> --out index.json` | Reads every entry and its file (`<dir>/<sha256>`), lists every problem, and writes the index with URLs `<release><id>-<version>/<file>` (`--release`, ending in `/`, defaults to the repository's releases). |
@@ -154,7 +152,7 @@ How the repository runs (its workflows are in `.github/workflows/`):
`cargo-horizon-mod` from Horizon at the commit in the repository variable `HORIZON_REV`. The form's pull requests
are opened with the token of the account in the variable `REGISTRY_BOT`; for those, the author is the login in the
body's `<!-- submitted-by: … -->` line.
- **Merging** needs the `check` run and a CODEOWNER's review. `publish.yml` then makes each new version a release
- **Merging** needs the `check` run and a CODEOWNER's review, except for an owner update: the form signs it (an HMAC trailer in the pull request's body, label `owner-update`) and horizon-mods' `owner-update` workflow verifies it, waits for the checks and merges it ([the service's README](../../crates/horizon-mod-registry/README.md#owner-updates)). `publish.yml` then makes each new version a release
`<id>-<version>` and deploys `index.json` and `icons/` to GitHub Pages:
`https://horizon-rust.github.io/horizon-mods/index.json`, served with `Access-Control-Allow-Origin: *`.
- **Weekly**, `cleanup.yml` deletes staged copies of published files after 7 days and of files no open pull request
+3
View File
@@ -23,6 +23,9 @@ Usage: horizon-server [--dir DIR] <command>
Up or not, TPS, MSPT, players, RAM and CPU
cmd COMMAND...
Run a console command on the running server and print what it answers
report [SECONDS]
Record a performance report for SECONDS (default 60): where the ticks' time goes,
entities, chunks, network, memory; saved in reports/ (JSON and text, no player data)
logs [-f] [-n N]
The last N lines of the log (default 50); -f follows it
backup [NAME]
-7
View File
@@ -32,13 +32,6 @@ required; refusing input that was accepted; a JSON field that changes type.
Not breaking: a new function, interface, capability, command, endpoint, tool or `server.toml` key; a new optional
argument or JSON field (readers must ignore fields they don't know); faster, or fixing behaviour to what the reference already says.
### Mod API levels (WebAssembly mods, removed)
While WebAssembly mods ran, every function the game offered them had an **API level**, the one it arrived at, read
from the mod's file (`crates/horizon-mods/src/api_level.rs`, levels 0 to 7). Nothing new is added to it. The registry
index still records each old version's level (`api_level`, [mod-index.md](mod-index.md)), for the modpacks that
require those mods.
## Stability levels
Every part of an API has one level, shown in its reference and its surface page.
+1 -1
View File
@@ -65,7 +65,7 @@ in Controls, and adding one would put a non-vanilla mapping into `options.txt`.
The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official native mods now (in
horizon-rust/horizon-mods under `native/<name>/`, [mods.md](mods.md#horizons-official-mods)), with the same look and
options. Players install them from the launcher's Mods page, and the launcher builds them into the game
([native-mods.md](native-mods.md)). They were WebAssembly mods for a while; those don't run any more. The game keeps
([native-mods.md](native-mods.md)). The game keeps
the hook points they use (`crates/horizon-client/src/game/native_hooks.rs`), and `horizon-extras.txt` keeps the old
keys and `movedToMods`.
+13
View File
@@ -200,6 +200,7 @@ horizon-server start | stop | restart in the background (its output goes to l
horizon-server status [--json] up or not, TPS, MSPT, players, chunks, RAM, CPU
horizon-server cmd COMMAND... one console command, and what it answers
horizon-server logs [-f] [-n N] the log; -f follows it
horizon-server report [SECONDS] record a performance report (below)
horizon-server op|deop NAME
horizon-server whitelist on|off|list
horizon-server whitelist add|remove NAME
@@ -221,6 +222,18 @@ console commands do. While it's stopped, they edit vanilla's files directly. Any
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
([pregeneration](pregeneration.md)).
To let only the Horizon client join, set `horizon-clients-only = true` under `[properties]` (off by default).
Other clients are turned away while they connect with "This server is for the Horizon client:
playhorizon.dev", and the console says which client it was. The check reads the brand a client names itself
by (`minecraft:brand`), which any client can fake: it keeps out players who didn't install Horizon, not
cheaters. The server's own checks (reach, movement) are what protect it.
`horizon-server report` records the running server for 60 seconds (`report 300` for five minutes, up to 600),
then prints a short summary and saves it in `reports/`: `perf-<time>.txt` and `perf-<time>.json`. They say where
the ticks' time went, phase by phase (mobs, block ticks, block entities, the network and the chunks it
sends, saving...), the slowest ticks, how many entities of each type, chunks, players, memory, CPU, and the
settings that matter (view and simulation distance, max players). They hold no names, UUIDs or addresses, so you
can attach them to an issue as they are. On the console it is `horizon-report [SECONDS]`.
`config set` changes `server.toml` without opening it: `horizon-server config set world.difficulty=hard
server.max_players=10`. Each key is a section and a name from the file (`properties.spawn-protection=0` for any
+28
View File
@@ -80,6 +80,7 @@ Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Ho
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance)
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
- `--allow-version-mismatch`: Build mods made for different Horizon versions together anyway, on the newest of them
MCP: none: the player's switch.
@@ -836,6 +837,33 @@ Update what the browser installed (or one pack).
MCP: `mods_update`.
## `horizon-launcher mods add-github`
Install a native mod from a GitHub link (docs/native-mods.md, "Install from GitHub"): https://github.com/OWNER/REPO (its latest release, else its default branch), .../releases/tag/TAG, .../tree/REF or .../commit/SHA. Resolved to an exact release file or commit, checked like a registry mod. NOT reviewed by the Horizon registry.
horizon-launcher mods add-github <URL>
- `URL`
- `--mod`: The mod to install when the link holds several
- `--all`: Install every mod the link holds
- `--instance`: The instance (default: the selected one)
- `--trust-native-code`: You read the mod's source (the link printed) and trust it with full access to this computer
MCP: none: the player's switch.
## `horizon-launcher mods update-github`
Look for newer versions of the native mods installed from GitHub (the latest release, or the default branch's head for a mod installed from a commit), and install them.
horizon-launcher mods update-github [ID]
- `ID`: One mod (default: all of them)
- `--check`: Only say what is newer
- `--instance`: The instance (default: the selected one)
- `--trust-native-code`: You read the new source and trust it with full access to this computer
MCP: none: the player's switch.
## `horizon-launcher mods remove`
Remove a pack the browser installed.
+2 -3
View File
@@ -178,9 +178,8 @@ settings) are copied in on the first install only, so the player's changes survi
down installs nothing. It is listed on the Modpacks tab with **Play** and **Remove**, and plays like an imported pack
(`modpacks::apply_profile`). A shader pack goes to `shaderpacks/<file>` once `check_file` has run the game's own
check on it (`horizon_shader_api::check`); a taken-down one is named, for the player to remove.
A registry `.hmod` that has WebAssembly code (any capability besides `registries`) "needs a native port": Horizon doesn't
run it any more. The browser neither lists nor installs it alone. A modpack that requires one still installs, and the
launcher lists the mod as not running, with that reason, and leaves out its code.
A registry `.hmod` with any capability besides `registries` is neither listed nor installed alone. A modpack that
requires one still installs, and the launcher lists the mod as not running, with that reason, and leaves out its code.
```sh
horizon-launcher mods search --source horizon --kind mod <words> # with whether it is signed and what it may do
+24 -2
View File
@@ -158,8 +158,8 @@ with advice if needed.
Content mods (`.hmod`, API 0.2) carry data: blocks, items, and everything in the 26.3 data-pack and resource-pack
formats ([mods.md](mods.md#content-mods-api-02)). Behaviour that needs code is not a content-mod feature: it is
written as a native mod ([native-mods.md](native-mods.md)), one per ported mod. The "Planned in" column keeps the
order of the original plan. The rows planned for 0.3 and later were designed as WebAssembly interfaces, which Horizon
dropped: their "Shape" column is history, and a native mod hooks the game's code instead. What a content pack needs:
order of the original plan; for the rows planned for 0.3 and later, a native mod hooks the game's code instead of
using the "Shape" column. What a content pack needs:
| Feature | Content mod today | Needed by (ATM10 content+integration, by tier) | Planned in | Shape |
|---|---|---|---|---|
@@ -217,6 +217,28 @@ properties and shapes the new block takes, and overrides what differs:
}
```
A block may define `shapes` to replace both its selection outline and collision
shape. It selects a list of boxes by one template state property:
```json
"shapes": { "property": "axis", "values": {
"x": [[0, 0.25, 0.25, 1, 0.75, 0.75]],
"y": [[0.25, 0, 0.25, 0.75, 1, 0.75]],
"z": [[0.25, 0.25, 0, 0.75, 0.75, 1]]
} }
```
Each box is `[min_x, min_y, min_z, max_x, max_y, max_z]` in block units.
Each value of the template property needs an entry. Up to 16 boxes form a union;
an empty list is an empty shape. Invalid bounds or values reject the content root.
Without `shapes`, both shapes remain the template's. Other properties such as
occlusion, light, and support still come from the template.
A declaration may also set `"default_state": {"facing": "north"}`. The map
changes only those state properties on the template's default; omitted properties keep
their template values. Names and values must identify exactly one template state, or
the content root is rejected. Leave the map out to keep the template default.
Most decoration and resource blocks are vanilla classes with other numbers: Macaw's doors are
`DoorBlock`s, XTones are full cubes, ore sets are `DropExperienceBlock`s. So templates cover the
data tier without code. Templates also give mod blocks every behaviour Horizon already
+4 -19
View File
@@ -48,7 +48,7 @@ cargo horizon-mod check target/horizon-mod/my_mod.hmod # the registry's checks
at start, so rebuild and run again after an edit.
- `check <file>` runs what the registry runs: the file's size and SHA-256, its manifest and content, and a signature
with `--key <public key> --signature <file>.minisig`. It reads `.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`
and `.hshader` files, and refuses an `.hmod` with WebAssembly code.
and `.hshader` files.
- `registry build|review|check|list` is the registry repository's CI ([api/mod-index.md](api/mod-index.md)).
### The manifest
@@ -191,7 +191,7 @@ refreshed every hour) and its rating: players signed in with GitHub on its page
account, which they can change. Its owners can't rate it, and nobody sees who rated.
An update is the same form with a new version; only the project's `owners` can publish one, signed with its key if it
has one. A published file never changes. Without the form, open the pull request by hand (the repository's README
has one. When nothing but the version is new (the page's text, key, owners and icon are as they were), the registry merges it by itself once its checks pass; anything else waits for a reviewer. A published file never changes. Without the form, open the pull request by hand (the repository's README
says how). The formats: [api/mod-index.md](api/mod-index.md).
### Integrity and signing
@@ -215,24 +215,9 @@ Horizon's own mods are native. They live in
| Keystrokes | `native/keystrokes` | Extras > Keystrokes |
| Zoom | `native/zoom` | Extras > Zoom |
| Minimap | `native/minimap` | Extras > Minimap |
| Recipes | `native/recipes` | The Recipes WebAssembly mod (item panel and recipe pages, on hook points version 2) |
| Coordinates HUD | `native/coords-hud` | The WebAssembly SDK's example mod (position and facing, `/where`, `/coords`) |
| Recipes | `native/recipes` | The Recipes mod (item panel and recipe pages, on hook points version 2) |
| Coordinates HUD | `native/coords-hud` | A small example mod (position and facing, `/where`, `/coords`) |
The game keeps the parts that must be fast, such as drawing the minimap's map, and a native mod picks the options
([extras.md](extras.md)). Far View is not a mod any more: its distance and detail are Graphics > LOD Distance and Far
Detail ([graphics.md](graphics.md)).
## WebAssembly code mods (removed)
Horizon used to run mods written in Rust and compiled to sandboxed WebAssembly (`.hmod` files with a `mod.wasm`).
That runtime, its SDK, the `horizon:mods` interface and the example mods are gone.
- **A player** who has such a mod sees it in Options > Mods as "Doesn't run any more: needs a native port", and reads
one notice in the chat. The mod's files are not deleted.
- **The launcher** hides these mods from search and browse, and can't install one alone. A modpack that requires one
still installs, with the mod listed as not running and its code left out.
- **An author** ports the mod to a native mod: copy
[horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template), look at the official
ones in horizon-mods, and read [native-mods.md](native-mods.md). The registry keeps the old versions for the
modpacks that need them.
- **A content mod is not affected.** Only an `.hmod` with a capability besides `registries` needs a port.
+127 -12
View File
@@ -16,9 +16,12 @@ A native mod builds on one exact Horizon commit: `[compatibility] horizon_revisi
and `horizon` in the `horizon-native.json` of its registry file (`.hnmod`, `horizon_mods::index::native`). Mods
pin a **Nightly's commit** (or a release's). Every Nightly ships its source and keeps its commit for good as the
tag `nightly-source/<date>-<short>` ([release-flow.md](release-flow.md#nightly-channel)), so a pinned mod still
builds after its Nightly is replaced. All the mods of one build must pin the same commit; when they don't, the
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
pin builds on the commit of the installed version the build starts from.
builds after its Nightly is replaced. When the mods of one build pin different commits, the launcher says which
mod pins which and the commit the build would use, the newest of them, and builds only after the player chooses
**Build anyway** (`native-build --allow-version-mismatch`): such mods often still apply and compile. A failed build
then says the mods were made for other versions. Their horizon-mixins pins may differ too: the build uses the
newest of them and the launcher's own, never one older than a mod was made with. A bundle with no pin builds on
the commit of the installed version the build starts from.
### Mods on the hook points
@@ -31,21 +34,50 @@ pin, else on the installed version's commit (the game the player plays), after c
`NATIVE_HOOKS_API` (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the
mod was tested on. In its own copy of the mod it sets `horizon_revision` to the commit it builds on. Horizon's
own native mods (Zoom, FPS counter, Keystrokes, Minimap; horizon-rust/horizon-mods, `native/<name>/`) are made this way, so any of them build together
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
with no re-release for each Nightly.
API 2 adds the item panel beside container screens and its recipe pages (`native_panel_items`,
`native_panel_recipes`, and the hook `native_panel_event` for the player's clicks, searches and keys over items), and
the game's items and recipes (`native_recipes`, `native_known_recipes`, `native_place_recipe`, `native_give`): what a
recipe viewer needs, in builds with or without the `mods` feature. A WebAssembly mod's panel shows only while no native
mod shows one.
recipe viewer needs.
API 11 adds `GameLevel::native_send_particles` on authoritative servers. It sends supported
vanilla particles to nearby players. Block particles require a valid block state. Item, trail and
vibration particles are not supported by the current dispatch. Count zero sends one directional
particle. The return value says whether the call was accepted, not whether any player received it. Bundles that use it need `"hooks": 11`.
API 3 adds authoritative world lifecycle events through `native_world_lifecycle`: open ready, open failed,
and final close. Bundles that use these events need `"hooks": 3`.
API 4 adds authoritative server tick events through `native_server_tick`: start and end of each non-paused
API 4 adds authoritative server tick events through `native_server_tick`: start (before any level ticks, like NeoForge's `ServerTickEvent.Pre`) and end of each non-paused
server tick, with `world_running`. A frozen world reports false except during a requested step. Bundles that
use these events need `"hooks": 4`.
API 8 adds server commands and players: `native_register_commands` (the commands a mod adds to every player's
command tree) and `native_perform_command` (a player's command on the server), with helpers to answer
([Server commands and players](#server-commands-and-players)). Bundles that use them need `"hooks": 8`.
API 9 adds the acting player's plain items for server mods (count, take, give; [Server commands and
players](#server-commands-and-players)). Bundles that use them need `"hooks": 9`.
API 10 adds server menus: a vanilla chest menu a server mod opens and fills, whose clicks and closing reach the mod
(`native_menu_clicked`, `native_menu_closed`), and `native_exchange_items`, two players' stacks swapped in one checked
step ([Server menus](#server-menus)). Bundles that use them need `"hooks": 10`.
API 12 adds item tooltips through `native_tooltip`: the hovered stack, with its components, and its tooltip's
lines, which a mod can add to, change or take out of, wherever the game shows an item's tooltip. Bundles that use it
need `"hooks": 12`.
API 13 adds using an item, on the server for every player: `native_use_item_on` hears a right click with an item on a
block once the block let it through (`ItemStack.useOn`), and `native_use_item` hears the item's own use, in the air or
after `native_use_item_on` let it through (`Item.use`). Each hears a click once. A mod that claims one swings the hand
instead of the item's use. Bundles that use them need `"hooks": 13`.
API 14 adds breaking and placing a block, on the server for every player: `native_destroy_block` hears a player break a
block once the game allowed it (`ServerPlayerGameMode.destroyBlock`) and may cancel it, which keeps the block and its
drops; `native_place_block` hears a block placed from an item (`BlockItem.place`). To stop a placement, a mod claims
`native_use_item_on`, which runs before it. Bundles that use them need `"hooks": 14`.
## What the launcher does
In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads
@@ -62,7 +94,7 @@ trusting native code is the player's decision, never an agent's.
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
(`rust_toolchain::MINGW`: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT like `rust-mingw`, one zip pinned by
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring`, `hidapi` and wasmtime compile C,
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring` and `hidapi` compile C,
and `windows-sys`' `raw-dylib` imports need `dlltool` and the assembler it runs. The linker is the system's on
macOS (the Command Line Tools: the launcher asks for `xcode-select --install`) and Linux (`cc`).
`horizon-launcher rust status|install|remove`.
@@ -83,8 +115,41 @@ trusting native code is the player's decision, never an agent's.
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
folder built from the command line, or mods of the default home, go to the **Native mods** instance). That
instance gets each mod's settings from the default home the first time it plays it (its
`horizon-mods/.horizon-mod-data/<id>/` folder and `key.<id>.` lines of `horizon-mods.txt`, where the mod's
WebAssembly version kept them); nothing it already has is overwritten.
`horizon-mods/.horizon-mod-data/<id>/` folder and `key.<id>.` lines of `horizon-mods.txt`, where earlier versions of
the mod kept them); nothing it already has is overwritten.
## Install from GitHub
An author can ship a native mod without a registry pull request per version: the player installs it from a
GitHub link, on the Mods page ("Add from GitHub") or with `horizon-launcher mods add-github <url>
[--mod ID | --all] [--instance NAME] --trust-native-code`. The link is `https://github.com/<owner>/<repo>`
(its latest release), `.../releases/tag/<tag>`, `.../tree/<ref>` or `.../commit/<sha>`.
- **Resolved to something exact.** GitHub's public API (no sign-in; a rate limit is said plainly, and
`HORIZON_GITHUB_TOKEN` raises it) turns the link into a release's `.hnmod` file or a **commit SHA**, never a
branch that moves. A release with a `.hnmod` asset installs that file (its SHA-256 is compared with the one GitHub
lists). With no release or no such asset, the launcher fetches
`codeload.github.com/<owner>/<repo>/tar.gz/<sha>` and looks in it for mods by the rules of
`native-build <folder>`: a folder with `horizon-mixin.toml`, or an unpacked `.hnmod` (`horizon-native.json`).
Several mods: they are listed, and the player picks one or installs all. A source folder must pin the Horizon commit
(`[compatibility] horizon_revision`); the launcher packs its Rust, TOML and `data/` files into a `.hnmod`.
A release that ships only a content mod (`.hmod`) says "not supported yet": installing `.hmod` files from a link
is not built.
- **Checked like the registry's files.** The `.hnmod` is read as `horizon_mods::index::native::Bundle::read` reads a
registry file (only Rust, TOML and data under `mixins/<id>/`, size limits, nothing run) before it is installed.
- **Said first.** Before anything is downloaded: "not reviewed by the Horizon registry; native mods run with full
access to this computer; read the source", with a link to the exact commit. The command needs
`--trust-native-code`, as `native-build` does. It is not an MCP tool: trusting native code is the player's
decision, never an agent's.
- **Origin kept.** `horizon-native-mods/<id>.origin.json` beside the `.hnmod`: owner/repo, release tag (for a
release), commit, asset name and the SHA-256 of the installed file. `mods list` shows "from GitHub: owner/repo @
tag" (or the commit's first 7 digits).
- **Updates.** `horizon-launcher mods update-github [ID] [--check]` compares a mod from a release with the
repository's latest release, and a mod from a commit with the head of the default branch; a newer one is
installed with the same warning and `--trust-native-code`. `mods remove <id>` removes it. A mod already
installed from the registry (or by hand) is never replaced.
Installing turns Native mods on, as any native mod does; then build as usual.
## Cache and disk
@@ -131,9 +196,9 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
has read the source: the `.hnmod` contains `.rs` and `.toml` files and may contain data files under
`mixins/<id>/data/`. The launcher checks the bundle without running anything. Native code is trusted.
The file is pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
A content mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the
player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
player's `.hmod`; removing the mod removes both.
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
@@ -170,3 +235,53 @@ A native server tick hook can call `native_server_block` with its context, a dim
### Server block changes (draft)
`native_block_changed` observes a committed authoritative server `Level.setBlock` change before block side effects and neighbour updates. It supplies the dimension, position and old and new state IDs. It does not observe chunk loading or direct storage writes that bypass `Level.setBlock`.
### Server commands and players
For mods that run where the world is simulated: a dedicated server, a LAN host, single player's server thread
(`crates/horizon-client/src/game/native_server.rs`). The SMP mod of the official server is built on them
([smp.md](smp.md)).
- **`native_register_commands(commands)`**: push a `NativeCommand` per usage, asked once when a world opens to
players. A usage is words, then arguments `<name:kind>`, optional ones last in brackets:
`NativeCommand::new("pay <player:player> <amount:integer>")`, `NativeCommand::op("eco set <player:player>
<amount:integer>", 3)` for operators of level 3. Kinds: `word`, `text` (the rest of the line, last), `integer`,
`number`, `boolean`, `player`, `item`, `block_pos`. The usages of one command merge into one tree, which every
player receives after vanilla's (filtered by their level, as vanilla's is), so their chat box completes and colours
it. A name vanilla has, or a usage that doesn't parse, is left out with a warning in the log.
- **`native_perform_command(player, command, ran)`**: a player typed a registered command (without its slash) and
their level allows it, whether the world allows commands or not. The mod parses the line and sets `ran`. The
player's state is swapped in for the call, as for their other actions, so `native_player` reads them; a
registered command no mod takes answers as vanilla's unknown command.
- Helpers: `native_server_players()` (uuid, name, level, dimension, position and game mode of each player on);
`native_tell(uuid, text)`, a system line to one player; `native_broadcast(text)`, to every player and the server's
log; `native_feedback(text)`, the command's answer to the player whose command it is; `native_world_dir()`, the
open world's folder, where a server mod keeps its data in a folder of its own so the world's backups carry it.
- Items (API 9), of the acting player (inside `native_perform_command`, whose player is swapped in): `native_held()`
(the main hand's item, count, and whether it is plain), `native_count_items(item)`, `native_carried_items()`,
`native_take_items(item, count)` (the main hand first, then as `Inventory.add` orders slots),
`native_room_for(item)` and `native_give_items(item, count)` (only where it fits: nothing is dropped). They look at
the 36 inventory slots and the off hand, never the armour, and only at **plain** stacks: no damage and no
component (name, enchantment...), so a shop never takes a worn or enchanted item for a new one. Each returns how
many it counted, took or gave; the player's screen follows as for any change to their inventory.
### Server menus
API 10, `crates/horizon-client/src/game/native_menus.rs`: menus a server mod builds from ordinary items, which a
stock client shows as a chest (`generic_9x1` to `generic_9x6`); nothing is installed on the player's side.
- `native_open_menu(uuid, rows, title, items)` opens one in place of the player's open menu (its window id);
`native_set_menu_items(uuid, items)` changes what it shows; `native_close_menu(uuid)` closes it.
- **No click moves anything.** Each reaches `native_menu_clicked(uuid, window, slot, button, mode)`, and the client
is sent the menu, its inventory and its cursor as they were. `slot` counts the menu's own slots first, then the
player's inventory below it: `native_menu_inventory_slot(size, slot)` gives the inventory slot (0..9 the hotbar).
- `native_menu_closed(uuid, window)`: the player closed it, another menu opened, or the player left.
- `native_player_stacks(uuid)`: the player's 36 inventory slots, to show copies or offer them.
- `native_exchange_items(a, a_offer, b, b_offer)`: each offer is inventory slots, each with the exact stack it must
still hold. Both offers leave their slots and go to the other player where they fit whole (onto matching stacks,
then empty slots), or nothing moves and the error says why (an offer that changed, no room, a player gone or
dead). A trade on these menus leaves the offered stacks in their owners' inventories until this one step, so
closing the menu, leaving or dying moves nothing.
The menu hooks run with no player's state swapped in; `native_player_stacks` and `native_exchange_items` refuse a
player whose state is (inside `native_perform_command`): call them from a menu hook or `native_server_tick`.
+176
View File
@@ -0,0 +1,176 @@
# Survival multiplayer (SMP): the official server's mod
The official Horizon server (horizon.bunchy7s.dev) wants an organised survival game: a currency players earn in
game, a server market, safe player-to-player trades, small games of chance with that currency, and spawn, homes,
teleport requests, warps, land claims and rules.
**None of it is in Horizon.** The server people download stays a plain server, like vanilla's server jar: no SMP
command, no SMP setting in `server.toml`, nothing in the release build. The SMP is a **native mod**, `smp`, that
only the official server builds into its engine. What Horizon gains are general **server hook points** any native
mod can use (commands, players, messages, inventories, the world folder, then interactions, damage, menus): the
mod API grows, and a server owner who wants their own SMP, shop or claims mod can write it on the same points.
Status: design (this page); built in phases below.
## Players install nothing
Everything works with a stock Horizon client: no mod, no resource pack, nothing on the player's side. The SMP speaks
only what vanilla's protocol already carries and the Horizon client already renders:
- chat commands with completions (the server's command tree), and chat lines;
- menus the server opens with vanilla menu types (a 6-row chest, `generic_9x6`) and fills with ordinary items as
buttons and displays; the server rules every click, the client only shows them;
- vanilla sounds, titles, the action bar, boss bars and the scoreboard sidebar for what a player should notice.
Only the Horizon client may join the official server; that gate is anticheat-1's work, not the SMP's. The SMP may rely
on what the stock client renders, never on a client mod.
## Architecture
| Part | Where | What |
|---|---|---|
| Server hook points | this repository, `crates/horizon-client/src/game/native_hooks.rs` ([native-mods.md](native-mods.md)) | stable, versioned (`NATIVE_HOOKS_API`), empty when no mod hooks them |
| The `smp` mod | [horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods), `native/smp/` | a horizon-mixins bundle: `smp_store.rs`, `smp_economy.rs`, `smp_market.rs`... hold the rules and data (also the mod's own crate, with its tests, no game types), `glue.rs` and small helpers hook the points |
| The official server's engine | built from a Horizon release's commit with the bundle applied | `horizon-server`'s `[engine] path` points at it; the rest of the server is unchanged |
Why a native mod: it is the mod system Horizon has now, it runs on the server
that simulates the world, and it keeps the SMP out of everyone else's game. Why hook points rather than reaching into
the game's private code: a mod on the hook points builds on any later Horizon (`"hooks": N`), so the official server
updates with each release without the mod being ported again.
Why not a datapack: no command arguments, no inventory transactions, no trade window, no way to refuse an action in a
claim.
### Hook points the SMP needs
Each is added in its own pull request, raising `NATIVE_HOOKS_API`, documented in [native-mods.md](native-mods.md),
and free when no mod hooks it (an empty function). Where a contributor's pull request already adds one (block
breaking and placing, item use: #4483, #4302), the SMP uses theirs.
| Phase | API | Hook points and helpers |
|---|---|---|
| 1. Economy | 8 | `native_register_commands` (commands every player's chat box completes), `native_perform_command` (a player's command on the server, with that player's state as the acting player); helpers: the online players and their game mode, a line to one player or to all, the world's folder |
| 1. Market | 9 | the acting player's items: held, count, carried, take, room, give (plain stacks only, nothing dropped) |
| 2. Trading | 10 | a vanilla chest menu the server opens and fills for a player, display only: its clicks and its closing go to the mod; an atomic exchange of two players' items; a player left or died |
| 3. Gambling | nothing new |
| 4. Organisation | teleport a player (across dimensions), damage taken, a player's interaction with a block or entity that a mod can refuse (with #4302 and #4483), the player's time played |
## Mojang's rules
The currency is earned in game only (the market, trades, playing). Rules that no setting changes:
- **in-game currency only**: games of chance take and pay only the SMP's currency, within limits the owner sets;
- **never bought with real money**: no store, rank or perk sells the currency, a stake or a ticket;
- **never cashed out**: nothing turns the currency or winnings into money or anything outside the game;
- **no paid random items**: nothing random (crates, keys, loot boxes) is sold, for money or for the currency bought
with money.
Games of chance are off in the mod by default (`gambling.enabled: false`): a server that builds the mod turns them
on itself. The official server has them on (Ludovic's decision, 2026-10-30). Operators' corrections (`/eco`) are
logged like every other transaction.
## Commands
Player commands work for every player; operator commands need the permission level shown.
| Command | Does |
|---|---|
| `/balance [player]`, `/bal` | your balance, or another player's |
| `/pay <player> <amount>` | gives money to a player who has an account |
| `/baltop [page]` | the richest players |
| `/eco give\|take\|set <player> <amount>` (level 3) | operator corrections, logged |
| `/sell hand [count]\|all\|<item> [count]`, `/buy <item> [count]`, `/worth [item]` | the server market, at the configured prices; plain items only, in survival and adventure |
| `/trade <player>`, `/trade accept\|deny\|cancel`, `/trade money <amount>` | a two-sided trade window |
| `/coinflip <amount> [player]`, `/coinflip accept <player>`, `/coinflip cancel\|list` | a 50/50 bet between two players, both stakes held until it is decided |
| `/lottery [buy <tickets>]` | tickets for the next draw; the pot goes to one ticket drawn at random |
| `/spawn`, `/setspawn` (level 2) | the server's spawn |
| `/sethome [name]`, `/home [name]`, `/delhome <name>`, `/homes` | up to `homes.max` homes |
| `/tpa <player>`, `/tpahere <player>`, `/tpaccept`, `/tpdeny` | teleport requests |
| `/warp [name]`, `/setwarp <name>`, `/delwarp <name>` (level 2) | warps set by operators |
| `/claim [radius]`, `/unclaim`, `/trust <player>`, `/untrust <player>`, `/claims` | land claims |
| `/rules` | the server's rules |
Teleports wait `teleport.warmup_seconds` and are cancelled when the player moves more than half a block or takes
damage.
**Trades.** `/trade Alex` asks Alex; once accepted, both see a 6-row chest menu: their own offer on the left, the
other's on the right, the money each offers, and confirm and cancel buttons (ordinary items such as lime and red
stained glass panes). The menu is display only: a player adds a stack to their offer by clicking it in their own
inventory, below the menu, and takes it back by clicking it in the offer. **Nothing moves until the swap**: the
offered stacks stay in their owners' inventories, the menu shows copies. Any change to either offer clears both
confirmations; when both have confirmed, the server checks that each player still holds exactly what they offer and
has room for what they get, then swaps the items and the money in one step of its tick, or swaps nothing. Closing the
menu, `/trade cancel`, disconnecting, dying or the other player leaving ends the trade with nothing moved, so there is
no item to return and nothing to duplicate. Tests cover each way out.
**Claims.** A rectangle of columns in one dimension, owned by a player who trusts others by name. In a claim, players
who are neither the owner, trusted nor an operator can't break or place blocks, open containers, or use doors,
buttons and levers. Claims start empty: existing builds are not claimed for anyone, their owners claim them. Each
player may claim `claims.initial_blocks` plus `claims.blocks_per_hour` for each hour played (vanilla's `play_time`
statistic, so past hours count), up to `claims.max_blocks`. Spawn protection is a claim of `spawn.protection_radius`
around `/setspawn` that only operators change. Not covered at first: pistons, fluids, explosions and fire entering a
claim from outside.
## Configuration and data
Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it, and a world without
that folder is untouched: the mod creates it the first time it runs.
| File | Holds |
|---|---|
| `config.json` | the settings below, written with their defaults on first run; a section added by a later version is added to the file |
| `economy.json` | balances by player UUID, and names last seen |
| `homes.json`, `warps.json`, `spawn.json` | positions with their dimension |
| `claims.json` | claims: id, owner, dimension, corners, trusted UUIDs |
| `lottery.json` | the current draw's tickets |
| `audit.log` | one JSON line per transaction: time, kind, from, to, amount, items, balances after |
Each `.json` file is written whole to a temporary file, flushed and renamed over the old one, at the end of a server
tick that changed it (at most once a tick) and when the world closes: a crash leaves the old file or the new one,
never half of one. `audit.log` is appended before the balances change.
```json
{
"currency": "coins",
"starting_balance": 0,
"market": {
"enabled": true,
"items": { "minecraft:diamond": { "buy": 400, "sell": 100 }, "minecraft:wheat": { "buy": 0, "sell": 1 } }
}
}
```
In the market, `sell` is what the server pays for one item and `buy` what it asks; 0 closes that side. The later
phases add their sections (`teleport`, `homes`, `claims`, `spawn`, `rules`). `gambling` holds `enabled` (false),
`min_bet` (10), `max_bet` (1000), `cooldown_seconds` (30), `offer_seconds` (60) and `fee_percent` (0). A coinflip's
stakes are held in `economy.json` itself (`held`), so one atomic write covers balances and stakes; an offer that
ends, expires or whose player leaves gives its stake back, and so does the next start for one the server stopped
with. The coin is a fresh random key from the system, never the world's random.
## Building the official server's engine
A native mod is built into the game it runs in ([native-mods.md](native-mods.md)). For the server: the source of the
release the server runs, `horizon-mixins apply` with the `smp` bundle, then the server build
(`cargo build --release --no-default-features -p horizon-client`), and `horizon-server`'s `[engine] path` set to
the result. When to deploy it on the official server is the owners' decision; the mod's README holds the exact
commands.
## Cost
With no mod hooking them, the hook points are empty functions. With `smp`, a server tick writes the files that
changed (none most ticks) and looks at the teleports waiting; a claim check is a hash lookup of the chunk's claims,
made only when a player acts on a block. Each phase measures a server tick with 55 bots (`tools/server_bench.py`).
## Phases
Each is pull requests under ~500 lines, each tested: the hook points here (unit tests, a game test that hooks
them), the mod in horizon-mods (its crate's tests, and a proof: a dedicated server built with the bundle and real
clients driven by `horizon-ctl`, in fresh folders).
1. **Economy**: command and player hook points; the mod's ledger, data files and audit log, `/balance`, `/pay`,
`/baltop`, `/eco`; then the items helpers and the market (`/sell`, `/buy`, `/worth`).
2. **Trading**: the server menu hook points; `/trade`, with a test for each way out (close, cancel, disconnect,
death, a full inventory, the other player leaving).
3. **Gambling**: `/coinflip` with escrow, then the lottery; limits, cooldown, logged.
4. **Organisation**: teleport and damage hook points, `/spawn`, homes, `/rules`, `/tpa`, warps; the interaction
hook points, claims, spawn protection and limits that grow with time played.
+3
View File
@@ -189,6 +189,9 @@
"Move to Trash": "In den Papierkorb",
"Install and turn on native mods": "Installieren und native Mods einschalten",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.",
"These mods were made for different Horizon versions": "Diese Mods wurden für verschiedene Horizon-Versionen gemacht",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Oft funktionieren sie trotzdem zusammen. Schlägt der Build fehl, installiere Versionen für dieselbe Nightly.",
"Build anyway": "Trotzdem bauen",
"Folder": "Ordner",
"Version": "Version",
"Import a modpack…": "Modpack importieren…",
+3
View File
@@ -189,6 +189,9 @@
"Install and turn on native mods": "Instalar y activar los mods nativos",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.",
"These mods were made for different Horizon versions": "Estos mods se hicieron para versiones distintas de Horizon",
"They often still work together. If the build fails, install versions made for the same Nightly.": "A menudo funcionan juntos de todos modos. Si la compilación falla, instala versiones hechas para la misma Nightly.",
"Build anyway": "Compilar de todos modos",
"Folder": "Carpeta",
"Version": "Versión",
"Import a modpack…": "Importar un modpack…",
+3
View File
@@ -189,6 +189,9 @@
"Keep": "Garder",
"Move to Trash": "Mettre à la corbeille",
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.",
"These mods were made for different Horizon versions": "Ces mods ont été faits pour des versions différentes de Horizon",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Ils fonctionnent souvent ensemble quand même. Si la compilation échoue, installe des versions faites pour la même Nightly.",
"Build anyway": "Compiler quand même",
"Folder": "Dossier",
"Version": "Version",
"Import a modpack…": "Importer un modpack…",
+3
View File
@@ -189,6 +189,9 @@
"Move to Trash": "Przenieś do kosza",
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.",
"These mods were made for different Horizon versions": "Te mody zrobiono dla różnych wersji Horizon",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Często i tak działają razem. Jeśli kompilacja się nie powiedzie, zainstaluj wersje zrobione dla tej samej Nightly.",
"Build anyway": "Mimo to zbuduj",
"Folder": "Folder",
"Version": "Wersja",
"Import a modpack…": "Importuj modpack…",
+3
View File
@@ -189,6 +189,9 @@
"Keep": "Оставить",
"Move to Trash": "В корзину",
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.",
"These mods were made for different Horizon versions": "Эти моды сделаны для разных версий Horizon",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Часто они всё равно работают вместе. Если сборка не удастся, установите версии, сделанные для одной и той же Nightly.",
"Build anyway": "Всё равно собрать",
"Folder": "Папка",
"Version": "Версия",
"Import a modpack…": "Импортировать модпак…",
+3
View File
@@ -189,6 +189,9 @@
"Move to Trash": "Перемістити до кошика",
"Install and turn on native mods": "Встановити й увімкнути нативні моди",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.",
"These mods were made for different Horizon versions": "Ці моди зроблено для різних версій Horizon",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Часто вони все одно працюють разом. Якщо збірка не вдасться, встановіть версії, зроблені для однієї й тієї ж Nightly.",
"Build anyway": "Все одно зібрати",
"Folder": "Тека",
"Version": "Версія",
"Import a modpack…": "Імпортувати модпак…",
+3
View File
@@ -189,6 +189,9 @@
"Install and turn on native mods": "安装并启用原生模组",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。",
"These mods were made for different Horizon versions": "这些模组是为不同版本的 Horizon 制作的",
"They often still work together. If the build fails, install versions made for the same Nightly.": "它们通常仍能一起使用。如果构建失败,请安装为同一个 Nightly 制作的版本。",
"Build anyway": "仍然构建",
"Folder": "文件夹",
"Version": "版本",
"Import a modpack…": "导入整合包…",
+3
View File
@@ -666,6 +666,9 @@ pub(crate) fn install_as(
pub fn remove(tag: &str) -> Result<(), String> {
let dir = version_dir(tag);
// The marker goes first: a version counted as installed until its folder is gone comes back
// into `installed` when an overlapping removal re-reads the list mid-delete.
let _ = fs::remove_file(dir.join(COMPLETE));
// Windows holds a file for a moment after the game, a scan or the launcher's own reader lets
// go of it, and remove_dir_all stops at the first one. Retry for a second; a folder that is
// already gone is removed.
+207 -3
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::github_mods;
use horizon_launcher::instances;
use horizon_launcher::loaders;
use horizon_launcher::native_mods;
@@ -587,6 +588,40 @@ enum ModsCmd {
#[arg(long)]
instance: Option<String>,
},
/// Install a native mod from a GitHub link (docs/native-mods.md, "Install from GitHub"):
/// https://github.com/OWNER/REPO (its latest release, else its default branch),
/// .../releases/tag/TAG, .../tree/REF or .../commit/SHA. Resolved to an exact release file or
/// commit, checked like a registry mod. NOT reviewed by the Horizon registry
AddGithub {
url: String,
/// The mod to install when the link holds several
#[arg(long = "mod")]
mod_id: Option<String>,
/// Install every mod the link holds
#[arg(long)]
all: bool,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
/// You read the mod's source (the link printed) and trust it with full access to this computer
#[arg(long)]
trust_native_code: bool,
},
/// Look for newer versions of the native mods installed from GitHub (the latest release, or the
/// default branch's head for a mod installed from a commit), and install them
UpdateGithub {
/// One mod (default: all of them)
id: Option<String>,
/// Only say what is newer
#[arg(long)]
check: bool,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
/// You read the new source and trust it with full access to this computer
#[arg(long)]
trust_native_code: bool,
},
/// Remove a pack the browser installed
Remove {
project: String,
@@ -724,6 +759,9 @@ enum Cmd {
/// You read the mods' source and trust it with full access to this computer
#[arg(long)]
trust_native_code: bool,
/// Build mods made for different Horizon versions together anyway, on the newest of them
#[arg(long)]
allow_version_mismatch: bool,
},
/// Show a release's patch notes (default: the newest)
Notes { tag: Option<String> },
@@ -1135,6 +1173,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
instance,
version,
trust_native_code,
allow_version_mismatch,
} => {
if !trust_native_code {
return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into());
@@ -1152,8 +1191,20 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Some(&target),
),
};
let built = horizon_launcher::native_mods::build(&base, &folder, target, |step| {
eprintln!("{}", step.label())
let built = horizon_launcher::native_mods::build(
&base,
&folder,
target,
allow_version_mismatch,
|step| eprintln!("{}", step.label()),
)
.map_err(|e| {
match e.starts_with(horizon_launcher::native_mods::MISMATCH) {
true => {
format!("{e} They often still build: pass --allow-version-mismatch to try.")
}
false => e,
}
})?;
println!(
"{built} built and selected for its instance ({:.0} s).",
@@ -1958,6 +2009,60 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> {
Ok(())
}
/// Installs the native mods of `found` (a GitHub link, resolved) into the game folder `home`: the
/// warning and the exact source link first, and nothing downloaded without `trust`.
fn github_native(
home: &Path,
java: bool,
found: &github_mods::Resolved,
only: Option<&str>,
all: bool,
trust: bool,
) -> Result<(), String> {
if java {
return Err("Native mods from GitHub are for Horizon instances.".into());
}
println!("{}", found.label());
println!("Source, to read: {}", found.source_url());
eprintln!("{}", github_mods::WARNING);
if !trust {
return Err(format!(
"Read the source at {}, then pass --trust-native-code.",
found.source_url()
));
}
let mods = github_mods::fetch(found)?;
let chosen = github_mods::choose(&mods, only, all);
let done = chosen.and_then(|chosen| {
chosen
.into_iter()
.map(|p| github_mods::install(home, found, p))
.collect::<Result<Vec<_>, _>>()
});
github_mods::discard(found);
for o in done? {
println!(
"Installed {} {} from GitHub ({}), sha256 {}.",
o.id,
o.version,
o.label(),
o.sha256
);
}
// A native mod turns Native mods on, with the window's notice, once.
let mut settings = backend::Settings::load();
if !settings.native_mods {
if !settings.native_mods_noticed {
eprintln!("{}", native_mods::NOTICE);
}
settings.native_mods = true;
settings.native_mods_noticed = true;
settings.save();
}
println!("Build it next: horizon-launcher native-build --trust-native-code");
Ok(())
}
fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::browser::{horizon, installed, java_instance, Kind, Query, SourceId};
use horizon_launcher::modpacks;
@@ -1969,6 +2074,8 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
| ModsCmd::Install { instance, .. }
| ModsCmd::Update { instance, .. }
| ModsCmd::Remove { instance, .. }
| ModsCmd::AddGithub { instance, .. }
| ModsCmd::UpdateGithub { instance, .. }
| ModsCmd::Enable { instance, .. }
| ModsCmd::Disable { instance, .. } => match instance {
Some(name) => Some(instances::find(name)?),
@@ -2004,11 +2111,23 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
let list = installed::Installed::load(&home).entries;
if as_json {
json(list.iter().map(entry_json).collect());
} else if list.is_empty() {
} else if list.is_empty() && github_mods::origins(&home).is_empty() {
println!(
"Nothing installed from the browser yet: horizon-launcher mods search <text>"
);
}
for o in horizon_launcher::github_mods::origins(&home)
.iter()
.filter(|_| !as_json && java.is_none())
{
println!(
"{} {} (native mod, from GitHub: {})\n {}",
o.id,
o.version,
o.label(),
o.source_url()
);
}
for e in list.iter().filter(|_| !as_json) {
let off = java.is_some() && !java_instance::is_on(&home, e);
println!(
@@ -2289,6 +2408,64 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
}
}
}
ModsCmd::AddGithub {
url,
mod_id,
all,
trust_native_code,
..
} => {
let link = github_mods::parse(&url)?;
let found = github_mods::resolve(&link, &github_mods::api)?;
github_native(
&home,
java.is_some(),
&found,
mod_id.as_deref(),
all,
trust_native_code,
)?;
}
ModsCmd::UpdateGithub {
id,
check,
trust_native_code,
..
} => {
if java.is_some() {
return Err("Native mods from GitHub are for Horizon instances.".into());
}
let mut origins = github_mods::origins(&home);
origins.retain(|o| id.as_deref().is_none_or(|i| o.id == i));
if origins.is_empty() {
return Err(match id {
Some(i) => format!("{i} wasn't installed from GitHub."),
None => "No native mod was installed from GitHub: horizon-launcher mods add-github <url>".into(),
});
}
for o in origins {
let latest = github_mods::resolve(&o.follows(), &github_mods::api)?;
if !github_mods::is_update(&o, &latest) {
println!("{} {}: up to date ({}).", o.id, o.version, o.label());
continue;
}
println!(
"{}: {} is available (now {}).",
o.id,
latest.label(),
o.label()
);
if !check {
github_native(&home, false, &latest, Some(&o.id), false, trust_native_code)?;
}
}
}
ModsCmd::Remove { project, .. }
if java.is_none() && github_mods::origin(&home, &project).is_some() =>
{
github_mods::remove(&home, &project)?;
println!("Removed {project} (installed from GitHub).");
}
ModsCmd::Remove { project, .. } => {
let e = match java {
Some(_) => java_instance::remove(&home, &project)?,
@@ -3305,6 +3482,33 @@ mod tests {
Some(ModsCmd::List { instance: None })
));
assert!(parse(&[]).is_none());
match parse(&[
"add-github",
"https://github.com/a/b",
"--mod",
"zoom",
"--all",
]) {
Some(ModsCmd::AddGithub {
url,
mod_id,
all,
trust_native_code,
..
}) => assert_eq!(
(url.as_str(), mod_id.as_deref(), all, trust_native_code),
("https://github.com/a/b", Some("zoom"), true, false)
),
_ => panic!("expected mods add-github"),
}
assert!(matches!(
parse(&["update-github", "--check"]),
Some(ModsCmd::UpdateGithub {
check: true,
id: None,
..
})
));
// Search names one for a Java instance's version and loader; Check reads none: checks
// are shared.
assert!(matches!(
+3
View File
@@ -14,6 +14,9 @@ const NOT_TOOLS: &[&str] = &[
"agent-config",
"no-ai",
"native-build",
// They install native code nobody reviewed, and say it first: the player's decision.
"mods add-github",
"mods update-github",
"play",
"server",
"account sign-in",
+685
View File
@@ -0,0 +1,685 @@
//! Native mods installed from a GitHub link (docs/native-mods.md, "Install from GitHub"), so an
//! author can ship a version without a registry pull request. The link names a repository (its
//! latest release), a release tag, a branch or tag (`tree/`) or a commit. It is resolved through
//! GitHub's public API to an exact **release asset** (a `.hnmod`, kept by its SHA-256) or an exact
//! **commit** (the archive of that commit, whose mod folder is packed into a `.hnmod`), never to a
//! branch that moves. The result passes the checks the registry's `.hnmod`s pass
//! (`horizon_mods::index::native::Bundle::read`) before anything is installed, and the origin is
//! kept beside the file (`horizon-native-mods/<id>.origin.json`) for "check for updates".
//!
//! Nothing here runs the mod's code, and nothing here trusts it: the registry has not reviewed it.
//! Callers show [`WARNING`] and ask first (`--trust-native-code`).
use crate::backend;
use crate::browser::java;
use crate::native_mods::{self, Bundle, INSTALLED};
use horizon_mods::index::native as format;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
/// What a player is told before a mod from GitHub is installed.
pub const WARNING: &str = "Not reviewed by the Horizon registry. Native mods run with full access to this computer. Read the source first.";
/// The most a release asset or a mod's archive may be.
const MAX_ASSET: u64 = 64 << 20;
const MAX_ARCHIVE: u64 = 256 << 20;
/// How deep below the archive's root a mod folder is looked for.
const DEPTH: usize = 6;
/// What a link points at.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
/// The repository: its latest release, else its default branch.
Latest,
/// The head of the default branch (what a mod installed from a commit is updated to).
Head,
/// `releases/tag/<tag>`: that release, else the tag itself.
Tag(String),
/// `tree/<ref>`: a branch, tag or commit.
Ref(String),
/// `commit/<sha>`.
Commit(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Link {
pub owner: String,
pub repo: String,
pub target: Target,
}
fn plain(part: &str) -> bool {
!part.is_empty()
&& part != "."
&& part != ".."
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
/// A ref (branch, tag, sha) as GitHub names them: no spaces, no `..`, nothing a URL would read.
fn ref_ok(r: &str) -> bool {
!r.is_empty()
&& !r.contains("..")
&& !r.starts_with('/')
&& !r.ends_with('/')
&& r.bytes().all(|b| {
b.is_ascii_graphic()
&& !matches!(
b,
b'?' | b'#' | b'%' | b'\\' | b'*' | b'^' | b'~' | b':' | b'[' | b'@'
)
})
}
/// Parses `https://github.com/<owner>/<repo>[/releases/tag/<tag> | /tree/<ref> | /commit/<sha>]`.
pub fn parse(text: &str) -> Result<Link, String> {
let bad = || {
format!(
"{:?} is not a GitHub link: use https://github.com/<owner>/<repo>, or .../releases/tag/<tag>, .../tree/<ref> or .../commit/<sha>.",
text.trim()
)
};
let text = text.trim();
let rest = text
.strip_prefix("https://github.com/")
.or_else(|| text.strip_prefix("https://www.github.com/"))
.ok_or_else(bad)?;
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
let owner = parts.next().unwrap_or_default();
let repo = parts.next().unwrap_or_default();
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if !plain(owner) || !plain(repo) {
return Err(bad());
}
let target = match parts.next() {
None => Target::Latest,
Some(tail) => {
let tail = tail.trim_end_matches('/');
let value = |prefix: &str| {
tail.strip_prefix(prefix)
.filter(|v| ref_ok(v))
.map(str::to_owned)
};
if let Some(tag) = value("releases/tag/") {
Target::Tag(tag)
} else if let Some(r) = value("tree/") {
Target::Ref(r)
} else if let Some(sha) = value("commit/")
.filter(|s| (7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit()))
{
Target::Commit(sha.to_ascii_lowercase())
} else if matches!(tail, "releases" | "releases/latest") {
Target::Latest
} else {
return Err(bad());
}
}
};
Ok(Link {
owner: owner.into(),
repo: repo.into(),
target,
})
}
/// A release's file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Asset {
pub name: String,
pub url: String,
/// The SHA-256 GitHub reports for it (`digest`), when it does: the download must match.
pub sha256: Option<String>,
}
/// A link resolved to something exact.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Resolved {
pub owner: String,
pub repo: String,
/// The release it came from, if any.
pub tag: Option<String>,
/// The full commit: what the source link names, and what a commit install fetches.
pub commit: String,
/// The release's `.hnmod`, installed as it is when there is one.
pub asset: Option<Asset>,
/// A release that ships only a content mod (`.hmod`): not supported here yet.
pub content_only: Option<String>,
}
impl Resolved {
pub fn name(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
/// `owner/repo @ tag` for a release, `owner/repo @ 7-digit sha` for a commit.
pub fn label(&self) -> String {
label(&self.name(), self.tag.as_deref(), &self.commit)
}
/// Where to read exactly what would be installed.
pub fn source_url(&self) -> String {
source_url(&self.owner, &self.repo, &self.commit)
}
}
fn label(name: &str, tag: Option<&str>, commit: &str) -> String {
match tag {
Some(tag) => format!("{name} @ {tag}"),
None => format!("{name} @ {}", &commit[..commit.len().min(7)]),
}
}
pub fn source_url(owner: &str, repo: &str, commit: &str) -> String {
format!("https://github.com/{owner}/{repo}/tree/{commit}")
}
/// GitHub's JSON for an API path (`repos/...`); `None` when it answers 404.
pub type Api<'a> = &'a dyn Fn(&str) -> Result<Option<serde_json::Value>, String>;
/// The real API, unauthenticated (`HORIZON_GITHUB_TOKEN` is used when set, for CI).
pub fn api(path: &str) -> Result<Option<serde_json::Value>, String> {
let url = format!("https://api.github.com/{path}");
match backend::get(&url) {
Ok(response) => response.into_json().map(Some).map_err(|e| e.to_string()),
Err(e) if e.ends_with("answered 404") => Ok(None),
Err(e) if e.ends_with("answered 429") => Err(RATE_LIMIT.into()),
Err(e) if e.contains("rate limit") => Err(RATE_LIMIT.into()),
Err(e) => Err(e),
}
}
const RATE_LIMIT: &str =
"GitHub's rate limit for anonymous requests was reached. Try again in a few minutes.";
/// Percent-encodes a ref for a URL path, keeping its `/`.
fn encode(r: &str) -> String {
r.bytes()
.map(|b| match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
fn commit_of(api: Api, link: &Link, reference: &str) -> Result<String, String> {
let path = format!(
"repos/{}/{}/commits/{}",
link.owner,
link.repo,
encode(reference)
);
let found = api(&path)?.ok_or_else(|| {
format!(
"{}/{} has no commit, tag or branch {reference}.",
link.owner, link.repo
)
})?;
found["sha"]
.as_str()
.filter(|s| format::revision(s))
.map(str::to_owned)
.ok_or_else(|| format!("GitHub did not name the commit of {reference}."))
}
fn default_head(api: Api, link: &Link) -> Result<String, String> {
let repo = api(&format!("repos/{}/{}", link.owner, link.repo))?
.ok_or_else(|| format!("{}/{} doesn't exist, or is private.", link.owner, link.repo))?;
let branch = repo["default_branch"]
.as_str()
.ok_or("GitHub did not name the default branch.")?;
commit_of(api, link, branch)
}
/// The `.hnmod` of a release's JSON (and a lone `.hmod`, which is not installed here).
fn assets(release: &serde_json::Value, link: &Link) -> (Option<Asset>, Option<String>) {
let prefix = format!(
"https://github.com/{}/{}/releases/download/",
link.owner, link.repo
);
let list = release["assets"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
let named = |ext: &'static str| {
list.iter()
.filter(move |a| a["name"].as_str().is_some_and(|n| n.ends_with(ext)))
};
let hnmod = named(".hnmod").find_map(|a| {
let url = a["browser_download_url"].as_str()?.to_owned();
let name = a["name"].as_str()?.to_owned();
// Only this repository's own release files.
url.starts_with(&prefix).then(|| Asset {
name,
url,
sha256: a["digest"]
.as_str()
.and_then(|d| d.strip_prefix("sha256:"))
.filter(|d| d.len() == 64)
.map(str::to_ascii_lowercase),
})
});
let hmod = named(".hmod")
.find_map(|a| a["name"].as_str().map(str::to_owned))
.filter(|_| hnmod.is_none());
(hnmod, hmod)
}
fn from_release(api: Api, link: &Link, release: &serde_json::Value) -> Result<Resolved, String> {
let tag = release["tag_name"]
.as_str()
.ok_or("GitHub did not name the release's tag.")?
.to_owned();
let (asset, content_only) = assets(release, link);
Ok(Resolved {
owner: link.owner.clone(),
repo: link.repo.clone(),
commit: commit_of(api, link, &tag)?,
tag: Some(tag),
asset,
content_only,
})
}
fn at_commit(commit: String, link: &Link) -> Resolved {
Resolved {
owner: link.owner.clone(),
repo: link.repo.clone(),
tag: None,
commit,
asset: None,
content_only: None,
}
}
/// Resolves `link` to an exact release asset or commit, through `api`.
pub fn resolve(link: &Link, api: Api) -> Result<Resolved, String> {
let base = format!("repos/{}/{}", link.owner, link.repo);
match &link.target {
Target::Latest => match api(&format!("{base}/releases/latest"))? {
Some(release) => from_release(api, link, &release),
None => Ok(at_commit(default_head(api, link)?, link)),
},
Target::Head => Ok(at_commit(default_head(api, link)?, link)),
Target::Tag(tag) => match api(&format!("{base}/releases/tags/{}", encode(tag)))? {
Some(release) => from_release(api, link, &release),
None => Ok(at_commit(commit_of(api, link, tag)?, link)),
},
Target::Ref(r) | Target::Commit(r) => Ok(at_commit(commit_of(api, link, r)?, link)),
}
}
/// Where a mod came from, kept beside it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Origin {
pub id: String,
pub version: String,
pub owner: String,
pub repo: String,
/// The release it came from, when it is a release's asset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
pub commit: String,
/// The release asset's name, when it came from one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<String>,
/// The SHA-256 of the installed `.hnmod`.
pub sha256: String,
}
impl Origin {
/// "from GitHub: owner/repo @ tag" (or the commit's first 7 digits).
pub fn label(&self) -> String {
label(
&format!("{}/{}", self.owner, self.repo),
self.tag.as_deref(),
&self.commit,
)
}
pub fn source_url(&self) -> String {
source_url(&self.owner, &self.repo, &self.commit)
}
/// What its update is read from: the latest release for a mod from a release, the default
/// branch's head for one from a commit.
pub fn follows(&self) -> Link {
Link {
owner: self.owner.clone(),
repo: self.repo.clone(),
target: match self.tag {
Some(_) => Target::Latest,
None => Target::Head,
},
}
}
}
/// Whether `latest` (what [`Origin::follows`] resolves to) is newer than what is installed.
pub fn is_update(origin: &Origin, latest: &Resolved) -> bool {
match (&origin.tag, &latest.tag) {
(Some(have), Some(new)) => have != new,
// A release's mod whose repository now has no release asset to follow.
(Some(_), None) => false,
(None, _) => origin.commit != latest.commit,
}
}
/// A mod ready to install: its `.hnmod` was read and checked, nothing run.
#[derive(Clone, Debug)]
pub struct Packed {
pub id: String,
pub version: String,
pub file: PathBuf,
pub sha256: String,
}
fn sha256(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn scratch(name: &str) -> PathBuf {
native_mods::root().join("github").join(name)
}
/// Reads `bytes` as `.hnmod`, with the registry's checks, into a file `Packed` names.
fn checked(bytes: &[u8], dir: &Path) -> Result<Packed, String> {
let sum = sha256(bytes);
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let file = dir.join(format!("{}.hnmod", &sum[..16]));
fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
let bundle = format::Bundle::read(&file).inspect_err(|_| {
let _ = fs::remove_file(&file);
})?;
Ok(Packed {
id: bundle.manifest.id,
version: bundle.manifest.version,
file,
sha256: sum,
})
}
/// Every mod folder under `root`: a `horizon-mixin.toml`, or an unpacked `.hnmod`
/// (`horizon-native.json`), the rules of `native-build <folder>`. A mod's folder is not searched
/// further.
pub fn find_mods(root: &Path) -> Result<Vec<Bundle>, String> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<Bundle>) -> Result<(), String> {
if let Some(found) = native_mods::bundle_in(dir)? {
out.push(found);
return Ok(());
}
if depth == 0 {
return Ok(());
}
let mut dirs: Vec<PathBuf> = fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
!name.starts_with('.') && name != "target" && name != "node_modules"
})
.map(|e| e.path())
.collect();
dirs.sort();
for sub in dirs {
walk(&sub, depth - 1, out)?;
}
Ok(())
}
let mut found = Vec::new();
walk(root, DEPTH, &mut found)?;
found.sort_by(|a, b| a.id.cmp(&b.id));
Ok(found)
}
fn walk_files(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
let mut entries: Vec<_> = fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let kind = entry.file_type().map_err(|e| e.to_string())?;
if kind.is_symlink() {
return Err(format!(
"{} is a link: not allowed in a mod.",
path.display()
));
} else if kind.is_dir() {
walk_files(root, &path, out)?;
} else {
let relative = path.strip_prefix(root).unwrap_or(&path);
out.push((relative.to_string_lossy().replace('\\', "/"), path));
}
}
Ok(())
}
/// The `.hnmod` of a mod folder, as bytes: its source (Rust, TOML and `data/`) with a manifest made
/// from its `horizon-mixin.toml`, which must pin the Horizon commit it was made for
/// (`[compatibility] horizon_revision`). An unpacked `.hnmod` (`horizon-native.json`) keeps the
/// pins and hook points its manifest names. The zip is the same for the same files.
pub fn pack(bundle: &Bundle) -> Result<Vec<u8>, String> {
let horizon = bundle.horizon.clone().filter(|h| format::revision(h)).ok_or_else(|| {
format!(
"{} has no [compatibility] horizon_revision (40 hex digits): add the Horizon commit it was made for.",
bundle.id
)
})?;
let manifest = format::Manifest {
schema: 1,
id: bundle.id.clone(),
version: bundle.version.clone(),
game: crate::browser::GAME.into(),
horizon,
mixins: bundle
.mixins
.clone()
.unwrap_or_else(|| native_mods::MIXINS_REV.into()),
hooks: bundle.hooks,
};
manifest.check()?;
let mut put = vec![(
format::MANIFEST.to_owned(),
serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?,
)];
let mut files = Vec::new();
walk_files(&bundle.dir, &bundle.dir, &mut files)?;
for (name, path) in files {
// What the registry allows in a bundle: sources and data, nothing else.
if name.ends_with(".rs") || name.ends_with(".toml") || name.starts_with("data/") {
let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
put.push((format!("mixins/{}/{name}", bundle.id), bytes));
}
}
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.last_modified_time(zip::DateTime::default())
.unix_permissions(0o644);
for (name, bytes) in put {
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(&bytes).map_err(|e| e.to_string())?;
}
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
}
fn download(url: &str) -> Result<Vec<u8>, String> {
let mut body = Vec::new();
backend::get(url)?
.into_reader()
.take(MAX_ASSET + 1)
.read_to_end(&mut body)
.map_err(|e| format!("Download interrupted: {e}"))?;
if body.len() as u64 > MAX_ASSET {
return Err("The file is bigger than 64 MiB: not a native mod bundle.".into());
}
Ok(body)
}
/// Downloads what `found` names and checks it: the release's `.hnmod` (its SHA-256 compared with
/// GitHub's when it reports one), else the mods of the commit's archive, each packed. A repository
/// of several mods gives several `Packed`. Nothing is installed.
pub fn fetch(found: &Resolved) -> Result<Vec<Packed>, String> {
let dir = scratch(&found.commit);
if let Some(asset) = &found.asset {
let bytes = download(&asset.url)?;
if let Some(expected) = &asset.sha256 {
if sha256(&bytes) != *expected {
return Err(format!(
"{} does not match the checksum GitHub lists for it.",
asset.name
));
}
}
return Ok(vec![checked(&bytes, &dir)?]);
}
let url = format!(
"https://codeload.github.com/{}/{}/tar.gz/{}",
found.owner, found.repo, found.commit
);
let top = native_mods::unpack_archive(&url, &dir.join("source"), MAX_ARCHIVE)?;
let mods = find_mods(&top)?;
if mods.is_empty() {
return Err(match &found.content_only {
Some(file) => format!(
"{} ships {file}, a content mod: installing .hmod files from GitHub is not supported yet.",
found.label()
),
None => format!(
"{} holds no native mod (a folder with horizon-mixin.toml, or an unpacked .hnmod).",
found.label()
),
});
}
let packed: Result<Vec<Packed>, String> = mods
.iter()
.map(|m| checked(&pack(m)?, &dir).map_err(|e| format!("{}: {e}", m.id)))
.collect();
let _ = fs::remove_dir_all(dir.join("source"));
packed
}
/// Deletes what [`fetch`] left for `found` (the files of mods not installed).
pub fn discard(found: &Resolved) {
let _ = fs::remove_dir_all(scratch(&found.commit));
}
/// Which of `found` to install: the one named, all of them, or the only one. Several with no
/// choice is an error that lists them.
pub fn choose<'a>(
found: &'a [Packed],
id: Option<&str>,
all: bool,
) -> Result<Vec<&'a Packed>, String> {
if let Some(id) = id {
return found
.iter()
.find(|p| p.id == id)
.map(|p| vec![p])
.ok_or_else(|| format!("No mod {id} there. It holds: {}.", ids(found)));
}
match found {
[one] => Ok(vec![one]),
_ if all => Ok(found.iter().collect()),
_ => Err(format!(
"It holds {} mods: {}. Pick one (--mod ID) or install them all (--all).",
found.len(),
ids(found)
)),
}
}
fn ids(found: &[Packed]) -> String {
found
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn origin_path(home: &Path, id: &str) -> PathBuf {
home.join(INSTALLED).join(format!("{id}.origin.json"))
}
/// The origin of mod `id` installed in `home`, if it came from GitHub.
pub fn origin(home: &Path, id: &str) -> Option<Origin> {
serde_json::from_str(&fs::read_to_string(origin_path(home, id)).ok()?).ok()
}
/// Every mod of `home` installed from GitHub.
pub fn origins(home: &Path) -> Vec<Origin> {
let mut found: Vec<Origin> = fs::read_dir(home.join(INSTALLED))
.map(|dir| {
dir.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".origin.json"))
.filter_map(|e| serde_json::from_str(&fs::read_to_string(e.path()).ok()?).ok())
.collect()
})
.unwrap_or_default();
found.sort_by(|a: &Origin, b| a.id.cmp(&b.id));
found
}
/// Installs `packed` (from `found`) into the game folder `home`, turns it on and records where
/// it came from. A mod from the registry or the player's own is never replaced; one from GitHub
/// is (that is how it updates).
pub fn install(home: &Path, found: &Resolved, packed: &Packed) -> Result<Origin, String> {
let target = home.join(INSTALLED).join(format!("{}.hnmod", packed.id));
if target.exists() && origin(home, &packed.id).is_none() {
return Err(format!(
"{} is already installed and not from GitHub: remove it first.",
packed.id
));
}
fs::create_dir_all(home.join(INSTALLED)).map_err(|e| e.to_string())?;
fs::copy(&packed.file, &target).map_err(|e| format!("{}: {e}", target.display()))?;
let record = Origin {
id: packed.id.clone(),
version: packed.version.clone(),
owner: found.owner.clone(),
repo: found.repo.clone(),
tag: found.asset.as_ref().and(found.tag.clone()),
commit: found.commit.clone(),
asset: found.asset.as_ref().map(|a| a.name.clone()),
sha256: packed.sha256.clone(),
};
let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
let path = origin_path(home, &packed.id);
fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
java::set_enabled(home, &packed.id, true)?;
let _ = fs::remove_file(&packed.file);
Ok(record)
}
/// Removes mod `id` installed from GitHub; false when it wasn't.
pub fn remove(home: &Path, id: &str) -> Result<bool, String> {
if origin(home, id).is_none() {
return Ok(false);
}
for path in [
origin_path(home, id),
home.join(INSTALLED).join(format!("{id}.hnmod")),
] {
if let Err(e) = fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(format!("{}: {e}", path.display()));
}
}
}
java::set_enabled(home, id, false)?;
Ok(true)
}
#[cfg(test)]
mod tests;
+399
View File
@@ -0,0 +1,399 @@
use super::*;
use serde_json::{json, Value};
const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const SHA2: &str = "fedcba9876543210fedcba9876543210fedcba98";
fn link(text: &str) -> Link {
parse(text).unwrap()
}
#[test]
fn links_name_a_repository_release_ref_or_commit() {
let plain = link("https://github.com/acme/zoom");
assert_eq!(
(plain.owner.as_str(), plain.repo.as_str(), plain.target),
("acme", "zoom", Target::Latest)
);
assert_eq!(link("https://github.com/acme/zoom.git/").repo, "zoom");
assert_eq!(
link("https://github.com/acme/zoom/releases").target,
Target::Latest
);
assert_eq!(
link("https://github.com/acme/zoom/releases/tag/v1.2.0").target,
Target::Tag("v1.2.0".into())
);
// A branch name keeps its slashes.
assert_eq!(
link("https://github.com/acme/zoom/tree/feature/x?tab=1").target,
Target::Ref("feature/x".into())
);
assert_eq!(
link("https://github.com/acme/zoom/commit/ABCDEF1").target,
Target::Commit("abcdef1".into())
);
}
#[test]
fn links_that_are_not_github_repositories_are_refused() {
for bad in [
"",
"acme/zoom",
"http://github.com/acme/zoom",
"https://gitlab.com/acme/zoom",
"https://github.com/acme",
"https://github.com/acme/zoom/issues/3",
"https://github.com/acme/zoom/commit/not-hex",
"https://github.com/acme/zoom/tree/a..b",
"https://github.com/../zoom",
"https://github.com/acme/zo om",
] {
assert!(parse(bad).is_err(), "{bad:?}");
}
}
/// A recorded API: the JSON each path answers, 404 for the rest.
fn recorded(answers: Vec<(&'static str, Value)>) -> impl Fn(&str) -> Result<Option<Value>, String> {
move |path| {
Ok(answers
.iter()
.find(|(p, _)| *p == path)
.map(|(_, v)| v.clone()))
}
}
fn release(tag: &str, assets: Value) -> Value {
json!({ "tag_name": tag, "assets": assets })
}
fn asset(name: &str, digest: Option<&str>) -> Value {
json!({
"name": name,
"browser_download_url": format!("https://github.com/acme/zoom/releases/download/v1/{name}"),
"digest": digest,
})
}
#[test]
fn a_repository_resolves_to_its_latest_releases_hnmod_and_commit() {
let digest = format!("sha256:{}", "AB".repeat(32));
let api = recorded(vec![
(
"repos/acme/zoom/releases/latest",
release(
"v1",
json!([
asset("zoom.hmod", None),
asset("zoom-1.hnmod", Some(&digest))
]),
),
),
("repos/acme/zoom/commits/v1", json!({ "sha": SHA })),
]);
let found = resolve(&link("https://github.com/acme/zoom"), &api).unwrap();
assert_eq!(
(found.tag.as_deref(), found.commit.as_str()),
(Some("v1"), SHA)
);
let file = found.asset.unwrap();
assert_eq!(file.name, "zoom-1.hnmod");
assert_eq!(file.sha256, Some("ab".repeat(32)));
assert_eq!(found.content_only, None);
}
#[test]
fn a_release_with_no_hnmod_falls_back_to_its_commit_and_remembers_a_content_mod() {
let api = recorded(vec![
(
"repos/acme/zoom/releases/latest",
release("v1", json!([asset("zoom.hmod", None)])),
),
("repos/acme/zoom/commits/v1", json!({ "sha": SHA })),
]);
let found = resolve(&link("https://github.com/acme/zoom"), &api).unwrap();
assert_eq!(found.asset, None);
assert_eq!(found.content_only.as_deref(), Some("zoom.hmod"));
assert_eq!(found.commit, SHA);
}
#[test]
fn an_asset_from_another_host_is_ignored() {
let evil = json!({ "name": "x.hnmod", "browser_download_url": "https://evil.example/x.hnmod" });
let api = recorded(vec![
(
"repos/acme/zoom/releases/latest",
release("v1", json!([evil])),
),
("repos/acme/zoom/commits/v1", json!({ "sha": SHA })),
]);
assert_eq!(
resolve(&link("https://github.com/acme/zoom"), &api)
.unwrap()
.asset,
None
);
}
#[test]
fn no_release_resolves_the_default_branch_to_a_commit() {
let api = recorded(vec![
("repos/acme/zoom", json!({ "default_branch": "dev/main" })),
("repos/acme/zoom/commits/dev/main", json!({ "sha": SHA })),
]);
let found = resolve(&link("https://github.com/acme/zoom"), &api).unwrap();
assert_eq!(
(&found.tag, &found.asset, found.commit.as_str()),
(&None, &None, SHA)
);
assert_eq!(found.label(), "acme/zoom @ 0123456");
assert_eq!(
found.source_url(),
format!("https://github.com/acme/zoom/tree/{SHA}")
);
}
#[test]
fn refs_and_short_commits_become_full_commits_and_unknown_ones_say_so() {
let api = recorded(vec![
("repos/acme/zoom/commits/feature/x", json!({ "sha": SHA })),
("repos/acme/zoom/commits/abcdef1", json!({ "sha": SHA2 })),
("repos/acme/zoom/releases/tags/v9", Value::Null),
]);
let commit = |url: &str| resolve(&link(url), &api).map(|r| r.commit);
assert_eq!(
commit("https://github.com/acme/zoom/tree/feature/x"),
Ok(SHA.into())
);
assert_eq!(
commit("https://github.com/acme/zoom/commit/abcdef1"),
Ok(SHA2.into())
);
let err = commit("https://github.com/acme/zoom/tree/gone").unwrap_err();
assert!(err.contains("no commit, tag or branch gone"), "{err}");
// A tag with no release is a commit.
assert!(commit("https://github.com/acme/zoom/releases/tag/v9").is_err());
}
#[test]
fn a_rate_limit_is_said_plainly() {
let limited = |_: &str| Err(RATE_LIMIT.to_string());
let err = resolve(&link("https://github.com/acme/zoom"), &limited).unwrap_err();
assert!(
err.contains("rate limit") && err.contains("Try again"),
"{err}"
);
}
fn tree(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("gh-mods-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write(path: &Path, text: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn mixin(id: &str, pin: bool) -> String {
let compat = match pin {
true => format!("[compatibility]\nhorizon_revision = \"{SHA}\"\n"),
false => String::new(),
};
format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n{compat}")
}
#[test]
fn mod_folders_are_found_wherever_they_are_and_not_searched_inside() {
let root = tree("find");
write(
&root.join("native/zoom/horizon-mixin.toml"),
&mixin("zoom", true),
);
write(
&root.join("native/zoom/src/horizon-mixin.toml"),
&mixin("inner", true),
);
write(
&root.join("native/keys/horizon-mixin.toml"),
&mixin("keys", true),
);
write(
&root.join("target/debug/horizon-mixin.toml"),
&mixin("built", true),
);
write(
&root.join(".git/x/horizon-mixin.toml"),
&mixin("hidden", true),
);
let ids: Vec<String> = find_mods(&root)
.unwrap()
.into_iter()
.map(|b| b.id)
.collect();
assert_eq!(ids, ["keys", "zoom"]);
assert!(find_mods(&tree("empty")).unwrap().is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn a_mod_folder_packs_into_a_bundle_the_registry_checks_accept() {
let root = tree("pack");
write(&root.join("horizon-mixin.toml"), &mixin("zoom", true));
write(&root.join("src/zoom.rs"), "pub fn f() {}");
write(&root.join("data/a.json"), "{}");
write(&root.join("README.md"), "not shipped");
let bundle = &find_mods(&root).unwrap()[0];
let bytes = pack(bundle).unwrap();
assert_eq!(bytes, pack(bundle).unwrap(), "the same files pack the same");
let packed = checked(&bytes, &root.join("out")).unwrap();
assert_eq!(
(packed.id.as_str(), packed.version.as_str()),
("zoom", "0.1.0")
);
assert_eq!(packed.sha256, sha256(&bytes));
let read = format::Bundle::read(&packed.file).unwrap();
assert!(read.files.contains_key("mixins/zoom/src/zoom.rs"));
assert!(read.files.contains_key("mixins/zoom/data/a.json"));
assert!(!read.files.keys().any(|n| n.contains("README")));
let _ = fs::remove_dir_all(root);
}
#[test]
fn a_mod_with_no_pinned_horizon_commit_is_refused_with_the_fix() {
let root = tree("unpinned");
write(&root.join("horizon-mixin.toml"), &mixin("zoom", false));
let err = pack(&find_mods(&root).unwrap()[0]).unwrap_err();
assert!(err.contains("horizon_revision"), "{err}");
let _ = fs::remove_dir_all(root);
}
#[test]
fn an_unpacked_hnmod_keeps_its_pins_and_hook_points_and_ships_only_sources() {
let root = tree("unpacked");
let manifest = json!({
"schema": 1, "id": "zoom", "version": "0.1.0", "game": "26.3",
"horizon": SHA, "mixins": SHA2, "hooks": 3,
});
write(&root.join("horizon-native.json"), &manifest.to_string());
write(
&root.join("mixins/zoom/horizon-mixin.toml"),
&mixin("zoom", true),
);
let bundle = &find_mods(&root).unwrap()[0];
assert_eq!(bundle.mixins.as_deref(), Some(SHA2));
let packed = checked(&pack(bundle).unwrap(), &root.join("out")).unwrap();
let read = format::Bundle::read(&packed.file).unwrap();
assert_eq!(
(
read.manifest.horizon.as_str(),
read.manifest.mixins.as_str()
),
(SHA, SHA2)
);
assert_eq!(read.manifest.hooks, Some(3));
// Only sources and data ship: a binary beside them stays behind.
write(&root.join("mixins/zoom/evil.exe"), "MZ");
let again = checked(&pack(bundle).unwrap(), &root.join("out2")).unwrap();
assert_eq!(again.sha256, packed.sha256);
// A manifest and a mixin file that pin different commits are refused when found.
write(
&root.join("mixins/zoom/horizon-mixin.toml"),
&format!(
"{}[compatibility]\nhorizon_revision = \"{SHA2}\"\n",
mixin("zoom", false)
),
);
assert!(find_mods(&root).unwrap_err().contains("pins Horizon"));
let _ = fs::remove_dir_all(root);
}
fn packed(id: &str) -> Packed {
Packed {
id: id.into(),
version: "0.1.0".into(),
file: PathBuf::new(),
sha256: String::new(),
}
}
#[test]
fn several_mods_list_and_ask_which() {
let two = [packed("keys"), packed("zoom")];
let err = choose(&two, None, false).unwrap_err();
assert!(err.contains("keys, zoom") && err.contains("--all"), "{err}");
assert_eq!(choose(&two, Some("zoom"), false).unwrap()[0].id, "zoom");
assert_eq!(choose(&two, None, true).unwrap().len(), 2);
assert!(choose(&two, Some("map"), false).is_err());
assert_eq!(choose(&two[..1], None, false).unwrap()[0].id, "keys");
}
#[test]
fn installing_records_the_origin_and_updates_follow_releases_or_the_head() {
let home = tree("install");
let source = tree("install-src");
write(&source.join("horizon-mixin.toml"), &mixin("zoom", true));
let bytes = pack(&find_mods(&source).unwrap()[0]).unwrap();
let ready = checked(&bytes, &source.join("out")).unwrap();
let found = Resolved {
owner: "acme".into(),
repo: "zoom".into(),
tag: None,
commit: SHA.into(),
asset: None,
content_only: None,
};
let kept = ready.file.clone();
let origin = install(&home, &found, &ready).unwrap();
assert!(!kept.exists());
assert_eq!(origin.label(), "acme/zoom @ 0123456");
assert_eq!(origin.sha256, sha256(&bytes));
assert_eq!(self::origin(&home, "zoom"), Some(origin.clone()));
assert_eq!(origins(&home), vec![origin.clone()]);
assert!(java::is_enabled(&home, "zoom"));
// A commit's mod follows the default branch's head.
assert_eq!(origin.follows().target, Target::Head);
let newer = Resolved {
commit: SHA2.into(),
..found.clone()
};
assert!(is_update(&origin, &newer));
assert!(!is_update(&origin, &found));
// A release's mod follows the latest release.
let release = Origin {
tag: Some("v1".into()),
asset: Some("zoom.hnmod".into()),
..origin.clone()
};
assert_eq!(release.label(), "acme/zoom @ v1");
assert_eq!(release.follows().target, Target::Latest);
let v2 = Resolved {
tag: Some("v2".into()),
..found.clone()
};
assert!(is_update(&release, &v2));
assert!(!is_update(
&release,
&Resolved {
tag: Some("v1".into()),
..found
}
));
// A file that isn't from GitHub is never replaced.
write(&home.join(INSTALLED).join("keys.hnmod"), "x");
let other = Packed {
id: "keys".into(),
..packed("keys")
};
assert!(install(&home, &newer, &other)
.unwrap_err()
.contains("not from GitHub"));
assert!(remove(&home, "zoom").unwrap());
assert!(!remove(&home, "zoom").unwrap());
assert!(!home.join(INSTALLED).join("zoom.hnmod").exists());
assert!(!java::is_enabled(&home, "zoom"));
let _ = fs::remove_dir_all(home);
let _ = fs::remove_dir_all(source);
}
+501
View File
@@ -0,0 +1,501 @@
//! "Add from GitHub" on the Mods page: a link to a repository, release, tag, branch or commit,
//! resolved to an exact release file or commit (`horizon_launcher::github_mods`), shown with the
//! plain warning and a link to the exact source, and installed only after the player says they
//! trust it. Mods installed that way list where they came from, with "Check for updates".
use super::line_edit::{self, Key, LineEdit};
use super::*;
use horizon_launcher::github_mods::{self, Origin, Packed, Resolved};
use std::path::{Path, PathBuf};
enum Event {
/// A link (or an update check for a mod id) resolved.
Resolved(Result<Resolved, String>, Option<String>),
/// The mods of a source, fetched and checked, when there is a choice to make.
Fetched(Resolved, Result<Vec<Packed>, String>),
Installed(Result<String, String>),
}
enum Stage {
Idle,
Working(String),
/// The warning and the source link, before anything is downloaded. `only`: the mod an update
/// is for.
Review(Resolved, Option<String>),
Pick(Resolved, Vec<Packed>),
}
pub(super) struct Github {
text: LineEdit,
focus: FocusHandle,
focused: bool,
stage: Stage,
message: Option<Result<String, String>>,
/// The mods installed from GitHub in `home`, and what the last update check found for each.
home: Option<PathBuf>,
origins: Vec<Origin>,
newer: std::collections::HashMap<String, Resolved>,
confirm_remove: Option<String>,
tx: Sender<Event>,
rx: Receiver<Event>,
}
impl Github {
pub(super) fn new(cx: &mut App) -> Self {
let (tx, rx) = channel();
Self {
text: LineEdit::new(String::new(), 300, 40),
focus: cx.focus_handle().tab_index(0).tab_stop(true),
focused: false,
stage: Stage::Idle,
message: None,
home: None,
origins: Vec::new(),
newer: Default::default(),
confirm_remove: None,
tx,
rx,
}
}
pub(super) fn note_focus(&mut self, window: &Window) {
self.focused = self.focus.is_focused(window);
}
fn busy(&self) -> bool {
matches!(self.stage, Stage::Working(_))
}
fn reread(&mut self, home: &Path) {
self.home = Some(home.to_owned());
self.origins = github_mods::origins(home);
}
}
/// Installs the chosen mods of `mods` and says what was installed.
fn finish(
home: &Path,
found: &Resolved,
mods: &[Packed],
only: Option<&str>,
all: bool,
) -> Result<String, String> {
let done = github_mods::choose(mods, only, all)?
.into_iter()
.map(|p| github_mods::install(home, found, p))
.collect::<Result<Vec<_>, _>>()?;
let names: Vec<String> = done
.iter()
.map(|o| format!("{} {}", o.id, o.version))
.collect();
Ok(format!(
"Installed {} from GitHub ({}). Build it from Settings > Native mods.",
names.join(", "),
found.label()
))
}
impl Launcher {
pub(super) fn github_tick(&mut self) -> bool {
let mut changed = false;
while let Ok(event) = self.github.rx.try_recv() {
changed = true;
match event {
Event::Resolved(Err(e), _) => {
self.github.stage = Stage::Idle;
self.github.message = Some(Err(e));
}
Event::Resolved(Ok(found), None) => self.github.stage = Stage::Review(found, None),
Event::Resolved(Ok(found), Some(id)) => {
self.github.stage = Stage::Idle;
let have = self.github.origins.iter().find(|o| o.id == id);
if have.is_some_and(|o| github_mods::is_update(o, &found)) {
self.github.newer.insert(id, found);
} else {
self.github.message = Some(Ok(format!("{id} is up to date.")));
}
}
Event::Fetched(found, Ok(mods)) => self.github.stage = Stage::Pick(found, mods),
Event::Fetched(_, Err(e)) => {
self.github.stage = Stage::Idle;
self.github.message = Some(Err(e));
}
Event::Installed(result) => {
self.github.stage = Stage::Idle;
if result.is_ok() {
self.github.text.set(String::new());
self.github.newer.clear();
// Installing a native mod turns Native mods on, so Settings offers Build.
self.settings.native_mods = true;
self.settings.native_mods_noticed = true;
self.settings.save();
}
self.github.message = Some(result);
let home = self.mods.home();
self.github.reread(&home);
}
}
}
changed
}
/// Resolves what the field holds, through GitHub's API on a thread.
fn github_resolve(&mut self, link: github_mods::Link, update_of: Option<String>) {
if self.github.busy() {
return;
}
self.github.message = None;
self.github.stage =
Stage::Working(format!("Asking GitHub about {}/{}…", link.owner, link.repo));
let tx = self.github.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(Event::Resolved(
github_mods::resolve(&link, &github_mods::api),
update_of,
));
});
}
fn github_add(&mut self) {
match github_mods::parse(self.github.text.text()) {
Ok(link) => self.github_resolve(link, None),
Err(e) => self.github.message = Some(Err(e)),
}
}
/// Downloads and checks what the player agreed to; installs it unless there is a choice.
fn github_install(&mut self, found: Resolved, only: Option<String>) {
self.github.stage = Stage::Working(format!("Downloading {}…", found.label()));
let tx = self.github.tx.clone();
let home = self.mods.home();
std::thread::spawn(move || {
let fetched = github_mods::fetch(&found);
let result = match fetched {
Ok(mods) if mods.len() == 1 || only.is_some() => {
let done = finish(&home, &found, &mods, only.as_deref(), false);
github_mods::discard(&found);
Event::Installed(done)
}
other => Event::Fetched(found, other),
};
let _ = tx.send(result);
});
}
fn github_pick(&mut self, found: Resolved, mods: Vec<Packed>, id: Option<String>) {
let home = self.mods.home();
let done = finish(&home, &found, &mods, id.as_deref(), id.is_none());
github_mods::discard(&found);
let _ = self.github.tx.send(Event::Installed(done));
self.github.stage = Stage::Working("Installing…".into());
}
fn github_field(&mut self, cx: &mut Context<Self>) -> Stateful<Div> {
let t = self.theme;
let focused = self.github.focused;
let field = div()
.id("github-link")
.track_focus(&self.github.focus)
.flex()
.items_center()
.gap(px(10.))
.w(px(460.))
.h(px(theme::FIELD + 4.))
.px(px(12.))
.overflow_hidden()
.border_2()
.border_color(if focused { t.accent } else { t.outline })
.bg(t.well)
.text_size(px(theme::UI.px))
.cursor_text()
.child(line_edit::content(
&self.github.text,
&t,
focused,
false,
false,
"https://github.com/owner/repo",
))
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
window.focus(&this.github.focus);
cx.notify();
}))
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
match line_edit::handle(&mut this.github.text, ev, cx) {
Key::Enter => this.github_add(),
Key::Escape => window.blur(),
Key::Pass => return,
_ => {}
}
cx.stop_propagation();
cx.notify();
}));
line_edit::mouse(field, cx, |this| &mut this.github.text)
}
/// The Add from GitHub panel of the Mods tab, and the mods installed that way.
pub(super) fn github_panel(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let home = self.mods.home();
if self.github.home.as_deref() != Some(home.as_path()) {
self.github.reread(&home);
self.github.newer.clear();
}
let busy = self.github.busy();
let mut add = button("github-add", "Add from GitHub", Kind::Secondary, &t);
add = match busy {
true => disabled(add),
false => add.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.github_add();
cx.notify();
})),
};
let mut panel = div()
.flex()
.flex_col()
.gap(px(12.))
.child(caps("Add a native mod from GitHub").text_color(t.muted))
.child(
text(theme::CAPTION).text_color(t.muted).child(
"A link to a repository (its latest release), a release, a branch or tag, or a commit. \
The launcher installs one exact release file or commit, never a branch that moves.",
),
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap(px(12.))
.child(self.github_field(cx))
.child(add),
);
match std::mem::replace(&mut self.github.stage, Stage::Idle) {
Stage::Idle => {}
Stage::Working(line) => {
self.github.stage = Stage::Working(line.clone());
panel = panel.child(text(theme::CAPTION).text_color(t.ink_soft).child(line));
}
Stage::Review(found, only) => {
panel = panel.child(self.github_review(&found, only.clone(), cx));
self.github.stage = Stage::Review(found, only);
}
Stage::Pick(found, mods) => {
panel = panel.child(self.github_choice(&found, &mods, cx));
self.github.stage = Stage::Pick(found, mods);
}
}
match &self.github.message {
Some(Ok(done)) => {
panel = panel.child(plate(&t).p(px(12.)).text_color(t.ink).child(done.clone()))
}
Some(Err(e)) => panel = panel.child(notice(e.clone(), &t)),
None => {}
}
for origin in self.github.origins.clone() {
panel = panel.child(self.github_row(&origin, busy, cx));
}
panel
}
/// The warning, the exact source, and the only way forward: saying they trust it.
fn github_review(&self, found: &Resolved, only: Option<String>, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let what = match (&found.asset, &found.content_only) {
(Some(asset), _) => format!("The release file {} ({}).", asset.name, found.label()),
(None, Some(file)) => format!(
"The source of {}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.",
found.label()
),
(None, None) => format!("The source at commit {}.", found.commit),
};
let (url, open) = (found.source_url(), found.source_url());
let install = found.clone();
plate(&t)
.flex()
.flex_col()
.gap(px(10.))
.p(px(16.))
.text_size(px(theme::UI.px))
.text_color(t.ink)
.child(caps("Not reviewed by the Horizon registry").text_color(t.danger))
.child(what)
.child(
"Native mods run with full access to this computer. Read the source before you install it.",
)
.child(text(theme::CAPTION).font_family(theme::MONO).text_color(t.ink_soft).child(url))
.child(
div()
.flex()
.flex_wrap()
.gap(px(8.))
.child(
button("github-source", "Open the source", Kind::Secondary, &t)
.on_click(move |_, _, cx| cx.open_url(&open)),
)
.child(
button("github-trust", "I read it and trust it: install", Kind::Primary, &t)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.github_install(install.clone(), only.clone());
cx.notify();
})),
)
.child(
button("github-cancel", "Cancel", Kind::Ghost, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.github.stage = Stage::Idle;
cx.notify();
},
)),
),
)
}
/// A source with several mods: one, or all.
fn github_choice(&self, found: &Resolved, mods: &[Packed], cx: &mut Context<Self>) -> Div {
let t = self.theme;
let mut buttons = div().flex().flex_wrap().gap(px(8.));
for (i, p) in mods.iter().enumerate() {
let (found, all, id) = (found.clone(), mods.to_vec(), p.id.clone());
buttons = buttons.child(
button(
SharedString::from(format!("github-pick-{i}")),
format!("{} {}", p.id, p.version),
Kind::Secondary,
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.github_pick(found.clone(), all.clone(), Some(id.clone()));
cx.notify();
})),
);
}
let (every, listed) = (found.clone(), mods.to_vec());
buttons = buttons.child(
button("github-pick-all", "Install all", Kind::Primary, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.github_pick(every.clone(), listed.clone(), None);
cx.notify();
},
)),
);
plate(&t)
.flex()
.flex_col()
.gap(px(10.))
.p(px(16.))
.text_size(px(theme::UI.px))
.text_color(t.ink)
.child(format!(
"{} holds {} mods. Install which?",
found.name(),
mods.len()
))
.child(buttons)
}
/// One mod installed from GitHub: where from, check for updates, remove.
fn github_row(&self, origin: &Origin, busy: bool, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let id = origin.id.clone();
let mut right = div().flex().flex_wrap().items_center().gap(px(8.));
let known = self.github.newer.get(&id).cloned();
let main = match &known {
Some(latest) => {
let (latest, only) = (latest.clone(), id.clone());
button(
SharedString::from(format!("github-update-{id}")),
format!("Update to {}", latest.label()),
Kind::Primary,
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.github.stage = Stage::Review(latest.clone(), Some(only.clone()));
cx.notify();
}))
}
None => {
let (link, only) = (origin.follows(), id.clone());
let check = button(
SharedString::from(format!("github-check-{id}")),
"Check for updates",
Kind::Secondary,
&t,
);
match busy {
true => disabled(check),
false => check.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.github_resolve(link.clone(), Some(only.clone()));
cx.notify();
})),
}
}
};
right = right.child(main);
let url = origin.source_url();
right = right.child(
button(
SharedString::from(format!("github-src-{id}")),
"Source",
Kind::Ghost,
&t,
)
.on_click(move |_, _, cx| cx.open_url(&url)),
);
let confirming = self.github.confirm_remove.as_deref() == Some(id.as_str());
let target = id.clone();
right = right.child(
button(
SharedString::from(format!("github-remove-{id}")),
if confirming {
"Remove for good"
} else {
"Remove"
},
if confirming {
Kind::Secondary
} else {
Kind::Ghost
},
&t,
)
.when(confirming, |b| b.text_color(t.danger))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if this.github.confirm_remove.as_ref() == Some(&target) {
let home = this.mods.home();
this.github.message = Some(
github_mods::remove(&home, &target).map(|_| format!("Removed {target}.")),
);
this.github.reread(&home);
this.github.confirm_remove = None;
} else {
this.github.confirm_remove = Some(target.clone());
}
cx.notify();
})),
);
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap(px(8.))
.child(
div()
.flex()
.flex_col()
.child(format!("{} {}", origin.id, origin.version))
.child(
text(theme::CAPTION)
.text_color(t.ink_soft)
.child(format!("from GitHub: {}", origin.label())),
)
.child(
text(theme::CAPTION)
.font_family(theme::MONO)
.text_color(t.muted)
.child(format!("sha256 {}", origin.sha256)),
),
)
.child(right)
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod export;
#[cfg(unix)]
pub mod game_app;
pub mod game_log;
pub mod github_mods;
pub mod heads;
pub mod i18n;
pub mod import;
+103 -21
View File
@@ -16,6 +16,7 @@ mod datapacks_page;
mod dev_options;
mod export_page;
mod game_tools;
mod github_mods_page;
mod home;
mod home_tabs;
mod import_page;
@@ -178,9 +179,10 @@ struct Launcher {
releases: Load<Vec<Release>>,
installed: Vec<String>,
installing: Option<(String, f32)>,
/// The version being deleted on a background thread. It is already out of `installed`, so
/// The versions being deleted on background threads. Each is already out of `installed`, so
/// nothing can launch, select or prepare it, and its row can't install or remove it again.
removing: Option<String>,
/// More than one may run at once: a row's Remove stays usable while another version goes.
removing: Vec<String>,
/// What each installed version takes on disk, measured on a thread for the Versions page, and
/// the installed list it was measured for.
disk: Vec<(String, u64)>,
@@ -191,6 +193,9 @@ struct Launcher {
native_step: Option<String>,
native_error: Option<String>,
native_confirm: Option<Result<Vec<native_mods::Bundle>, String>>,
/// The mods of a build made for other Horizon versions (`native_mods::MISMATCH`): the build
/// waits for "Build anyway".
native_mismatch: Option<String>,
confirm_remove: Option<String>,
picker_open: bool,
/// The top bar's quick switch of who plays (`switch_page.rs`).
@@ -297,6 +302,7 @@ struct Launcher {
starting_pack: Option<std::path::PathBuf>,
/// The Mods page: the browser's search, installs and updates (`mods_page.rs`).
mods: mods_page::Mods,
github: github_mods_page::Github,
/// The instances, for the sidebar and Play, and their dialog (`instances_page.rs`).
instances: instances_page::Instances,
/// Home's Worlds section, of the selected instance (`worlds_page.rs`).
@@ -419,13 +425,14 @@ impl Launcher {
releases: Load::Loading,
installed,
installing: None,
removing: None,
removing: Vec::new(),
disk: Vec::new(),
disk_for: Vec::new(),
install_error: None,
native_step: None,
native_error: None,
native_confirm: None,
native_mismatch: None,
confirm_remove: None,
picker_open: false,
account_menu: false,
@@ -496,6 +503,7 @@ impl Launcher {
pack_error: None,
starting_pack: None,
mods: mods_page::Mods::new(cx),
github: github_mods_page::Github::new(cx),
instances: instances_page::Instances::default(),
worlds: worlds_page::Worlds::default(),
multiplayer: multiplayer_page::Multiplayer::default(),
@@ -606,7 +614,7 @@ impl Launcher {
let Some(release) = self.updates.horizon.clone() else {
return;
};
if self.installing.is_some() || self.removing.as_ref() == Some(&release.tag) {
if self.installing.is_some() || self.removing.contains(&release.tag) {
return;
}
let select =
@@ -627,12 +635,14 @@ impl Launcher {
/// Builds the selected instance's native mods, once the player accepted the warning. The
/// build reuses the game files of the version the instance plays, else the newest installed.
fn build_native(&mut self) {
/// `anyway`: the player agreed to build mods made for other Horizon versions together.
fn build_native(&mut self, anyway: bool) {
if self.native_step.is_some() {
return;
}
self.native_confirm = None;
self.native_error = None;
self.native_mismatch = None;
let instance = instances::selected();
let base = instance
.version
@@ -658,7 +668,7 @@ impl Launcher {
self.native_step = Some(tr("Starting the build").into());
let tx = self.tx.clone();
std::thread::spawn(move || {
let result = native_mods::build(&base, &folder, Some(&instance), |step| {
let result = native_mods::build(&base, &folder, Some(&instance), anyway, |step| {
let _ = tx.send(Msg::NativeProgress(step.label()));
});
let _ = tx.send(Msg::Native(result));
@@ -866,9 +876,7 @@ impl Launcher {
}
}
Msg::Removed(tag, result) => {
if self.removing.as_ref() == Some(&tag) {
self.removing = None;
}
self.removing.retain(|t| *t != tag);
self.installed = backend::installed();
self.settings.selected =
selection_after_removal(&self.installed, Settings::load().selected);
@@ -898,8 +906,13 @@ impl Launcher {
self.native_step = None;
self.installed = backend::installed();
self.reload_instances();
if let Err(e) = result {
self.native_error = Some(e);
match result.map_err(|e| match e.strip_prefix(native_mods::MISMATCH) {
Some(list) => Ok(list.to_owned()),
None => Err(e),
}) {
Err(Ok(list)) => self.native_mismatch = Some(list),
Err(Err(e)) => self.native_error = Some(e),
Ok(_) => {}
}
}
Msg::LauncherProgress(p) => {
@@ -1034,6 +1047,7 @@ impl Launcher {
}
changed |= self.servers_tick();
changed |= self.mods_tick();
changed |= self.github_tick();
if let Some(sign_in) = &self.sign_in {
let state = sign_in.state();
let changed_state = format!("{state:?}") != format!("{:?}", self.sign_in_state);
@@ -1101,7 +1115,7 @@ impl Launcher {
}
fn install(&mut self, release: &Release) {
if release.asset.is_none() || self.removing.as_ref() == Some(&release.tag) {
if release.asset.is_none() || self.removing.contains(&release.tag) {
return;
}
let (release, tx) = (release.clone(), self.tx.clone());
@@ -1123,14 +1137,12 @@ impl Launcher {
/// the window must keep drawing meanwhile. `Msg::Removed` ends it.
fn remove(&mut self, tag: &str) {
self.confirm_remove = None;
if self.removing.is_some()
|| self.installing.as_ref().is_some_and(|(t, _)| t == tag)
|| !self.installed.iter().any(|t| t == tag)
{
let installing = self.installing.as_ref().map(|(t, _)| t.as_str());
if !removable(&self.installed, installing, &self.removing, tag) {
return;
}
self.install_error = None;
self.removing = Some(tag.to_string());
self.removing.push(tag.to_string());
self.installed.retain(|t| t != tag);
self.settings.selected =
selection_after_removal(&self.installed, self.settings.selected.take());
@@ -1741,6 +1753,19 @@ fn starts_now(prepared: bool, preparing: bool, running: bool) -> bool {
play_press(prepared, None, preparing || running) == PlayPress::Play
}
/// Whether `tag` can start being deleted now: it is installed, no install is writing it, and it
/// is not already going. Another version deleting at the same time does not stop it.
fn removable(
installed: &[String],
installing: Option<&str>,
removing: &[String],
tag: &str,
) -> bool {
installing != Some(tag)
&& installed.iter().any(|t| t == tag)
&& !removing.iter().any(|t| t == tag)
}
/// The selected version once one has been removed: the saved choice if it is still installed,
/// else the newest left.
fn selection_after_removal(installed: &[String], saved: Option<String>) -> Option<String> {
@@ -3084,6 +3109,7 @@ impl Launcher {
switch("native-mods", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.settings.native_mods = !this.settings.native_mods;
this.native_confirm = None;
this.native_mismatch = None;
this.settings.save();
cx.notify();
}));
@@ -3102,7 +3128,9 @@ impl Launcher {
}),
};
detail = detail.children(status);
if self.native_step.is_none() && self.native_confirm.is_none() {
if let (None, Some(list)) = (&self.native_step, &self.native_mismatch) {
detail = detail.child(self.native_mismatch_panel(list, cx));
} else if self.native_step.is_none() && self.native_confirm.is_none() {
detail = detail.child(div().flex().gap(px(8.)).mt(px(4.)).child(
button("native-build", "Build", Kind::Primary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
@@ -3162,7 +3190,7 @@ impl Launcher {
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.build_native();
this.build_native(false);
cx.notify();
})),
);
@@ -3177,6 +3205,44 @@ impl Launcher {
))
}
/// Mods made for other Horizon versions: which, and the version the build uses. They often
/// still work, so the player may build anyway.
fn native_mismatch_panel(&self, list: &str, cx: &mut Context<Self>) -> Div {
let t = self.theme;
div()
.flex()
.flex_col()
.gap(px(6.))
.mt(px(8.))
.p(px(12.))
.bg(t.well)
.child(div().text_color(t.danger).child(tr("These mods were made for different Horizon versions").to_string()))
.child(list.to_owned())
.child(tr("They often still work together. If the build fails, install versions made for the same Nightly.").to_string())
.child(
div()
.flex()
.gap(px(8.))
.mt(px(4.))
.child(
button("native-anyway", "Build anyway", Kind::Primary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.build_native(true);
cx.notify();
}),
),
)
.child(
button("native-mismatch-cancel", "Cancel", Kind::Secondary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.native_mismatch = None;
cx.notify();
}),
),
),
)
}
fn game_args_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let focused = self.args_focused;
@@ -3553,7 +3619,7 @@ impl Launcher {
self.installing.as_ref().is_some_and(|(t, _)| t == tag)
|| self.install_error.as_ref().is_some_and(|(t, _)| t == tag)
|| self.confirm_remove.as_ref() == Some(tag)
|| self.removing.as_ref() == Some(tag)
|| self.removing.contains(tag)
};
let list = self.long_list.clone().column(
&self.content_scroll.handle,
@@ -3593,7 +3659,7 @@ impl Launcher {
.child(format!("{:.0}%", p * 100.)),
)
.into_any_element()
} else if self.removing.as_ref() == Some(&tag) {
} else if self.removing.contains(&tag) {
div()
.flex()
.items_center()
@@ -3914,6 +3980,7 @@ impl Render for Launcher {
self.servers.note_focus(window);
self.friend_focused = self.friend_focus.is_focused(window);
self.mods.note_focus(window);
self.github.note_focus(window);
self.args_focused = self.args_focus.is_focused(window);
self.proxy_focused = self.proxy_focus.is_focused(window);
let size = window.viewport_size();
@@ -4289,6 +4356,21 @@ mod tests {
assert!(hit.iter().any(|b| b.action().partial_eq(&CloseWindow)));
}
#[test]
fn a_second_version_can_be_removed_while_another_deletes() {
let installed = ["v0.2.97".to_string(), "v0.2.76".to_string()];
let removing = ["v0.2.97".to_string()];
assert!(super::removable(&installed, None, &removing, "v0.2.76"));
assert!(!super::removable(&installed, None, &removing, "v0.2.97"));
assert!(!super::removable(
&installed,
Some("v0.2.76"),
&[],
"v0.2.76"
));
assert!(!super::removable(&installed, None, &[], "v0.2.85"));
}
#[test]
fn the_selection_survives_a_removal_or_moves_to_the_newest_left() {
let left = ["v0.2.49".to_string(), "v0.2.48".to_string()];
+4
View File
@@ -962,6 +962,10 @@ impl Launcher {
.child(section_title("Browse modpacks", "search", &t));
}
body = body.child(controls);
// Native mods from a GitHub link: Horizon instances, on the Mods tab.
if self.mods.kind == PackKind::Mod && self.mods.java.is_none() {
body = body.child(self.github_panel(cx));
}
if let Some(list) = self.mods_installed_list(cx) {
body = body.child(list);
}
+191 -21
View File
@@ -4,8 +4,9 @@
//! ([`rust_toolchain`]). Unlike `.hmod` mods they are not sandboxed: their code runs with the
//! player's rights.
//!
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
//! every mod of one build must pin the same one. A mod that hooks only Horizon's native hook points
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit).
//! Mods pinning different ones build on the newest of them, once the player agreed to try (they
//! often still apply and compile). A mod that hooks only Horizon's native hook points
//! (`game/native_hooks.rs`) names the version it needs instead (`hooks`): it builds on any Horizon
//! whose hook points have that version or a later one (they are only ever added to), the player's own
//! by default, so such mods always build together. The build compiles that revision's source, from
@@ -58,7 +59,7 @@ pub fn is_native(version: &str) -> bool {
version.starts_with(PREFIX)
}
fn root() -> PathBuf {
pub(crate) fn root() -> PathBuf {
backend::data_dir().join("native")
}
@@ -160,7 +161,7 @@ pub fn bundles(folder: &Path) -> Result<Vec<Bundle>, String> {
Ok(found)
}
fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
pub(crate) fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
if dir.join(MANIFEST).is_file() {
return bundle(dir, None).map(Some);
}
@@ -253,6 +254,76 @@ fn agreed(
Ok(revision)
}
/// The revision `bundles` build on for `which` when their pins differ: the newest of the pins and
/// `also` (the launcher's own horizon-mixins), by its commit's time (`date`), so no mod gets an
/// older one than it was made for. With one pin or none, it is [`agreed`]'s.
fn newest(
bundles: &[Bundle],
which: &str,
get: impl Fn(&Bundle) -> Option<&String>,
fallback: impl FnOnce() -> Result<String, String>,
also: Option<&str>,
date: impl Fn(&str) -> Result<String, String>,
) -> Result<String, String> {
let mut pins: Vec<&str> = bundles
.iter()
.filter_map(|b| get(b).map(String::as_str))
.collect();
pins.sort();
pins.dedup();
if pins.len() < 2 {
return agreed(bundles, which, get, fallback);
}
if let Some(bad) = pins.iter().find(|p| !is_revision(p)) {
return Err(format!("{bad} is not a {which} commit (40 hex digits)."));
}
pins.extend(also.filter(|a| !pins.contains(a)));
let mut dated = Vec::new();
for pin in pins {
let when = date(pin).map_err(|e| {
format!("Can't tell which of the mods' {which} revisions is newest: {e}")
})?;
dated.push((when, pin));
}
Ok(dated
.into_iter()
.max()
.map(|(_, p)| p.to_owned())
.unwrap_or_default())
}
/// When commit `revision` of GitHub's `repo` was made (ISO 8601, so it sorts).
fn committed(repo: &str, revision: &str) -> Result<String, String> {
let url = format!("https://api.github.com/repos/{repo}/commits/{revision}");
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
commit["commit"]["committer"]["date"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| format!("GitHub doesn't know {repo} commit {revision}."))
}
/// How [`build`] starts its error when mods pin other Horizon revisions than the one it would
/// use and the player hasn't agreed to try (`anyway`); the rest lists them.
pub const MISMATCH: &str = "Made for other Horizon versions: ";
/// Each pinned mod whose Horizon revision isn't `revision`, as `id → pin`, and the build's.
fn mismatch(mods: &[Bundle], revision: &str) -> Option<String> {
let list: Vec<String> = mods
.iter()
.filter(|b| b.hooks.is_none())
.filter_map(|b| b.horizon.as_ref().map(|r| (b, r)))
.filter(|(_, r)| *r != revision)
.map(|(b, r)| format!("{} → {}", b.id, &r[..r.len().min(7)]))
.collect();
(!list.is_empty()).then(|| {
format!(
"{}; the build uses {}",
list.join(", "),
&revision[..revision.len().min(7)]
)
})
}
/// Where Horizon declares its native hook points' version, and the line that does.
const HOOKS_FILE: &str = "crates/horizon-client/src/game/native_hooks.rs";
const HOOKS_LINE: &str = "pub(crate) const NATIVE_HOOKS_API: u32 = ";
@@ -536,9 +607,21 @@ fn source(revision: &str) -> Result<PathBuf, String> {
backend::REPO
);
let partial = root().join("source").join(format!(".{revision}.partial"));
let top = unpack_archive(&archive, &partial, u64::MAX)?;
fs::write(top.join(".horizon-revision"), revision).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&dir);
fs::rename(&top, &dir).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
let reader = flate2::read::GzDecoder::new(backend::get(&archive)?.into_reader());
Ok(dir)
}
/// GitHub's archive (`.tar.gz`) at `url`, unpacked into the fresh folder `partial`: the archive's
/// one top folder, which is returned. Links are left out; at most `limit` bytes are unpacked.
pub(crate) fn unpack_archive(url: &str, partial: &Path, limit: u64) -> Result<PathBuf, String> {
use std::io::Read;
let _ = fs::remove_dir_all(partial);
fs::create_dir_all(partial).map_err(|e| e.to_string())?;
let reader = flate2::read::GzDecoder::new(backend::get(url)?.into_reader()).take(limit);
let mut tar = tar::Archive::new(reader);
for entry in tar.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| format!("Download interrupted: {e}"))?;
@@ -547,21 +630,16 @@ fn source(revision: &str) -> Result<PathBuf, String> {
continue;
}
entry
.unpack_in(&partial)
.unpack_in(partial)
.map_err(|e| format!("Could not unpack the source: {e}"))?;
}
// The archive's one top folder (horizon-<commit>/) is the source's root.
let top = fs::read_dir(&partial)
// The archive's one top folder (<repo>-<commit>/) is the source's root.
fs::read_dir(partial)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.find(|p| p.is_dir())
.ok_or("The source archive is empty.")?;
fs::write(top.join(".horizon-revision"), revision).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&dir);
fs::rename(&top, &dir).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&partial);
Ok(dir)
.ok_or_else(|| "The source archive is empty.".into())
}
/// Makes `to` hold `from`'s files, writing only those that changed, so Cargo, which goes by file
@@ -587,11 +665,14 @@ fn sync(from: &Path, to: &Path) -> Result<(), String> {
/// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version
/// whose game files the build reuses) and points `instance` at the build: the instance the mods
/// were installed in, else (None, or the game's own home) the Native mods instance. Returns the
/// build's version; a build already made is used again.
/// build's version; a build already made is used again. Mods pinning different revisions build on
/// the newest one, with the newest horizon-mixins they name, only when the player agreed to try
/// (`anyway`): else the error starts with [`MISMATCH`] and says which.
pub fn build(
base: &str,
folder: &Path,
instance: Option<&Instance>,
anyway: bool,
progress: impl Fn(Step),
) -> Result<String, String> {
backend::need_prepared(base)?;
@@ -615,21 +696,39 @@ pub fn build(
// A mod naming a hook points' version doesn't pin: it follows the others, else the game.
let pins: Vec<Bundle> = mods.iter().filter(|b| b.hooks.is_none()).cloned().collect();
let free = pins.iter().all(|b| b.horizon.is_none());
let pinned = agreed(
let pinned = newest(
&pins,
"Horizon",
|b| b.horizon.as_ref(),
|| revision_of(base),
None,
|r| committed(backend::REPO, r),
)?;
let revision = with_hooks(&mods, pinned, free, hooks_at)?;
for b in mods.iter().filter(|b| b.hooks.is_some()) {
let other = mismatch(&mods, &revision);
if let (Some(list), false) = (&other, anyway) {
return Err(format!("{MISMATCH}{list}."));
}
for b in mods
.iter()
.filter(|b| b.hooks.is_some() || b.horizon.as_ref().is_some_and(|r| *r != revision))
{
repin(b, &revision)?;
}
let mixins = agreed(
// Said with a failure: a mod made for another revision may not apply or compile on this one.
let blame = |e: String| {
match &other {
Some(list) => format!("{e}\n\nSome mods were made for other Horizon versions ({list}), which may be why. Install versions made for the same Nightly."),
None => e,
}
};
let mixins = newest(
&mods,
"horizon-mixins",
|b| b.mixins.as_ref(),
|| Ok(MIXINS_REV.into()),
Some(MIXINS_REV),
|r| committed("horizon-rust/horizon-mixins", r),
)?;
let version = version_for(&revision, &mixins, &mods)?;
if !backend::installed().contains(&version) {
@@ -648,7 +747,8 @@ pub fn build(
apply(&rust, &tool, &source, &incoming, &fresh),
"horizon-mixins",
|_| {},
)?;
)
.map_err(blame)?;
let stage = root().join("stage");
sync(&fresh, &stage)?;
let _ = fs::remove_dir_all(&fresh);
@@ -667,7 +767,8 @@ pub fn build(
let name = name.split_whitespace().next().unwrap_or(name);
progress(Step::Compile(crates, name.to_owned()));
}
})?;
})
.map_err(blame)?;
progress(Step::Install);
let about = serde_json::json!({
"base": base,
@@ -1064,6 +1165,75 @@ mod tests {
assert!(agreed(&bad, "Horizon", get, never).is_err());
}
#[test]
fn mods_made_for_different_revisions_build_on_the_newest() {
let pinned = |id: &str, horizon: Option<&str>, mixins: Option<&str>| Bundle {
id: id.into(),
version: "1".into(),
dir: PathBuf::new(),
horizon: horizon.map(str::to_owned),
mixins: mixins.map(str::to_owned),
hooks: None,
};
let c = "c".repeat(40);
// a is the oldest commit, then b, then c.
let date = |r: &str| -> Result<String, String> {
Ok(match &r[..1] {
"a" => "2026-10-28T00:00:00Z",
"b" => "2026-10-29T00:00:00Z",
_ => "2026-10-30T00:00:00Z",
}
.into())
};
let never = || -> Result<String, String> { panic!("a pin wins") };
fn horizon(b: &Bundle) -> Option<&String> {
b.horizon.as_ref()
}
fn mixins(b: &Bundle) -> Option<&String> {
b.mixins.as_ref()
}
let split = [
pinned("veinminer", Some(B), Some(&c)),
pinned("lookat", Some(A), Some(A)),
pinned("zoom", None, None),
];
assert_eq!(
newest(&split, "Horizon", horizon, never, None, date).unwrap(),
B
);
// horizon-mixins: never older than a pin, and the launcher's own when it is newer.
assert_eq!(
newest(&split, "horizon-mixins", mixins, never, Some(B), date).unwrap(),
c
);
let older = [pinned("a", None, Some(A)), pinned("b", None, Some(B))];
assert_eq!(
newest(&older, "horizon-mixins", mixins, never, Some(&c), date).unwrap(),
c
);
// One pin, or the same one everywhere: that pin, and no date is asked for.
let no_date = |_: &str| -> Result<String, String> { panic!("no date needed") };
let same = [pinned("a", Some(A), None), pinned("b", Some(A), None)];
assert_eq!(
newest(&same, "Horizon", horizon, never, None, no_date).unwrap(),
A
);
let offline = |_: &str| -> Result<String, String> { Err("offline".into()) };
let err = newest(&split, "Horizon", horizon, never, None, offline).unwrap_err();
assert!(err.contains("offline"), "{err}");
// What the player is shown: each mod made for another revision, and the build's.
assert_eq!(
mismatch(&split, B).unwrap(),
"lookat → aaaaaaa; the build uses bbbbbbb"
);
assert_eq!(mismatch(&same, A), None);
let hooked = [Bundle {
hooks: Some(1),
..pinned("minimap", Some(A), None)
}];
assert_eq!(mismatch(&hooked, B), None, "hook mods follow the build");
}
#[test]
fn mods_on_the_hook_points_follow_the_game_or_the_pins() {
assert_eq!(
+45
View File
@@ -79,6 +79,51 @@
"name": "panel_event",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_panel_event"
},
{
"name": "register_commands",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_register_commands"
},
{
"name": "perform_command",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_perform_command"
},
{
"name": "menu_clicked",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_menu_clicked"
},
{
"name": "menu_closed",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_menu_closed"
},
{
"name": "tooltip",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_tooltip"
},
{
"name": "use_item_on",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_use_item_on"
},
{
"name": "use_item",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_use_item"
},
{
"name": "destroy_block",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_destroy_block"
},
{
"name": "place_block",
"version": 1,
"target": "horizon_client::game::native_hooks::Game<'_>::native_place_block"
}
]
}
+3 -3
View File
@@ -215,7 +215,7 @@
"status": "now",
"area": "mods",
"plain": "Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.",
"dev": "One item for all modding work (#3911), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, #3345, #3346, #3434, #3435) built by the launcher (#3388, #3855); server-side hooks (#3878, #3706); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry (#1634, #1642, #1659); machines with inventories and menus (#1189); mods that read and change the world through the server. The WebAssembly runtime is removed (#3350, #3351, #3353). docs/native-mods.md.",
"dev": "One item for all modding work (#3911), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, #3345, #3346, #3434, #3435) built by the launcher (#3388, #3855); server-side hooks (#3878, #3706); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry (#1634, #1642, #1659); machines with inventories and menus (#1189); mods that read and change the world through the server. docs/native-mods.md.",
"links": [
"#3855",
"#3351",
@@ -490,7 +490,7 @@
"status": "later",
"area": "mods",
"plain": "Small mods written as scripts, in the style of KubeJS, with no compiler.",
"dev": "Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.",
"dev": "Waits for native mods to settle: a script layer over the native hook points is to be designed.",
"links": [
"docs/native-mods.md"
]
@@ -867,7 +867,7 @@
"status": "later",
"area": "platforms",
"plain": "Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.",
"dev": "No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters here too. Horizon ships none of Mojang's files on any platform.",
"dev": "No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters here too. Horizon ships none of Mojang's files on any platform.",
"links": [
"#1813",
"#1751"
+3 -3
View File
@@ -183,7 +183,7 @@ dev = """One item for all modding work (#3911), led by levzzz5154 with vladislav
parts is theirs. Native mods on stable hook points (game/native_hooks.rs, #3345, #3346, #3434, #3435) built by the \
launcher (#3388, #3855); server-side hooks (#3878, #3706); mod-owned screens, input and drawing; tools for mod makers \
(templates, `native.py check`); the registry (#1634, #1642, #1659); machines with inventories and menus (#1189); mods \
that read and change the world through the server. The WebAssembly runtime is removed (#3350, #3351, #3353). \
that read and change the world through the server. \
docs/native-mods.md."""
links = ["#3855", "#3351", "#3353", "#3360", "#3706", "#3878", "#3345", "#3346", "#3388", "#3434", "#3435", "#1634", "#1642", "#1659", "#1596", "#1635", "docs/native-mods.md"]
@@ -428,7 +428,7 @@ title = "Scripts for mods"
status = "later"
area = "mods"
plain = """Small mods written as scripts, in the style of KubeJS, with no compiler."""
dev = """Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed."""
dev = """Waits for native mods to settle: a script layer over the native hook points is to be designed."""
links = ["docs/native-mods.md"]
[[item]]
@@ -790,7 +790,7 @@ plain = """Versions of Horizon for Android, iOS and web browsers. Nothing offici
first pieces."""
dev = """No official build. Android was first closed as not planned, with touch controls, a mobile interface, \
packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a \
WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` \
build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` \
compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters \
here too. Horizon ships none of Mojang's files on any platform."""
links = ["#1813", "#1751"]
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""`horizon-clients-only`, Horizon's own server.properties key, end to end on a dedicated server.
python3 tools/server_horizon_only_e2e.py --bin-dir target/fast
- off (the default): a client that sends no brand joins, as on vanilla.
- on (`horizon-clients-only=true`): a client with no brand, or with vanilla's "vanilla", is disconnected during
configuration with "This server is for the Horizon client: playhorizon.dev", and the console says why; a
client whose `minecraft:brand` is "horizon" joins. (Any client can send that brand: the gate keeps out
players who didn't install Horizon, not cheaters.)
horizon-bots stand in for the clients (`--brand`). Each server runs in a fresh folder with fresh HORIZON_HOME
and XDG folders (lab.game_env), on a free port. Build first:
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-bots
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
REASON = 'This server is for the Horizon client: playhorizon.dev'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'view-distance': '3',
'simulation-distance': '3',
}
class Failed(Exception):
pass
def bot(bins, port, out, name, *extra):
"""One bot for a few seconds: its report's entry."""
report = out / f'{name}.json'
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', 'idle', '--seconds', '3', '--name', name, '--report', report, *extra],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=180)
return json.loads(report.read_text())['per_bot'][0]
def server(bins, folder, out, name, extra):
folder.mkdir(parents=True)
port = lab.free_port()
props = dict(PROPERTIES, **extra, **{'server-port': str(port)})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
s = Server(command, folder, out / f'{name}.log')
s.wait_for(DONE, 300)
return s, port
def joined(entry):
return entry['joined_after_ms'] > 0 and not entry['error']
def check(bins, scratch, out):
s, port = server(bins, scratch / 'off', out, 'off', {})
try:
plain = bot(bins, port, out, 'Plain')
finally:
s.stop()
if not joined(plain):
raise Failed(f'off: a client without a brand was refused: {plain["error"]}')
print('off: a client without a brand joins')
s, port = server(bins, scratch / 'on', out, 'on', {'horizon-clients-only': 'true'})
try:
results = {name: bot(bins, port, out, name, *extra) for name, extra in
[('None', ()), ('Vanilla', ('--brand', 'vanilla')), ('Horizon', ('--brand', 'horizon'))]}
lines = s.since(0)
finally:
s.stop()
for name in ('None', 'Vanilla'):
entry = results[name]
if joined(entry) or REASON not in (entry['error'] or ''):
raise Failed(f'on: {name} was not turned away with the reason: {entry["error"]}')
if not any(f'Disconnecting {name}0' in l and 'horizon-clients-only' in l for l in lines):
raise Failed(f'on: the console did not say why {name}0 was turned away')
if not joined(results['Horizon']):
raise Failed(f'on: the Horizon client was refused: {results["Horizon"]["error"]}')
print('on: no brand and "vanilla" are turned away with the reason; "horizon" joins')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
parser.add_argument('--out', default=str(lab.LOCAL / 'server-horizon-only-e2e'))
args = parser.parse_args()
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
os.environ.update(lab.game_env())
with tempfile.TemporaryDirectory(prefix='server-horizon-only-e2e-') as scratch:
try:
check(bins, Path(scratch), out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_horizon_only_e2e failed (logs in {out}): {e}')
if __name__ == '__main__':
main()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Vanilla's attack reach on Horizon's dedicated server, end to end.
ServerGamePacketListenerImpl.handleAttack drops an attack unless `Player.isWithinAttackRange(mainHandItem,
targetBox, 3.0)`: without an `attack_range` item, the eyes within the entity interaction range (3, 5 in
creative) plus 3 of the target's box. A client can send any entity id from any distance; the server must
refuse what the range doesn't allow.
python3 tools/server_reach_e2e.py --bin-dir target/fast
A survival dedicated server in a flat world; pairs of horizon-bots stand on the ground (`--behaviour stand`),
placed by the console's /tp 200 blocks from the other pairs (side by side first, so the attacker knows its
victim's entity id, as a cheat would). In each pair the attacker sends
ServerboundAttackPacket at its victim twice a second (`--attack`) from:
- 3 blocks (survival): hits;
- 6 blocks (survival): hits; the victim's box is 5.7 from the eyes, within vanilla's 3 + 3;
- 7 blocks (survival): refused (6.7 from the eyes);
- 50 blocks (survival): refused;
- 7 blocks (creative, 5 + 3): hits;
- 9 blocks (creative): refused.
A hit shows in the victim's health (SetHealth, in the bots' report). The server runs in a fresh folder with
fresh HORIZON_HOME and XDG folders (lab.game_env), on a free port. Build first:
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-bots
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'survival',
'difficulty': 'easy',
'pvp': 'true',
'spawn-protection': '0',
'view-distance': '3',
'simulation-distance': '3',
}
# (distance, attacker's game mode, hits)
CASES = [
(3, 'survival', True),
(6, 'survival', True),
(7, 'survival', False),
(50, 'survival', False),
(7, 'creative', True),
(9, 'creative', False),
]
# A flat world's grass top is at y = -61.
GROUND = -60
SECONDS = 16
class Failed(Exception):
pass
def bot(bins, port, out, name, *extra):
report = out / f'{name}.json'
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', 'stand', '--seconds', str(SECONDS), '--name', name, '--report', report, *extra],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=SECONDS + 180)
return json.loads(report.read_text())['per_bot'][0]
def run(bins, folder, out):
folder.mkdir(parents=True)
port = lab.free_port()
props = dict(PROPERTIES, **{'server-port': str(port)})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
server = Server(command, folder, out / 'server.log')
try:
server.wait_for(DONE, 300)
with ThreadPoolExecutor(2 * len(CASES)) as pool:
jobs = []
for i, _ in enumerate(CASES):
jobs.append((pool.submit(bot, bins, port, out, f'V{i}'),
pool.submit(bot, bins, port, out, f'A{i}', '--attack', f'V{i}0',
'--attack-after', '6')))
# Bots are named PREFIX0: place each pair once both are in, side by side first, so the
# attacker sees its victim (AddEntity gives it the id a cheat would have kept), then apart.
for i, (_, mode, _) in enumerate(CASES):
for name in (f'V{i}0', f'A{i}0'):
server.wait_for(rf'{name} joined the game', 60)
z = 200 * i + 0.5
server.console(f'gamemode survival V{i}0')
server.console(f'gamemode {mode} A{i}0')
server.console(f'tp V{i}0 0.5 {GROUND} {z}')
server.console(f'tp A{i}0 1.5 {GROUND} {z}')
time.sleep(2)
for i, (distance, _, _) in enumerate(CASES):
server.console(f'tp A{i}0 {0.5 + distance} {GROUND} {200 * i + 0.5}')
results = [(v.result(), a.result()) for v, a in jobs]
finally:
server.stop()
failed = []
for (distance, mode, hits), (victim, attacker) in zip(CASES, results):
health = victim['health']
hit = health is not None and health < 20
line = (f'{mode} attacker at {distance} blocks: {attacker["attacks"]} attacks, victim health {health} '
f'({"hit" if hit else "refused"}, expected {"hit" if hits else "refused"})')
print(line)
if attacker['attacks'] == 0 or health is None:
failed.append(f'{line}: the bots never fought ({attacker["error"]}, {victim["error"]})')
elif hit != hits:
failed.append(line)
if failed:
raise Failed('\n'.join(failed))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
parser.add_argument('--out', default=str(lab.LOCAL / 'server-reach-e2e'))
args = parser.parse_args()
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
os.environ.update(lab.game_env())
with tempfile.TemporaryDirectory(prefix='server-reach-e2e-') as scratch:
try:
run(bins, Path(scratch) / 'server', out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_reach_e2e failed (logs in {out}):\n{e}')
print('server_reach_e2e: every attack within vanilla\'s range hit, every one beyond it was refused')
if __name__ == '__main__':
main()