From f16bc2387f262341b48740e641ce3febef78fca1 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:43:11 +0300 Subject: [PATCH] /airdef command tree with typed arguments, live scenario names and generated help (#98, fork patch 36) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/commands.rs | 275 +++ airdef-mod/src/lib.rs | 10 +- airdef-mod/src/scenario.rs | 22 +- ...ork-world-models-client-particles-so.patch | 2 +- ...izon-mod-report-particles-and-sounds.patch | 2 +- ...-fork-full-screen-post-pass-for-mods.patch | 2 +- ...far-models-as-scaled-impostors-past-.patch | 2 +- ...er.set-views-draws-the-world-from-ot.patch | 2 +- ...er-tick-and-per-event-AirDef-plans-r.patch | 2 +- ...-up-to-1920x1080-full-screen-replays.patch | 2 +- ...ant-terrain-tiles-fog-measured-from-.patch | 2 +- ...id-so-mods-keep-state-per-world-API-.patch | 2 +- ...d-trial-runs-behind-feature-full-so-.patch | 2 +- ...ffer-pass-per-pixel-mod-point-lights.patch | 2 +- ...e-2-sun-and-moon-shadow-cascades-the.patch | 2 +- ...ached-until-a-resize-or-a-new-source.patch | 2 +- ...-per-16x16-tile-surface-and-halo-lis.patch | 2 +- ...pass-in-the-F3-timing-list-half-reso.patch | 2 +- ...only-sun-facing-quads-and-skip-burie.patch | 2 +- ...es-render.model-id-model-generation-.patch | 2 +- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 2 +- ...-pass-colour-normal-depth-the-floor-.patch | 2 +- ...lla-texture-on-the-surface-the-biome.patch | 2 +- ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 2 +- ...r-blue-a-little-clearer-ripples-a-li.patch | 2 +- ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 2 +- ...es-for-simulations-render.query-rays.patch | 2 +- ...vel-from-the-world-not-from-rain-geo.patch | 2 +- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 2 +- ...scales-that-divide-the-camera-s-6553.patch | 2 +- ...oke-and-missile-trails-with-clear-tu.patch | 2 +- ...ited-by-depth-so-terrain-and-vehicle.patch | 2 +- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 2 +- ...main-view-API-level-15-renders-the-s.patch | 2 +- ...mods-render.set-spot-lights-API-leve.patch | 2 +- ...g-polish-cloud-panorama-cirrus-terra.patch | 2 +- ...through-glass-and-leaves-rain-ripple.patch | 2 +- ...y-frame-in-flight-reaches-the-writer.patch | 2 +- ...s-command.set-tree-and-set-suggestio.patch | 1925 +++++++++++++++++ 39 files changed, 2260 insertions(+), 42 deletions(-) create mode 100644 airdef-mod/src/commands.rs create mode 100644 horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch diff --git a/airdef-mod/src/commands.rs b/airdef-mod/src/commands.rs new file mode 100644 index 0000000..150fcdc --- /dev/null +++ b/airdef-mod/src/commands.rs @@ -0,0 +1,275 @@ +//! The `/airdef` command tree: the one description the game's chat box (colours, errors, +//! Tab completion), the help line and the tests read (#98). The handler in `lib.rs` still +//! splits its line itself; this tree decides what reaches it. +use crate::sim::{DroneKind, SamKind}; +use horizon_mod_sdk::tree::{Node, Opt, Tree, Value}; + +/// The suggestion list of the saved scenarios' names (`command::set_suggestions`). +pub const SCENARIOS: &str = "scenarios"; + +fn on_off() -> Value { + Value::choice(["on", "off"]) +} + +/// `/airdef ...` as `lib.rs` accepts it. +pub fn tree() -> Tree { + let wave_kinds: Vec<&str> = DroneKind::ALL.iter().map(|k| k.spec().key).chain(["mix"]).collect(); + let deploy_kinds: Vec<&str> = SamKind::ALL.iter().map(|k| k.key()).collect(); + let armed: Vec<&str> = SamKind::ARMED.iter().map(|k| k.key()).chain(["all"]).collect(); + let toggle = |word: &str| Node::lit(word).then(Node::arg("on/off", on_off()).runs()); + Tree::new("airdef").runs().with([ + Node::lit("menu").runs(), + Node::lit("monitor").runs(), + Node::lit("grid").runs(), + Node::lit("replay").runs().with([ + Node::lit("map").runs(), + Node::lit("full").runs(), + Node::lit("reel").runs(), + Node::lit("film").runs().with([ + Node::arg("режим", Value::choice(["best", "all"])).runs(), + Node::arg("N", Value::int_from(1)).runs(), + ]), + // The words after `export` come in any order: a name, best/all/N, key=value options. + Node::lit("export").runs().then( + Node::options( + "параметры", + vec![ + Opt::new("fps", Value::int_from(1)), + Opt::new("size", Value::word()), + Opt::new("crf", Value::int_from(0)), + Opt::new("sec", Value::Number(Some(0.0), None)), + Opt::flag("noaudio"), + ], + Some(vec!["best".into(), "all".into()]), + ) + .runs(), + ), + Node::lit("stop").runs(), + ]), + Node::lit("scenario").with([ + Node::lit("save").then( + Node::arg("имя", Value::word()).runs().then(Node::arg("fresh", Value::choice(["fresh"])).runs()), + ), + Node::lit("load").then( + Node::arg("имя", Value::names(SCENARIOS)).runs().then(Node::arg("force", Value::choice(["force"])).runs()), + ), + Node::lit("list").runs(), + Node::lit("delete").then(Node::arg("имя", Value::names(SCENARIOS)).runs()), + ]), + Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()), + Node::lit("raid").runs(), + Node::lit("repair").runs(), + Node::lit("import").runs(), + toggle("ew"), + Node::lit("flare").runs(), + Node::lit("move").then(Node::arg("номер", Value::int_from(1)).runs()), + Node::lit("target").then(Node::lit("here").runs()), + Node::lit("give").runs(), + Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()), + Node::lit("wave").then( + Node::arg("тип", Value::choice(wave_kinds)).runs().then( + Node::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 800.0)).runs()), + ), + ), + Node::lit("clear").runs(), + Node::lit("targets").then(Node::lit("clear").runs()), + toggle("damage"), + toggle("truth"), + Node::lit("shader").then(Node::arg("режим", Value::choice(["on", "off", "hd"])).runs()), + toggle("hold"), + Node::lit("roles").runs().then( + Node::arg("комплекс", Value::choice(armed)).then( + Node::arg( + "классы", + Value::Word(["default", "all", "drone", "jet", "cruise", "supersonic", "ballistic"].map(String::from).to_vec()), + ) + .runs(), + ), + ), + Node::lit("stats").runs(), + ]) +} + +/// The help line, from the same tree. +pub fn help() -> String { + tree().usage() +} + +/// The scenario names after `name` is saved (once, in the order saved). +pub fn names_after_save(mut all: Vec, name: &str) -> Vec { + if !all.iter().any(|n| n == name) { + all.push(name.to_string()); + } + all +} + +/// The scenario names after `name` is deleted. +pub fn names_after_delete(mut all: Vec, name: &str) -> Vec { + all.retain(|n| n != name); + all +} + +/// Registers the tree with the game, and the scenario names it suggests. +pub fn register(scenarios: &[String]) { + horizon_mod_sdk::command::set_suggestions(SCENARIOS, scenarios); + if let Err(e) = tree().register() { + horizon_mod_sdk::warn(&format!("airdef: command tree refused: {e}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(line: &str) { + if let Err(e) = tree().parse(line) { + panic!("`{line}` should parse: {e}"); + } + } + fn bad_at(line: &str) -> usize { + match tree().parse(line) { + Ok(_) => panic!("`{line}` should not parse"), + Err(e) => e.at, + } + } + fn complete(line: &str, scenarios: &[&str]) -> Vec { + let names: Vec = scenarios.iter().map(|s| s.to_string()).collect(); + tree().complete(line, &|_| names.clone()) + } + + /// Every branch of the old `HELP` line and of the handler's match parses, with its + /// options. + #[test] + fn every_branch_of_the_handler_parses() { + for line in [ + "", "menu", "monitor", "grid", "give", "raid", "repair", "import", "flare", "clear", "stats", "roles", + "replay", "replay map", "replay full", "replay reel", "replay film", "replay film best", "replay film all", + "replay film 3", "replay export", "replay export mine", "replay export best", "replay export all", "replay export 3", + "replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop", + "scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list", + "scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear", + "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", + "wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", + "roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic", + ] { + ok(line); + } + } + + #[test] + fn the_trees_kinds_are_the_enums_own() { + for k in SamKind::ALL { + ok(&format!("deploy {}", k.key())); + } + for k in DroneKind::ALL { + ok(&format!("wave {}", k.spec().key)); + } + for k in SamKind::ARMED { + ok(&format!("roles {} all", k.key())); + } + } + + #[test] + fn numbers_and_words_outside_what_the_handler_takes_are_refused_where_they_are() { + assert_eq!(bad_at("wave shahed 0"), 12); + assert_eq!(bad_at("wave shahed 81"), 12); + assert_eq!(bad_at("wave shahed 5 59"), 14); + assert_eq!(bad_at("wave shahed 5 801"), 14); + assert_eq!(bad_at("wave shahed x"), 12); + assert_eq!(bad_at("wave scud"), 5); + assert_eq!(bad_at("wave"), 4); + assert_eq!(bad_at("deploy tank"), 7); + assert_eq!(bad_at("deploy"), 6); + assert_eq!(bad_at("ew maybe"), 3); + assert_eq!(bad_at("shader ultra"), 7); + assert_eq!(bad_at("why 0"), 4); + assert_eq!(bad_at("why x"), 4); + assert_eq!(bad_at("move"), 4); + assert_eq!(bad_at("move 0"), 5); + assert_eq!(bad_at("scenario"), 8); + assert_eq!(bad_at("scenario load"), 13); + assert_eq!(bad_at("scenario wipe"), 9); + assert_eq!(bad_at("scenario save a keep"), 16); + assert_eq!(bad_at("target there"), 7); + assert_eq!(bad_at("roles tank all"), 6); + assert_eq!(bad_at("roles patriot"), 13); + assert_eq!(bad_at("replay sideways"), 7); + assert_eq!(bad_at("replay film best now"), 17); + assert_eq!(bad_at("nothing"), 0); + assert_eq!(bad_at("menu 1"), 5); + } + + /// The key=value options of `replay export` stay what `start_export` reads. + #[test] + fn export_options_are_checked_by_key_and_value() { + assert_eq!(bad_at("replay export fps=0"), 14); + assert_eq!(bad_at("replay export fps=x"), 14); + assert_eq!(bad_at("replay export fps"), 14); + assert_eq!(bad_at("replay export noaudio=1"), 14); + assert_eq!(bad_at("replay export fps=30 fps=60"), 21); + assert_eq!(bad_at("replay export sec=-1"), 14); + assert_eq!(bad_at("replay export crf=-2"), 14); + assert_eq!(bad_at("replay export size="), 14); + let p = tree().parse("replay export mine fps=30 noaudio").unwrap(); + assert_eq!(p.get("параметры"), Some("mine fps=30 noaudio")); + } + + #[test] + fn completion_offers_the_next_word_only() { + let top = complete("", &[]); + for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move", + "target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "roles", "stats"] { + assert!(top.contains(&w.to_string()), "{w} in {top:?}"); + } + assert_eq!(top.len(), 24); + assert_eq!(complete("rep", &[]), ["repair", "replay"]); + assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]); + assert_eq!(complete("replay film ", &[]), ["all", "best"]); + assert_eq!(complete("scenario ", &[]), ["delete", "list", "load", "save"]); + assert_eq!(complete("wave ", &[]).len(), 14); + assert!(complete("wave s", &[]).contains(&"shahed".to_string())); + assert_eq!(complete("shader ", &[]), ["hd", "off", "on"]); + assert_eq!(complete("replay export ", &[]), ["all", "best", "crf=", "fps=", "noaudio", "sec=", "size="]); + assert_eq!(complete("replay export fps=30 no", &[]), ["noaudio"]); + assert!(complete("menu ", &[]).is_empty()); + assert!(complete("wave nothing ", &[]).is_empty()); + } + + /// The names follow the data: after a save the new name is offered, after a delete it is gone. + #[test] + fn scenario_names_follow_a_save_and_a_delete() { + let mut all: Vec = Vec::new(); + let names = |all: &[String]| -> Vec { tree().complete("scenario load ", &|_| all.to_vec()) }; + assert!(names(&all).is_empty()); + all = names_after_save(all, "base"); + all = names_after_save(all, "night"); + all = names_after_save(all, "base"); + assert_eq!(names(&all), ["base", "night"]); + let of = |all: &[String], line: &str| tree().complete(line, &|_| all.to_vec()); + assert_eq!(of(&all, "scenario delete n"), ["night"]); + all = names_after_delete(all, "base"); + assert_eq!(names(&all), ["night"]); + assert_eq!(of(&all, "scenario delete "), ["night"]); + all = names_after_delete(all, "night"); + assert!(names(&all).is_empty()); + } + + #[test] + fn the_help_line_is_the_trees() { + let help = help(); + assert!(help.starts_with("/airdef menu | monitor | grid | replay "), "{help}"); + assert!(help.contains("scenario save <имя> [fresh]"), "{help}"); + assert!(help.contains("wave <тип> [<кол-во> [<дальность>]]"), "{help}"); + assert!(help.ends_with("| stats"), "{help}"); + assert!(help.contains("export [fps=… size=… crf=… sec=… noaudio]"), "{help}"); + assert!(help.contains("deploy <комплекс>"), "{help}"); + } + + /// The nodes handed to the game are a tree the game accepts. + #[test] + fn the_nodes_are_flat_and_ordered() { + let nodes = tree().nodes(); + assert!(nodes.len() < 512, "{}", nodes.len()); + assert!(nodes.iter().enumerate().all(|(i, n)| n.children.iter().all(|&c| c as usize > i))); + } +} diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 641ab9b..62e73fa 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -13,6 +13,7 @@ use horizon_mod_sdk::{ }; mod cinema; +mod commands; mod layout; mod film; mod monitor; @@ -1368,7 +1369,10 @@ impl AirDef { } } -const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|film [best|all|N]|export [имя] [best|all|N] [fps=60 size=1920x1080 crf=14 sec=N noaudio]|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; +/// The help line: generated from the command tree (`commands.rs`), which the game checks lines with. +fn help() -> String { + commands::help() +} impl Mod for AirDef { fn init() -> Result<(Self, Registration), String> { @@ -1422,6 +1426,8 @@ impl Mod for AirDef { me.block_log = Some(render::world_log_start()); me.sim.shader = true; me.apply_far(); + // Subcommands, typed arguments and the saved scenarios' names for the chat box (#98). + commands::register(&scenario::saved_names()); Ok((me, Registration::new().command("airdef").on_tick())) } @@ -1946,7 +1952,7 @@ impl Mod for AirDef { self.sim.crashes, self.ground.world.text() )), - _ => return Err(HELP.to_string()), + _ => return Err(help()), }; self.emit(out); Ok(reply) diff --git a/airdef-mod/src/scenario.rs b/airdef-mod/src/scenario.rs index 3235bfd..1987798 100644 --- a/airdef-mod/src/scenario.rs +++ b/airdef-mod/src/scenario.rs @@ -20,6 +20,16 @@ fn valid(name: &str) -> Result { Ok(n) } +/// The saved scenarios' names. +pub(crate) fn saved_names() -> Vec { + names() +} + +/// Tells the chat box the names now saved, so Tab offers them. +fn publish(all: &[String]) { + command::set_suggestions(commands::SCENARIOS, all); +} + fn names() -> Vec { storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default() } @@ -82,11 +92,12 @@ impl AirDef { let fresh = flags.contains(&"fresh"); let text = self.scenario_text(&name, fresh); storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?; - let mut all = names(); - if !all.contains(&name) { - all.push(name.clone()); + let before = names(); + let all = commands::names_after_save(before.clone(), &name); + if all != before { storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?; } + publish(&all); Ok(format!( "Сценарий «{name}» сохранён: комплексов {}, объектов {}{}", self.sim.sams.len(), @@ -109,13 +120,14 @@ impl AirDef { } ["delete", name] => { let name = valid(name)?; - let mut all = names(); + let all = names(); if !all.contains(&name) { return Err(format!("Нет сценария «{name}»")); } - all.retain(|n| *n != name); + let all = commands::names_after_delete(all, &name); storage::delete(&key(&name)); storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?; + publish(&all); Ok(format!("Сценарий «{name}» удалён")) } _ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()), diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index 3cc004f..7102404 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/35] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/36] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index e6b85a5..c464889 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/35] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/36] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index a75465c..3f6f6dc 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/35] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/36] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index 0bd624f..d64e24d 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/35] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/36] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index aba161b..f300824 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/35] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/36] Mod cameras: render.set-views draws the world from other points into pictures that HUD view shapes show (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 22e6370..4f6a7ba 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/35] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/36] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index ba9d89c..696a5d7 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/35] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/36] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index cc48487..aff487a 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/35] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/36] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 63ff37c..c7c3721 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/35] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/36] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index 94ba7cd..e833ca5 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/35] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/36] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index fe0cd30..0d4a2d2 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/35] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/36] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index ce9009a..6413832 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/35] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/36] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index fc577c2..c72845e 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/35] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/36] HD: bind groups cached until a resize or a new source, shadow passes draw only the entities that cast shadows Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index d680c0e..a39e82a 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/35] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/36] HD: lights culled per 16x16 tile (surface and halo lists built on the CPU), a naga test for the lighting shaders Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index 435dbb1..dff1c54 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/35] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/36] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index 6aa16a8..0df9d22 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/35] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/36] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index 33ea6b6..e6a95ae 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/35] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/36] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index cff6449..1a21730 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/35] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/36] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index c2f6b5d..e4dd294 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/35] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/36] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index 450cc1a..a81d06c 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/35] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/36] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index 2863c28..c4e5725 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/35] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/36] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 8e7d0a2..3757ce6 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/35] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/36] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch index 1e6f7e3..6b765ca 100644 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/35] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/36] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun tint, blue hour; MIT), volumetric clouds with ground shadows instead of vanilla's in HD, volumetric fog with sun shafts and morning mist (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 1208d3b..8af4dc3 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/35] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/36] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index e8d07c9..8a3eafe 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/35] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/36] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 9aa0081..fb93d05 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/35] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/36] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index 6b21e06..41aa70c 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/35] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/36] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index 308bdc6..d025681 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/35] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/36] HD: volumetric smoke and missile trails with clear tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun speculars by material, wet surfaces and puddles in rain, wind in foliage (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index 8d38559..a7b3820 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/35] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/36] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index d52fa77..73de521 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/35] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/36] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch index 46228dd..6b060fb 100644 --- a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -1,7 +1,7 @@ From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:59:41 +0300 -Subject: [PATCH 31/35] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/36] Mods: render.set-main-view (API level 15) renders the screen's world from a mod camera with the full pipeline, so replays get HD and the post effects; small mod views get the post pass with their own camera diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch index 99d8e06..15b60e3 100644 --- a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -1,7 +1,7 @@ From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 -Subject: [PATCH 32/35] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/36] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); diff --git a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch index db031d3..078d380 100644 --- a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch +++ b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch @@ -1,7 +1,7 @@ From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 14:30:39 +0300 -Subject: [PATCH 33/35] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/36] HD clouds and fog polish: cloud panorama, cirrus, terrain-aware fog, far smoke cascade; replay world log and film clock; material overlays by sprite name diff --git a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch index 24e7aa7..037e3e2 100644 --- a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch +++ b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch @@ -1,7 +1,7 @@ From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 -Subject: [PATCH 34/35] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/36] HD: coloured sun through glass and leaves, rain ripples on water, film motion blur and depth of field Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch index e7566a8..27e1f11 100644 --- a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch +++ b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch @@ -1,7 +1,7 @@ From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:19:32 +0300 -Subject: [PATCH 35/35] Film export: every frame in flight reaches the writer, +Subject: [PATCH 35/36] Film export: every frame in flight reaches the writer, listener follows the main view, cloud wind on film frames, set-smoke reports real activity diff --git a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch new file mode 100644 index 0000000..697e257 --- /dev/null +++ b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch @@ -0,0 +1,1925 @@ +From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 15:43:11 +0300 +Subject: [PATCH 36/36] Mod command trees: command.set-tree and + set-suggestions, typed parse, colouring and completion from one tree (API 19) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/game/commands.rs | 2 +- + crates/horizon-client/src/game/mods.rs | 129 +++- + crates/horizon-command/src/args.rs | 9 + + crates/horizon-command/src/lib.rs | 23 + + crates/horizon-command/src/mod_tree.rs | 244 ++++++++ + crates/horizon-command/src/tests.rs | 163 +++++ + crates/horizon-mod-sdk/src/lib.rs | 1 + + crates/horizon-mod-sdk/src/tree.rs | 583 ++++++++++++++++++ + crates/horizon-mod-sdk/wit/horizon.wit | 73 +++ + crates/horizon-mods/src/api_level.rs | 7 +- + crates/horizon-mods/src/runtime.rs | 267 ++++++++ + crates/horizon-mods/tests/sandbox.rs | 31 + + crates/horizon-mods/tests/ui_guest/src/lib.rs | 12 + + docs/api/mods-reference.md | 52 ++ + docs/api/mods.md | 7 + + 15 files changed, 1598 insertions(+), 5 deletions(-) + create mode 100644 crates/horizon-command/src/mod_tree.rs + create mode 100644 crates/horizon-mod-sdk/src/tree.rs + +diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs +index ea628917e..5c59b7f22 100644 +--- a/crates/horizon-client/src/game/commands.rs ++++ b/crates/horizon-client/src/game/commands.rs +@@ -135,7 +135,7 @@ impl Game<'_> { + self.command_source = outer; + ok + } +- fn syntax_failure(&mut self, e: &CommandError, command: &str) { ++ pub(super) fn syntax_failure(&mut self, e: &CommandError, command: &str) { + self.command_failure(e.message.clone()); + let Some(input) = &e.input else { return }; + let cursor = e.cursor.min(input.len()); +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index b4d332f41..b524ff83f 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -19,6 +19,7 @@ use { + super::Screen, + super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent}, + horizon_client::{ ++ command::mod_tree::{ModKind, ModNode, ModOption, ModValue}, + extras::Corner, + extras_hud, + items::ItemStack, +@@ -62,6 +63,9 @@ pub(super) struct ModsState { + /// The chat's command tree while a running mod owns commands. + #[cfg(feature = "mods")] + tree: Option, ++ /// The mods' `Mods::tree_generation` the trees were built at. ++ #[cfg(feature = "mods")] ++ tree_generation: u64, + /// A server mod runs here: what players do is queued for it. + #[cfg(feature = "mods")] + pub(super) server: bool, +@@ -188,6 +192,54 @@ impl ModsState { + #[cfg(feature = "mods")] + const RELOAD_CHECK: Duration = Duration::from_secs(1); + ++/// What the running mods' trees say, as the chat's tree takes them: a `names` ++/// node as the words its list holds now. ++#[cfg(feature = "mods")] ++fn mod_trees(mods: &Mods) -> Vec<(String, Vec)> { ++ mods.command_trees() ++ .iter() ++ .map(|t| (t.name.to_owned(), mod_nodes(t.nodes, |l| t.list(l).to_vec()))) ++ .collect() ++} ++ ++/// `nodes` of `command.set-tree` as the chat's [`ModNode`]s; `list` gives the ++/// words of a `names` list. ++#[cfg(feature = "mods")] ++fn mod_nodes(nodes: &[horizon_mods::TreeNode], list: impl Fn(&str) -> Vec) -> Vec { ++ use horizon_mods::{TreeNodeKind as K, TreeValueKind as V}; ++ let value = |v: &V| match v { ++ V::Word(words) => ModValue::Word(words.clone()), ++ V::Choice(words) => ModValue::Choice(words.clone()), ++ V::Integer((min, max)) => ModValue::Integer(*min, *max), ++ V::Number((min, max)) => ModValue::Number(*min, *max), ++ V::Names(name) => ModValue::Word(list(name)), ++ V::BlockPos => ModValue::BlockPos, ++ }; ++ nodes ++ .iter() ++ .map(|n| ModNode { ++ name: n.name.clone(), ++ kind: n.kind.as_ref().map(|k| match k { ++ K::Value(v) => ModKind::Value(value(v)), ++ K::Text => ModKind::Text, ++ K::Options(o) => ModKind::Options { ++ entries: o ++ .entries ++ .iter() ++ .map(|e| ModOption { ++ key: e.key.clone(), ++ value: e.value.as_ref().map(value), ++ }) ++ .collect(), ++ free: o.free.clone(), ++ }, ++ }), ++ children: n.children.iter().map(|&c| c as usize).collect(), ++ runs: n.runs, ++ }) ++ .collect() ++} ++ + /// A vanilla command name: mods may not take it. + #[cfg(feature = "mods")] + fn vanilla_command(name: &str) -> bool { +@@ -396,22 +448,33 @@ impl Game<'_> { + // here (the server parses their arguments). + let server = self.mods.mods.server_commands().map(|c| c.name.as_str()); + let mut names = self.mods.mods.commands().chain(server).peekable(); ++ let trees = mod_trees(&self.mods.mods); + self.mods.tree = names + .peek() + .is_some() +- .then(|| vanilla::game_dispatcher().with_mod_commands(names)); ++ .then(|| vanilla::game_dispatcher().with_mod_trees(names, &trees)); ++ self.mods.tree_generation = self.mods.mods.tree_generation(); + self.merge_remote_tree(); + } + ++ /// Rebuilds the chat's tree when a mod changed its tree or a list of ++ /// suggestions (a saved scenario, a deleted one). ++ fn refresh_mod_trees(&mut self) { ++ if self.mods.tree_generation != self.mods.mods.tree_generation() { ++ self.sync_mod_commands(); ++ } ++ } ++ + /// The server's command tree with the mods' commands, for the chat. + pub(super) fn merge_remote_tree(&mut self) { + let names: Vec = self.mods.mods.commands().map(str::to_owned).collect(); ++ let trees = mod_trees(&self.mods.mods); + self.mods.remote_tree = self + .remote + .as_ref() + .and_then(|r| r.commands.as_ref()) + .filter(|_| !names.is_empty()) +- .map(|t| t.with_mod_commands(names.iter().map(String::as_str))); ++ .map(|t| t.with_mod_trees(names.iter().map(String::as_str), &trees)); + } + + /// After the options changed: restart the mods if the list of the ones +@@ -643,6 +706,7 @@ impl Game<'_> { + }) + }); + self.mods.mods.tick(self.ticks); ++ self.refresh_mod_trees(); + self.update_mod_models(); + self.show_mod_output(); + } +@@ -792,12 +856,23 @@ impl Game<'_> { + if !self.mods.mods.is_running() { + return false; + } ++ // The mod's own tree decides whether the line reaches it: the error ++ // is the one the chat box showed while the line was typed. ++ let name = command.split(' ').next().unwrap_or_default(); ++ if let Some(tree) = &self.mods.tree ++ && self.mods.mods.commands().any(|c| c == name) ++ && let Err(e) = tree.parse(command, &self.ui.registries).executable() ++ { ++ self.syntax_failure(&e, command); ++ return true; ++ } + // The command may follow a teleport in the same tick. + let snapshot = self.mod_snapshot(); + self.mods.mods.set_snapshot(snapshot); + if !self.mods.mods.command(command) { + return false; + } ++ self.refresh_mod_trees(); + self.show_mod_output(); + true + } +@@ -1400,3 +1475,53 @@ const MOD_UI_COLUMN: f32 = 200.0; + /// A key row's height with its gap, as the widgets below it space out. + #[cfg(feature = "mods")] + const MOD_UI_KEY_ROW: f32 = 26.0; ++ ++#[cfg(all(test, feature = "mods"))] ++mod tree_tests { ++ use super::*; ++ use horizon_mods::{TreeNode, TreeNodeKind, TreeValueKind}; ++ ++ /// What a mod's `command.set-tree` says becomes the chat's tree: a `names` ++ /// node follows its list as the mod changes it, and the line the tree ++ /// refuses is the one the dispatch refuses. ++ #[test] ++ fn a_registered_tree_becomes_the_chats_tree_with_its_lists() { ++ let node = |name: &str, kind, children: Vec, runs| TreeNode { ++ name: name.into(), ++ kind, ++ children, ++ runs, ++ }; ++ let nodes = vec![ ++ node("", None, vec![1], false), ++ node("load", None, vec![2], false), ++ node( ++ "name", ++ Some(TreeNodeKind::Value(TreeValueKind::Names("scenarios".into()))), ++ vec![], ++ true, ++ ), ++ ]; ++ let build = |words: &[&str]| { ++ let list = |_: &str| words.iter().map(|w| (*w).to_owned()).collect::>(); ++ vanilla::game_dispatcher() ++ .with_mod_trees(["tool"], &[("tool".to_owned(), mod_nodes(&nodes, list))]) ++ }; ++ let reg = horizon_client::command::Registries::default(); ++ let complete = |d: &Dispatcher, line: &str| -> Vec { ++ let parse = d.parse(line, ®); ++ d.client_suggestions(&parse, line.len(), ®) ++ .1 ++ .into_iter() ++ .map(|s| s.text) ++ .collect() ++ }; ++ assert_eq!(complete(&build(&["a", "b"]), "tool load "), ["a", "b"]); ++ assert_eq!(complete(&build(&["b"]), "tool load "), ["b"]); ++ let d = build(&[]); ++ assert!(d.parse("tool load x", ®).executable().is_ok()); ++ assert!(d.parse("tool load", ®).executable().is_err()); ++ assert!(d.parse("tool", ®).executable().is_err()); ++ assert!(d.parse("tool nothing", ®).executable().is_err()); ++ } ++} +diff --git a/crates/horizon-command/src/args.rs b/crates/horizon-command/src/args.rs +index 9b665e25b..a321393fc 100644 +--- a/crates/horizon-command/src/args.rs ++++ b/crates/horizon-command/src/args.rs +@@ -139,6 +139,11 @@ pub enum ArgType { + ResourceOrId(&'static str), + /// ResourceSelectorArgument: a `*`/`?` pattern over a registry's ids. + ResourceSelector(&'static str), ++ /// A mod command's word (`mod_tree`): a non-empty unquoted word, one of a ++ /// set when it is strict, suggested from the set otherwise. ++ ModWord(std::sync::Arc), ++ /// A mod command's `key=value` options and flags, to the end of the line. ++ ModOptions(std::sync::Arc), + } + + /// FilenameUtils.wildcardMatch (case-sensitive) of `*` and `?` patterns. +@@ -1575,6 +1580,8 @@ impl ArgType { + } + Arg::Str(reader.string[start..reader.cursor].to_owned()) + } ++ ArgType::ModWord(set) => Arg::Str(set.parse(reader)?), ++ ArgType::ModOptions(set) => Arg::Str(set.parse(reader)?), + ArgType::Enum(words) => { + let word = reader.read_unquoted_string(); + if !words.contains(&word) { +@@ -1776,6 +1783,8 @@ impl ArgType { + } + ArgType::Operation => builder.suggest_matching(OPERATIONS), + ArgType::Enum(words) => builder.suggest_matching(words.iter().copied()), ++ ArgType::ModWord(set) => builder.suggest_matching(set.words.iter().map(String::as_str)), ++ ArgType::ModOptions(set) => set.suggest(builder), + ArgType::Particle => { + let known = reg.ids("minecraft:particle_type"); + if known.is_empty() { +diff --git a/crates/horizon-command/src/lib.rs b/crates/horizon-command/src/lib.rs +index 838c8de87..fbecdcd97 100644 +--- a/crates/horizon-command/src/lib.rs ++++ b/crates/horizon-command/src/lib.rs +@@ -9,6 +9,7 @@ pub mod block; + pub mod codec; + pub mod component; + pub mod item; ++pub mod mod_tree; + pub mod network; + pub mod packrat; + pub mod selector; +@@ -763,6 +764,28 @@ impl Dispatcher { + } + d + } ++ /// [`Dispatcher::with_mod_commands`], where the commands named in `trees` ++ /// have the subcommands and typed arguments their mod described ++ /// ([`mod_tree`]) instead of free text. ++ pub fn with_mod_trees<'a>( ++ &self, ++ names: impl IntoIterator, ++ trees: &[(String, Vec)], ++ ) -> Dispatcher { ++ let mut d = self.clone(); ++ for name in names { ++ let command = d.literal(0, name); ++ match trees.iter().find(|(n, _)| n == name) { ++ Some((_, nodes)) if !nodes.is_empty() => mod_tree::build(&mut d, command, nodes), ++ _ => { ++ d.executes(command, MOD_COMMAND); ++ let args = d.argument(command, "args", ArgType::Greedy); ++ d.executes(args, MOD_COMMAND); ++ } ++ } ++ } ++ d ++ } + /// A root literal by name. + pub fn find(&self, name: &str) -> Option { + self.nodes[0] +diff --git a/crates/horizon-command/src/mod_tree.rs b/crates/horizon-command/src/mod_tree.rs +new file mode 100644 +index 000000000..e336b9fe5 +--- /dev/null ++++ b/crates/horizon-command/src/mod_tree.rs +@@ -0,0 +1,244 @@ ++//! The command trees client mods describe (`command.set-tree`): literal ++//! branches, typed words and numbers with bounds, optional branches, option ++//! lists, put into a [`Dispatcher`] so the chat colours, completes and ++//! checks them as it does vanilla's commands, and so the same parse decides ++//! whether a line reaches the mod. ++use crate::{ArgType, CmdResult, CommandError, Dispatcher, MOD_COMMAND, Reader, SuggestionsBuilder}; ++use horizon_client_core::text::Text; ++use std::sync::Arc; ++ ++/// What one word takes. ++#[derive(Clone, Debug, PartialEq)] ++pub enum ModValue { ++ /// Any word; the given ones are suggested (a mod's `names` list included). ++ Word(Vec), ++ Integer(Option, Option), ++ Number(Option, Option), ++ /// One of these words. ++ Choice(Vec), ++ BlockPos, ++} ++#[derive(Clone, Debug, PartialEq)] ++pub struct ModOption { ++ pub key: String, ++ /// None: a bare flag. ++ pub value: Option, ++} ++#[derive(Clone, Debug, PartialEq)] ++pub enum ModKind { ++ Value(ModValue), ++ /// The rest of the line. ++ Text, ++ Options { ++ entries: Vec, ++ /// Words that are no option, with the ones suggested; none refuses them. ++ free: Option>, ++ }, ++} ++/// A node of a flat tree: `children` index later nodes; node 0 follows the ++/// command's name. ++#[derive(Clone, Debug, PartialEq)] ++pub struct ModNode { ++ pub name: String, ++ /// None: a literal. ++ pub kind: Option, ++ pub children: Vec, ++ pub runs: bool, ++} ++ ++/// A word, and the words it may or must be. ++#[derive(Debug, PartialEq)] ++pub struct WordSet { ++ pub words: Vec, ++ pub strict: bool, ++} ++impl WordSet { ++ pub fn parse(&self, reader: &mut Reader) -> CmdResult { ++ let start = reader.cursor; ++ // A word is whatever stands up to the next space (a mod splits its line the same way). ++ let rest = reader.remaining(); ++ let word = &rest[..rest.find(' ').unwrap_or(rest.len())]; ++ reader.cursor += word.len(); ++ let fail = |reader: &mut Reader, text: String| { ++ reader.cursor = start; ++ Err(CommandError::at(Text::literal(text), reader)) ++ }; ++ if word.is_empty() { ++ return fail(reader, "Expected a word".into()); ++ } ++ if self.strict && !self.words.iter().any(|w| w == word) { ++ let list = self.words.join(", "); ++ return fail(reader, format!("Expected one of: {list}")); ++ } ++ Ok(word.to_owned()) ++ } ++} ++ ++/// `key=value` options and flags, taking the rest of the line in any order. ++#[derive(Debug, PartialEq)] ++pub struct OptionSet { ++ pub entries: Vec, ++ pub free: Option>, ++} ++impl OptionSet { ++ fn entry(&self, key: &str) -> Option<&ModOption> { ++ self.entries.iter().find(|e| e.key == key) ++ } ++ /// The error for a value, or None when it is fine. ++ fn check(key: &str, kind: &ModValue, value: &str) -> Option { ++ match kind { ++ ModValue::Integer(min, max) => match value.parse::() { ++ Err(_) => Some(format!("{key}: expected a whole number, found '{value}'")), ++ Ok(n) if min.is_some_and(|m| n < m) => { ++ Some(format!("{key}: must not be less than {}, found {n}", min.unwrap_or(0))) ++ } ++ Ok(n) if max.is_some_and(|m| n > m) => { ++ Some(format!("{key}: must not be more than {}, found {n}", max.unwrap_or(0))) ++ } ++ Ok(_) => None, ++ }, ++ ModValue::Number(min, max) => match value.parse::() { ++ Ok(n) if n.is_finite() && min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m) => None, ++ _ => Some(format!("{key}: expected a number in range, found '{value}'")), ++ }, ++ ModValue::Choice(words) if !words.iter().any(|w| w == value) => { ++ Some(format!("{key}: expected one of: {}", words.join(", "))) ++ } ++ ModValue::Word(_) | ModValue::Choice(_) if value.is_empty() => { ++ Some(format!("{key}: expected a value")) ++ } ++ _ => None, ++ } ++ } ++ pub fn parse(&self, reader: &mut Reader) -> CmdResult { ++ let text = reader.remaining(); ++ let base = reader.cursor; ++ let mut used: Vec<&str> = Vec::new(); ++ let mut at = 0; ++ for token in text.split(' ') { ++ let start = at; ++ at += token.len() + 1; ++ if token.is_empty() { ++ continue; ++ } ++ let fail = |reader: &mut Reader, message: String| { ++ reader.cursor = base + start; ++ Err(CommandError::at(Text::literal(message), reader)) ++ }; ++ let (key, value) = match token.split_once('=') { ++ Some((k, v)) => (k, Some(v)), ++ None => (token, None), ++ }; ++ match (self.entry(key), value) { ++ (Some(e), v) => { ++ if used.contains(&key) { ++ return fail(reader, format!("'{key}' twice")); ++ } ++ match (&e.value, v) { ++ (None, None) => {} ++ (None, Some(_)) => return fail(reader, format!("'{key}' takes no value")), ++ (Some(_), None) => { ++ return fail(reader, format!("'{key}' needs a value: {key}=...")); ++ } ++ (Some(kind), Some(v)) => { ++ if let Some(m) = Self::check(key, kind, v) { ++ return fail(reader, m); ++ } ++ } ++ } ++ used.push(key); ++ } ++ (None, _) if self.free.is_some() && value.is_none() => {} ++ (None, _) => return fail(reader, format!("Unknown option '{token}'")), ++ } ++ } ++ reader.cursor = reader.string.len(); ++ Ok(text.to_owned()) ++ } ++ /// Candidates for the word being typed at the end of the line. ++ pub fn suggest(&self, builder: &mut SuggestionsBuilder) { ++ let rest = builder.remaining(); ++ let token_start = builder.start + rest.rfind(' ').map_or(0, |i| i + 1); ++ let before = &rest[..token_start - builder.start]; ++ let used: Vec<&str> = before ++ .split(' ') ++ .filter(|t| !t.is_empty()) ++ .map(|t| t.split('=').next().unwrap_or(t)) ++ .collect(); ++ let mut b = builder.offset(token_start); ++ let typed = b.remaining().to_owned(); ++ if let Some((key, value)) = typed.split_once('=') { ++ // A value: the choices of a valued option, after its `key=`. ++ if let Some(ModOption { value: Some(ModValue::Choice(words) | ModValue::Word(words)), .. }) = self.entry(key) { ++ let mut v = builder.offset(token_start + key.len() + 1); ++ let _ = value; ++ v.suggest_matching(words.iter().map(String::as_str)); ++ builder.add(v); ++ } ++ return; ++ } ++ for e in &self.entries { ++ if used.contains(&e.key.as_str()) { ++ continue; ++ } ++ let text = if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() }; ++ if text.starts_with(&typed) { ++ b.suggest(text); ++ } ++ } ++ if let Some(free) = &self.free { ++ b.suggest_matching(free.iter().map(String::as_str)); ++ } ++ builder.add(b); ++ } ++} ++ ++fn value_type(kind: &ModValue) -> ArgType { ++ match kind { ++ ModValue::Word(words) => ArgType::ModWord(Arc::new(WordSet { words: words.clone(), strict: false })), ++ ModValue::Choice(words) => ArgType::ModWord(Arc::new(WordSet { words: words.clone(), strict: true })), ++ ModValue::Integer(min, max) => ArgType::Integer { ++ min: min.unwrap_or(i32::MIN), ++ max: max.unwrap_or(i32::MAX), ++ }, ++ ModValue::Number(min, max) => ArgType::Double { ++ min: min.unwrap_or(f64::MIN), ++ max: max.unwrap_or(f64::MAX), ++ }, ++ ModValue::BlockPos => ArgType::BlockPos, ++ } ++} ++ ++/// Adds the tree `nodes` below the command's literal `command`. ++pub fn build(d: &mut Dispatcher, command: usize, nodes: &[ModNode]) { ++ fn add(d: &mut Dispatcher, parent: usize, nodes: &[ModNode], i: usize) { ++ let n = &nodes[i]; ++ let id = match &n.kind { ++ None => d.literal(parent, &n.name), ++ Some(ModKind::Value(v)) => d.argument(parent, &n.name, value_type(v)), ++ Some(ModKind::Text) => d.argument(parent, &n.name, ArgType::Greedy), ++ Some(ModKind::Options { entries, free }) => d.argument( ++ parent, ++ &n.name, ++ ArgType::ModOptions(Arc::new(OptionSet { entries: entries.clone(), free: free.clone() })), ++ ), ++ }; ++ if n.runs { ++ d.executes(id, MOD_COMMAND); ++ } ++ for &c in &n.children { ++ if c > i && c < nodes.len() { ++ add(d, id, nodes, c); ++ } ++ } ++ } ++ let root = &nodes[0]; ++ if root.runs { ++ d.executes(command, MOD_COMMAND); ++ } ++ for &c in &root.children { ++ if c > 0 && c < nodes.len() { ++ add(d, command, nodes, c); ++ } ++ } ++} +diff --git a/crates/horizon-command/src/tests.rs b/crates/horizon-command/src/tests.rs +index d61fd8ae1..d3a7ce678 100644 +--- a/crates/horizon-command/src/tests.rs ++++ b/crates/horizon-command/src/tests.rs +@@ -610,3 +610,166 @@ fn horizon_commands_are_off_unless_the_server_turns_them_on() { + ); + } + } ++ ++/// A tree like AirDef's: subcommands, a bounded number, a choice, names that ++/// change, optional words and `key=value` options. ++fn demo_tree(scenarios: &[&str]) -> Vec { ++ use mod_tree::{ModKind::*, ModNode, ModOption, ModValue::*}; ++ let words = |w: &[&str]| w.iter().map(|s| (*s).to_owned()).collect::>(); ++ let lit = |name: &str, children: &[usize], runs: bool| ModNode { ++ name: name.into(), ++ kind: None, ++ children: children.to_vec(), ++ runs, ++ }; ++ let arg = |name: &str, kind, children: &[usize], runs: bool| ModNode { ++ name: name.into(), ++ kind: Some(kind), ++ children: children.to_vec(), ++ runs, ++ }; ++ vec![ ++ lit("", &[1, 2, 4, 8, 11, 12], true), // 0 ++ lit("menu", &[], true), // 1 ++ lit("wave", &[3], false), // 2 ++ arg("kind", Value(Choice(words(&["shahed", "mix"]))), &[5], true), // 3 ++ lit("scenario", &[6, 7], false), // 4 ++ arg("count", Value(Integer(Some(1), Some(80))), &[9], true), // 5 ++ lit("list", &[], true), // 6 ++ lit("load", &[10], false), // 7 ++ lit("replay", &[13, 14], true), // 8 ++ arg("range", Value(Number(Some(60.0), Some(800.0))), &[], true), // 9 ++ arg("name", Value(Word(words(scenarios))), &[], true), // 10 ++ lit("why", &[15], true), // 11 ++ lit("hold", &[16], false), // 12 ++ lit("map", &[], true), // 13 ++ lit("export", &[17], true), // 14 ++ arg("number", Value(Integer(Some(1), None)), &[], true), // 15 ++ arg("on", Value(Choice(words(&["on", "off"]))), &[], true), // 16 ++ arg( ++ "options", ++ Options { ++ entries: vec![ ++ ModOption { key: "fps".into(), value: Some(Integer(Some(1), Some(240))) }, ++ ModOption { key: "size".into(), value: Some(Word(vec![])) }, ++ ModOption { key: "noaudio".into(), value: None }, ++ ], ++ free: Some(words(&["best", "all"])), ++ }, ++ &[], ++ true, ++ ), // 17 ++ ] ++} ++ ++fn demo(scenarios: &[&str]) -> Dispatcher { ++ vanilla::game_dispatcher().with_mod_trees( ++ ["airdef", "plain"], ++ &[("airdef".to_owned(), demo_tree(scenarios))], ++ ) ++} ++ ++fn runs(d: &Dispatcher, line: &str) -> Result<(), usize> { ++ let parse = d.parse(line, ®()); ++ match parse.executable() { ++ Ok((_, id)) => { ++ assert_eq!(id, MOD_COMMAND); ++ Ok(()) ++ } ++ Err(e) => Err(e.cursor), ++ } ++} ++ ++#[test] ++fn a_mod_tree_parses_every_branch_and_refuses_the_rest() { ++ let d = demo(&[]); ++ for line in [ ++ "airdef", ++ "airdef menu", ++ "airdef wave mix", ++ "airdef wave shahed 5 260.5", ++ "airdef scenario list", ++ "airdef scenario load abc", ++ "airdef replay", ++ "airdef replay map", ++ "airdef replay export", ++ "airdef replay export mine best fps=30 size=1280x720 noaudio", ++ "airdef why 3", ++ "airdef hold off", ++ ] { ++ assert_eq!(runs(&d, line), Ok(()), "{line}"); ++ } ++ // The cursor is at the first thing wrong. ++ assert_eq!(runs(&d, "airdef wave scud"), Err(12), "enum"); ++ assert_eq!(runs(&d, "airdef wave shahed 0"), Err(19), "below the bound"); ++ assert_eq!(runs(&d, "airdef wave shahed 81"), Err(19), "above the bound"); ++ assert_eq!(runs(&d, "airdef wave shahed x"), Err(19), "not a number"); ++ assert_eq!(runs(&d, "airdef wave shahed 5 900"), Err(21), "number bound"); ++ assert_eq!(runs(&d, "airdef wave"), Err(11), "incomplete"); ++ assert_eq!(runs(&d, "airdef scenario load"), Err(20), "a name is needed"); ++ assert_eq!(runs(&d, "airdef hold maybe"), Err(12), "enum"); ++ assert_eq!(runs(&d, "airdef why 0"), Err(11), "bound"); ++ assert_eq!(runs(&d, "airdef nothing"), Err(7), "unknown subcommand"); ++ assert_eq!(runs(&d, "airdef menu now"), Err(12), "trailing words"); ++ assert_eq!( ++ runs(&d, "airdef replay export best fps=0"), ++ Err(26), ++ "option bound" ++ ); ++ assert_eq!(runs(&d, "airdef replay export fps=30 fps=60"), Err(28), "option twice"); ++ assert_eq!(runs(&d, "airdef replay export size"), Err(21), "option needs a value"); ++ assert_eq!(runs(&d, "airdef replay export noaudio=1"), Err(21), "a flag takes none"); ++} ++ ++#[test] ++fn a_mod_command_without_a_tree_stays_free_text() { ++ let d = demo(&[]); ++ for line in ["plain", "plain anything at all 1 2 3"] { ++ assert_eq!(runs(&d, line), Ok(()), "{line}"); ++ } ++ // Vanilla's commands are as they were. ++ assert!(d.find("gamemode").is_some()); ++} ++ ++#[test] ++fn a_mod_tree_completes_the_next_word() { ++ let d = demo(&["alpha", "beta"]); ++ let complete = |d: &Dispatcher, line: &str| -> Vec { ++ let parse = d.parse(line, ®()); ++ d.client_suggestions(&parse, line.len(), ®()) ++ .1 ++ .into_iter() ++ .map(|s| s.text) ++ .collect() ++ }; ++ let subs = complete(&d, "airdef "); ++ for expected in ["menu", "wave", "scenario", "replay", "why", "hold"] { ++ assert!(subs.contains(&expected.to_owned()), "{expected} in {subs:?}"); ++ } ++ assert_eq!(complete(&d, "airdef sc"), ["scenario"]); ++ assert_eq!(complete(&d, "airdef scenario "), ["list", "load"]); ++ assert_eq!(complete(&d, "airdef scenario load "), ["alpha", "beta"]); ++ assert_eq!(complete(&d, "airdef scenario load b"), ["beta"]); ++ assert_eq!(complete(&d, "airdef wave "), ["mix", "shahed"]); ++ assert_eq!(complete(&d, "airdef hold "), ["off", "on"]); ++ // The list is rebuilt with the data: a deleted name is gone, a new one there. ++ let d = demo(&["gamma"]); ++ assert_eq!(complete(&d, "airdef scenario load "), ["gamma"]); ++ assert_eq!(complete(&demo(&[]), "airdef scenario load "), Vec::::new()); ++ // Options: the ones not used yet, then a value's choices. ++ assert_eq!( ++ complete(&d, "airdef replay export "), ++ ["all", "best", "fps=", "noaudio", "size="] ++ ); ++ assert_eq!(complete(&d, "airdef replay export fps=30 n"), ["noaudio"]); ++ assert_eq!(complete(&d, "airdef replay export fps=30 "), ["all", "best", "noaudio", "size="]); ++} ++ ++#[test] ++fn the_usage_of_a_mod_tree_comes_from_its_nodes() { ++ let d = demo(&[]); ++ let airdef = d.find("airdef").unwrap(); ++ let usage: Vec = d.smart_usage(airdef, 0).into_iter().map(|(_, u)| u).collect(); ++ assert!(usage.iter().any(|u| u == "[wave] []"), "{usage:?}"); ++ assert!(usage.iter().any(|u| u == "[menu]"), "{usage:?}"); ++} +diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs +index 1e7120091..478efbd24 100644 +--- a/crates/horizon-mod-sdk/src/lib.rs ++++ b/crates/horizon-mod-sdk/src/lib.rs +@@ -39,6 +39,7 @@ wit_bindgen::generate!({ + + pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; + pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; ++pub mod tree; + pub use horizon::mods::{ + browser, chat, command, game, hud, input, log, recipes, render, storage, ui, + }; +diff --git a/crates/horizon-mod-sdk/src/tree.rs b/crates/horizon-mod-sdk/src/tree.rs +new file mode 100644 +index 000000000..208eee6b0 +--- /dev/null ++++ b/crates/horizon-mod-sdk/src/tree.rs +@@ -0,0 +1,583 @@ ++//! A command's tree: subcommands, typed words and numbers with bounds, ++//! optional branches (AirDef fork, API level 19). One description serves four ++//! uses: [`Tree::register`] gives it to the game, whose chat box colours, ++//! checks and completes the line before Enter; [`Tree::parse`] checks a line ++//! the way the game does (for the mod's own tests, and as a last guard); ++//! [`Tree::usage`] is the help line; [`Tree::complete`] lists what may come ++//! next. ++//! ++//! ```ignore ++//! use horizon_mod_sdk::tree::{Node, Tree, Value}; ++//! let tree = Tree::new("hello") ++//! .runs() ++//! .then(Node::lit("wave").then(Node::arg("count", Value::int(1, 10)).runs())) ++//! .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs())); ++//! tree.register()?; ++//! horizon_mod_sdk::command::set_suggestions("saves", &["a".into(), "b".into()]); ++//! ``` ++//! ++//! The mod's `command` hook still gets the line as it was typed and reads ++//! it itself (or from [`Tree::parse`]'s [`Parsed`]). ++ ++/// What one word takes. ++#[derive(Clone, Debug, PartialEq)] ++pub enum Value { ++ /// Any word (anything up to the next space); the given ones are suggested. ++ Word(Vec), ++ Integer(Option, Option), ++ Number(Option, Option), ++ /// One of these words. ++ Choice(Vec), ++ /// Any word, suggested from the list [`crate::command::set_suggestions`] ++ /// last gave under this name. ++ Names(String), ++ /// Three coordinates, each a whole number, `~` or `~n`. ++ BlockPos, ++} ++impl Value { ++ pub fn word() -> Self { ++ Value::Word(Vec::new()) ++ } ++ pub fn int(min: i32, max: i32) -> Self { ++ Value::Integer(Some(min), Some(max)) ++ } ++ pub fn int_from(min: i32) -> Self { ++ Value::Integer(Some(min), None) ++ } ++ pub fn number(min: f64, max: f64) -> Self { ++ Value::Number(Some(min), Some(max)) ++ } ++ pub fn choice>(words: impl IntoIterator) -> Self { ++ Value::Choice(words.into_iter().map(|w| w.as_ref().to_owned()).collect()) ++ } ++ pub fn names(list: &str) -> Self { ++ Value::Names(list.into()) ++ } ++} ++ ++/// A `key=value` option, or a flag. ++#[derive(Clone, Debug, PartialEq)] ++pub struct Opt { ++ pub key: String, ++ pub value: Option, ++} ++impl Opt { ++ pub fn new(key: &str, value: Value) -> Self { ++ Opt { key: key.into(), value: Some(value) } ++ } ++ pub fn flag(key: &str) -> Self { ++ Opt { key: key.into(), value: None } ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq)] ++enum Kind { ++ Literal, ++ Value(Value), ++ Text, ++ Options { entries: Vec, free: Option> }, ++} ++ ++/// A node: a literal word or an argument, what may follow, whether the line ++/// may end here. ++#[derive(Clone, Debug, PartialEq)] ++pub struct Node { ++ name: String, ++ kind: Kind, ++ children: Vec, ++ runs: bool, ++} ++impl Node { ++ fn new(name: &str, kind: Kind) -> Self { ++ Node { name: name.into(), kind, children: Vec::new(), runs: false } ++ } ++ /// A word to be typed as it is. ++ pub fn lit(word: &str) -> Self { ++ Self::new(word, Kind::Literal) ++ } ++ /// A typed argument; `name` is its hint (``). ++ pub fn arg(name: &str, value: Value) -> Self { ++ Self::new(name, Kind::Value(value)) ++ } ++ /// The rest of the line. ++ pub fn text(name: &str) -> Self { ++ Self::new(name, Kind::Text) ++ } ++ /// `key=value` options and flags, in any order, to the end of the line; ++ /// `free`: other words are accepted too (a name), with the ones suggested. ++ pub fn options(name: &str, entries: Vec, free: Option>) -> Self { ++ Self::new(name, Kind::Options { entries, free }) ++ } ++ /// The line may end here. ++ pub fn runs(mut self) -> Self { ++ self.runs = true; ++ self ++ } ++ /// What may follow (several: the first that fits wins). ++ pub fn then(mut self, child: Node) -> Self { ++ self.children.push(child); ++ self ++ } ++ /// Several of them. ++ pub fn with(mut self, children: impl IntoIterator) -> Self { ++ self.children.extend(children); ++ self ++ } ++ fn head(&self) -> String { ++ match &self.kind { ++ Kind::Literal => self.name.clone(), ++ Kind::Value(Value::Choice(words)) if words.len() <= 6 => words.join("|"), ++ _ => format!("<{}>", self.name), ++ } ++ } ++ fn usage(&self) -> String { ++ let mut out = match &self.kind { ++ Kind::Options { entries, .. } => { ++ let list: Vec = entries ++ .iter() ++ .map(|e| if e.value.is_some() { format!("{}=…", e.key) } else { e.key.clone() }) ++ .collect(); ++ list.join(" ") ++ } ++ _ => self.head(), ++ }; ++ if self.children.is_empty() { ++ return out; ++ } ++ let sub: Vec = self.children.iter().map(Node::usage).collect(); ++ let sub = sub.join("|"); ++ out.push(' '); ++ if self.runs { ++ out.push_str(&format!("[{sub}]")); ++ } else { ++ out.push_str(&sub); ++ } ++ out ++ } ++} ++ ++/// Why a line does not parse: the byte of `args` where it goes wrong and what ++/// the game's chat box says there. ++#[derive(Clone, Debug, PartialEq)] ++pub struct ParseError { ++ pub at: usize, ++ pub message: String, ++} ++impl std::fmt::Display for ParseError { ++ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ write!(f, "{} (символ {})", self.message, self.at + 1) ++ } ++} ++ ++/// A line that parsed: the literals it went through and each argument's text. ++#[derive(Clone, Debug, Default, PartialEq)] ++pub struct Parsed { ++ pub path: Vec, ++ pub values: Vec<(String, String)>, ++} ++impl Parsed { ++ /// The text of the argument `name`, if the line had it. ++ pub fn get(&self, name: &str) -> Option<&str> { ++ self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) ++ } ++} ++ ++fn number_text(s: &str) -> bool { ++ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || matches!(c, '-' | '.')) ++} ++ ++impl Value { ++ /// The tokens a value takes, and whether `tokens` fit it. ++ fn take(&self, tokens: &[(usize, &str)]) -> Result { ++ let Some(&(at, t)) = tokens.first() else { ++ return Err((0, "Ожидалось значение".into())); ++ }; ++ let bad = |m: String| Err((at, m)); ++ match self { ++ Value::Word(_) | Value::Names(_) if t.is_empty() => bad("Ожидалось слово".into()), ++ Value::Word(_) | Value::Names(_) => Ok(1), ++ Value::Choice(words) if words.iter().any(|w| w == t) => Ok(1), ++ Value::Choice(words) => bad(format!("Ожидалось одно из: {}", words.join(", "))), ++ Value::Integer(min, max) => match t.parse::().ok().filter(|_| number_text(t)) { ++ None => bad(format!("Ожидалось целое число, найдено «{t}»")), ++ Some(n) if min.is_some_and(|m| n < m) => { ++ bad(format!("Число не меньше {}, найдено {n}", min.unwrap_or(0))) ++ } ++ Some(n) if max.is_some_and(|m| n > m) => { ++ bad(format!("Число не больше {}, найдено {n}", max.unwrap_or(0))) ++ } ++ Some(_) => Ok(1), ++ }, ++ Value::Number(min, max) => match t.parse::().ok().filter(|n| n.is_finite() && number_text(t)) { ++ None => bad(format!("Ожидалось число, найдено «{t}»")), ++ Some(n) if min.is_some_and(|m| n < m) => { ++ bad(format!("Число не меньше {}, найдено {n}", min.unwrap_or(0.0))) ++ } ++ Some(n) if max.is_some_and(|m| n > m) => { ++ bad(format!("Число не больше {}, найдено {n}", max.unwrap_or(0.0))) ++ } ++ Some(_) => Ok(1), ++ }, ++ Value::BlockPos => { ++ for i in 0..3 { ++ let Some(&(at, t)) = tokens.get(i) else { ++ return Err((tokens.last().map_or(0, |l| l.0 + l.1.len()), "Нужны три координаты".into())); ++ }; ++ let rel = t.strip_prefix('~'); ++ let ok = match rel { ++ Some("") => true, ++ Some(n) => number_text(n) && n.parse::().is_ok(), ++ None => number_text(t) && t.parse::().is_ok(), ++ }; ++ if !ok { ++ return Err((at, format!("Координата: число, ~ или ~n, найдено «{t}»"))); ++ } ++ } ++ Ok(3) ++ } ++ } ++ } ++} ++ ++/// A command's tree; the root is the command's own name. ++#[derive(Clone, Debug, PartialEq)] ++pub struct Tree { ++ root: Node, ++} ++impl Tree { ++ /// The tree of `/name`. ++ pub fn new(name: &str) -> Self { ++ Tree { root: Node::lit(name) } ++ } ++ /// The bare command runs. ++ pub fn runs(mut self) -> Self { ++ self.root.runs = true; ++ self ++ } ++ pub fn then(mut self, child: Node) -> Self { ++ self.root.children.push(child); ++ self ++ } ++ pub fn with(mut self, children: impl IntoIterator) -> Self { ++ self.root.children.extend(children); ++ self ++ } ++ pub fn name(&self) -> &str { ++ &self.root.name ++ } ++ ++ /// The help line: `/name sub | sub arg | …`, from the same nodes the game ++ /// checks the line with. ++ pub fn usage(&self) -> String { ++ let sub: Vec = self.root.children.iter().map(Node::usage).collect(); ++ format!("/{} {}", self.root.name, sub.join(" | ")) ++ } ++ ++ /// Checks the arguments `args` of the command (after its name) as the ++ /// game's chat box does. ++ pub fn parse(&self, args: &str) -> Result { ++ let args = args.trim_end(); ++ let mut tokens: Vec<(usize, &str)> = Vec::new(); ++ let mut at = 0; ++ for t in args.split(' ') { ++ tokens.push((at, t)); ++ at += t.len() + 1; ++ } ++ // The command's name was followed by one space, which is no token. ++ if args.is_empty() { ++ tokens.clear(); ++ } ++ let mut node = &self.root; ++ let mut parsed = Parsed::default(); ++ let mut i = 0; ++ loop { ++ if i >= tokens.len() { ++ return if node.runs { ++ Ok(parsed) ++ } else { ++ Err(ParseError { at: args.len(), message: format!("Не хватает аргументов: {}", node.children.iter().map(Node::head).collect::>().join(" | ")) }) ++ }; ++ } ++ let (at, token) = tokens[i]; ++ if let Some(next) = node.children.iter().find(|c| c.kind == Kind::Literal && c.name == token) { ++ parsed.path.push(token.to_owned()); ++ node = next; ++ i += 1; ++ continue; ++ } ++ let mut first_error = None; ++ let mut matched = None; ++ for child in node.children.iter().filter(|c| c.kind != Kind::Literal) { ++ let rest = &tokens[i..]; ++ match &child.kind { ++ Kind::Value(v) => match v.take(rest) { ++ Ok(n) => { ++ matched = Some((child, n, false)); ++ break; ++ } ++ Err((bad_at, m)) => { ++ let bad_at = if bad_at == 0 { at } else { bad_at }; ++ first_error.get_or_insert(ParseError { at: bad_at, message: m }); ++ } ++ }, ++ Kind::Text => { ++ matched = Some((child, rest.len(), true)); ++ break; ++ } ++ Kind::Options { entries, free } => match check_options(entries, free.as_deref(), rest) { ++ Ok(()) => { ++ matched = Some((child, rest.len(), true)); ++ break; ++ } ++ Err(e) => { ++ first_error.get_or_insert(e); ++ } ++ }, ++ Kind::Literal => {} ++ } ++ } ++ let Some((child, n, rest_of_line)) = matched else { ++ return Err(first_error.unwrap_or_else(|| ParseError { ++ at, ++ message: if node.children.is_empty() { ++ format!("Лишнее: «{token}»") ++ } else { ++ let lits: Vec<&str> = node.children.iter().filter(|c| c.kind == Kind::Literal).map(|c| c.name.as_str()).collect(); ++ format!("Неизвестная ветка «{token}»: {}", lits.join(", ")) ++ }, ++ })); ++ }; ++ let text = if rest_of_line { args[at..].to_owned() } else { tokens[i..i + n].iter().map(|t| t.1).collect::>().join(" ") }; ++ parsed.values.push((child.name.clone(), text)); ++ node = child; ++ i += n; ++ } ++ } ++ ++ /// What may complete the word being typed at the end of `args` (the line ++ /// after the command's name): literals and choices that start with it, ++ /// the words of a `names` list (`lists` gives them), options not used yet. ++ pub fn complete(&self, args: &str, lists: &dyn Fn(&str) -> Vec) -> Vec { ++ let (done, typing) = match args.rfind(' ') { ++ Some(i) => (&args[..i], &args[i + 1..]), ++ None => ("", args), ++ }; ++ // Walk the finished words with the same parse, as far as they go. ++ let mut node = &self.root; ++ let mut tokens: Vec<&str> = if args.contains(' ') { done.split(' ').collect() } else { Vec::new() }; ++ let mut rest_start = 0; ++ while rest_start < tokens.len() { ++ let t = tokens[rest_start]; ++ if let Some(next) = node.children.iter().find(|c| c.kind == Kind::Literal && c.name == t) { ++ node = next; ++ rest_start += 1; ++ continue; ++ } ++ let rest: Vec<(usize, &str)> = tokens[rest_start..].iter().map(|t| (0, *t)).collect(); ++ let hit = node.children.iter().filter(|c| c.kind != Kind::Literal).find_map(|c| match &c.kind { ++ Kind::Value(v) => v.take(&rest).ok().map(|n| (c, n)), ++ Kind::Text | Kind::Options { .. } => Some((c, rest.len())), ++ Kind::Literal => None, ++ }); ++ match hit { ++ Some((c, n)) if !matches!(c.kind, Kind::Text) => { ++ node = c; ++ if matches!(c.kind, Kind::Options { .. }) { ++ // Its words are the options so far; the one typed is the next. ++ break; ++ } ++ rest_start += n; ++ } ++ _ => return Vec::new(), ++ } ++ } ++ let used: Vec<&str> = tokens.drain(rest_start.min(tokens.len())..).map(|t| t.split('=').next().unwrap_or(t)).collect(); ++ let mut out = Vec::new(); ++ let mut offer = |w: String| { ++ if w.starts_with(typing) && w != typing && !out.contains(&w) { ++ out.push(w); ++ } ++ }; ++ if let Kind::Options { entries, free } = &node.kind { ++ for e in entries.iter().filter(|e| !used.contains(&e.key.as_str())) { ++ offer(if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() }); ++ } ++ for w in free.iter().flatten() { ++ offer(w.clone()); ++ } ++ out.sort(); ++ return out; ++ } ++ for c in &node.children { ++ match &c.kind { ++ Kind::Literal => offer(c.name.clone()), ++ Kind::Value(Value::Choice(words) | Value::Word(words)) => words.iter().for_each(|w| offer(w.clone())), ++ Kind::Value(Value::Names(list)) => lists(list).into_iter().for_each(&mut offer), ++ Kind::Options { entries, free } => { ++ for e in entries { ++ offer(if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() }); ++ } ++ for w in free.iter().flatten() { ++ offer(w.clone()); ++ } ++ } ++ _ => {} ++ } ++ } ++ out.sort(); ++ out ++ } ++ ++ /// The nodes as `command.set-tree` takes them. ++ pub fn nodes(&self) -> Vec { ++ fn value(v: &Value) -> crate::command::ValueKind { ++ use crate::command::ValueKind as V; ++ match v { ++ Value::Word(w) => V::Word(w.clone()), ++ Value::Integer(a, b) => V::Integer((*a, *b)), ++ Value::Number(a, b) => V::Number((*a, *b)), ++ Value::Choice(w) => V::Choice(w.clone()), ++ Value::Names(n) => V::Names(n.clone()), ++ Value::BlockPos => V::BlockPos, ++ } ++ } ++ fn flat(node: &Node, out: &mut Vec) -> u32 { ++ use crate::command::{NodeKind as K, TreeNode, TreeOption, TreeOptions}; ++ let index = out.len() as u32; ++ out.push(TreeNode { ++ name: if node.name.is_empty() { "-".into() } else { node.name.clone() }, ++ kind: match &node.kind { ++ Kind::Literal => None, ++ Kind::Value(v) => Some(K::Value(value(v))), ++ Kind::Text => Some(K::Text), ++ Kind::Options { entries, free } => Some(K::Options(TreeOptions { ++ entries: entries.iter().map(|e| TreeOption { key: e.key.clone(), value: e.value.as_ref().map(value) }).collect(), ++ free: free.clone(), ++ })), ++ }, ++ children: Vec::new(), ++ runs: node.runs, ++ }); ++ let children: Vec = node.children.iter().map(|c| flat(c, out)).collect(); ++ out[index as usize].children = children; ++ index ++ } ++ let mut out = Vec::new(); ++ flat(&self.root, &mut out); ++ out ++ } ++ ++ /// Gives the tree to the game as the arguments of `/name` (which the mod ++ /// registers with [`crate::Registration::command`]). ++ pub fn register(&self) -> Result<(), String> { ++ crate::command::set_tree(&self.root.name, &self.nodes()) ++ } ++} ++ ++/// `key=value` options and flags: each token one of them, once, or a free word. ++fn check_options(entries: &[Opt], free: Option<&[String]>, tokens: &[(usize, &str)]) -> Result<(), ParseError> { ++ let mut used: Vec<&str> = Vec::new(); ++ for &(at, token) in tokens.iter().filter(|t| !t.1.is_empty()) { ++ let err = |m: String| Err(ParseError { at, message: m }); ++ let (key, value) = match token.split_once('=') { ++ Some((k, v)) => (k, Some(v)), ++ None => (token, None), ++ }; ++ match (entries.iter().find(|e| e.key == key), value) { ++ (Some(_), _) if used.contains(&key) => return err(format!("«{key}» дважды")), ++ (Some(e), v) => { ++ match (&e.value, v) { ++ (None, None) => {} ++ (None, Some(_)) => return err(format!("«{key}» без значения")), ++ (Some(_), None) => return err(format!("«{key}» требует значение: {key}=…")), ++ (Some(kind), Some(v)) => { ++ let probe = [(at + key.len() + 1, v)]; ++ if let Err((_, m)) = kind.take(&probe) { ++ return err(format!("{key}: {m}")); ++ } ++ } ++ } ++ used.push(key); ++ } ++ (None, None) if free.is_some() => {} ++ (None, _) => return err(format!("Неизвестный параметр «{token}»")), ++ } ++ } ++ Ok(()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn tree() -> Tree { ++ Tree::new("demo") ++ .runs() ++ .then(Node::lit("menu").runs()) ++ .then(Node::lit("wave").then( ++ Node::arg("kind", Value::choice(["shahed", "mix"])).runs().then( ++ Node::arg("count", Value::int(1, 80)).runs().then(Node::arg("range", Value::number(60.0, 800.0)).runs()), ++ ), ++ )) ++ .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs())) ++ .then(Node::lit("at").then(Node::arg("pos", Value::BlockPos).runs())) ++ .then(Node::lit("export").runs().then( ++ Node::options("options", vec![Opt::new("fps", Value::int(1, 240)), Opt::flag("noaudio")], Some(vec!["best".into()])).runs(), ++ )) ++ } ++ ++ #[test] ++ fn the_tree_parses_branches_and_points_at_the_error() { ++ let t = tree(); ++ for ok in ["", "menu", "wave mix", "wave shahed 5 260.5", "load abc", "at 1 ~ ~-2", "export", "export n best fps=60 noaudio", "menu "] { ++ assert!(t.parse(ok).is_ok(), "{ok}: {:?}", t.parse(ok)); ++ } ++ assert_eq!(t.parse("wave shahed 5 260.5").unwrap().get("range"), Some("260.5")); ++ assert_eq!(t.parse("wave mix").unwrap().path, ["wave"]); ++ let at = |line: &str| t.parse(line).unwrap_err().at; ++ assert_eq!(at("wave scud"), 5); ++ assert_eq!(at("wave shahed 0"), 12); ++ assert_eq!(at("wave shahed 81"), 12); ++ assert_eq!(at("wave shahed 5 900"), 14); ++ assert_eq!(at("wave shahed x"), 12); ++ assert_eq!(at("wave"), 4, "incomplete"); ++ assert_eq!(at("load"), 4); ++ assert_eq!(at("nothing"), 0); ++ assert_eq!(at("menu now"), 5); ++ assert_eq!(at("at 1 2"), 6); ++ assert_eq!(at("export fps=0"), 7); ++ assert_eq!(at("export fps"), 7); ++ assert_eq!(at("export noaudio noaudio"), 15); ++ assert_eq!(at("export noaudio=1"), 7); ++ } ++ ++ #[test] ++ fn the_usage_line_and_the_nodes_come_from_the_same_tree() { ++ let t = tree(); ++ assert_eq!( ++ t.usage(), ++ "/demo menu | wave shahed|mix [ []] | load | at | export [fps=… noaudio]" ++ ); ++ let nodes = t.nodes(); ++ assert_eq!(nodes[0].children.len(), 5); ++ assert!(nodes.iter().enumerate().all(|(i, n)| n.children.iter().all(|&c| c as usize > i))); ++ assert!(nodes[0].runs); ++ } ++ ++ #[test] ++ fn completion_follows_the_lists_as_they_change() { ++ let t = tree(); ++ let saves = std::cell::RefCell::new(vec!["alpha".to_string(), "beta".to_string()]); ++ let lists = |_: &str| saves.borrow().clone(); ++ assert_eq!(t.complete("", &lists), ["at", "export", "load", "menu", "wave"]); ++ assert_eq!(t.complete("lo", &lists), ["load"]); ++ assert_eq!(t.complete("load ", &lists), ["alpha", "beta"]); ++ saves.borrow_mut().push("gamma".into()); ++ assert_eq!(t.complete("load ", &lists), ["alpha", "beta", "gamma"]); ++ saves.borrow_mut().retain(|n| n != "alpha"); ++ assert_eq!(t.complete("load b", &lists), ["beta"]); ++ assert_eq!(t.complete("wave ", &lists), ["mix", "shahed"]); ++ assert_eq!(t.complete("export fps=3 ", &lists), ["best", "noaudio"]); ++ assert!(t.complete("wave nothing ", &lists).is_empty()); ++ } ++} +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index a367a666f..2798c2bcd 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -123,6 +123,79 @@ interface command { + /// `msg`) is refused. On any other server they go as chat commands, and + /// there their feedback shows: a mod running many should say so. + run-quiet: func(lines: list); ++ ++ /// What one word of a command line takes (AirDef fork, level 19). ++ variant value-kind { ++ /// One word, anything up to the next space. The words given are ++ /// only what the chat box suggests; any word is accepted. ++ word(list), ++ /// A whole number, within the bounds given (either may be none). ++ integer(tuple, option>), ++ /// A number with or without decimals, within the bounds given. ++ number(tuple, option>), ++ /// One of these words and no other. ++ choice(list), ++ /// One word, suggested from the list `set-suggestions` last gave ++ /// under this name; any word is accepted, so a list that is out ++ /// of date refuses nothing. ++ names(string), ++ /// Three coordinates: whole numbers, each one also `~` or `~n`. ++ block-pos, ++ } ++ /// One `key=value` option, or a bare flag (`noaudio`) when `value` is none. ++ record tree-option { ++ key: string, ++ value: option, ++ } ++ /// The words that follow, in any order, to the end of the line. ++ record tree-options { ++ /// The options and flags that may appear, each at most once. ++ entries: list, ++ /// Words that are no option are free words (a name): none refuses ++ /// them, some accepts them and suggests the words given. ++ free: option>, ++ } ++ variant node-kind { ++ /// One word, as `value-kind` says. ++ value(value-kind), ++ /// The rest of the line, spaces included: only a last node. ++ text, ++ options(tree-options), ++ } ++ /// A node of a command's tree. Nodes are listed flat; a node's children ++ /// come after it in the list (a tree, never a loop). ++ record tree-node { ++ /// A literal's word, or an argument's name (the chat box's hint ++ /// shows it as ``). ++ name: string, ++ /// None: a literal that must be typed as it is. ++ kind: option, ++ /// The nodes that may follow, by their place in the list. A word ++ /// no literal child is, goes to the argument children, in order. ++ children: list, ++ /// The line may end here (a branch with optional words after it ++ /// runs without them). ++ runs: bool, ++ } ++ /// Describes the arguments of a command `name` the mod registered ++ /// (AirDef fork, level 19), replacing the free text it takes ++ /// otherwise: the chat box colours the line, shows the error and the ++ /// next word before Enter, and completes it with Tab; a line that does ++ /// not parse never reaches the mod's `command` hook (the player gets ++ /// the error). A line that parses reaches it as it was typed. ++ /// `nodes[0]` is the node right after the command's name (its literal ++ /// children are the subcommands; `runs` says whether the bare command ++ /// runs). At most 512 nodes, 256 words in a list, 64 characters in a ++ /// word, no loops, no two children of a node with one name. Errs, and ++ /// changes nothing, on a tree that breaks these rules; a tree for a ++ /// name the mod did not register in `init` is ignored. Calling again ++ /// replaces the tree. ++ set-tree: func(name: string, nodes: list) -> result<_, string>; ++ /// Sets the words a `names` node suggests under `topic` (at most 256 ++ /// of 64 characters, kept as given, the rest dropped). Call it again ++ /// whenever the words change (a saved scenario, a deleted one): the ++ /// chat box follows. ++ set-suggestions: func(topic: string, values: list); + } + + /// Capability `ui`: widgets the game draws with its own menus' widgets, for +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 55920c1da..2300f0fcc 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 18; ++pub const LEVEL: u32 = 19; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 78] = [ ++pub const FUNCTIONS: [(&str, u32); 80] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -100,6 +100,9 @@ pub const FUNCTIONS: [(&str, u32); 78] = [ + ("render.world-log-stop", 18), + ("render.world-log-range", 18), + ("render.set-world-time-view", 18), ++ // AirDef fork: a command's tree of subcommands and typed arguments. ++ ("command.set-tree", 19), ++ ("command.set-suggestions", 19), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index 89fa150d0..4762af39c 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -43,6 +43,9 @@ mod ui_bindings { + }); + } + pub use bindings::horizon::mods::{ ++ command::{ ++ NodeKind as TreeNodeKind, TreeNode, TreeOption, TreeOptions, ValueKind as TreeValueKind, ++ }, + game::{Clock, Hit, Player}, + hud::{Corner, Line}, + }; +@@ -535,6 +538,12 @@ struct State { + lighting: Option, + /// `render.set-time-override` (AirDef fork). + time_override: Option, ++ /// `command.set-tree` (AirDef fork): the trees of the commands' arguments, by command. ++ trees: Vec<(String, Vec)>, ++ /// `command.set-suggestions`: the lists `names` nodes suggest from. ++ lists: Vec<(String, Vec)>, ++ /// Counts the changes of `trees` and `lists`. ++ tree_generation: u64, + /// `render.set-smoke` (AirDef fork): the segments, the clock of this call and of the one before. + smoke: Vec, + smoke_clock: (f64, f64), +@@ -783,6 +792,9 @@ impl Mods { + spots: Vec::new(), + lighting: None, + time_override: None, ++ trees: Vec::new(), ++ lists: Vec::new(), ++ tree_generation: 0, + smoke: Vec::new(), + smoke_clock: (0.0, 0.0), + input: InputState::default(), +@@ -1441,6 +1453,38 @@ impl Mods { + .iter() + .find_map(|e| e.run.as_ref()?.store.data().main_view) + } ++ /// The argument trees running client mods gave their registered commands ++ /// (`command.set-tree`), for the chat to colour and complete; a tree ++ /// for a name the mod did not register is left out. ++ pub fn command_trees(&self) -> Vec> { ++ let mut out = Vec::new(); ++ for e in &self.entries { ++ let Some(run) = e.run.as_ref() else { continue }; ++ let Some(registration) = run.hooks.client() else { ++ continue; ++ }; ++ let state = run.store.data(); ++ for (name, nodes) in &state.trees { ++ if registration.commands.contains(name) { ++ out.push(CommandTree { ++ name, ++ nodes, ++ lists: &state.lists, ++ }); ++ } ++ } ++ } ++ out ++ } ++ /// Changes whenever a mod's tree or suggestion list does (and when mods ++ /// start or stop), so the chat knows to rebuild its tree. ++ pub fn tree_generation(&self) -> u64 { ++ self.entries ++ .iter() ++ .filter_map(|e| e.run.as_ref()) ++ .map(|r| r.store.data().tree_generation + 1) ++ .sum() ++ } + /// The time of day the first running mod asked to show, if any. + pub fn time_override(&self) -> Option { + self.entries +@@ -2626,7 +2670,161 @@ impl render::Host for State { + } + } + ++/// The most nodes a command's tree may have, words a list may hold and ++/// characters in one word (`command.set-tree`). ++const TREE_NODES: usize = 512; ++const TREE_WORDS: usize = 256; ++const TREE_WORD_CHARS: usize = 64; ++ ++/// A command's tree as `command.set-tree` gave it, with the mod's lists. ++pub struct CommandTree<'a> { ++ pub name: &'a str, ++ pub nodes: &'a [TreeNode], ++ pub lists: &'a [(String, Vec)], ++} ++impl CommandTree<'_> { ++ /// The words of a `names` list, empty for one the mod never set. ++ pub fn list(&self, list: &str) -> &[String] { ++ self.lists ++ .iter() ++ .find(|(n, _)| n == list) ++ .map_or(&[], |(_, v)| v.as_slice()) ++ } ++} ++ ++fn check_word(word: &str, what: &str) -> Result<(), String> { ++ if word.is_empty() || word.chars().count() > TREE_WORD_CHARS || word.contains(char::is_whitespace) ++ { ++ return Err(format!("{what} {word:?}: one to {TREE_WORD_CHARS} characters, no spaces")); ++ } ++ Ok(()) ++} ++fn check_words(words: &[String], what: &str) -> Result<(), String> { ++ if words.len() > TREE_WORDS { ++ return Err(format!("{what}: at most {TREE_WORDS} words")); ++ } ++ words.iter().try_for_each(|w| check_word(w, what)) ++} ++fn check_value(kind: &TreeValueKind) -> Result<(), String> { ++ match kind { ++ TreeValueKind::Word(words) => check_words(words, "word suggestion"), ++ TreeValueKind::Choice(words) if words.is_empty() => Err("a choice needs a word".into()), ++ TreeValueKind::Choice(words) => check_words(words, "choice"), ++ TreeValueKind::Names(list) => check_word(list, "list name"), ++ TreeValueKind::Integer((min, max)) if matches!((min, max), (Some(a), Some(b)) if a > b) => { ++ Err("integer bounds: min above max".into()) ++ } ++ TreeValueKind::Number((min, max)) => { ++ let ok = |b: &Option| b.is_none_or(f64::is_finite); ++ if !ok(min) || !ok(max) { ++ return Err("number bounds must be finite".into()); ++ } ++ match (min, max) { ++ (Some(a), Some(b)) if a > b => Err("number bounds: min above max".into()), ++ _ => Ok(()), ++ } ++ } ++ TreeValueKind::Integer(_) | TreeValueKind::BlockPos => Ok(()), ++ } ++} ++ ++/// Whether `nodes` is a tree the chat can use: at most [`TREE_NODES`], ++/// children after their parent and each reached once, literals that are ++/// single words, text only as a last node, kinds within their limits. ++pub fn check_tree(nodes: &[TreeNode]) -> Result<(), String> { ++ if nodes.is_empty() || nodes.len() > TREE_NODES { ++ return Err(format!("a tree has 1 to {TREE_NODES} nodes")); ++ } ++ let mut parent = vec![None; nodes.len()]; ++ for (i, n) in nodes.iter().enumerate() { ++ let what = format!("node {i} ({:?})", n.name); ++ if i > 0 { ++ check_word(&n.name, "node name").map_err(|e| format!("{what}: {e}"))?; ++ } ++ match &n.kind { ++ Some(TreeNodeKind::Value(v)) => check_value(v).map_err(|e| format!("{what}: {e}"))?, ++ Some(TreeNodeKind::Text) if !n.children.is_empty() => { ++ return Err(format!("{what}: text is the rest of the line: nothing follows it")); ++ } ++ Some(TreeNodeKind::Options(o)) => { ++ if !n.children.is_empty() { ++ return Err(format!("{what}: options take the rest of the line: nothing follows them")); ++ } ++ if o.entries.len() > TREE_WORDS { ++ return Err(format!("{what}: too many options")); ++ } ++ for (j, e) in o.entries.iter().enumerate() { ++ check_word(&e.key, "option key").map_err(|e| format!("{what}: {e}"))?; ++ if e.key.contains('=') || o.entries[..j].iter().any(|b| b.key == e.key) { ++ return Err(format!("{what}: option {:?} twice or with an =", e.key)); ++ } ++ match &e.value { ++ Some(TreeValueKind::BlockPos) => { ++ return Err(format!("{what}: an option value is one word")); ++ } ++ Some(v) => check_value(v).map_err(|e| format!("{what}: {e}"))?, ++ None => {} ++ } ++ } ++ if let Some(free) = &o.free { ++ check_words(free, "free word").map_err(|e| format!("{what}: {e}"))?; ++ } ++ } ++ Some(TreeNodeKind::Text) | None => {} ++ } ++ for &c in &n.children { ++ let c = c as usize; ++ if c <= i || c >= nodes.len() { ++ return Err(format!("{what}: child {c} must come after it, within the list")); ++ } ++ if parent[c].replace(i).is_some() { ++ return Err(format!("{what}: child {c} has two parents")); ++ } ++ } ++ } ++ // A name once among a node's children. ++ for (i, n) in nodes.iter().enumerate() { ++ for (a, &x) in n.children.iter().enumerate() { ++ if n.children[..a] ++ .iter() ++ .any(|&y| nodes[y as usize].name == nodes[x as usize].name) ++ { ++ return Err(format!("node {i}: two children named {:?}", nodes[x as usize].name)); ++ } ++ } ++ } ++ Ok(()) ++} ++ + impl command::Host for State { ++ fn set_tree(&mut self, name: String, nodes: Vec) -> Result<(), String> { ++ check_tree(&nodes)?; ++ let known = self.trees.iter().position(|(n, _)| *n == name); ++ match known { ++ Some(i) => self.trees[i].1 = nodes, ++ None if self.trees.len() < 64 => self.trees.push((name, nodes)), ++ None => return Err("at most 64 commands have a tree".into()), ++ } ++ self.tree_generation += 1; ++ Ok(()) ++ } ++ fn set_suggestions(&mut self, topic: String, values: Vec) { ++ let values: Vec = values ++ .into_iter() ++ .filter(|w| check_word(w, "").is_ok()) ++ .take(TREE_WORDS) ++ .collect(); ++ let known = self.lists.iter().position(|(n, _)| *n == topic); ++ match known { ++ Some(i) if self.lists[i].1 == values => return, ++ Some(i) => self.lists[i].1 = values, ++ None if self.lists.len() < 64 && check_word(&topic, "").is_ok() => { ++ self.lists.push((topic, values)); ++ } ++ None => return, ++ } ++ self.tree_generation += 1; ++ } + fn run(&mut self, line: String) { + let queued = self + .out +@@ -2742,3 +2940,72 @@ fn storage_key_ok(key: &str) -> bool { + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b)); + chars_ok && key != "." && key != ".." + } ++ ++#[cfg(test)] ++mod tree_tests { ++ use super::*; ++ ++ fn lit(name: &str, children: &[u32]) -> TreeNode { ++ TreeNode { ++ name: name.into(), ++ kind: None, ++ children: children.to_vec(), ++ runs: true, ++ } ++ } ++ fn value(name: &str, kind: TreeValueKind) -> TreeNode { ++ TreeNode { ++ name: name.into(), ++ kind: Some(TreeNodeKind::Value(kind)), ++ children: Vec::new(), ++ runs: true, ++ } ++ } ++ ++ #[test] ++ fn a_tree_is_checked_before_a_mod_may_set_it() { ++ let ok = vec![ ++ lit("", &[1, 2]), ++ lit("wave", &[3]), ++ lit("menu", &[]), ++ value("count", TreeValueKind::Integer((Some(1), Some(80)))), ++ ]; ++ assert_eq!(check_tree(&ok), Ok(())); ++ assert!(check_tree(&[]).is_err()); ++ // A loop, a child out of range, two parents, two children with a name. ++ let mut bad = ok.clone(); ++ bad[3].children = vec![1]; ++ assert!(check_tree(&bad).unwrap_err().contains("after it")); ++ let mut bad = ok.clone(); ++ bad[1].children = vec![9]; ++ assert!(check_tree(&bad).is_err()); ++ let mut bad = ok.clone(); ++ bad[2].children = vec![3]; ++ assert!(check_tree(&bad).unwrap_err().contains("two parents")); ++ let mut bad = ok.clone(); ++ bad[2].name = "wave".into(); ++ assert!(check_tree(&bad).unwrap_err().contains("two children")); ++ // Words: one, short, no spaces; bounds the right way round. ++ let mut bad = ok.clone(); ++ bad[2].name = "two words".into(); ++ assert!(check_tree(&bad).is_err()); ++ let mut bad = ok.clone(); ++ bad[3] = value("count", TreeValueKind::Integer((Some(5), Some(1)))); ++ assert!(check_tree(&bad).is_err()); ++ let mut bad = ok.clone(); ++ bad[3] = value("n", TreeValueKind::Number((Some(f64::NAN), None))); ++ assert!(check_tree(&bad).is_err()); ++ let mut bad = ok.clone(); ++ bad[3] = value("kind", TreeValueKind::Choice(Vec::new())); ++ assert!(check_tree(&bad).is_err()); ++ // Text and options end the line. ++ let mut bad = ok.clone(); ++ bad[3].kind = Some(TreeNodeKind::Text); ++ bad[3].children = vec![]; ++ assert_eq!(check_tree(&bad), Ok(())); ++ bad[1].kind = Some(TreeNodeKind::Text); ++ assert!(check_tree(&bad).unwrap_err().contains("nothing follows")); ++ let too_many: Vec = (0..=TREE_NODES).map(|_| lit("x", &[])).collect(); ++ assert!(check_tree(&too_many).is_err()); ++ } ++} +diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs +index e400dd56b..b0aa8d322 100644 +--- a/crates/horizon-mods/tests/sandbox.rs ++++ b/crates/horizon-mods/tests/sandbox.rs +@@ -110,6 +110,37 @@ fn player() -> Snapshot { + } + } + ++/// Capability `command` (level 19): a mod gives its command a tree and the lists it suggests from; ++/// a tree for a command it did not register is ignored; changing a list changes the generation. ++#[test] ++fn a_mod_describes_its_commands_arguments() { ++ use horizon_mods::{TreeNodeKind, TreeValueKind}; ++ let mut mods = load("horizon_mods_test_ui_guest.hmod"); ++ assert_eq!(mods.entries()[0].status, Status::Running); ++ let trees = mods.command_trees(); ++ assert_eq!(trees.len(), 1, "the unregistered command's tree is left out"); ++ let tree = &trees[0]; ++ assert_eq!(tree.name, "ui_screen"); ++ assert_eq!(tree.nodes.len(), 4); ++ assert!(tree.nodes[0].runs); ++ assert_eq!(tree.nodes[0].children, [1, 2]); ++ assert!(matches!( ++ &tree.nodes[3].kind, ++ Some(TreeNodeKind::Value(TreeValueKind::Names(list))) if list == "saves" ++ )); ++ assert_eq!(tree.list("saves"), ["a"]); ++ let before = mods.tree_generation(); ++ // A list changed from a hook: the generation moves, the same list again leaves it. ++ assert!(mods.command("saves b,c")); ++ assert_eq!(mods.command_trees()[0].list("saves"), ["b", "c"]); ++ let after = mods.tree_generation(); ++ assert_ne!(before, after); ++ assert!(mods.command("saves b,c")); ++ assert_eq!(mods.tree_generation(), after); ++ // The line is still the mod's own: it gets what was typed. ++ assert!(mods.command("ui_screen show")); ++} ++ + /// Capability `command`: `/missile` queues vanilla commands, and the chat + /// line its impact check prints sets off the warhead. + #[test] +diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs +index f95aedffa..2696da95b 100644 +--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs ++++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs +@@ -124,10 +124,21 @@ impl Mod for Guest { + "recipe_give", + "corner", + "readings", ++ "saves", + ] + .into_iter() + .fold(Registration::new(), Registration::command) + .on_tick(); ++ // `/ui_screen` has a tree: `/ui_screen show`, `/ui_screen load `; `/saves A,B` ++ // changes the saves it suggests. A tree for a command the mod did not register is ignored. ++ use horizon_mod_sdk::tree::{Node, Tree, Value}; ++ Tree::new("ui_screen") ++ .runs() ++ .then(Node::lit("show").runs()) ++ .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs())) ++ .register()?; ++ Tree::new("unregistered").runs().register()?; ++ command::set_suggestions("saves", &["a".to_string()]); + Ok((Guest { pending: None }, hooks)) + } + fn command(&mut self, name: &str, args: &str) -> Result, String> { +@@ -252,6 +263,7 @@ impl Mod for Guest { + }); + render::set_far_terrain(far); + } ++ "saves" => command::set_suggestions("saves", &args.split(',').map(str::to_owned).collect::>()), + "quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::>()), + "quiet_many" => { + let n: usize = args.parse().unwrap_or(0); +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index c1dc88f54..22ad00589 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -183,10 +183,62 @@ Access: capability `command`. + + Capability `command`: vanilla commands run as the player, with the player's permissions, after the hook call returns (8 per call). A mod's own commands are not run this way. + ++### variant `value-kind` ++ ++What one word of a command line takes (AirDef fork, level 19). ++ ++| Case | Payload | | ++| --- | --- | --- | ++| `word` | `list` | One word, anything up to the next space. The words given are only what the chat box suggests; any word is accepted. | ++| `integer` | `tuple, option>` | A whole number, within the bounds given (either may be none). | ++| `number` | `tuple, option>` | A number with or without decimals, within the bounds given. | ++| `choice` | `list` | One of these words and no other. | ++| `names` | `string` | One word, suggested from the list `set-suggestions` last gave under this name; any word is accepted, so a list that is out of date refuses nothing. | ++| `block-pos` | | Three coordinates: whole numbers, each one also `~` or `~n`. | ++ ++### record `tree-option` ++ ++One `key=value` option, or a bare flag (`noaudio`) when `value` is none. ++ ++| Field | Type | | ++| --- | --- | --- | ++| `key` | `string` | | ++| `value` | `option` | | ++ ++### record `tree-options` ++ ++The words that follow, in any order, to the end of the line. ++ ++| Field | Type | | ++| --- | --- | --- | ++| `entries` | `list` | The options and flags that may appear, each at most once. | ++| `free` | `option>` | Words that are no option are free words (a name): none refuses them, some accepts them and suggests the words given. | ++ ++### variant `node-kind` ++ ++| Case | Payload | | ++| --- | --- | --- | ++| `value` | `value-kind` | One word, as `value-kind` says. | ++| `text` | | The rest of the line, spaces included: only a last node. | ++| `options` | `tree-options` | | ++ ++### record `tree-node` ++ ++A node of a command's tree. Nodes are listed flat; a node's children come after it in the list (a tree, never a loop). ++ ++| Field | Type | | ++| --- | --- | --- | ++| `name` | `string` | A literal's word, or an argument's name (the chat box's hint shows it as ``). | ++| `kind` | `option` | None: a literal that must be typed as it is. | ++| `children` | `list` | The nodes that may follow, by their place in the list. A word no literal child is, goes to the argument children, in order. | ++| `runs` | `bool` | The line may end here (a branch with optional words after it runs without them). | ++ + | Function | Signature | What it does | + | --- | --- | --- | + | `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. | + | `run-quiet` | `func(lines: list)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. | ++| `set-tree` | `func(name: string, nodes: list) -> result<_, string>` | Describes the arguments of a command `name` the mod registered (AirDef fork, level 19), replacing the free text it takes otherwise: the chat box colours the line, shows the error and the next word before Enter, and completes it with Tab; a line that does not parse never reaches the mod's `command` hook (the player gets the error). A line that parses reaches it as it was typed. `nodes[0]` is the node right after the command's name (its literal children are the subcommands; `runs` says whether the bare command runs). At most 512 nodes, 256 words in a list, 64 characters in a word, no loops, no two children of a node with one name. Errs, and changes nothing, on a tree that breaks these rules; a tree for a name the mod did not register in `init` is ignored. Calling again replaces the tree. | ++| `set-suggestions` | `func(topic: string, values: list)` | Sets the words a `names` node suggests under `topic` (at most 256 of 64 characters, kept as given, the rest dropped). Call it again whenever the words change (a saved scenario, a deleted one): the chat box follows. | + + ## `ui` + +diff --git a/docs/api/mods.md b/docs/api/mods.md +index 0dc74e7a3..c255056e7 100644 +--- a/docs/api/mods.md ++++ b/docs/api/mods.md +@@ -106,6 +106,13 @@ What real mods need next, in the order modpacks and servers need it: + + ## Changelog + ++- API level 19 (AirDef fork, capability `command`): `set-tree` gives a client mod's command a tree of subcommands ++ (literals), typed words (`word`, `choice`, `integer` and `number` with bounds, `names` from a list, `block-pos`), ++ optional branches (`runs`), the rest of the line (`text`) and `key=value` options; `set-suggestions` sets the list a ++ `names` node suggests from. The chat box colours the line, shows the error and completes the next word before ++ Enter, and a line that does not parse never reaches the mod; one that does gets the line as it was typed. The ++ plain `registration.commands` name stays the free-text fallback. SDK `tree::{Tree, Node, Value, Opt}`: `register`, ++ `parse`, `complete`, `usage` from the same nodes. + - API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word, + text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server; + `command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`. +-- +2.54.0 (Apple Git-157) +