mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae482a72e9 | ||
|
|
6a85c55988 | ||
|
|
bea1b54dd2 | ||
|
|
7f3738072f | ||
|
|
4bf5625b9b | ||
|
|
d9560d46e6 | ||
|
|
26142a9c40 | ||
|
|
832f879b27 | ||
|
|
b7453b129b | ||
|
|
05e169f4e3 | ||
|
|
8b75ac3cdb | ||
|
|
55891f9c95 | ||
|
|
2f883b5cdb | ||
|
|
0468b75c43 | ||
|
|
ac25d41578 | ||
|
|
acbfb8ceca | ||
|
|
6ea7a51ff3 | ||
|
|
e570561db9 | ||
|
|
21998a7e6a | ||
|
|
30cdd6298a |
No files matched your search
@@ -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 }}"
|
||||
|
||||
@@ -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!({})),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod storage;
|
||||
pub mod text;
|
||||
pub mod ticks;
|
||||
|
||||
pub mod release;
|
||||
pub mod version;
|
||||
|
||||
pub use version::VANILLA_VERSION;
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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']
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in new issue
Block a user