horizon-prepare --launch-api: what this preparer supports, as JSON

The Launch API version (the same line as horizon-client's, a test reads it),
the preparer's version, the pinned Minecraft and Java major, the resources'
schema and output version that --check compares, the options a launcher may
pass and the exit codes. A launcher asks it before it runs a preparer it
didn't build. Part of #3545.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
GIGABAIT93andClaude Opus 5.5 committed 2026-11-02 11:13:43 +02:00
1 parent ce8d86cb23
commit 89efa41590
5 files changed
+161 -10

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
//! The preparer's side of the Launch API (docs/api/launch.md): the options another launcher may
//! pass and `horizon-prepare --launch-api`'s reply, so it can ask what this preparer supports
//! before it runs one.
use crate::artifacts::Lock;
use crate::pyjson::{self, Value};
/// The Launch API's version, the same line as `horizon-client --launch-api`'s
/// (docs/api/stability.md): an addition makes the next patch.
pub const VERSION: &str = "0.1.0";
/// What a launcher may pass, with its value's placeholder (none for a flag). The asset commands
/// (`sounds`, `fonts`...) stay out: `--complete` runs what a launcher needs of them.
pub const OPTIONS: &[(&str, Option<&str>)] = &[
("--prepare", None),
("--server-jar", Some("PATH")),
("--minecraft-dir", Some("PATH")),
("--version-json", Some("PATH")),
("--client-jar", Some("PATH")),
("--libraries-dir", Some("PATH")),
("--assets-dir", Some("PATH")),
("--java-home", Some("PATH")),
("--root", Some("PATH")),
("--output", Some("PATH")),
("--check", None),
("--complete", None),
("--progress", Some("json")),
("--launch-api", None),
];
/// 0 done, 1 failed or gaps found, 2 a refused command line.
pub const EXIT_CODES: &[i64] = &[0, 1, 2];
/// `--launch-api`'s reply for this preparer (`horizon`, its version) and the pins beside it.
pub fn reply(horizon: &str, lock: &Lock) -> String {
let int = |n: i64| Value::Int(n.to_string());
let options = OPTIONS
.iter()
.map(|(name, value)| {
Value::Object(vec![
("name".into(), Value::str(*name)),
("value".into(), value.map_or(Value::Null, Value::str)),
])
})
.collect();
let reply = Value::Object(vec![
("launch_api".into(), Value::str(VERSION)),
("horizon".into(), Value::str(horizon)),
("minecraft".into(), Value::str(&lock.version)),
("java_major".into(), int(lock.java_major)),
("schema".into(), int(crate::SCHEMA)),
(
"output_version".into(),
int(i64::from(crate::OUTPUT_VERSION)),
),
("options".into(), Value::Array(options)),
(
"exit_codes".into(),
Value::Array(EXIT_CODES.iter().map(|&c| int(c)).collect()),
),
]);
pyjson::dumps(&reply, &pyjson::INDENT2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_reply_names_the_pins_and_the_layout() {
let root = std::env::temp_dir().join(format!("horizon-launch-api-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("research-lock.json"),
r#"{"minecraft": {"version": "9.9", "java_major": 25}}"#,
)
.unwrap();
let lock = Lock::read(&root).unwrap();
let _ = std::fs::remove_dir_all(&root);
let reply = pyjson::loads(&reply("1.2.3", &lock)).unwrap();
let text = |key: &str| reply.get(key).and_then(Value::as_str).map(str::to_owned);
let int = |key: &str| match reply.get(key) {
Some(Value::Int(n)) => n.clone(),
other => panic!("{key}: {other:?}"),
};
assert_eq!(text("launch_api").as_deref(), Some(VERSION));
assert_eq!(text("horizon").as_deref(), Some("1.2.3"));
assert_eq!(text("minecraft").as_deref(), Some("9.9"));
assert_eq!(int("java_major"), "25");
assert_eq!(int("schema"), crate::SCHEMA.to_string());
assert_eq!(int("output_version"), crate::OUTPUT_VERSION.to_string());
let progress = reply
.get("options")
.and_then(|o| match o {
Value::Array(items) => items
.iter()
.find(|i| i.get("name").and_then(Value::as_str) == Some("--progress")),
_ => None,
})
.expect("--progress is listed");
assert_eq!(progress.get("value").and_then(Value::as_str), Some("json"));
}
/// One Launch API, one version: the client's and the preparer's move together.
#[test]
fn the_client_speaks_the_same_version() {
let client = include_str!("../../horizon-client/src/game/launch_api.rs");
assert!(
client.contains(&format!("pub const VERSION: &str = \"{VERSION}\";")),
"horizon-client's Launch API version is not {VERSION}"
);
}
}
+1
View File
@@ -16,6 +16,7 @@ pub mod exporters;
mod files;
mod inventory;
pub mod java;
pub mod launch_api;
mod pool;
pub mod pyjson;
mod sounds;
+31
View File
@@ -19,6 +19,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
[--version-json PATH] [--client-jar PATH] [--libraries-dir PATH] [--assets-dir PATH]
[--java-home PATH] [--complete] [--progress json]
horizon-prepare --check [--root PATH] [--output PATH]
horizon-prepare --launch-api [--root PATH]
horizon-prepare compile-exporters [--root PATH]
horizon-prepare sounds [--minecraft-dir PATH] [--version-json PATH] [--assets-dir PATH] [--root PATH] [--output PATH]
horizon-prepare panorama [--minecraft-dir PATH] [--version-json PATH] [--assets-dir PATH] [--root PATH] [--output PATH]
@@ -52,6 +53,8 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
for resources the game plays with nothing missing
--progress json One JSON object a line on stdout: {\"message\": ...} for each log line,
{\"fraction\": F} as the work advances (0 to 1), {\"error\": ...} on failure
--launch-api Print what this preparer supports as JSON (the Launch API's version, the
pinned Minecraft and Java, the resources' layout, the options) and exit 0
Needs Minecraft played once from the official launcher, and Java of the version
research-lock.json pins. Releases use a runtime; development or changed exporters need a JDK.
@@ -100,6 +103,7 @@ fn main() -> ExitCode {
let mut check = false;
let mut complete = false;
let mut json = false;
let mut launch_api = false;
let mut args = std::env::args_os().skip(1).peekable();
let assets = args.next_if(|a| {
matches!(
@@ -161,6 +165,10 @@ fn main() -> ExitCode {
Ok(v) => Err(format!("--progress takes json, not {}", v.display())),
Err(e) => Err(e),
},
"--launch-api" if inline.is_none() => {
launch_api = true;
Ok(())
}
"-h" | "--help" => {
print!("{HELP}");
return ExitCode::SUCCESS;
@@ -183,6 +191,18 @@ fn main() -> ExitCode {
return ExitCode::from(2);
};
options.root = root;
if launch_api {
return match horizon_prepare::artifacts::Lock::read(&options.root) {
Ok(lock) => {
println!("{}", horizon_prepare::launch_api::reply(VERSION, &lock));
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
};
}
if check {
if options.import || options.server_jar.is_some() {
eprintln!("horizon-prepare: error: --check prepares nothing; leave out --prepare and --server-jar");
@@ -330,4 +350,15 @@ mod tests {
assert!(horizon_prepare::pyjson::loads(&line).is_ok(), "{line}");
}
}
/// Every option `--launch-api` promises is one `--help` documents.
#[test]
fn launch_api_options_are_in_the_help() {
for (name, _) in horizon_prepare::launch_api::OPTIONS {
assert!(
HELP.contains(&format!(" {name} ")),
"{name} is not in --help"
);
}
}
}
+14 -8
View File
@@ -8,10 +8,10 @@ reference implementation. Proposed in horizon-rust/horizon#3545.
| | |
| --- | --- |
| Version | 0.1.0, printed by `horizon-client --launch-api` |
| Version | 0.1.0, printed by `horizon-client --launch-api` and `horizon-prepare --launch-api` |
| Level | experimental ([stability.md](stability.md)): preview once preparing and the guide are covered too |
| Covers | starting `horizon-client`: its options, the session JSON, the exit codes, the crash report's path; preparing with `horizon-prepare`: `--output`, `--check`, `--complete`, `--progress json` |
| Not yet | finding a build, asking the preparer what it supports (see Not there yet) |
| Covers | starting `horizon-client`: its options, the session JSON, the exit codes, the crash report's path; preparing with `horizon-prepare`: `--launch-api`, `--output`, `--check`, `--complete`, `--progress json` |
| Not yet | finding a build (see Not there yet) |
## Guide
@@ -58,10 +58,18 @@ downloads nothing: it reads the files of a Minecraft the player owns (Guide, ste
pins in `research-lock.json`.
```sh
horizon-prepare --launch-api
horizon-prepare --prepare --output PREPARED --complete --progress json
horizon-prepare --check --output PREPARED
```
- `--launch-api`: prints one JSON object on stdout and exits 0 (1, with an `error:` line, when there is no
`research-lock.json` beside it or at `--root`). `launch_api` is the version, the same as
`horizon-client --launch-api`'s; `horizon` is the preparer's own; `minecraft` and `java_major` are the pins it
prepares for (the game files it needs, and the Java `--java-home` must give); `schema` and `output_version`
are the layout and contents of the resources it writes, the ones `--check` compares; `options` lists the
options above and below as `{"name", "value"}` (`value` is the placeholder, null for a flag) and
`exit_codes` its exit codes. Pass only options it lists; readers ignore fields they don't know.
- `--output DIR`: where the resources go; the preparer's scratch files stay in its own `.local/`. One
preparation at a time writes to a folder; a second one waits.
- `--check`: exit 0 when the resources are complete and current for this preparer. Otherwise exit 1, with each
@@ -105,14 +113,12 @@ test keeps the contract and the code together.
## Not there yet
- **Asking the preparer what it supports:** `horizon-prepare --launch-api`, with its output version, schema and
Java major.
- **Finding a build:** a signed release index and builds kept longer than seven days.
- **A guide**, "Running Horizon from another launcher", from picking a build to playing.
## Changelog
- 0.1.0 (not released yet, so still one version): `horizon-client`'s `--game-dir`, `--home`, `--player-dir`, `--fullscreen`,
`--session-stdin`, `--launch-api`, exit code 2 for a refused command line; `horizon-prepare`'s `--output`,
`--check`, `--complete`, `--progress json`, `--version-json`, `--client-jar`, `--libraries-dir`, `--assets-dir`
and `--java-home`.
`--session-stdin`, `--launch-api`, exit code 2 for a refused command line; `horizon-prepare`'s `--launch-api`,
`--output`, `--check`, `--complete`, `--progress json`, `--version-json`, `--client-jar`, `--libraries-dir`,
`--assets-dir` and `--java-home`.
+2 -2
View File
@@ -72,7 +72,7 @@ Every preview and stable API has tests that run old users against the current co
| Agent control | a transcript per protocol version: commands and the reply keys each must have |
| Server | `api::ROUTES` against `route()` (generated reference); a `server.toml` from each release line parses to the same settings; golden replies per endpoint |
| Launcher | the generated reference; golden `--json` output per command |
| Launch | the generated reference, which holds `--launch-api`'s reply; every listed option is accepted; `horizon-prepare`'s `--progress json` lines (`progress_json_lines_keep_their_shape`) |
| Launch | the generated reference, which holds `--launch-api`'s reply; every listed option is accepted; `horizon-prepare`'s `--progress json` lines (`progress_json_lines_keep_their_shape`), its `--launch-api` reply and options (`launch_api::tests`, `launch_api_options_are_in_the_help`) |
| Data formats | a sample file of every version a reader accepts, read by today's reader |
The generated references are the API's own diff: a pull request that changes an API changes its reference, and the
@@ -96,7 +96,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| `horizon-server` commands, `server.toml` | stable | `horizon-server --version` |
| Server control API | stable | `/api/v1` |
| `horizon-launcher` commands and MCP tools | preview | none yet (unversioned) |
| Launch API: `horizon-client`'s launch options, session JSON, exit codes; `horizon-prepare`'s output and progress | experimental | 0.1.0, `horizon-client --launch-api` |
| Launch API: `horizon-client`'s launch options, session JSON, exit codes; `horizon-prepare`'s output and progress | experimental | 0.1.0, `horizon-client --launch-api` and `horizon-prepare --launch-api` |
| Benchmark report | stable | `"version": 1` |
| Telemetry batch and consent | stable | `"schema": 1`, consent `2` |
| `horizon-graphics.txt`, `horizon-extras.txt`, `horizon-mods.txt` | preview | none |