Compare commits

...
4 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 9bc1425177 Villagers: find POIs without reading every block of a 48-block sphere
AcquirePoi scanned every block within 48 of the villager (about 460,000),
naming each with string matches, every 20 to 40 ticks for each villager missing
a home, job site or meeting point. Villages gain such villagers over time
(babies, more villagers than beds), so the game thread's work grew with play
time: in a village, 18% of samples at the start and 30-42% eight minutes on,
frame rate dropping and staying low.

Block states are classified once per search, and 16^3 sections whose palette
holds no POI block are skipped whole (PoiManager only visits sections with
records). The same positions are visited in the same order, so the result,
ties in the distance sort included, is unchanged. Same session after: 18%
throughout, frames at the vsync cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:58:33 +02:00
Ludovic MarieandClaude Opus 5.5 1a9452d408 Memory: drop late chunks whole, bound the decoded sounds, count what stays resident
A column that finished loading or generating after the player moved out of
range was dropped, but its block entities and saved entities were added to the
world first. The entities spawned in a column that never became resident, so
nothing saved or removed them: travelling, mobs piled up (141 to 5,028 in an
8-minute flight), and when the column loaded later its entities spawned again,
so villages near the view edge doubled their villagers. Such a column is now
dropped with everything in it. Mesh results for a column unloaded meanwhile are
discarded too, rather than uploaded with nothing left to remove them.

Decoded static sounds were kept for the whole session: every sound heard, up to
1.4 GiB of samples, and a village plays hundreds. They now stay within 64 MiB,
least recently used out, and are decoded on a thread of their own instead of the
game loop (vanilla's buffers load asynchronously too). Stopping a source that
is still waiting for its sound stops it, as alSourceStop does, so a late decode
cannot start it.

status reports mobs, block entities, resident GPU sections and the sound
engine's tables, for soak tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:10:32 +02:00
Ludovic MarieandClaude Opus 5.5 1a10d4efe8 Windows: launcher without VCRUNTIME140.dll, a clear message without Vulkan, import checks in CI
The Windows launcher was built without crt-static and imported VCRUNTIME140.dll,
which only the Visual C++ redistributable installs: players without it got a
missing-DLL dialog before anything started. The game itself links only system
DLLs (vulkan-1.dll is loaded at run time).

- launcher.yml links the C runtime on Windows.
- tools/check_imports.py reads PE import and delay-import tables (and ELF
  loader, needed libraries and run paths); release.yml and launcher.yml fail
  when a Windows binary imports a non-system DLL.
- release.yml builds Linux on Ubuntu 22.04: the NixOS-made Linux release asked
  for a /nix/store loader, so it ran nowhere but NixOS.
- No adapter: the game says the Vulkan driver is missing and what to install,
  and on Windows keeps the console open until Enter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
Ludovic MarieandClaude Opus 5.5 59fd98dea4 Sound: play under the launcher's own stream name, open any sample format, say what opened
PipeWire remembers mute per application name. A test run's stream, muted once
in the mixer, left "PipeWire ALSA [horizon-client]" muted for every later game,
the release played from the launcher included: no sound at all, with the device,
the stream and the mixer all working.

- The launcher starts the game as "Horizon" (media.role Game), its own entry.
- The game opens no output on a vscreen, where agents run it (HORIZON_AUDIO=1
  forces it, for a null sink), so nobody needs to mute it again.
- Any device sample format is accepted (the mixer converts from f32): a plain
  ALSA default reporting i16 used to fail.
- Other output devices are tried when the default does not open, and the log
  names the output opened or every reason none did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
12 changed files with 567 additions and 78 deletions

No files matched your search

+8
View File
@@ -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
+26 -2
View File
@@ -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
+263 -59
View File
@@ -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,
+20 -1
View File
@@ -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: {} ({:?}, {:?})",
+7
View File
@@ -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) {
+11
View File
@@ -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)
}
+27 -10
View File
@@ -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)> =
+9
View File
@@ -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`,
+16
View File
@@ -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"));
+143
View File
@@ -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()