Server build: the game without sound or controllers, so Linux servers need neither ALSA nor udev

horizon-client's cpal output and its controllers (gilrs, the DualSense's HID output) move behind the
audio and controllers features, on by default, so the game is unchanged. Built with
--no-default-features it links neither libasound.so.2 nor libudev.so.1. release.yml packages that
build as horizon-engine in horizon-server-linux-x86_64.tar.gz (xtask package-server, checked by
check_imports.py --server); install.sh installs it on Linux (and --archive installs a local one);
horizon-server already prefers horizon-engine. The Dockerfile uses it too, and CI lints it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-18 20:17:21 +02:00
1 parent 2a9d67bd68
commit 519ec1217c
14 files changed
+670 -454

No files matched your search

+2
View File
@@ -56,6 +56,8 @@ jobs:
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
+27 -1
View File
@@ -76,6 +76,9 @@ jobs:
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
@@ -190,6 +193,29 @@ jobs:
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
"$folder/horizon-server" --version
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
publish:
needs: [decide, build]
@@ -220,7 +246,7 @@ jobs:
run: |
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
+11 -4
View File
@@ -31,7 +31,8 @@ toml = "1.1"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
cpal = "0.16"
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
@@ -44,10 +45,10 @@ horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
gilrs = "0.11"
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense" }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
@@ -60,6 +61,12 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
winresource = "0.1"
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
+19 -2
View File
@@ -9,6 +9,7 @@ use crate::sound_engine::{
Sounds, TickContext,
};
use crate::sound_events::{Sound, SoundRegistry};
#[cfg(feature = "audio")]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::{
@@ -134,8 +135,14 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
c.looping |= looping;
}
}
/// The open output stream. A build without the `audio` feature (the server build, which must not
/// need libasound) never has one.
#[cfg(feature = "audio")]
type Stream = cpal::Stream;
#[cfg(not(feature = "audio"))]
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<cpal::Stream>,
_stream: Option<Stream>,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -333,6 +340,8 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
Some(_) => 0.0,
}
}
// Only the output stream mixes; a build without one (no `audio` feature) never does.
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
@@ -415,6 +424,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
/// harnesses run the game and must stay off the user's speakers.
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
#[cfg(any(feature = "audio", test))]
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
if var("HORIZON_NO_AUDIO").is_some() {
return Some("audio off: HORIZON_NO_AUDIO is set".into());
@@ -430,6 +440,7 @@ fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<S
}
/// An output stream on `device` in its default configuration, whatever its
/// sample format: the mixer works in f32 and converts.
#[cfg(feature = "audio")]
fn open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
@@ -450,6 +461,7 @@ fn open_device(
stream.play()?;
Ok((stream, config))
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
@@ -523,7 +535,12 @@ impl Audio {
capture: None,
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
#[cfg(not(feature = "audio"))]
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
+41 -363
View File
@@ -6,12 +6,12 @@
use super::{Game, Screen};
use horizon_client::{
Result,
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble, TriggerFeel},
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble},
keybinds::{KeyBinds, key_code, mouse_button},
mouse::Rotation,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
use std::time::Instant;
use winit::{event_loop::ActiveEventLoop, keyboard::KeyCode};
pub(super) const HELP: &[&str] = &[
@@ -25,10 +25,9 @@ pub(super) const HELP: &[&str] = &[
/// The controllers and the state the game last acted on.
#[derive(Default)]
pub(super) struct Pads {
gilrs: Option<gilrs::Gilrs>,
started: bool,
/// The pad last touched; only its input counts.
active: Option<gilrs::GamepadId>,
/// The real controllers and the DualSense's output.
#[cfg(feature = "controllers")]
devices: hardware::Devices,
/// The virtual pad of `horizon-ctl pad`.
injected: PadState,
/// The virtual pad was in use at the last poll.
@@ -48,49 +47,38 @@ pub(super) struct Pads {
pub(super) style: GlyphStyle,
/// The cursor the pad drives in menus.
pub(super) menu: super::pad_menu::MenuCursor,
/// Vibrations playing (an effect stops when dropped) and when they end.
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
/// The last vibration asked for, for `pad status`.
last_rumble: Option<(&'static str, gamepad::Pulse)>,
/// The player's health between polls (a drop is damage taken).
health: HealthWatch,
/// The active pad is a DualSense.
dualsense: bool,
/// Its own output over HID (rumble here; gilrs only reads it), started once this window
/// has the pad in hand; dropped, it releases the pad.
output: Option<horizon_dualsense::Link>,
/// Its HD haptics, on USB: looked for once each time the output opens.
haptics: Option<horizon_dualsense::haptics::Haptics>,
haptics_tried: bool,
/// What it was last told to show.
shown: Option<horizon_dualsense::device::State>,
}
fn gilrs_button(button: Button) -> gilrs::Button {
use gilrs::Button as G;
match button {
Button::South => G::South,
Button::East => G::East,
Button::West => G::West,
Button::North => G::North,
Button::LeftBumper => G::LeftTrigger,
Button::RightBumper => G::RightTrigger,
Button::LeftTrigger => G::LeftTrigger2,
Button::RightTrigger => G::RightTrigger2,
Button::Select => G::Select,
Button::Start => G::Start,
Button::LeftStick => G::LeftThumb,
Button::RightStick => G::RightThumb,
Button::DPadUp => G::DPadUp,
Button::DPadDown => G::DPadDown,
Button::DPadLeft => G::DPadLeft,
Button::DPadRight => G::DPadRight,
#[cfg(feature = "controllers")]
mod hardware;
/// A build without the `controllers` feature (the server build, which must not need libudev): no
/// hardware pads, only `horizon-ctl pad`'s virtual one.
#[cfg(not(feature = "controllers"))]
mod hardware {
use super::{super::Game, Pads};
use horizon_client::gamepad::{PadState, Pulse, Rumble};
use serde_json::{Value, json};
impl Pads {
pub(super) fn read(&mut self) -> PadState {
PadState::default()
}
}
}
fn is_dualsense(pad: &gilrs::Gamepad) -> bool {
matches!((pad.vendor_id(), pad.product_id()),
(Some(vendor), Some(product)) if horizon_dualsense::is_dualsense(vendor, product))
impl Game<'_> {
pub(super) fn pad_output_off(&mut self) {}
pub(super) fn pad_output_frame(&mut self) {}
pub(in crate::game) fn pad_close_output(&mut self) {}
pub(super) fn pad_vibrate(&mut self, _: Rumble, _: Pulse) {}
pub(super) fn pad_devices_status(&self) -> Value {
json!({ "backend": false, "controllers": [] })
}
}
}
impl Pads {
@@ -99,93 +87,6 @@ impl Pads {
pub(super) fn player_replaced(&mut self) {
self.health.forget();
}
/// Open gilrs on first use. A platform without it (or a Linux without
/// udev) leaves controllers off; the game runs as before.
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
self.gilrs = match gilrs::Gilrs::new() {
Ok(g) => {
for (_, pad) in g.gamepads() {
log::info!("Controller connected: {}", pad.name());
}
Some(g)
}
Err(gilrs::Error::NotImplemented(_)) => None,
Err(e) => {
log::warn!("Horizon: controllers are unavailable: {e}");
None
}
};
}
/// Drain gilrs' events and read the active pad.
fn read(&mut self) -> PadState {
let Some(gilrs) = self.gilrs.as_mut() else {
return PadState::default();
};
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
use gilrs::EventType as E;
match event {
E::Connected => {
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
if let Some(output) = &self.output {
output.scan();
}
}
E::Disconnected => {
log::info!("Controller disconnected: {}", gilrs.gamepad(id).name());
if self.active == Some(id) {
self.active = None;
}
if let Some(output) = &self.output {
output.scan();
}
}
E::ButtonPressed(..) => self.active = Some(id),
E::AxisChanged(_, value, _) | E::ButtonChanged(_, value, _)
if value.abs() > 0.5 =>
{
self.active = Some(id)
}
_ => {}
}
}
let Some(pad) = self.active.and_then(|id| gilrs.connected_gamepad(id)) else {
// Before any pad is touched (the virtual pad playing), a DualSense still shows it.
self.dualsense = gilrs.gamepads().any(|(_, pad)| is_dualsense(&pad));
return PadState::default();
};
self.style = GlyphStyle::of_vendor(pad.vendor_id());
self.dualsense = is_dualsense(&pad);
let mut state = PadState::default();
for button in Button::ALL {
if !matches!(button, Button::LeftTrigger | Button::RightTrigger) {
state.set(button, pad.is_pressed(gilrs_button(button)));
}
}
let axis = |a| pad.value(a);
use gilrs::Axis as A;
state.left = [axis(A::LeftStickX), axis(A::LeftStickY)];
state.right = [axis(A::RightStickX), axis(A::RightStickY)];
// Triggers arrive as analog buttons on most mappings, as plain
// buttons on some.
let trigger = |b: gilrs::Button| {
let value = pad.button_data(b).map_or(0.0, |d| d.value());
if pad.is_pressed(b) {
value.max(1.0)
} else {
value
}
};
state.triggers = [
trigger(gilrs::Button::LeftTrigger2),
trigger(gilrs::Button::RightTrigger2),
];
state
}
}
impl Game<'_> {
@@ -193,9 +94,7 @@ impl Game<'_> {
pub(super) fn poll_pads(&mut self, event_loop: &ActiveEventLoop) {
let settings = &self.settings.controller;
if !settings.enabled {
// Dropping the output frees the triggers.
self.pad.output = None;
self.pad.shown = None;
self.pad_output_off();
if self.pad.state != PadState::default() {
self.pad.state = PadState::default();
for (button, down) in self.pad.edges.release_all() {
@@ -204,7 +103,6 @@ impl Game<'_> {
}
return;
}
self.pad.start();
let hardware = self.pad.read();
self.ui.options.pad_style = self.settings.controller.glyphs.resolve(self.pad.style);
let state = hardware.merge(&self.pad.injected);
@@ -219,11 +117,7 @@ impl Game<'_> {
self.pad.in_use = true;
}
self.pad.state = state;
if self.pad.dualsense && self.pad.in_use && self.focused && self.pad.output.is_none() {
self.pad.output = Some(horizon_dualsense::Link::start());
}
self.pad_haptics();
self.pad_show();
self.pad_output_frame();
// Damage from any source, here or on a server; nothing counts on
// the title screen.
let health = self
@@ -234,8 +128,6 @@ impl Game<'_> {
if let Some(damage) = self.pad.health.observe(health) {
self.pad_rumble(Rumble::Hurt(damage));
}
let now = Instant::now();
self.pad.rumbles.retain(|(_, end)| *end > now);
let ticking = self.title.is_none() && self.screen == Screen::Playing && !self.pause;
for button in std::mem::take(&mut self.pad.late_releases) {
if !ticking || self.pad.pressed_at[button as usize] != Some(self.ticks) {
@@ -404,150 +296,8 @@ impl Game<'_> {
self.pitch = rotation.x_rot;
}
/// The DualSense's HID output, while it is the active pad, open, and this window has focus
/// (another game on the same pad keeps it otherwise).
fn pad_output(&self) -> Option<&horizon_dualsense::Link> {
let output = self.pad.output.as_ref()?;
(self.pad.dualsense && self.focused && output.open().is_some()).then_some(output)
}
/// What the DualSense shows: while playing, its triggers feel what their actions do; in
/// menus, paused, unfocused or with another pad active, they are free.
fn pad_show(&mut self) {
if self.pad.output.is_none() {
return;
}
let playing = self.pad.dualsense
&& self.focused
&& self.title.is_none()
&& self.screen == Screen::Playing
&& !self.pause;
let (using, attacking) = if playing {
self.trigger_feels()
} else {
(TriggerFeel::Free, TriggerFeel::Free)
};
// The lightbar follows the player; menus keep the last colour.
let in_world = self.title.is_none();
let lightbar = match self.player.as_mut().filter(|_| in_world) {
Some(p) => {
let max = p.attribute("minecraft:max_health").unwrap_or(20.0) as f32;
let health = p.health.health() / max.max(1.0);
self.settings
.controller
.lightbar
.colour(health, self.dim.id())
}
None => self.pad.shown.and_then(|s| s.lightbar),
};
let lights = self.settings.controller.lightbar != gamepad::Lightbar::Off;
let state = horizon_dualsense::device::State {
triggers: self.settings.controller.trigger_effects(using, attacking),
lightbar: lightbar.filter(|_| lights),
player_leds: lights.then_some(horizon_dualsense::PLAYER_1),
};
if self.pad.shown != Some(state)
&& let Some(output) = &self.pad.output
{
output.set(state);
self.pad.shown = Some(state);
}
}
/// What `key.use` and `key.attack` are doing, for the triggers bound to them.
fn trigger_feels(&self) -> (TriggerFeel, TriggerFeel) {
let using = match self.using {
Some((slot, ticks)) => match self.stack_in(slot) {
Some(s) if &*s.item == "minecraft:bow" => {
TriggerFeel::Draw(gamepad::bow_power(ticks))
}
// TridentItem.THROW_THRESHOLD_TIME.
Some(s) if &*s.item == "minecraft:trident" => {
TriggerFeel::Draw(ticks as f32 / 10.0)
}
Some(s) if &*s.item == "minecraft:crossbow" => {
TriggerFeel::Draw(ticks as f32 / Self::charge_ticks(s).max(1) as f32)
}
Some(s) if self.blocks_attacks(s).is_some() => TriggerFeel::Brace,
_ => TriggerFeel::Free,
},
None => self.idle_use_feel(),
};
// Creative breaks at once; blocks that can't be broken have a negative hardness.
let attacking = match &self.mining {
Some(m) if !self.creative => {
let state = self.world.block(m.pos[0], m.pos[1], m.pos[2]);
TriggerFeel::Texture(self.res.facts().get(state).hardness)
}
_ => TriggerFeel::Free,
};
(using, attacking)
}
/// The use trigger while nothing is in use: a loaded crossbow clicks, an item that boosts (a
/// rocket while gliding, the stick of the mount being ridden) catches, a shield weighs, and
/// locks while an axe has disabled it.
fn idle_use_feel(&self) -> TriggerFeel {
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id));
let mount = mount.map_or("", |m| m.kind.id.as_str());
let feel = |stack: Option<&horizon_client::items::ItemStack>| match stack
.map(|s| (s, &*s.item))
{
Some((s, "minecraft:crossbow")) if s.data().is_some_and(|d| !d.charged.is_empty()) => {
TriggerFeel::Click
}
Some((_, "minecraft:firework_rocket")) if gliding => TriggerFeel::Boost,
Some((_, "minecraft:carrot_on_a_stick")) if mount == "minecraft:pig" => {
TriggerFeel::Boost
}
Some((_, "minecraft:warped_fungus_on_a_stick")) if mount == "minecraft:strider" => {
TriggerFeel::Boost
}
Some((s, _)) if self.blocks_attacks(s).is_some() => match self.shield_cooldown {
0 => TriggerFeel::Brace,
_ => TriggerFeel::Locked,
},
_ => TriggerFeel::Free,
};
// Use tries the main hand first.
match feel(self.stack_in(self.selected)) {
TriggerFeel::Free => feel(self.offhand.as_ref()),
main => main,
}
}
/// Open the pad's haptics once its output is open over USB (its audio device is there
/// only then); close them when it goes. Opt-in with `HORIZON_DUALSENSE_HAPTICS=1` until
/// they are proven on a pad (docs/controllers.md); never for games without sound (tests).
fn pad_haptics(&mut self) {
let usb = self.pad.output.as_ref().and_then(|o| o.open())
== Some(horizon_dualsense::Transport::Usb);
if !usb {
self.pad.haptics = None;
self.pad.haptics_tried = false;
} else if !self.pad.haptics_tried {
self.pad.haptics_tried = true;
if std::env::var_os("HORIZON_DUALSENSE_HAPTICS").is_some_and(|v| v == "1")
&& std::env::var_os("HORIZON_NO_AUDIO").is_none()
{
self.pad.haptics = horizon_dualsense::haptics::Haptics::open();
}
}
}
/// Release the DualSense's output (the game is quitting).
pub(super) fn pad_close_output(&mut self) {
self.pad.haptics = None;
self.pad.output = None;
}
/// Vibrate the active pad, when vibration is on and the pad is the one
/// playing (a pad lying idle while the mouse plays stays still): a
/// DualSense through its own report, else gilrs' force feedback, which
/// platforms without it (gilrs on macOS) skip.
/// playing (a pad lying idle while the mouse plays stays still).
pub(super) fn pad_rumble(&mut self, rumble: Rumble) {
let settings = &self.settings.controller;
if !settings.enabled || !settings.vibration || !self.pad.in_use {
@@ -557,68 +307,7 @@ impl Game<'_> {
return;
};
self.pad.last_rumble = Some((rumble.name(), pulse));
let level = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
if let Some(haptics) = self
.pad
.haptics
.as_ref()
.filter(|_| self.pad_output().is_some())
{
let steps = rumble.shape().into_iter().map(|step| {
(
step.strong,
step.weak,
Duration::from_millis(u64::from(step.millis)),
)
});
haptics.play(&steps.collect::<Vec<_>>());
return;
}
if let Some(output) = self.pad_output() {
let steps = rumble.shape().into_iter().map(|step| {
let length = Duration::from_millis(u64::from(step.millis));
([level(step.strong), level(step.weak)], length)
});
output.rumble(steps.collect());
return;
}
let (Some(gilrs), Some(id)) = (self.pad.gilrs.as_mut(), self.pad.active) else {
return;
};
if !gilrs
.connected_gamepad(id)
.is_some_and(|g| g.is_ff_supported())
{
return;
}
use gilrs::ff::{BaseEffect, BaseEffectType, EffectBuilder, Repeat, Replay, Ticks};
let length = Ticks::from_ms(pulse.millis);
let motor = |kind| BaseEffect {
kind,
scheduling: Replay {
play_for: length,
..Default::default()
},
envelope: Default::default(),
};
let magnitude = |v: f32| (v.clamp(0.0, 1.0) * f32::from(u16::MAX)) as u16;
let effect = EffectBuilder::new()
.add_effect(motor(BaseEffectType::Strong {
magnitude: magnitude(pulse.strong),
}))
.add_effect(motor(BaseEffectType::Weak {
magnitude: magnitude(pulse.weak),
}))
.repeat(Repeat::For(length))
.gamepads(&[id])
.finish(gilrs);
match effect.and_then(|e| e.play().map(|()| e)) {
Ok(effect) => {
let end = Instant::now() + Duration::from_millis(u64::from(pulse.millis) + 50);
self.pad.rumbles.push((effect, end));
}
Err(e) => log::debug!("Horizon: controller vibration: {e}"),
}
self.pad_vibrate(rumble, pulse);
}
/// `horizon-ctl pad ...`.
@@ -639,32 +328,15 @@ impl Game<'_> {
};
match arg(1)? {
"status" => {
let pads: Vec<Value> = self.pad.gilrs.as_ref().map_or(Vec::new(), |g| {
g.gamepads()
.map(|(id, p)| {
json!({
"name": p.name(),
"vendor": p.vendor_id(),
"product": p.product_id(),
"active": self.pad.active == Some(id),
})
})
.collect()
});
let s = &self.pad.state;
let held: Vec<&str> = Button::ALL
.into_iter()
.filter(|b| self.pad.edges.is_held(*b))
.map(Button::name)
.collect();
Ok(json!({
let mut status = json!({
"enabled": self.settings.controller.enabled,
"backend": self.pad.gilrs.is_some(),
"controllers": pads,
"glyphs": self.pad.style.name(),
"dualsense_output": self.pad.output.as_ref().and_then(|o| o.open())
.map(|t| format!("{t:?}")),
"trigger_effects": self.pad.shown.map(|s| s.triggers),
"last_rumble": self.pad.last_rumble.map(|(why, p)| json!({
"reason": why, "strong": p.strong, "weak": p.weak, "ms": p.millis,
})),
@@ -674,7 +346,13 @@ impl Game<'_> {
"left": s.left,
"right": s.right,
"triggers": s.triggers,
}))
});
if let (Some(status), Value::Object(devices)) =
(status.as_object_mut(), self.pad_devices_status())
{
status.extend(devices);
}
Ok(status)
}
"release" => {
self.pad.injected = PadState::default();
@@ -0,0 +1,409 @@
//! The real controllers (the `controllers` feature): gilrs' pads, and the DualSense's own output
//! over HID: its rumble, adaptive triggers, lights and HD haptics. A build without the feature (the
//! server build) has the stub in pad.rs instead, and only `horizon-ctl pad`'s virtual pad.
use super::{
super::{Game, Screen},
Pads,
};
use horizon_client::gamepad::{self, Button, GlyphStyle, PadState, Rumble, TriggerFeel};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
/// The controllers gilrs sees and the DualSense's output.
#[derive(Default)]
pub(super) struct Devices {
gilrs: Option<gilrs::Gilrs>,
started: bool,
/// The pad last touched; only its input counts.
active: Option<gilrs::GamepadId>,
/// Vibrations playing (an effect stops when dropped) and when they end.
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
/// The active pad is a DualSense.
dualsense: bool,
/// Its own output over HID (rumble here; gilrs only reads it), started once this window
/// has the pad in hand; dropped, it releases the pad.
output: Option<horizon_dualsense::Link>,
/// Its HD haptics, on USB: looked for once each time the output opens.
haptics: Option<horizon_dualsense::haptics::Haptics>,
haptics_tried: bool,
/// What it was last told to show.
shown: Option<horizon_dualsense::device::State>,
}
fn gilrs_button(button: Button) -> gilrs::Button {
use gilrs::Button as G;
match button {
Button::South => G::South,
Button::East => G::East,
Button::West => G::West,
Button::North => G::North,
Button::LeftBumper => G::LeftTrigger,
Button::RightBumper => G::RightTrigger,
Button::LeftTrigger => G::LeftTrigger2,
Button::RightTrigger => G::RightTrigger2,
Button::Select => G::Select,
Button::Start => G::Start,
Button::LeftStick => G::LeftThumb,
Button::RightStick => G::RightThumb,
Button::DPadUp => G::DPadUp,
Button::DPadDown => G::DPadDown,
Button::DPadLeft => G::DPadLeft,
Button::DPadRight => G::DPadRight,
}
}
fn is_dualsense(pad: &gilrs::Gamepad) -> bool {
matches!((pad.vendor_id(), pad.product_id()),
(Some(vendor), Some(product)) if horizon_dualsense::is_dualsense(vendor, product))
}
impl Pads {
/// Open gilrs on first use. A platform without it (or a Linux without
/// udev) leaves controllers off; the game runs as before.
fn start(&mut self) {
if self.devices.started {
return;
}
self.devices.started = true;
self.devices.gilrs = match gilrs::Gilrs::new() {
Ok(g) => {
for (_, pad) in g.gamepads() {
log::info!("Controller connected: {}", pad.name());
}
Some(g)
}
Err(gilrs::Error::NotImplemented(_)) => None,
Err(e) => {
log::warn!("Horizon: controllers are unavailable: {e}");
None
}
};
}
/// Drain gilrs' events and read the active pad (opening gilrs the first time).
pub(super) fn read(&mut self) -> PadState {
self.start();
let Some(gilrs) = self.devices.gilrs.as_mut() else {
return PadState::default();
};
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
use gilrs::EventType as E;
match event {
E::Connected => {
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
if let Some(output) = &self.devices.output {
output.scan();
}
}
E::Disconnected => {
log::info!("Controller disconnected: {}", gilrs.gamepad(id).name());
if self.devices.active == Some(id) {
self.devices.active = None;
}
if let Some(output) = &self.devices.output {
output.scan();
}
}
E::ButtonPressed(..) => self.devices.active = Some(id),
E::AxisChanged(_, value, _) | E::ButtonChanged(_, value, _)
if value.abs() > 0.5 =>
{
self.devices.active = Some(id)
}
_ => {}
}
}
let Some(pad) = self
.devices
.active
.and_then(|id| gilrs.connected_gamepad(id))
else {
// Before any pad is touched (the virtual pad playing), a DualSense still shows it.
self.devices.dualsense = gilrs.gamepads().any(|(_, pad)| is_dualsense(&pad));
return PadState::default();
};
self.style = GlyphStyle::of_vendor(pad.vendor_id());
self.devices.dualsense = is_dualsense(&pad);
let mut state = PadState::default();
for button in Button::ALL {
if !matches!(button, Button::LeftTrigger | Button::RightTrigger) {
state.set(button, pad.is_pressed(gilrs_button(button)));
}
}
let axis = |a| pad.value(a);
use gilrs::Axis as A;
state.left = [axis(A::LeftStickX), axis(A::LeftStickY)];
state.right = [axis(A::RightStickX), axis(A::RightStickY)];
// Triggers arrive as analog buttons on most mappings, as plain
// buttons on some.
let trigger = |b: gilrs::Button| {
let value = pad.button_data(b).map_or(0.0, |d| d.value());
if pad.is_pressed(b) {
value.max(1.0)
} else {
value
}
};
state.triggers = [
trigger(gilrs::Button::LeftTrigger2),
trigger(gilrs::Button::RightTrigger2),
];
state
}
}
impl Game<'_> {
/// Controllers are turned off: dropping the DualSense's output frees its triggers.
pub(super) fn pad_output_off(&mut self) {
self.pad.devices.output = None;
self.pad.devices.shown = None;
}
/// After each poll: the DualSense's output opens once this window has the pad in hand, then
/// shows what the game is doing; vibrations that are over stop.
pub(super) fn pad_output_frame(&mut self) {
if self.pad.devices.dualsense
&& self.pad.in_use
&& self.focused
&& self.pad.devices.output.is_none()
{
self.pad.devices.output = Some(horizon_dualsense::Link::start());
}
self.pad_haptics();
self.pad_show();
let now = Instant::now();
self.pad.devices.rumbles.retain(|(_, end)| *end > now);
}
/// The DualSense's HID output, while it is the active pad, open, and this window has focus
/// (another game on the same pad keeps it otherwise).
fn pad_output(&self) -> Option<&horizon_dualsense::Link> {
let output = self.pad.devices.output.as_ref()?;
(self.pad.devices.dualsense && self.focused && output.open().is_some()).then_some(output)
}
/// What the DualSense shows: while playing, its triggers feel what their actions do; in
/// menus, paused, unfocused or with another pad active, they are free.
fn pad_show(&mut self) {
if self.pad.devices.output.is_none() {
return;
}
let playing = self.pad.devices.dualsense
&& self.focused
&& self.title.is_none()
&& self.screen == Screen::Playing
&& !self.pause;
let (using, attacking) = if playing {
self.trigger_feels()
} else {
(TriggerFeel::Free, TriggerFeel::Free)
};
// The lightbar follows the player; menus keep the last colour.
let in_world = self.title.is_none();
let lightbar = match self.player.as_mut().filter(|_| in_world) {
Some(p) => {
let max = p.attribute("minecraft:max_health").unwrap_or(20.0) as f32;
let health = p.health.health() / max.max(1.0);
self.settings
.controller
.lightbar
.colour(health, self.dim.id())
}
None => self.pad.devices.shown.and_then(|s| s.lightbar),
};
let lights = self.settings.controller.lightbar != gamepad::Lightbar::Off;
let state = horizon_dualsense::device::State {
triggers: self.settings.controller.trigger_effects(using, attacking),
lightbar: lightbar.filter(|_| lights),
player_leds: lights.then_some(horizon_dualsense::PLAYER_1),
};
if self.pad.devices.shown != Some(state)
&& let Some(output) = &self.pad.devices.output
{
output.set(state);
self.pad.devices.shown = Some(state);
}
}
/// What `key.use` and `key.attack` are doing, for the triggers bound to them.
fn trigger_feels(&self) -> (TriggerFeel, TriggerFeel) {
let using = match self.using {
Some((slot, ticks)) => match self.stack_in(slot) {
Some(s) if &*s.item == "minecraft:bow" => {
TriggerFeel::Draw(gamepad::bow_power(ticks))
}
// TridentItem.THROW_THRESHOLD_TIME.
Some(s) if &*s.item == "minecraft:trident" => {
TriggerFeel::Draw(ticks as f32 / 10.0)
}
Some(s) if &*s.item == "minecraft:crossbow" => {
TriggerFeel::Draw(ticks as f32 / Self::charge_ticks(s).max(1) as f32)
}
Some(s) if self.blocks_attacks(s).is_some() => TriggerFeel::Brace,
_ => TriggerFeel::Free,
},
None => self.idle_use_feel(),
};
// Creative breaks at once; blocks that can't be broken have a negative hardness.
let attacking = match &self.mining {
Some(m) if !self.creative => {
let state = self.world.block(m.pos[0], m.pos[1], m.pos[2]);
TriggerFeel::Texture(self.res.facts().get(state).hardness)
}
_ => TriggerFeel::Free,
};
(using, attacking)
}
/// The use trigger while nothing is in use: a loaded crossbow clicks, an item that boosts (a
/// rocket while gliding, the stick of the mount being ridden) catches, a shield weighs, and
/// locks while an axe has disabled it.
fn idle_use_feel(&self) -> TriggerFeel {
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id));
let mount = mount.map_or("", |m| m.kind.id.as_str());
let feel = |stack: Option<&horizon_client::items::ItemStack>| match stack
.map(|s| (s, &*s.item))
{
Some((s, "minecraft:crossbow")) if s.data().is_some_and(|d| !d.charged.is_empty()) => {
TriggerFeel::Click
}
Some((_, "minecraft:firework_rocket")) if gliding => TriggerFeel::Boost,
Some((_, "minecraft:carrot_on_a_stick")) if mount == "minecraft:pig" => {
TriggerFeel::Boost
}
Some((_, "minecraft:warped_fungus_on_a_stick")) if mount == "minecraft:strider" => {
TriggerFeel::Boost
}
Some((s, _)) if self.blocks_attacks(s).is_some() => match self.shield_cooldown {
0 => TriggerFeel::Brace,
_ => TriggerFeel::Locked,
},
_ => TriggerFeel::Free,
};
// Use tries the main hand first.
match feel(self.stack_in(self.selected)) {
TriggerFeel::Free => feel(self.offhand.as_ref()),
main => main,
}
}
/// Open the pad's haptics once its output is open over USB (its audio device is there
/// only then); close them when it goes. Opt-in with `HORIZON_DUALSENSE_HAPTICS=1` until
/// they are proven on a pad (docs/controllers.md); never for games without sound (tests).
fn pad_haptics(&mut self) {
let usb = self.pad.devices.output.as_ref().and_then(|o| o.open())
== Some(horizon_dualsense::Transport::Usb);
if !usb {
self.pad.devices.haptics = None;
self.pad.devices.haptics_tried = false;
} else if !self.pad.devices.haptics_tried {
self.pad.devices.haptics_tried = true;
if std::env::var_os("HORIZON_DUALSENSE_HAPTICS").is_some_and(|v| v == "1")
&& std::env::var_os("HORIZON_NO_AUDIO").is_none()
{
self.pad.devices.haptics = horizon_dualsense::haptics::Haptics::open();
}
}
}
/// Release the DualSense's output (the game is quitting).
pub(in crate::game) fn pad_close_output(&mut self) {
self.pad.devices.haptics = None;
self.pad.devices.output = None;
}
/// Play a vibration on the active pad: a DualSense through its own report, else gilrs' force
/// feedback, which platforms without it (gilrs on macOS) skip.
pub(super) fn pad_vibrate(&mut self, rumble: Rumble, pulse: gamepad::Pulse) {
let level = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
if let Some(haptics) = self
.pad
.devices
.haptics
.as_ref()
.filter(|_| self.pad_output().is_some())
{
let steps = rumble.shape().into_iter().map(|step| {
(
step.strong,
step.weak,
Duration::from_millis(u64::from(step.millis)),
)
});
haptics.play(&steps.collect::<Vec<_>>());
return;
}
if let Some(output) = self.pad_output() {
let steps = rumble.shape().into_iter().map(|step| {
let length = Duration::from_millis(u64::from(step.millis));
([level(step.strong), level(step.weak)], length)
});
output.rumble(steps.collect());
return;
}
let (Some(gilrs), Some(id)) = (self.pad.devices.gilrs.as_mut(), self.pad.devices.active)
else {
return;
};
if !gilrs
.connected_gamepad(id)
.is_some_and(|g| g.is_ff_supported())
{
return;
}
use gilrs::ff::{BaseEffect, BaseEffectType, EffectBuilder, Repeat, Replay, Ticks};
let length = Ticks::from_ms(pulse.millis);
let motor = |kind| BaseEffect {
kind,
scheduling: Replay {
play_for: length,
..Default::default()
},
envelope: Default::default(),
};
let magnitude = |v: f32| (v.clamp(0.0, 1.0) * f32::from(u16::MAX)) as u16;
let effect = EffectBuilder::new()
.add_effect(motor(BaseEffectType::Strong {
magnitude: magnitude(pulse.strong),
}))
.add_effect(motor(BaseEffectType::Weak {
magnitude: magnitude(pulse.weak),
}))
.repeat(Repeat::For(length))
.gamepads(&[id])
.finish(gilrs);
match effect.and_then(|e| e.play().map(|()| e)) {
Ok(effect) => {
let end = Instant::now() + Duration::from_millis(u64::from(pulse.millis) + 50);
self.pad.devices.rumbles.push((effect, end));
}
Err(e) => log::debug!("Horizon: controller vibration: {e}"),
}
}
/// The hardware part of `pad status`.
pub(super) fn pad_devices_status(&self) -> Value {
let devices = &self.pad.devices;
let pads: Vec<Value> = devices.gilrs.as_ref().map_or(Vec::new(), |g| {
g.gamepads()
.map(|(id, p)| {
json!({
"name": p.name(),
"vendor": p.vendor_id(),
"product": p.product_id(),
"active": devices.active == Some(id),
})
})
.collect()
});
json!({
"backend": devices.gilrs.is_some(),
"controllers": pads,
"dualsense_output": devices.output.as_ref().and_then(|o| o.open()).map(|t| format!("{t:?}")),
"trigger_effects": devices.shown.map(|s| s.triggers),
})
}
}
+3
View File
@@ -1009,6 +1009,7 @@ pub enum TriggerFeel {
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
Texture(f32),
}
#[cfg(feature = "controllers")]
impl TriggerFeel {
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
@@ -1044,6 +1045,7 @@ impl TriggerFeel {
}
}
}
#[cfg(feature = "controllers")]
impl PadSettings {
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
@@ -1404,6 +1406,7 @@ mod tests {
assert_eq!(w.observe(Some(1.0)), Some(1.0));
}
#[cfg(feature = "controllers")]
#[test]
fn triggers_feel_their_action() {
use horizon_dualsense::{TRIGGER_OFF, trigger};
+19 -6
View File
@@ -11,7 +11,9 @@ use std::{
time::Duration,
};
/// The game's program names, most specific first: a headless engine once there is one, else the game.
/// The game's program names, most specific first: the server build (the game without sound or
/// controllers, so it needs neither ALSA nor udev on Linux; `horizon-server-linux-x86_64.tar.gz` holds it),
/// else the full game.
const PROGRAMS: [&str; 2] = ["horizon-engine", "horizon-client"];
fn exe(name: &str) -> String {
@@ -50,8 +52,8 @@ pub fn program(config: &Config, dir: &Path) -> Result<PathBuf, String> {
}
/// Checks that the game program starts at all: `--help` loads no game files, so it fails only when the
/// system can't load the program, as on a Linux without the ALSA and udev libraries it links (even a
/// server needs them until there is a headless engine). The reason, with what to install.
/// system can't load the program, as on a Linux without the ALSA and udev libraries the full game links
/// (the server build doesn't). The reason, with what to install.
pub fn check_starts(program: &Path) -> Result<(), String> {
let output = Command::new(program)
.arg("--help")
@@ -84,10 +86,13 @@ fn start_failure(program: &Path, code: Option<i32>, stderr: &str) -> String {
format!(": {reason}")
}
);
if reason.contains("error while loading shared libraries") {
if reason.contains("error while loading shared libraries")
&& (reason.contains("libasound") || reason.contains("libudev"))
{
text.push_str(
"\nIt needs ALSA's and udev's libraries (libasound.so.2, libudev.so.1), even on a server. Install \
them:\n Debian, Ubuntu: sudo apt install libasound2 libudev1 (Ubuntu 24.04 and later: \
"\nThe full game needs ALSA's and udev's libraries (libasound.so.2, libudev.so.1). Install the \
server build, which needs neither (packaging/server/install.sh picks it on Linux), or the \
libraries:\n Debian, Ubuntu: sudo apt install libasound2 libudev1 (Ubuntu 24.04 and later: \
libasound2t64)\n Fedora, RHEL: sudo dnf install alsa-lib systemd-libs\n Arch: sudo pacman -S \
alsa-lib systemd-libs\n NixOS: enable programs.nix-ld with alsa-lib and udev in its libraries",
);
@@ -256,6 +261,14 @@ mod tests {
error while loading shared libraries: libasound.so.2"
));
assert!(text.contains("sudo apt install libasound2 libudev1"));
// Another library: no ALSA advice (the server build, horizon-engine, links neither).
let engine = start_failure(
Path::new("/opt/horizon/horizon-engine"),
Some(127),
"/opt/horizon/horizon-engine: error while loading shared libraries: libgcc_s.so.1: cannot open \
shared object file: No such file or directory\n",
);
assert!(!engine.contains("libasound"), "{engine}");
let other = start_failure(program, Some(1), "something else\n");
assert_eq!(
other,
+1
View File
@@ -131,6 +131,7 @@ the notes.
| Archive | Runs on | Checks |
| --- | --- | --- |
| `horizon-linux-x86_64.tar.gz` | GitHub `ubuntu-22.04` (an older glibc, so it runs on any recent distribution) | the standard loader and libraries only (`tools/check_imports.py`) |
| `horizon-server-linux-x86_64.tar.gz` | the same Linux job, after the client archive: `horizon-server`, `horizon-prepare` and the game built with `--no-default-features` as `horizon-engine` (docs/server.md) | as above, and neither `libasound.so.2` nor `libudev.so.1` (`tools/check_imports.py --server`) |
| `horizon-windows-x64.zip` | GitHub `windows-latest`, C runtime linked in | system DLLs only (`tools/check_imports.py`) |
| `horizon-macos-arm64.zip` | The Mac mini's runner (`vars.MAC_RUNNER=macos-latest` sends it to GitHub) | system libraries only (`otool -L`); a 1500-frame offscreen run of a new world (about 12 s; 300 frames end before the world has loaded) with resources prepared from the runner's own Minecraft (`--offscreen --world smoke --frames 1500 --smoke-report`: frames drawn, chunks generated and meshed, a player, no error) |
+29 -21
View File
@@ -55,9 +55,10 @@ distance 10. The vanilla server was the pinned 26.3 `server.jar` on Java 25 with
| What has to be installed | one 46.8 MB binary + 589 MB of prepared resources | Java 25 + the 62 MB jar |
| Crates linked | 384 (wgpu, winit, cpal, gilrs, arboard, naga…) | n/a |
On Linux the binary loads `libasound.so.2` (cpal) and `libudev.so.1` (gilrs) at startup, even on a server.
wgpu, winit and the Wayland/X11 libraries are loaded only when a window opens. That makes a minimal
container image need ALSA and udev for a program that never plays a sound or reads a controller.
On Linux the full game loads `libasound.so.2` (cpal) and `libudev.so.1` (gilrs) at startup. wgpu, winit
and the Wayland/X11 libraries are loaded only when a window opens. The server build (`horizon-engine`,
section 3) is the same program built without its `audio` and `controllers` features, so it needs neither
library, and a minimal server image runs it as it is.
The two clear losses are RAM and idle CPU. The dedicated server loads everything the client needs to draw
(the HUD atlas, item renderer, block-entity models, panorama, fog and music tables, entity models), and it
@@ -145,16 +146,22 @@ game built in `Game::new`, the integrated server on its own thread. On that base
1. The dedicated path skips everything only a window uses: the HUD atlas, item renderer, block-entity and
entity models, panorama, fog, music, and mesh tables (perf-ticks-2 already stops server-side meshing).
This is the RAM and start-time item.
2. The game moves into a library crate, with the window, audio and controller parts behind a `client`
feature. A `horizon-engine` binary built without that feature links no wgpu, winit, cpal or gilrs, and
needs neither ALSA nor udev. The supervisor finds it beside itself and falls back to `horizon-client`.
2. The game moves into a library crate, with the window parts behind a `client` feature, so
`horizon-engine` links no wgpu or winit either. Sound and controllers are already optional (the
`audio` and `controllers` features, on by default): `horizon-engine` today is the game built without
them (section 3).
## 3. Packaging
**Binaries.** The release archives already hold `horizon-client`, `horizon-ctl` and `horizon-prepare`.
`horizon-server` joins them (PR 3: `xtask package` and `release.yml` build it and check it starts). Once
`horizon-engine` exists (section 2), a separate small archive per platform
(`horizon-server-<os>-<arch>.tar.gz` or `.zip`) holds just the server's binaries.
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
and copy `target/release/horizon-client` as `horizon-engine`. macOS and Windows have no such libraries to
avoid, so they keep the one archive.
**Game files: the owner's own copy, as with `prepare`.** Nothing of Mojang's is redistributed and nothing is
downloaded on the owner's behalf. `horizon-server` looks for prepared resources in this order:
@@ -181,15 +188,16 @@ comes out of the headless-engine work.
- Linux and macOS:
`curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh`
The script picks the release archive for the OS and architecture (Linux x86_64, macOS arm64), downloads
it from GitHub Releases, and checks it against the SHA-256 GitHub publishes for each asset. It never asks
for sudo. Options: `--version vX.Y.Z`, `--dir` (default `~/.local/share/horizon`), `--bin` (default
`~/.local/bin`).
On Linux the game links ALSA's and udev's libraries (`libasound.so.2`, `libudev.so.1`) even as a server,
until the headless engine exists. Minimal server images often lack ALSA (Debian and Ubuntu:
`sudo apt install libasound2 libudev1`, `libasound2t64` on Ubuntu 24.04 and later). The script lists any
that are missing, `init` warns, and `run` stops at once with what to install, rather than restarting a
game that can't load.
The script picks the release archive for the OS and architecture (on Linux x86_64 the server archive,
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the SHA-256 GitHub
publishes for each asset. It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
`~/.local/share/horizon`), `--bin` (default `~/.local/bin`), and `--archive FILE` to install an archive
already on the machine (a build of your own; no download and no checksum).
A Linux release from before the server archive installs the full game, which links ALSA's and udev's
libraries (`libasound.so.2`, `libudev.so.1`); minimal server images often lack them (Debian and Ubuntu:
`sudo apt install libasound2 libudev1`, `libasound2t64` on Ubuntu 24.04 and later). Then the script lists
any that are missing, `init` warns, and `run` stops at once with what to install, rather than restarting
a game that can't load.
- Windows:
`irm https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.ps1 | iex`
The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of
@@ -201,8 +209,8 @@ comes out of the headless-engine work.
(`horizon-prepare`, `horizon-server init`).
**Docker** (`packaging/server/Dockerfile`, built the same way as the relay's image): a multi-stage build that
ends on `debian:bookworm-slim` as user `horizon`. It adds `libasound2` and `libudev1` until the headless engine
lands. It uses volume `/data` for the server folder, takes the owner's prepared game files as a read-only
ends on `debian:bookworm-slim` as user `horizon`, with the server build as `horizon-engine`, so the image needs
no ALSA or udev. It uses volume `/data` for the server folder, takes the owner's prepared game files as a read-only
mount on `/opt/horizon/.local/horizon-client/26.3`, exposes port `25565/tcp`, and keeps the control API (and
later the panel) on 127.0.0.1 inside the container: `docker exec <name> horizon-server status|cmd|backup`.
On the first start, `HORIZON_EULA=true` and `HORIZON_WORLD`, `HORIZON_SEED`, `HORIZON_PORT`,
+5 -10
View File
@@ -13,20 +13,15 @@
# start writes /data/server.toml from HORIZON_* (EULA, WORLD, SEED, PORT, WHITELIST, PLAYERS, OP); later
# starts use the file. Manage it with `docker exec horizon horizon-server status|cmd|backup|logs ...`.
FROM rust:1-slim-bookworm AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libasound2-dev libudev-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cargo build --release --locked -p horizon-client --bin horizon-client -p horizon-server --bin horizon-server \
&& mkdir /out && cp target/release/horizon-client target/release/horizon-server /out/
# The server build of the game (no sound, no controllers: it needs neither ALSA nor udev) as horizon-engine.
RUN cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client \
&& cargo build --release --locked -p horizon-server --bin horizon-server \
&& mkdir /out && cp target/release/horizon-client /out/horizon-engine && cp target/release/horizon-server /out/
FROM debian:bookworm-slim
# The game links ALSA and udev even when it runs headless with no sound.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libasound2 libudev1 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --home-dir /data --shell /usr/sbin/nologin horizon \
RUN useradd --system --home-dir /data --shell /usr/sbin/nologin horizon \
&& mkdir -p /data /opt/horizon/.local/horizon-client \
&& chown horizon:horizon /data
COPY --from=build /out/ /opt/horizon/
+48 -32
View File
@@ -1,9 +1,11 @@
#!/bin/sh
# Installs horizon-server (docs/server.md) on Linux or macOS: the latest Horizon release for this computer,
# downloaded from GitHub Releases and checked against the SHA-256 GitHub publishes for it.
# downloaded from GitHub Releases and checked against the SHA-256 GitHub publishes for it. On Linux that is
# the server build (horizon-server-linux-x86_64.tar.gz), which needs neither ALSA nor udev.
#
# curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
# curl -fsSL .../install.sh | sh -s -- --version v0.5.0 --dir /opt/horizon --bin /usr/local/bin
# sh install.sh --archive horizon-server-linux-x86_64.tar.gz --version mybuild (an archive you have)
#
# It needs curl, tar or unzip, and sha256sum or shasum; it never asks for sudo (pick folders you can write).
# Releases go to <dir>/releases/<version>, <dir>/current points at the one in use, and <bin>/horizon-server
@@ -15,6 +17,7 @@ REPO=ludovic111/horizon
VERSION=latest
DIR="${XDG_DATA_HOME:-$HOME/.local/share}/horizon"
BIN="$HOME/.local/bin"
ARCHIVE=
die() { echo "horizon install: $*" >&2; exit 1; }
@@ -23,45 +26,58 @@ while [ $# -gt 0 ]; do
--version) [ $# -ge 2 ] || die "--version needs a tag"; VERSION=$2; shift 2 ;;
--dir) [ $# -ge 2 ] || die "--dir needs a folder"; DIR=$2; shift 2 ;;
--bin) [ $# -ge 2 ] || die "--bin needs a folder"; BIN=$2; shift 2 ;;
-h|--help) sed -n '2,12p' "$0" 2>/dev/null || true; exit 0 ;;
--archive) [ $# -ge 2 ] || die "--archive needs a file"; ARCHIVE=$2; shift 2 ;;
-h|--help) sed -n '2,14p' "$0" 2>/dev/null || true; exit 0 ;;
*) die "unknown option $1" ;;
esac
done
# The archives to look for, in order: on Linux the server build, else (releases before it) the full game.
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) ASSET=horizon-linux-x86_64.tar.gz ;;
Darwin-arm64) ASSET=horizon-macos-arm64.zip ;;
Linux-x86_64) ASSETS="horizon-server-linux-x86_64.tar.gz horizon-linux-x86_64.tar.gz" ;;
Darwin-arm64) ASSETS=horizon-macos-arm64.zip ;;
*) die "no Horizon release for $(uname -s) $(uname -m) yet: build it from source (docs/server.md)" ;;
esac
command -v curl >/dev/null || die "curl is needed"
if command -v sha256sum >/dev/null; then SHA="sha256sum"
elif command -v shasum >/dev/null; then SHA="shasum -a 256"
else die "sha256sum or shasum is needed"; fi
if [ "$VERSION" = latest ]; then API="https://api.github.com/repos/$REPO/releases/latest"
else API="https://api.github.com/repos/$REPO/releases/tags/$VERSION"; fi
JSON=$(curl -fsSL -H "Accept: application/vnd.github+json" "$API") || die "no release $VERSION on GitHub"
TAG=$(printf '%s\n' "$JSON" | sed -n 's/^ "tag_name": "\([^"]*\)".*/\1/p' | head -n 1)
[ -n "$TAG" ] || die "could not read the release's tag"
# The asset's download address and digest, from its block in the release's JSON.
FIELDS=$(printf '%s\n' "$JSON" | awk -v asset="$ASSET" '
/"name": / { line = $0; sub(/.*"name": "/, "", line); sub(/".*/, "", line); current = line }
current == asset && /"digest": / { line = $0; sub(/.*"digest": "sha256:/, "", line); sub(/".*/, "", line); digest = line }
current == asset && /"browser_download_url": / { line = $0; sub(/.*"browser_download_url": "/, "", line); sub(/".*/, "", line); url = line }
END { print url; print digest }')
URL=$(printf '%s\n' "$FIELDS" | sed -n 1p)
DIGEST=$(printf '%s\n' "$FIELDS" | sed -n 2p)
[ -n "$URL" ] || die "release $TAG has no $ASSET"
[ -n "$DIGEST" ] || die "release $TAG publishes no SHA-256 for $ASSET"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
echo "Downloading Horizon $TAG ($ASSET)..."
curl -fL --progress-bar -o "$TMP/$ASSET" "$URL"
GOT=$($SHA "$TMP/$ASSET" | cut -d' ' -f1)
[ "$GOT" = "$DIGEST" ] || die "$ASSET doesn't match its SHA-256 (got $GOT, expected $DIGEST): not installed"
if [ -n "$ARCHIVE" ]; then
# An archive already here (a build of your own, or one fetched by hand): checking it is up to you.
[ -f "$ARCHIVE" ] || die "no archive $ARCHIVE"
if [ "$VERSION" = latest ]; then TAG=local; else TAG=$VERSION; fi
ASSET=$(basename "$ARCHIVE")
cp "$ARCHIVE" "$TMP/$ASSET"
else
command -v curl >/dev/null || die "curl is needed"
if command -v sha256sum >/dev/null; then SHA="sha256sum"
elif command -v shasum >/dev/null; then SHA="shasum -a 256"
else die "sha256sum or shasum is needed"; fi
if [ "$VERSION" = latest ]; then API="https://api.github.com/repos/$REPO/releases/latest"
else API="https://api.github.com/repos/$REPO/releases/tags/$VERSION"; fi
JSON=$(curl -fsSL -H "Accept: application/vnd.github+json" "$API") || die "no release $VERSION on GitHub"
TAG=$(printf '%s\n' "$JSON" | sed -n 's/^ "tag_name": "\([^"]*\)".*/\1/p' | head -n 1)
[ -n "$TAG" ] || die "could not read the release's tag"
# The first asset the release has: its download address and digest, from its block in the release's JSON.
for ASSET in $ASSETS; do
FIELDS=$(printf '%s\n' "$JSON" | awk -v asset="$ASSET" '
/"name": / { line = $0; sub(/.*"name": "/, "", line); sub(/".*/, "", line); current = line }
current == asset && /"digest": / { line = $0; sub(/.*"digest": "sha256:/, "", line); sub(/".*/, "", line); digest = line }
current == asset && /"browser_download_url": / { line = $0; sub(/.*"browser_download_url": "/, "", line); sub(/".*/, "", line); url = line }
END { print url; print digest }')
URL=$(printf '%s\n' "$FIELDS" | sed -n 1p)
DIGEST=$(printf '%s\n' "$FIELDS" | sed -n 2p)
[ -z "$URL" ] || break
done
[ -n "$URL" ] || die "release $TAG has none of $ASSETS"
[ -n "$DIGEST" ] || die "release $TAG publishes no SHA-256 for $ASSET"
echo "Downloading Horizon $TAG ($ASSET)..."
curl -fL --progress-bar -o "$TMP/$ASSET" "$URL"
GOT=$($SHA "$TMP/$ASSET" | cut -d' ' -f1)
[ "$GOT" = "$DIGEST" ] || die "$ASSET doesn't match its SHA-256 (got $GOT, expected $DIGEST): not installed"
fi
mkdir -p "$TMP/x"
case "$ASSET" in
@@ -86,8 +102,8 @@ for tool in horizon-server horizon-prepare; do
done
echo "Installed Horizon $TAG in $RELEASE; horizon-server and horizon-prepare are in $BIN."
# The game links ALSA and udev, even as a server; minimal Linux images often lack them.
if [ "$(uname -s)" = Linux ] && command -v ldd >/dev/null; then
# The full game (releases before the server build) links ALSA and udev; minimal Linux images often lack them.
if [ "$(uname -s)" = Linux ] && [ ! -e "$RELEASE/horizon-engine" ] && command -v ldd >/dev/null; then
MISSING=$(ldd "$RELEASE/horizon-client" 2>/dev/null | awk '/not found/ { printf "%s ", $1 }')
if [ -n "$MISSING" ]; then
echo "The game needs libraries this system doesn't have: $MISSING"
+10 -3
View File
@@ -5,7 +5,8 @@ Windows .exe: every DLL in the import and delay-import tables must ship with
Windows (no VCRUNTIME140.dll, vulkan-1.dll, ...): Vulkan and the like are
loaded at run time, so a missing driver is an error message, not a dialog.
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
needed libraries the ones every desktop distribution has.
needed libraries the ones every desktop distribution has. `--server`: a server
build, which may not need ALSA or udev either (minimal server images lack them).
"""
import argparse
@@ -30,6 +31,8 @@ LINUX_SYSTEM = {
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
"ld-linux-x86-64.so.2", "libasound.so.2", "libudev.so.1",
}
# The game's sound (cpal) and controllers (gilrs): a desktop has them, a server image may not.
LINUX_DESKTOP_ONLY = {"libasound.so.2", "libudev.so.1"}
def windows_allowed(name):
@@ -111,7 +114,7 @@ def elf_needs(data):
return interpreter, needed, runpath
def check(path):
def check(path, server=False):
data = Path(path).read_bytes()
problems = []
if data[:2] == b"MZ":
@@ -123,6 +126,8 @@ def check(path):
if interpreter not in (None, LINUX_LOADER):
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
if server:
problems += [f"needs {n}, which a server may not have" for n in needed if n in LINUX_DESKTOP_ONLY]
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
summary = f"{interpreter}; " + ", ".join(needed)
print(f"{path}: {summary}")
@@ -134,8 +139,10 @@ def check(path):
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("binaries", nargs="+")
parser.add_argument("--server", action="store_true",
help="a server build: not even ALSA or udev (the game's sound and controllers)")
args = parser.parse_args()
ok = all([check(path) for path in args.binaries])
ok = all([check(path, args.server) for path in args.binaries])
sys.exit(0 if ok else 1)
+46 -12
View File
@@ -6,6 +6,10 @@
//! them to its own), and PLAY.txt. It holds none of Minecraft's files: players prepare those
//! from their own copy. .github/workflows/release.yml makes the release folders with it.
//!
//! `package-server ENGINE OUTDIR`: a server folder (docs/server.md): the server build of the game
//! (`cargo build --release -p horizon-client --no-default-features`, without sound or controllers)
//! as horizon-engine, with horizon-prepare and horizon-server from beside it and the same sources.
//!
//! `tokens`: writes `website/src/tokens.css`, the design tokens of `crates/horizon-theme` as CSS
//! custom properties (a horizon-theme test fails when the file is out of date).
//!
@@ -17,7 +21,7 @@ use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, ExitCode};
const USAGE: &str = "usage: cargo run -p xtask -- package BINARY OUTDIR | tokens | icons";
const USAGE: &str = "usage: cargo run -p xtask -- package BINARY OUTDIR | package-server ENGINE OUTDIR | tokens | icons";
/// What the release folder takes from the repository at HEAD, as `git archive` writes it.
const ARCHIVED: [&str; 5] = [
@@ -34,10 +38,16 @@ const ARCHIVED: [&str; 5] = [
/// (docs/server.md).
const TOOLS: [&str; 3] = ["horizon-ctl", "horizon-prepare", "horizon-server"];
/// The programs beside the engine in a server folder.
const SERVER_TOOLS: [&str; 2] = ["horizon-prepare", "horizon-server"];
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.as_slice() {
[task, binary, out] if task == "package" => package(Path::new(binary), Path::new(out)),
[task, engine, out] if task == "package-server" => {
package_server(Path::new(engine), Path::new(out))
}
[task] if task == "tokens" => tokens(),
[task] if task == "icons" => icons(),
_ => Err(USAGE.to_string()),
@@ -209,6 +219,21 @@ fn icons() -> Result<(), String> {
}
fn package(binary: &Path, out: &Path) -> Result<(), String> {
let repo = new_folder(out)?;
copy_into(binary, out)?;
copy_beside(binary, &TOOLS, out)?;
let play = repo.join("packaging/PLAY.txt");
copy(&play, &out.join("PLAY.txt"))
}
fn package_server(engine: &Path, out: &Path) -> Result<(), String> {
new_folder(out)?;
copy(engine, &out.join(program_name(engine, "horizon-engine")))?;
copy_beside(engine, &SERVER_TOOLS, out)
}
/// `OUT`, emptied, with the sources the release folders take (`ARCHIVED`); the repository's path.
fn new_folder(out: &Path) -> Result<PathBuf, String> {
let top = git(
Path::new(env!("CARGO_MANIFEST_DIR")),
&["rev-parse", "--show-toplevel"],
@@ -224,15 +249,25 @@ fn package(binary: &Path, out: &Path) -> Result<(), String> {
let mut args = vec!["archive", "--format=tar", "HEAD"];
args.extend(ARCHIVED);
extract(&git(&repo, &args)?, out)?;
copy_into(binary, out)?;
let exe = binary.extension().is_some_and(|e| e == "exe");
for tool in TOOLS {
let name = if exe {
format!("{tool}.exe")
} else {
tool.to_string()
};
let path = binary.parent().unwrap_or(Path::new("")).join(name);
Ok(repo)
}
/// `name`, with `.exe` when `program` has it.
fn program_name(program: &Path, name: &str) -> String {
if program.extension().is_some_and(|e| e == "exe") {
format!("{name}.exe")
} else {
name.to_string()
}
}
/// Copies `tools`, built beside `program`, into `out`.
fn copy_beside(program: &Path, tools: &[&str], out: &Path) -> Result<(), String> {
for tool in tools {
let path = program
.parent()
.unwrap_or(Path::new(""))
.join(program_name(program, tool));
if !path.is_file() {
return Err(format!(
"{} is missing; build it too: cargo build --release -p horizon-client --bins -p horizon-prepare -p horizon-server",
@@ -241,8 +276,7 @@ fn package(binary: &Path, out: &Path) -> Result<(), String> {
}
copy_into(&path, out)?;
}
let play = repo.join("packaging/PLAY.txt");
copy(&play, &out.join("PLAY.txt"))
Ok(())
}
/// `cp FILE DIR/`.