{
+ result[key]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|v| v.as_f64())
+ .map(|v| v as f32)
+ .collect()
+ };
+ let (ui, scroll) = (times("ui_ms"), times("scroll_ms"));
report
.frames
.extend(Frames::of("pages", repeat as u32, &ui, &[], &[]));
+ report
+ .frames
+ .extend(Frames::of("scroll", repeat as u32, &scroll, &[], &[]));
+ let mut sorted = scroll.clone();
+ sorted.sort_by(f32::total_cmp);
+ let at = |q: f32| {
+ sorted
+ .get(((sorted.len() as f32 - 1.) * q) as usize)
+ .copied()
+ };
eprintln!(
- "start {}: {:.0} ms to the first frame",
+ "start {}: {:.0} ms to the first frame; scrolling {} frames, median {:.1} ms, p95 {:.1} ms",
repeat + 1,
- starts.last().unwrap_or(&0.0)
+ starts.last().unwrap_or(&0.0),
+ sorted.len(),
+ at(0.5).unwrap_or(0.),
+ at(0.95).unwrap_or(0.)
);
}
report.metric("launcher.cold_start_ms", &starts[..starts.len().min(1)]);
@@ -364,6 +412,7 @@ mod tests {
began: None,
page: 0,
idle_cpu: None,
+ scroll: None,
};
assert!(matches!(bench.tick(&perf), Step::Wait));
perf.frame();
diff --git a/launcher/src/scrollbar.rs b/launcher/src/scrollbar.rs
index 87cdbee77..757d3525e 100644
--- a/launcher/src/scrollbar.rs
+++ b/launcher/src/scrollbar.rs
@@ -49,7 +49,7 @@ impl Scrollbar {
/// Wrap the scroll container, reserving a narrow gutter so the bar never covers controls.
pub fn wrap(&self, content: Stateful, theme: &Theme) -> Div {
let state = self.clone();
- let track_color = theme.rule;
+ let track_color = theme.well;
let thumb_color = theme.muted;
div()
.relative()
@@ -83,17 +83,14 @@ impl Scrollbar {
return;
};
let hitbox = hitbox.unwrap();
- window.paint_quad(fill(bounds, track_color).corner_radii(px(3.)));
- window.paint_quad(
- fill(
- Bounds::new(
- point(bounds.left() + px(3.), bounds.top() + px(g.top)),
- size(bounds.size.width - px(6.), px(g.thumb)),
- ),
- thumb_color,
- )
- .corner_radii(px(3.)),
- );
+ window.paint_quad(fill(bounds, track_color));
+ window.paint_quad(fill(
+ Bounds::new(
+ point(bounds.left() + px(3.), bounds.top() + px(g.top)),
+ size(bounds.size.width - px(6.), px(g.thumb)),
+ ),
+ thumb_color,
+ ));
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, cx| {
diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs
index b1338f1aa..b1e7eb775 100644
--- a/launcher/src/servers_page.rs
+++ b/launcher/src/servers_page.rs
@@ -114,6 +114,18 @@ pub(super) struct Create {
eula_missing: bool,
}
+/// What the sidebar shows of the Servers page: its list once the page has read it, and whether
+/// a server answers.
+impl Servers {
+ pub(super) fn sidebar_list(&self) -> Option<&[Entry]> {
+ self.loaded.then_some(&self.list[..])
+ }
+
+ pub(super) fn is_up(&self, dir: &std::path::Path) -> bool {
+ self.statuses.get(dir).is_some_and(|s| s.phase == "running")
+ }
+}
+
pub(super) struct Servers {
list: Vec,
loaded: bool,
@@ -272,12 +284,10 @@ pub(super) fn stop_on_quit() {
/// The player's own name, to let them in on a friends-only server: the Microsoft account that plays,
/// else the name the game made up for them (`horizon-player.txt`).
fn own_name(accounts: &Accounts) -> Option {
- accounts.selected().map(|a| a.name.clone()).or_else(|| {
- std::fs::read_to_string(backend::game_home().join("horizon-player.txt"))
- .ok()
- .map(|n| n.trim().to_string())
- .filter(|n| !n.is_empty())
- })
+ accounts
+ .selected()
+ .map(|a| a.name.clone())
+ .or_else(crate::offline_name)
}
fn bytes(n: u64) -> String {
diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs
index 579259661..6425232c5 100644
--- a/launcher/src/theme.rs
+++ b/launcher/src/theme.rs
@@ -1,19 +1,29 @@
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`, described in
-//! `docs/design-system.md`), which the game's menus and the website share: cool neutrals in two
-//! appearances and one violet accent, Geist and Geist Mono (bundled, SIL Open Font License).
+//! `docs/design-system.md`): the blocky look it shares with the website ("Blocks"), stone-dark or
+//! lavender-light with Horizon's violet, Pixelify Sans for headings and controls, Geist and Geist
+//! Mono for text (all bundled, SIL Open Font License).
use gpui::{rgb, Hsla, WindowAppearance};
+use horizon_launcher::backend::Appearance;
use horizon_theme as tokens;
use std::time::Duration;
pub use tokens::{Step, BODY, CAPTION, DISPLAY, HEADING, LABEL, MICRO, TITLE, UI, WORDMARK};
pub const FONT: &str = tokens::FONT;
pub const MONO: &str = tokens::MONO;
+pub const PIXEL: &str = tokens::PIXEL;
+pub use tokens::{BLOCK_BEVEL as BEVEL, BLOCK_LIP as LIP};
/// Shapes and controls, shared with the game's menus and the website.
-pub use tokens::{
- CONTROL, CONTROL_LARGE, DISABLED, FIELD, GUTTER, INDEX, PLATE_BLEED, PRESSED, RADIUS,
- RADIUS_PILL, RADIUS_PLATE, RISE, ROW, SEGMENT,
-};
+pub use tokens::{CONTROL_LARGE, DISABLED, FIELD, PRESSED, RISE, ROW, SEGMENT};
+
+/// Blocks have square corners: these stay for the places that ask for a radius.
+pub const RADIUS: f32 = 0.;
+pub const RADIUS_PLATE: f32 = 0.;
+/// Buttons are a little taller than the quiet look's, for their lip.
+pub const CONTROL: f32 = 34.;
+/// The top bar, and the sidebar of instances and servers.
+pub const TOPBAR: f32 = 56.;
+pub const SIDEBAR: f32 = 248.;
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = tokens::QUICK.duration();
@@ -23,9 +33,15 @@ pub const STAGGER: Duration = tokens::STAGGER;
#[derive(Clone, Copy)]
pub struct Theme {
+ /// The page behind everything (under its stone texture), and its colour as `0xRRGGBB` for
+ /// the texture.
pub page: Hsla,
+ pub page_rgb: u32,
+ /// Hover plates, fields and the console: the raised panel colour.
pub plate_soft: Hsla,
+ /// Pressed plates, a switch that is off: the button colour.
pub plate: Hsla,
+ /// Dialogs and menus above the page: the panel colour.
pub surface: Hsla,
pub rule: Hsla,
pub ink: Hsla,
@@ -35,6 +51,21 @@ pub struct Theme {
pub on_accent: Hsla,
pub danger: Hsla,
pub success: Hsla,
+ pub warn: Hsla,
+ /// The blocky parts (`horizon_theme::Blocks`).
+ pub side: Hsla,
+ pub panel: Hsla,
+ pub raised: Hsla,
+ pub button: Hsla,
+ pub button_bevel: Hsla,
+ pub well: Hsla,
+ pub bevel: Hsla,
+ pub lip: Hsla,
+ pub outline: Hsla,
+ pub face: Hsla,
+ pub face_bevel: Hsla,
+ pub face_lip: Hsla,
+ pub on_face: Hsla,
pub dark: bool,
}
@@ -43,42 +74,61 @@ fn c(hex: u32) -> Hsla {
}
impl Theme {
- /// The system's appearance; `HORIZON_LAUNCHER_APPEARANCE=light` or `dark` overrides it (for
+ /// The player's choice in Settings (dark unless they picked another), the system's when they
+ /// asked to follow it. `HORIZON_LAUNCHER_APPEARANCE=light` or `dark` overrides both (for
/// screenshots and tests).
- pub fn for_appearance(appearance: WindowAppearance) -> Self {
+ pub fn for_appearance(choice: Appearance, system: WindowAppearance) -> Self {
match std::env::var("HORIZON_LAUNCHER_APPEARANCE").as_deref() {
Ok("light") => return Self::light(),
Ok("dark") => return Self::dark(),
_ => {}
}
- match appearance {
- WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
+ match (choice, system) {
+ (Appearance::Light, _) => Self::light(),
+ (Appearance::System, WindowAppearance::Light | WindowAppearance::VibrantLight) => {
+ Self::light()
+ }
_ => Self::dark(),
}
}
pub fn dark() -> Self {
- Self::from(tokens::DARK)
+ Self::from(tokens::BLOCKS_DARK)
}
pub fn light() -> Self {
- Self::from(tokens::LIGHT)
+ Self::from(tokens::BLOCKS_LIGHT)
}
- fn from(p: tokens::Palette) -> Self {
+ fn from(p: tokens::Blocks) -> Self {
Self {
page: c(p.page),
- plate_soft: c(p.plate_soft),
- plate: c(p.plate),
- surface: c(p.surface),
+ page_rgb: p.page,
+ plate_soft: c(p.raised),
+ plate: c(p.button),
+ surface: c(p.panel),
rule: c(p.rule),
ink: c(p.ink),
ink_soft: c(p.ink_soft),
muted: c(p.muted),
accent: c(p.accent),
- on_accent: c(p.on_accent),
+ on_accent: c(p.on_face),
danger: c(p.danger),
success: c(p.success),
+ warn: c(p.warn),
+ side: c(p.side),
+ panel: c(p.panel),
+ raised: c(p.raised),
+ button: c(p.button),
+ button_bevel: c(p.button_bevel),
+ well: c(p.well),
+ bevel: c(p.bevel),
+ lip: c(p.lip),
+ outline: c(p.outline),
+ face: c(p.face),
+ face_bevel: c(p.face_bevel),
+ face_lip: c(p.face_lip),
+ on_face: c(p.on_face),
dark: p.dark,
}
}
@@ -105,13 +155,14 @@ mod tests {
/// field (WCAG 1.4.11 asks 3:1 for those), and text over a selected suggestion.
#[test]
fn marks_and_selections_read_clearly() {
- for p in [tokens::LIGHT, tokens::DARK] {
+ for p in [tokens::BLOCKS_LIGHT, tokens::BLOCKS_DARK] {
let theme = if p.dark { "dark" } else { "light" };
for (name, bg) in [
("page", p.page),
- ("plate_soft", p.plate_soft),
- ("plate", p.plate),
- ("surface", p.surface),
+ ("raised", p.raised),
+ ("button", p.button),
+ ("panel", p.panel),
+ ("well", p.well),
] {
let ring = tokens::contrast(p.accent, bg);
assert!(ring >= 3.0, "{theme} focus ring on {name}: {ring:.2}");
@@ -121,7 +172,7 @@ mod tests {
"{theme} refused field on {name}: {refused:.2}"
);
}
- let selection = blend(p.accent, p.surface, SELECTION);
+ let selection = blend(p.accent, p.panel, SELECTION);
let ratio = tokens::contrast(p.ink, selection);
assert!(ratio >= 4.5, "{theme} text on a selection: {ratio:.2}");
}
diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py
index 894bb8627..c5a90150f 100755
--- a/tools/lan_e2e.py
+++ b/tools/lan_e2e.py
@@ -123,6 +123,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
it (#1222).
- frames: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back (#1222).
+- mounts: the host and the guest each open a tamed horse's, donkey's and llama's inventory
+ (shift + right click, then the inventory key while riding), put on a saddle, armour
+ or a carpet and fill the chest by shift-clicking.
- riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider
(with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and
get off by sneaking; the other side sees the animal move.
@@ -169,7 +172,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
-CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding']
+CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts']
class Game:
@@ -2638,6 +2641,101 @@ def check_riding(host, guest, out):
return result
+# Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands
+# in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot).
+MOUNT_MENUS = [
+ ('horse', '{Tame:1b,NoAI:1b}', [('minecraft:saddle', 700), ('minecraft:iron_horse_armor', 701)]),
+ ('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', [('minecraft:saddle', 700), ('minecraft:dirt', 710)]),
+ ('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}',
+ [('minecraft:red_carpet', 701), ('minecraft:dirt', 710)]),
+]
+
+
+def mount_screen(game, ticks=20):
+ """The open mount menu's slots and items ({700: 'minecraft:saddle', ...}), waiting up to
+ `ticks` for it to open; None when no mount menu opened."""
+ for _ in range(ticks // 2):
+ slots = {}
+ for w in game.raw('ui').get('widgets', []):
+ wid = w.get('id', '')
+ if wid.startswith('slot-') and wid[5:].isdigit() and int(wid[5:]) >= 700:
+ slots[int(wid[5:])] = (w.get('stack') or {}).get('item')
+ if slots:
+ return slots
+ game.wait(2)
+ return None
+
+
+def check_mounts(host, guest, out):
+ """The host and the guest each open a tamed horse's, donkey's and llama's inventory with
+ shift + right click (`ClientboundMountScreenOpenPacket` for a server's player), put on a
+ saddle, horse armour or a carpet and fill the chest by shift-clicking, close it, then ride the
+ mount and open its inventory with the inventory key (OPEN_INVENTORY): it shows what was put
+ in, and the server took the items from the inventory."""
+ spots = apart(host, guest)
+ result = {}
+ for label, player in (('host', host), ('guest', guest)):
+ x, y, z = spots[label]
+ player('command', command='gamemode survival')
+ result[label] = {}
+ for kind, nbt, puts in MOUNT_MENUS:
+ player('command', command='clear @s')
+ for item, _ in puts:
+ player('command', command=f'give @s {item}')
+ player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 2.5} {nbt}')
+ player.wait(5)
+ found = [m for m in player.raw('mobs', 16).get('near', [])
+ if m['kind'].endswith(kind) and m['health'] > 0]
+ assert found, f'the {label} sees no {kind}'
+ mob = found[0]
+ p = mob['pos']
+ aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
+ try:
+ player.raw('look-at', *aim)
+ player.raw('key', 'shift', 'down')
+ player.wait(3)
+ player.raw('mouse', 'right', 'click')
+ player.wait(5)
+ player.raw('key', 'shift', 'up')
+ slots = mount_screen(player)
+ assert slots is not None, f"the {label}'s shift + right click on the {kind} opened no inventory"
+ for item, _ in puts:
+ player.raw('click-slot', hotbar_slot(player, item), 'shift')
+ player.wait(5)
+ slots = mount_screen(player)
+ for item, slot in puts:
+ assert slots.get(slot) == item, f"the {label}'s {kind}: {item} shift-clicked, slot {slot} holds {slots.get(slot)}"
+ player.raw('key', 'esc', 'tap')
+ player.wait(5)
+ for item, _ in puts:
+ left = server_count(player, item.split(':')[1])
+ assert left == 0, f'the server still counts {left} {item} on the {label}'
+ player.raw('look-at', *aim)
+ player.raw('mouse', 'right', 'click')
+ player.wait(8)
+ riding = player('status').get('riding')
+ assert riding == mob['id'], f'the {label} right-clicked its {kind}: riding {riding}'
+ player.raw('key', 'e', 'tap')
+ again = mount_screen(player)
+ assert again is not None, f'the inventory key on the {label}\'s {kind} opened no inventory'
+ for item, slot in puts:
+ assert again.get(slot) == item, f"the {label}'s {kind} reopened: slot {slot} holds {again.get(slot)}, not {item}"
+ player.raw('key', 'esc', 'tap')
+ player.wait(3)
+ hold(player, 'shift', 10)
+ player.wait(5)
+ result[label][kind] = {slot: item for item, slot in puts}
+ finally:
+ back_to_game(player)
+ player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
+ player.wait(2)
+ player('command', command=f'kill @e[type=minecraft:item,x={x},y={y},z={z},distance=..16]')
+ player('command', command='clear @s')
+ player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
+ player.wait(5)
+ return result
+
+
def check_lightning(host, guest, out):
"""A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the
host's window too on its server thread, FEATURES 4z(f))."""
@@ -2977,7 +3075,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
'light': check_light, 'plates': check_plates,
'swings': check_swings, 'cracks': check_cracks,
- 'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding}
+ 'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts}
def run(ctl, out, resources, checks):
diff --git a/tools/mod_browser_harness.py b/tools/mod_browser_harness.py
new file mode 100755
index 000000000..4531570bc
--- /dev/null
+++ b/tools/mod_browser_harness.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""End-to-end harness for Java mods from the launcher's mod browser (docs/mod-browser.md).
+
+ mod_browser_harness.py [--mod SLUG] [--query TEXT] [--keep]
+
+In a scratch game folder, as a player would through the Mods page (its CLI):
+
+1. `horizon-launcher mods search --kind mod TEXT` finds the mod on Modrinth;
+2. `horizon-launcher mods check SLUG` downloads its jar (checksum checked) and says Works or Partly;
+3. `horizon-launcher mods install SLUG` ports it into horizon-mods/ and turns it on;
+4. the game opens a survival world with it (horizon-ctl, offscreen), and
+5. a ported block item is given, held, and placed: the world then holds the mod's block;
+6. a ported recipe with vanilla ingredients is crafted on a crafting table: the result slot shows
+ the mod's item and taking it puts it in the inventory.
+
+It also times the install and the world's load with the mod against the same world with none
+(a ported mod must not slow a game that doesn't use it; this measures what one that does costs).
+Prints one JSON line per step and a summary; exits 1 when a step fails.
+
+Needs the launcher (launcher/target/debug/horizon-launcher, built with
+`--features gpui/runtime_shaders` on macOS), the game and horizon-ctl
+(target/fast/horizon-client, target/fast/horizon-ctl, or --bin DIR) and the prepared resources in
+.local/horizon-client/26.3. Downloads from Modrinth.
+"""
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class Failed(Exception):
+ pass
+
+
+def run(cmd, env, cwd=None, timeout=600):
+ p = subprocess.run(cmd, env=env, cwd=cwd, capture_output=True, text=True, timeout=timeout)
+ if p.returncode != 0:
+ raise Failed(f"{' '.join(map(str, cmd))}: exit {p.returncode}: {p.stderr.strip()[-800:] or p.stdout.strip()[-800:]}")
+ return p.stdout
+
+
+def report(step, **fields):
+ print(json.dumps({"step": step, **fields}), flush=True)
+
+
+class Game:
+ def __init__(self, ctl, client, resources, home, env):
+ self.ctl, self.client, self.resources, self.home, self.env = ctl, client, resources, home, env
+ self.socket = str(home / "ctl.sock")
+ self.pid = None
+
+ def __call__(self, *args, timeout=120):
+ out = run([self.ctl, "--socket", self.socket, "--timeout", str(timeout), *map(str, args)], self.env,
+ timeout=timeout + 30)
+ return json.loads(out.strip().splitlines()[-1])
+
+ def launch(self, world):
+ start = time.monotonic()
+ state = self("launch", "--game", self.client, "--world", world, "--seed", "1234", "--wait", "--offscreen",
+ "--resources", self.resources, timeout=300)
+ self.pid = state.get("launch", {}).get("pid")
+ return time.monotonic() - start, state
+
+ def quit(self):
+ try:
+ self("quit", timeout=30)
+ except Exception:
+ pass
+ if self.pid:
+ for _ in range(50):
+ try:
+ os.kill(self.pid, 0)
+ except OSError:
+ break
+ time.sleep(0.2)
+ else:
+ os.kill(self.pid, 9)
+ self.pid = None
+
+
+def resources_loaded(log):
+ """Seconds the game's log says its resources took."""
+ m = re.findall(r"resources loaded in ([0-9.]+)s", Path(log).read_text(errors="replace"))
+ return float(m[-1]) if m else None
+
+
+def pick_block(mod):
+ """A block the carry declares with its item."""
+ for f in sorted((mod / "data").glob("*/horizon/block/*.json")):
+ decl = json.loads(f.read_text())
+ if decl.get("item", True):
+ return f"{f.parts[-4]}:{f.stem}"
+ raise Failed("the carry declares no block with an item")
+
+
+def pick_recipe(mod, ns):
+ """A crafting recipe of the mod's whose result is its own and whose ingredients are plain vanilla
+ item ids, shaped first."""
+ found = []
+ for f in sorted((mod / "data").glob("*/recipe/**/*.json")):
+ try:
+ r = json.loads(f.read_text())
+ except ValueError:
+ continue
+ result = (r.get("result") or {}).get("id", "")
+ if not result.startswith(ns + ":"):
+ continue
+ if r.get("type") == "minecraft:crafting_shaped":
+ keys = r.get("key", {})
+ if all(isinstance(v, str) and v.startswith("minecraft:") for v in keys.values()) and \
+ len(r["pattern"]) <= 3 and all(len(row) <= 3 for row in r["pattern"]):
+ found.append((0, f.name, r))
+ elif r.get("type") == "minecraft:crafting_shapeless":
+ ing = r.get("ingredients", [])
+ if ing and len(ing) <= 9 and all(isinstance(v, str) and v.startswith("minecraft:") for v in ing):
+ found.append((1, f.name, r))
+ if not found:
+ raise Failed("no crafting recipe of the mod's with vanilla ingredients")
+ return sorted(found, key=lambda x: (x[0], x[1]))[0][2]
+
+
+def grid_cells(recipe):
+ """(slot, item) for each grid cell of a 3x3 crafting table (slots 100-108)."""
+ if recipe["type"] == "minecraft:crafting_shaped":
+ return [(100 + 3 * y + x, recipe["key"][c]) for y, row in enumerate(recipe["pattern"])
+ for x, c in enumerate(row) if c != " "]
+ return [(100 + i, item) for i, item in enumerate(recipe["ingredients"])]
+
+
+def inventory_slot(game, item):
+ inv = game("inventory")
+ for s in inv["hotbar"] + inv["main"]:
+ if s.get("item") == item:
+ return s["slot"]
+ raise Failed(f"{item} is not in the inventory: {inv.get('counts')}")
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--mod", default="macaws-fences-and-walls", help="Modrinth slug of a Works or Partly mod")
+ ap.add_argument("--query", default="macaw", help="the search text that finds it")
+ ap.add_argument("--launcher", default=str(ROOT / "launcher/target/debug/horizon-launcher"))
+ ap.add_argument("--bin", default=str(ROOT / "target/fast"), help="folder with horizon-client and horizon-ctl")
+ ap.add_argument("--resources", default=str(ROOT / ".local/horizon-client/26.3"))
+ ap.add_argument("--keep", action="store_true", help="keep the scratch game folder")
+ args = ap.parse_args()
+
+ home = Path(tempfile.mkdtemp(prefix="horizon-mod-browser-"))
+ env = dict(os.environ, HORIZON_HOME=str(home), HORIZON_NO_AUDIO="1", HORIZON_NO_HEARTBEAT="1",
+ HORIZON_CONTROL=str(home / "ctl.sock"))
+ launcher = [args.launcher]
+ game = Game(str(Path(args.bin) / "horizon-ctl"), str(Path(args.bin) / "horizon-client"),
+ args.resources, home, env)
+ summary = {"mod": args.mod}
+ ok = False
+ try:
+ # A baseline world first, with no mod: the same world's load time with the mod is compared.
+ base_home = home / "vanilla"
+ base_home.mkdir()
+ base = Game(game.ctl, game.client, args.resources, base_home,
+ dict(env, HORIZON_HOME=str(base_home), HORIZON_CONTROL=str(base_home / "ctl.sock")))
+ base.socket = str(base_home / "ctl.sock")
+ os.chdir(base_home)
+ took, _ = base.launch("browser")
+ summary["world_load_without_mods_s"] = round(took, 2)
+ summary["resources_without_mods_s"] = resources_loaded(base_home / "ctl.log")
+ base.quit()
+
+ found = json.loads(run(launcher + ["--json", "mods", "search", "--kind", "mod", *args.query.split()], env))
+ slugs = [p["slug"] for p in found]
+ report("search", query=args.query, results=len(found), found=args.mod in slugs)
+ if args.mod not in slugs:
+ raise Failed(f"{args.mod} not in the results: {slugs[:10]}")
+
+ start = time.monotonic()
+ check = json.loads(run(launcher + ["--json", "mods", "check", args.mod], env).strip().splitlines()[-1])
+ summary["check_s"] = round(time.monotonic() - start, 2)
+ report("check", verdict=check["label"], summary=check["summary"], seconds=summary["check_s"])
+ if check["verdict"] not in ("works", "partly"):
+ raise Failed(f"{args.mod} doesn't run: {check['summary']}")
+
+ start = time.monotonic()
+ run(launcher + ["mods", "install", args.mod], env)
+ summary["install_s"] = round(time.monotonic() - start, 2)
+ mod_id = check["mod_id"]
+ mod = home / "horizon-mods" / mod_id
+ on = (home / "horizon-mods.txt").read_text()
+ report("install", folder=str(mod.relative_to(home)), turned_on=f"mod.{mod_id}:true" in on,
+ seconds=summary["install_s"])
+ if not (mod / "horizon-mod.toml").is_file() or f"mod.{mod_id}:true" not in on:
+ raise Failed("not installed or not turned on")
+
+ os.chdir(home)
+ took, state = game.launch("browser")
+ summary["world_load_with_mod_s"] = round(took, 2)
+ summary["resources_with_mod_s"] = resources_loaded(home / "ctl.log")
+ log = (home / "ctl.log").read_text(errors="replace")
+ loaded = "content from 1 mod(s)" in log
+ report("world", seconds=round(took, 2), content_loaded=loaded, screen=state.get("screen"))
+ if not loaded:
+ raise Failed("the game didn't load the mod's content")
+
+ x, y, z = (int(v // 1) for v in state["player"]["position"])
+ block = pick_block(mod)
+ game("command", "gamemode survival")
+ game("command", f"fill {x + 1} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone")
+ game("command", f"fill {x + 1} {y} {z - 2} {x + 4} {y + 2} {z + 2} minecraft:air")
+ game("give", block, 1)
+ game("select", block)
+ held = game("state")["held"]
+ placed = game("place", x + 2, y, z, "--item", block)
+ at = game("block", x + 2, y, z)
+ report("place", block=block, held=held, placed=at.get("block"))
+ if at.get("block") != block:
+ raise Failed(f"placing {block} left {at.get('block')}")
+
+ recipe = pick_recipe(mod, mod_id)
+ result = recipe["result"]["id"]
+ # A server command: horizon-ctl's own setblock changes the client's world only.
+ game("command", f"setblock {x + 2} {y} {z + 2} minecraft:crafting_table")
+ cells = grid_cells(recipe)
+ game("select", 9)
+ game("interact", x + 2, y, z + 2)
+ if game("screen").get("craft_size") != 3:
+ raise Failed("the crafting table didn't open")
+ # One item at a time, picked up and put down with left clicks: quick right clicks can
+ # cross the server's answers and be dropped.
+ for slot, item in cells:
+ game("give", item, 1)
+ time.sleep(0.2)
+ game("click-slot", inventory_slot(game, item))
+ time.sleep(0.2)
+ game("click-slot", slot)
+ time.sleep(0.2)
+ screen = game("screen")
+ out = next((s for s in screen.get("slots", []) if s.get("slot") == 200), {})
+ report("craft", recipe=result, result_slot=out.get("item"), count=out.get("count"))
+ if out.get("item") != result:
+ raise Failed(f"the table shows {out.get('item')}, not {result}")
+ game("click-slot", 200, "shift")
+ time.sleep(0.3)
+ counts = game("inventory")["counts"]
+ report("crafted", inventory=counts.get(result, 0))
+ if counts.get(result, 0) < 1:
+ raise Failed(f"{result} didn't reach the inventory")
+ game("close")
+ ok = True
+ except Failed as e:
+ report("failed", why=str(e))
+ finally:
+ game.quit()
+ summary["ok"] = ok
+ print(json.dumps({"summary": summary}), flush=True)
+ if args.keep:
+ print(f"kept {home}", file=sys.stderr)
+ else:
+ shutil.rmtree(home, ignore_errors=True)
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/website/docs/modpacks.md b/website/docs/modpacks.md
index e3ed053d9..a1aa1e2ea 100644
--- a/website/docs/modpacks.md
+++ b/website/docs/modpacks.md
@@ -8,7 +8,7 @@ Java mod jars do not run in Horizon. Instead, the launcher reads a modpack, tell
Open **Modpacks** in the launcher. (From the next launcher release, it is the **Modpacks** tab of the **Mods** page.)
-1. **Import a pack.** Choose **Import a pack file…** for a Modrinth `.mrpack` or a CurseForge zip, or **Import an instance folder…** for a Prism, MultiMC or CurseForge instance. The **Mods** page can also find a Modrinth modpack and import it for you (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)).
+1. **Import a pack.** Choose **Import a pack file…** for a Modrinth `.mrpack` or a CurseForge zip, or **Import an instance folder…** for a Prism, MultiMC or CurseForge instance. The **Mods** page can also find a Modrinth modpack and import it for you (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)). From the next launcher release it also ports it in the same step, so you can press **Play** right away.
2. **Read the coverage report.** For every mod in the pack it says **Ported**, **Partial**, **Not yet** or **Not needed**, with the reason. You can filter the list by status.
3. **Port.** **Port N mods** gets the jars of the mods this computer can carry over today, and carries them over for the version you picked. It copies the jars from a folder you have, or downloads them from the pack's own links (or the same file from Modrinth's download network), and checks their hashes. CurseForge downloads are coming soon, so for files that only CurseForge has, choose **Use jars from my instance…** and point it at your installed instance.
4. **Play.** **Play with N mods** starts the game as the pack's own profile, with its own saves and options, and those mods on.
@@ -29,6 +29,10 @@ Nothing is uploaded or shared. The mod jars and what is made from them stay on y
So a pack is a list of mods, and today Horizon can bring over a part of that list. The coverage report is the honest answer for your pack.
+If two ported mods use the same namespace, the game can only load one of them. The launcher keeps the first, leaves the other off, and lists it under the pack.
+
+Measured on three packs: Vanilla Perfected (108 files) ports 2 mods, Slimes Adventure (105) ports 20, and All the Mods 10 (496) ports 42. Each opens a world, with no crash. Every ported mod is partial: its blocks, items, recipes and structures come over, its Java code doesn't. A world with All the Mods 10's 42 ported mods takes about 40 seconds to load, against about 5 without mods.
+
## Example: All the Mods 10
All the Mods 10 has 495 jars. Horizon's report sorts them: about 290 add content, 80 are libraries, and the rest are integrations, interface mods, tools and performance mods. A few dozen of the content mods carry over today. The measured numbers are in [docs/modpacks.md](https://github.com/ludovic111/horizon/blob/main/docs/modpacks.md).