mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e599462a | ||
|
|
31cfa786d2 | ||
|
|
68be90d92b | ||
|
|
246a39cfc2 | ||
|
|
2325ba52d4 |
No files matched your search
@@ -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, the mods' content 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
|
||||
|
||||
@@ -1966,6 +1966,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`, the mods'
|
||||
content (`native-content/`, as an install writes it) 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
|
||||
|
||||
+78
-92
@@ -16,6 +16,9 @@ use std::io::{IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod media;
|
||||
use media::{multiplayer, screenshots};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "horizon-launcher",
|
||||
@@ -807,6 +810,17 @@ 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, the mods' content
|
||||
/// 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)]
|
||||
@@ -1316,6 +1330,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
Cmd::NativeBuild {
|
||||
folder,
|
||||
server_output,
|
||||
prebuilt_out,
|
||||
instance,
|
||||
version,
|
||||
update,
|
||||
@@ -1336,6 +1351,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).
|
||||
@@ -3486,98 +3525,6 @@ fn set_pack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn screenshots(action: ScreenshotsCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::screenshots;
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
ScreenshotsCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let shots = screenshots::list(&home);
|
||||
if as_json {
|
||||
json(
|
||||
shots
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let millis = s
|
||||
.taken
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
serde_json::json!({ "name": s.name, "path": s.path, "taken_ms": millis, "size": s.size })
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
} else if shots.is_empty() {
|
||||
println!("No screenshots in {}", screenshots::folder(&home).display());
|
||||
}
|
||||
for s in shots.iter().filter(|_| !as_json) {
|
||||
println!("{}", s.path.display());
|
||||
}
|
||||
}
|
||||
ScreenshotsCmd::Delete {
|
||||
screenshot,
|
||||
instance,
|
||||
} => {
|
||||
let shot = screenshots::find(&home(instance)?, &screenshot)?;
|
||||
screenshots::delete(&shot)?;
|
||||
println!("{} is in the trash.", shot.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn multiplayer(action: MultiplayerCmd, as_json: bool) -> Result<(), String> {
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
MultiplayerCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let servers = server_list::List::load(&home).servers();
|
||||
if as_json {
|
||||
json(
|
||||
servers
|
||||
.iter()
|
||||
.map(|s| serde_json::json!({ "name": s.name, "address": s.ip, "icon": s.icon.is_some() }))
|
||||
.collect(),
|
||||
);
|
||||
} else if servers.is_empty() {
|
||||
println!("No servers in {}", home.join("servers.dat").display());
|
||||
}
|
||||
for (i, s) in servers.iter().enumerate().filter(|_| !as_json) {
|
||||
println!("{}. {} ({})", i + 1, s.name, s.ip);
|
||||
}
|
||||
}
|
||||
MultiplayerCmd::Add {
|
||||
address,
|
||||
name,
|
||||
instance,
|
||||
} => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
list.add(name.as_deref().unwrap_or(""), &address)?;
|
||||
list.save(&home)?;
|
||||
println!("Added {}.", address.trim());
|
||||
}
|
||||
MultiplayerCmd::Remove { server, instance } => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
let (i, _) = server_list::find(&list, &server)?;
|
||||
let gone = list.remove(i)?;
|
||||
list.save(&home)?;
|
||||
println!("Removed {} ({}).", gone.name, gone.ip);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worlds(action: WorldsCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::datapacks;
|
||||
use horizon_launcher::worlds::{self, World};
|
||||
@@ -4030,6 +3977,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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! `screenshots` and `multiplayer`: the commands over an instance's screenshots and server list.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn screenshots(action: ScreenshotsCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::screenshots;
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
ScreenshotsCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let shots = screenshots::list(&home);
|
||||
if as_json {
|
||||
json(
|
||||
shots
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let millis = s
|
||||
.taken
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
serde_json::json!({ "name": s.name, "path": s.path, "taken_ms": millis, "size": s.size })
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
} else if shots.is_empty() {
|
||||
println!("No screenshots in {}", screenshots::folder(&home).display());
|
||||
}
|
||||
for s in shots.iter().filter(|_| !as_json) {
|
||||
println!("{}", s.path.display());
|
||||
}
|
||||
}
|
||||
ScreenshotsCmd::Delete {
|
||||
screenshot,
|
||||
instance,
|
||||
} => {
|
||||
let shot = screenshots::find(&home(instance)?, &screenshot)?;
|
||||
screenshots::delete(&shot)?;
|
||||
println!("{} is in the trash.", shot.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn multiplayer(action: MultiplayerCmd, as_json: bool) -> Result<(), String> {
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
MultiplayerCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let servers = server_list::List::load(&home).servers();
|
||||
if as_json {
|
||||
json(
|
||||
servers
|
||||
.iter()
|
||||
.map(|s| serde_json::json!({ "name": s.name, "address": s.ip, "icon": s.icon.is_some() }))
|
||||
.collect(),
|
||||
);
|
||||
} else if servers.is_empty() {
|
||||
println!("No servers in {}", home.join("servers.dat").display());
|
||||
}
|
||||
for (i, s) in servers.iter().enumerate().filter(|_| !as_json) {
|
||||
println!("{}. {} ({})", i + 1, s.name, s.ip);
|
||||
}
|
||||
}
|
||||
MultiplayerCmd::Add {
|
||||
address,
|
||||
name,
|
||||
instance,
|
||||
} => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
list.add(name.as_deref().unwrap_or(""), &address)?;
|
||||
list.save(&home)?;
|
||||
println!("Added {}.", address.trim());
|
||||
}
|
||||
MultiplayerCmd::Remove { server, instance } => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
let (i, _) = server_list::find(&list, &server)?;
|
||||
let gone = list.remove(i)?;
|
||||
list.save(&home)?;
|
||||
println!("Removed {} ({}).", gone.name, gone.ip);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+84
-115
@@ -19,6 +19,7 @@ use crate::backend;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::instances::{self, Instance};
|
||||
use crate::rust_toolchain::{self, Toolchain};
|
||||
use conflicts::warn_conflicts;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
@@ -27,6 +28,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
mod api;
|
||||
mod conflicts;
|
||||
mod crates;
|
||||
mod dependencies;
|
||||
mod diagnostics;
|
||||
@@ -35,6 +37,7 @@ mod inject_cache;
|
||||
mod isolate;
|
||||
mod source;
|
||||
|
||||
pub(crate) use source::unpack;
|
||||
use source::{
|
||||
api_target, build_files, bundle_files, files, files_with_api, install_content,
|
||||
installed_mods_into, mod_hash, source_digest,
|
||||
@@ -204,6 +207,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")
|
||||
@@ -861,111 +875,6 @@ fn is_revision(text: &str) -> bool {
|
||||
text.len() == 40 && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// The build's warnings about the mods' injections (#6490): two mods on one
|
||||
/// place, mods wrapping one function at its head and its return, and a
|
||||
/// cancelling injection on a native hook point. Neither stops the build —
|
||||
/// they say what the player and the author could not otherwise see. The
|
||||
/// places are the manifest's: whether two selectors pick the same call, or
|
||||
/// a raw `target` names a hook point's function, only horizon-mixins
|
||||
/// resolves, so the warnings stay to what the manifest says for sure.
|
||||
fn warn_conflicts(mods: &[Bundle]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// One place (the function, the kind, the selector, the ordinal), the
|
||||
// injections of at least two mods on it, ordered as horizon-mixins runs
|
||||
// same-place injections: priority descending, mod id ascending,
|
||||
// injection id ascending — the tail of its own sort, which the place
|
||||
// prefixes already fixed.
|
||||
let mut places: BTreeMap<_, Vec<(&str, &Injection)>> = BTreeMap::new();
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
places.entry(i.place()).or_default().push((&b.id, i));
|
||||
}
|
||||
}
|
||||
for (_, list) in places {
|
||||
let mut list = list;
|
||||
list.sort_by_key(|(mod_id, i)| {
|
||||
(
|
||||
std::cmp::Reverse(i.priority),
|
||||
(*mod_id).to_owned(),
|
||||
i.id.clone(),
|
||||
)
|
||||
});
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for (mod_id, _) in &list {
|
||||
if !names.contains(mod_id) {
|
||||
names.push(mod_id);
|
||||
}
|
||||
}
|
||||
if names.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let first = list[0].1;
|
||||
let both = if names.len() == 2 { "both" } else { "all" };
|
||||
out.push(format!(
|
||||
"mods {} {both} inject at {}; {} runs first (priority descending, mod id ascending).",
|
||||
names.join(", "),
|
||||
first.place_as_text(),
|
||||
names[0]
|
||||
));
|
||||
}
|
||||
// A function's head and its return: two mods wrapping the whole
|
||||
// function, which run in that order whatever their priorities. Said
|
||||
// without a "runs first".
|
||||
let mut functions: BTreeMap<&InjectionSite, Vec<(&str, &Injection)>> = BTreeMap::new();
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
if i.kind.as_deref() == Some("HEAD") || i.kind.as_deref() == Some("RETURN") {
|
||||
functions.entry(&i.site).or_default().push((&b.id, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (site, list) in functions {
|
||||
let kinds: std::collections::BTreeSet<&str> =
|
||||
list.iter().filter_map(|(_, i)| i.kind.as_deref()).collect();
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for (mod_id, _) in &list {
|
||||
if !names.contains(mod_id) {
|
||||
names.push(mod_id);
|
||||
}
|
||||
}
|
||||
// Both sides, and more than one mod on the function: a single mod
|
||||
// wrapping its own function at both ends is no one's conflict.
|
||||
if kinds.len() < 2 || names.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let sides: Vec<String> = list
|
||||
.iter()
|
||||
.map(|(mod_id, i)| format!("{mod_id} ({})", i.kind.as_deref().unwrap_or("")))
|
||||
.collect();
|
||||
out.push(format!(
|
||||
"mods {} inject into {}: HEAD runs on entry, RETURN on return, whatever their priorities.",
|
||||
sides.join(", "),
|
||||
site.as_text()
|
||||
));
|
||||
}
|
||||
// A cancelling `HEAD` on a hook point: a `Break` skips the later
|
||||
// injections at the place and the function's body. A raw `target` is
|
||||
// only known to be a hook point through the catalog, which the launcher
|
||||
// does not read; a `hook` name is one by definition. Only `HEAD` is
|
||||
// said: the interior kinds' cancellation skips less than the body.
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
if i.cancellable
|
||||
&& i.kind.as_deref() == Some("HEAD")
|
||||
&& matches!(i.site, InjectionSite::Hook(_))
|
||||
{
|
||||
out.push(format!(
|
||||
"{}'s `{}` can cancel at {}: a Break skips the later injections and the function's body.",
|
||||
b.id,
|
||||
i.id,
|
||||
i.place_as_text()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Appends `text` to the build log, the way a compiler warning reads.
|
||||
fn log_warning(text: &str) {
|
||||
if let Ok(mut log) = fs::OpenOptions::new()
|
||||
@@ -1096,6 +1005,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
|
||||
@@ -2109,9 +2023,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
|
||||
@@ -2265,7 +2184,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(),
|
||||
@@ -2355,6 +2274,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)?;
|
||||
@@ -2476,15 +2400,23 @@ fn pipeline(
|
||||
})).collect::<Vec<_>>(),
|
||||
"left_out": left_out.iter().map(LeftOut::to_json).collect::<Vec<_>>(),
|
||||
});
|
||||
install(base, &version, target, &binary, &about, |dir| {
|
||||
install_content(&mods, 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())?;
|
||||
install_content(&mods, out)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
None => install(base, &version, target, &binary, &about, |dir| {
|
||||
install_content(&mods, 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`).
|
||||
@@ -2585,6 +2517,7 @@ pub fn build(
|
||||
&progress,
|
||||
skip,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
},
|
||||
&progress,
|
||||
@@ -2637,6 +2570,41 @@ 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,
|
||||
)
|
||||
.map_err(|(error, _)| error)?;
|
||||
Ok((built.version, built.left_out))
|
||||
})
|
||||
}
|
||||
|
||||
/// The mods in `bundles` a build left out (`left_out`) for not building on its commit though they
|
||||
/// pin another one: on theirs, they may.
|
||||
fn made_elsewhere(bundles: &[Bundle], left_out: &[LeftOut]) -> Vec<String> {
|
||||
@@ -2703,6 +2671,7 @@ pub fn build_server(
|
||||
&progress,
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.map_err(String::from)?;
|
||||
export(&Target::Server.directory(&built.version), &output)?;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Where two mods inject into the same place: the warnings a build prints (`warn_conflicts`).
|
||||
use super::*;
|
||||
|
||||
/// The build's warnings about the mods' injections (#6490): two mods on one
|
||||
/// place, mods wrapping one function at its head and its return, and a
|
||||
/// cancelling injection on a native hook point. Neither stops the build —
|
||||
/// they say what the player and the author could not otherwise see. The
|
||||
/// places are the manifest's: whether two selectors pick the same call, or
|
||||
/// a raw `target` names a hook point's function, only horizon-mixins
|
||||
/// resolves, so the warnings stay to what the manifest says for sure.
|
||||
pub(super) fn warn_conflicts(mods: &[Bundle]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// One place (the function, the kind, the selector, the ordinal), the
|
||||
// injections of at least two mods on it, ordered as horizon-mixins runs
|
||||
// same-place injections: priority descending, mod id ascending,
|
||||
// injection id ascending — the tail of its own sort, which the place
|
||||
// prefixes already fixed.
|
||||
let mut places: BTreeMap<_, Vec<(&str, &Injection)>> = BTreeMap::new();
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
places.entry(i.place()).or_default().push((&b.id, i));
|
||||
}
|
||||
}
|
||||
for (_, list) in places {
|
||||
let mut list = list;
|
||||
list.sort_by_key(|(mod_id, i)| {
|
||||
(
|
||||
std::cmp::Reverse(i.priority),
|
||||
(*mod_id).to_owned(),
|
||||
i.id.clone(),
|
||||
)
|
||||
});
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for (mod_id, _) in &list {
|
||||
if !names.contains(mod_id) {
|
||||
names.push(mod_id);
|
||||
}
|
||||
}
|
||||
if names.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let first = list[0].1;
|
||||
let both = if names.len() == 2 { "both" } else { "all" };
|
||||
out.push(format!(
|
||||
"mods {} {both} inject at {}; {} runs first (priority descending, mod id ascending).",
|
||||
names.join(", "),
|
||||
first.place_as_text(),
|
||||
names[0]
|
||||
));
|
||||
}
|
||||
// A function's head and its return: two mods wrapping the whole
|
||||
// function, which run in that order whatever their priorities. Said
|
||||
// without a "runs first".
|
||||
let mut functions: BTreeMap<&InjectionSite, Vec<(&str, &Injection)>> = BTreeMap::new();
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
if i.kind.as_deref() == Some("HEAD") || i.kind.as_deref() == Some("RETURN") {
|
||||
functions.entry(&i.site).or_default().push((&b.id, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (site, list) in functions {
|
||||
let kinds: std::collections::BTreeSet<&str> =
|
||||
list.iter().filter_map(|(_, i)| i.kind.as_deref()).collect();
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for (mod_id, _) in &list {
|
||||
if !names.contains(mod_id) {
|
||||
names.push(mod_id);
|
||||
}
|
||||
}
|
||||
// Both sides, and more than one mod on the function: a single mod
|
||||
// wrapping its own function at both ends is no one's conflict.
|
||||
if kinds.len() < 2 || names.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let sides: Vec<String> = list
|
||||
.iter()
|
||||
.map(|(mod_id, i)| format!("{mod_id} ({})", i.kind.as_deref().unwrap_or("")))
|
||||
.collect();
|
||||
out.push(format!(
|
||||
"mods {} inject into {}: HEAD runs on entry, RETURN on return, whatever their priorities.",
|
||||
sides.join(", "),
|
||||
site.as_text()
|
||||
));
|
||||
}
|
||||
// A cancelling `HEAD` on a hook point: a `Break` skips the later
|
||||
// injections at the place and the function's body. A raw `target` is
|
||||
// only known to be a hook point through the catalog, which the launcher
|
||||
// does not read; a `hook` name is one by definition. Only `HEAD` is
|
||||
// said: the interior kinds' cancellation skips less than the body.
|
||||
for b in mods {
|
||||
for i in &b.injections {
|
||||
if i.cancellable
|
||||
&& i.kind.as_deref() == Some("HEAD")
|
||||
&& matches!(i.site, InjectionSite::Hook(_))
|
||||
{
|
||||
out.push(format!(
|
||||
"{}'s `{}` can cancel at {}: a Break skips the later injections and the function's body.",
|
||||
b.id,
|
||||
i.id,
|
||||
i.place_as_text()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -88,12 +88,7 @@ pub(super) fn installed_mods_into(home: &Path, out: &Path) -> Result<PathBuf, St
|
||||
&[("id", &bundle.manifest.id)],
|
||||
));
|
||||
}
|
||||
let dir = out.join(&bundle.manifest.id);
|
||||
for (name, bytes) in bundle.source_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 {
|
||||
@@ -105,6 +100,21 @@ pub(super) fn installed_mods_into(home: &Path, out: &Path) -> Result<PathBuf, St
|
||||
Ok(out.to_path_buf())
|
||||
}
|
||||
|
||||
/// Writes a checked mod file's source files into `out/<id>/`, as a build reads an installed mod
|
||||
/// (`installed_mods_into`, and `native_prebuilt::export` for the registry's CI).
|
||||
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.source_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(())
|
||||
}
|
||||
|
||||
/// Every file under `dir`, by its path relative to `dir` (with `/`), sorted. A symlink is refused:
|
||||
/// horizon-mixins refuses them too.
|
||||
pub(super) fn files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! 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 into DIR the program, its `native-mods.json`, the mods' content
|
||||
//! (`native-content/`, as an install writes it) and the request, 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`, the mods' content
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user