Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 2325ba52d4 Launcher: native-build --prebuilt-out, the registry CI's side of prebuilt builds (#11859)
`horizon-launcher native-build --prebuilt-out DIR --version <Nightly> FOLDER` builds every
.hnmod in FOLDER through the player's own pipeline (pins, hook points, left-out mods), but
needs no prepared game files and installs nothing: it writes the program, its
native-mods.json and request.txt into DIR. request.txt is the build's request
(horizon_release_sign::native_build): target, the base's commit, each mod's id, version and
file SHA-256, so the registry's workflow and the launcher name a build alike. A set with a
post-build step is refused: the step runs on the player's game files.

native_mods: pipeline takes an export folder; the .hnmod unpacking is shared with
installed_mods; build_target names the Rust target of a build.

Measured on the desktop: zoom + minimap on Nightly 6560641, release profile, -j 4: 8 min,
request key equal to the one horizon-mods' plan computes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:53:51 +01:00
6 changed files with 406 additions and 15 deletions

No files matched your search

+1
View File
@@ -111,6 +111,7 @@ Native mods (docs/native-mods.md): compile mixin bundles on the Horizon revision
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
- `--server-output`: Export mode: build a standalone dedicated-server engine into this folder instead of an instance's build. Needs a mod FOLDER; no instance is read or changed, and no launcher config is written
- `--prebuilt-out`: Prebuilt mode, for the registry's CI (#11859): build every .hnmod file in FOLDER as a player's launcher would, and write the program, its native-mods.json and request.txt into this new folder instead of installing it. The base needs no prepared game files
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one)
- `--update`: Move to the newest installed release or Nightly instead of the Horizon the instance plays (a full rebuild)
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
+12
View File
@@ -1922,6 +1922,18 @@ whole file, **Open full log** opens it outside the launcher, **Show in folder**
refresh, not a live stream: a running build's later output appears only on the next refresh. Nothing is
uploaded; the log stays on the player's computer.
## Prebuilt builds (in progress)
Most players can't wait 30 to 60 minutes for a first build, so the registry builds sets of its native mods itself
and signs them (#11859). The launcher's own pipeline makes them in CI:
`horizon-launcher native-build --prebuilt-out DIR --version <Nightly> --profile release --trust-native-code FOLDER`
builds every `.hnmod` in FOLDER as a player's launcher would (the same pins, hook points and left-out mods), on the
Nightly's source, without the player's game files. It writes the program, its `native-mods.json` and `request.txt`
into DIR instead of installing anything. `request.txt` names the build: the target, the Nightly's commit and each
mod's id, version and file SHA-256 (`horizon_release_sign::native_build::Request`), the text whose SHA-256 the
launcher computes to look the build up. A set with a post-build step is never exported, because that step runs on
the player's own game files. Downloading and checking the signed builds in the launcher comes next.
## Rebuilds stay on the instance's Horizon
A build starts from the version the instance plays (`native_mods::base_for`). When that is a native build, the
+74
View File
@@ -805,6 +805,16 @@ enum Cmd {
requires = "folder"
)]
server_output: Option<PathBuf>,
/// Prebuilt mode, for the registry's CI (#11859): build every .hnmod file in FOLDER as a
/// player's launcher would, and write the program, its native-mods.json and request.txt
/// into this new folder instead of installing it. The base needs no prepared game files
#[arg(
long = "prebuilt-out",
value_name = "DIR",
conflicts_with_all = ["instance", "server_output", "update"],
requires = "folder"
)]
prebuilt_out: Option<PathBuf>,
/// The instance whose installed native mods to build, and which then plays the build
/// (default: the selected one)
#[arg(long)]
@@ -1314,6 +1324,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Cmd::NativeBuild {
folder,
server_output,
prebuilt_out,
instance,
version,
update,
@@ -1334,6 +1345,30 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
let secs = started.elapsed().as_secs();
eprintln!("[{:>2}:{:02}] {}", secs / 60, secs % 60, step.label())
};
if let Some(out) = prebuilt_out {
// For the registry's CI: no instance, no install, no launcher config.
let folder = folder.unwrap();
let base = match &version {
Some(v) => backend::resolve_version(Some(v))?,
None => native_mods::base_for(None, &backend::installed(), false)
.ok_or_else(backend::no_version_installed)?,
};
let made = horizon_launcher::native_prebuilt::export(
&base, &folder, &out, profile, steps,
)?;
let left: Vec<&str> = made.left_out.iter().map(|l| l.id.as_str()).collect();
println!(
"{}",
serde_json::json!({
"key": made.request.key(),
"target": made.request.target,
"base": made.request.base,
"version": made.version,
"left_out": left,
})
);
return Ok(());
}
if let Some(output) = server_output {
// Export mode: a standalone server engine in `output`. No instance is read
// or changed, and no launcher config is written; `folder` is required (clap).
@@ -3970,6 +4005,45 @@ mod tests {
));
}
#[test]
fn prebuilt_mode_needs_mods_and_builds_for_no_instance() {
let parse = |args: &[&str]| {
Cli::try_parse_from(
[
&["horizon-launcher", "native-build", "--trust-native-code"],
args,
]
.concat(),
)
};
match parse(&["hnmods", "--prebuilt-out", "out", "--version", "nightly"])
.unwrap()
.command
{
Some(Cmd::NativeBuild {
folder,
prebuilt_out,
version,
..
}) => {
assert_eq!(folder.as_deref(), Some(Path::new("hnmods")));
assert_eq!(prebuilt_out.as_deref(), Some(Path::new("out")));
assert_eq!(version.as_deref(), Some("nightly"));
}
_ => panic!("expected native-build"),
}
// The CI names its mods; an instance's, a server engine or a move to another Horizon
// are a player's builds.
assert!(parse(&["--prebuilt-out", "out"]).is_err());
for other in [
&["--instance", "A"][..],
&["--server-output", "engine"],
&["--update"],
] {
assert!(parse(&[&["hnmods", "--prebuilt-out", "out"][..], other].concat()).is_err());
}
}
#[test]
fn export_mode_base_is_an_installed_release_not_a_native_build() {
// The export mode picks the newest installed release or Nightly, never a native build
+1
View File
@@ -33,6 +33,7 @@ pub mod mojang;
mod native_cache;
pub mod native_fit;
pub mod native_mods;
pub mod native_prebuilt;
pub mod native_status;
pub mod nightly;
pub mod other_launchers;
+93 -15
View File
@@ -198,6 +198,17 @@ fn platform_for_game(base: &str) -> Result<BuildPlatform, String> {
windows_build_platform(machine, host.target)
}
/// The Rust target a build on installed version `base` compiles for: a prebuilt build's
/// (`native_prebuilt`) is named by it.
pub(crate) fn build_target(base: &str) -> Result<&'static str, String> {
match platform_for_game(base)?.target {
Some(target) => Ok(target),
None => rust_toolchain::host()
.map(|h| h.target)
.ok_or_else(|| architecture_mismatch(std::env::consts::ARCH, "a supported computer")),
}
}
fn windows_build_platform(machine: u16, host_target: &str) -> Result<BuildPlatform, String> {
let target = rust_toolchain::windows_target_for_machine(machine).ok_or_else(|| {
architecture_mismatch(&format!("PE machine 0x{machine:04x}"), "x64 or ARM64")
@@ -627,12 +638,7 @@ pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
if !crate::browser::java::is_enabled(home, &bundle.manifest.id) {
continue;
}
let dir = out.join(&bundle.manifest.id);
for (name, bytes) in &bundle.files {
let to = dir.join(name);
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
}
unpack(&bundle, &out)?;
count += 1;
}
if count == 0 {
@@ -644,6 +650,20 @@ pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
Ok(out)
}
/// Writes a checked `.hnmod`'s files into `out/<id>/`, as a build reads an installed mod.
pub(crate) fn unpack(
bundle: &horizon_mods::index::native::Bundle,
out: &Path,
) -> Result<(), String> {
let dir = out.join(&bundle.manifest.id);
for (name, bytes) in &bundle.files {
let to = dir.join(name);
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
}
Ok(())
}
/// A mixin bundle: a folder with a `horizon-mixin.toml`, and the revisions it pins.
#[derive(Clone, Debug, PartialEq)]
pub struct Bundle {
@@ -1133,6 +1153,11 @@ fn ask_github(repo: &str, revision: &str) -> Result<String, String> {
/// use and the player hasn't agreed to try (`anyway`); the rest lists them.
pub const MISMATCH: &str = "Made for other Horizon versions: ";
/// Why a set with a post-build step is never exported as a prebuilt build: the step runs in the
/// installed version, on the player's own game files (`native_prebuilt`, #11859).
pub const PREBUILT_POST_BUILD: &str =
"A mod of this set has a post-build step, which runs on the player's own game files: it is built on the player's computer, never prebuilt.";
/// Each pinned mod whose Horizon revision isn't `revision`, as `id → pin`, and the build's.
fn mismatch(mods: &[Bundle], revision: &str) -> Option<String> {
let list: Vec<String> = mods
@@ -2219,9 +2244,14 @@ fn pipeline(
progress: &impl Fn(Step),
skip: &[LeftOut],
isolate: bool,
export: Option<&Path>,
) -> Result<Built, Failed> {
let started = std::time::Instant::now();
backend::need_prepared(base)?;
// An export (`native_prebuilt::export`, the registry's CI) never plays: it needs only the
// version's program, never the player's prepared game files.
if export.is_none() {
backend::need_prepared(base)?;
}
let platform = platform_for_game(base)?;
fs::create_dir_all(root()).map_err(|e| e.to_string())?;
// The mods are copied first: the build and its key use the copy, whatever happens to the
@@ -2375,7 +2405,7 @@ fn pipeline(
.map(|m| Ok((m.id.clone(), mod_hash(m)?)))
.collect::<Result<_, String>>()?,
};
let exists = target.directory(&version).join(".installed").is_file();
let exists = export.is_none() && target.directory(&version).join(".installed").is_file();
let plan = classify(
exists,
last_record(target, profile, platform.cache_key()).as_ref(),
@@ -2465,6 +2495,11 @@ fn pipeline(
let stage = stage_dir(&real)?;
sync(&fresh, &stage)?;
let _ = fs::remove_dir_all(&fresh);
// A post-build step runs in the installed version, on the player's game files: a build
// with one is only ever made on that computer.
if export.is_some() && !crates::post_builds(&stage)?.is_empty() {
return Err(PREBUILT_POST_BUILD.to_string().into());
}
if crates::any(&stage) || has_api {
progress(Step::Crates);
lock_crates(&rust, &source, &stage, &mods)?;
@@ -2588,14 +2623,21 @@ fn pipeline(
})).collect::<Vec<_>>(),
"left_out": left_out.iter().map(LeftOut::to_json).collect::<Vec<_>>(),
});
install(base, &version, target, &binary, &about, |dir| {
for (step, program) in &post {
progress(Step::PostBuild(step.mod_id.clone()));
let cmd = crates::post_build(program, step, dir, &real.join("cache"), &stage)?;
run(cmd, &format!("{}'s post-build step", step.mod_id), |_| {})?;
match export {
Some(out) => {
copy_out(&binary, &out.join(target.binary()))?;
fs::write(out.join("native-mods.json"), about.to_string())
.map_err(|e| e.to_string())?;
}
Ok(())
})?;
None => install(base, &version, target, &binary, &about, |dir| {
for (step, program) in &post {
progress(Step::PostBuild(step.mod_id.clone()));
let cmd = crates::post_build(program, step, dir, &real.join("cache"), &stage)?;
run(cmd, &format!("{}'s post-build step", step.mod_id), |_| {})?;
}
Ok(())
})?,
}
let _ = fs::remove_dir_all(real.join("programs"));
} else {
// Used again: it counts as the newest of the builds kept (`prune`).
@@ -2694,6 +2736,7 @@ pub fn build(
&progress,
skip,
true,
None,
)
},
&progress,
@@ -2713,6 +2756,40 @@ pub fn build(
})
}
/// Builds the bundles in `folder` as a player's launcher builds them on installed version
/// `base` (left-out mods included), and writes the program and its `native-mods.json` into `out`
/// instead of installing it: a prebuilt build for the registry's CI (`native_prebuilt::export`).
/// Nothing is installed, no instance is changed, and `base` needs no prepared game files.
/// Returns the version a player's build of the same set would install, and the mods left out.
pub(crate) fn build_prebuilt(
base: &str,
folder: &Path,
out: &Path,
profile: Profile,
progress: impl Fn(Step),
) -> Result<(String, Vec<LeftOut>), String> {
logged(|| {
let built = isolated(
|skip| {
pipeline(
base,
folder,
false,
false,
profile,
Target::Client,
&progress,
skip,
true,
Some(out),
)
},
&progress,
)?;
Ok((built.version, built.left_out))
})
}
/// Builds `folder`'s mods as the headless engine a dedicated server runs (`horizon-engine`,
/// docs/server.md): the same pipeline as [`build`] on the same revision, compiled with
/// `--no-default-features`, installed as its own version (its key distinct from the game's, so
@@ -2768,6 +2845,7 @@ pub fn build_server(
&progress,
&[],
false,
None,
)
.map_err(String::from)?;
export(&Target::Server.directory(&built.version), &output)?;
+225
View File
@@ -0,0 +1,225 @@
//! Prebuilt native builds (#11859): the game compiled for one exact set of registry native mods by
//! horizon-mods' CI, so players download it instead of compiling.
//!
//! A build is named by its request (`horizon_release_sign::native_build::Request`): the target, the
//! commit of the release or Nightly it starts from, and each mod's id, version and the SHA-256 of
//! its `.hnmod`. [`request_for`] computes it the same way on the player's computer and in CI.
//! [`export`] is CI's side: `horizon-launcher native-build --prebuilt-out DIR FOLDER` builds the
//! `.hnmod` files in FOLDER with the player's pipeline (the same pins, hook points and left-out
//! mods), and writes the program, its `native-mods.json` and the request into DIR, for the workflow
//! to sign. A set with a post-build step is never exported: the step runs on the player's game files.
use crate::native_mods::{self, LeftOut, Profile, Step};
use horizon_release_sign::native_build::{Mod, Request};
use std::fs;
use std::path::{Path, PathBuf};
/// The request's text in an export's folder: what the workflow checks against its own.
pub const REQUEST_FILE: &str = "request.txt";
/// The `.hnmod` files directly in `dir`, by name.
pub fn hnmod_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "hnmod"))
.collect();
files.sort();
Ok(files)
}
/// The request for building `files` (registry `.hnmod`s, each checked as the registry checks it)
/// for `target` on commit `base`: each mod's id and version from its `horizon-native.json`, and the
/// file's SHA-256.
pub fn request_for(target: &str, base: &str, files: &[PathBuf]) -> Result<Request, String> {
let mods = files
.iter()
.map(|path| {
let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let bundle = horizon_mods::index::native::Bundle::read(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Mod {
id: bundle.manifest.id,
version: bundle.manifest.version,
sha256: horizon_release_sign::sha256_hex(&bytes),
})
})
.collect::<Result<Vec<_>, String>>()?;
Request::new(target, base, mods)
}
/// What [`export`] made.
#[derive(Debug)]
pub struct Exported {
pub request: Request,
/// The version a player's own build of this set would install (`native-<commit>-<key>`).
pub version: String,
/// The mods the build left out, as a player's build leaves them out.
pub left_out: Vec<LeftOut>,
}
/// Builds every `.hnmod` in `folder` on installed version `base` as a player's launcher would,
/// and writes into `out` (a new folder): the program, its `native-mods.json` and [`REQUEST_FILE`].
/// Nothing is installed and `base` needs no prepared game files.
pub fn export(
base: &str,
folder: &Path,
out: &Path,
profile: Profile,
progress: impl Fn(Step),
) -> Result<Exported, String> {
let files = hnmod_files(folder)?;
if files.is_empty() {
return Err(format!("{} holds no .hnmod file.", folder.display()));
}
let empty = fs::read_dir(out).map_or(true, |mut dir| dir.next().is_none());
if !empty {
return Err(format!(
"{} already holds files: export into a new folder.",
out.display()
));
}
let request = request_for(
native_mods::build_target(base)?,
&native_mods::revision_of(base)?,
&files,
)?;
fs::create_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
// Unpacked as a player's installed mods are (`native_mods::installed_mods`), so the build's
// record names them alike.
let incoming = native_mods::root().join("prebuilt.incoming");
let _ = fs::remove_dir_all(&incoming);
for path in &files {
let bundle = horizon_mods::index::native::Bundle::read(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
native_mods::unpack(&bundle, &incoming)?;
}
let built = native_mods::build_prebuilt(base, &incoming, out, profile, progress);
let _ = fs::remove_dir_all(&incoming);
let (version, left_out) = built?;
fs::write(out.join(REQUEST_FILE), request.text()).map_err(|e| e.to_string())?;
Ok(Exported {
request,
version,
left_out,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("native-prebuilt-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// A registry `.hnmod` for `id` at `version`, as `.hnmod` files are zipped.
fn hnmod(dir: &Path, file: &str, id: &str, version: &str) -> PathBuf {
hnmod_with(dir, file, id, version, "")
}
/// [`hnmod`] with `extra` at the end of its manifest.
fn hnmod_with(dir: &Path, file: &str, id: &str, version: &str, extra: &str) -> PathBuf {
let pin = "a".repeat(40);
let path = dir.join(file);
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
let manifest = serde_json::json!({
"schema": 1, "id": id, "version": version, "game": "26.3", "horizon": pin, "mixins": pin
});
let source = format!(
"[mod]\nid = \"{id}\"\nversion = \"{version}\"\n[compatibility]\nhorizon_revision = \"{pin}\"\n{extra}"
);
for (name, text) in [
("horizon-native.json".to_string(), manifest.to_string()),
(format!("mixins/{id}/horizon-mixin.toml"), source),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
}
fn sha256(path: &Path) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(fs::read(path).unwrap())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn a_request_names_each_file_by_its_manifest_and_its_bytes() {
let dir = scratch("request");
// The file name is not the id: the manifest says what the mod is.
let zoom = hnmod(&dir, "b.hnmod", "zoom", "2.0.0");
let minimap = hnmod(&dir, "a-renamed.hnmod", "minimap", "1.4.0");
fs::write(dir.join("notes.txt"), "not a mod").unwrap();
let files = hnmod_files(&dir).unwrap();
assert_eq!(files, vec![minimap.clone(), zoom.clone()]);
let base = "6".repeat(40);
let request = request_for("x86_64-unknown-linux-gnu", &base, &files).unwrap();
let expected = Request::new(
"x86_64-unknown-linux-gnu",
&base,
vec![
Mod {
id: "zoom".into(),
version: "2.0.0".into(),
sha256: sha256(&zoom),
},
Mod {
id: "minimap".into(),
version: "1.4.0".into(),
sha256: sha256(&minimap),
},
],
)
.unwrap();
assert_eq!(request, expected);
// Another file for the same mod and version is another build.
let other = scratch("request-other");
let edited = hnmod_with(&other, "b.hnmod", "zoom", "2.0.0", "# edited\n");
fs::copy(&minimap, other.join("a.hnmod")).unwrap();
let changed = request_for(
"x86_64-unknown-linux-gnu",
&base,
&hnmod_files(&other).unwrap(),
)
.unwrap();
assert_ne!(sha256(&edited), sha256(&zoom));
assert_ne!(changed.key(), request.key());
fs::remove_dir_all(dir).unwrap();
fs::remove_dir_all(other).unwrap();
}
#[test]
fn a_file_the_registry_would_refuse_names_no_build() {
let dir = scratch("refused");
fs::write(dir.join("broken.hnmod"), b"not a zip").unwrap();
let files = hnmod_files(&dir).unwrap();
assert!(request_for("x86_64-unknown-linux-gnu", &"6".repeat(40), &files).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn an_export_needs_mods_and_a_new_folder_before_anything_runs() {
let mods = scratch("export-mods");
let out = scratch("export-out");
let nothing = export("v0.0.1", &mods, &out.join("new"), Profile::Release, |_| {});
assert!(nothing.unwrap_err().contains("no .hnmod"));
hnmod(&mods, "zoom.hnmod", "zoom", "2.0.0");
fs::write(out.join("left-over"), "").unwrap();
let taken = export("v0.0.1", &mods, &out, Profile::Release, |_| {});
assert!(taken.unwrap_err().contains("already holds files"));
fs::remove_dir_all(mods).unwrap();
fs::remove_dir_all(out).unwrap();
}
}