mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc1425177 | ||
|
|
1a9452d408 | ||
|
|
1a10d4efe8 | ||
|
|
59fd98dea4 |
No files matched your search
@@ -27,7 +27,12 @@ jobs:
|
||||
- os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: launcher
|
||||
@@ -48,6 +53,9 @@ jobs:
|
||||
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
|
||||
- run: cargo test --release --locked --lib
|
||||
- run: cargo build --release --locked
|
||||
- name: Only system libraries
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 client and uploads the playable folder as an artifact.
|
||||
# Linux and macOS builds are made on the development machines.
|
||||
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
|
||||
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
|
||||
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
|
||||
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
@@ -18,6 +20,10 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Only system DLLs
|
||||
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
|
||||
# "DLL not found" dialog on some players' machines.
|
||||
run: python tools/check_imports.py target/release/horizon-client.exe
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
@@ -25,3 +31,21 @@ jobs:
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: ALSA headers
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
- name: Build the client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Standard loader and libraries only
|
||||
run: python3 tools/check_imports.py target/release/horizon-client
|
||||
- name: Package
|
||||
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux-x86_64
|
||||
path: dist/horizon-linux-x86_64
|
||||
@@ -61,10 +61,78 @@ pub struct Heard {
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
cache: Arc<Mutex<SoundCache>>,
|
||||
/// The decoder thread for static sounds, started with the first one.
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
#[derive(Default)]
|
||||
struct SoundCache {
|
||||
entries: HashMap<String, (Samples, u64)>,
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
}
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
impl SoundCache {
|
||||
fn get(&mut self, key: &str) -> Option<Samples> {
|
||||
self.clock += 1;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.1 = self.clock;
|
||||
Some(entry.0.clone())
|
||||
}
|
||||
fn insert(&mut self, key: String, samples: Samples) {
|
||||
self.clock += 1;
|
||||
self.bytes += Self::size(&samples);
|
||||
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
|
||||
self.bytes -= Self::size(&old);
|
||||
}
|
||||
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
|
||||
let Some(oldest) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, used))| *used)
|
||||
.map(|(k, _)| k.clone())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some((gone, _)) = self.entries.remove(&oldest) {
|
||||
self.bytes -= Self::size(&gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
struct Decode {
|
||||
key: String,
|
||||
file: PathBuf,
|
||||
channel: ChannelId,
|
||||
}
|
||||
/// Give a channel waiting on its sound the decoded samples and start it, as
|
||||
/// the buffer future attaching and playing the source. A source stopped or
|
||||
/// released meanwhile stays so.
|
||||
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
&& c.state == State::Initial
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
c.looping |= looping;
|
||||
}
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub engine: SoundEngine,
|
||||
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
/// as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
|
||||
if let Some(samples) = cached {
|
||||
attach(&self.mixer, channel, samples, false);
|
||||
return;
|
||||
}
|
||||
let decoder = self.decoder.get_or_insert_with(|| {
|
||||
let (send, receive) = std::sync::mpsc::channel::<Decode>();
|
||||
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("sound decoder".into())
|
||||
.spawn(move || {
|
||||
for job in receive {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
samples
|
||||
});
|
||||
attach(&mixer, job.channel, samples, false);
|
||||
}
|
||||
})
|
||||
.expect("sound decoder thread");
|
||||
send
|
||||
});
|
||||
let _ = decoder.send(Decode { key, file, channel });
|
||||
}
|
||||
/// `alSourceStop`: any state becomes stopped, an initial source too (a
|
||||
/// sound still decoding then never starts).
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| c.state = State::Stopped);
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// Why the game should not open an output, if it should not:
|
||||
/// `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).
|
||||
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());
|
||||
}
|
||||
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
|
||||
return None;
|
||||
}
|
||||
let display = var("WAYLAND_DISPLAY")?;
|
||||
Path::new(&display)
|
||||
.components()
|
||||
.any(|c| c.as_os_str() == "vscreen")
|
||||
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
|
||||
}
|
||||
/// An output stream on `device` in its default configuration, whatever its
|
||||
/// sample format: the mixer works in f32 and converts.
|
||||
fn open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
scratch.resize(data.len(), 0.0f32);
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, &mut scratch, channels, rate);
|
||||
} else {
|
||||
scratch.fill(0.0);
|
||||
}
|
||||
for (out, s) in data.iter_mut().zip(&scratch) {
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
@@ -347,7 +486,7 @@ impl Audio {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
eprintln!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -360,7 +499,8 @@ impl Audio {
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
@@ -374,32 +514,40 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or("no audio output device")?;
|
||||
let config = device.default_output_config()?;
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
eprintln!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
return Err(format!("no audio output device on the {} host", host.id().name()).into());
|
||||
}
|
||||
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
|
||||
}
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
@@ -525,6 +673,21 @@ impl Audio {
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
/// The engine's table sizes and the mixer's channels (`status`).
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
let mut census = self.engine.census();
|
||||
census["channels"] = self
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.map_or(0, |m| m.channels.len())
|
||||
.into();
|
||||
if let Ok(cache) = self.backend.cache.lock() {
|
||||
census["decoded"] = cache.entries.len().into();
|
||||
census["decoded_mib"] = (cache.bytes >> 20).into();
|
||||
}
|
||||
census
|
||||
}
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
@@ -590,6 +753,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
assert!(cache.get("a").is_some());
|
||||
cache.insert("e".into(), sound());
|
||||
assert!(cache.bytes <= CACHE_BYTES);
|
||||
assert!(
|
||||
cache.get("b").is_none(),
|
||||
"the least recently used goes first"
|
||||
);
|
||||
assert!(cache.get("a").is_some() && cache.get("e").is_some());
|
||||
// A failed load is remembered without counting.
|
||||
cache.insert("missing".into(), None);
|
||||
assert_eq!(cache.get("missing"), Some(None));
|
||||
}
|
||||
#[test]
|
||||
fn output_stays_off_on_a_virtual_screen_unless_asked() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
|
||||
assert!(output_disabled(env(&[])).is_none());
|
||||
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
|
||||
assert!(
|
||||
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
|
||||
);
|
||||
assert!(
|
||||
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
|
||||
@@ -491,6 +491,7 @@ impl Game<'_> {
|
||||
"present_mode": format!("{:?}", r.config.present_mode),
|
||||
"drawn_sections": r.drawn_sections,
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
@@ -510,6 +511,7 @@ impl Game<'_> {
|
||||
"ticks": self.ticks,
|
||||
"day_time": self.world.day_time,
|
||||
"music": self.music.current(&self.audio.engine),
|
||||
"sounds": self.audio.census(),
|
||||
"fishing": self.hook_status(),
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
@@ -519,6 +521,8 @@ impl Game<'_> {
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
"chunks": self.world.chunks.len(),
|
||||
"mobs": self.mobs.len(),
|
||||
"block_entities": self.world.block_entities.len(),
|
||||
"pending_chunks": self.streaming.pending(),
|
||||
"generated_chunks": self.streaming.generated_total,
|
||||
"meshed_sections": self.streaming.meshed_total,
|
||||
|
||||
@@ -214,6 +214,24 @@ fn mip_chain(pixels: &[u8], size: u32, layers: u32) -> (Vec<u8>, u32) {
|
||||
}
|
||||
(out, levels)
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs a GPU driver with Vulkan \
|
||||
(vulkan-1.dll comes with it). Install the latest driver for your NVIDIA, AMD or Intel GPU \
|
||||
and start the game again";
|
||||
#[cfg(target_os = "macos")]
|
||||
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
@@ -234,7 +252,8 @@ impl Renderer {
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
|
||||
let info = adapter.get_info();
|
||||
eprintln!(
|
||||
"Horizon renderer: {} ({:?}, {:?})",
|
||||
|
||||
@@ -5,6 +5,13 @@ mod game;
|
||||
fn main() {
|
||||
if let Err(error) = game::run() {
|
||||
eprintln!("Horizon: {error}");
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `MemorySlot.NEVER_EXPIRE`.
|
||||
pub(super) const NEVER: i64 = i64::MAX;
|
||||
@@ -843,12 +844,20 @@ impl Poi {
|
||||
}
|
||||
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
|
||||
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
|
||||
let name = w.block_name(x, y, z);
|
||||
poi_of_state(w, w.world.block(x, y, z))
|
||||
}
|
||||
/// The POI a block state is, if any (`PoiTypes.forState`).
|
||||
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
|
||||
let name = &*w.facts.get(state).name;
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Poi::Meeting);
|
||||
}
|
||||
if name.ends_with("_bed") {
|
||||
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
|
||||
let part = w
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("part"));
|
||||
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
|
||||
}
|
||||
super::villager::job_site_profession(name).map(Poi::Job)
|
||||
}
|
||||
@@ -920,12 +929,30 @@ fn pois_in_range(
|
||||
let chunks = radius.div_euclid(16) + 1;
|
||||
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
|
||||
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
|
||||
// The POI each block state is, worked out once per search, and the
|
||||
// sections whose palette holds none skipped whole, as PoiManager only
|
||||
// visits sections with records: the same positions in the same order,
|
||||
// without reading and naming every block of the sphere.
|
||||
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
|
||||
let mut kind = |state: u32| {
|
||||
*kinds
|
||||
.entry(state)
|
||||
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
|
||||
};
|
||||
for qz in cz - chunks..=cz + chunks {
|
||||
for qx in cx - chunks..=cx + chunks {
|
||||
if !w.world.chunks.contains_key(&(qx, qz)) {
|
||||
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let holds: Vec<bool> = chunk
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
|
||||
.collect();
|
||||
for y in y0..=y1 {
|
||||
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
|
||||
continue;
|
||||
}
|
||||
for z in qz * 16..qz * 16 + 16 {
|
||||
for x in qx * 16..qx * 16 + 16 {
|
||||
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
|
||||
@@ -937,11 +964,11 @@ fn pois_in_range(
|
||||
if d > r2 {
|
||||
continue;
|
||||
}
|
||||
let state = w.world.block(x, y, z);
|
||||
let state = chunk.block(x & 15, y, z & 15);
|
||||
if w.facts.is_air(state) {
|
||||
continue;
|
||||
}
|
||||
let Some(poi) = poi_at(w, x, y, z) else {
|
||||
let Some(poi) = kind(state.0) else {
|
||||
continue;
|
||||
};
|
||||
if !poi_matches(m, f, poi) {
|
||||
|
||||
@@ -445,6 +445,17 @@ impl SoundEngine {
|
||||
self.instances.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Table sizes, for soak tests watching what the engine holds.
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"instances": self.instances.len(),
|
||||
"playing": self.instance_to_channel.len(),
|
||||
"handles": self.handles.len(),
|
||||
"ticking": self.ticking.len(),
|
||||
"queued": self.queued.len(),
|
||||
"used": self.used,
|
||||
})
|
||||
}
|
||||
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
|
||||
self.instances.get(&id)
|
||||
}
|
||||
|
||||
@@ -358,20 +358,24 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(mut chunk) => {
|
||||
let key = (chunk.x, chunk.z);
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generating.remove(&key);
|
||||
self.finishing.remove(&key);
|
||||
self.decorated.insert(key);
|
||||
// A column that finished after the player moved away is
|
||||
// dropped whole: its block entities and saved entities
|
||||
// too, or they would stay in the world (and the entities
|
||||
// spawn again, duplicated, when the column loads later).
|
||||
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generated_total += 1;
|
||||
// A chunk saved with its light needs no light job; its
|
||||
// unlit neighbours still light themselves from it.
|
||||
@@ -424,6 +428,12 @@ impl Streaming {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.meshed_total += 1;
|
||||
uploads.push(((x, sy, z), mesh));
|
||||
@@ -449,6 +459,13 @@ impl Streaming {
|
||||
}
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
self.light_snapshots.remove(key);
|
||||
}
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
}
|
||||
// Drop far proto chunks that are not checked out.
|
||||
let areas: Vec<((i32, i32), i64)> =
|
||||
|
||||
@@ -55,6 +55,15 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
|
||||
On headless Linux use `vscreen start ./target/release/horizon-client` and
|
||||
`vscreen shot`.
|
||||
|
||||
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
|
||||
why it has none (`Horizon: no sound: ...`). It opens none with
|
||||
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
|
||||
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
|
||||
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
|
||||
mute a test run's stream in the mixer: PipeWire remembers the mute for every
|
||||
later `horizon-client`. The launcher starts the game as "Horizon" for that
|
||||
reason.
|
||||
|
||||
### Drive the game from scripts (`horizon-ctl`)
|
||||
|
||||
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
|
||||
|
||||
@@ -351,6 +351,12 @@ pub fn client_command(tag: &str) -> Command {
|
||||
let _ = fs::create_dir_all(&home);
|
||||
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
|
||||
cmd.current_dir(home).arg("--resources").arg(resources(tag));
|
||||
// PipeWire remembers volume and mute per application name. The game's own name is
|
||||
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
|
||||
// muting it once left every later game silent: played from here it is "Horizon".
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
|
||||
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -799,6 +805,16 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::*;
|
||||
|
||||
#[test]
|
||||
fn the_game_plays_under_its_own_audio_stream_name() {
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
|
||||
return;
|
||||
}
|
||||
let cmd = client_command("v2026.10.13");
|
||||
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
|
||||
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_never_mix() {
|
||||
assert!(is_game_tag("v2026.10.13"));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a release binary needs a library its players may not have.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# DLLs that are part of Windows 10 and 11. api-ms-win-* and ext-ms-win-* API
|
||||
# sets resolve inside Windows too.
|
||||
WINDOWS_SYSTEM = {
|
||||
"advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "cfgmgr32.dll", "combase.dll",
|
||||
"comctl32.dll", "comdlg32.dll", "crypt32.dll", "d2d1.dll", "d3d11.dll", "d3d12.dll",
|
||||
"d3dcompiler_47.dll", "dbghelp.dll", "dcomp.dll", "dwmapi.dll", "dwrite.dll", "dxgi.dll",
|
||||
"gdi32.dll", "hid.dll", "icuuc.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll",
|
||||
"kernelbase.dll", "mfplat.dll", "mmdevapi.dll", "msvcrt.dll", "ncrypt.dll", "ntdll.dll",
|
||||
"ole32.dll", "oleaut32.dll", "opengl32.dll", "propsys.dll", "secur32.dll", "setupapi.dll",
|
||||
"shcore.dll", "shell32.dll", "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll",
|
||||
"uxtheme.dll", "version.dll", "winmm.dll", "ws2_32.dll", "xinput1_4.dll",
|
||||
}
|
||||
LINUX_LOADER = "/lib64/ld-linux-x86-64.so.2"
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def windows_allowed(name):
|
||||
name = name.lower()
|
||||
return name in WINDOWS_SYSTEM or name.startswith(("api-ms-win-", "ext-ms-win-"))
|
||||
|
||||
|
||||
def pe_imports(data):
|
||||
"""DLL names in a PE32+ file's import and delay-import tables."""
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError("not a PE file")
|
||||
sections_count, optional_size = struct.unpack_from("<H12xH", data, pe + 6)
|
||||
optional = pe + 24
|
||||
magic = struct.unpack_from("<H", data, optional)[0]
|
||||
directories = optional + (112 if magic == 0x20B else 96)
|
||||
sections = [struct.unpack_from("<8xIIII", data, optional + optional_size + 40 * i)
|
||||
for i in range(sections_count)]
|
||||
|
||||
def offset(rva):
|
||||
for size, address, raw_size, raw in sections:
|
||||
if address <= rva < address + max(size, raw_size):
|
||||
return rva - address + raw
|
||||
raise ValueError(f"RVA {rva:#x} outside every section")
|
||||
|
||||
def name_at(rva):
|
||||
start = offset(rva)
|
||||
return data[start:data.index(b"\0", start)].decode("ascii")
|
||||
|
||||
names = []
|
||||
# Directory 1: imports (20-byte descriptors, name at +12); 13: delay imports (32 bytes, name at +4).
|
||||
for index, size, name_field in ((1, 20, 12), (13, 32, 4)):
|
||||
rva = struct.unpack_from("<I", data, directories + 8 * index)[0]
|
||||
if not rva:
|
||||
continue
|
||||
at = offset(rva)
|
||||
while any(data[at:at + size]):
|
||||
names.append(name_at(struct.unpack_from("<I", data, at + name_field)[0]))
|
||||
at += size
|
||||
return names
|
||||
|
||||
|
||||
def elf_needs(data):
|
||||
"""The interpreter and DT_NEEDED libraries of a little-endian ELF64 file."""
|
||||
if data[:4] != b"\x7fELF" or data[4] != 2:
|
||||
raise ValueError("not an ELF64 file")
|
||||
phoff, phentsize, phnum = struct.unpack_from("<Q", data, 32)[0], *struct.unpack_from("<HH", data, 54)
|
||||
interpreter, dynamic, loads = None, None, []
|
||||
for i in range(phnum):
|
||||
kind, _, offset, vaddr, _, filesz = struct.unpack_from("<IIQQQQ", data, phoff + i * phentsize)
|
||||
if kind == 3:
|
||||
interpreter = data[offset:offset + filesz].rstrip(b"\0").decode()
|
||||
elif kind == 2:
|
||||
dynamic = (offset, filesz)
|
||||
elif kind == 1:
|
||||
loads.append((vaddr, offset, filesz))
|
||||
|
||||
def file_offset(address):
|
||||
for vaddr, offset, size in loads:
|
||||
if vaddr <= address < vaddr + size:
|
||||
return address - vaddr + offset
|
||||
raise ValueError(f"address {address:#x} outside every segment")
|
||||
|
||||
entries = []
|
||||
if dynamic:
|
||||
for at in range(dynamic[0], dynamic[0] + dynamic[1], 16):
|
||||
tag, value = struct.unpack_from("<qQ", data, at)
|
||||
if tag == 0:
|
||||
break
|
||||
entries.append((tag, value))
|
||||
strtab = next((file_offset(v) for t, v in entries if t == 5), None)
|
||||
|
||||
def string(index):
|
||||
start = strtab + index
|
||||
return data[start:data.index(b"\0", start)].decode()
|
||||
|
||||
needed = [string(v) for t, v in entries if t == 1]
|
||||
runpath = [string(v) for t, v in entries if t in (15, 29)]
|
||||
return interpreter, needed, runpath
|
||||
|
||||
|
||||
def check(path):
|
||||
data = Path(path).read_bytes()
|
||||
problems = []
|
||||
if data[:2] == b"MZ":
|
||||
names = pe_imports(data)
|
||||
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
|
||||
summary = ", ".join(sorted(set(n.lower() for n in names)))
|
||||
else:
|
||||
interpreter, needed, runpath = elf_needs(data)
|
||||
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]
|
||||
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}")
|
||||
for problem in problems:
|
||||
print(f" {path} {problem}", file=sys.stderr)
|
||||
return not problems
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("binaries", nargs="+")
|
||||
args = parser.parse_args()
|
||||
ok = all([check(path) for path in args.binaries])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user