mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 13:03:44 -10:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5528ef25 | ||
|
|
f4c1010c64 | ||
|
|
edbeffbcf3 | ||
|
|
8a2e5cb55f | ||
|
|
1c7eb12800 | ||
|
|
02c48952b8 | ||
|
|
0ddaf69abc | ||
|
|
0e060bb2ac | ||
|
|
754440dd55 | ||
|
|
cd79f2e326 | ||
|
|
b4ad459449 | ||
|
|
a3726f4bf8 | ||
|
|
d17163d417 | ||
|
|
a83ac6ac1c | ||
|
|
701168f1e5 | ||
|
|
15a18c3a5e | ||
|
|
7af000900b | ||
|
|
f7ce36c8e1 | ||
|
|
f6154f9ebb | ||
|
|
f5eb5a18f3 | ||
|
|
71e6b60d96 | ||
|
|
f533c3af7d | ||
|
|
25e03754e2 | ||
|
|
00c9b04674 | ||
|
|
20703366f9 | ||
|
|
d7cb3537ea | ||
|
|
59e71f00ef | ||
|
|
fcf76afc05 | ||
|
|
b12a499a9a | ||
|
|
59526900a0 | ||
|
|
4783b8d552 | ||
|
|
ab6851d5a8 | ||
|
|
a03ce15c4e | ||
|
|
d60c8d4e0f | ||
|
|
152a0db8c3 | ||
|
|
b4a7018864 | ||
|
|
c7e33a9163 | ||
|
|
7a4f7d5c99 | ||
|
|
c9f49f3b54 | ||
|
|
8e93b54a41 | ||
|
|
9e50f5618c | ||
|
|
ce5086e2d3 | ||
|
|
9ab3cee2e8 | ||
|
|
ff8bd6a91f | ||
|
|
9c825e67df | ||
|
|
00448b2ea0 | ||
|
|
81410769f5 | ||
|
|
1df5237769 | ||
|
|
9ca78f1309 | ||
|
|
e37351ed75 | ||
|
|
0d6db579ec | ||
|
|
e749114173 | ||
|
|
05bc171f13 | ||
|
|
b7d754a60a | ||
|
|
1c8532cbe1 | ||
|
|
ed6d97cbbb | ||
|
|
d52d37f944 | ||
|
|
d39eb5d638 | ||
|
|
91352551d6 | ||
|
|
848c0b10d4 | ||
|
|
e23b4b7d8f | ||
|
|
396f456556 | ||
|
|
e0cb05bfce | ||
|
|
6f6650a68f | ||
|
|
768d20d998 | ||
|
|
b070f41dda | ||
|
|
8f376a4712 | ||
|
|
63db88b844 | ||
|
|
076c41fc27 | ||
|
|
3f1a45313e | ||
|
|
9d0473d748 | ||
|
|
f6a757ac2e | ||
|
|
ca9195fb02 | ||
|
|
c1c646a326 | ||
|
|
aa906ecbe7 | ||
|
|
eeb1d3773d | ||
|
|
bdc422ad8c | ||
|
|
91c77af9ae | ||
|
|
4ee9816b82 | ||
|
|
4d3ee1ace0 | ||
|
|
11d1936a5e | ||
|
|
3ecec2ca7b | ||
|
|
94a93ab7aa | ||
|
|
8a806d23e0 | ||
|
|
85dbd835d6 | ||
|
|
fd21d6ee82 | ||
|
|
c274dad88d | ||
|
|
8a196eeb77 | ||
|
|
1e14e33704 | ||
|
|
7ee787f8de | ||
|
|
737d74f592 | ||
|
|
1a37932efd | ||
|
|
5866611176 | ||
|
|
fe8c64382d | ||
|
|
71571ed2cc | ||
|
|
5f7338178c | ||
|
|
1c27a0c66d | ||
|
|
cfcd6c9112 | ||
|
|
c172ab7dae | ||
|
|
cf5290e835 | ||
|
|
a1dc00d466 | ||
|
|
7090cd2d38 | ||
|
|
66cc52fd9d | ||
|
|
adc6e8626e | ||
|
|
3bf35d81c8 | ||
|
|
01c6de15a3 | ||
|
|
37f449f91f | ||
|
|
ccb85be48e | ||
|
|
24c323960e | ||
|
|
df440f90b2 | ||
|
|
791ac3d928 | ||
|
|
6b01498127 | ||
|
|
3b3560d71d | ||
|
|
a7771347e6 |
No files matched your search
+4
-4
@@ -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>
|
||||
|
||||
|
||||
@@ -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("-") {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrassModifier {
|
||||
@@ -133,10 +138,27 @@ impl BiomeInfo {
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
|
||||
/// file order, and keep their ids whatever a world's data packs do; a pack's
|
||||
/// new biomes follow them (`with_packs`). Those are the client's own ids:
|
||||
/// the network registry's are `registry_order`'s.
|
||||
#[derive(Clone)]
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
info_noise: SimplexNoise,
|
||||
colormaps: Arc<Colormaps>,
|
||||
/// The built-in biomes, the first of `list` unless packs replaced them.
|
||||
builtin: Arc<Vec<BiomeInfo>>,
|
||||
/// The data packs' biome files (new biomes and replaced built-in ones),
|
||||
/// for the tables that read more of a biome than `BiomeInfo` keeps.
|
||||
packs: BTreeMap<String, Arc<Value>>,
|
||||
}
|
||||
/// The grass, foliage and dry foliage colormaps.
|
||||
struct Colormaps {
|
||||
grass: Vec<u32>,
|
||||
foliage: Vec<u32>,
|
||||
dry: Vec<u32>,
|
||||
}
|
||||
fn color(v: &Value) -> Option<u32> {
|
||||
match v {
|
||||
@@ -169,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
|
||||
let index = (y << 8 | x) as usize;
|
||||
map.get(index).copied().unwrap_or(fallback)
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
|
||||
/// leaves out sampled from the colormaps.
|
||||
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Identifier.compareTo`: the path first, then the namespace.
|
||||
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
(path, ns)
|
||||
}
|
||||
split(a).cmp(&split(b))
|
||||
}
|
||||
impl Biomes {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let grass = colormap(root, "grass")?;
|
||||
let foliage = colormap(root, "foliage")?;
|
||||
let dry = colormap(root, "dry_foliage")?;
|
||||
let maps = Colormaps {
|
||||
grass: colormap(root, "grass")?,
|
||||
foliage: colormap(root, "foliage")?,
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -189,70 +290,7 @@ impl Biomes {
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
|
||||
.unwrap_or(0x050533),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
});
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
@@ -260,11 +298,65 @@ impl Biomes {
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
list,
|
||||
index,
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
})
|
||||
}
|
||||
/// The built-in biomes with a world's data packs' biome files (id →
|
||||
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
|
||||
/// for a built-in biome replaces it, keeping its id, and new biomes join
|
||||
/// after the built-in ones, in registry order. Starts from the built-in
|
||||
/// table whatever packs `self` has.
|
||||
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
|
||||
let mut out = self.builtin_only();
|
||||
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
|
||||
files.sort_by(|a, b| identifier_order(a.0, b.0));
|
||||
for (name, json) in files {
|
||||
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
|
||||
match out.index.get(name) {
|
||||
Some(&id) => out.list[usize::from(id)] = info,
|
||||
None if out.list.len() < usize::from(u16::MAX) => {
|
||||
out.index.insert(name.to_owned(), out.list.len() as u16);
|
||||
out.list.push(info);
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The built-in biomes alone: this table without what packs gave it.
|
||||
pub fn builtin_only(&self) -> Self {
|
||||
let mut out = self.clone();
|
||||
if self.packs.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.list = self.builtin.to_vec();
|
||||
let builtin = out.list.len();
|
||||
out.index.retain(|_, id| usize::from(*id) < builtin);
|
||||
out.packs.clear();
|
||||
out
|
||||
}
|
||||
/// The data pack file a world's biome `name` came from, if a pack added
|
||||
/// or replaced it.
|
||||
pub fn pack_file(&self, name: &str) -> Option<&Value> {
|
||||
self.packs.get(name).map(|v| &**v)
|
||||
}
|
||||
/// Whether data packs added or replaced any biome.
|
||||
pub fn has_packs(&self) -> bool {
|
||||
!self.packs.is_empty()
|
||||
}
|
||||
/// The biome names in the network registry's order: `RegistryDataLoader`
|
||||
/// sorts a registry's files by `Identifier` (path, then namespace).
|
||||
pub fn registry_order(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort_by(|a, b| identifier_order(a, b));
|
||||
names
|
||||
}
|
||||
pub fn id(&self, name: &str) -> u16 {
|
||||
self.index
|
||||
.get(name)
|
||||
@@ -360,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
|
||||
result.unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A built-in table of `names` (no colormaps: sampled colours fall back).
|
||||
fn builtin(names: &[&str]) -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<BiomeInfo> = names
|
||||
.iter()
|
||||
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
|
||||
.collect();
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_order_is_path_then_namespace() {
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
|
||||
assert_eq!(identifier_order("z:a", "a:b"), Less);
|
||||
assert_eq!(identifier_order("b:same", "a:same"), Greater);
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
|
||||
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
|
||||
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
|
||||
let hot = json!({"effects": {"grass_color": 0x123456}});
|
||||
let files = [
|
||||
("pack:zeta", &hot),
|
||||
("minecraft:molten_peaks", &hot),
|
||||
("minecraft:river", &red),
|
||||
];
|
||||
let world = base.with_packs(files);
|
||||
// The built-in ids stay; the river takes the pack's values.
|
||||
assert_eq!(world.id("minecraft:plains"), 1);
|
||||
assert_eq!(world.id("minecraft:river"), 2);
|
||||
assert_eq!(world.get(2).water, 0xFF0000);
|
||||
assert_eq!(world.get(2).temperature, 2.0);
|
||||
// New biomes follow, in registry order.
|
||||
assert_eq!(world.id("minecraft:molten_peaks"), 3);
|
||||
assert_eq!(world.id("pack:zeta"), 4);
|
||||
assert_eq!(world.get(3).grass, 0x123456);
|
||||
assert!(world.pack_file("minecraft:river").is_some());
|
||||
assert!(world.pack_file("minecraft:plains").is_none());
|
||||
// The network registry sorts every name by path, then namespace.
|
||||
assert_eq!(
|
||||
world.registry_order(),
|
||||
[
|
||||
"minecraft:meadow",
|
||||
"minecraft:molten_peaks",
|
||||
"minecraft:plains",
|
||||
"minecraft:river",
|
||||
"pack:zeta"
|
||||
]
|
||||
);
|
||||
// Another world starts again from the built-in biomes.
|
||||
let other = world.with_packs([]);
|
||||
assert_eq!(other.list.len(), 3);
|
||||
assert!(!other.contains("minecraft:molten_peaks"));
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -296,8 +296,9 @@ fn memory() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
|
||||
pub fn system_details() -> Vec<(String, String)> {
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
|
||||
/// server renders nothing: it leaves out the render settings it read from options.txt.
|
||||
pub fn system_details(side: &str) -> Vec<(String, String)> {
|
||||
let mut out = vec![
|
||||
("Horizon Version".to_owned(), version()),
|
||||
(
|
||||
@@ -308,13 +309,21 @@ pub fn system_details() -> Vec<(String, String)> {
|
||||
("CPU".to_owned(), cpu()),
|
||||
("Memory".to_owned(), memory()),
|
||||
];
|
||||
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
|
||||
out.extend(
|
||||
details()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| side != "server" || *k != "Render Settings")
|
||||
.map(|(k, v)| (k.to_owned(), v)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
|
||||
/// and the dedicated server's "Server thread".
|
||||
fn thread_name(side: &str) -> String {
|
||||
let current = std::thread::current();
|
||||
match current.name() {
|
||||
Some("main") | None if side == "server" => "Server thread".into(),
|
||||
Some("main") | None => "Render thread".into(),
|
||||
Some(name) => name.into(),
|
||||
}
|
||||
@@ -369,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -480,9 +489,9 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
}
|
||||
}
|
||||
@@ -503,7 +512,7 @@ pub fn install(home: PathBuf, side: &'static str) {
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
thread_name(),
|
||||
thread_name(side),
|
||||
panic_message(info)
|
||||
);
|
||||
}
|
||||
@@ -552,9 +561,9 @@ pub fn report_error(
|
||||
description: DebugCrash::MESSAGE.into(),
|
||||
error: DebugCrash::MESSAGE.into(),
|
||||
stack: debug.stack.clone(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
);
|
||||
@@ -572,9 +581,9 @@ pub fn report_error(
|
||||
description: "Fatal error".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -672,6 +681,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
|
||||
let unnamed = |side: &'static str| {
|
||||
std::thread::spawn(move || thread_name(side))
|
||||
.join()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(unnamed("server"), "Server thread");
|
||||
assert_eq!(unnamed("client"), "Render thread");
|
||||
set_detail("Render Settings", "render distance 12");
|
||||
let has = |side| {
|
||||
system_details(side)
|
||||
.iter()
|
||||
.any(|(k, _)| k == "Render Settings")
|
||||
};
|
||||
assert!(has("client"));
|
||||
assert!(!has("server"));
|
||||
clear_detail("Render Settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_start_at_the_panicking_frame() {
|
||||
let bt = format!(
|
||||
|
||||
@@ -131,8 +131,20 @@ impl Style {
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
value,
|
||||
hover && active,
|
||||
active,
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -855,6 +855,7 @@ impl GraphicsUi {
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
|
||||
@@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
|
||||
/// it names other dimensions or the frames run along a row.
|
||||
fn first_frame(
|
||||
(w, h, pixels): (u32, u32, Vec<u8>),
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
|
||||
}
|
||||
_ => (w, h, pixels),
|
||||
}
|
||||
}
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
@@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
/// such as other namespaces' item textures; and the animated ones' names.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
@@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let mut animated = false;
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
(w, w, p[..(w * w * 4) as usize].to_vec())
|
||||
// Its path under `textures/` in any namespace (a server
|
||||
// pack's `lobby:item/...` as well as vanilla's).
|
||||
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
|
||||
let file = atlas_sources::texture_path(root, texture, "textures");
|
||||
decode(&file).ok().map(|image| {
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
(w, h, p)
|
||||
image
|
||||
}
|
||||
})
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
out.sort_unstable_by_key(|(order, ..)| *order);
|
||||
let animated = out
|
||||
.iter()
|
||||
.filter(|(.., animated)| *animated)
|
||||
.map(|(_, image, _)| image.0.clone())
|
||||
.collect();
|
||||
(
|
||||
out.into_iter().map(|(_, image, _)| image).collect(),
|
||||
animated,
|
||||
)
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
@@ -425,37 +467,15 @@ impl HudAtlas {
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (mut w, mut h, pixels) = decode(path).ok()?;
|
||||
let image = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = mcmeta
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.and_then(|meta| {
|
||||
meta.get("animation")
|
||||
.and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok())
|
||||
});
|
||||
// An animated strip keeps its first frame: the region the mcmeta's
|
||||
// frame size picks (`AnimationMetadataSection.frameSize`), which is
|
||||
// the top-left square unless it names other dimensions or the
|
||||
// frames run along a row.
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames {
|
||||
let stride = w;
|
||||
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(w, h) = (side, side);
|
||||
strip_frame(&pixels, stride, 0, 0, fw, fh)
|
||||
}
|
||||
_ => pixels,
|
||||
}
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
pixels
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
@@ -507,7 +527,9 @@ impl HudAtlas {
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -579,7 +601,15 @@ impl HudAtlas {
|
||||
let Some(rect) = sprites.get(&name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
|
||||
// Another namespace's sprite is named with it (`lobby:item/...`).
|
||||
let id = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let Ok(layer) = loaded.insert(root, &id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
|
||||
else {
|
||||
continue;
|
||||
@@ -1890,6 +1920,39 @@ mod tests {
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let item = root.join("assets/lobby/textures/item/npc");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A server pack's NPC skin: two 16x16 frames, green then red.
|
||||
write_strip(
|
||||
&item.join("skin.png"),
|
||||
16,
|
||||
32,
|
||||
&[[0, 255, 0, 255], [255, 0, 0, 255]],
|
||||
Some(r#"{"animation": {"frametime": 3}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["lobby:item/npc/skin"];
|
||||
// One frame, not the whole strip, which a model's UVs would span.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
|
||||
@@ -2551,6 +2551,7 @@ impl OptionsUi {
|
||||
shown,
|
||||
hover || dragging,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
@@ -3672,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();
|
||||
|
||||
@@ -278,7 +278,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
|
||||
};
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
|
||||
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
@@ -938,6 +939,25 @@ impl SelectionModel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn mcmeta_with_utf8_bom_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut meta = b"\xEF\xBB\xBF".to_vec();
|
||||
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
|
||||
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
// Without a BOM the same JSON still reads; other leading bytes still fail.
|
||||
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
#[test]
|
||||
fn compatibility_ranges() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
assert!(compatible(&v(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -390,6 +390,7 @@ pub fn slider(
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
let kind = crate::widgets::Kind::Slider {
|
||||
value,
|
||||
@@ -401,17 +402,8 @@ pub fn slider(
|
||||
// A controller's D-pad can focus it (Horizon's controller support).
|
||||
crate::widgets::target_as([x, y, w, 20.0], kind);
|
||||
}
|
||||
let track = if hover && active {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let (track, handle) = slider_sprites(hover, active, dragging);
|
||||
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -429,6 +421,21 @@ pub fn slider(
|
||||
};
|
||||
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
|
||||
}
|
||||
/// Track and handle sprites: the track outline shows only while the slider is
|
||||
/// dragged, hovering highlights just the handle.
|
||||
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
|
||||
let track = if dragging {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
(track, handle)
|
||||
}
|
||||
/// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse).
|
||||
pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 {
|
||||
((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0)
|
||||
@@ -1344,6 +1351,18 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(true, true, true);
|
||||
assert_eq!(track, "gui/sprites/widget/slider_highlighted");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(false, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle");
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
|
||||
@@ -65,7 +65,7 @@ unicode-bidi = "0.3.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Non-player entities simulated on the client world. Dropped items follow
|
||||
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
|
||||
//! a ground bounce of -0.5, water buoyancy, pickup delay and 6000-tick despawn.
|
||||
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -237,6 +238,8 @@ impl ItemEntity {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
|
||||
self.apply_honey_slide(world, facts);
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -260,6 +263,51 @@ impl ItemEntity {
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
|
||||
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
|
||||
/// entity event 53 are skipped (`doesEntityDoHoneyBlockSlideEffects` is
|
||||
/// false for an item).
|
||||
fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
|
||||
let b = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if &*facts.get(world.block(x, y, z)).name == "minecraft:honey_block" {
|
||||
self.honey_block_inside([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `HoneyBlock.isSlidingDown` then `doSlideMovement`. `getOldDeltaY` /
|
||||
/// `getNewDeltaY` wrap the stored velocity the same way they do for every
|
||||
/// entity (`vy / 0.98F + 0.08`).
|
||||
pub(crate) fn honey_block_inside(&mut self, pos: [i32; 3]) {
|
||||
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
|
||||
let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32);
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
if self.on_ground
|
||||
|| self.position.y > y + 0.9375 - 1.0e-7
|
||||
|| old_delta_y(self.velocity.y) >= -0.08
|
||||
{
|
||||
return;
|
||||
}
|
||||
let dx = (x + 0.5 - self.position.x).abs();
|
||||
let dz = (z + 0.5 - self.position.z).abs();
|
||||
let overlap = 0.4375 + Self::WIDTH / 2.0;
|
||||
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
|
||||
return;
|
||||
}
|
||||
let v = self.velocity;
|
||||
let old = old_delta_y(v.y);
|
||||
self.velocity = if old < -0.13 {
|
||||
let factor = -0.05 / old;
|
||||
Vec3::new(v.x * factor, new_delta_y(-0.05), v.z * factor)
|
||||
} else {
|
||||
Vec3::new(v.x, new_delta_y(-0.05), v.z)
|
||||
};
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
@@ -1272,3 +1320,89 @@ mod merge_tests {
|
||||
assert_eq!(items[1].stack.count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from("minecraft:stone"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::new(vx, vy, vz),
|
||||
on_ground: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 10,
|
||||
}
|
||||
}
|
||||
|
||||
fn old_delta_y(dy: f64) -> f64 {
|
||||
dy / f64::from(0.98_f32) + 0.08
|
||||
}
|
||||
fn new_delta_y(dy: f64) -> f64 {
|
||||
(dy - 0.08) * f64::from(0.98_f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_falling_item_beside_honey_slides_down() {
|
||||
// Item width 0.25: overlap 0.5625. Honey at (0, 10, 0); centre 0.5.
|
||||
// x = -0.0625 is against the west face (inset 1/16).
|
||||
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.12, -0.20, 0.04);
|
||||
assert!(old_delta_y(-0.20) < -0.08);
|
||||
assert!(old_delta_y(-0.20) >= -0.13);
|
||||
item.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(item.velocity.x, 0.12);
|
||||
assert_eq!(item.velocity.z, 0.04);
|
||||
assert_eq!(item.velocity.y, new_delta_y(-0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fast_fall_scales_the_horizontal_speed() {
|
||||
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.2, -0.30, -0.1);
|
||||
let old = old_delta_y(-0.30);
|
||||
assert!(old < -0.13);
|
||||
item.honey_block_inside([0, 10, 0]);
|
||||
let factor = -0.05 / old;
|
||||
assert_eq!(item.velocity.x, 0.2 * factor);
|
||||
assert_eq!(item.velocity.z, -0.1 * factor);
|
||||
assert_eq!(item.velocity.y, new_delta_y(-0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_on_the_ground_or_on_top_does_not_slide() {
|
||||
let mut grounded = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.20, 0.0);
|
||||
grounded.on_ground = true;
|
||||
grounded.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(grounded.velocity.y, -0.20);
|
||||
|
||||
let mut on_top = falling_item(0.5, 10.9375, 0.5, 0.0, -0.20, 0.0);
|
||||
on_top.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(on_top.velocity.y, -0.20, "standing on the honey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_or_slow_item_does_not_slide() {
|
||||
let mut centred = falling_item(0.5, 10.4, 0.5, 0.0, -0.20, 0.0);
|
||||
centred.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(centred.velocity.y, -0.20, "inside, not on a side");
|
||||
|
||||
let mut slow = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.04, 0.0);
|
||||
assert!(old_delta_y(-0.04) >= -0.08);
|
||||
slow.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(slow.velocity.y, -0.04);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -4951,6 +4966,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 +5004,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;
|
||||
@@ -5785,10 +5814,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);
|
||||
@@ -8871,6 +8900,15 @@ impl Game<'_> {
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
&|state| {
|
||||
self.res
|
||||
.collision()
|
||||
.cached_shapes()
|
||||
.cached_shape(state)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|shape| shape.bounds())
|
||||
},
|
||||
sprite,
|
||||
camera,
|
||||
pos,
|
||||
@@ -9328,7 +9366,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
|
||||
@@ -9558,11 +9596,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
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -162,6 +163,61 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
ids
|
||||
}
|
||||
|
||||
/// Every block's properties and their values, as `/setblock` completes them.
|
||||
type BlockProperties = BTreeMap<String, Vec<(String, Vec<String>)>>;
|
||||
static BLOCK_PROPERTIES: Mutex<Vec<(usize, Arc<BlockProperties>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// `BlockProperties` of a block registry, listed once: a registry never
|
||||
/// changes once built, and walking its tens of thousands of states took
|
||||
/// about 30 ms of every world opening.
|
||||
fn block_properties(registry: &'static BlockRegistry) -> Arc<BlockProperties> {
|
||||
let key = std::ptr::from_ref(registry) as usize;
|
||||
let mut cache = BLOCK_PROPERTIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, properties)) = cache.iter().find(|(cached, _)| *cached == key) {
|
||||
return properties.clone();
|
||||
}
|
||||
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
|
||||
for id in 0..registry.len() {
|
||||
let Some(state) = registry.state(BlockStateId(id as u32)) else {
|
||||
continue;
|
||||
};
|
||||
let props = blocks.entry(state.name.clone()).or_default();
|
||||
for (k, v) in &state.properties {
|
||||
let values = props.entry(k.clone()).or_default();
|
||||
if !values.contains(v) {
|
||||
values.push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let properties: Arc<BlockProperties> = Arc::new(
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into_iter().collect()))
|
||||
.collect(),
|
||||
);
|
||||
cache.push((key, properties.clone()));
|
||||
properties
|
||||
}
|
||||
|
||||
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<serde_json::Value>>)>> =
|
||||
Mutex::new(Vec::new());
|
||||
|
||||
/// A folder's `command-registries.json` (vanilla's built-in registries),
|
||||
/// read once like the resource ids: parsing its 1.6 MB again took about
|
||||
/// 15 ms of every world opening.
|
||||
fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
|
||||
let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) {
|
||||
return export.clone();
|
||||
}
|
||||
let export = std::fs::read(dir.join("command-registries.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.map(Arc::new);
|
||||
cache.push((dir.to_owned(), export.clone()));
|
||||
export
|
||||
}
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
ids_in(root, extension, "minecraft")
|
||||
@@ -306,25 +362,7 @@ impl Game<'_> {
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect();
|
||||
reg.block_tags.sort();
|
||||
let registry = self.pack.assets.registry;
|
||||
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
|
||||
for id in 0..registry.len() {
|
||||
let Some(state) = registry.state(horizon_world::registry::BlockStateId(id as u32))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let props = blocks.entry(state.name.clone()).or_default();
|
||||
for (k, v) in &state.properties {
|
||||
let values = props.entry(k.clone()).or_default();
|
||||
if !values.contains(v) {
|
||||
values.push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
reg.blocks = blocks
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into_iter().collect()))
|
||||
.collect();
|
||||
reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry));
|
||||
let mut summonable: Vec<String> = self
|
||||
.res
|
||||
.mob_types()
|
||||
@@ -438,10 +476,8 @@ impl Game<'_> {
|
||||
}
|
||||
// Vanilla's built-in registries (mob effects, components, dialogs...).
|
||||
for dir in [&self.options.resources, &self.ui.vanilla_root] {
|
||||
if let Ok(bytes) = std::fs::read(dir.join("command-registries.json"))
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
self.ui.registries.fill_from_export(&v);
|
||||
if let Some(export) = command_export(dir) {
|
||||
self.ui.registries.fill_from_export(&export);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1395,4 +1431,18 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The export is read once per folder; a folder without one stays without.
|
||||
#[test]
|
||||
fn command_exports_are_read_once_per_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap();
|
||||
let first = command_export(&dir).unwrap();
|
||||
assert_eq!(first["effects"][0], "a");
|
||||
std::fs::write(dir.join("command-registries.json"), "{}").unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap()));
|
||||
assert!(command_export(&dir.join("missing")).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ impl Game<'_> {
|
||||
self.world.chest_openers.remove(&key);
|
||||
}
|
||||
self.block_entity_event(chest, 1, count.max(0));
|
||||
// The guests get the same `ClientboundBlockEventPacket` (the lid).
|
||||
self.lan_block_event(chest, 1, count.max(0));
|
||||
}
|
||||
/// A copper golem took from or filled a chest slot.
|
||||
pub(super) fn golem_chest_slot(
|
||||
@@ -100,6 +102,7 @@ impl Game<'_> {
|
||||
o.0 = count;
|
||||
}
|
||||
self.block_entity_event(p, 1, count);
|
||||
self.lan_block_event(p, 1, count);
|
||||
}
|
||||
}
|
||||
/// `ChestBlockEntity.playSound` for a chest the recheck closed.
|
||||
|
||||
@@ -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);
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -850,6 +852,7 @@ impl Game<'_> {
|
||||
self.tick_sniffer_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
self.sulfur_cube_power_priming();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
|
||||
@@ -67,6 +67,9 @@ impl Game<'_> {
|
||||
/// own handling. Returns true when it is used up (a debug combination,
|
||||
/// F3 itself, F3+Escape).
|
||||
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
||||
if self.title.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
@@ -275,8 +278,8 @@ impl Game<'_> {
|
||||
/// entries, over everything the frame drew (menus too), at the debug GUI
|
||||
/// scale. F1 hides it unless a screen is open.
|
||||
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.title.is_some() || self.screen != Screen::Playing;
|
||||
if !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
return;
|
||||
}
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
|
||||
@@ -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) {
|
||||
@@ -805,6 +815,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;
|
||||
@@ -847,7 +863,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);
|
||||
}
|
||||
@@ -1045,6 +1071,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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,7 @@ impl Game<'_> {
|
||||
position,
|
||||
hover || held,
|
||||
true,
|
||||
held,
|
||||
);
|
||||
}
|
||||
Part::Item { body, rect } => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -138,7 +138,15 @@ impl Game<'_> {
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
})
|
||||
});
|
||||
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
|
||||
// placed on the top face of the highest block or under the lowest one.
|
||||
if !(horizon_client::chunk::MIN_Y
|
||||
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
|
||||
.contains(&pos[1])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let frame = ItemFrame {
|
||||
id: self.next_entity + 1,
|
||||
pos,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! 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 +47,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 +71,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 = 13;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -326,8 +346,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 +409,137 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,11 @@ use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
|
||||
};
|
||||
use std::{io, path::Path, sync::Arc};
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
|
||||
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
|
||||
@@ -137,8 +141,25 @@ fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag:
|
||||
fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool {
|
||||
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
/// The player's model type for each resources root it was asked for.
|
||||
static PLAYER_TYPES: Mutex<Vec<(PathBuf, Option<Arc<MobType>>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The player's model type (`PlayerModel` with the default skin), read once
|
||||
/// per resources root: `entity-models.json` doesn't change while the game
|
||||
/// runs, and parsing it again held every world join for about 100 ms. The
|
||||
/// world resources read it as the game starts (`load_world_rest`), so a join
|
||||
/// finds it; a resource pack change's new root is read once.
|
||||
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
let mut cache = PLAYER_TYPES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, kind)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return kind.clone();
|
||||
}
|
||||
let kind = load_player_type(root);
|
||||
cache.push((root.to_owned(), kind.clone()));
|
||||
kind
|
||||
}
|
||||
/// `player_type` from the document: the model and its layers from one parse.
|
||||
fn load_player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
|
||||
models.model("minecraft:player#main").map(|model| {
|
||||
Arc::new(MobType {
|
||||
|
||||
@@ -127,6 +127,123 @@ impl Kind {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CyclingSlotBackground`: the icons an empty slot cycles through, one every
|
||||
/// 30 ticks, the next fading in over 4 (`CyclingSlotBackground.tick` in
|
||||
/// `advance`, `CyclingSlotBackground.extractRenderState` in `shown`).
|
||||
#[derive(Default)]
|
||||
struct CyclingSlot {
|
||||
icons: Vec<&'static str>,
|
||||
tick: u32,
|
||||
index: usize,
|
||||
/// The client tick it was last advanced to.
|
||||
at: Option<u64>,
|
||||
}
|
||||
impl CyclingSlot {
|
||||
const CHANGE_TICKS: u32 = 30;
|
||||
const FADE_TICKS: f32 = 4.0;
|
||||
/// `CyclingSlotBackground.tick(newIcons)` for each client tick since the last call.
|
||||
fn advance(&mut self, now: u64, icons: &[&'static str]) {
|
||||
let ticks = self.at.map_or(0, |at| now.saturating_sub(at).min(600));
|
||||
self.at = Some(now);
|
||||
if self.icons != icons {
|
||||
self.icons = icons.to_vec();
|
||||
self.index = 0;
|
||||
}
|
||||
for _ in 0..ticks {
|
||||
if !self.icons.is_empty() {
|
||||
self.tick += 1;
|
||||
if self.tick.is_multiple_of(Self::CHANGE_TICKS) {
|
||||
self.index = (self.index + 1) % self.icons.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `CyclingSlotBackground.extractRenderState` draws for an empty slot: the icon before
|
||||
/// this one fading out while this one fades in, with their alphas.
|
||||
fn shown(&self, partial: f32) -> Vec<(&'static str, f32)> {
|
||||
if self.icons.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let fading = self.icons.len() > 1 && self.tick >= Self::CHANGE_TICKS;
|
||||
let alpha = if fading {
|
||||
(((self.tick % Self::CHANGE_TICKS) as f32 + partial).min(Self::FADE_TICKS))
|
||||
/ Self::FADE_TICKS
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut shown = Vec::new();
|
||||
if alpha < 1.0 {
|
||||
let previous = (self.index + self.icons.len() - 1) % self.icons.len();
|
||||
shown.push((self.icons[previous], 1.0 - alpha));
|
||||
}
|
||||
shown.push((self.icons[self.index], alpha));
|
||||
shown
|
||||
}
|
||||
}
|
||||
/// `SmithingScreen`'s `EMPTY_SLOT_SMITHING_TEMPLATES`.
|
||||
const EMPTY_TEMPLATE_ICONS: [&str; 2] = [
|
||||
"smithing_template_armor_trim",
|
||||
"smithing_template_netherite_upgrade",
|
||||
];
|
||||
/// The base and additions slot icons of the template in the smithing table:
|
||||
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
|
||||
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
|
||||
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
|
||||
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
|
||||
/// `getAdditionalSlotEmptyIcons` give them.
|
||||
fn smithing_slot_icons(
|
||||
template: Option<&str>,
|
||||
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
|
||||
let none = ([""; 11], 0, [""; 7], 0);
|
||||
match template.map(|t| t.trim_start_matches("minecraft:")) {
|
||||
Some("netherite_upgrade_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"sword",
|
||||
"chestplate",
|
||||
"pickaxe",
|
||||
"leggings",
|
||||
"axe",
|
||||
"boots",
|
||||
"hoe",
|
||||
"shovel",
|
||||
"nautilus_armor",
|
||||
"spear",
|
||||
],
|
||||
11,
|
||||
["ingot", "", "", "", "", "", ""],
|
||||
1,
|
||||
),
|
||||
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"chestplate",
|
||||
"leggings",
|
||||
"boots",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
4,
|
||||
[
|
||||
"ingot",
|
||||
"redstone_dust",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"diamond",
|
||||
"emerald",
|
||||
"amethyst_shard",
|
||||
],
|
||||
7,
|
||||
),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Station {
|
||||
pub(super) kind: Kind,
|
||||
pub(super) pos: [i32; 3],
|
||||
@@ -161,6 +278,8 @@ pub(super) struct Station {
|
||||
pub(super) beacon_levels: Option<i32>,
|
||||
/// The loom's patterns, selection and scroller (game/loom.rs).
|
||||
pub(super) loom: super::loom::LoomState,
|
||||
/// The smithing table's three `CyclingSlotBackground`s: the template, base and additions slots.
|
||||
cycling: [CyclingSlot; 3],
|
||||
}
|
||||
impl Station {
|
||||
fn new(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
@@ -186,6 +305,7 @@ impl Station {
|
||||
beacon: [None; 2],
|
||||
beacon_levels: None,
|
||||
loom: Default::default(),
|
||||
cycling: Default::default(),
|
||||
}
|
||||
}
|
||||
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
@@ -1221,6 +1341,21 @@ impl Game<'_> {
|
||||
if self.station.is_none() && !brewing {
|
||||
return false;
|
||||
}
|
||||
// `SmithingScreen.containerTick`: the icons of the empty slots follow the template.
|
||||
let (now, partial) = (self.ticks, self.clock.camera_fraction() as f32);
|
||||
if let Some(s) = self.station.as_mut().filter(|s| s.kind == Kind::Smithing) {
|
||||
let template = s.inputs[0].as_ref().map(|t| t.item.clone());
|
||||
let (base, base_len, additions, additions_len) =
|
||||
smithing_slot_icons(template.as_deref());
|
||||
let lists: [&[&'static str]; 3] = [
|
||||
&EMPTY_TEMPLATE_ICONS,
|
||||
&base[..base_len],
|
||||
&additions[..additions_len],
|
||||
];
|
||||
for (cycling, icons) in s.cycling.iter_mut().zip(lists) {
|
||||
cycling.advance(now, icons);
|
||||
}
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
@@ -1609,15 +1744,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Kind::Smithing => {
|
||||
let icons = ["smithing_template_netherite_upgrade", "", ""];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
if s.inputs[i].is_none() && !icon.is_empty() {
|
||||
let (sx, sy) = kind.input_pos(i);
|
||||
batch.sprite(
|
||||
// `CyclingSlotBackground.extractRenderState`: an empty slot shows the
|
||||
// icons of what the template in the table takes.
|
||||
for (i, cycling) in s.cycling.iter().enumerate() {
|
||||
if s.inputs[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = kind.input_pos(i);
|
||||
for (icon, alpha) in cycling.shown(partial) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("gui/sprites/container/slot/{icon}"),
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
16.0,
|
||||
16.0,
|
||||
((alpha.clamp(0.0, 1.0) * 255.0).round() as u32) << 24
|
||||
| 0x00FF_FFFF,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2189,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.
|
||||
@@ -2605,3 +2753,45 @@ mod tests {
|
||||
assert!(!s.stonecutter_scrolls());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycling_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_empty_slot_cycles_its_icons_every_30_ticks_and_fades_the_next_in_over_4() {
|
||||
let icons = ["helmet", "sword", "chestplate"];
|
||||
let mut slot = CyclingSlot::default();
|
||||
slot.advance(100, &icons);
|
||||
// The first icon, fully shown, until the 30th tick.
|
||||
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
|
||||
slot.advance(129, &icons);
|
||||
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
|
||||
// The 30th tick moves to the next icon, which fades in over 4 ticks while the
|
||||
// one before fades out.
|
||||
slot.advance(130, &icons);
|
||||
assert_eq!(slot.shown(0.0), vec![("helmet", 1.0), ("sword", 0.0)]);
|
||||
assert_eq!(slot.shown(2.0), vec![("helmet", 0.5), ("sword", 0.5)]);
|
||||
assert_eq!(slot.shown(4.0), vec![("sword", 1.0)]);
|
||||
// A new list starts again from its first icon, keeping the tick count.
|
||||
slot.advance(131, &["ingot"]);
|
||||
assert_eq!(slot.shown(0.0), vec![("ingot", 1.0)]);
|
||||
// No icons, nothing shown.
|
||||
slot.advance(132, &[]);
|
||||
assert!(slot.shown(0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icons_of_a_smithing_slot_follow_the_template() {
|
||||
let (base, n, additions, m) =
|
||||
smithing_slot_icons(Some("minecraft:netherite_upgrade_smithing_template"));
|
||||
assert_eq!(base[..n].len(), 11);
|
||||
assert_eq!(additions[..m], ["ingot"]);
|
||||
let (base, n, additions, m) =
|
||||
smithing_slot_icons(Some("minecraft:coast_armor_trim_smithing_template"));
|
||||
assert_eq!(base[..n], ["helmet", "chestplate", "leggings", "boots"]);
|
||||
assert_eq!(additions[..m].len(), 7);
|
||||
assert_eq!(smithing_slot_icons(Some("minecraft:stick")).1, 0);
|
||||
assert_eq!(smithing_slot_icons(None).3, 0);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Species, sulfur_cube},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
fn block_transform(base: glam::Mat4, baby: bool) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, -1.0))
|
||||
@@ -150,6 +150,34 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
|
||||
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
|
||||
/// no block has an own signal, so the neighbour signal is all there is).
|
||||
pub(super) fn sulfur_cube_power_priming(&mut self) {
|
||||
if !self.ui.rules.bool("tnt_explodes") {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::SulfurCube
|
||||
|| !sulfur_cube::archetype(m).is_some_and(|a| a.explosion.is_some())
|
||||
|| !m.alive()
|
||||
|| sulfur_cube::primed(m)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let at = BlockPos::new(
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let signal =
|
||||
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at));
|
||||
if signal != 0 {
|
||||
sulfur_cube::prime(&mut self.mobs[i], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SulfurCubeSearchForItemsGoal` and `pickUpItem`: an adult with nothing
|
||||
/// inside hops to a block it can swallow within 8 and takes it in.
|
||||
pub(super) fn sulfur_cube_pickups(&mut self) {
|
||||
|
||||
@@ -223,6 +223,24 @@ impl Game<'_> {
|
||||
((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
}
|
||||
}
|
||||
/// `Display.shouldRenderAtSqrDistance` and, when width and height are set,
|
||||
/// `EntityRenderer.shouldRender` with `Display.getInterpolatedBoundingBox`
|
||||
/// inflated by 0.5. Width or height 0 is `noCulling`: distance only.
|
||||
fn display_in_view(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
origin: glam::DVec3,
|
||||
data: &TextDisplayData,
|
||||
view_scale: f64,
|
||||
) -> bool {
|
||||
let range = f64::from(data.view_range) * 64.0 * view_scale;
|
||||
if text_display::no_culling(data.width, data.height) {
|
||||
return (origin - camera).length_squared() < range * range;
|
||||
}
|
||||
let (lo, hi) = text_display::culling_box(origin, data.width, data.height);
|
||||
self.entity_in_view(camera, origin, range, lo, hi)
|
||||
}
|
||||
|
||||
/// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the
|
||||
/// block model or the item under the display's transformation, lit like
|
||||
/// any entity and never tinted by a hurt overlay.
|
||||
@@ -246,8 +264,7 @@ impl Game<'_> {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range {
|
||||
if !self.display_in_view(camera, position, &d, scale) {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
@@ -295,18 +312,15 @@ impl Game<'_> {
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
self.mark_translucent_entity(normal.len(), "minecraft:text_display");
|
||||
let atlas = &self.gui.atlas;
|
||||
let angles = self.display_camera_angles();
|
||||
let scale = self.view_scale();
|
||||
for display in self.text_displays.values_mut() {
|
||||
if display.shows != Shows::Text {
|
||||
continue;
|
||||
}
|
||||
let background = self.settings.text_background(0.25);
|
||||
let mut displays = std::mem::take(&mut self.text_displays);
|
||||
for display in displays.values_mut().filter(|d| d.shows == Shows::Text) {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
if d.text.is_empty() || !self.display_in_view(camera, position, &d, scale) {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
@@ -315,7 +329,7 @@ impl Game<'_> {
|
||||
});
|
||||
let lines = display
|
||||
.lines
|
||||
.get_or_insert_with(|| Lines::split(&atlas.font, &d.text, d.line_width));
|
||||
.get_or_insert_with(|| Lines::split(&self.gui.atlas.font, &d.text, d.line_width));
|
||||
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* lines.pose();
|
||||
@@ -323,13 +337,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
text_display::draw(
|
||||
&mut batch,
|
||||
atlas,
|
||||
&d,
|
||||
lines,
|
||||
self.settings.text_background(0.25),
|
||||
);
|
||||
text_display::draw(&mut batch, &self.gui.atlas, &d, lines, background);
|
||||
let light = Self::display_light(&self.world, &d, position);
|
||||
// Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET.
|
||||
let through = d.flags & 2 != 0;
|
||||
@@ -358,6 +366,7 @@ impl Game<'_> {
|
||||
self.text_display_vertices += batch.vertices.len() / 6 * 4;
|
||||
self.map_patches.extend(batch.patches);
|
||||
}
|
||||
self.text_displays = displays;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -954,6 +957,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> {
|
||||
@@ -1336,6 +1344,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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)]
|
||||
@@ -173,6 +179,10 @@ pub struct NetConfig {
|
||||
/// sent in configuration when non-empty
|
||||
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
|
||||
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
/// `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,
|
||||
@@ -204,6 +214,7 @@ impl NetConfig {
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
server_links: Vec::new(),
|
||||
horizon_clients_only: false,
|
||||
levels: crate::chunk::LevelShapes::VANILLA,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ fn main() {
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
let usage = error.is::<game::UsageError>();
|
||||
@@ -67,3 +68,39 @@ fn main() {
|
||||
}
|
||||
logging::flush();
|
||||
}
|
||||
|
||||
/// The JVM raises its open-file limit to the hard limit as it starts
|
||||
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
|
||||
/// sockets and region files as the host allows, not the soft default (often
|
||||
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
|
||||
/// JVM does there.
|
||||
fn raise_open_file_limit() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
|
||||
let limit = getrlimit(Resource::Nofile);
|
||||
let hard = if cfg!(target_os = "macos") {
|
||||
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
|
||||
} else {
|
||||
limit.maximum
|
||||
};
|
||||
let raised = match (limit.current, hard) {
|
||||
(Some(soft), Some(hard)) => hard > soft,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if raised {
|
||||
let to = Rlimit {
|
||||
current: hard,
|
||||
maximum: limit.maximum,
|
||||
};
|
||||
match setrlimit(Resource::Nofile, to) {
|
||||
Ok(()) => log::info!(
|
||||
"Open file limit raised from {:?} to {hard:?}",
|
||||
limit.current
|
||||
),
|
||||
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1042,15 +1042,14 @@ fn hostile_to_player(m: &Mob, w: &MobWorld) -> bool {
|
||||
}
|
||||
}
|
||||
pub fn register_piglin(m: &mut Mob) {
|
||||
let crossbow = m.extra.main_hand.as_deref() == Some("minecraft:crossbow");
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Float);
|
||||
g.add(1, Admire);
|
||||
// `PiglinAi.initRetreatActivity`: `SetWalkTargetAwayFrom.entity(AVOID_TARGET, 1.0, 12, true)`.
|
||||
g.add(2, WalkAway::retreat(1.0, 12.0, true));
|
||||
if crossbow {
|
||||
g.add(3, super::illager::crossbow_goal(1.0, 8.0));
|
||||
}
|
||||
// Registered before the spawn weapon is equipped, so the goal checks the
|
||||
// held item itself (it only starts while a crossbow is in the main hand).
|
||||
g.add(3, super::illager::crossbow_goal(1.0, 8.0));
|
||||
// `PiglinAi.initFightActivity`: the spear behaviours come before the melee one.
|
||||
g.add(3, super::spear_use::PiglinSpear::new());
|
||||
g.add(
|
||||
@@ -1860,3 +1859,33 @@ pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
p.xrot("right_hind_leg", (pos + PI).mcos() * 1.2 * sp);
|
||||
p.xrot("left_hind_leg", pos.mcos() * 1.2 * sp);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crossbow_tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn piglin() -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:piglin".into();
|
||||
k.species = Species::Piglin;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// The spawn weapon is equipped after the goals are registered, so the
|
||||
/// crossbow goal must exist on a piglin that holds nothing yet, and the
|
||||
/// melee goal must be off once a crossbow is held.
|
||||
#[test]
|
||||
fn a_piglin_with_a_crossbow_shoots_instead_of_meleeing() {
|
||||
let mut m = piglin();
|
||||
assert!(format!("{:?}", m.ai.goals).contains("CrossbowAttack"));
|
||||
assert!(!super::super::spear_use::holds_non_melee_weapon(&m));
|
||||
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
|
||||
assert!(super::super::spear_use::holds_non_melee_weapon(&m));
|
||||
m.extra.set_main_hand(Some("minecraft:golden_sword".into()));
|
||||
assert!(!super::super::spear_use::holds_non_melee_weapon(&m));
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,10 @@ impl Goal for PiglinSpear {
|
||||
/// weapon (`canUseNonMeleeWeapon`) never melees; its spear behaviours attack.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NoMeleeWithSpear<G>(pub G);
|
||||
/// A kinetic weapon or a crossbow in the main hand: the melee goal stays off.
|
||||
pub(super) fn holds_non_melee_weapon(m: &Mob) -> bool {
|
||||
main_hand_kinetic(m).is_some() || m.extra.main_hand.as_deref() == Some("minecraft:crossbow")
|
||||
}
|
||||
impl<G: Goal + Clone + 'static> Goal for NoMeleeWithSpear<G> {
|
||||
goal_clone!();
|
||||
fn flags(&self) -> u8 {
|
||||
@@ -858,10 +862,10 @@ impl<G: Goal + Clone + 'static> Goal for NoMeleeWithSpear<G> {
|
||||
self.0.name()
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
main_hand_kinetic(m).is_none() && self.0.can_use(m, cx)
|
||||
!holds_non_melee_weapon(m) && self.0.can_use(m, cx)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
main_hand_kinetic(m).is_none() && self.0.can_continue(m, cx)
|
||||
!holds_non_melee_weapon(m) && self.0.can_continue(m, cx)
|
||||
}
|
||||
fn interruptable(&self) -> bool {
|
||||
self.0.interruptable()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1687,7 +1687,47 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
chunk.light_loaded = true;
|
||||
}
|
||||
|
||||
type RegionCache = Mutex<HashMap<(i32, i32), Option<Arc<Mutex<RegionFile>>>>>;
|
||||
type RegionCache = Mutex<RegionFiles>;
|
||||
/// `RegionFileStorage.MAX_CACHE_SIZE`: the region files of a folder kept
|
||||
/// open. Each holds a file descriptor: kept for every region ever visited,
|
||||
/// a server whose players travel runs out of them (`Too many open files`).
|
||||
const MAX_OPEN_REGIONS: usize = 256;
|
||||
/// A folder's region files, and the ones known to be missing, by region,
|
||||
/// with when each was last asked for.
|
||||
#[derive(Default)]
|
||||
struct RegionFiles {
|
||||
regions: HashMap<(i32, i32), (Option<Arc<Mutex<RegionFile>>>, u64)>,
|
||||
clock: u64,
|
||||
}
|
||||
impl RegionFiles {
|
||||
#[cfg(test)]
|
||||
fn is_empty(&self) -> bool {
|
||||
self.regions.is_empty()
|
||||
}
|
||||
/// The open region files.
|
||||
#[cfg(test)]
|
||||
fn open(&self) -> usize {
|
||||
self.regions.values().filter(|(r, _)| r.is_some()).count()
|
||||
}
|
||||
/// Close the least recently used regions until there is room for one
|
||||
/// more, as `RegionFileStorage.getRegionFile` does. A region a reader or
|
||||
/// the writer still holds stays: closing it under them would let a
|
||||
/// second handle read a header older than its sectors.
|
||||
fn make_room(&mut self) {
|
||||
while self.regions.len() >= MAX_OPEN_REGIONS {
|
||||
let oldest = self
|
||||
.regions
|
||||
.iter()
|
||||
.filter(|(_, (r, _))| r.as_ref().is_none_or(|r| Arc::strong_count(r) == 1))
|
||||
.min_by_key(|(_, (_, used))| *used)
|
||||
.map(|(key, _)| *key);
|
||||
let Some(key) = oldest else {
|
||||
return;
|
||||
};
|
||||
self.regions.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The region file at `path` through the cache, which readers and the writer
|
||||
/// share, so a read never sees a header older than the sectors it points to.
|
||||
/// `create` makes a missing file (for a write); otherwise a missing file is
|
||||
@@ -1698,13 +1738,16 @@ fn cached_region(
|
||||
path: &Path,
|
||||
create: bool,
|
||||
) -> Result<Option<Arc<Mutex<RegionFile>>>> {
|
||||
let mut regions = cache.lock().map_err(|_| "region cache poisoned")?;
|
||||
if let Some(Some(region)) = regions.get(&key) {
|
||||
return Ok(Some(region.clone()));
|
||||
}
|
||||
if !create && regions.contains_key(&key) {
|
||||
return Ok(None);
|
||||
let mut cache = cache.lock().map_err(|_| "region cache poisoned")?;
|
||||
cache.clock += 1;
|
||||
let now = cache.clock;
|
||||
if let Some((cached, used)) = cache.regions.get_mut(&key)
|
||||
&& (cached.is_some() || !create)
|
||||
{
|
||||
*used = now;
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
cache.make_room();
|
||||
if create && let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -1720,7 +1763,7 @@ fn cached_region(
|
||||
Err(_) if !create => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
regions.insert(key, region.clone());
|
||||
cache.regions.insert(key, (region.clone(), now));
|
||||
Ok(region)
|
||||
}
|
||||
/// Write `records` into the region files of `folder` in place, as vanilla's
|
||||
@@ -4936,6 +4979,61 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn region_files_kept_open_stay_under_vanillas_cache_size() {
|
||||
let open_fds = || std::fs::read_dir("/dev/fd").map_or(0, |d| d.count());
|
||||
let dir = std::env::temp_dir().join(format!("horizon-save-fds-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let save = super::SaveDir::new(dir.clone(), LevelShape::OVERWORLD);
|
||||
let before = open_fds();
|
||||
let held = super::cached_region(
|
||||
&save.regions,
|
||||
(0, 0),
|
||||
&save.region_path("region", 0, 0),
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for x in 1..=600 {
|
||||
super::cached_region(
|
||||
&save.regions,
|
||||
(x, 0),
|
||||
&save.region_path("region", x, 0),
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
// Other tests open files at the same time: a margin, far below 600.
|
||||
let grown = open_fds().saturating_sub(before);
|
||||
assert!(
|
||||
grown <= super::MAX_OPEN_REGIONS + 32,
|
||||
"{grown} descriptors more"
|
||||
);
|
||||
let cache = save.regions.lock().unwrap();
|
||||
assert!(
|
||||
cache.open() <= super::MAX_OPEN_REGIONS,
|
||||
"{} open",
|
||||
cache.open()
|
||||
);
|
||||
// The one still held is never closed under its holder.
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.regions[&(0, 0)].0.clone().unwrap(),
|
||||
&held
|
||||
));
|
||||
drop(cache);
|
||||
// What was written to regions since closed reads back from the disk.
|
||||
let entity = |x: i32| Tag::compound([("x", Tag::Int(x))]);
|
||||
save.save_entities_later((0..300).map(|i| ((i * 32, 0), vec![entity(i)])).collect())
|
||||
.unwrap();
|
||||
save.flush().unwrap();
|
||||
assert!(save.entity_regions.lock().unwrap().open() <= super::MAX_OPEN_REGIONS);
|
||||
for i in [0, 1, 150, 299] {
|
||||
assert_eq!(save.load_entities(i * 32, 0), vec![entity(i)]);
|
||||
}
|
||||
drop(held);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn write_completion_polls_without_waiting_and_keeps_latest_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-save-poll-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Entity shadows (`EntityRenderer.extractShadow` + `ShadowFeatureRenderer`):
|
||||
//! `misc/shadow.png` projected onto the tops of full-collision blocks under
|
||||
//! `misc/shadow.png` projected onto block collision bounds under
|
||||
//! an entity, faded by camera distance, depth below the entity and the light
|
||||
//! at each block.
|
||||
use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, mobs::Mob, world::World};
|
||||
@@ -111,6 +111,9 @@ pub fn mob_casters(mobs: &[Mob], alpha: f64) -> impl Iterator<Item = (DVec3, &st
|
||||
pub fn shadow(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision_bounds: &impl Fn(
|
||||
horizon_world::registry::BlockStateId,
|
||||
) -> Option<horizon_core::geometry::Aabb>,
|
||||
sprite: Rect,
|
||||
camera: DVec3,
|
||||
pos: DVec3,
|
||||
@@ -139,11 +142,17 @@ pub fn shadow(
|
||||
);
|
||||
for z in z0..=z1 {
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
let below = facts.get(world.block(x, y - 1, z));
|
||||
if below.render_shape == crate::blocks::RenderShape::Invisible
|
||||
|| !below.full_collision
|
||||
{
|
||||
for y in y0..=y1.saturating_add(1) {
|
||||
let state = world.block(x, y - 1, z);
|
||||
let below = facts.get(state);
|
||||
if below.render_shape == crate::blocks::RenderShape::Invisible {
|
||||
continue;
|
||||
}
|
||||
let Some(bounds) = collision_bounds(state) else {
|
||||
continue;
|
||||
};
|
||||
let top = f64::from(y - 1) + bounds.max_position().y;
|
||||
if top > pos.y + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let packed = world.light(x, y, z);
|
||||
@@ -151,14 +160,20 @@ pub fn shadow(
|
||||
if raw <= 3 {
|
||||
continue;
|
||||
}
|
||||
let power_at_depth = pow - (pos.y - f64::from(y)) as f32 * 0.5;
|
||||
let power_at_depth = pow - (pos.y - top) as f32 * 0.5;
|
||||
let alpha = (power_at_depth
|
||||
* 0.5
|
||||
* crate::screen_effects::lightmap_brightness(ambient_light, raw as u8))
|
||||
.clamp(0.0, 1.0);
|
||||
// The block top, clipped to the shadow square so the UVs stay inside the sprite.
|
||||
let (bx0, bx1) = (f64::from(x).max(pos.x - r), f64::from(x + 1).min(pos.x + r));
|
||||
let (bz0, bz1) = (f64::from(z).max(pos.z - r), f64::from(z + 1).min(pos.z + r));
|
||||
let (bx0, bx1) = (
|
||||
(f64::from(x) + bounds.min_position().x).max(pos.x - r),
|
||||
(f64::from(x) + bounds.max_position().x).min(pos.x + r),
|
||||
);
|
||||
let (bz0, bz1) = (
|
||||
(f64::from(z) + bounds.min_position().z).max(pos.z - r),
|
||||
(f64::from(z) + bounds.max_position().z).min(pos.z + r),
|
||||
);
|
||||
if bx1 <= bx0 || bz1 <= bz0 {
|
||||
continue;
|
||||
}
|
||||
@@ -169,7 +184,6 @@ pub fn shadow(
|
||||
// ShadowFeatureRenderer: relativeY + shapeBelow.minY, the block top
|
||||
// itself. A model's feet sit 0.001 above it (LivingEntityRenderer's
|
||||
// translate(0, -1.501, 0)), so a lifted shadow fights with them.
|
||||
let top = f64::from(y);
|
||||
for (wx, wz) in [(bx0, bz0), (bx0, bz1), (bx1, bz1), (bx1, bz0)] {
|
||||
let p = DVec3::new(wx, top, wz) - camera;
|
||||
out.push(Vertex {
|
||||
@@ -231,7 +245,22 @@ mod tests {
|
||||
h: 16,
|
||||
};
|
||||
shadow(
|
||||
&world, &facts, sprite, camera, pos, 0.3, 1.0, 0.0, 0, &mut out,
|
||||
&world,
|
||||
&facts,
|
||||
&|_| {
|
||||
Some(horizon_core::geometry::Aabb::new(
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
horizon_core::geometry::Vec3::new(1.0, 1.0, 1.0),
|
||||
))
|
||||
},
|
||||
sprite,
|
||||
camera,
|
||||
pos,
|
||||
0.3,
|
||||
1.0,
|
||||
0.0,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 4);
|
||||
let feet = (pos.y + (1.501 - 24.0 / 16.0) - camera.y) as f32;
|
||||
@@ -239,6 +268,32 @@ mod tests {
|
||||
assert_eq!(v.pos[1], (pos.y - camera.y) as f32);
|
||||
assert!(v.pos[1] < feet);
|
||||
}
|
||||
let farmland = registry.default_state("minecraft:farmland").unwrap();
|
||||
world.set(8, 63, 8, farmland).unwrap();
|
||||
out.clear();
|
||||
let pos = DVec3::new(8.5, 63.9375, 8.5);
|
||||
shadow(
|
||||
&world,
|
||||
&facts,
|
||||
&|state| {
|
||||
(state == farmland).then(|| {
|
||||
horizon_core::geometry::Aabb::new(
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
horizon_core::geometry::Vec3::new(1.0, 0.9375, 1.0),
|
||||
)
|
||||
})
|
||||
},
|
||||
sprite,
|
||||
camera,
|
||||
pos,
|
||||
0.3,
|
||||
1.0,
|
||||
0.0,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 4);
|
||||
assert!(out.iter().all(|v| v.pos[1] == (pos.y - camera.y) as f32));
|
||||
}
|
||||
#[test]
|
||||
fn invisible_mobs_cast_no_shadow() {
|
||||
|
||||
@@ -118,6 +118,27 @@ impl TextDisplayData {
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display.updateCulling`: either edge at 0 sets `noCulling`, so the renderer
|
||||
/// skips the frustum box (`DisplayRenderer.affectedByCulling`).
|
||||
pub fn no_culling(width: f32, height: f32) -> bool {
|
||||
width == 0.0 || height == 0.0
|
||||
}
|
||||
|
||||
/// `Display.updateCulling` / `getInterpolatedBoundingBox` at an interpolated
|
||||
/// origin: the AABB from `(x − width/2, y, z − width/2)` to
|
||||
/// `(x + width/2, y + height, z + width/2)`.
|
||||
pub fn culling_box(origin: glam::DVec3, width: f32, height: f32) -> (glam::DVec3, glam::DVec3) {
|
||||
let half = f64::from(width) * 0.5;
|
||||
let tall = f64::from(height);
|
||||
(
|
||||
glam::DVec3::new(origin.x - half, origin.y, origin.z - half),
|
||||
glam::DVec3::new(origin.x + half, origin.y + tall, origin.z + half),
|
||||
)
|
||||
}
|
||||
|
||||
impl TextDisplayData {
|
||||
/// Omitted fields retain their previous values.
|
||||
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
|
||||
for (id, _, value) in values {
|
||||
@@ -455,4 +476,21 @@ mod tests {
|
||||
assert_eq!(d.align(), Align::Left);
|
||||
assert_eq!(d.translation, [0.0; 3]);
|
||||
}
|
||||
|
||||
/// `Display.updateCulling`: width or height 0 disables the frustum box.
|
||||
#[test]
|
||||
fn zero_width_or_height_disables_display_culling() {
|
||||
assert!(no_culling(0.0, 1.0));
|
||||
assert!(no_culling(2.0, 0.0));
|
||||
assert!(no_culling(0.0, 0.0));
|
||||
assert!(!no_culling(2.0, 1.0));
|
||||
}
|
||||
|
||||
/// `Display.updateCulling`: the box is width-centred on XZ and rises by height.
|
||||
#[test]
|
||||
fn display_culling_box_is_centred_on_xz_and_stands_on_y() {
|
||||
let (lo, hi) = culling_box(glam::DVec3::new(10.0, 64.0, -3.0), 2.0, 1.0);
|
||||
assert_eq!(lo, glam::DVec3::new(9.0, 64.0, -4.0));
|
||||
assert_eq!(hi, glam::DVec3::new(11.0, 65.0, -2.0));
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
&[
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -1236,12 +1236,22 @@ impl<R: RandomSource> Context<'_, R> {
|
||||
"minecraft:set_ominous_bottle_amplifier" => {
|
||||
stack.ominous_amplifier = Some(self.int(&f["amplifier"]));
|
||||
}
|
||||
// SetInstrumentFunction: `options.getRandomElement(random)` becomes the item's
|
||||
// `instrument` component (a goat horn's tune).
|
||||
"minecraft:set_instrument" => {
|
||||
let options = data.holder_set("instrument", &f["options"]);
|
||||
if !options.is_empty() {
|
||||
self.random
|
||||
let pick = self
|
||||
.random
|
||||
.next_int_bound(options.len() as i32)
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(0) as usize;
|
||||
stack
|
||||
.components
|
||||
.retain(|(k, _)| k != "minecraft:instrument");
|
||||
stack.components.push((
|
||||
"minecraft:instrument".to_owned(),
|
||||
Value::String(options[pick].clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
"minecraft:set_item" => {
|
||||
@@ -1632,4 +1642,40 @@ mod namespace_tests {
|
||||
assert_eq!(count, 3);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `SetInstrumentFunction`: a goat horn from a loot table gets one of the options as its instrument.
|
||||
#[test]
|
||||
fn set_instrument_picks_one_of_its_options() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-loot-horn-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let table = r##"{"pools": [{"rolls": 1, "entries": [{"type": "minecraft:item", "name": "minecraft:goat_horn",
|
||||
"modifier": [{"type": "minecraft:set_instrument", "options": ["minecraft:ponder_goat_horn", "minecraft:sing_goat_horn"]}]}]}]}"##;
|
||||
let files = [
|
||||
("items.json", r#"{"minecraft:goat_horn": {}}"#),
|
||||
("data/minecraft/loot_table/chests/horn.json", table),
|
||||
];
|
||||
for (name, text) in files {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
let data = LootData::load(&root, &PackOverlay::NONE).unwrap();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for seed in (0..40).map(|n| n * 7_919) {
|
||||
let filled = data.fill("minecraft:chests/horn", seed, 27, None);
|
||||
let horn = filled.iter().flatten().next().expect("a horn");
|
||||
let instrument: Vec<_> = horn
|
||||
.components
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "minecraft:instrument")
|
||||
.collect();
|
||||
assert_eq!(instrument.len(), 1, "{:?}", horn.components);
|
||||
seen.insert(instrument[0].1.as_str().unwrap().to_owned());
|
||||
}
|
||||
assert_eq!(
|
||||
seen.into_iter().collect::<Vec<_>>(),
|
||||
["minecraft:ponder_goat_horn", "minecraft:sing_goat_horn"]
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -267,6 +267,12 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it.
|
||||
A tab names how many it holds ("Worlds 3"); its own buttons (Add server, Open folder) sit at the
|
||||
strip's end, where a list's header would put them (`HORIZON_LAUNCHER_PAGE=worlds`, `packs`...
|
||||
opens one for screenshots).
|
||||
- **The Mods tab** (`launcher/src/instance_mods.rs`): the instance's mods on one nearly opaque
|
||||
card, its loader in the header ("Fabric mods", "Horizon mods") beside a filter. A row is a
|
||||
coloured square with the mod's initial, its name and version, where it comes from (Modrinth,
|
||||
"needed by another mod", or a local file by name), an update as an amber pill when the browser
|
||||
found one, and a switch (a Java jar turned off is renamed `.disabled`). Update all and Add mods
|
||||
sit at the strip's end (`HORIZON_LAUNCHER_PAGE=instance-mods`).
|
||||
- **The sign-in sheet** (`launcher/src/owner_page.rs`, the owner gate): an opaque plate over the
|
||||
dimmed launcher, never instead of it. It says why (the account that owns Java Edition, no install
|
||||
needed), the code in mono on a raised box with Copy, and Open link as the main action; the window
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
+122
-12
@@ -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,45 @@ 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`.
|
||||
|
||||
## 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 +89,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 +110,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 +191,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 +230,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`.
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# 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): it can't be bought with real money and can't be
|
||||
cashed out, and nothing random is sold for money. Games of chance take and pay only the in-game currency, within
|
||||
limits the owner sets. 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>`, `/coinflip accept\|deny <player>` | a 50/50 bet between two players, the money 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`, `gambling` with `enabled: false`, `rules`).
|
||||
|
||||
## 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.
|
||||
+15
-1
@@ -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…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Mehrspieler",
|
||||
"Packs": "Pakete",
|
||||
"Screenshots": "Screenshots",
|
||||
"Console": "Konsole"
|
||||
"Console": "Konsole",
|
||||
"Update all ({count})": "Alle aktualisieren ({count})",
|
||||
"Add mods": "Mods hinzufügen",
|
||||
"{loader} mods": "{loader}-Mods",
|
||||
"Horizon mods": "Horizon-Mods",
|
||||
"Filter installed": "Installierte filtern",
|
||||
"Reading the mods…": "Mods werden gelesen…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Noch keine Mods: „Mods hinzufügen“ findet welche für diese Instanz.",
|
||||
"No mod matches.": "Kein Mod passt.",
|
||||
"{source} · needed by another mod": "{source} · von einem anderen Mod benötigt",
|
||||
"Local file · {file}": "Lokale Datei · {file}",
|
||||
"Update {version}": "Update {version}"
|
||||
}
|
||||
+15
-1
@@ -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…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Multijugador",
|
||||
"Packs": "Paquetes",
|
||||
"Screenshots": "Capturas",
|
||||
"Console": "Consola"
|
||||
"Console": "Consola",
|
||||
"Update all ({count})": "Actualizar todo ({count})",
|
||||
"Add mods": "Añadir mods",
|
||||
"{loader} mods": "Mods de {loader}",
|
||||
"Horizon mods": "Mods de Horizon",
|
||||
"Filter installed": "Filtrar instalados",
|
||||
"Reading the mods…": "Leyendo los mods…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Aún no hay mods: Añadir mods encuentra algunos para esta instancia.",
|
||||
"No mod matches.": "Ningún mod coincide.",
|
||||
"{source} · needed by another mod": "{source} · lo necesita otro mod",
|
||||
"Local file · {file}": "Archivo local · {file}",
|
||||
"Update {version}": "Actualizar a {version}"
|
||||
}
|
||||
+15
-1
@@ -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…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Multijoueur",
|
||||
"Packs": "Packs",
|
||||
"Screenshots": "Captures d'écran",
|
||||
"Console": "Console"
|
||||
"Console": "Console",
|
||||
"Update all ({count})": "Tout mettre à jour ({count})",
|
||||
"Add mods": "Ajouter des mods",
|
||||
"{loader} mods": "Mods {loader}",
|
||||
"Horizon mods": "Mods Horizon",
|
||||
"Filter installed": "Filtrer les mods installés",
|
||||
"Reading the mods…": "Lecture des mods…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Pas encore de mods : Ajouter des mods en trouve pour cette instance.",
|
||||
"No mod matches.": "Aucun mod ne correspond.",
|
||||
"{source} · needed by another mod": "{source} · requis par un autre mod",
|
||||
"Local file · {file}": "Fichier local · {file}",
|
||||
"Update {version}": "Mettre à jour : {version}"
|
||||
}
|
||||
+15
-1
@@ -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…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Gra wieloosobowa",
|
||||
"Packs": "Paczki",
|
||||
"Screenshots": "Zrzuty ekranu",
|
||||
"Console": "Konsola"
|
||||
"Console": "Konsola",
|
||||
"Update all ({count})": "Zaktualizuj wszystko ({count})",
|
||||
"Add mods": "Dodaj mody",
|
||||
"{loader} mods": "Mody {loader}",
|
||||
"Horizon mods": "Mody Horizon",
|
||||
"Filter installed": "Filtruj zainstalowane",
|
||||
"Reading the mods…": "Odczytywanie modów…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Brak modów: Dodaj mody znajdzie jakieś dla tej instancji.",
|
||||
"No mod matches.": "Żaden mod nie pasuje.",
|
||||
"{source} · needed by another mod": "{source} · wymagany przez inny mod",
|
||||
"Local file · {file}": "Plik lokalny · {file}",
|
||||
"Update {version}": "Aktualizuj do {version}"
|
||||
}
|
||||
+15
-1
@@ -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…": "Импортировать модпак…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Сетевая игра",
|
||||
"Packs": "Наборы",
|
||||
"Screenshots": "Скриншоты",
|
||||
"Console": "Консоль"
|
||||
"Console": "Консоль",
|
||||
"Update all ({count})": "Обновить все ({count})",
|
||||
"Add mods": "Добавить моды",
|
||||
"{loader} mods": "Моды {loader}",
|
||||
"Horizon mods": "Моды Horizon",
|
||||
"Filter installed": "Фильтр установленных",
|
||||
"Reading the mods…": "Чтение модов…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Модов пока нет: «Добавить моды» найдёт их для этой сборки.",
|
||||
"No mod matches.": "Нет подходящих модов.",
|
||||
"{source} · needed by another mod": "{source} · нужен другому моду",
|
||||
"Local file · {file}": "Локальный файл · {file}",
|
||||
"Update {version}": "Обновить до {version}"
|
||||
}
|
||||
+15
-1
@@ -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…": "Імпортувати модпак…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "Мережева гра",
|
||||
"Packs": "Набори",
|
||||
"Screenshots": "Знімки екрана",
|
||||
"Console": "Консоль"
|
||||
"Console": "Консоль",
|
||||
"Update all ({count})": "Оновити все ({count})",
|
||||
"Add mods": "Додати моди",
|
||||
"{loader} mods": "Моди {loader}",
|
||||
"Horizon mods": "Моди Horizon",
|
||||
"Filter installed": "Фільтр встановлених",
|
||||
"Reading the mods…": "Читання модів…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Модів поки немає: «Додати моди» знайде їх для цієї збірки.",
|
||||
"No mod matches.": "Немає відповідних модів.",
|
||||
"{source} · needed by another mod": "{source} · потрібен іншому моду",
|
||||
"Local file · {file}": "Локальний файл · {file}",
|
||||
"Update {version}": "Оновити до {version}"
|
||||
}
|
||||
+15
-1
@@ -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…": "导入整合包…",
|
||||
@@ -223,5 +226,16 @@
|
||||
"Multiplayer": "多人游戏",
|
||||
"Packs": "资源包",
|
||||
"Screenshots": "截图",
|
||||
"Console": "控制台"
|
||||
"Console": "控制台",
|
||||
"Update all ({count})": "全部更新({count})",
|
||||
"Add mods": "添加模组",
|
||||
"{loader} mods": "{loader} 模组",
|
||||
"Horizon mods": "Horizon 模组",
|
||||
"Filter installed": "筛选已安装",
|
||||
"Reading the mods…": "正在读取模组…",
|
||||
"No mods yet: Add mods finds some for this instance.": "还没有模组:点击“添加模组”为此实例查找。",
|
||||
"No mod matches.": "没有匹配的模组。",
|
||||
"{source} · needed by another mod": "{source} · 其他模组需要",
|
||||
"Local file · {file}": "本地文件 · {file}",
|
||||
"Update {version}": "更新到 {version}"
|
||||
}
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::*;
|
||||
pub(super) enum HomeTab {
|
||||
#[default]
|
||||
Overview,
|
||||
Mods,
|
||||
Worlds,
|
||||
Multiplayer,
|
||||
Packs,
|
||||
@@ -15,10 +16,11 @@ pub(super) enum HomeTab {
|
||||
}
|
||||
|
||||
impl HomeTab {
|
||||
/// `HORIZON_LAUNCHER_PAGE=worlds` (or `multiplayer`, `packs`, `screenshots`, `console`) opens
|
||||
/// Home on that tab, for screenshots.
|
||||
/// `HORIZON_LAUNCHER_PAGE=worlds` (or `instance-mods`, `multiplayer`, `packs`, `screenshots`,
|
||||
/// `console`) opens Home on that tab, for screenshots.
|
||||
pub(super) fn from_env() -> Self {
|
||||
match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() {
|
||||
Ok("instance-mods") => HomeTab::Mods,
|
||||
Ok("worlds") => HomeTab::Worlds,
|
||||
Ok("multiplayer") => HomeTab::Multiplayer,
|
||||
Ok("packs") => HomeTab::Packs,
|
||||
@@ -37,6 +39,9 @@ impl Launcher {
|
||||
if self.selected_instance().is_none() {
|
||||
return tabs;
|
||||
}
|
||||
if self.has_mods_tab() {
|
||||
tabs.push((HomeTab::Mods, self.instance_mods_count(cx)));
|
||||
}
|
||||
tabs.push((HomeTab::Worlds, self.worlds_count(cx)));
|
||||
tabs.push((HomeTab::Multiplayer, Some(self.servers_count())));
|
||||
tabs.push((HomeTab::Packs, Some(self.packs_count())));
|
||||
@@ -67,6 +72,7 @@ impl Launcher {
|
||||
.children(tabs.into_iter().map(|(tab, count)| {
|
||||
let name = match tab {
|
||||
HomeTab::Overview => tr("Overview"),
|
||||
HomeTab::Mods => tr("Mods"),
|
||||
HomeTab::Worlds => tr("Worlds"),
|
||||
HomeTab::Multiplayer => tr("Multiplayer"),
|
||||
HomeTab::Packs => tr("Packs"),
|
||||
@@ -87,6 +93,10 @@ impl Launcher {
|
||||
// The open tab's own buttons sit at the strip's end, as a list's header would.
|
||||
let (actions, body) = match open {
|
||||
HomeTab::Overview => (None, Some(overview.into_any_element())),
|
||||
HomeTab::Mods => {
|
||||
let (actions, body) = split(self.instance_mods_tab(cx));
|
||||
(Some(actions), Some(body))
|
||||
}
|
||||
HomeTab::Worlds => (
|
||||
None,
|
||||
self.worlds_section(cx).map(IntoElement::into_any_element),
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Home's Mods tab (docs/design-system.md, "Night"; plan R0, screens 5 and 6): every mod of the
|
||||
//! selected instance, the browser's and those put in its folder by hand, with its version, where
|
||||
//! it comes from, an update when the browser found one, and a switch. A Java instance turns a mod
|
||||
//! off by renaming its jar (`.disabled`, as `java_instance` does); a Horizon instance's mods are
|
||||
//! the registry's, switched as the game's Options > Mods does. Adding mods is the Mods page's.
|
||||
use super::*;
|
||||
use crate::plate::alpha;
|
||||
use crate::shell::{c, Action};
|
||||
use horizon_launcher::browser::{horizon, installed, java, Kind as PackKind, SourceId};
|
||||
use servers_page::Field;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theme::night;
|
||||
|
||||
/// A turned-off jar's suffix, which the loaders don't read.
|
||||
const OFF: &str = ".disabled";
|
||||
|
||||
/// A mod as the tab lists it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct ModRow {
|
||||
/// Its file in the instance, as it is when on (`mods/lumen-0.6.2.jar`).
|
||||
file: String,
|
||||
name: String,
|
||||
version: String,
|
||||
on: bool,
|
||||
/// What the browser knows of it; None: a file put in by hand.
|
||||
entry: Option<installed::Entry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct InstanceMods {
|
||||
home: Option<PathBuf>,
|
||||
fresh: bool,
|
||||
reading: bool,
|
||||
list: Vec<ModRow>,
|
||||
/// The browser was installing at the last draw: its end changes the list.
|
||||
was_busy: bool,
|
||||
}
|
||||
|
||||
/// The mods of the instance at `home`: a Java one's jars in `mods/` (on or off), a Horizon one's
|
||||
/// registry mods. Each jar names itself (`fabric.mod.json`, `quilt.mod.json`...), else its file.
|
||||
fn read(home: &Path, java_instance: bool) -> Vec<ModRow> {
|
||||
let entries: Vec<installed::Entry> = installed::Installed::load(home)
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == PackKind::Mod)
|
||||
.collect();
|
||||
let mut rows: Vec<ModRow> = if java_instance {
|
||||
let Ok(dir) = std::fs::read_dir(home.join("mods")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
dir.flatten()
|
||||
.filter_map(|f| {
|
||||
let name = f.file_name().to_string_lossy().into_owned();
|
||||
let on = name.ends_with(".jar");
|
||||
let jar = name.strip_suffix(OFF).unwrap_or(&name);
|
||||
(jar.ends_with(".jar") && f.path().is_file()).then(|| {
|
||||
let file = format!("mods/{jar}");
|
||||
let entry = entries.iter().find(|e| e.file == file).cloned();
|
||||
let (name, version) = match &entry {
|
||||
Some(e) => (e.title.clone(), e.number.clone()),
|
||||
None => horizon_port_pack::jar::read(&f.path())
|
||||
.map(|j| (j.name, j.version))
|
||||
.unwrap_or_else(|_| {
|
||||
(jar.trim_end_matches(".jar").into(), String::new())
|
||||
}),
|
||||
};
|
||||
ModRow {
|
||||
file,
|
||||
name,
|
||||
version,
|
||||
on,
|
||||
entry,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| ModRow {
|
||||
file: e.file.clone(),
|
||||
name: e.title.clone(),
|
||||
version: e.number.clone(),
|
||||
on: horizon::mod_id(&e).is_some_and(|id| java::is_enabled(home, id)),
|
||||
entry: Some(e),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
rows.sort_by_key(|r| r.name.to_lowercase());
|
||||
rows
|
||||
}
|
||||
|
||||
/// Turns a Java instance's jar on or off by renaming it.
|
||||
fn switch_jar(home: &Path, file: &str, on: bool) -> Result<(), String> {
|
||||
let (jar, off) = (home.join(file), home.join(format!("{file}{OFF}")));
|
||||
let (from, to) = if on { (off, jar) } else { (jar, off) };
|
||||
std::fs::rename(&from, &to).map_err(|e| format!("{}: {e}", from.display()))
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Whether the selected instance has mods to show: Horizon's always, a Java one with a loader.
|
||||
pub(super) fn has_mods_tab(&self) -> bool {
|
||||
self.selected_instance()
|
||||
.is_some_and(|i| i.java.as_ref().is_none_or(|j| j.loader.is_some()))
|
||||
}
|
||||
|
||||
/// Reads the selected instance's mods off the UI thread, once until something changes them.
|
||||
fn load_instance_mods(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(i) = self.selected_instance() else {
|
||||
return;
|
||||
};
|
||||
let (home, java_instance) = (i.home.clone(), i.java.is_some());
|
||||
let m = &mut self.instance_mods;
|
||||
if m.home.as_ref() != Some(&home) {
|
||||
*m = InstanceMods {
|
||||
home: Some(home.clone()),
|
||||
..InstanceMods::default()
|
||||
};
|
||||
}
|
||||
let busy = self.mods.busy.is_some();
|
||||
if m.was_busy && !busy {
|
||||
m.fresh = false;
|
||||
}
|
||||
m.was_busy = busy;
|
||||
if m.fresh || m.reading {
|
||||
return;
|
||||
}
|
||||
m.reading = true;
|
||||
let task = cx
|
||||
.background_executor()
|
||||
.spawn(async move { (home.clone(), read(&home, java_instance)) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (home, list) = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let m = &mut this.instance_mods;
|
||||
m.reading = false;
|
||||
if m.home.as_ref() == Some(&home) {
|
||||
m.list = list;
|
||||
m.fresh = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// How many mods the selected instance has; None while they are read.
|
||||
pub(super) fn instance_mods_count(&mut self, cx: &mut Context<Self>) -> Option<usize> {
|
||||
self.mods_load();
|
||||
self.load_instance_mods(cx);
|
||||
let m = &self.instance_mods;
|
||||
m.fresh.then_some(m.list.len())
|
||||
}
|
||||
|
||||
fn switch_mod(&mut self, row: &ModRow) {
|
||||
let home = self.instance_mods.home.clone().unwrap_or_default();
|
||||
let java_instance = self.selected_instance().is_some_and(|i| i.java.is_some());
|
||||
match &row.entry {
|
||||
Some(e) if !java_instance => self.mods_switch(e),
|
||||
_ => {
|
||||
self.mods.message = switch_jar(&home, &row.file, !row.on).err().map(Err);
|
||||
}
|
||||
}
|
||||
self.instance_mods.fresh = false;
|
||||
}
|
||||
|
||||
/// The Mods tab: its buttons (beside the tabs) and the list.
|
||||
pub(super) fn instance_mods_tab(&mut self, cx: &mut Context<Self>) -> (Div, Div) {
|
||||
let t = self.theme;
|
||||
let n = t.night;
|
||||
let busy = self.mods.busy.is_some();
|
||||
let updates = self.mods.updates.len();
|
||||
let actions = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.children((updates > 0 && !busy).then(|| {
|
||||
shell::action("instance-mods-update-all", Action::Raised, &t)
|
||||
.child(shell::label(trf(
|
||||
"Update all ({count})",
|
||||
&[("count", &updates.to_string())],
|
||||
)))
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.mods_update_all();
|
||||
cx.notify();
|
||||
}))
|
||||
}))
|
||||
.child(
|
||||
shell::action("instance-mods-add", Action::Raised, &t)
|
||||
.child(shell::label(tr("Add mods").to_string()))
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.go(Page::Mods);
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
let loader = self
|
||||
.selected_instance()
|
||||
.and_then(|i| i.java.as_ref())
|
||||
.map(|j| j.loader.as_ref().map(|l| l.kind.name()));
|
||||
let title = match loader {
|
||||
Some(Some(loader)) => trf("{loader} mods", &[("loader", loader)]),
|
||||
_ => tr("Horizon mods").to_string(),
|
||||
};
|
||||
let filter = self
|
||||
.servers
|
||||
.value(Field::InstanceModsFilter)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let field = self.field(Field::InstanceModsFilter, "Filter installed", 240., cx);
|
||||
let m = &self.instance_mods;
|
||||
let rows: Vec<ModRow> = m
|
||||
.list
|
||||
.iter()
|
||||
.filter(|r| filter.is_empty() || r.name.to_lowercase().contains(&filter))
|
||||
.cloned()
|
||||
.collect();
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap(px(16.))
|
||||
.px(px(18.))
|
||||
.py(px(14.))
|
||||
.child(
|
||||
shell::text(night::UI, c(n.ink))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(title),
|
||||
)
|
||||
.child(field);
|
||||
let empty = |s: &str| {
|
||||
shell::text(night::UI, c(n.muted))
|
||||
.px(px(18.))
|
||||
.py(px(16.))
|
||||
.child(tr(s).to_string())
|
||||
};
|
||||
let body: Vec<AnyElement> = if !m.fresh {
|
||||
vec![empty("Reading the mods…").into_any_element()]
|
||||
} else if m.list.is_empty() {
|
||||
vec![empty("No mods yet: Add mods finds some for this instance.").into_any_element()]
|
||||
} else if rows.is_empty() {
|
||||
vec![empty("No mod matches.").into_any_element()]
|
||||
} else {
|
||||
rows.into_iter()
|
||||
.map(|row| self.mod_row(row, busy, cx).into_any_element())
|
||||
.collect()
|
||||
};
|
||||
let card = plate::cut(
|
||||
night::PIXEL_CORNER,
|
||||
2,
|
||||
// Nearly opaque: a list reads over the sunset.
|
||||
Some(alpha(n.card, 0.94)),
|
||||
Some((alpha(n.stroke, n.stroke_alpha), 1.)),
|
||||
)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(header)
|
||||
.children(body.into_iter().map(|row| {
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(alpha(n.stroke, n.rule_alpha))
|
||||
.child(row)
|
||||
}));
|
||||
(
|
||||
actions,
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(12.))
|
||||
.children(self.mods_busy_plate())
|
||||
.child(card),
|
||||
)
|
||||
}
|
||||
|
||||
fn mod_row(&self, row: ModRow, busy: bool, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let n = t.night;
|
||||
// Each mod its own colour, the same every time: a hue from its name.
|
||||
let hue = row
|
||||
.name
|
||||
.bytes()
|
||||
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32));
|
||||
let icon = div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(40.))
|
||||
.rounded(px(night::RADIUS_CONTROL))
|
||||
.bg(gpui::hsla((hue % 360) as f32 / 360., 0.42, 0.52, 1.))
|
||||
.child(
|
||||
shell::text(night::TITLE, c(n.on_accent))
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.child(
|
||||
row.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
let source = match &row.entry {
|
||||
Some(e) if e.dependency => trf(
|
||||
"{source} · needed by another mod",
|
||||
&[("source", e.source.label())],
|
||||
),
|
||||
Some(e) => e.source.label().to_string(),
|
||||
None => trf(
|
||||
"Local file · {file}",
|
||||
&[("file", row.file.trim_start_matches("mods/"))],
|
||||
),
|
||||
};
|
||||
let update = row.entry.as_ref().and_then(|e| {
|
||||
let key = (e.source, e.project.clone());
|
||||
let number = self.mods.updates.get(&key)?.clone();
|
||||
let (source, id): (SourceId, String) = key;
|
||||
let chip = div()
|
||||
.id(SharedString::from(format!("mod-update-{id}")))
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.h(px(24.))
|
||||
.px(px(10.))
|
||||
.rounded(px(night::RADIUS_CONTROL))
|
||||
.bg(alpha(n.warn, 0.16))
|
||||
.cursor_pointer()
|
||||
.hover(move |s| s.bg(alpha(n.warn, 0.26)))
|
||||
.child(
|
||||
shell::text(night::CAPTION, c(n.warn))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(trf("Update {version}", &[("version", &number)])),
|
||||
);
|
||||
Some(match busy {
|
||||
true => chip.opacity(theme::DISABLED),
|
||||
false => chip.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.mods_install(source, id.clone(), None);
|
||||
cx.notify();
|
||||
})),
|
||||
})
|
||||
});
|
||||
let switch_row = row.clone();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(14.))
|
||||
.px(px(18.))
|
||||
.py(px(12.))
|
||||
.child(icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
shell::text(night::UI, c(n.ink))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(row.name.clone()),
|
||||
)
|
||||
.child(shell::text(night::CAPTION, c(n.muted)).child(row.version)),
|
||||
)
|
||||
.child(
|
||||
shell::text(night::CAPTION, c(n.muted))
|
||||
.truncate()
|
||||
.child(source),
|
||||
),
|
||||
)
|
||||
.children(update)
|
||||
.child(
|
||||
switch(format!("mod-switch-{}", row.file), row.on, &t).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.switch_mod(&switch_row);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_java_instance_lists_its_jars_on_and_off() {
|
||||
let home = std::env::temp_dir().join(format!("instance-mods-{}", std::process::id()));
|
||||
let mods = home.join("mods");
|
||||
std::fs::create_dir_all(&mods).unwrap();
|
||||
std::fs::write(mods.join("zeta-1.0.jar"), b"not a zip").unwrap();
|
||||
std::fs::write(mods.join("alpha-2.0.jar.disabled"), b"not a zip").unwrap();
|
||||
std::fs::write(mods.join("notes.txt"), b"").unwrap();
|
||||
let rows = read(&home, true);
|
||||
let seen: Vec<_> = rows.iter().map(|r| (r.name.as_str(), r.on)).collect();
|
||||
assert_eq!(seen, [("alpha-2.0", false), ("zeta-1.0", true)]);
|
||||
switch_jar(&home, "mods/alpha-2.0.jar", true).unwrap();
|
||||
assert!(read(&home, true).iter().all(|r| r.on));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+107
-21
@@ -16,9 +16,11 @@ mod datapacks_page;
|
||||
mod dev_options;
|
||||
mod export_page;
|
||||
mod game_tools;
|
||||
mod github_mods_page;
|
||||
mod home;
|
||||
mod home_tabs;
|
||||
mod import_page;
|
||||
mod instance_mods;
|
||||
mod instances_page;
|
||||
mod internet;
|
||||
mod language_page;
|
||||
@@ -177,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)>,
|
||||
@@ -190,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`).
|
||||
@@ -276,6 +282,8 @@ struct Launcher {
|
||||
download_seen: Option<(Instant, usize)>,
|
||||
/// The tab open under Home's hero (`home_tabs.rs`).
|
||||
home_tab: home_tabs::HomeTab,
|
||||
/// The selected instance's mods, for Home's Mods tab (`instance_mods.rs`).
|
||||
instance_mods: instance_mods::InstanceMods,
|
||||
play_pressed: Option<Instant>,
|
||||
/// `horizon-launcher benchmark` started this window to measure it.
|
||||
bench: Option<perf::Bench>,
|
||||
@@ -294,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`).
|
||||
@@ -416,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,
|
||||
@@ -480,6 +490,7 @@ impl Launcher {
|
||||
prepare_started: None,
|
||||
download_seen: None,
|
||||
home_tab: home_tabs::HomeTab::from_env(),
|
||||
instance_mods: Default::default(),
|
||||
play_pressed: None,
|
||||
bench: perf::Bench::from_env(),
|
||||
packs: Vec::new(),
|
||||
@@ -492,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(),
|
||||
@@ -602,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 =
|
||||
@@ -623,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
|
||||
@@ -654,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));
|
||||
@@ -862,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);
|
||||
@@ -894,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) => {
|
||||
@@ -1030,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);
|
||||
@@ -1097,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());
|
||||
@@ -1119,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());
|
||||
@@ -1737,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> {
|
||||
@@ -3080,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();
|
||||
}));
|
||||
@@ -3098,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| {
|
||||
@@ -3158,7 +3190,7 @@ impl Launcher {
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.build_native();
|
||||
this.build_native(false);
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
@@ -3173,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;
|
||||
@@ -3549,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,
|
||||
@@ -3589,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()
|
||||
@@ -3910,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();
|
||||
@@ -4285,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()];
|
||||
|
||||
+28
-15
@@ -73,8 +73,10 @@ pub(super) struct Mods {
|
||||
pub(super) report_list: cull::Cull,
|
||||
more: bool,
|
||||
error: Option<String>,
|
||||
installed: Vec<installed::Entry>,
|
||||
pub(super) installed: Vec<installed::Entry>,
|
||||
loaded: bool,
|
||||
/// Whether the page searched for the instance as it is (its version and loader).
|
||||
asked: bool,
|
||||
/// The game folder the browser installs into and lists: the selected instance's.
|
||||
home: Option<PathBuf>,
|
||||
/// The selected Java instance's version and loader: its mods and resource packs are the
|
||||
@@ -85,8 +87,8 @@ pub(super) struct Mods {
|
||||
world: Option<PathBuf>,
|
||||
/// The project being installed, and the line and fraction of its download.
|
||||
pub(super) busy: Option<(String, String, f32)>,
|
||||
message: Option<Result<String, String>>,
|
||||
updates: HashMap<(SourceId, String), String>,
|
||||
pub(super) message: Option<Result<String, String>>,
|
||||
pub(super) updates: HashMap<(SourceId, String), String>,
|
||||
confirm_remove: Option<String>,
|
||||
/// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`).
|
||||
native_notice: Option<(SourceId, String, Option<Project>)>,
|
||||
@@ -166,6 +168,7 @@ impl Mods {
|
||||
error: None,
|
||||
installed: Vec::new(),
|
||||
loaded: false,
|
||||
asked: false,
|
||||
home: None,
|
||||
java: None,
|
||||
worlds: Vec::new(),
|
||||
@@ -465,7 +468,7 @@ impl Launcher {
|
||||
|
||||
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does;
|
||||
/// in a Java instance, renames its file.
|
||||
fn mods_switch(&mut self, e: &installed::Entry) {
|
||||
pub(super) fn mods_switch(&mut self, e: &installed::Entry) {
|
||||
let home = self.mods.home();
|
||||
if self.mods.java.is_some() {
|
||||
let on = !java_instance::is_on(&home, e);
|
||||
@@ -580,7 +583,7 @@ impl Launcher {
|
||||
|
||||
/// Updates every installed pack a newer version was found for, one after the other on a
|
||||
/// thread, each checked as an install is. One failing doesn't stop the others.
|
||||
fn mods_update_all(&mut self) {
|
||||
pub(super) fn mods_update_all(&mut self) {
|
||||
if self.mods.busy.is_some() || self.mods.updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -827,18 +830,18 @@ impl Launcher {
|
||||
line_edit::mouse(field, cx, |this| &mut this.mods.text)
|
||||
}
|
||||
|
||||
pub(super) fn mods_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
// Packs and mods go into the selected instance: picking another reads its list.
|
||||
/// Reads what the browser installed in the selected instance (once per instance) and looks
|
||||
/// for updates: the Mods page and Home's Mods tab (`instance_mods.rs`) show it. Packs and mods
|
||||
/// go into the selected instance: picking another reads its list.
|
||||
pub(super) fn mods_load(&mut self) {
|
||||
let home = self.selected_instance().map(|i| i.home.clone());
|
||||
let java = self
|
||||
.selected_instance()
|
||||
.and_then(|i| i.java.as_ref())
|
||||
.map(|j| j.target());
|
||||
// Another version or loader: other results.
|
||||
if self.mods.loaded && java != self.mods.java {
|
||||
self.mods.java = java.clone();
|
||||
self.mods_search(0);
|
||||
// Another version or loader: other results, on the page's next draw.
|
||||
if java != self.mods.java {
|
||||
self.mods.asked = false;
|
||||
}
|
||||
self.mods.java = java;
|
||||
if !self.mods.loaded || (home.is_some() && self.mods.home != home) {
|
||||
@@ -850,11 +853,17 @@ impl Launcher {
|
||||
self.mods.read_worlds();
|
||||
self.mods_check_updates();
|
||||
self.mods_taken_down();
|
||||
if !self.mods.loaded {
|
||||
self.mods_search(0);
|
||||
}
|
||||
self.mods.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mods_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
self.mods_load();
|
||||
if !self.mods.asked {
|
||||
self.mods.asked = true;
|
||||
self.mods_search(0);
|
||||
}
|
||||
let header = page_header(
|
||||
"Mods",
|
||||
"Mods and shader packs made for Horizon from its own reviewed registry, and Java \
|
||||
@@ -953,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
@@ -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!(
|
||||
|
||||
@@ -38,6 +38,8 @@ pub(super) enum Field {
|
||||
InstanceGroup,
|
||||
/// Its search in Mojang's versions (`new_instance.rs`).
|
||||
InstanceVersion,
|
||||
/// Home's Mods tab's filter (`instance_mods.rs`).
|
||||
InstanceModsFilter,
|
||||
/// Home's Multiplayer section's (`multiplayer_page.rs`).
|
||||
ServerName,
|
||||
ServerAddress,
|
||||
@@ -54,7 +56,7 @@ pub(super) enum Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
const ALL: [Field; 21] = [
|
||||
const ALL: [Field; 22] = [
|
||||
Field::Name,
|
||||
Field::Seed,
|
||||
Field::Players,
|
||||
@@ -67,6 +69,7 @@ impl Field {
|
||||
Field::InstanceName,
|
||||
Field::InstanceGroup,
|
||||
Field::InstanceVersion,
|
||||
Field::InstanceModsFilter,
|
||||
Field::ServerName,
|
||||
Field::ServerAddress,
|
||||
Field::ConsoleSearch,
|
||||
@@ -999,6 +1002,7 @@ impl Launcher {
|
||||
Field::InstanceName
|
||||
| Field::InstanceGroup
|
||||
| Field::InstanceVersion
|
||||
| Field::InstanceModsFilter
|
||||
| Field::ConsoleSearch
|
||||
| Field::DeleteServer => {}
|
||||
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv | Field::PostExit => {
|
||||
|
||||
@@ -79,6 +79,41 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -274,6 +274,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- merchant-close: the host and the guest each open a trade screen with the payment in its slot; the screen
|
||||
closes and the payment returns when they walk out of reach (7 blocks in survival, 9 in
|
||||
creative) or the villager dies, and stays open in range.
|
||||
- sulfur_cube_power: an explosive sulfur cube (TNT inside) stays calm on stone, lights its fuse when a redstone
|
||||
block is put beside it, and blows up when the fuse runs out; the guest sees it flash.
|
||||
- riptide_rain: the host and the guest each launch with a Riptide trident in rain, with an elytra worn or not (#3458).
|
||||
- hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I
|
||||
and 12 with Hero III; clearing the effect restores the ordinary price (#1365).
|
||||
@@ -7289,6 +7291,48 @@ def check_riptide_rain(host, guest, out):
|
||||
CHECKS += ['riptide_rain']
|
||||
CHECK_FNS['riptide_rain'] = check_riptide_rain
|
||||
|
||||
|
||||
def check_sulfur_cube_power(host, guest, out):
|
||||
"""SulfurCube.primeWhenOnPoweredPosition: an explosive cube standing in or beside redstone power lights
|
||||
its fuse (getBestOwnOrNeighbourSignal), then explodes when the fuse is out."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
tag = 'power_cube'
|
||||
result = {}
|
||||
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 3} {y + 3} {z + 3} minecraft:stone')
|
||||
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:air')
|
||||
host('command', command=f'fill {x - 2} {y + 3} {z - 2} {x + 2} {y + 3} {z + 2} minecraft:glowstone')
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 180 25')
|
||||
host('command', command=f'tp @s {x + 40} {y} {z + 40}')
|
||||
guest.raw('key', 'f1', 'tap')
|
||||
host('command', command='gamerule minecraft:mob_griefing false')
|
||||
host('command', command=f'summon minecraft:sulfur_cube {x + 0.5} {y} {z - 1.5} '
|
||||
f'{{Tags:["{tag}"],Size:1,PersistenceRequired:1b,equipment:{{body:{{id:"minecraft:tnt",count:1}}}}}}')
|
||||
host.wait(40)
|
||||
calm = guest('entities', radius=12, type='sulfur_cube')['entities']
|
||||
result['calm'] = calm
|
||||
assert len(calm) == 1, f'the cube is missing: {result}'
|
||||
guest.shot(out, 'sulfur-cube-calm')
|
||||
host('command', command=f'setblock {x} {y - 1} {z - 1} minecraft:redstone_block')
|
||||
host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block')
|
||||
host('command', command=f'setblock {x + 1} {y - 1} {z - 1} minecraft:redstone_block')
|
||||
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:redstone_block')
|
||||
host.wait(8)
|
||||
guest.shot(out, 'sulfur-cube-primed')
|
||||
data = host('command', command=f'data get entity @e[tag={tag},limit=1]')['chat'][0]
|
||||
result['fuse'] = data.split('fuse: ')[1].split(',')[0]
|
||||
assert int(result['fuse']) > 0 and 'Invulnerable: 1b' in data, f'the powered cube is not primed: {result}'
|
||||
host.wait(100)
|
||||
guest.shot(out, 'sulfur-cube-flash')
|
||||
host.wait(40)
|
||||
gone = guest('entities', radius=12, type='sulfur_cube')['entities']
|
||||
result['after'] = gone
|
||||
assert not gone, f'the powered cube did not explode: {result}'
|
||||
return result
|
||||
|
||||
|
||||
CHECKS += ['sulfur_cube_power']
|
||||
CHECK_FNS['sulfur_cube_power'] = check_sulfur_cube_power
|
||||
|
||||
CHECKS += ['hero-trading']
|
||||
CHECK_FNS.update({'hero-trading': check_hero_trading})
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ ItemAttributeModifiers.forEach inline crates/horizon-client/src/item_tooltip.rs:
|
||||
ItemAttributeModifiers.withModifierAdded missing minor: the loot set_attributes function (SetAttributesFunction) is not ported; no loot path adds a modifier to a stack
|
||||
ItemAttributeModifiers.Builder.add skip:java
|
||||
ItemAttributeModifiers.Entry.matches inline crates/horizon-world/src/attributes/mod.rs::Instance::add_or_replace_permanent modifier identity by id within one attribute
|
||||
OminousBottleAmplifier.addToTooltip missing player: an ominous bottle's tooltip shows no Bad Omen line for its level; Horizon's potion tooltip covers potion items only
|
||||
OminousBottleAmplifier.addToTooltip port crates/horizon-client/src/item_tooltip.rs::ominous_bottle_line the Bad Omen line of an ominous bottle, listed in the stack tooltip (stack_tooltip_lines)
|
||||
SimpleMutableContainer.modifySlots port crates/horizon-client/src/game/commands/items.rs::Game::cmd_item_modify each slot is visited and set
|
||||
SimpleMutableContainer.replaceSlotItems port crates/horizon-client/src/game/commands/items.rs::Game::cmd_item /item replace fills slots from the item list
|
||||
SulfurCubeContent.addToTooltip missing minor: Horizon never writes sulfur_cube_content, so no item shows the absorbed block line
|
||||
SuspiciousStewEffects.addToTooltip missing player: in creative, a suspicious stew's tooltip should list its effects; Horizon's stack tooltip shows none
|
||||
SuspiciousStewEffects.addToTooltip port crates/horizon-client/src/item_tooltip.rs::stew_lines the effects of a suspicious stew, in the stack tooltip (stack_tooltip_lines)
|
||||
TypedEntityData.addToTooltip missing player: in Peaceful, a hostile mob's spawn egg should show a red Peaceful line; Horizon shows only the OP-only warning
|
||||
TypedEntityData.equals skip:java
|
||||
TypedEntityData.stripId skip:other the stored entity data keeps its id; block_entity_item.rs reads it from the tag
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1 and column 84.
|
@@ -139,7 +139,7 @@ SetEnchantmentsFunction.validate skip:data validation at datapack load
|
||||
SetEnchantmentsFunction.Builder skip:java builder
|
||||
SetFireworkExplosionFunction.apply missing minor: no vanilla loot table uses set_firework_explosion
|
||||
SetFireworksFunction.apply missing minor: no vanilla loot table uses set_fireworks
|
||||
SetInstrumentFunction.run port crates/horizon-world/src/loot.rs::Context::run gap: player: only draws the random option; the goat horn's instrument component is never set (pillager outpost horns)
|
||||
SetInstrumentFunction.run port crates/horizon-world/src/loot.rs::Context::run the random option becomes the item's minecraft:instrument component
|
||||
SetItemCountFunction.run port crates/horizon-world/src/loot.rs::Context::run set_count arm
|
||||
SetItemCountFunction.setCount skip:java builder factory
|
||||
SetItemDamageFunction.run port crates/horizon-world/src/loot.rs::Context::run set_damage arm
|
||||
|
||||
|
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user