Compare commits

...
20 Commits
Author SHA1 Message Date
Ludovic Marie ae482a72e9 Merge remote-tracking branch 'origin/feat/dynamic-lights' into integ/w1 2026-10-22 08:16:12 +02:00
Ludovic Marie 6a85c55988 Merge remote-tracking branch 'origin/fix/shader-pack-folder' into integ/w1 2026-10-22 08:16:12 +02:00
Ludovic Marie bea1b54dd2 Merge remote-tracking branch 'origin/fix/shaderpack-compat' into integ/w1 2026-10-22 08:16:12 +02:00
Ludovic Marie 7f3738072f Merge remote-tracking branch 'origin/feat/version-sources' into integ/w1 2026-10-22 08:16:12 +02:00
Ludovic Marie 4bf5625b9b Merge remote-tracking branch 'origin/fix/server-thread-default-v2' into integ/w1 2026-10-22 08:16:12 +02:00
Ludovic Marie d9560d46e6 Merge remote-tracking branch 'origin/fix/moving-piston-tint-pos' into HEAD 2026-10-22 07:24:39 +02:00
Ludovic Marie 26142a9c40 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 07:18:55 +02:00
Ludovic MarieandClaude Opus 5.5 832f879b27 Dynamic lights: held, dropped and burning light sources (Horizon Extras)
A new Horizon Extras option, Lighting > Dynamic lights (`dynamicLights`,
off by default): a torch, lantern or other light-emitting item held by a
player or mob, dropped as an item, or any entity on fire lights the blocks,
entities and hand around it, as LambDynamicLights and OptiFine do.

Client-only and at render time: the light engine, chunk meshes and saves
never see it. Each frame the game picks the 16 sources nearest the camera
(dynamic_light::select) and passes them in Globals; terrain.wgsl and
entity.wgsl raise each vertex's block light to `level - distance` when that
is brighter. Off, the count in globals.time.z is 0, nothing is gathered and
the shaders' loop runs zero times, so no pipeline is rebuilt either way.

An item glows like its block's default state (torch 14, lantern and
glowstone 15, lit redstone torch 7); a lava bucket 15; burning entities 15.

Fixes #814

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:40:09 +02:00
Ludovic MarieandClaude Opus 5.5 b7453b129b agent_control_e2e survival: wait for the log's drop or its pickup
On single player's server thread the broken log's drop and its pickup
both come from the server, a few ticks after `break` answers: the
pickup could land before the scenario looked for the drop, which it
then never found (StopIteration). Step 3 now waits up to 20 ticks for
the logs in the inventory or the drop to walk to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 05e169f4e3 agent_control_e2e pick: wait for the server to clear the cursor
Step 2 clones a stack in the creative tab and closes the screen. The
clone lived only on the client: ItemPickerMenu's carried stack is the
inventoryMenu's, and AbstractContainerMenu.removed clears the carried
stack only for a ServerPlayer, so vanilla's client still holds it when
the survival inventory opens. With a stack in hand the middle button's
release sends CLONE (AbstractContainerScreen.mouseReleased); the server
finds the carried stack it was told about wrong and sends the empty one
(broadcastChanges). On single player's server thread that answer takes
a round trip, as in vanilla, so step 3 now waits up to 20 ticks for it.
The in-frame world puts the clone away when the menu closes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 8b75ac3cdb lan_e2e and agent_control_e2e on the server thread
From b9f3821f (fix/server-thread-default): the host window draws what
it was sent on the server thread, and agent_control_e2e waits two ticks
where the server answers a click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic Marie 55891f9c95 Wait for the server's block in the agent's place, try hidden spots
On a client of a server (single player's server thread, a LAN guest) the
placed block comes with the server's answer: `place` waits up to a second
for it before reporting no change. agent_control_e2e tries the next free
spot when the tree's trunk above the broken log hides a spot's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9821bb7ef5)
2026-10-22 05:16:51 +02:00
Ludovic Marie 2f883b5cdb Keep the command harness in the frame; compare finished border chunks
- The command harness (HORIZON_COMMAND_SCRIPT) drives the world itself,
  without the event loop, so it does not start the server thread.
- `status` lists the minecarts a client of a server was sent.
- save_roundtrip: single player's server thread finishes the chunks its
  player tracks, as vanilla's integrated server does, including some that
  vanilla's server (view distance 4) left before their terrain or light.
  Those had no blocks, light or heightmaps to compare yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2818c89f24)
2026-10-22 05:16:51 +02:00
Ludovic Marie 0468b75c43 Run single player on its server thread by default
Single player's world now runs on the integrated server thread unless
asked otherwise, as vanilla's does, so a world tick no longer holds up
a frame. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` keeps the
world in the frame as an escape hatch; `--server-thread` stays.

lan_e2e.py sets the host's mode explicitly either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 4ff23b686d)
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 ac25d41578 Shader Packs page: show the first line of a pack's error
Translator errors go on with a drawn source excerpt that the row can't show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 acbfb8ceca docs/shader-packs.md: the ten packs tested and what stops the rest
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 6ea7a51ff3 Shader Packs page: the folder's path, Open Folder, a switch per pack
The Shader Pack setting cycled through what shaderpacks/ held, so with the
folder empty (or the packs in the launcher's version folder, as brabus_ had
them) it silently stayed Off. The row now opens a Shader Packs page: a
sidebar section in the Horizon menus, Video Settings > Shader Packs... in
the Classic ones. It shows the game home's shaderpacks/ path and an Open
Folder button (made if missing), says when there is no pack, and lists one
switch per pack. A pack that fails to load says why on its row. The list is
read again every second while the page shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 e570561db9 Shader packs: write narrow fragment outputs through a vec4
GL fills the channels a fragment shader leaves out; wgpu refuses a pipeline
whose output has fewer channels than its target. Steadfast's final pass
writes `out vec3` and failed to build in the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 21998a7e6a Shader packs: read pack sources as Iris's JCPP does; legalise what naga refuses
Preprocessor (GLSL and shaders.properties), as JCPP does:
- a #define replaces an earlier macro (Bloop, Steadfast: DefineRedefined);
- in #if, a float counts as its integer part (Steadfast's
  shaders.properties float token), and a macro always defined empty counts
  as 0 (Iris's MC_GL_VENDOR_OTHER, Bloop's TAA_USE_YCOCG);
- an #if may run to the end of the input (Bloop's taa.fsh);
- #error "..." lexes in skipped blocks (Photon);
- shaders.properties keeps its empty lines and hides backslashes from the
  preprocessor (IRIS_PASSTHROUGHBACKSLASH), so a continued value no longer
  swallows the next directive or line (Photon, Bliss, Steadfast's custom
  uniforms; Complementary's isCold uniform is now complete).

Legaliser: % through overloads (naga won't take ivec2 % int: Noble),
sqrt of a negative constant left to the GPU (Bloop's NaN), constants naga
can't fold (?:, mix: Bliss) become globals set in main, and 16/8-bit types
(u16vec2 & co., Noble on NVIDIA) become their 32-bit ones.

horizon-client: real_packs_load runs the game's own load over the packs in
HORIZON_TEST_SHADERPACKS (the compatibility table's source).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 30cdd6298a Version sources: a release build says its version (0.2.3), not 0.1.0 or a folder name
release.yml builds every game release with HORIZON_VERSION, the tag without its v
(docs/versioning.md), as launcher.yml already does for the launcher: no version-bump commits.

- horizon_core::release::{RELEASE, VERSION}: the built-in version, or Cargo.toml's for a build from
  source. (horizon_core::version stays Minecraft's, generated by gen_version_consts.py.)
- horizon-server and horizon-prepare --version, the server's /api/v1/status, horizon-ctl's MCP
  serverInfo and the "Horizon/<version>" user agents use it; they all said 0.1.0.
- The client's crash reports and telemetry say v0.2.3 from the built-in version wherever the game
  is installed; releases from before 0.2 (no version built in) still read the launcher's folder name.
- The launcher's benchmark telemetry used Cargo.toml's version instead of the release's.
- release.yml checks that horizon-prepare and horizon-server --version say the release's version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:20:13 +02:00
42 changed files with 1759 additions and 120 deletions

No files matched your search

+14 -3
View File
@@ -45,6 +45,7 @@ jobs:
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
@@ -65,6 +66,9 @@ jobs:
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
@@ -99,6 +103,9 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
defaults:
run:
shell: bash
@@ -162,9 +169,13 @@ jobs:
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --version
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
"$bin/horizon-server${{ matrix.exe }}" --version
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
@@ -245,7 +256,7 @@ jobs:
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
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
+1 -1
View File
@@ -174,7 +174,7 @@ pub fn discovery_url() -> String {
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
@@ -130,7 +130,7 @@ impl Session {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
+23 -6
View File
@@ -105,18 +105,23 @@ impl Report {
}
}
/// The release tag (the launcher installs each build in a folder named after it) and the client's
/// own version, or just the latter.
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
/// crate version; for a build from source, that version alone.
pub fn version() -> String {
let version = env!("CARGO_PKG_VERSION");
match release_tag() {
Some(tag) => format!("{tag} (client {version})"),
None => version.to_owned(),
match (horizon_core::release::RELEASE, release_tag()) {
(Some(_), Some(tag)) => tag,
(None, Some(tag)) => format!("{tag} (client {version})"),
(_, None) => version.to_owned(),
}
}
/// The release this binary came with: launcher installs live in a folder named after the tag.
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
pub fn release_tag() -> Option<String> {
if let Some(version) = horizon_core::release::RELEASE {
return Some(format!("v{version}"));
}
let exe = std::env::current_exe().ok()?;
let folder = exe.parent()?.file_name()?.to_str()?;
(folder.starts_with('v') && folder[1..].starts_with(|c: char| c.is_ascii_digit()))
@@ -517,6 +522,18 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
match horizon_core::release::RELEASE {
Some(version) => {
assert_eq!(release_tag(), Some(format!("v{version}")));
assert_eq!(super::version(), format!("v{version}"));
}
None => assert!(release_tag().is_none() || super::version().contains("(client ")),
}
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
+133
View File
@@ -0,0 +1,133 @@
//! Horizon Extras' dynamic lights (docs/extras.md): a torch, lantern or other light source held by
//! a player or mob, dropped as an item, or an entity on fire lights the blocks and entities
//! around it. Not in vanilla; off by default.
//!
//! Client-only and at render time: the light engine, chunk meshes and saves never see it. Each
//! frame the game picks the [`MAX`] sources nearest the camera; terrain.wgsl and entity.wgsl
//! raise a vertex's block light to `level - distance` (one level per block, as block light falls
//! off) when that is brighter. Off, the list is empty and the shaders skip the loop.
use crate::blocks::BlockFacts;
use glam::DVec3;
use horizon_world::registry::BlockRegistry;
use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
const REACH: f64 = 96.0;
/// How brightly each item glows when held or dropped.
#[derive(Debug, Default)]
pub struct Luminance(HashMap<String, u8>);
impl Luminance {
/// A block item glows like its block's default state (`getLightEmission`): torches 14,
/// lanterns and glowstone 15, a lit redstone torch 7; a furnace's item, unlit, not at all.
pub fn new(facts: &BlockFacts, registry: &BlockRegistry) -> Self {
let mut map = HashMap::new();
for (id, state) in facts.states.iter().enumerate() {
if state.emission == 0 || *state.item != *state.name {
continue;
}
if registry.default_state(&state.name).map(|d| d.0 as usize) == Some(id) {
map.insert(state.item.to_string(), state.emission);
}
}
// A bucket of lava glows as lava does.
map.insert("minecraft:lava_bucket".into(), 15);
Self(map)
}
pub fn of(&self, item: &str) -> u8 {
self.0.get(item).copied().unwrap_or(0)
}
#[cfg(test)]
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
}
}
/// One light this frame: where it is (world space) and its level, 1 to 15.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Source {
pub at: DVec3,
pub level: u8,
}
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
/// position and level. Overwrites `out`.
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
out.clear();
let key = |s: &Source| s.at.distance_squared(camera);
let mut near: Vec<&Source> = sources
.iter()
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
.collect();
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
out.extend(near.into_iter().take(MAX).map(|s| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
}));
}
/// The block light a vertex at `pos` (camera-relative) gets from `lights`, 0 to 15: the WGSL
/// `dynamic_block_light` in Rust, for tests.
pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
lights.iter().fold(0.0f32, |level, l| {
let d =
((pos[0] - l[0]).powi(2) + (pos[1] - l[1]).powi(2) + (pos[2] - l[2]).powi(2)).sqrt();
level.max(l[3] - d)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
let mut sources: Vec<Source> = (0..40)
.map(|i| Source {
at: camera + DVec3::new(f64::from(i), 0.0, 0.0),
level: 14,
})
.collect();
sources.push(Source {
at: camera + DVec3::new(0.5, 0.0, 0.0),
level: 0,
});
sources.push(Source {
at: camera + DVec3::new(0.0, 0.0, 200.0),
level: 15,
});
let mut out = Vec::new();
select(&sources, camera, &mut out);
assert_eq!(out.len(), MAX);
assert_eq!(out[0], [0.0, 0.0, 0.0, 14.0]);
assert_eq!(out[MAX - 1][0], (MAX - 1) as f32);
select(&[], camera, &mut out);
assert!(out.is_empty());
}
#[test]
fn light_falls_off_a_level_per_block() {
let torch = [[0.0, 0.0, 0.0, 14.0]];
assert_eq!(level_at([0.0, 0.0, 0.0], &torch), 14.0);
assert_eq!(level_at([3.0, 4.0, 0.0], &torch), 9.0);
assert_eq!(level_at([20.0, 0.0, 0.0], &torch), 0.0);
let two = [[0.0, 0.0, 0.0, 14.0], [10.0, 0.0, 0.0, 15.0]];
assert_eq!(level_at([8.0, 0.0, 0.0], &two), 13.0);
assert_eq!(level_at([0.0; 3], &[]), 0.0);
}
#[test]
fn item_luminance() {
let l = Luminance::from_pairs(&[("minecraft:torch", 14)]);
assert_eq!(l.of("minecraft:torch"), 14);
assert_eq!(l.of("minecraft:stone"), 0);
}
}
+16 -2
View File
@@ -14,11 +14,13 @@ struct Globals {
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting.
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -58,13 +60,25 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
// level less per block. time.z of them are set: none when the extra is off, so no loop runs.
fn dynamic_block_light(pos: vec3<f32>) -> f32 {
let count = min(u32(globals.time.z), 16u);
var level = 0.0;
for (var i = 0u; i < count; i++) {
let l = globals.dynamic_lights[i];
level = max(level, l.w - distance(pos, l.xyz));
}
return level * 16.0;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
out.uv = uv;
let l = vec2(f32(light.y), f32(light.x));
let l = vec2(max(f32(light.y), dynamic_block_light(position)), f32(light.x));
let lm = textureSampleLevel(lightmap, lightmap_sampler,
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
// Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit.
+21 -4
View File
@@ -69,6 +69,8 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
pub zoom: bool,
/// A vanilla key name (`key.keyboard.z`).
pub zoom_key: String,
@@ -110,6 +112,7 @@ impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
dynamic_lights: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -158,6 +161,7 @@ impl Extras {
};
match key.trim() {
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
@@ -201,8 +205,9 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
self.zoom_key,
self.zoom_level,
@@ -423,6 +428,7 @@ impl Cps {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Fullbright,
DynamicLights,
Zoom,
ZoomKey,
ZoomLevel,
@@ -452,6 +458,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
vec![
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -501,6 +508,7 @@ impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
@@ -533,7 +541,8 @@ impl Item {
KeystrokesCorner | KeystrokesCps => Keystrokes,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort | AutoReconnect => {
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect => {
return None;
}
})
@@ -542,6 +551,9 @@ impl Item {
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
@@ -581,7 +593,7 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap => Some(Cost::Low),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
@@ -590,6 +602,7 @@ impl Item {
pub fn switch(self, e: &Extras) -> Option<bool> {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
@@ -632,6 +645,7 @@ impl Item {
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
@@ -677,7 +691,7 @@ mod tests {
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
assert!(!d.fullbright);
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert_eq!(Extras::parse(""), d);
@@ -685,6 +699,9 @@ mod tests {
let mut e = d.clone();
Item::Fullbright.cycle(&mut e, false);
assert_eq!(Item::Fullbright.switch(&e), Some(true));
Item::DynamicLights.cycle(&mut e, false);
assert_eq!(Item::DynamicLights.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
e.zoom = true;
e.zoom_key = "key.keyboard.c".into();
e.zoom_level = 6;
+23 -4
View File
@@ -38,6 +38,7 @@ mod deflect;
mod dispense_fixture;
mod dispenser;
mod dragon;
mod dynamic_lights;
mod enchant_effects;
mod end;
mod entity_culling;
@@ -269,8 +270,9 @@ struct Options {
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
dev_mods: Vec<PathBuf>,
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on
/// an integrated server thread (game/integrated.rs).
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--no-server-thread` or
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
@@ -325,7 +327,10 @@ impl Options {
dedicated: None,
benchmark: None,
dev_mods: Vec::new(),
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1"),
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
@@ -334,7 +339,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -349,6 +354,7 @@ impl Options {
"--offscreen" => o.offscreen = true,
"--control" => o.control = Some(horizon_client::control::default_path()),
"--server-thread" => o.server_thread = true,
"--no-server-thread" => o.server_thread = false,
_ => {
let value = args
.next()
@@ -1179,6 +1185,10 @@ struct Game<'a> {
renderer: Option<Renderer>,
/// CPU geometry storage retained across frames, including empty/title frames.
entity_vertices: Vec<Vertex>,
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
/// item glows (built the first time the extra is on).
dynamic_lights: Vec<[f32; 4]>,
luminance: Option<horizon_client::dynamic_light::Luminance>,
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
see_through_vertices: Vec<Vertex>,
started: Instant,
@@ -9660,6 +9670,11 @@ impl Game<'_> {
.then(|| self.border_draw(camera.to_array(), alpha as f32))
.flatten();
let see_through = std::mem::take(&mut self.see_through_vertices);
if title {
self.dynamic_lights.clear();
} else {
self.gather_dynamic_lights(camera, alpha);
}
let frame = Frame {
shader: self.shader_inputs(camera, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
@@ -9717,6 +9732,7 @@ impl Game<'_> {
color: sky.cloud_color,
}),
entities: &entities,
dynamic_lights: &self.dynamic_lights,
additive: &additive,
see_through: &see_through,
celestial: if self.settings.horizon.sky || title {
@@ -11307,6 +11323,8 @@ impl<'a> Game<'a> {
window: None,
renderer: None,
entity_vertices: Vec::new(),
dynamic_lights: Vec::new(),
luminance: None,
see_through_vertices: Vec::new(),
health_hud: hud_layout::HealthHud::default(),
started: now,
@@ -12180,6 +12198,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
hud,
nether_lighting: false,
entities: &[],
dynamic_lights: &[],
clouds: None,
additive: &[],
see_through: &[],
+11 -1
View File
@@ -1840,11 +1840,21 @@ impl Game<'_> {
if self.ticks < *report_tick && !timed_out {
return None;
}
let after = pos.map(|p| self.block_json(p));
let changed = match (*pos, *before) {
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
_ => false,
};
// A client of a server (single player's server thread, a LAN
// guest) sees the block once the server's answer comes.
if *place
&& !changed
&& self.remote.is_some()
&& self.ticks < *report_tick + 20
&& !timed_out
{
return None;
}
let after = pos.map(|p| self.block_json(p));
let used = delta(inventory, &self.inventory_counts());
let mut v = json!({
"ok": !*place || changed,
+3 -1
View File
@@ -879,7 +879,9 @@ impl Game<'_> {
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
"fight": self.dragon_status(),
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
// A client of a server (single player's server thread) knows the
// carts it was sent.
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
"riding": self.riding,
"using": self.using,
"render_distance": self.streaming.radius,
@@ -0,0 +1,67 @@
//! Horizon Extras' dynamic lights (`horizon_client::dynamic_light`): the light sources near the
//! camera this frame. Nothing is gathered while the extra is off.
use super::{Game, shield};
use glam::DVec3;
use horizon_client::dynamic_light::{self, BURNING, Luminance, Source};
impl Game<'_> {
/// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and
/// glowing dropped items, the nearest [`dynamic_light::MAX`] of them.
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
if !self.settings.extras.dynamic_lights {
self.dynamic_lights = out;
return;
}
if self.luminance.is_none() {
self.luminance = Some(Luminance::new(self.res.facts(), self.pack.assets.registry));
}
let lum = self.luminance.as_ref().expect("built above");
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
let mut sources = Vec::new();
if let Some(p) = &self.player
&& !p.spectator
{
let feet = self
.previous_position
.lerp(p.position, self.clock.camera_fraction());
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
// About where the hands are.
sources.push(Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
});
}
for m in self.mobs.iter().filter(|m| !m.removed) {
let level = held(m.extra.main_hand.as_deref())
.max(held(m.extra.off_hand.as_deref()))
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
if level == 0 {
continue;
}
let at = m.previous.lerp(m.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
});
}
for item in &self.dropped {
let level = lum
.of(&item.stack.item)
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
if level == 0 {
continue;
}
let at = item.previous.lerp(item.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
});
}
dynamic_light::select(&sources, camera, &mut out);
self.dynamic_lights = out;
}
}
+3 -3
View File
@@ -6,9 +6,9 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
//! player joins as a network player, and the server side of a network player
//! does not do everything single player does yet.
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
//! the world in the frame instead (the escape hatch while the network
//! player's side catches up with the in-frame one).
use super::{
Game, Options, PackResources, Resources, UiResources,
lan::{LanSettings, Owner},
@@ -230,6 +230,11 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::OpenShaderPackFolder => {
let dir = horizon_client::shader_pack::folder();
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
}
}
Ok(())
+22
View File
@@ -529,6 +529,28 @@ impl Game<'_> {
}
}
/// The minecarts the server sent, for the control socket's `status`
/// (their velocity is not tracked here).
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
let r = self.remote.as_ref()?;
let mut carts: Vec<&Tracked> = r
.entities
.values()
.filter(|t| t.kind.ends_with("minecart"))
.collect();
carts.sort_by(|a, b| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
});
Some(
carts
.into_iter()
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
.collect(),
)
}
/// The names in the server's player list, sorted.
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
@@ -37,11 +37,8 @@ impl ShaderPacks {
}
}
/// `shaderpacks/` beside `options.txt`.
pub(super) fn dir() -> PathBuf {
std::env::current_dir()
.unwrap_or_default()
.join(shader_pack::DIR)
shader_pack::folder()
}
impl Game<'_> {
@@ -77,6 +74,7 @@ impl Game<'_> {
return self.shader_pack_error(&id, &e.to_string());
}
self.shader_packs.pack = pack;
self.ui.options.shader_pack_error = None;
}
/// Give the loaded pack to the renderer (also after it is rebuilt).
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
@@ -105,6 +103,11 @@ impl Game<'_> {
}
fn shader_pack_error(&mut self, id: &str, error: &str) {
log::warn!("Horizon: shader pack {id}: {error}");
// The Shader Packs page says why on the pack's row: the first line,
// since translator errors go on with a drawn source excerpt.
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
self.ui.options.shader_pack_error =
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
}
}
@@ -373,6 +373,11 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::OpenShaderPackFolder => {
let dir = horizon_client::shader_pack::folder();
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::ButtonClick => {}
}
}
+14 -2
View File
@@ -107,13 +107,16 @@ struct Globals {
fog_ends: [f32; 4],
/// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: [[f32; 4]; 2],
/// x: vanilla's `GameTime` uniform; y: nether cardinal lighting, zw unused.
/// x: vanilla's `GameTime` uniform; y: nether cardinal lighting; z: how many
/// `dynamic_lights` are set; w unused.
time: [f32; 4],
/// Atlas rect of a destroy stage (entity.wgsl takes only its size: every stage's is the same,
/// and each crack carries its own stage's origin).
crumbling_sprite: [f32; 4],
/// Framebuffer size, selection stroke width, perspective layering scale.
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -186,6 +189,8 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
pub dynamic_lights: &'a [[f32; 4]],
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
pub clouds: Option<crate::clouds::Input<'a>>,
/// Additive translucent geometry after the entities (lightning, dragon rays).
@@ -2908,10 +2913,17 @@ impl Renderer {
time: [
frame.shader_game_time,
f32::from(frame.nether_lighting),
0.0,
frame.dynamic_lights.len().min(crate::dynamic_light::MAX) as f32,
0.0,
],
outline_params: outline_params(self.config.width, self.config.height),
dynamic_lights: {
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
*to = *from;
}
lights
},
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
+30 -17
View File
@@ -237,7 +237,7 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::ShaderPack,
High,
"Redraws the world with a pack from shaderpacks/. Off is vanilla.",
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
),
Row::Header("Preferences"),
v(Opt::ViewBobbing, None, "Sways the view while walking."),
@@ -422,17 +422,6 @@ impl H {
H::Antialiasing => g.fxaa = !g.fxaa,
H::BatteryMode => g.battery = !g.battery,
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
H::ShaderPack => {
// Off, then every pack in shaderpacks/ by folder name.
let dir = std::env::current_dir()
.unwrap_or_default()
.join(crate::shader_pack::DIR);
let mut all = vec![String::new()];
all.extend(crate::shader_pack::list(&dir).into_iter().map(|(id, _)| id));
let i = all.iter().position(|x| *x == g.shader_pack).unwrap_or(0);
let n = all.len();
g.shader_pack = all[if back { (i + n - 1) % n } else { (i + 1) % n }].clone();
}
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -559,6 +548,8 @@ pub enum Action {
Changed,
OpenAdvanced,
OpenSimple,
/// The Shader Pack row: the Shader Packs page (where packs are picked).
OpenShaderPacks,
Done,
}
@@ -784,6 +775,9 @@ impl GraphicsUi {
}
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
Target::Item(Item::Horizon(H::ShaderPack)) => {
vec![Action::ButtonClick, Action::OpenShaderPacks]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -1003,11 +997,30 @@ mod tests {
);
s.horizon.shader_pack = "horizon-sample".into();
assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample");
// Picking a pack keeps the preset.
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let preset = s.horizon.preset;
row.cycle(&mut s, false, [854, 480]);
assert_eq!(s.horizon.preset, preset);
// The row opens the Shader Packs page, where packs are picked.
let mut ui = GraphicsUi::default();
let find = |ui: &mut GraphicsUi| {
let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0);
widgets
.into_iter()
.find(|w| matches!(w.target, Target::Item(i) if i == row))
.unwrap()
};
ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0;
let w = find(&mut ui);
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(w.x + 1.0, w.y + 1.0),
(854.0, 480.0),
[854, 480],
false,
false
),
[Action::ButtonClick, Action::OpenShaderPacks]
);
assert_eq!(s.horizon.shader_pack, "horizon-sample");
}
#[test]
+39
View File
@@ -179,4 +179,43 @@ mod tests {
);
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
}
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
/// game loads them, with their default options: the compatibility table
/// of docs/shader-packs.md. Skipped when the variable isn't set.
#[test]
fn real_packs_load() {
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
return;
};
let mut entries: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| is_java_pack(p))
.collect();
entries.sort();
let mut failures = Vec::new();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
Ok(pack) => {
let folders: Vec<String> = pack
.folders
.iter()
.map(|f| {
let n = f.passes.len() + usize::from(f.final_pass.is_some());
format!("{} {n} passes", f.folder)
})
.collect();
println!("{name}: loads ({})", folders.join(", "));
}
Err(e) => {
println!("{name}: {e}");
failures.push(name);
}
}
}
assert!(failures.is_empty(), "{failures:?}");
}
}
+1
View File
@@ -51,6 +51,7 @@ pub mod datapack;
pub mod debug_chart;
pub mod debug_keys;
pub mod debug_overlay;
pub mod dynamic_light;
pub mod effects;
pub mod enchant;
pub mod end_crystal;
+159 -8
View File
@@ -38,7 +38,14 @@ pub enum HRow {
Mod(usize),
/// Opens the directory where Horizon mods are installed.
OpenModFolder,
/// `shaderpacks/`'s path and a button opening it.
ShaderPackFolder,
/// Shader pack `i` of `OptionsUi::shader_packs` and its switch.
ShaderPack(usize),
}
/// The sidebar section listing the shader packs.
pub const SHADER_PACKS: &str = "Shader Packs";
/// A sidebar section: name, description, rows.
pub struct Section {
pub name: &'static str,
@@ -172,6 +179,12 @@ pub const SECTIONS: &[Section] = &[
.to_vec()
},
},
Section {
name: SHADER_PACKS,
blurb: "Packs that redraw the world. Off is the vanilla look.",
// The packs follow, from the folder (`horizon_rows`).
rows: |_| vec![HRow::ShaderPackFolder],
},
Section {
name: "Interface",
blurb: "Menus and resource packs.",
@@ -309,7 +322,15 @@ fn horizon_batch(framebuffer: [i32; 2]) -> HudBatch {
}
impl OptionsUi {
fn horizon_rows(&self, s: &Settings, section: usize) -> Vec<HRow> {
let rows = (SECTIONS[section].rows)(s);
let mut rows = (SECTIONS[section].rows)(s);
if SECTIONS[section].name == SHADER_PACKS {
if self.shader_packs.packs.is_empty() {
rows.push(HRow::Header(
"No shader packs: put a pack's folder or .zip in this folder",
));
}
rows.extend((0..self.shader_packs.packs.len()).map(HRow::ShaderPack));
}
let query = self.settings_search.value.to_lowercase();
if query.is_empty() || !searchable_section(section) {
return rows;
@@ -511,6 +532,38 @@ impl OptionsUi {
ui.described_row(x, y, &installed.title, "", 0.0, cost, text, cw - 60.0);
ui.switch(ROW + i as u64, control_x, y + ROW_H / 2.0, s.mods.is_on(m));
}
HRow::ShaderPackFolder => {
let label = "Open Folder";
let bw = ui.button_width(label, false).max(96.0);
let dir = self.shader_packs.dir.display().to_string();
ui.described_row(x, y, "Folder", "", 0.0, (0, ""), &dir, cw - bw - 24.0);
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
label,
Button::Secondary,
true,
);
}
HRow::ShaderPack(p) => {
let (id, info) = &self.shader_packs.packs[p];
let error = match info {
Err(e) => Some(e.as_str()),
Ok(_) => self
.shader_pack_error
.as_ref()
.filter(|(failed, _)| failed == id)
.map(|(_, e)| e.as_str()),
};
let (cost, text) = match (error, info) {
(Some(e), _) => ((3, "Failed"), e),
(None, Ok(info)) => ((0, ""), info.description.as_str()),
(None, Err(_)) => unreachable!(),
};
ui.described_row(x, y, id, "", 0.0, cost, text, cw - 60.0);
let on = s.horizon.shader_pack == *id;
ui.switch(ROW + i as u64, control_x, y + ROW_H / 2.0, on);
}
HRow::OpenModFolder => {
ui.row(x, y, "Mod folder", "", 0.0);
let label = "Open Folder";
@@ -756,13 +809,7 @@ impl OptionsUi {
return events;
}
match target {
Some(Target::Nav(i)) => {
if searchable_section(self.section) || searchable_section(i) {
self.settings_search.set_value("");
}
self.section = i;
self.hscroll = 0.0;
}
Some(Target::Nav(i)) => self.select_section(i),
Some(Target::Search) => {
self.settings_search.focused = true;
self.settings_search.move_cursor_to_end(false);
@@ -798,6 +845,10 @@ impl OptionsUi {
}
events.push(Event::Changed);
}
Some(HRow::Graphics(Item::Horizon(graphics_ui::H::ShaderPack), ..)) => {
events.push(Event::ButtonClick);
self.open_section(SHADER_PACKS);
}
Some(HRow::Graphics(item, ..)) => {
item.cycle(s, right, framebuffer);
events.push(Event::Changed);
@@ -818,6 +869,14 @@ impl OptionsUi {
events.push(Event::ButtonClick);
events.push(Event::OpenModFolder);
}
Some(HRow::ShaderPackFolder) if !right => {
events.push(Event::ButtonClick);
events.push(Event::OpenShaderPackFolder);
}
Some(HRow::ShaderPack(p)) => {
self.toggle_shader_pack(s, *p);
events.push(Event::Changed);
}
_ => {}
}
}
@@ -825,6 +884,19 @@ impl OptionsUi {
}
events
}
fn select_section(&mut self, i: usize) {
if searchable_section(self.section) || searchable_section(i) {
self.settings_search.set_value("");
}
self.section = i;
self.hscroll = 0.0;
}
/// Show the sidebar section called `name`.
fn open_section(&mut self, name: &str) {
if let Some(i) = SECTIONS.iter().position(|s| s.name == name) {
self.select_section(i);
}
}
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
self.hscroll -= amount * 40.0;
}
@@ -1057,6 +1129,85 @@ mod tests {
assert_eq!(ui.hscroll, 0.0);
}
/// The Shader Packs section: the folder's path and Open Folder, a switch
/// per pack; the Video section's Shader Pack row leads here (#941, #925).
#[test]
fn shader_packs_section_opens_the_folder_and_picks_a_pack() {
let atlas = atlas();
let framebuffer = [2560, 1440];
let dir =
std::env::temp_dir().join(format!("horizon-shader-section-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut options = section_ui(SHADER_PACKS);
let mut settings = Settings::default();
options.shader_packs.poll(dir.clone());
assert!(matches!(
options.horizon_rows(&settings, options.section)[..],
[HRow::ShaderPackFolder, HRow::Header(_)]
));
std::fs::write(dir.join("Pack.zip"), b"").unwrap();
options.shader_packs = Default::default();
options.shader_packs.poll(dir.clone());
// Click the control with hit id `id`, scrolling until it shows.
let click = |options: &mut OptionsUi, settings: &mut Settings, id: u64| {
for _ in 0..40 {
let mut batch = horizon_batch(framebuffer);
let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0));
options.horizon_layout(settings, &mut ui, 1.0);
if let Some(hit) = ui.hits.iter().find(|hit| hit.id == id) {
let r = hit.rect;
let scale = ui.density / ui.batch.scale;
let at = ((r[0] + r[2]) * 0.5 * scale, (r[1] + r[3]) * 0.5 * scale);
drop(ui);
return options.click_horizon(
settings,
&atlas,
at.0,
at.1,
framebuffer,
false,
false,
);
}
drop(ui);
options.hscroll += 200.0;
}
panic!("no control {id}");
};
assert_eq!(
click(&mut options, &mut settings, ROW),
[Event::ButtonClick, Event::OpenShaderPackFolder]
);
assert_eq!(
click(&mut options, &mut settings, ROW + 1),
[Event::Changed]
);
assert_eq!(settings.horizon.shader_pack, "Pack.zip");
click(&mut options, &mut settings, ROW + 1);
assert_eq!(settings.horizon.shader_pack, "");
let mut options = section_ui("Video");
settings.horizon.advanced = true;
let video = SECTIONS.iter().position(|s| s.name == "Video").unwrap();
let row = options
.horizon_rows(&settings, video)
.iter()
.position(|r| {
matches!(
r,
HRow::Graphics(Item::Horizon(graphics_ui::H::ShaderPack), ..)
)
})
.unwrap();
assert_eq!(
click(&mut options, &mut settings, ROW + row as u64),
[Event::ButtonClick]
);
assert_eq!(SECTIONS[options.section].name, SHADER_PACKS);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mods_section_opens_folder_from_its_button() {
let atlas = atlas();
+216 -3
View File
@@ -862,6 +862,8 @@ pub enum Page {
Font,
Packs,
Mods,
/// The shader packs in `shaderpacks/`: pick one, open the folder.
ShaderPacks,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -888,6 +890,9 @@ enum Act {
HorizonMenus,
ToggleMod(usize),
OpenModFolder,
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
ToggleShaderPack(usize),
OpenShaderPackFolder,
}
enum WKind {
Button {
@@ -935,6 +940,8 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// Create `shaderpacks/` if needed and open it.
OpenShaderPackFolder,
}
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
@@ -993,6 +1000,10 @@ pub struct OptionsUi {
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
/// `shaderpacks/` and its packs, read while the Shader Packs page shows.
pub shader_packs: crate::shader_pack::Folder,
/// The pack that failed to load last and why (set by the game).
pub shader_pack_error: Option<(String, String)>,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1025,6 +1036,8 @@ impl Default for OptionsUi {
in_world: false,
center_language: false,
graphics: Default::default(),
shader_packs: Default::default(),
shader_pack_error: None,
}
}
}
@@ -1073,6 +1086,8 @@ enum Row {
Big(Opt),
Pair(Opt, Option<Opt>),
Header(&'static str),
/// A line of grey text (a path).
Note(String),
Buttons(Widget, Option<Widget>),
}
fn pairs(opts: &[Opt]) -> Vec<Row> {
@@ -1131,6 +1146,33 @@ impl OptionsUi {
self.graphics.reset();
}
}
/// Pick shader pack `i` of the folder, or turn it off when it is the
/// one running.
pub(crate) fn toggle_shader_pack(&self, s: &mut Settings, i: usize) {
let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else {
return;
};
let g = &mut s.horizon;
g.shader_pack = if g.shader_pack == *id {
String::new()
} else {
id.clone()
};
}
pub(crate) fn shader_pack_failed(&self, id: &str) -> bool {
self.shader_pack_error
.as_ref()
.is_some_and(|(e, _)| e == id)
}
/// Whether the Shader Packs page (or the Horizon menus' section) shows.
fn shader_packs_shown(&self) -> bool {
self.page() == Page::ShaderPacks
|| crate::ui_theme::horizon()
&& self.page() == Page::Main
&& crate::options_horizon::SECTIONS
.get(self.section)
.is_some_and(|s| s.name == crate::options_horizon::SHADER_PACKS)
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
match page {
@@ -1153,6 +1195,7 @@ impl OptionsUi {
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
@@ -1262,6 +1305,16 @@ impl OptionsUi {
Act::Push(Page::GraphicsAdvanced),
)),
)];
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Shader Packs..."),
Act::Push(Page::ShaderPacks),
),
None,
));
// The way back from Classic menus. Horizon-only like the row
// above, here rather than on the main Options screen so that
// one keeps vanilla's exact layout; vanilla's interface
@@ -1450,6 +1503,32 @@ impl OptionsUi {
})
.collect()
}
Page::ShaderPacks => {
let folder = &self.shader_packs;
let mut rows = vec![Row::Note(folder.dir.display().to_string())];
if folder.packs.is_empty() {
rows.push(Row::Header(
"No shader packs: put a pack's folder or .zip in this folder",
));
}
for (i, (id, info)) in folder.packs.iter().enumerate() {
let on = s.horizon.shader_pack == *id;
let mut label = Text::literal(format!("{id}: ")).append(on_off(on));
if info.is_err() || self.shader_pack_failed(id) {
label = label.append(" (Failed)");
}
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleShaderPack(i));
rows.push(Row::Buttons(
if info.is_ok() {
widget
} else {
inactive(widget)
},
None,
));
}
rows
}
_ => {
let _ = s;
Vec::new()
@@ -1849,6 +1928,21 @@ impl OptionsUi {
});
y += padding + 18.0;
}
Row::Note(text) => {
out.push(Widget {
x: 0.0,
y: y + 4.0,
w,
h: 9.0,
kind: WKind::Text {
text: Text::literal(text),
color: 0xFFA0A0A0,
centered: true,
},
list: false,
});
y += 16.0;
}
Row::Big(opt) => {
out.push(option_widget(s, opt, cx - 155.0, y + 2.0, 310.0));
y += 25.0;
@@ -1875,13 +1969,20 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
if page == Page::Mods {
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => {
Some(("Open Shader Pack Folder", Act::OpenShaderPackFolder))
}
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal("Open Mod Folder"),
Act::OpenModFolder,
Text::literal(label),
act,
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
@@ -1911,6 +2012,9 @@ impl OptionsUi {
atlas: &HudAtlas,
cursor: (f32, f32),
) {
if self.shader_packs_shown() {
self.shader_packs.poll(crate::shader_pack::folder());
}
let (w, h) = (batch.width, batch.height);
if let Some(error) = &self.resource_error {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
@@ -2210,6 +2314,7 @@ impl OptionsUi {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenShaderPacks => self.push(Page::ShaderPacks),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -2377,6 +2482,11 @@ impl OptionsUi {
events.push(Event::Changed);
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ToggleShaderPack(i) => {
self.toggle_shader_pack(s, i);
events.push(Event::Changed);
}
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
Act::None => {}
}
}
@@ -2831,6 +2941,109 @@ mod tests {
assert!(ui.resource_error.is_none());
}
/// The Shader Packs page (Video Settings > Shader Packs..., or the
/// Shader Pack row of Advanced graphics): the folder's path, Open Shader
/// Pack Folder, and a switch per pack (#941, #925).
#[test]
fn classic_shader_packs_page_shows_the_folder_and_picks_a_pack() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
let dir = std::env::temp_dir().join(format!("horizon-shaderpacks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let find = |ui: &mut OptionsUi, s: &Settings, act: &dyn Fn(&Act) -> bool| {
ui.layout(s, size.0, size.1)
.0
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
};
ui.show(Page::Video);
let open = find(&mut ui, &s, &|a| *a == Act::Push(Page::ShaderPacks)).unwrap();
ui.click(
&mut s,
&atlas,
open.x + 1.0,
open.y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(ui.page(), Page::ShaderPacks);
ui.shader_packs.poll(dir.clone());
let empty = ui.layout(&s, size.0, size.1).0;
let path = dir.display().to_string();
for line in [path.as_str(), "No shader packs"] {
assert!(
empty.iter().any(
|w| matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(line))
),
"{line}"
);
}
let folder = find(&mut ui, &s, &|a| *a == Act::OpenShaderPackFolder).unwrap();
assert_eq!(
ui.click(
&mut s,
&atlas,
folder.x + 1.0,
folder.y + 1.0,
size,
[480, 240],
false,
false
),
vec![Event::ButtonClick, Event::OpenShaderPackFolder]
);
std::fs::write(dir.join("Pack.zip"), b"").unwrap();
ui.shader_packs = Default::default();
ui.shader_packs.poll(dir.clone());
let pack = find(&mut ui, &s, &|a| *a == Act::ToggleShaderPack(0)).unwrap();
let events = ui.click(
&mut s,
&atlas,
pack.x + 1.0,
pack.y + 1.0,
size,
[480, 240],
false,
false,
);
assert!(events.contains(&Event::Changed));
assert_eq!(s.horizon.shader_pack, "Pack.zip");
ui.shader_pack_error = Some(("Pack.zip".into(), "broken".into()));
let label = |ui: &mut OptionsUi, s: &Settings| match find(ui, s, &|a| {
*a == Act::ToggleShaderPack(0)
})
.unwrap()
.kind
{
WKind::Button { label, .. } => label.plain(),
_ => unreachable!(),
};
assert!(
label(&mut ui, &s).ends_with("(Failed)"),
"{}",
label(&mut ui, &s)
);
ui.click(
&mut s,
&atlas,
pack.x + 1.0,
pack.y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(s.horizon.shader_pack, "", "a second click turns it off");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
let atlas = atlas();
+31 -1
View File
@@ -171,11 +171,41 @@ pub fn list(dir: &Path) -> Vec<(String, Result<Info, String>)> {
packs
}
/// `shaderpacks/` beside `options.txt` (the game's working folder: the game
/// home, not the launcher's version folder).
pub fn folder() -> PathBuf {
std::env::current_dir().unwrap_or_default().join(DIR)
}
/// `shaderpacks/` and its packs as the Shader Packs page lists them, read
/// again at most once a second while the page is open, so a pack dropped
/// into the folder shows up.
#[derive(Debug, Default)]
pub struct Folder {
pub dir: PathBuf,
pub packs: Vec<(String, Result<Info, String>)>,
read: Option<std::time::Instant>,
}
impl Folder {
pub fn poll(&mut self, dir: PathBuf) {
let fresh = self
.read
.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(1));
if fresh && self.dir == dir {
return;
}
self.packs = list(&dir);
self.dir = dir;
self.read = Some(std::time::Instant::now());
}
}
/// A Java pack has no manifest: its name is its file name.
fn java_info(name: String) -> Info {
Info {
name,
description: String::new(),
description: "Iris/OptiFine pack: its full-screen passes run.".into(),
authors: Vec::new(),
version: String::new(),
license: String::new(),
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn system() -> System {
system
}
/// The release (`v2026.10.15`), or the client's crate version for a build from source.
/// The release (`v0.2.3`), or the client's crate version for a build from source.
pub fn version() -> String {
crate::crash::release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
}
+18 -2
View File
@@ -16,11 +16,14 @@ struct Globals {
fog_ends: vec4<f32>,
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000), z: how many
// dynamic_lights are set
time: vec4<f32>,
// Atlas rect of the current destroy stage (read by entity.wgsl).
crumbling_sprite: vec4<f32>,
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -67,6 +70,18 @@ struct TerrainOut {
@location(3) spherical: f32,
@location(4) cylindrical: f32,
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
// level less per block. time.z of them are set: none when the extra is off, so no loop runs.
fn dynamic_block_light(pos: vec3<f32>) -> f32 {
let count = min(u32(globals.time.z), 16u);
var level = 0.0;
for (var i = 0u; i < count; i++) {
let l = globals.dynamic_lights[i];
level = max(level, l.w - distance(pos, l.xyz));
}
return level * 16.0;
}
fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
let uv = vec2(block, sky) * 16.0;
return textureSampleLevel(lightmap, lightmap_sampler,
@@ -105,7 +120,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.clip = globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.layer = layer;
out.color = color * sample_lightmap(f32(light.x) / 16.0, f32(light.y) / 16.0);
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
return out;
+1
View File
@@ -17,6 +17,7 @@ pub mod storage;
pub mod text;
pub mod ticks;
pub mod release;
pub mod version;
pub use version::VANILLA_VERSION;
+32
View File
@@ -0,0 +1,32 @@
//! Which Horizon release this build is (docs/versioning.md). Not Minecraft's version: that is
//! [`crate::version`], generated from the game's own files.
/// The release's version (`0.2.3`, no `v`): release.yml builds every release with `HORIZON_VERSION`
/// set from its tag, so there are no version-bump commits. None for a build from source.
pub const RELEASE: Option<&str> = match option_env!("HORIZON_VERSION") {
Some(version) if !version.is_empty() => Some(version),
_ => None,
};
/// [`RELEASE`], or this crate's Cargo.toml version for a build from source: what `--version`, user
/// agents and reports say.
pub const VERSION: &str = match RELEASE {
Some(version) => version,
None => env!("CARGO_PKG_VERSION"),
};
#[cfg(test)]
mod tests {
#[test]
fn the_version_is_the_release_or_the_crate() {
assert_eq!(
super::RELEASE,
option_env!("HORIZON_VERSION").filter(|v| !v.is_empty())
);
assert_eq!(
super::VERSION,
super::RELEASE.unwrap_or(env!("CARGO_PKG_VERSION"))
);
assert!(!super::VERSION.starts_with('v'));
}
}
+8 -1
View File
@@ -5,6 +5,13 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
/// This release's version: release.yml builds it with `HORIZON_VERSION` from the tag
/// (docs/versioning.md); a build from source says Cargo.toml's.
const VERSION: &str = match option_env!("HORIZON_VERSION") {
Some(version) if !version.is_empty() => version,
_ => env!("CARGO_PKG_VERSION"),
};
const HELP: &str = "\
horizon-prepare: prepare Horizon's resources from your own Minecraft Java Edition
@@ -92,7 +99,7 @@ fn main() -> ExitCode {
return ExitCode::SUCCESS;
}
"-V" | "--version" => {
println!("horizon-prepare {}", env!("CARGO_PKG_VERSION"));
println!("horizon-prepare {VERSION}");
return ExitCode::SUCCESS;
}
_ => Err(format!("unrecognized argument: {text}")),
+2 -2
View File
@@ -45,7 +45,7 @@ pub fn resolve(name: &str, online: bool) -> Option<NameAndId> {
pub fn find_by_name(discovery: &str, name: &str) -> Option<NameAndId> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build();
let doc = json::parse(&agent.get(discovery).call().ok()?.into_string().ok()?, true).ok()?;
let url = endpoint(&doc, "profiles", "getByName")?.replace("{name}", &name.to_lowercase());
@@ -61,7 +61,7 @@ pub fn fetch_profile_key_keys(discovery: &str) -> Result<Vec<RsaPublicKey>, &'st
const FAILED: &str = "Failed to request Minecraft Services public key";
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build();
let get = |url: &str| agent.get(url).call().ok()?.into_string().ok();
let doc = get(discovery)
+1 -1
View File
@@ -286,7 +286,7 @@ fn status(shared: &Shared) -> Value {
let state = shared.state.lock().unwrap();
json!({
"phase": state.phase.name(),
"version": env!("CARGO_PKG_VERSION"),
"version": crate::VERSION,
"pid": std::process::id(),
"uptime_seconds": shared.started.elapsed().as_secs(),
"engine_pid": state.engine_pid,
+8 -1
View File
@@ -18,6 +18,13 @@ mod update;
use std::{path::PathBuf, process::ExitCode};
/// This release's version: release.yml builds it with `HORIZON_VERSION` from the tag
/// (docs/versioning.md); a build from source says Cargo.toml's.
const VERSION: &str = match option_env!("HORIZON_VERSION") {
Some(version) if !version.is_empty() => version,
_ => env!("CARGO_PKG_VERSION"),
};
const HELP: &str = "\
horizon-server: host a Horizon (Minecraft Java Edition 26.3) server
@@ -86,7 +93,7 @@ fn main() -> ExitCode {
Ok(())
}
Some("version" | "-V" | "--version") => {
println!("horizon-server {}", env!("CARGO_PKG_VERSION"));
println!("horizon-server {VERSION}");
Ok(())
}
Some("init") => init_answers(&args[1..]).and_then(|a| init::run(&dir, a)),
+175 -5
View File
@@ -6,8 +6,9 @@
use crate::include::Inlined;
use pp_rs::{
pp::Preprocessor,
token::{Punct, TokenValue},
token::{PreprocessorError, Punct, TokenValue},
};
use std::collections::HashMap;
/// The version of Minecraft packs are told they run on (`MC_VERSION`,
/// Iris's `formatVersionString("26.3")`).
@@ -286,14 +287,176 @@ fn mark_comments(text: &str) -> (String, Vec<String>) {
(out, comments)
}
/// Whether trimmed `line` is an `#if` or `#elif` directive.
pub(crate) fn is_condition(line: &str) -> bool {
line.strip_prefix('#')
.map(str::trim_start)
.and_then(|d| d.strip_prefix("elif").or_else(|| d.strip_prefix("if")))
.is_some_and(|r| !r.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
}
/// Whether `word` is a float literal.
pub(crate) fn is_float(word: &str) -> bool {
word.starts_with(|c: char| c.is_ascii_digit() || c == '.')
&& !word.starts_with("0x")
&& !word.starts_with("0X")
&& word.contains(['.', 'e', 'E'])
}
/// What JCPP's `#if` makes of a number (`Preprocessor.expr` takes
/// `NumericValue.longValue`): a float counts as its integer part, so
/// `0.75` is 0 and `1.5e1` is 10. `None` for a non-number.
pub(crate) fn jcpp_integer(number: &str) -> Option<i64> {
let number = number.trim_end_matches(['f', 'F', 'l', 'L', 'u', 'U']);
let (mantissa, exponent) = match number.find(['e', 'E']) {
Some(i) => (&number[..i], number[i + 1..].parse::<i32>().ok()?),
None => (number, 0),
};
let integer = mantissa.split('.').next()?;
let value = if integer.is_empty() {
0
} else {
integer.parse::<i64>().ok()?
};
Some((value as f64 * 10f64.powi(exponent)) as i64)
}
/// `line` with each word (identifier or number, with an exponent's sign:
/// `1e-3`) replaced by what `f` returns for it, if anything. The directive's
/// name and the names after `defined` are kept.
pub(crate) fn rewrite_condition(line: &str, f: impl Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(line.len());
let (mut rest, mut first, mut after_defined) = (line, true, false);
while let Some(c) = rest.chars().next() {
if !(c.is_ascii_alphanumeric() || c == '_' || c == '.') {
if !c.is_whitespace() && c != '(' && c != '#' {
after_defined = false;
}
out.push(c);
rest = &rest[c.len_utf8()..];
continue;
}
let bytes = rest.as_bytes();
let mut len = 0;
while let Some(&b) = bytes.get(len) {
let sign = (b == b'+' || b == b'-')
&& bytes[0].is_ascii_digit()
&& matches!(bytes[len - 1], b'e' | b'E');
if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || sign) {
break;
}
len += 1;
}
let word = &rest[..len];
let kept = first || after_defined;
match (!kept).then(|| f(word)).flatten() {
Some(replacement) => out.push_str(&replacement),
None => out.push_str(word),
}
after_defined = word == "defined";
first = false;
rest = &rest[len..];
}
out
}
/// Make pp-rs read `text` as JCPP, Iris's preprocessor, does:
/// - a `#define` replaces an earlier macro (`Preprocessor.addMacro`; Bloop
/// and Steadfast redefine theirs), where pp-rs refuses a different
/// redefinition: an `#undef` line goes before every `#define`;
/// - in `#if`/`#elif`, a float counts as its integer part, and so does a
/// macro always defined as the same float (an option: `#define LEVEL 0.5`);
/// a macro always defined empty (Iris's `MC_GL_VENDOR_OTHER`, Bloop's
/// `#define TAA_USE_YCOCG`) counts as 0: JCPP reports the missing operand
/// and goes on with 0. pp-rs stops on both;
/// - `#error "..."` (Photon's guard in programs it disables) lexes: pp-rs
/// has no string tokens and fails on the quote, even in a skipped block.
///
/// Returns the text and the added lines' 1-based numbers in it.
fn jcpp_compatible(text: &str, env: &Environment) -> (String, Vec<u32>) {
// A `#define` line's name and body.
let define = |l: &str| {
let rest = l
.trim_start()
.strip_prefix('#')?
.trim_start()
.strip_prefix("define")
.filter(|rest| rest.starts_with([' ', '\t']))?
.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let (name, body) = rest.split_at(end);
let body = body.split("//").next().unwrap_or("").trim();
(!name.is_empty()).then(|| (name.to_owned(), body.to_owned()))
};
// Each macro's body, if every definition has the same one.
let mut bodies: HashMap<String, Option<String>> = HashMap::new();
let definitions = env
.defines
.iter()
.cloned()
.chain(text.lines().filter_map(define));
for (name, body) in definitions {
bodies
.entry(name)
.and_modify(|b| {
if b.as_ref() != Some(&body) {
*b = None;
}
})
.or_insert(Some(body));
}
let number = |word: &str| -> Option<String> {
let word = match bodies.get(word) {
Some(Some(body)) if body.is_empty() => return Some("0".into()),
Some(Some(body)) => body.as_str(),
Some(None) => return None,
None => word,
};
is_float(word)
.then(|| jcpp_integer(word))
.flatten()
.map(|n| n.to_string())
};
let mut out = String::with_capacity(text.len() + text.len() / 16);
let mut added = Vec::new();
let (mut line, mut continued) = (1, false);
for l in text.split_inclusive('\n') {
if !continued && let Some((name, _)) = define(l) {
out.push_str(&format!("#undef {name}\n"));
added.push(line);
line += 1;
}
let directive = l.trim_start().strip_prefix('#').map(str::trim_start);
if !continued
&& directive.is_some_and(|d| d.starts_with("error") || d.starts_with("warning"))
{
out.push_str(&l.replace(['"', '\''], ""));
} else if !continued && is_condition(l.trim_start()) {
out.push_str(&rewrite_condition(l, number));
} else {
out.push_str(l);
}
continued = l.trim_end().ends_with('\\');
line += 1;
}
(out, added)
}
/// Expand `source` with `env`'s macros. Errors name the file and line.
pub fn preprocess(source: &Inlined, env: &Environment) -> Result<Preprocessed, String> {
let (marked, comments) = mark_comments(&source.text);
let text = &marked;
let (text, added) = jcpp_compatible(&marked, env);
let text = &text;
let line_starts: Vec<usize> = std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let line_of = |offset: u32| line_starts.partition_point(|&s| s <= offset as usize) as u32;
// Lines of the source: the added `#undef` lines don't count.
let line_of = |offset: u32| {
let line = line_starts.partition_point(|&s| s <= offset as usize) as u32;
line - added.partition_point(|&a| a < line) as u32
};
let mut pp = Preprocessor::new(text);
for (name, value) in &env.defines {
pp.add_define(name, value)
@@ -307,8 +470,15 @@ pub fn preprocess(source: &Inlined, env: &Environment) -> Result<Preprocessed, S
};
let mut line = 1;
for item in pp {
let token =
item.map_err(|(e, at)| format!("{}: {e:?}", source.locate(line_of(at.start))))?;
let token = match item {
// JCPP lets an `#if` run to the end of the input (Bloop's
// taa.fsh never closes its `#ifdef TAA`); pp-rs says so there,
// after the last token.
Err((PreprocessorError::UnfinishedBlock, _)) => break,
item => {
item.map_err(|(e, at)| format!("{}: {e:?}", source.locate(line_of(at.start))))?
}
};
let at = line_of(token.location.start);
let piece = match token.value {
TokenValue::Version(v) => {
+62 -6
View File
@@ -6,7 +6,7 @@
//! so `#if` blocks pick the directives that apply.
use crate::options::{OptionSet, OptionValues};
use crate::preprocess::Environment;
use crate::preprocess::{Environment, is_condition, is_float, jcpp_integer, rewrite_condition};
use pp_rs::{pp::Preprocessor, token::TokenValue};
use std::collections::BTreeMap;
@@ -120,9 +120,32 @@ const DIRECTIVES: &[&str] = &[
"#warning", "#pragma", "#line", "#include",
];
/// An `#if`/`#elif` line with its floats as JCPP reads them (Steadfast tests
/// `#if ATMOSPHERE_FOG_STRENGTH_NOON == 0.75` with the option at 0.5, which
/// Iris reads as `0 == 0`): float literals and the options set to a float
/// become their integer part.
fn jcpp_condition(line: &str, options: &OptionSet, values: &OptionValues) -> String {
rewrite_condition(line, |word| {
let value = match options.strings.get(word) {
Some(o) => values.strings.get(word).unwrap_or(&o.default).as_str(),
None => word,
};
is_float(value)
.then(|| jcpp_integer(value))
.flatten()
.map(|n| n.to_string())
})
}
/// What backslashes are while the macros expand, as in Iris: the
/// preprocessor must not join a `\`-continued value with the directive on
/// the next line (Photon ends an `#if`'d value with `) \` before `#else`).
const BACKSLASH: &str = "IRIS_PASSTHROUGHBACKSLASH";
/// `PropertiesPreprocessor.preprocessSource`: comment lines dropped, other
/// `#`s removed, then the macros expanded. Tokens keep their source spelling
/// and spacing (`0.50` stays `0.50`, `<empty>` stays one word).
/// `#`s removed, then the macros expanded with the backslashes hidden.
/// Tokens keep their source spelling and spacing (`0.50` stays `0.50`,
/// `<empty>` stays one word).
pub fn preprocess(
text: &str,
env: &Environment,
@@ -132,7 +155,6 @@ pub fn preprocess(
let filtered: String = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| match l.starts_with('#') {
// JCPP lets a #define replace a macro (Complementary sets
// COLORED_LIGHTING 0 on macOS); pp-rs refuses, so undefine first.
@@ -142,11 +164,12 @@ pub fn preprocess(
None => l.to_owned(),
}
}
true if is_condition(l) => jcpp_condition(l, options, values),
true if DIRECTIVES.iter().any(|d| l.starts_with(d)) => l.to_owned(),
true => String::new(),
false => l.replace('#', ""),
})
.map(|l| l + "\n")
.map(|l| l.replace('\\', BACKSLASH) + "\n")
.collect();
let mut pp = Preprocessor::new(&filtered);
let mut define = |name: &str, value: &str| {
@@ -202,7 +225,7 @@ pub fn preprocess(
});
}
out.push('\n');
Ok(out)
Ok(out.replace(BACKSLASH, "\\"))
}
/// One element of an option screen (`OptionMenuElement`).
@@ -698,6 +721,39 @@ mod tests {
);
}
#[test]
fn floats_in_conditions_count_as_their_integer_part() {
assert_eq!(
[".5", "0.75", "1.0", "2.5f", "1.5e1", "7", "0x10"].map(jcpp_integer),
[Some(0), Some(0), Some(1), Some(2), Some(10), Some(7), None]
);
// Steadfast's fog strength: the option is 0.5, so Iris takes the
// 0.75 branch (0 == 0).
let set = options(&["#define FOG 0.5 // [0.0 0.5 0.75 1.0]"]);
let text = "\
#if FOG == 1.0
a = 1
#elif FOG == 0.75
a = 0.75
#else
a = 0.5
#endif
#if(FOG<1e1)
b = 1
#endif
";
let env = Environment {
defines: Vec::new(),
};
let values = OptionValues::default();
let out = preprocess(text, &env, &set, &values).unwrap();
assert_eq!(out, "a = 0.75\nb = 1\n");
assert_eq!(
jcpp_condition("#elif FOG == 0.75 && X > 2.5", &set, &values),
"#elif 0 == 0 && X > 2"
);
}
#[test]
fn boolean_expressions() {
let set = options(&["#define A", "//#define B", "#define C"]);
+287 -5
View File
@@ -18,7 +18,11 @@
//! swizzled shadow lookup is widened first (naga has no scalar swizzle);
//! - global initialisers run at the start of `main` (drivers accept
//! non-constant ones), unsized global arrays are sized from them;
//! - `isnan`, `isinf` and `modf` are helper functions (WGSL has none).
//! - `isnan`, `isinf` and `modf` are helper functions (WGSL has none);
//! - what naga refuses and drivers accept: `%` goes through overloads (naga
//! won't take `ivec2 % int`), `sqrt` of a negative constant isn't folded,
//! constants naga can't fold (`?:`, `mix`) become globals set in `main`,
//! and 16/8-bit types are their 32-bit ones.
//!
//! The programs of one pass share a [`Link`], so their locations, bindings and
//! uniform block agree.
@@ -234,6 +238,106 @@ vec3 iris_modf(vec3 x, out vec3 i) { i = trunc(x); return x - i; }
vec4 iris_modf(vec4 x, out vec4 i) { i = trunc(x); return x - i; }
";
/// A fragment output narrower than four channels (`out vec3 color`,
/// Steadfast's `final`): the four-channel type it is written through and the
/// epilogue line that fills it, with alpha 1 and the rest 0. `None` when the
/// output already has four channels.
fn widen_output(ty: &str, name: &str) -> Option<(String, String)> {
let (wide, one, zero) = match ty {
"float" | "vec2" | "vec3" => ("vec4", "1.0", "0.0"),
"int" | "ivec2" | "ivec3" => ("ivec4", "1", "0"),
"uint" | "uvec2" | "uvec3" => ("uvec4", "1u", "0u"),
_ => return None,
};
let channels = match ty.chars().last() {
Some('2') => 2,
Some('3') => 3,
_ => 1,
};
let mut parts = vec![name.to_owned()];
parts.extend((channels..3).map(|_| zero.to_owned()));
parts.push(one.to_owned());
Some((
wide.to_owned(),
format!("iris_out_{name} = {wide}({});", parts.join(", ")),
))
}
/// `%` as overloads: naga's GLSL front end splats a scalar operand for
/// `+ - / & ^ |` but not for `%`, and rejects `ivec2(uv) % 256` (Noble).
fn modulo_helpers() -> String {
let mut out = String::new();
for (s, v) in [("int", "ivec"), ("uint", "uvec")] {
out.push_str(&format!("{s} iris_mod({s} a, {s} b) {{ return a % b; }}\n"));
for n in 2..=4 {
let v = format!("{v}{n}");
out.push_str(&format!("{v} iris_mod({v} a, {v} b) {{ return a % b; }}\n"));
out.push_str(&format!(
"{v} iris_mod({v} a, {s} b) {{ return a % {v}(b); }}\n"
));
out.push_str(&format!(
"{v} iris_mod({s} a, {v} b) {{ return {v}(a) % b; }}\n"
));
}
}
out
}
/// 16- and 8-bit types (`GL_NV_gpu_shader5`, `GL_EXT_shader_explicit_arithmetic_types`)
/// and their built-ins, as the 32-bit ones WGSL has: Noble declares
/// `u16vec2` & co. when Iris reports an NVIDIA GPU, and falls back to these
/// same aliases elsewhere.
const SMALL_TYPES: &[(&str, &str)] = &[
("float16_t", "float"),
("f16vec2", "vec2"),
("f16vec3", "vec3"),
("f16vec4", "vec4"),
("int16_t", "int"),
("i16vec2", "ivec2"),
("i16vec3", "ivec3"),
("i16vec4", "ivec4"),
("uint16_t", "uint"),
("u16vec2", "uvec2"),
("u16vec3", "uvec3"),
("u16vec4", "uvec4"),
("int8_t", "int"),
("i8vec2", "ivec2"),
("i8vec3", "ivec3"),
("i8vec4", "ivec4"),
("uint8_t", "uint"),
("u8vec2", "uvec2"),
("u8vec3", "uvec3"),
("u8vec4", "uvec4"),
("packFloat2x16", "packHalf2x16"),
("unpackFloat2x16", "unpackHalf2x16"),
];
/// `source` with [`SMALL_TYPES`] replaced, word by word.
fn widen_small_types(source: &str) -> std::borrow::Cow<'_, str> {
if !SMALL_TYPES.iter().any(|(t, _)| source.contains(t)) {
return source.into();
}
let mut out = String::with_capacity(source.len());
let mut rest = source;
while let Some(c) = rest.chars().next() {
let len = if c.is_ascii_alphanumeric() || c == '_' {
rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len())
} else {
c.len_utf8()
};
let word = &rest[..len];
out.push_str(
SMALL_TYPES
.iter()
.find(|(t, _)| *t == word)
.map_or(word, |(_, w)| w),
);
rest = &rest[len..];
}
out.into()
}
/// Translate one preprocessed program. `locate` turns a line of `source`
/// into `file:line` for errors.
pub fn translate(
@@ -248,7 +352,8 @@ pub fn translate(
Stage::Compute => naga::ShaderStage::Compute,
_ => return Err(format!("{stage:?} programs are not supported")),
};
let mut unit = ast::TranslationUnit::parse(source).map_err(|e| {
let source = widen_small_types(source);
let mut unit = ast::TranslationUnit::parse(&source).map_err(|e| {
let line = e.line() + 1;
format!("{}: {}", locate(line), e.inner())
})?;
@@ -281,6 +386,111 @@ pub fn translate(
})
}
/// The GLSL built-ins naga's constant evaluator folds (`MathFunction`s it
/// implements), besides type constructors.
const FOLDABLE: &[&str] = &[
"abs",
"acos",
"acosh",
"asin",
"asinh",
"atan",
"atanh",
"ceil",
"clamp",
"cos",
"cosh",
"cross",
"degrees",
"distance",
"dot",
"exp",
"exp2",
"floor",
"fma",
"fract",
"inversesqrt",
"length",
"log",
"log2",
"max",
"min",
"normalize",
"pow",
"radians",
"round",
"sign",
"sin",
"sinh",
"sqrt",
"step",
"tan",
"tanh",
"trunc",
];
/// Whether a constant's initialisers need what naga can't fold and drivers
/// do: a `?:`, another built-in (Bliss's `mix`), or a constant that had to
/// become a global (`runtime`).
fn needs_runtime(list: &ast::InitDeclaratorList, runtime: &HashSet<String>) -> bool {
let constructor = |name: &str| {
let n = name.trim_start_matches(['i', 'u', 'b', 'd']);
matches!(name, "float" | "int" | "uint" | "bool" | "double")
|| n.starts_with("vec")
|| n.starts_with("mat")
};
let expr = |e: &ast::Expr| {
fn walk(e: &ast::Expr, f: &dyn Fn(&ast::Expr) -> bool) -> bool {
use ast::ExprData as E;
f(e) || match &e.content {
E::Unary(_, a) | E::PostInc(a) | E::PostDec(a) | E::Dot(a, _) => walk(a, f),
E::Binary(_, a, b) | E::Assignment(a, _, b) | E::Comma(a, b) | E::Bracket(a, b) => {
walk(a, f) || walk(b, f)
}
E::Ternary(a, b, c) => walk(a, f) || walk(b, f) || walk(c, f),
E::FunCall(_, args) => args.iter().any(|a| walk(a, f)),
_ => false,
}
}
walk(e, &|e| match &e.content {
ast::ExprData::Ternary(..) => true,
ast::ExprData::Variable(v) => runtime.contains(v.as_str()),
ast::ExprData::FunCall(f, _) => {
callee(f).is_some_and(|c| !constructor(&c) && !FOLDABLE.contains(&c.as_str()))
}
_ => false,
})
};
fn init(i: &ast::Initializer, expr: &dyn Fn(&ast::Expr) -> bool) -> bool {
match &i.content {
ast::InitializerData::Simple(e) => expr(e),
ast::InitializerData::List(items) => items.iter().any(|i| init(i, expr)),
}
}
let tail = list.tail.iter().filter_map(|t| t.initializer.as_ref());
list.head
.initializer
.iter()
.chain(tail)
.any(|i| init(i, &expr))
}
/// A negative number: `-1.0`, `-1`.
fn negative_constant(e: &ast::Expr) -> bool {
use ast::ExprData as E;
match &e.content {
E::Unary(op, a) if op.content == ast::UnaryOpData::Minus => {
matches!(
a.content,
E::FloatConst(v) if v > 0.0
) || matches!(a.content, E::IntConst(v) if v > 0)
|| matches!(a.content, E::DoubleConst(v) if v > 0.0)
}
E::FloatConst(v) => *v < 0.0,
_ => false,
}
}
fn first_lines(s: &str) -> String {
s.lines().take(8).collect::<Vec<_>>().join("\n")
}
@@ -316,6 +526,14 @@ struct Legaliser<'a> {
/// Identifiers used, for the implicit declarations.
used: HashSet<String>,
errors: Vec<String>,
/// Inside a constant initialiser: no helper calls there.
constant: bool,
/// The program uses `%` ([`modulo_helpers`]) or `iris_sqrt`: their
/// helpers go in.
modulo: bool,
nan: bool,
/// Constants turned into globals ([`needs_runtime`]).
runtime_constants: HashSet<String>,
}
fn var(name: &str) -> ast::Expr {
@@ -507,6 +725,10 @@ impl<'a> Legaliser<'a> {
declared: HashSet::new(),
used: HashSet::new(),
errors: Vec::new(),
constant: false,
modulo: false,
nan: false,
runtime_constants: HashSet::new(),
}
}
@@ -727,8 +949,19 @@ impl<'a> Legaliser<'a> {
let at = explicit
.map(|l: u32| l + i as u32)
.unwrap_or_else(|| self.next_output());
self.header
.push(format!("layout(location = {at}) out {full} {name}{array};"));
if let (Some(widened), "") = (widen_output(&full, name), array.as_str()) {
// GL fills the missing channels of a narrow output;
// wgpu wants every channel of the target written.
let (wide, fill) = widened;
self.header.push(format!("{full} {name};"));
self.header.push(format!(
"layout(location = {at}) out {wide} iris_out_{name};"
));
self.epilogue.push(fill);
} else {
self.header
.push(format!("layout(location = {at}) out {full} {name}{array};"));
}
self.interface.outputs.insert(at, name.clone());
} else {
self.varying(dir, &interp, &full, name, array);
@@ -736,9 +969,25 @@ impl<'a> Legaliser<'a> {
}
return Ok(false);
}
if has(ast::StorageQualifierData::Const) || has(ast::StorageQualifierData::Shared) {
// A constant naga can't fold becomes a global set at the start of `main`.
if has(ast::StorageQualifierData::Const) && needs_runtime(list, &self.runtime_constants) {
self.runtime_constants
.extend(names.iter().map(|(n, _)| n.clone()));
let q = &mut list.head.ty.qualifier;
if let Some(q) = q {
q.qualifiers.retain(|q| {
!matches!(&q.content, ast::TypeQualifierSpecData::Storage(s)
if s.content == ast::StorageQualifierData::Const)
});
}
if q.as_ref().is_some_and(|q| q.qualifiers.is_empty()) {
*q = None;
}
} else if has(ast::StorageQualifierData::Const) || has(ast::StorageQualifierData::Shared) {
self.sized(list);
self.constant = true;
self.global_initialisers(list, false);
self.constant = false;
return Ok(true);
}
self.sized(list);
@@ -930,6 +1179,8 @@ impl<'a> Legaliser<'a> {
return;
};
self.sized(list);
self.constant =
storage(&list.head.ty.qualifier).contains(&ast::StorageQualifierData::Const);
if let Some(i) = &mut list.head.initializer {
self.initializer(i);
}
@@ -944,6 +1195,7 @@ impl<'a> Legaliser<'a> {
let n = t.ident.ident.as_str().to_owned();
self.declare(&n);
}
self.constant = false;
}
fn initializer(&mut self, i: &mut ast::Initializer) {
@@ -1148,6 +1400,24 @@ impl<'a> Legaliser<'a> {
| E::FloatConst(_)
| E::DoubleConst(_) => {}
E::Unary(_, a) | E::PostInc(a) | E::PostDec(a) => self.expr(a),
E::Binary(op, a, b) if op.content == ast::BinaryOpData::Mod && !self.constant => {
self.expr(a);
self.expr(b);
self.modulo = true;
let (a, b) = (
std::mem::replace(&mut **a, var("_")),
std::mem::replace(&mut **b, var("_")),
);
*e = call("iris_mod", vec![a, b]);
}
E::Assignment(a, op, b) if op.content == ast::AssignmentOpData::Mod => {
self.expr(a);
self.expr(b);
self.modulo = true;
let rhs = std::mem::replace(&mut **b, var("_"));
**b = call("iris_mod", vec![(**a).clone(), rhs]);
*op = ast::AssignmentOpData::Equal.into_node();
}
E::Binary(_, a, b) | E::Assignment(a, _, b) | E::Comma(a, b) => {
self.expr(a);
self.expr(b);
@@ -1268,6 +1538,12 @@ impl<'a> Legaliser<'a> {
if self.functions.contains(&name) {
return;
}
// `sqrt(-1.0)`, Bloop's NaN: naga folds it and fails, drivers give NaN.
if name == "sqrt" && !self.constant && args.len() == 1 && negative_constant(&args[0]) {
*f = ast::FunIdentifierData::Expr(Box::new(var("iris_sqrt"))).into_node();
self.nan = true;
return;
}
if let Some((_, new)) = LEGACY_CALLS.iter().find(|(old, _)| *old == name) {
*f = ast::FunIdentifierData::Expr(Box::new(var(new))).into_node();
} else if let Some((_, new)) = SHADOW_CALLS.iter().find(|(old, _)| *old == name) {
@@ -1307,6 +1583,12 @@ impl<'a> Legaliser<'a> {
}
let mut out = String::from("#version 450 core\n");
out.push_str(HELPERS);
if self.modulo {
out.push_str(&modulo_helpers());
}
if self.nan {
out.push_str("float iris_sqrt(float x) { return sqrt(x); }\n");
}
if !self.link.uniforms.is_empty() {
out.push_str("layout(std140, set = 0, binding = 0) uniform IrisUniforms {\n");
for (_, decl) in &self.link.uniforms {
+63
View File
@@ -121,6 +121,69 @@ fn preprocesses_with_defaults_and_with_values() {
);
}
/// JCPP lets a `#define` replace a macro (Bloop's `TILT_ANGLE`); the added
/// `#undef`s don't move the lines errors name.
#[test]
fn a_define_replaces_an_earlier_one() {
use horizon_shaderpack::{include::Inlined, preprocess::preprocess};
let text = "#define TILT 5.0\nfloat a = TILT;\n # define TILT 1.5 // radians\nfloat b = TILT;\n#define F(x) x\n#define F(x) (x * 2)\nfloat c = F(1);\n#if\n";
let source = Inlined {
text: text.into(),
origin: (1..=8).map(|l| ("lib/specular.glsl".into(), l)).collect(),
};
let error = preprocess(&source, &env()).unwrap_err();
assert!(error.starts_with("lib/specular.glsl:8:"), "{error}");
let source = Inlined {
text: text.replace("#if\n", ""),
..source
};
let out = preprocess(&source, &env()).unwrap().text;
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[1], "float a = 5.0 ;");
assert_eq!(lines[3], "float b = 1.5 ;");
assert_eq!(lines[6], "float c = ( 1 * 2 ) ;");
}
/// Conditions as JCPP reads them: an empty macro counts as 0 (Bloop's
/// `#if MC_GL_VENDOR_INTEL || MC_GL_VENDOR_OTHER` with Iris's empty vendor
/// macro, its `#define TAA_USE_YCOCG`), a float as its integer part; a
/// quoted `#error` lexes, and an `#if` may run to the end.
#[test]
fn conditions_read_as_jcpp_does() {
use horizon_shaderpack::{include::Inlined, preprocess::preprocess};
let mut env = env();
env.define("MC_GL_VENDOR_OTHER", "");
let text = "#define TAA_USE_YCOCG
#define LEVEL 0.5
#if MC_GL_VENDOR_INTEL || MC_GL_VENDOR_OTHER
float gather = 0.0;
#else
float gather = 1.0;
#endif
#if TAA_USE_YCOCG
float ycocg = 1.0;
#endif
#if defined TAA_USE_YCOCG && defined(MC_GL_VENDOR_OTHER) && LEVEL == 0
float both = 1.0;
#endif
#ifndef TAA_USE_YCOCG
#error \"This program should be disabled\"
#endif
#ifdef TAA_USE_YCOCG
float open = 1.0;
";
let source = Inlined {
text: text.into(),
origin: Vec::new(),
};
let out = preprocess(&source, &env).unwrap().text;
let words: Vec<&str> = out.split_whitespace().collect();
assert_eq!(
words.join(" "),
"float gather = 1.0 ; float both = 1.0 ; float open = 1.0 ;"
);
}
#[test]
fn reports_missing_includes_and_cycles() {
let pack = JavaPack::open(&mini()).unwrap();
@@ -290,6 +290,103 @@ fn reports_errors_with_their_place() {
assert_eq!(err, "uniform a is declared as both `float a` and `int a`");
}
/// What naga refuses and drivers accept: `vec % scalar` (Noble), `sqrt` of
/// a negative constant (Bloop's NaN), constants naga can't fold (Bliss's
/// `?:`, `mix`), and 16-bit types (Noble on NVIDIA).
#[test]
fn legalises_what_naga_refuses() {
let mut link = Link::default();
let fs = run(
Stage::Fragment,
"uniform vec2 uv;
const int RES = 256 % 100;
const vec3 TARGET = length(vec3(1.0)) < 0.001 ? vec3(0.0) : normalize(vec3(1.0));
const vec3 TINT = mix(TARGET, vec3(1.0), 0.5);
const vec3 HALF = TINT * 0.5;
u16vec2 pack(uint v) { return u16vec2(v & 65535u, v >> 16u); }
float nan() { return sqrt(-1.0); }
void main() {
ivec2 texel = ivec2(uv) % RES;
texel %= 7;
uint packed = packFloat2x16(f16vec2(HALF.xy));
gl_FragColor = vec4(vec2(texel), float(pack(packed).x), nan());
}
",
&mut link,
)
.unwrap();
for wanted in [
"const int RES = 256 % 100;",
"vec3 TARGET;",
"vec3 TINT;",
"vec3 HALF;",
"ivec2 texel = iris_mod(ivec2(uv), RES);",
"texel = iris_mod(texel, 7);",
"uvec2 pack(uint v)",
"return iris_sqrt(-1.);",
"uint packed = packHalf2x16(vec2(HALF.xy));",
] {
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
}
reparse(&fs.wgsl).unwrap();
}
/// A fragment output narrower than its four-channel target (Steadfast's
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
/// GL fills the missing channels, wgpu refuses the pipeline.
#[test]
fn narrow_fragment_outputs_fill_four_channels() {
let mut link = Link::default();
let fs = run(
Stage::Fragment,
"uniform vec3 tint;
layout(location = 0) out vec3 color;
layout(location = 1) out float depth;
out vec4 extra;
void main() {
color = tint;
depth = 0.5;
extra = vec4(tint, 0.25);
}
",
&mut link,
)
.unwrap();
for wanted in [
"layout(location = 0) out vec4 iris_out_color;",
"iris_out_color = vec4(color, 1.0);",
"layout(location = 1) out vec4 iris_out_depth;",
"iris_out_depth = vec4(depth, 0.0, 0.0, 1.0);",
"layout(location = 2) out vec4 extra;",
] {
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
}
assert_eq!(
fs.interface.outputs.values().collect::<Vec<_>>(),
["color", "depth", "extra"]
);
let module = naga::front::wgsl::parse_str(&fs.wgsl).unwrap();
let entry = &module.entry_points[0];
let result = entry.function.result.as_ref().unwrap();
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
panic!("{}", fs.wgsl);
};
for m in members {
assert!(
matches!(
module.types[m.ty].inner,
naga::TypeInner::Vector {
size: naga::VectorSize::Quad,
..
}
),
"{}",
fs.wgsl
);
}
reparse(&fs.wgsl).unwrap();
}
/// The written WGSL, read back and validated: what wgpu is given.
fn reparse(wgsl: &str) -> Result<(), String> {
let module = naga::front::wgsl::parse_str(wgsl).map_err(|e| e.emit_to_string(wgsl))?;
+17 -1
View File
@@ -1,6 +1,6 @@
# Horizon extras
Optional conveniences built into the client: fullbright, zoom, a minimap, an FPS counter, a
Optional conveniences built into the client: fullbright, dynamic lights, zoom, a minimap, an FPS counter, a
keystroke overlay, auto-reconnect, Mouse Tweaks-style inventory controls and
inventory sorting. **Every one is off by default**: a fresh
install looks and behaves exactly like vanilla 26.3, and turning an extra off
@@ -12,6 +12,7 @@ again restores vanilla's behaviour completely.
|---|---|
| Settings, parsing and saving | `crates/horizon-client/src/extras.rs` |
| Zoom curve, FPS and click counters | `extras.rs` (pure logic, unit tested) |
| Dynamic lights: item glow, nearest sources | `crates/horizon-client/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) |
| HUD drawing (FPS, keystrokes, minimap frame) | `crates/horizon-client/src/extras_hud.rs` |
| Minimap colours, tiles and image | `crates/horizon-client/src/minimap.rs` |
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` |
@@ -45,6 +46,21 @@ Horizon category in Controls, and adding one would put a non-vanilla mapping int
- Turning it off restores the normal lightmap using the current brightness,
time of day and effects. The brightness slider keeps its normal range.
### Dynamic lights (`dynamicLights`, default off)
- A light source held by a player or mob (main or off hand), dropped as an item, or an entity on
fire lights the blocks, entities and hand around it, as LambDynamicLights and OptiFine do. Not
in vanilla; the idea is ported, not a mod's code (issue #814).
- An item glows like its block's default state (`getLightEmission`): torches 14, lanterns,
glowstone, sea lanterns and jack o'lanterns 15, soul torches 10, a redstone torch 7; a lava bucket
15. An entity on fire gives 15.
- Client-only, at render time: the light engine, meshes, saves and other players never see it.
Each frame the game picks the 16 sources nearest the camera (within 96 blocks,
`dynamic_light::select`); terrain.wgsl and entity.wgsl raise each vertex's block light to the
source's level minus its distance in blocks when that is brighter, then light it through the
lightmap as usual. The light doesn't stop at walls.
- Off: nothing is gathered and the shaders' loop runs zero times (the count is 0), so it costs
nothing measurable (numbers in the pull request, #814).
### Zoom (`zoom`, default off)
- Hold the zoom key (default **Z**, unused by every vanilla 26.3 key mapping; C
is `key.saveToolbarActivator`) to zoom. The key does nothing while zoom is off.
+50 -9
View File
@@ -13,10 +13,10 @@ Both are off by default. With no pack selected the renderer draws exactly what i
| Part | State |
| --- | --- |
| Renderer layout (below) | today's code |
| Horizon shader packs: `composite` stage, uniforms, hot reload, Shader Packs screen | first slice planned |
| Horizon shader packs: `composite` stage, uniforms, hot reload, Shader Packs page | done (the page: list, pick, open the folder) |
| Horizon shader packs: `deferred`, `gbuffer`, `shadow` stages, custom textures | planned |
| Java shader packs: preprocessing + GLSL parse coverage | spike done: 450 of 470 programs reach valid WGSL (below) |
| Java shader packs: preprocessor (`crates/horizon-shaderpack`: includes, options, Iris's macros) | done: every program of the three test packs preprocesses |
| Java shader packs: preprocessor (`crates/horizon-shaderpack`: includes, options, Iris's macros, JCPP's `#if` and `#define` rules) | done: every program of the three test packs preprocesses; ten packs tested (Compatibility, below) |
| Java shader packs: GLSL → WGSL (`horizon_shaderpack::translate`: syntax-tree legaliser, naga) | done: the 464 programs the three test packs enable by default translate (below) |
| Java shader packs: `shaders.properties` (`horizon_shaderpack::properties`: screens, sliders, profiles, `program.*.enabled`, image formats, blend modes) | done, no screen yet: read like Iris for the three test packs |
| Java shader packs: full-screen pass plan (`horizon_shaderpack::pipeline`: `DRAWBUFFERS`/`RENDERTARGETS`, `colortexN` formats and clearing, buffer flips) | done: planned for the three test packs |
@@ -56,15 +56,19 @@ The lightmap, fog and sky colours are computed on the CPU (`environment.rs`, `fo
### Where packs live and how one is picked
- Packs go in `shaderpacks/` beside `options.txt` in the game folder (the same folder Iris uses, so one folder
holds both kinds). A pack is a folder (zips planned). A pack with a `pack.toml` at its root is a Horizon pack; a pack
with a `shaders/` folder is a Java pack.
- **Options → Video Settings → Shader Packs...** (Classic screens, laid out like Iris's: the packs on the left with
"(off)" first, Apply and Done, *Open Shader Pack Folder*, *Shader Pack Settings...*) and a **Shader Packs** row in
the Horizon menus' Video section open the same list.
holds both kinds). That is the game home the launcher starts the game in (`%APPDATA%\horizon` on Windows,
`~/Library/Application Support/horizon` on macOS, `~/.local/share/horizon` on Linux), not the launcher's version
folder. A pack with a `pack.toml` at its root is a Horizon pack; a folder with `shaders/`, or a `.zip`, is a Java
pack.
- The **Shader Packs** page lists them: a sidebar section in the Horizon menus, **Video Settings → Shader Packs...**
in the Classic ones, and the Shader Pack row of the graphics settings leads to it. It shows the folder's full path
and an *Open Folder* button (the folder is made if it is missing), then one switch per pack: turning one on picks
it, turning it off is the vanilla picture. With no pack, it says so under the path. The list is read again every
second while the page is open, so a pack dropped into the folder shows up.
- The choice is kept in `horizon-graphics.txt` (`shaderPack:<folder or zip name>`, empty = off). A pack's option
values are kept in `shaderpacks/<pack>.txt`, as Iris does.
- A pack that fails to load or compile leaves the vanilla picture on and shows why, in red, on its row (and in the
log). Nothing a pack does can stop the game from starting.
- A pack that fails to load or compile leaves the vanilla picture (or the last pack that worked) on and says why on
its row ("Failed", with the error), in chat and in the log. Nothing a pack does can stop the game from starting.
- Benchmarks (`--benchmark`) and the parity harnesses never load a pack.
### Layout
@@ -378,6 +382,43 @@ shadows, water or sky look yet; Distant Horizons' and Voxy's depth read as far.
compute (`.csh`; a pack that needs them doesn't load), mipmapped targets (`colortexNMipmapEnabled` is read but
ignored), custom textures, the option screen; shadow matrices are identity.
### Compatibility
Tested on 2026-10-22 on the Mac mini (M4 Pro, Metal) with each pack's default options, the 26.3 builds from Modrinth or
the authors' pages (dev-only test inputs, kept outside the repository). "Loads" means every pass the pack enables for
the overworld, Nether and End is preprocessed, translated and validated, as the game does when the pack is picked
(`java_pack::tests::real_packs_load` with `HORIZON_TEST_SHADERPACKS`); "drawn" means it was also picked in the game
and screenshotted. Only full-screen passes run (above), so no pack has its own terrain, water, shadows or sky yet.
| Pack | Version | Result | First thing that stops it |
| --- | --- | --- | --- |
| BSL | v10.1.8 | drawn: 26 passes | |
| Complementary Reimagined | r5.9.3 | drawn: 27 passes | |
| Complementary Unbound | r5.8 | loads: 27 passes | |
| Sildur's Vibrant Extreme | 2.02 | drawn: 18 passes, only its sky (above) | |
| Steadfast | 0.8.0 | drawn: 3 passes | |
| Bliss | 2.1.2 | fails | `world0/deferred.vsh`: an operation naga forbids in a vertex shader |
| Bloop | 1.8.0-Alpha-3 | fails | `world0/prepare1.fsh`: `mat2(vec4)` and other constructors naga's GLSL front end refuses |
| Noble | v1.9.9 | fails (Apple and NVIDIA) | `world0/deferred4.vsh`: a `vec3[9][9]` varying (nested arrays between stages) |
| Photon | v1.4 | fails | `world0/prepare.vsh`: a struct varying (`CloudsParameters`) |
| Rethinking Voxels | r0.1-beta9 | fails | `world0/prepare.fsh`: `1 << 31` overflows naga's constant folding; it also needs images |
What the translator does for these packs, as Iris (JCPP and its GLSL transformer) does:
- **`#if` reads floats as JCPP does**: a float in a condition, written there or through a macro, counts as its
integer part, and an empty operand as 0. The same holds in `shaders.properties`, where Steadfast's
`#elif ATMOSPHERE_FOG_STRENGTH_NOON == 0.5` stopped the pack (`UnexpectedToken(Float(0.5))`).
- **A later `#define` replaces an earlier one** instead of failing (Bloop's `DefineRedefined`), and an unterminated
`#if` runs to the end of the file.
- **`shaders.properties`** keeps its empty lines and backslashes through the preprocessor, as Iris's does, so a
continued line doesn't swallow the next one.
- **16-bit and 8-bit types** (`float16_t`, `u16vec2`, `packFloat2x16`...), which Noble uses when the GPU reports
NVIDIA (`NV_gpu_shader5`), become their 32-bit types: WGSL has none.
- **What naga refuses**: `%` with a scalar and a vector operand goes through overloads, a `const` naga can't fold
(`mix`, `?:`, `smoothstep`) becomes a global set at the start of `main`, `sqrt` of a negative constant is
evaluated at run time, and a fragment output narrower than four channels (`out vec3`, Steadfast's `final`) is
written through a `vec4` with alpha 1 as OpenGL fills it.
### Order to implement
1. **Translator crate** (`horizon-shaderpack`): the preprocessor (includes, option `#define`s with the player's
+2 -2
View File
@@ -307,7 +307,7 @@ pub fn benchmark(repeats: usize) -> Result<PathBuf, String> {
if let Ok(data) = serde_json::to_value(report.for_upload()) {
let item = horizon_telemetry::Item::new(
horizon_telemetry::Source::Launcher,
env!("CARGO_PKG_VERSION"),
crate::backend::VERSION,
horizon_telemetry::Payload::Benchmark(data),
);
let _ = horizon_telemetry::outbox::push(&crate::backend::game_home(), &item);
@@ -320,7 +320,7 @@ pub fn benchmark(repeats: usize) -> Result<PathBuf, String> {
fn app() -> App {
App {
name: "horizon-launcher".into(),
version: env!("CARGO_PKG_VERSION").into(),
version: crate::backend::VERSION.into(),
..App::default()
}
}
+39 -12
View File
@@ -227,11 +227,18 @@ def scenario(d, out):
raise Failed('no reachable tree near spawn')
assert broke['block_before'].startswith(log_id), broke
assert broke['drops'].get(log_id, 0) >= 1, broke
if broke['picked_up'].get(log_id, 0) < 1:
# On a client of a server (single player's server thread) the drop and its pickup both come from
# the server a few ticks later: wait for the logs in the inventory, or walk to the drop.
for _ in range(20):
if broke['picked_up'].get(log_id, 0) >= 1 or d('inventory')['counts'].get(log_id, 0) >= 1:
break
items = d('entities', radius=8, type='item')['entities']
drop = next(e for e in items if e['item']['item'] == log_id)
d('goto', x=drop['pos'][0], y=drop['pos'][1], z=drop['pos'][2], near=1.0, timeout=20) # pickup reaches a block; the drop may lie under the trunk
d('wait', ticks=10)
drop = next((e for e in items if e['item']['item'] == log_id), None)
if drop is not None:
d('goto', x=drop['pos'][0], y=drop['pos'][1], z=drop['pos'][2], near=1.0, timeout=20) # pickup reaches a block; the drop may lie under the trunk
d('wait', ticks=10)
break
d('wait', ticks=1)
inventory = d('inventory')
assert inventory['counts'].get(log_id, 0) >= 1, inventory
@@ -244,29 +251,38 @@ def scenario(d, out):
d('click_slot', slot=log_slot) # pick up the logs
d('click_slot', slot=100, right=True) # put one in the crafting grid
d('click_slot', slot=log_slot) # put the rest back
d('wait', ticks=2) # the server crafts the result (single player's server thread)
result = next(s for s in d('screen')['slots'] if s['slot'] == 200)
assert result.get('item') == planks_id and result['count'] == 4, result
d('click_slot', slot=200, shift=True) # craft into the inventory
d('close')
d('wait', ticks=2)
inventory = d('inventory')
assert inventory['counts'].get(planks_id, 0) >= 4, inventory
# 5. Place a plank beside the player and read it back.
p = d('state')['player']['block_pos']
target = None
hotbar = d('inventory')['hotbar']
if not any(s.get('item') == planks_id for s in hotbar):
raise Failed(f'{planks_id} did not land in the hotbar: {hotbar}')
target = placed = None
for dx, dz in [(2, 0), (-2, 0), (0, 2), (0, -2), (2, 1), (1, 2), (-2, -1), (-1, -2)]:
at = (p[0] + dx, p[1], p[2] + dz)
here = d('block', x=at[0], y=at[1], z=at[2])['block']
below = d('block', x=at[0], y=at[1] - 1, z=at[2])['block']
if here in ('minecraft:air', 'minecraft:short_grass') and below not in ('minecraft:air', 'minecraft:water'):
target = at
break
if here not in ('minecraft:air', 'minecraft:short_grass') or below in ('minecraft:air', 'minecraft:water'):
continue
# The tree's trunk above the broken log can hide a spot's faces: try the next one.
try:
placed = d('place', x=at[0], y=at[1], z=at[2], item=planks_id)
except Failed as e:
if 'no visible face' in str(e):
continue
raise
target = at
break
if target is None:
raise Failed(f'no free spot beside {p} to place a block')
hotbar = d('inventory')['hotbar']
if not any(s.get('item') == planks_id for s in hotbar):
raise Failed(f'{planks_id} did not land in the hotbar: {hotbar}')
placed = d('place', x=target[0], y=target[1], z=target[2], item=planks_id)
assert placed['changed'], placed
back = d('block', x=target[0], y=target[1], z=target[2])
assert back['block'] == planks_id, back
@@ -322,6 +338,7 @@ def ui_scenario(d, out):
# 3. The inventory's slots are widgets too, with their stacks.
d('give', item='oak_log', count=2)
d('wait', ticks=2) # single player's server thread gives them
d('open_inventory')
tree = widgets(d)
log = next(w for w in tree.values() if w['kind'] == 'slot' and (w.get('stack') or {}).get('item') == 'minecraft:oak_log')
@@ -329,6 +346,7 @@ def ui_scenario(d, out):
d('click', id=log['id'])
d('click', id='slot-100', right=True)
d('click', id=log['id'])
d('wait', ticks=2)
assert widgets(d)['slot-200']['stack']['item'] == 'minecraft:oak_planks'
d('close')
shot = d('screenshot', path=str(out / f'{d.name}-ui.png'))
@@ -489,6 +507,15 @@ def pick_scenario(d, out):
d('give', item='oak_log', count=2)
assert d('open_inventory')['screen'] == 'inventory'
d('click', id='slot-0', middle=True)
# The clone of step 2 lived only on this client: ItemPickerMenu's carried stack is the
# inventoryMenu's, and AbstractContainerMenu.removed clears it only for a ServerPlayer. With a
# stack in hand, mouseReleased sends CLONE, the server finds the carried stack it was told about
# wrong and sends the empty one back (broadcastChanges). On the server thread that takes a
# round trip, as in vanilla; in the frame, closing the menu already put the clone away.
for _ in range(20):
if carried() is None:
break
d('wait', ticks=1)
assert carried() is None, carried()
assert widgets(d)['slot-0']['stack'] == {'item': 'minecraft:oak_log', 'count': 2}
d('close')
+22 -8
View File
@@ -187,6 +187,11 @@ def remote(guest):
return guest('status')['server']
def dead(guest):
"""Whether the guest is dead: its death screen (the last health it was sent can stay above 0)."""
return guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0
def back_to_game(game):
"""Close whatever screen a failed check left open, without opening the pause menu."""
try:
@@ -246,9 +251,15 @@ def check_armour(host, guest, out):
face_each_other(host, guest)
host.shot(out, 'armour')
guest.shot(out, 'armour')
seen = lan_guest(host).get('model_armor')
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
f'the host draws the guest in {seen}'
if SERVER_THREAD:
# The host's window is a client of its server thread: what it draws is what it was sent.
seen = [e for e in remote(host).get('player_equipment', [])]
assert all(any(item in json.dumps(e) for e in seen) for item in
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
else:
seen = lan_guest(host).get('model_armor')
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
f'the host draws the guest in {seen}'
host_gear = [e for e in remote(guest).get('player_equipment', [])]
assert any('golden_chestplate' in json.dumps(e) for e in host_gear), f'the guest sees the host in {host_gear}'
return 'each in their own'
@@ -578,7 +589,7 @@ def check_hazards(host, guest, out):
x, y, z = hx + 3, hy, hz
for name, fill, ticks in (('lava', 'minecraft:lava', 60), ('wall', 'minecraft:stone', 60),
('drowning', 'minecraft:water', 500), ('void', None, 60)):
if remote(guest)['health'] <= 0:
if dead(guest):
guest.raw('respawn')
guest.wait(40)
# A stone box around the guest holds the lava and the water in.
@@ -601,7 +612,7 @@ def check_hazards(host, guest, out):
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
guest.wait(20)
if remote(guest)['health'] <= 0:
if dead(guest):
guest.raw('respawn')
guest.wait(40)
# [food, saturation, exhaustion]: 8 s of sprint-jumping is about 2 of exhaustion; every 4
@@ -614,6 +625,9 @@ def check_hazards(host, guest, out):
def check_pvp(host, guest, out):
if dead(guest):
guest.raw('respawn')
guest.wait(40)
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
@@ -1187,7 +1201,7 @@ def check_walls(host, guest, out):
for g in (host, guest):
back_to_game(g)
# The trial spawner's zombies may have killed the guest.
if guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0:
if dead(guest):
guest.raw('respawn')
guest.wait(40)
face_each_other(host, guest)
@@ -2151,8 +2165,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
if SERVER_THREAD:
host.d.env['HORIZON_SERVER_THREAD'] = '1'
# Either way explicitly: the game's default is the server thread.
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
results = {}
try:
host.pid = host('launch', wait=False, offscreen=sys.platform == 'darwin')['pid']
+29 -3
View File
@@ -68,6 +68,14 @@ IGNORED = {
# differently (no worldgen heightmaps, entities in entities/) is accepted.
r'^chunk\.promoted\.(Status|Heightmaps\.(OCEAN_FLOOR_WG|WORLD_SURFACE_WG)|entities)$':
'a proto chunk vanilla left at its view distance, finished by Horizon',
# Single player's server thread sends the chunks its player tracks (view distance 6
# and its outer ring), as vanilla's integrated server does, so it finishes some that
# vanilla's server (view distance 4) left before their terrain: those had no blocks,
# light or heightmaps to compare yet.
r'^chunk\.blank\.':
'a chunk vanilla left before its terrain (no blocks yet), finished by Horizon',
r'^chunk\.unlit\.':
'a chunk vanilla left before its light, finished (and lit) by Horizon',
# Horizon keeps simulating between loading and saving (it needs a few seconds of
# chunk generation first), so counters and positions of live things move on.
r'^chunk\.block_entities\.\(2, -60, 5\)\.(cooking_time_spent|lit_time_remaining|Items\[\]\.count)$':
@@ -464,6 +472,13 @@ def normalize_chunk(root):
return out, sections
# ChunkStatus before NOISE: nothing has filled the chunk's blocks yet.
BLANK_STATUSES = {'minecraft:empty', 'minecraft:structure_starts', 'minecraft:structure_references',
'minecraft:biomes'}
# ChunkStatus LIGHT and later: the chunk's light is saved.
LIT_STATUSES = {'minecraft:light', 'minecraft:spawn', 'minecraft:full'}
def compare_chunks(vanilla, horizon, report):
for key in sorted(set(vanilla) | set(horizon)):
if key not in horizon:
@@ -476,9 +491,10 @@ def compare_chunks(vanilla, horizon, report):
ha, hs = normalize_chunk(horizon[key])
out = []
diff(va, ha, 'chunk', out)
if va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full':
promoted = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$')
out = [(path.replace('chunk.', 'chunk.promoted.', 1) if promoted.match(path) else path, p, q)
promoted = va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full'
if promoted:
rule = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$')
out = [(path.replace('chunk.', 'chunk.promoted.', 1) if rule.match(path) else path, p, q)
for path, p, q in out]
for y in sorted(set(vs) | set(hs)):
empty = {'blocks': ['minecraft:air'] * 4096, 'biomes': None, 'BlockLight': None, 'SkyLight': None}
@@ -493,6 +509,16 @@ def compare_chunks(vanilla, horizon, report):
if a[light] != b[light]:
out.append((f'chunk.sections.{light}[{y}]', None if a[light] is None else 'present',
None if b[light] is None else 'present'))
# Before its terrain a chunk has no blocks, light or heightmaps yet.
if promoted and va.get('Status') in BLANK_STATUSES:
blank = re.compile(r'^chunk\.(Heightmaps|sections)\.')
out = [(path.replace('chunk.', 'chunk.blank.', 1) if blank.match(path) else path, p, q)
for path, p, q in out]
# Before its light step a chunk saves no light; Horizon lights what it finishes.
elif promoted and va.get('Status') not in LIT_STATUSES:
unlit = re.compile(r'^chunk\.sections\.(SkyLight|BlockLight)\[')
out = [(path.replace('chunk.', 'chunk.unlit.', 1) if unlit.match(path) else path, p, q)
for path, p, q in out]
for path, p, q in out:
report.append((f'{path} @{key}', p, q))