mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #10168 from NikitaMGrimm/shader-pack-identity
Keep native shader selections and settings across updates
This commit is contained in:
10 files changed
+479
-62
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user