Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 c98b1438cb Shader Packs page: the picked pack's options
In the Horizon menus, the selected Horizon shader pack's options now show
under its row: switches for booleans, a cycling button for integer choices
and sliders for numbers. A change is written to shaderpacks/<pack>.txt
right away, and since that file now counts in the pack's stamp, the game
loads the pack again with the new values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:09:42 +02:00
5 changed files with 247 additions and 4 deletions

No files matched your search

@@ -53,7 +53,7 @@ impl Game<'_> {
state.stamp = Stamp::default();
None
} else {
state.stamp = Stamp::of(&dir.join(&id));
state.stamp = Stamp::of_pack(&dir, &id);
// Java packs are told which GPU they run on.
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
let name = r.info.name.as_str();
@@ -97,7 +97,7 @@ impl Game<'_> {
return;
}
state.checked = Some(Instant::now());
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
if Stamp::of_pack(&dir(), &state.selected) != state.stamp {
self.load_shader_pack();
}
}
+134 -2
View File
@@ -42,6 +42,8 @@ pub enum HRow {
ShaderPackFolder,
/// Shader pack `i` of `OptionsUi::shader_packs` and its switch.
ShaderPack(usize),
/// Option `i` of the selected Horizon shader pack.
ShaderPackOption(usize),
}
/// The sidebar section listing the shader packs.
@@ -298,6 +300,8 @@ enum Target {
Search,
Row(usize),
Slider(Item, f32),
/// The selected shader pack's option `i`, a slider at `v`.
PackSlider(usize, f32),
Style(usize),
Level(usize),
}
@@ -315,6 +319,8 @@ impl HorizonScrollbar {
}
struct HorizonLayout {
sliders: Vec<(Item, f32, f32)>,
/// The selected shader pack's option sliders: option, x, width.
pack_sliders: Vec<(usize, f32, f32)>,
scrollbar: Option<HorizonScrollbar>,
}
fn horizon_batch(framebuffer: [i32; 2]) -> HudBatch {
@@ -329,7 +335,15 @@ impl OptionsUi {
"No shader packs: put a pack's folder or .zip in this folder",
));
}
rows.extend((0..self.shader_packs.packs.len()).map(HRow::ShaderPack));
for (p, (id, _)) in self.shader_packs.packs.iter().enumerate() {
rows.push(HRow::ShaderPack(p));
// The selected pack's options, under its row.
if *id == s.horizon.shader_pack
&& let Some(options) = self.shader_packs.options.get(id)
{
rows.extend((0..options.len()).map(HRow::ShaderPackOption));
}
}
}
let query = self.settings_search.value.to_lowercase();
if query.is_empty() || !searchable_section(section) {
@@ -412,6 +426,7 @@ impl OptionsUi {
let hstart = ui.hits.len();
let tstart = crate::widgets::targets_mark();
let mut sliders = Vec::new();
let mut pack_sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
for (i, row) in rows.iter().enumerate() {
@@ -564,6 +579,39 @@ impl OptionsUi {
let on = s.horizon.shader_pack == *id;
ui.switch(ROW + i as u64, control_x, y + ROW_H / 2.0, on);
}
HRow::ShaderPackOption(k) => {
let Some(o) = self.pack_option(s, k) else {
continue;
};
let id = ROW + i as u64;
let cy = y + ROW_H / 2.0;
let value = crate::shader_pack::option_text(o);
match o.kind {
crate::shader_pack::OptionKind::Bool => {
ui.row(x, y, &o.label, "", 0.0);
ui.switch(id, control_x, cy, o.value != 0.0);
}
crate::shader_pack::OptionKind::Int => {
ui.row(x, y, &o.label, "", 0.0);
let bw = ui.button_width(&value, false).max(96.0);
ui.button(
id,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
crate::shader_pack::OptionKind::Float { min, max, .. } => {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
ui.row(x, y, &o.label, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
let sx = control_x - sw - 8.0;
let v = ((o.value - min) / (max - min).max(1e-9)) as f32;
ui.slider(id, sx, cy, sw, v);
pack_sliders.push((k, sx, sw));
}
}
}
HRow::OpenModFolder => {
ui.row(x, y, "Mod folder", "", 0.0);
let label = "Open Folder";
@@ -664,7 +712,11 @@ impl OptionsUi {
let _ = ox;
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
HorizonLayout { sliders, scrollbar }
HorizonLayout {
sliders,
pack_sliders,
scrollbar,
}
}
pub(crate) fn draw_horizon(
&mut self,
@@ -734,6 +786,12 @@ impl OptionsUi {
let v = t::slider_value(&ui, sx, sw, x);
return (Some(Target::Slider(item, v)), scrollbar, cursor);
}
if let Some(HRow::ShaderPackOption(k)) = rows.get(i)
&& let Some(&(_, sx, sw)) = layout.pack_sliders.iter().find(|(o, ..)| o == k)
{
let v = t::slider_value(&ui, sx, sw, x);
return (Some(Target::PackSlider(*k, v)), scrollbar, cursor);
}
(
(i < rows.len()).then_some(Target::Row(i)),
scrollbar,
@@ -785,6 +843,12 @@ impl OptionsUi {
set_item_slider(item, s, t::slider_value(&ui, sx, sw, x));
events.push(Event::Changed);
}
if let Some(k) = self.hdragging_pack
&& let Some(&(_, sx, sw)) = layout.pack_sliders.iter().find(|(o, ..)| *o == k)
{
let v = t::slider_value(&ui, sx, sw, x);
self.set_pack_option(s, k, |o| o.set_fraction(v));
}
return events;
}
// Rebinding consumes the click before sidebar or widget actions.
@@ -835,6 +899,10 @@ impl OptionsUi {
set_item_slider(item, s, v);
events.push(Event::Changed);
}
Some(Target::PackSlider(k, v)) => {
self.hdragging_pack = Some(k);
self.set_pack_option(s, k, |o| o.set_fraction(v));
}
Some(Target::Row(i)) => {
let section = self.section.min(SECTIONS.len() - 1);
match self.horizon_rows(s, section).get(i) {
@@ -877,6 +945,10 @@ impl OptionsUi {
self.toggle_shader_pack(s, *p);
events.push(Event::Changed);
}
Some(HRow::ShaderPackOption(k)) => {
events.push(Event::ButtonClick);
self.set_pack_option(s, *k, |o| o.cycle(right));
}
_ => {}
}
}
@@ -884,6 +956,35 @@ impl OptionsUi {
}
events
}
/// The selected shader pack's option `k`.
fn pack_option(&self, s: &Settings, k: usize) -> Option<&crate::shader_pack::PackOption> {
self.shader_packs
.options
.get(&s.horizon.shader_pack)?
.get(k)
}
/// Change the selected pack's option `k` and save the pack's values; the
/// game sees the file change and loads the pack again with them.
fn set_pack_option(
&mut self,
s: &Settings,
k: usize,
change: impl FnOnce(&mut crate::shader_pack::PackOption),
) {
let id = &s.horizon.shader_pack;
let folder = &mut self.shader_packs;
let Some(options) = folder.options.get_mut(id) else {
return;
};
let Some(o) = options.get_mut(k) else { return };
let before = o.value;
change(o);
if o.value != before
&& let Err(e) = crate::shader_pack::save_values(&folder.dir, id, options)
{
log::warn!("Horizon: shader pack {id}: saving its options: {e}");
}
}
fn select_section(&mut self, i: usize) {
if searchable_section(self.section) || searchable_section(i) {
self.settings_search.set_value("");
@@ -1187,6 +1288,37 @@ mod tests {
click(&mut options, &mut settings, ROW + 1);
assert_eq!(settings.horizon.shader_pack, "");
// A Horizon pack's options show under it once it's picked, and a
// change is saved where the game reads it.
std::fs::create_dir_all(dir.join("Mine")).unwrap();
std::fs::write(
dir.join("Mine/pack.toml"),
"[pack]\nname = \"Mine\"\napi = 1\n\n[[stage]]\nkind = \"composite\"\nshader = \"a.wgsl\"\n\n\
[options.vignette]\nlabel = \"Vignette\"\ndefault = true\n\n\
[options.strength]\nlabel = \"Strength\"\ndefault = 0.5\nmin = 0.0\nmax = 1.0\nstep = 0.25\n",
)
.unwrap();
options.shader_packs = Default::default();
options.shader_packs.poll(dir.clone());
click(&mut options, &mut settings, ROW + 1);
assert_eq!(settings.horizon.shader_pack, "Mine");
assert!(matches!(
options.horizon_rows(&settings, options.section)[..],
[
HRow::ShaderPackFolder,
HRow::ShaderPack(0),
HRow::ShaderPackOption(0),
HRow::ShaderPackOption(1),
HRow::ShaderPack(1)
]
));
click(&mut options, &mut settings, ROW + 3);
assert_eq!(
std::fs::read_to_string(dir.join("Mine.txt")).unwrap(),
"strength=0.5\nvignette=false\n"
);
click(&mut options, &mut settings, ROW + 1);
let mut options = section_ui("Video");
settings.horizon.advanced = true;
let video = SECTIONS.iter().position(|s| s.name == "Video").unwrap();
+4
View File
@@ -977,6 +977,8 @@ pub struct OptionsUi {
pub(crate) hscroll: f32,
/// The slider being dragged on the Horizon options shell.
pub(crate) hdragging: Option<crate::graphics_ui::Item>,
/// The selected shader pack's option slider being dragged.
pub(crate) hdragging_pack: Option<usize>,
/// Key Binds: the mapping waiting for a key.
pub selected_key: Option<&'static str>,
/// Controller Binds: the action waiting for a pad button.
@@ -1021,6 +1023,7 @@ impl Default for OptionsUi {
section_shown: None,
hscroll: 0.0,
hdragging: None,
hdragging_pack: None,
selected_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
@@ -2541,6 +2544,7 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
self.hdragging = None;
self.hdragging_pack = None;
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
+104
View File
@@ -103,6 +103,48 @@ pub struct PackOption {
pub value: f64,
}
impl PackOption {
/// A slider at `v` (0..1): the value there, on the option's steps.
pub fn set_fraction(&mut self, v: f32) {
if let OptionKind::Float { min, max, step } = self.kind {
let mut value = min + f64::from(v.clamp(0.0, 1.0)) * (max - min);
if step > 0.0 {
value = min + ((value - min) / step).round() * step;
}
self.value = value.clamp(min, max);
}
}
/// A click: a switch flips, a choice moves on (back with `back`), a
/// slider moves one step and wraps.
pub fn cycle(&mut self, back: bool) {
match self.kind {
OptionKind::Bool => self.value = if self.value != 0.0 { 0.0 } else { 1.0 },
OptionKind::Int if !self.values.is_empty() => {
let n = self.values.len();
let i = self
.values
.iter()
.position(|&v| v as f64 == self.value)
.unwrap_or(0);
let i = if back { (i + n - 1) % n } else { (i + 1) % n };
self.value = self.values[i] as f64;
}
OptionKind::Int => {}
OptionKind::Float { min, max, step } => {
let step = if step > 0.0 { step } else { (max - min) / 20.0 };
let next = self.value + if back { -step } else { step };
self.value = if next > max + 1e-9 {
min
} else if next < min - 1e-9 {
max
} else {
next
};
}
}
}
}
pub struct ShaderPack {
/// The pack's folder name.
pub id: String,
@@ -120,6 +162,15 @@ pub struct ShaderPack {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Stamp(Vec<(PathBuf, u64, Option<SystemTime>)>);
impl Stamp {
/// Pack `id` in `shaderpacks/` and its saved option values.
pub fn of_pack(folder: &Path, id: &str) -> Self {
let mut stamp = Self::of(&folder.join(id));
let values = folder.join(format!("{id}.txt"));
if let Ok(meta) = fs::metadata(&values) {
stamp.0.push((values, meta.len(), meta.modified().ok()));
}
stamp
}
pub fn of(dir: &Path) -> Self {
let mut files = Vec::new();
if let Ok(meta) = fs::metadata(dir)
@@ -184,6 +235,8 @@ pub fn folder() -> PathBuf {
pub struct Folder {
pub dir: PathBuf,
pub packs: Vec<(String, Result<Info, String>)>,
/// Each Horizon pack's options with their saved values.
pub options: BTreeMap<String, Vec<PackOption>>,
read: Option<std::time::Instant>,
}
@@ -196,6 +249,15 @@ impl Folder {
return;
}
self.packs = list(&dir);
self.options = self
.packs
.iter()
.filter(|(id, info)| info.is_ok() && dir.join(id).join("pack.toml").is_file())
.filter_map(|(id, _)| {
let options = pack_options(&dir.join(id), &load_values(&dir, id)).ok()?;
Some((id.clone(), options))
})
.collect();
self.dir = dir;
self.read = Some(std::time::Instant::now());
}
@@ -412,6 +474,37 @@ fn validate(source: &str, file: &str, entry: &str) -> Result<(), String> {
Ok(())
}
/// A Horizon pack's options with `values` (the Shader Packs page's rows).
pub fn pack_options(
dir: &Path,
values: &BTreeMap<String, String>,
) -> Result<Vec<PackOption>, String> {
manifest(dir)?
.options
.into_iter()
.map(|(key, entry)| option(key, entry, values))
.collect()
}
/// An option's value as `shaderpacks/<id>.txt` keeps it.
pub fn option_text(o: &PackOption) -> String {
match o.kind {
OptionKind::Bool => (o.value != 0.0).to_string(),
OptionKind::Int => (o.value as i64).to_string(),
OptionKind::Float { .. } => format!("{}", o.value as f32),
}
}
/// Save a pack's option values; the game sees the file change and loads the
/// pack again with them.
pub fn save_values(dir: &Path, id: &str, options: &[PackOption]) -> std::io::Result<()> {
let text: String = options
.iter()
.map(|o| format!("{}={}\n", o.key, option_text(o)))
.collect();
fs::write(dir.join(format!("{id}.txt")), text)
}
/// The option values saved for a pack (`shaderpacks/<id>.txt`, `key=value`).
pub fn load_values(dir: &Path, id: &str) -> BTreeMap<String, String> {
fs::read_to_string(dir.join(format!("{id}.txt")))
@@ -577,6 +670,17 @@ override mode: i32 = 1;
assert_eq!(listed[1].1.as_ref().unwrap().name, "Test");
}
#[test]
fn saved_option_values_change_the_pack_stamp() {
let dir = scratch();
fs::create_dir_all(dir.join("p")).unwrap();
fs::write(dir.join("p/pack.toml"), "").unwrap();
let before = Stamp::of_pack(&dir, "p");
fs::write(dir.join("p.txt"), "a=1\n").unwrap();
assert!(Stamp::of_pack(&dir, "p") != before);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn saved_values_are_read_per_pack() {
let dir = scratch();
+3
View File
@@ -67,6 +67,9 @@ The lightmap, fog and sky colours are computed on the CPU (`environment.rs`, `fo
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.
- In the Horizon menus, the picked Horizon pack's options show under its row: a switch for `true`/`false`, a
button cycling through `values`, a slider for numbers (a click beside it moves one `step`). A change is saved
to `shaderpacks/<pack>.txt` at once, and the pack loads again with it (the file counts as one of the pack's).
- 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.