Merge pull request #10168 from NikitaMGrimm/shader-pack-identity

Keep native shader selections and settings across updates
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:27:07 +00:00
commit c90689fd74
10 files changed
+479 -62

No files matched your search

+1 -1
View File
@@ -771,7 +771,7 @@ pub struct Graphics {
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
pub shader_pack: String,
+11 -1
View File
@@ -338,11 +338,21 @@ impl Settings {
.map_or(-1, |i| i as i32)
}
pub fn load(path: &Path) -> Self {
let horizon = path
let mut horizon = path
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
if let Some(dir) = path.parent() {
let (key, _) = crate::shader_pack_list::resolve(
&crate::logging::shared_dir(dir).join(crate::shader_pack_list::DIR),
&horizon.shader_pack,
);
if key != horizon.shader_pack {
horizon.shader_pack = key;
horizon.save(dir);
}
}
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -18,8 +18,10 @@ pub enum Kind {
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
/// The folder or file name in `shaderpacks`.
pub file: String,
/// Native manifest identity (`horizon:<id>`), or a classic filename.
pub key: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
@@ -33,7 +35,7 @@ pub struct Entry {
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
let path = values_pack(&dir.join(&self.file), &self.key);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
@@ -106,7 +108,22 @@ pub fn scan(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
let mut keys = std::collections::HashSet::new();
let mut packs: Vec<Entry> = paths
.iter()
.filter_map(|p| entry(p))
.filter(|e| keys.insert(e.key.clone()))
.collect();
for pack in &mut packs {
if let Some(id) = pack.key.strip_prefix("horizon:") {
pack.values = load_values(
&native_values_pack(&dir.join(&pack.file), id),
&pack.options,
);
}
}
packs.sort_by(|a, b| {
(a.kind == Kind::Classic, a.title.to_lowercase())
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
@@ -122,26 +139,18 @@ fn entry(path: &Path) -> Option<Entry> {
if horizon_shader_api::package::is_pack(path) {
// Its manifest's name; a pack that can't be read is still listed, under its file
// name: picking it says why in the chat.
let manifest = if path.is_dir() {
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
.ok()
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
} else {
horizon_shader_api::Pack::read(path)
.ok()
.map(|p| p.manifest)
};
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
let manifest = horizon_shader_api::Pack::read_manifest(path).ok();
let (key, title, options, pages) = manifest.map_or_else(
|| (file.clone(), file.clone(), Vec::new(), Vec::new()),
|m| (format!("horizon:{}", m.id), m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
key,
file,
title,
kind: Kind::Horizon,
options,
values,
values: Vec::new(),
pages,
});
}
@@ -151,6 +160,7 @@ fn entry(path: &Path) -> Option<Entry> {
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
(zip || path.join("shaders").is_dir()).then(|| Entry {
title: file.clone(),
key: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
@@ -159,6 +169,104 @@ fn entry(path: &Path) -> Option<Entry> {
})
}
/// Resolves a saved identity, migrating a legacy native filename when its manifest or the
/// launcher's `<id>-<version>.hshader` name identifies it. Duplicate IDs use the first filename
/// in lexical order, matching `scan`; versions are free-form strings, not ordered versions.
/// An absent arbitrary old filename cannot be attributed to a manifest safely.
pub fn resolve(dir: &Path, picked: &str) -> (String, PathBuf) {
if picked.is_empty() {
return (String::new(), dir.join(picked));
}
let direct = dir.join(picked);
if direct.exists() && !horizon_shader_api::package::is_pack(&direct) {
return (picked.to_owned(), direct);
}
let mut native: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
let file = path.file_name()?.to_str()?.to_owned();
if file.starts_with('.') || !horizon_shader_api::package::is_pack(&path) {
return None;
}
Some((file, horizon_shader_api::Pack::read_manifest(&path).ok()?))
})
.collect();
native.sort_by(|a, b| a.0.cmp(&b.0));
let id = native
.iter()
.find(|(file, _)| file == picked)
.map(|(_, m)| m.id.as_str())
.or_else(|| picked.strip_prefix("horizon:"))
.or_else(|| {
// Never reinterpret an installed classic pack as a native identity.
if dir.join(picked).exists() {
return None;
}
let stem = picked.strip_suffix(".hshader")?;
native
.iter()
.find(|(_, m)| {
stem.strip_prefix(&m.id)
.is_some_and(|v| v.starts_with('-') && v.len() > 1)
})
.map(|(_, m)| m.id.as_str())
});
if let Some((file, m)) = native.iter().find(|(_, m)| Some(m.id.as_str()) == id) {
let key = format!("horizon:{}", m.id);
if !picked.starts_with("horizon:") {
migrate_values(&dir.join(picked), &key);
}
migrate_values(&dir.join(file), &key);
return (key, dir.join(file));
}
(picked.to_owned(), dir.join(picked))
}
fn values_pack(pack: &Path, key: &str) -> PathBuf {
key.strip_prefix("horizon:").map_or_else(
|| pack.to_path_buf(),
|id| {
pack.parent()
.unwrap_or_else(|| Path::new("."))
.join(".horizon-options")
.join(id)
},
)
}
/// The native options path; imports a filename sidecar once, keeping the original intact.
pub fn native_values_pack(pack: &Path, id: &str) -> PathBuf {
let key = format!("horizon:{id}");
migrate_values(pack, &key);
values_pack(pack, &key)
}
fn migrate_values(pack: &Path, key: &str) {
let target = values_path(&values_pack(pack, key));
if target.exists() {
return;
}
if let Ok(text) = std::fs::read(values_path(pack)) {
let result = std::fs::create_dir_all(target.parent().unwrap()).and_then(|()| {
use std::io::Write;
// Another reader may already have imported the values.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)?;
file.write_all(&text)
});
if let Err(e) = result
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
log::warn!("shader pack options migration: {e}");
}
}
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
@@ -186,8 +294,7 @@ pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
/// Saves non-default values. An empty file records a reset, so old sidecars stay retired.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
@@ -196,14 +303,10 @@ fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, text)
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
@@ -315,6 +418,102 @@ pub fn show_value(kind: &OptionKind, v: f64) -> String {
mod tests {
use super::*;
fn install(dir: &Path, file: &str, version: &str) {
let manifest = format!(
"schema = 1\nid = \"warm\"\nname = \"Warm\"\nversion = \"{version}\"\napi = \"0.1\"\n\
[[post]]\nfile = \"p.wgsl\"\n[[options]]\nid = \"strength\"\nname = \"Strength\"\ntype = \"float\"\ndefault = 1.0\nmin = 0.0\nmax = 2.0\nstep = 0.5\n"
);
let files = [
(horizon_shader_api::MANIFEST.into(), manifest.into_bytes()),
("p.wgsl".into(), b"original test fixture".to_vec()),
]
.into();
let pack = horizon_shader_api::Pack::from_files(files).unwrap();
std::fs::write(dir.join(file), pack.package().unwrap()).unwrap();
}
#[test]
fn native_selection_and_values_survive_install_update_rename_and_reset() {
let dir =
std::env::temp_dir().join(format!("horizon-identity-update-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "warm-1.hshader", "1");
std::fs::write(dir.join("warm-1.hshader.txt"), "strength=1.5\n").unwrap();
let options = dir.join("options.txt");
std::fs::write(&options, "").unwrap();
std::fs::write(
dir.join(crate::graphics::FILE),
"pickedShaderPack:warm-1.hshader\n",
)
.unwrap();
// Use the real settings loader, including persistence of the migrated selection.
// Its shaderpacks folder sits beside options.txt.
let packs = dir.join(DIR);
std::fs::create_dir_all(&packs).unwrap();
std::fs::rename(dir.join("warm-1.hshader"), packs.join("warm-1.hshader")).unwrap();
std::fs::rename(
dir.join("warm-1.hshader.txt"),
packs.join("warm-1.hshader.txt"),
)
.unwrap();
let settings = crate::settings::Settings::load(&options);
assert_eq!(settings.horizon.shader_pack, "horizon:warm");
assert_eq!(
crate::graphics::Graphics::load(&dir).shader_pack,
"horizon:warm"
);
assert_eq!(scan(&packs)[0].values, [1.5]);
install(&packs, "warm-2.hshader", "2");
std::fs::remove_file(packs.join("warm-1.hshader")).unwrap();
let (_, path) = resolve(&packs, &settings.horizon.shader_pack);
assert_eq!(path, packs.join("warm-2.hshader"));
std::fs::rename(path, packs.join("renamed.hshader")).unwrap();
let (_, path) = resolve(&packs, "horizon:warm");
assert_eq!(path, packs.join("renamed.hshader"));
let manifest = horizon_shader_api::Pack::read_manifest(&path).unwrap();
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[1.5]
);
let mut entry = scan(&packs).remove(0);
entry.values[0] = 0.5;
entry.save_values(&packs);
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[0.5]
);
entry.reset_options();
entry.save_values(&packs);
assert_eq!(scan(&packs)[0].values, [1.0]);
assert_eq!(
std::fs::read_to_string(packs.join("warm-1.hshader.txt")).unwrap(),
"strength=1.5\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn migrates_a_removed_registry_filename_and_resolves_duplicates_deterministically() {
let dir = std::env::temp_dir().join(format!(
"horizon-identity-duplicates-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "z.hshader", "newest");
install(&dir, "a.hshader", "oldest");
std::fs::write(dir.join("warm-previous.hshader.txt"), "strength=2\n").unwrap();
let (key, path) = resolve(&dir, "warm-previous.hshader");
assert_eq!(key, "horizon:warm");
assert_eq!(path, dir.join("a.hshader"));
assert_eq!(resolve(&dir, "z.hshader"), (key.clone(), path));
assert_eq!(scan(&dir).len(), 1);
assert_eq!(scan(&dir)[0].values, [2.0]);
// A classic folder named like a native ID keeps its filename identity.
std::fs::create_dir_all(dir.join("warm/shaders")).unwrap();
assert_eq!(resolve(&dir, "warm"), ("warm".into(), dir.join("warm")));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
@@ -397,7 +596,7 @@ mod tests {
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(std::fs::read_to_string(values_path(&pack)).unwrap(), "");
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -417,6 +616,7 @@ mod tests {
};
let mut entry = Entry {
file: "p".into(),
key: "horizon:p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
+30 -16
View File
@@ -64,7 +64,7 @@ fn picked(s: &Settings) -> Option<(usize, Kind)> {
let i = s
.shader_packs
.iter()
.position(|p| !s.horizon.shader_pack.is_empty() && p.file == s.horizon.shader_pack)?;
.position(|p| !s.horizon.shader_pack.is_empty() && p.key == s.horizon.shader_pack)?;
Some((i, s.shader_packs[i].kind))
}
@@ -360,7 +360,7 @@ impl ShaderUi {
Item::Pack(i) => Some(
s.shader_packs
.get(usize::from(i))
.is_some_and(|p| p.file == s.horizon.shader_pack),
.is_some_and(|p| p.key == s.horizon.shader_pack),
),
Item::Option(i) => match picked(s)? {
(p, Kind::Horizon) => {
@@ -406,7 +406,7 @@ impl ShaderUi {
Kind::Horizon => "A Horizon shader pack.",
Kind::Classic => "An OptiFine or Iris shader pack.",
});
let what = lang::get(if pack.file == s.horizon.shader_pack {
let what = lang::get(if pack.key == s.horizon.shader_pack {
"In use: press to turn shaders off."
} else {
"Press to use it."
@@ -588,8 +588,14 @@ impl ShaderUi {
// The picked pack's own screens go under its name, apart from Horizon's pack list.
if side.len() > 1 {
let [x, y, w, _] = cols.page(1);
let pack = &s.horizon.shader_pack;
let pack = pack.strip_suffix(".zip").unwrap_or(pack);
let pack = match picked(s) {
Some((i, Kind::Horizon)) => s.shader_packs[i].title.as_str(),
_ => s
.horizon
.shader_pack
.strip_suffix(".zip")
.unwrap_or(&s.horizon.shader_pack),
};
let caption = lang::tr("%s settings", &[&pack]);
let rect = [x + 2.0, y + 6.0, w - 4.0, 8.0];
crate::widgets::record(&caption, rect, false);
@@ -854,10 +860,10 @@ impl ShaderUi {
let Some(p) = s.shader_packs.get(usize::from(i)) else {
return Vec::new();
};
s.horizon.shader_pack = if s.horizon.shader_pack == p.file {
s.horizon.shader_pack = if s.horizon.shader_pack == p.key {
String::new()
} else {
p.file.clone()
p.key.clone()
};
vec![Action::ButtonClick, Action::Picked]
}
@@ -1107,6 +1113,7 @@ mod tests {
fn entry(file: &str, kind: Kind) -> Entry {
Entry {
file: file.into(),
key: file.into(),
title: file.into(),
kind,
options: Vec::new(),
@@ -1174,14 +1181,20 @@ mod tests {
#[test]
fn picking_a_pack_and_picking_it_again_turns_shaders_off() {
let mut s = settings();
let mut ui = ShaderUi::default();
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
assert_eq!(s.horizon.shader_pack, "B.zip");
ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(s.horizon.shader_pack, "");
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
for (kind, key) in [(Kind::Classic, "B.zip"), (Kind::Horizon, "horizon:warm")] {
let mut s = settings();
s.shader_packs[1].kind = kind;
s.shader_packs[1].key = key.into();
let mut ui = ShaderUi::default();
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
assert_eq!(s.horizon.shader_pack, key);
assert_eq!(picked(&s), Some((1, kind)));
assert_eq!(ShaderUi::switch(Item::Pack(1), &s), Some(true));
ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(s.horizon.shader_pack, "");
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
}
}
#[test]
@@ -1251,6 +1264,7 @@ mod tests {
#[test]
fn a_horizon_packs_slider_drag_is_saved_once_when_let_go() {
let mut pack = entry("H", Kind::Horizon);
pack.key = "horizon:slider_test".into();
pack.options = vec![horizon_shader_api::PackOption {
id: "strength".into(),
name: "Strength".into(),
@@ -1267,7 +1281,7 @@ mod tests {
shader_packs: vec![pack],
..Settings::default()
};
s.horizon.shader_pack = "H".into();
s.horizon.shader_pack = "horizon:slider_test".into();
let mut ui = ShaderUi::default();
ui.open(Tab::Screen(0));
let cols = Columns::of(SIZE.0, SIZE.1);
@@ -7,7 +7,7 @@
use std::{
hash::{Hash, Hasher},
path::Path,
path::{Path, PathBuf},
time::{Duration, Instant},
};
@@ -21,14 +21,47 @@ const RED: u32 = 0xFF5555;
#[derive(Default)]
pub(super) struct NativeShaders {
/// The pack's name in `shaderpacks/`, of either kind (`game/shader_packs.rs`); empty: none.
/// The resolved pack path, of either kind (`game/shader_packs.rs`); empty: none.
pub picked: String,
/// The pack's files when it was last read (`fingerprint`).
stamp: Option<u64>,
looked: Option<Instant>,
selection: String,
resolved: PathBuf,
resolved_at: Option<Instant>,
}
impl NativeShaders {
pub fn resolve_path(&mut self, dir: &Path, picked: &str, filesystem: bool) -> PathBuf {
if self.selection != picked || self.resolved_at.is_none_or(|t| t.elapsed() >= LOOK_EVERY) {
let resolved = if filesystem {
dir.join(picked)
} else {
horizon_client::shader_pack_list::resolve(dir, picked).1
};
// A manifest being edited may be temporarily invalid. Keep its path so the
// reload reports the error and retains the last good shader.
let unresolved =
!filesystem && picked.starts_with("horizon:") && resolved == dir.join(picked);
let keep_path = unresolved
&& self.selection == picked
&& self.resolved_at.is_some()
&& Pack::read_manifest(&self.resolved).is_err();
if !keep_path {
self.resolved = resolved;
}
self.selection = picked.to_owned();
self.resolved_at = Some(Instant::now());
}
self.resolved.clone()
}
pub fn select(&mut self, name: &str) {
self.picked = name.to_owned();
self.stamp = None;
self.looked = None;
}
pub fn picking(name: &str) -> Self {
Self {
picked: name.to_owned(),
@@ -63,7 +96,9 @@ impl Game<'_> {
.map_err(|p| p.iter().map(ToString::to_string).collect::<Vec<String>>())?;
// The player's values (the pack's Shader Options page), else the defaults.
let options = &pack.manifest.options;
let values = horizon_client::shader_pack_list::load_values(path, options);
let values_pack =
horizon_client::shader_pack_list::native_values_pack(path, &pack.manifest.id);
let values = horizon_client::shader_pack_list::load_values(&values_pack, options);
let constants = options.iter().map(|o| o.id.clone()).zip(values).collect();
let sprites = || {
let layers = self.pack.render.sprite_layers.iter();
@@ -182,8 +217,11 @@ fn fingerprint(path: &Path) -> Option<u64> {
let mut hasher = std::hash::DefaultHasher::new();
walk(path, &mut hasher)?;
// The player's option values, when there are any.
let values = horizon_client::shader_pack_list::values_path(path);
let _ = walk(&values, &mut hasher);
if let Ok(manifest) = Pack::read_manifest(path) {
let values_pack = horizon_client::shader_pack_list::native_values_pack(path, &manifest.id);
let values = horizon_client::shader_pack_list::values_path(&values_pack);
let _ = walk(&values, &mut hasher);
}
Some(hasher.finish())
}
@@ -200,6 +238,58 @@ mod tests {
.unwrap()
}
#[test]
fn stable_options_are_in_the_reload_signature_and_override_keeps_its_path() {
use super::{NativeShaders, fingerprint};
let dir =
std::env::temp_dir().join(format!("horizon-native-identity-{}", std::process::id()));
let pack = dir.join("native-test_mod");
std::fs::create_dir_all(&pack).unwrap();
std::fs::write(pack.join(horizon_shader_api::MANIFEST),
"schema = 1\nid = \"p\"\nname = \"P\"\nversion = \"1\"\napi = \"0.1\"\n[[post]]\nfile = \"post.wgsl\"\n").unwrap();
std::fs::write(pack.join("post.wgsl"), "fixture").unwrap();
let mut state = NativeShaders::default();
assert_eq!(state.resolve_path(&dir, "horizon:p", false), pack);
let first = fingerprint(&pack).unwrap();
let manifest_path = pack.join(horizon_shader_api::MANIFEST);
let manifest = std::fs::read_to_string(&manifest_path).unwrap();
std::fs::write(&manifest_path, "temporarily invalid").unwrap();
state.resolved_at = Some(std::time::Instant::now() - super::LOOK_EVERY);
assert_eq!(state.resolve_path(&dir, "horizon:p", false), pack);
assert_ne!(fingerprint(&pack).unwrap(), first);
std::fs::write(&manifest_path, manifest).unwrap();
let first = fingerprint(&pack).unwrap();
let values = horizon_client::shader_pack_list::native_values_pack(&pack, "p");
let path = horizon_client::shader_pack_list::values_path(&values);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "strength=2\n").unwrap();
assert_ne!(fingerprint(&pack).unwrap(), first);
std::fs::rename(&pack, dir.join("renamed")).unwrap();
state.resolved_at = None;
assert_eq!(
state.resolve_path(&dir, "horizon:p", false),
dir.join("renamed")
);
let manifest_path = dir.join("renamed").join(horizon_shader_api::MANIFEST);
let text = std::fs::read_to_string(&manifest_path)
.unwrap()
.replace("id = \"p\"", "id = \"other\"");
std::fs::write(manifest_path, text).unwrap();
state.resolved_at = Some(std::time::Instant::now() - super::LOOK_EVERY);
assert_eq!(
state.resolve_path(&dir, "horizon:p", false),
dir.join("horizon:p"),
"a valid different manifest ID must not replace the selected identity"
);
let mut override_state = NativeShaders::default();
assert_eq!(
override_state.resolve_path(&dir, "native-test_mod", true),
pack,
"an explicit missing filesystem override must not switch to another installed pack"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn only_shader_api_0_1_is_drawn() {
assert_eq!(not_drawn(&manifest("0.1", "")), None);
@@ -446,6 +446,14 @@ impl Game<'_> {
// The website, through ConfirmLinkScreen as other links.
Event::OpenModPage(url) => self.confirm_trusted_link(&url, Screen::Options),
Event::ListShaderPacks => {
let (key, _) = horizon_client::shader_pack_list::resolve(
&Self::shader_packs_dir(),
&self.settings.horizon.shader_pack,
);
if key != self.settings.horizon.shader_pack {
self.settings.horizon.shader_pack = key;
self.settings.save(&Self::options_path());
}
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
}
+66 -12
View File
@@ -29,6 +29,10 @@ pub(super) fn read_shader_options(
language: &str,
macros: &Macros,
) -> Option<ShaderOptions> {
// Native options come from the manifest; the shared screen opens either kind.
if name.starts_with("horizon:") {
return None;
}
let pack = match Pack::open(&Game::shader_packs_dir().join(name)) {
Ok(pack) => pack,
Err(e) => {
@@ -70,11 +74,16 @@ pub(super) fn write_shader_options(options: &ShaderOptions) {
/// The pack that runs over the world ("": none): `HORIZON_SHADER_PACK`, else the pick
/// (`active_pack`).
fn picked_pack(settings: &horizon_client::settings::Settings) -> &str {
fn dev_pack() -> Option<&'static str> {
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
DEV_PACK
.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok())
.as_deref()
}
fn picked_pack(settings: &horizon_client::settings::Settings) -> &str {
active_pack(
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
dev_pack().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
)
}
@@ -84,7 +93,17 @@ fn picked_pack(settings: &horizon_client::settings::Settings) -> &str {
/// (`Renderer::new`).
pub(super) fn picked_pack_draws_flat(settings: &horizon_client::settings::Settings) -> bool {
let picked = picked_pack(settings);
!picked.is_empty() && pack_draws_flat(&Game::shader_packs_dir().join(picked))
let dir = Game::shader_packs_dir();
let path = if dev_pack().is_some() {
dir.join(picked)
} else {
horizon_client::shader_pack_list::resolve(&dir, picked).1
};
!picked.is_empty()
&& path
.file_name()
.is_none_or(|f| !active_pack(&f.to_string_lossy(), &settings.mods).is_empty())
&& pack_draws_flat(&path)
}
/// Whether the pack at `path` has passes that draw the terrain flat where the device can,
@@ -92,7 +111,7 @@ pub(super) fn picked_pack_draws_flat(settings: &horizon_client::settings::Settin
/// draw terrain, or one of Horizon's own with a `[shadow]` pass.
fn pack_draws_flat(path: &std::path::Path) -> bool {
!horizon_shader_api::package::is_pack(path)
|| horizon_shader_api::Pack::read(path).is_ok_and(|p| p.manifest.shadow.is_some())
|| horizon_shader_api::Pack::read_manifest(path).is_ok_and(|m| m.shadow.is_some())
}
// A native mod can bundle a pack as `native-<mod-id>`; its Mods toggle controls it too.
@@ -115,7 +134,17 @@ pub(super) fn picks_material_pack(settings: &horizon_client::settings::Settings)
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
);
!picked.is_empty()
if picked.is_empty() {
return false;
}
let dir = Game::shader_packs_dir();
let path = if dev.is_some() {
dir.join(picked)
} else {
horizon_client::shader_pack_list::resolve(&dir, picked).1
};
path.file_name()
.is_none_or(|f| !active_pack(&f.to_string_lossy(), &settings.mods).is_empty())
}
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
@@ -174,10 +203,20 @@ impl Game<'_> {
self.streaming.separate_ao = separate_ao;
self.all_sections_changed();
}
let picked = picked_pack(&self.settings).to_owned();
let selected = picked_pack(&self.settings).to_owned();
let path = self.native_shaders.resolve_path(
&Self::shader_packs_dir(),
&selected,
dev_pack().is_some(),
);
let file = path.file_name().unwrap_or_default().to_string_lossy();
let picked = if selected.is_empty() || active_pack(&file, &self.settings.mods).is_empty() {
String::new()
} else {
path.to_string_lossy().into_owned()
};
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
self.native_shaders = super::native_shaders::NativeShaders::picking(&picked);
self.native_shaders.select(&picked);
self.set_translucent_layers(Vec::new());
self.pack_ids = None;
if let Some(renderer) = &mut self.renderer {
@@ -217,14 +256,15 @@ impl Game<'_> {
self.reload_packs();
return PackInputs::default();
}
let name = picked;
let name = selected.clone();
let dimension = match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
};
let path = Self::shader_packs_dir().join(&name);
if horizon_shader_api::package::is_pack(&path) {
if (dev_pack().is_none() && selected.starts_with("horizon:"))
|| horizon_shader_api::package::is_pack(&path)
{
// One of Horizon's own packs (game/native_shaders.rs).
self.tick_native_shader_pack(&path);
} else if let Some(renderer) = &mut self.renderer
@@ -1081,6 +1121,20 @@ mod native_pack_tests {
assert_eq!(active_pack(owned, &mods), owned);
mods.disabled.insert("horizon_better_visuals".into());
assert_eq!(active_pack(owned, &mods), "");
let root =
std::env::temp_dir().join(format!("horizon-bundled-shader-{}", std::process::id()));
let pack = root.join(owned);
std::fs::create_dir_all(&pack).unwrap();
std::fs::write(pack.join(horizon_shader_api::MANIFEST),
"schema = 1\nid = \"bundled\"\nname = \"Bundled\"\nversion = \"1\"\napi = \"0.1\"\n[[post]]\nfile = \"p.wgsl\"\n").unwrap();
let (key, path) = horizon_client::shader_pack_list::resolve(&root, owned);
assert_eq!(key, "horizon:bundled");
assert_eq!(path, pack);
assert_eq!(
active_pack(&path.file_name().unwrap().to_string_lossy(), &mods),
""
);
std::fs::remove_dir_all(root).unwrap();
assert_eq!(active_pack("golden_hour", &mods), "golden_hour");
mods.installed[0].native = None;
assert_eq!(active_pack(owned, &mods), owned);
@@ -586,6 +586,14 @@ impl Loading {
}
Event::OpenModPage(url) => packs::open_uri(&url),
Event::ListShaderPacks => {
let (key, _) = horizon_client::shader_pack_list::resolve(
&Game::shader_packs_dir(),
&self.settings.horizon.shader_pack,
);
if key != self.settings.horizon.shader_pack {
self.settings.horizon.shader_pack = key;
self.save_menu_settings();
}
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Game::shader_packs_dir());
}
+25
View File
@@ -54,6 +54,31 @@ pub fn is_pack(path: &Path) -> bool {
}
impl Pack {
/// Reads only the manifest, without loading or validating shader source.
pub fn read_manifest(path: &Path) -> Result<Manifest, String> {
let file = std::fs::File::open(if path.is_dir() {
path.join(MANIFEST)
} else {
path.to_path_buf()
})
.map_err(|e| e.to_string())?;
let mut text = String::new();
if path.is_dir() {
file.take(MAX_OTHER + 1).read_to_string(&mut text)
} else {
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
zip.by_name(MANIFEST)
.map_err(|e| e.to_string())?
.take(MAX_OTHER + 1)
.read_to_string(&mut text)
}
.map_err(|e| e.to_string())?;
if text.len() as u64 > MAX_OTHER {
return Err("shader manifest is too large".into());
}
Manifest::parse(&text)
}
/// Reads `path`: a `.hshader` file, or a folder (its hidden files and
/// folders, such as `.git`, are skipped).
pub fn read(path: &Path) -> Result<Pack, String> {
+8
View File
@@ -152,6 +152,14 @@ value). The pack holds only the manifest, the `.wgsl` files it names (each used,
`README.md`, `LICENSE` (`.md`, `.txt`) and `icon.png`, under plain paths, 8 MiB in all. A `.hshader` is built
the same way every time (files in path order, fixed dates), so a pack's SHA-256 depends only on its files.
Native selections are saved as `horizon:<id>`; option values live in
`shaderpacks/.horizon-options/<id>.txt`. Updates and file renames keep both. Existing filename
selections and sidecars are imported without deleting the originals; after an update has removed
the old file, migration recognizes the launcher's `<id>-<version>.hshader` names. An arbitrary
removed filename cannot be mapped back to an ID. If several installed packs share an ID, the first
filename in lexical order is used and listed. The `HORIZON_SHADER_PACK` development override still
names an exact filesystem path; `shaders/` packs still use filenames.
## Shader API 0.2 (experimental)
0.2 is the line for linear light and HDR ([hdr-linear-light.md](../design/hdr-linear-light.md)). A pack opts in with