mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
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:
1 parent
2a9d67bd68
commit
519ec1217c
14 files changed
+670
-454
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/`.
|
||||
|
||||
Reference in new issue
Block a user