Compare commits

...
Author SHA1 Message Date
Ludovic Marie 0e58f6fec6 Merge commit 'refs/pr/3341' into union/pt3-4 2026-10-29 11:36:44 +01:00
Ludovic Marie 00a0afb2c9 Merge commit 'refs/pr/3341' into union/pt3-4 2026-10-29 11:16:56 +01:00
Ludovic Marie 0385c48cde Merge commit 'refs/pr/3253' into union/pt3-4 2026-10-29 11:16:56 +01:00
Ludovic Marie 1816cf0b52 Merge pull request #3612 from horizon-rust/feat/launcher-import-launchers
🤖 Agent: imports from Prism/MultiMC/official launcher, read-only on their folders (copy into a new Java instance), formats from public docs. Verify green; matrix 40853481205 green on Windows, macOS and Linux.
2026-10-29 11:15:16 +01:00
Ludovic Marie 258c5946b5 Merge pull request #3603 from horizon-rust/feat/launcher-java-modrinth
🤖 Agent: Modrinth for Java instances, filtered by version and loader; downloads go through download::save (safe names, checksums). Verify green; matrix 40853843158 green on Windows, macOS and Linux.
2026-10-29 11:15:11 +01:00
Ludovic Marie 452224a28a Merge pull request #3617 from horizon-rust/docs/native-mods-on-after-install
🤖 Agent: docs only (native-mods.md); pr_ready --quick green (408 Python tests); skip-release.
2026-10-29 11:09:54 +01:00
Ludovic Marie f8988d5a73 Merge pull request #3000 from hectoreccles/lod-small-end-islands
🤖 Agent: requeued by pr-triage-3 after a main merge (was dc3fc6961, cg27-verified, rdbt approved; stopped on a conflict with #2894). Both LOD tests kept. union/pt3-2 285ecb6e8 (main 2b294bbe3 + #2417 #2951 #2981 #3000 #3417 #3433 #2544 #2586): fmt, clippy -D warnings (horizon-client, -client-render, -client-core, -world, -items, all targets), their tests pass.
2026-10-29 11:05:54 +01:00
Ludovic MarieandClaude Opus 5.5 e6412d6e5c Launcher: the CLI test expects mods search to take --instance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:57:49 +01:00
Ludovic MarieandClaude Opus 5.5 90b583896a Docs: native mods turn on when a player installs one; Linux builds them
Since #3596 installing a native mod turns native mods on after a notice;
the intro and the Trust section still said they were off by default. And
a full game build on Linux is proven (an x86_64 server with no Rust of its
own, the launcher's toolchain, Zoom's proof passing on the built game), so
it leaves "Left to do" for the measurements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:55:27 +01:00
Ludovic MarieandClaude Opus 5.5 7db6a085bf Launcher: copy Prism, MultiMC and official launcher instances into Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:55:17 +01:00
Ludovic Marie 0b74b68aac Merge pull request #3598 from horizon-rust/feat/launcher-java-play
🤖 Agent: Play for Java instances. Diff reviewed: the owner gate holds (a session only for an account that owns the game; downloads via download_version_for_owner); #3451's guards kept. Verify green; build matrix 40851406886 green on Windows, macOS and Linux.
2026-10-29 10:51:41 +01:00
Ludovic Marie 1db57affe6 Merge pull request #3600 from horizon-rust/feat/launcher-java-inherits
🤖 Agent: inheritsFrom merge (child libraries first with parent duplicates dropped, arguments appended, parent jar). Diff reviewed; verify green; build matrix 40849739616 green on Windows, macOS and Linux.
2026-10-29 10:47:10 +01:00
Ludovic Marie e1844d064e Merge pull request #2971 from kacpherek/fix/skin-online-sound-options
🤖 Agent: requeued by contrib-gate-28 after merging main (B9 render crate split; stopped on a conflict or a known flake). fmt, clippy -D warnings and tests pass on the new head; proven earlier by cg26/cg27.
2026-10-29 10:41:47 +01:00
Ludovic MarieandClaude Opus 5.5 1231679c4d Launcher: Modrinth mods and resource packs for Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:40:02 +01:00
Ludovic Marie 9e9b9862ee Merge remote-tracking branch 'origin/main' into feat/launcher-java-play
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/fr.json
#	launcher/lang/ru.json
2026-10-29 10:39:44 +01:00
Ludovic Marie 13d7d9995d Merge pull request #3596 from horizon-rust/fix/launcher-native-mods-auto-on
🤖 Agent: Ludovic's decision 10-29 (native mods on when one is installed, notice once). Diff reviewed; verify green; build matrix 40848959744 green on Windows, macOS and Linux.
2026-10-29 10:35:00 +01:00
Ludovic MarieandClaude Opus 5.5 25f4545038 Merge main into this branch
lod.rs: main's chorus plant test (#2894) and this branch's small island test are
both kept; docs/graphics.md names chorus plants and small islands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:29:46 +01:00
Ludovic MarieandClaude Opus 5.5 abca441ad4 Launcher: Java versions that inherit from another (loader profiles)
A version file with `inheritsFrom` (what Fabric, Quilt and other mod
loaders install) is merged into the version it names, as the official
launcher reads it: its fields over the parent's, its libraries first (a
parent's library it names again is left out), its arguments after the
parent's, and the parent's client jar (`jar`). On a version from before
1.13, a profile's own argument lists join the old line instead of
replacing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:28 +01:00
Ludovic Marie 99b52c2617 Merge remote-tracking branch 'origin/main' into feat/launcher-java-play
# Conflicts:
#	launcher/lang/fr.json
#	launcher/lang/ru.json
#	launcher/src/main.rs
2026-10-29 10:23:05 +01:00
Ludovic Marie 8ea9a2c849 Merge remote-tracking branch 'origin/main' into fix/launcher-native-mods-auto-on
# Conflicts:
#	launcher/lang/fr.json
#	launcher/lang/ru.json
2026-10-29 10:20:52 +01:00
Ludovic MarieandClaude Opus 5.5 f220157a0f Launcher: installing a native mod turns native mods on
Ludovic's decision (2026-10-29). Native mods were on only once the
player found Settings > Native mods (experimental). Now installing a
native mod turns them on, so Settings offers its Build at once; players
who never install one keep them off.

The first native mod's Install shows a notice once, before anything is
installed: native mods compile into a game of their own, the first
build downloads Rust and can take 20 to 30 minutes, later builds are
faster (Install and turn on native mods · Cancel). `mods install`
turns them on the same way and prints the notice. The build itself
stays behind its trust warning, now saying 20 to 30 minutes too, and
turning a mod off in the game's Options > Mods is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:12:09 +01:00
Ludovic MarieandClaude Opus 5.5 fb27a63dce Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 b9449b2348 Merge main into fix/skin-online-sound-options
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 dc3fc6961e Decoration: World keeps its own doc comment
OriginWorld was inserted between World's doc comment and World, so the
comment documented both structs as one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:35:11 +01:00
Ludovic MarieandClaude Opus 5.5 7826732a5c Launcher: Play starts a Java instance's Minecraft
With a Java instance selected, Play builds its game off the window's
thread (`backend::java_game`: the owner gate, the version and Java
downloaded the first time, their lines in the log), then starts it as
the Horizon client starts. No account that owns the game opens the
sign-in screen, as for Horizon. Joining a friend and quick play stay
Horizon's.

The Play page names the instance's Minecraft version (a Game row in
place of Horizon's resources and Minecraft folder), needs no Horizon
version, and the sidebar shows the version. An old version's logged
session line never reaches the window.

The translations test also reads strings rustfmt moved to the line
after `tr(`/`trf(`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:08:34 +01:00
Ludovic MarieandClaude Opus 5.5 dcd733c7c1 Launcher: play a Java instance from the command line
`horizon-launcher play --instance <java instance>` starts its Minecraft:
Java Edition. Past the owner gate itself (no developer option), the
version is downloaded from Mojang the first time, the Java it needs is
the instance's own, one found here or Mojang's (downloaded), and the
game starts in the instance's folder as the account that owns it. With
no answer from the services, an owner plays with its last token.

Options only Horizon has (--version, --world, --creative, --join,
--server) are refused for Java instances; --arg passes through. The
window's Play still refuses them and names this command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:59:59 +01:00
Ludovic Marie a49e72201a Merge branch 'feat/launcher-java-instances' into feat/launcher-java-play 2026-10-28 22:51:29 +01:00
Hector Eccles e41443c64c fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
2026-10-28 13:42:04 -04:00
Hector EcclesandOpenCode d46bed5c36 perf: hash the world's integer bookkeeping maps with Fx
World.modified, inhabited, chest_openers and block_names used std SipHash
on small integer keys, and modified/inhabited rehash as they grow while an
area unloads. Use the existing crate::fast_hash maps; none of their
iterations is order-sensitive (save order comes from the batch list and
ChunkExtras::block_entity_order). chunks_with_block_entities takes the
names map by hasher so the faster map still fits.

BlockEntities::map stays std HashMap: crate::lan::chunks::body takes the
default-hasher map by type and lan/** is outside this change.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 9b9d045150 perf: find section palette ids with a scan and a last-state cache
BlockSection kept a HashMap of state ids per section (24 tables and
allocations per proto chunk) and hashed on every worldgen write. Resident
palettes are almost always tiny, and the resident container already scans
them; use the same scan with a one-entry last lookup so a run of one state
is a compare. set/compact behaviour and the palette, counts and free list
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 1e5cc9031f perf: share the multiply-shift index between paletted storage and PackedStorage
PackedStorage divided by its per-word width on every read and write, while
PalettedContainer already used an exact multiply-shift. Move MAGIC/locate/
mask into storage.rs and use them from both; only storages past 16,384
entries keep the division path.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode ee351caed8 perf: mark only the distinct sections a block write dirties
A write looped 27 block offsets through the dirty-section map; all but a
few hashed the same keys. Mark the exact section set instead: one key in
the interior, the adjacent section on a section face, a neighbour column
on a chunk border, and the diagonal where both borders meet (at most eight
keys). The mesher still sees the same 3x3x3 neighbourhood.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandDeepSeek V4.1 Flash a96f66ce1e Replay vanilla's small End island placement
The hash estimator placed islands with the right density but different
roots, so an estimate could vanish when the real chunk arrived. Reproduce
the feature's decoration seed, step-local index and placement modifiers
instead, run the feature on a no-op world to keep the random stream in
sync, and use those exact origins for the far cone. The per-cell biome
sampling is no longer needed; the feature's biome filter decides.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 17:52:07 -04:00
Hector EcclesandDeepSeek V4.1 Flash b561f83220 Draw small End islands in LOD terrain
The small_end_islands biome's only feature is end_island_decorated, so
unexplored End void rendered empty beyond the refined rings. Carry the
cell biome for void direct samples and estimate the feature: rarity and
count from its placement modifiers, in-square positions, the 55..70
height range, and the vanilla end-stone cone.

Vanilla reference: EndIslandFeature (BasicFeature::EndIsland and
minecraft:end_island_decorated), already ported in horizon-world.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 17:52:07 -04:00
Kacpherek 736842d40f Unlock the Distortion Effects slider
The option was greyed out and always 100 percent. The slider now moves and saves screenEffectScale, which vanilla's Options.screenEffectScale scales camera shakes and the nausea effects by.
2026-10-27 22:01:26 +01:00
Kacpherek ba37fa75d6 Add the Directional Audio and Music Toast sound rows
Vanilla's SoundOptionsScreen has both next to Show Subtitles and Music Frequency. Nothing in Horizon's audio code reads them yet, so they are greyed out. Audio Device is left out: Horizon opens the default output and cannot switch it.
2026-10-27 22:01:14 +01:00
Kacpherek e9fbcfef78 Add the Online options page with Allow Server Listing
Vanilla's OnlineOptionsScreen has Allow Server Listing, which ClientInformation carries to servers, and the notification and presence options. Horizon has no friends list or Realms, so those three rows are greyed out.
2026-10-27 22:00:52 +01:00
Kacpherek 206b7acee4 Add the Skin Customisation options page and hide skin layers
The seven skin layers and Main Hand were saved but had no page. Vanilla's SkinCustomizationScreen lists them; the layers hide on the player model and reach servers in ClientInformation (Options.createInformation), and other players' layers follow Avatar.DATA_PLAYER_MODE_CUSTOMISATION.
2026-10-27 22:00:31 +01:00
46 changed files with 2781 additions and 243 deletions

No files matched your search

@@ -570,6 +570,15 @@ impl OptionsFile {
self.values[i] = V::Int(p as i32);
}
}
/// `Options.modelCustomisation`: the PlayerModelPart bits the player shows
/// (cape first, as `modelPart_*` is in `MODEL_PARTS`).
pub fn model_customisation(&self) -> u8 {
MODEL_PARTS
.iter()
.enumerate()
.filter(|(_, part)| self.bool(&format!("modelPart_{part}")))
.fold(0, |bits, (bit, _)| bits | 1 << bit)
}
/// Key mapping value by mapping name.
pub fn key_binding(&self, name: &str) -> String {
self.string(&format!("key_{name}"))
+118 -19
View File
@@ -45,6 +45,16 @@ pub(crate) enum Kind {
Slider(f32),
Cycle,
}
/// `PlayerModelPart` keys, in vanilla's bit order (`modelPart_*` in options.txt).
const MODEL_PART_KEYS: [&str; 7] = [
"modelPart_cape",
"modelPart_jacket",
"modelPart_left_sleeve",
"modelPart_right_sleeve",
"modelPart_left_pants_leg",
"modelPart_right_pants_leg",
"modelPart_hat",
];
/// The Accessibility Settings page:
/// `AccessibilityOptionsScreen.options`, with the Controls link inserted separately.
pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
@@ -97,9 +107,15 @@ impl Opt {
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::DistortionEffects
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
// Saved, but no Horizon system reads them yet: no Realms, no
// friends list, no directional audio, no now-playing toast.
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::SharePresence
| Opt::DirectionalAudio
| Opt::MusicToast
// Saved, but nothing in rendering reads them yet.
| Opt::ChunkFade
| Opt::ImprovedTransparency
@@ -128,6 +144,13 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
Opt::AllowServerListing => "allowServerListing",
Opt::RealmsNotifications => "realmsNotifications",
Opt::InGameNotification => "inGameNotification",
Opt::SharePresence => "sharePresence",
Opt::DirectionalAudio => "directionalAudio",
Opt::MusicToast => "musicToast",
_ => return None,
})
}
@@ -225,6 +248,22 @@ impl Opt {
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadMenuNav => "horizon.options.menu_navigation",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
"options.modelPart.left_sleeve",
"options.modelPart.right_sleeve",
"options.modelPart.left_pants_leg",
"options.modelPart.right_pants_leg",
"options.modelPart.hat",
][i],
Opt::MainHand => "options.mainHand",
Opt::AllowServerListing => "options.allowServerListing",
Opt::RealmsNotifications => "options.realmsNotifications.button",
Opt::InGameNotification => "options.inGameNotification",
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -460,9 +499,29 @@ impl Opt {
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
on_off(s.file.bool(self.file_key().unwrap()))
}
Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::ModelPart(_)
| Opt::AllowServerListing
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::DirectionalAudio => on_off(s.file.bool(self.file_key().unwrap())),
Opt::MainHand => tr(if s.main_hand_left {
"options.mainHand.left"
} else {
"options.mainHand.right"
}),
Opt::SharePresence => tr([
"options.sharePresence.none",
"options.sharePresence.limited",
"options.sharePresence.all",
][s.file.int("sharePresence").clamp(0, 2) as usize]),
Opt::MusicToast => tr([
"options.musicToast.never",
"options.musicToast.pauseMenu",
"options.musicToast.pauseMenuAndToast",
][s.file.int("musicToast").clamp(0, 2) as usize]),
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
@@ -579,12 +638,14 @@ impl Opt {
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v).powi(2)),
),
Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => {
s.file.set(
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v)),
)
}
Opt::DistortionEffects
| Opt::DamageTilt
| Opt::PanoramaSpeed
| Opt::GlintSpeed
| Opt::GlintStrength => s.file.set(
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v)),
),
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
@@ -669,7 +730,12 @@ impl Opt {
| Opt::HighContrastBlockOutlines
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts => {
| Opt::SaveChatDrafts
| Opt::ModelPart(_)
| Opt::AllowServerListing
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::DirectionalAudio => {
let key = self.file_key().unwrap();
s.file
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
@@ -718,6 +784,17 @@ impl Opt {
s.file
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
}
Opt::MainHand => s.main_hand_left = !s.main_hand_left,
Opt::SharePresence => {
let n = next(s.file.int("sharePresence").clamp(0, 2) as u8, 3);
s.file
.set("sharePresence", crate::options_file::V::Int(i32::from(n)));
}
Opt::MusicToast => {
let n = next(s.file.int("musicToast").clamp(0, 2) as u8, 3);
s.file
.set("musicToast", crate::options_file::V::Int(i32::from(n)));
}
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
@@ -855,6 +932,10 @@ pub enum Page {
Horizon,
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
Agent,
/// SkinCustomizationScreen: the skin layers and Main Hand.
Skin,
/// OnlineOptionsScreen: server listing, Realms and friends notifications.
Online,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1173,6 +1254,8 @@ impl OptionsUi {
Page::Main => "options.title",
Page::Video => "options.videoTitle",
Page::Sound => "options.sounds.title",
Page::Skin => "options.skinCustomisation.title",
Page::Online => "options.online.title",
Page::Controls => "controls.title",
Page::KeyBinds => "controls.keybinds.title",
Page::Mouse => "options.mouse_settings.title",
@@ -1432,13 +1515,29 @@ impl OptionsUi {
self.filter_option_rows(rows)
}
Page::Sound => {
// SoundOptionsScreen, without Audio Device: Horizon opens the
// output device itself and cannot switch it.
let mut rows = vec![Row::Big(Opt::Volume(0))];
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
rows.extend(pairs(&others));
rows.extend(pairs(&[Opt::ShowSubtitles]));
// SoundOptionsScreen: Music Frequency starts the next row (with
// Music Toast, not in Horizon yet).
rows.extend(pairs(&[Opt::MusicFrequency]));
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
rows
}
Page::Skin => {
// SkinCustomizationScreen: the seven layers, then Main Hand.
let mut opts: Vec<Opt> = (0..MODEL_PART_KEYS.len()).map(Opt::ModelPart).collect();
opts.push(Opt::MainHand);
pairs(&opts)
}
Page::Online => {
// OnlineOptionsScreen without the Xbox and friends-list parts.
let mut rows = vec![Row::Header("options.online.friends.header")];
rows.extend(pairs(&[Opt::InGameNotification, Opt::SharePresence]));
rows.push(Row::Header("options.online.servers.header"));
rows.push(Row::Big(Opt::AllowServerListing));
rows.push(Row::Header("options.online.realms.header"));
rows.push(Row::Big(Opt::RealmsNotifications));
rows
}
Page::Controls => {
@@ -1633,15 +1732,15 @@ impl OptionsUi {
list: false,
});
out.push(option_widget(s, Opt::Fov, cx - 154.0, 29.0, 150.0));
out.push(inactive(button(
out.push(button(
cx + 4.0,
29.0,
150.0,
tr("options.online"),
Act::None,
)));
Act::Push(Page::Online),
));
let entries = [
("options.skinCustomisation", None),
("options.skinCustomisation", Some(Page::Skin)),
("options.sounds", Some(Page::Sound)),
("options.video", Some(Page::Video)),
("options.controls", Some(Page::Controls)),
@@ -3,6 +3,7 @@
//! The option screens live in `options_ui`.
use crate::keybinds::KeyBinds;
use crate::options_file::{OptionsFile, V};
use horizon_protocol::packets::configuration::ClientInformation;
use std::{fs, path::Path};
pub use horizon_client_core::sound_source::SOUND_SOURCES;
@@ -448,6 +449,16 @@ impl Settings {
file: f,
}
}
/// `Options.createInformation`: what the server learns about this player.
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
ClientInformation {
chat_visibility: self.chat_visibility as i32,
model_customisation: self.file.model_customisation(),
main_hand: i32::from(!self.main_hand_left),
allows_listing: self.file.bool("allowServerListing"),
..ClientInformation::new(view_distance)
}
}
/// The options file reflecting the typed fields; values the game did not
/// change keep their stored form.
pub fn to_file(&self) -> OptionsFile {
@@ -706,6 +717,15 @@ pub enum Opt {
PadGlyphs,
PadMenuNav,
PadKeyboard,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
MainHand,
AllowServerListing,
RealmsNotifications,
InGameNotification,
SharePresence,
DirectionalAudio,
MusicToast,
}
#[cfg(test)]
+91 -25
View File
@@ -1,12 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::sync::Arc;
/// Sections (x, section index, z) waiting to be remeshed, kept by column
/// (a bit per section index), so the mesh scheduler looks at each column once
@@ -65,14 +63,14 @@ pub struct World {
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks changed since the last save.
pub modified: HashSet<(i32, i32)>,
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
/// interaction range.
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
pub block_names: HashMap<(i32, i32, i32), String>,
pub block_names: FxHashMap<(i32, i32, i32), String>,
/// States (by id) whose writes are recorded in `watched_writes` (sculk
/// blocks, which need a block entity).
pub watched: Vec<bool>,
@@ -80,7 +78,7 @@ pub struct World {
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
/// within spawning range (local difficulty).
pub inhabited: HashMap<(i32, i32), i64>,
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
pub weather: Weather,
@@ -177,13 +175,13 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
modified: HashSet::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: HashMap::new(),
block_names: HashMap::new(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
watched: Vec::new(),
watched_writes: Vec::new(),
inhabited: HashMap::new(),
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
weather: Weather::default(),
@@ -274,20 +272,40 @@ impl World {
log.push((x, y, z));
}
self.modified.insert((x >> 4, z >> 4));
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
continue;
}
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
}
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
let (bx, bz) = (x & 15, z & 15);
mark(&mut self.dirty_sections, x, z);
if bx == 0 {
mark(&mut self.dirty_sections, x - 1, z);
} else if bx == 15 {
mark(&mut self.dirty_sections, x + 1, z);
}
if bz == 0 {
mark(&mut self.dirty_sections, x, z - 1);
} else if bz == 15 {
mark(&mut self.dirty_sections, x, z + 1);
}
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
mark(
&mut self.dirty_sections,
if bx == 0 { x - 1 } else { x + 1 },
if bz == 0 { z - 1 } else { z + 1 },
);
}
let _ = sy;
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
self.light_edits.push(((x, y, z), previous, state.0));
} else {
@@ -407,11 +425,59 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::World;
use crate::chunk::Chunk;
use super::{DirtySections, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
/// a corner (eight keys at a corner on a section face).
#[test]
fn writes_mark_exactly_the_reachable_sections() {
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
for (x, y, z) in [
(3, 70, 3),
(0, 70, 3),
(15, 70, 3),
(3, 70, 0),
(0, 70, 0),
(15, 70, 15),
(0, MIN_Y, 0),
(0, 80, 0),
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
] {
world.dirty_sections.clear();
world.set(x, y, z, BlockStateId(1)).unwrap();
let mut expected = DirtySections::default();
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if (0..SECTIONS as i32).contains(&s) {
expected.insert((bx >> 4, s, bz >> 4));
}
}
}
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
}
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
// Four columns, each with the section below and this one.
assert_eq!(world.dirty_sections.len(), 8);
}
}
}
/// A box misses a state only where no section it reaches lists it, and
/// unloaded columns or heights past the world count as their air.
#[test]
+1
View File
@@ -2152,6 +2152,7 @@ impl Game<'_> {
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
@@ -424,6 +424,7 @@ impl Game<'_> {
m.extra.fall_fly_ticks = fly_ticks;
m.extra.auto_spin_attack = spinning;
m.extra.main_hand_left = self.settings.main_hand_left;
m.extra.hidden_model_parts = !self.settings.file.model_customisation() & 0x7F;
m.extra.using_item = using;
m.extra.using_off_hand = using_off_hand;
m.extra.blocking = blocking;
+20 -19
View File
@@ -141,11 +141,9 @@ pub(super) struct Remote {
/// the first teleport, which takes them then.
abilities: Option<play::PlayerAbilities>,
loaded_sent: bool,
/// The view distance last sent in ClientInformation.
sent_view: i8,
/// The Chat option's visibility last sent in ClientInformation
/// (`Options.broadcastOptions` sends it again when it changes).
sent_chat_visibility: i32,
/// The ClientInformation last sent (`Options.broadcastOptions` sends it
/// again when a view distance, Chat, skin or Main Hand option changes).
sent_info: horizon_protocol::packets::configuration::ClientInformation,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
@@ -476,8 +474,7 @@ impl Game<'_> {
carried: None,
abilities: None,
loaded_sent: false,
sent_view: view,
sent_chat_visibility: self.settings.chat_visibility as i32,
sent_info: self.settings.client_information(view),
predictions: HashMap::new(),
menu: None,
menu_title: None,
@@ -1791,13 +1788,12 @@ impl Game<'_> {
self.streaming.radius =
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
self.enter_remote_dimension(&p.spawn, false);
let view = client_view_distance(self.settings.render_distance);
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
if let Some(r) = self.remote.as_mut() {
r.sent_view = view;
r.sent_info = info.clone();
}
let mut info =
horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = self.settings.chat_visibility as i32;
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
@@ -2161,6 +2157,13 @@ impl Game<'_> {
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
@@ -3654,16 +3657,14 @@ impl Game<'_> {
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
let visibility = self.settings.chat_visibility as i32;
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
if let Some(r) = self.remote.as_mut()
&& r.joined
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
&& r.sent_info != info
{
r.sent_view = view;
r.sent_chat_visibility = visibility;
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = visibility;
r.sent_info = info.clone();
self.send(S::ClientInformation(info));
}
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
+3
View File
@@ -769,6 +769,9 @@ pub struct Extra {
/// `LivingEntity.isAutoSpinAttack` (Riptide).
pub auto_spin_attack: bool,
pub main_hand_left: bool,
/// The skin layers a player hides (`PlayerModelPart` bits, set to
/// `Avatar.DATA_PLAYER_MODE_CUSTOMISATION` inverted); 0 shows all.
pub hidden_model_parts: u8,
pub elytra: ElytraAnimation,
pub swim: SwimAnimation,
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
+14
View File
@@ -802,6 +802,20 @@ pub fn setup(
h.spear_ticks[using_side] = m.extra.using_item as f32 + s.partial;
}
humanoid(p, s, &h);
if player {
// PlayerModel.setupAnim: the layers the player hid (PlayerModelPart).
let hidden = m.extra.hidden_model_parts;
for (bit, part) in [
(1, "jacket"),
(2, "left_sleeve"),
(3, "right_sleeve"),
(4, "left_pants"),
(5, "right_pants"),
(6, "hat"),
] {
p.visible(part, hidden & (1 << bit) == 0);
}
}
if m.ai.aggressive && !bow {
// SkeletonModel melee pose.
let sw = s.swing;
+2 -2
View File
@@ -54,10 +54,10 @@ pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
/// mutating the world. Each map is visited once, retaining its iteration order
/// within each chunk (including repeated chunk coordinates in the input).
/// An empty batch skips both maps; one chunk uses the direct filter path.
pub fn chunks_with_block_entities<'a>(
pub fn chunks_with_block_entities<'a, N: std::hash::BuildHasher>(
chunks: impl IntoIterator<Item = &'a Arc<Chunk>>,
entities: &HashMap<(i32, i32, i32), crate::containers::BlockEntity>,
names: &HashMap<(i32, i32, i32), String>,
names: &HashMap<(i32, i32, i32), String, N>,
) -> Vec<Chunk> {
let chunks: Vec<_> = chunks.into_iter().collect();
if chunks.is_empty() {
+168 -1
View File
@@ -20,7 +20,9 @@ use std::{
thread,
};
mod islands;
mod vegetation;
use islands::EndIslands;
use vegetation::Vegetation;
const CACHE: usize = 1024;
const REQUESTS: usize = 64;
@@ -299,6 +301,7 @@ pub(super) struct VanillaLodSampler {
worker: GeneratorWorker,
builder: Option<Builder>,
vegetation: Arc<Vegetation>,
islands: EndIslands,
cache: FxHashMap<(i32, i32), Arc<[Column; 256]>>,
order: VecDeque<(i32, i32)>,
details: Vec<LodDetail>,
@@ -312,11 +315,13 @@ impl VanillaLodSampler {
&generator.biome_map,
generator.seed,
));
let islands = EndIslands::new(generator.clone());
Self {
generator,
worker,
builder,
vegetation,
islands,
cache: FxHashMap::default(),
order: VecDeque::new(),
details: Vec::new(),
@@ -330,6 +335,7 @@ impl LodSampler for VanillaLodSampler {
worker: self.generator.generator.worker(),
builder: None,
vegetation: self.vegetation.clone(),
islands: self.islands.fork(),
cache: FxHashMap::default(),
order: VecDeque::new(),
details: Vec::new(),
@@ -428,7 +434,7 @@ impl LodSampler for VanillaLodSampler {
}
impl VanillaLodSampler {
fn preview_column(
&self,
&mut self,
x: i32,
z: i32,
width: i32,
@@ -468,6 +474,7 @@ impl VanillaLodSampler {
240
};
self.vegetation.apply(&mut column, x, z, width, light);
self.islands.apply(&mut column, x, z, width, light);
column
}
}
@@ -771,6 +778,166 @@ mod tests {
}
Ok(())
}
/// Small End islands are decoration over the void, so the direct sampler
/// has to estimate them from `end_island_decorated`.
#[test]
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
fn end_small_islands_are_estimated_over_the_void() -> crate::Result<()> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
);
let assets = crate::assets::Assets::load(&root)?;
let biomes = crate::biome::Biomes::load(&root)?;
let terrain = Arc::new(VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
1234,
"minecraft:the_end",
)?);
let mut sampler = VanillaLodSampler::new(terrain.clone());
let mut search = terrain.generator.worker();
let mut center = None;
'search: for x in (1024..=8192).step_by(512) {
for z in (-8192..=8192).step_by(512) {
let biome = search.noise_biomes(&[[x >> 2, 16, z >> 2]]);
if biome.first().and_then(|name| name.as_deref())
!= Some("minecraft:small_end_islands")
{
continue;
}
if search.surface_sample(x, z).is_some() {
continue;
}
center = Some((x, z));
break 'search;
}
}
let (cx, cz) = center.expect("a small end islands region");
let end_stone = assets
.registry
.default_state("minecraft:end_stone")
.unwrap();
// The estimator itself, a wide void at one block per cell.
let mut islands = 0;
for z in cz - 128..cz + 128 {
for x in cx - 128..cx + 128 {
let mut column = Column::default();
sampler.islands.apply(&mut column, x, z, 1, 240);
let Some(surface) = column.surface else {
continue;
};
assert!(
(56..=71).contains(&surface.height),
"island top at {}",
surface.height
);
assert_eq!(surface.state, end_stone);
assert!(column.spans.iter().any(|span| {
span.state == end_stone && i32::from(span.max) == surface.height
}));
assert!(
column.spans.iter().all(|span| i32::from(span.min) >= 45),
"an estimated island floats above the void"
);
islands += 1;
}
}
assert!(islands > 0, "no estimated small islands in a 256² void");
// And through the whole direct-sampling path with the real biome map.
let sampled = sampler.grid(cx - 32, cz - 32, 1, 64);
assert!(
sampled.iter().any(|column| column
.surface
.is_some_and(|surface| surface.state == end_stone)),
"direct sampling shows an island in the real small end islands biome"
);
Ok(())
}
/// The refined path must keep the real feature's end stone in its columns.
#[test]
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
fn end_small_islands_from_decoration_survive_far_columns() -> crate::Result<()> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
);
let assets = crate::assets::Assets::load(&root)?;
let biomes = crate::biome::Biomes::load(&root)?;
let terrain = Arc::new(VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
1234,
"minecraft:the_end",
)?);
let sampler = VanillaLodSampler::new(terrain.clone());
let mut worker = terrain.generator.worker();
let stop = AtomicBool::new(false);
let mut centers = Vec::new();
'search: for cx in (64..=512).step_by(4) {
for cz in (-512..=512).step_by(4) {
let (x, z) = (cx * 16 + 8, cz * 16 + 8);
let biome = worker.noise_biomes(&[[x >> 2, 16, z >> 2]]);
if biome.first().and_then(|name| name.as_deref())
!= Some("minecraft:small_end_islands")
{
continue;
}
if worker.surface_sample(x, z).is_some() {
continue;
}
centers.push((cx, cz));
if centers.len() >= 200 {
break 'search;
}
}
}
for &(cx, cz) in &centers {
// The replay is cheap; only decorate chunks it actually places in.
let origins = sampler.islands.origins(cx, cz);
if origins.is_empty() {
continue;
}
let Some(chunk) = decorated_region(&mut worker, (cx, cz), &stop)? else {
continue;
};
for [x, y, z] in &origins {
assert!((55..=70).contains(y), "island origin at y {y} in {cx} {cz}");
assert_eq!(
chunk.block_name(*x, *y, *z),
"minecraft:end_stone",
"exact origin {x} {y} {z} is the real island's top centre"
);
}
let min = chunk.terrain.min_y();
let max = min + chunk.terrain.height();
let mut raw = 0;
for y in min..max {
for z in 0..16 {
for x in 0..16 {
raw += usize::from(chunk.block_name(x, y, z) == "minecraft:end_stone");
}
}
}
let kept: usize = columns(&chunk, &terrain.biome_map, "minecraft:the_end")
.iter()
.flat_map(|column| &column.spans)
.filter(|span| {
assets.registry.state(span.state).unwrap().name == "minecraft:end_stone"
})
.map(|span| (span.max - span.min) as usize)
.sum();
assert_eq!(
raw, kept,
"far columns keep the real small island in {cx} {cz}"
);
return Ok(());
}
panic!(
"no exact small end island among {} candidates",
centers.len()
);
}
/// Vanilla decoration places chorus plants in the End; the far columns must
/// keep them even though they are not motion-blocking blocks.
#[test]
@@ -0,0 +1,206 @@
//! Small End islands at far distances.
//!
//! The End's small islands are decoration (`minecraft:end_island_decorated` in
//! `minecraft:small_end_islands`), not terrain density. The generator replays
//! vanilla's decoration seed and feature on a no-op world, so the origins and
//! heights are exact without generating chunks; the visible shape is the cone
//! the direct sampler always drew. Refined, visited and saved chunks still win.
use super::vegetation::hash;
use crate::{
fast_hash::FxHashMap,
lod::{Column, Span, Surface},
vanillagen::VanillaTerrain,
};
use horizon_core::position::ChunkPos;
use horizon_world::registry::BlockStateId;
use std::{collections::VecDeque, sync::Arc};
/// A cell is drawn as a whole column; past this width a 4–6 block island would
/// be a slab. At 8 blocks a whole island is about one cell, which is enough at
/// the distance where direct sampling takes over.
const END_ISLAND_CELL: i32 = 8;
/// `EndIslandFeature` starts at radius 4..=6.
const MAX_RADIUS: i32 = 6;
const CACHE: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Island {
x: i32,
z: i32,
y: i32,
/// Top radius, 4..=6 blocks.
radius: i32,
/// Per-layer shrink in half blocks: 1 = 0.5, 3 = 1.5.
shrink: i32,
}
pub(super) struct EndIslands {
generator: Arc<VanillaTerrain>,
end_stone: Option<BlockStateId>,
cache: FxHashMap<(i32, i32), Arc<[Island]>>,
order: VecDeque<(i32, i32)>,
}
impl EndIslands {
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
let end_stone = generator
.generator
.data()
.registry()
.default_state("minecraft:end_stone");
Self {
generator,
end_stone,
cache: FxHashMap::default(),
order: VecDeque::new(),
}
}
/// Independent sampling state: the generator is shared, the cache is not.
pub(super) fn fork(&self) -> Self {
Self {
generator: self.generator.clone(),
end_stone: self.end_stone,
cache: FxHashMap::default(),
order: VecDeque::new(),
}
}
/// Paint the small islands of the chunks around a direct-sampled void cell.
pub(super) fn apply(&mut self, column: &mut Column, x: i32, z: i32, width: i32, light: u16) {
let Some(state) = self.end_stone else {
return;
};
if width > END_ISLAND_CELL || width <= 0 || column.surface.is_some() {
return;
}
let (last_x, last_z) = (x + width - 1, z + width - 1);
let mut spans = Vec::new();
for cz in (z - MAX_RADIUS).div_euclid(16)..=(last_z + MAX_RADIUS).div_euclid(16) {
for cx in (x - MAX_RADIUS).div_euclid(16)..=(last_x + MAX_RADIUS).div_euclid(16) {
let islands = self.layout(cx, cz);
for island in islands.iter() {
let nx = island.x.clamp(x, last_x);
let nz = island.z.clamp(z, last_z);
let d2 = (nx - island.x) * (nx - island.x) + (nz - island.z) * (nz - island.z);
let Some(bottom) = island_bottom(island, d2) else {
continue;
};
spans.push((bottom, island.y + 1));
}
}
}
if spans.is_empty() {
return;
}
spans.sort_unstable_by_key(|span| span.0);
let mut merged: Vec<(i32, i32)> = Vec::with_capacity(spans.len());
for (min, max) in spans {
if let Some(last) = merged.last_mut()
&& min <= last.1
{
last.1 = last.1.max(max);
} else {
merged.push((min, max));
}
}
for &(min, max) in &merged {
column.push(Span {
min: min as i16,
max: max as i16,
state,
biome: 0,
light,
});
}
let top = merged.last().unwrap().1;
column.surface = Some(Surface {
height: top,
state,
water: None,
biome: 0,
});
}
fn layout(&mut self, cx: i32, cz: i32) -> Arc<[Island]> {
let key = (cx, cz);
if let Some(islands) = self.cache.get(&key) {
return islands.clone();
}
let origins = self
.generator
.generator
.end_island_origins(ChunkPos::new(cx, cz));
let islands: Arc<[Island]> = origins
.into_iter()
.map(|[x, y, z]| {
let n = hash(
self.generator.seed as u64
^ (x as u64).rotate_left(17)
^ (z as u64).rotate_left(33)
^ (y as u64),
x,
z,
);
Island {
x,
z,
y,
radius: 4 + (n % 3) as i32,
shrink: 1 + 2 * ((n >> 8) & 1) as i32,
}
})
.collect();
self.cache.insert(key, islands.clone());
self.order.push_back(key);
while self.cache.len() > CACHE {
if let Some(old) = self.order.pop_front() {
self.cache.remove(&old);
}
}
islands
}
#[cfg(test)]
pub(super) fn origins(&self, cx: i32, cz: i32) -> Vec<[i32; 3]> {
self.generator
.generator
.end_island_origins(ChunkPos::new(cx, cz))
}
}
/// The lowest layer of the island's cone that reaches a column at squared
/// distance `d2`. The top layer is always `island.y` when this returns `Some`.
fn island_bottom(island: &Island, d2: i32) -> Option<i32> {
let mut half = island.radius * 2;
let mut y = island.y;
let mut bottom = None;
while half > 1 {
if d2 * 4 <= half * half {
bottom = Some(y);
}
half -= island.shrink;
y -= 1;
}
bottom
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn island_columns_are_cones_with_a_flat_top() {
let island = Island {
x: 0,
z: 0,
y: 60,
radius: 4,
shrink: 1,
};
assert_eq!(island_bottom(&island, 0), Some(54));
assert_eq!(island_bottom(&island, 3), Some(56));
assert_eq!(island_bottom(&island, 16), Some(60));
assert_eq!(island_bottom(&island, 17), None);
let steep = Island {
shrink: 3,
..island
};
assert_eq!(island_bottom(&steep, 0), Some(58));
}
}
@@ -233,7 +233,7 @@ fn gather(
_ => {}
}
}
fn hash(seed: u64, x: i32, z: i32) -> u64 {
pub(super) fn hash(seed: u64, x: i32, z: i32) -> u64 {
let mut n = seed
^ (x as u64).wrapping_mul(0x9e3779b97f4a7c15)
^ (z as u64).wrapping_mul(0xd1b54a32d192ed03);
+2 -31
View File
@@ -8,6 +8,8 @@
//! in index order, and the value that did not fit is added last. Palettes
//! never shrink while resident; `pack` writes the canonical saved form.
use crate::storage::{locate, mask};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// `Strategy.createForBlockStates`: 4096 entries, linear palettes up to 4
@@ -106,20 +108,6 @@ enum Palette {
List(Vec<u32>),
Global,
}
/// `n / per_word` for `n < 4096` by multiplication: `MAGIC[bits].0 = ceil(2^20 /
/// (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is the bits a
/// word's entries fill (`64 / bits * bits`): looked up, not divided for, as a
/// division by a run-time value costs tens of cycles on x86.
const MAGIC: [(u32, u32); 33] = {
let mut table = [(0, 0); 33];
let mut bits = 1;
while bits <= 32 {
let per = 64 / bits as u32;
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
bits += 1;
}
table
};
/// Words of `bits`-wide values: `entries.div_ceil(64 / bits)`.
pub fn storage_len(bits: u8, entries: usize) -> usize {
if bits == 0 {
@@ -128,23 +116,6 @@ pub fn storage_len(bits: u8, entries: usize) -> usize {
entries.div_ceil(64 / usize::from(bits))
}
}
#[inline]
fn locate(bits: u8, i: usize) -> (usize, u32) {
if bits.is_power_of_two() {
// 64 / bits is a power of two: shifts instead of the multiplication.
let log = bits.trailing_zeros();
let word = i >> (6 - log);
(word, ((i & ((64 >> log) - 1)) as u32) << log)
} else {
let (magic, filled) = MAGIC[usize::from(bits)];
let word = (i as u32 * magic) >> 20;
(word as usize, i as u32 * u32::from(bits) - word * filled)
}
}
#[inline]
fn mask(bits: u8) -> u64 {
(1u64 << bits) - 1
}
fn read(words: &[u64], bits: u8, i: usize) -> u32 {
let (word, shift) = locate(bits, i);
((words[word] >> shift) & mask(bits)) as u32
+93 -9
View File
@@ -1,5 +1,46 @@
//! Palette indices packed into padded 64-bit words, not a continuous bitstream.
/// `n / per_word` for `n < MAGIC_LEN` by multiplication: `MAGIC[bits].0 =
/// ceil(2^20 / (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is
/// the bits a word's entries fill (`64 / bits * bits`): looked up, not divided
/// for, as a division by a run-time value costs tens of cycles on x86.
pub(crate) const MAGIC: [(u32, u32); 33] = {
let mut table = [(0, 0); 33];
let mut bits = 1;
while bits <= 32 {
let per = 64 / bits as u32;
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
bits += 1;
}
table
};
/// Entries `locate` handles exactly for every width: the smallest width (1 bit,
/// 64 per word) gives the bound `2^20 / 64`.
pub(crate) const MAGIC_LEN: usize = 1 << 14;
/// The multiply-shift replaces a ~20-26 cycle x86 divide, but arm64's divider
/// is as cheap as the table lookup: measured on an Apple M-series CPU, 65k
/// reads took 1.5 ms with the division and 2.5 ms with the table (bits 4-16),
/// so only the x86 targets take the table.
const MAGIC_BEATS_DIVISION: bool = cfg!(any(target_arch = "x86", target_arch = "x86_64"));
/// The word and bit shift of entry `i`; exact for `i < MAGIC_LEN`.
#[inline]
pub(crate) fn locate(bits: u8, i: usize) -> (usize, u32) {
if bits.is_power_of_two() {
// 64 / bits is a power of two: shifts instead of the multiplication.
let log = bits.trailing_zeros();
let word = i >> (6 - log);
(word, ((i & ((64 >> log) - 1)) as u32) << log)
} else {
let (magic, filled) = MAGIC[usize::from(bits)];
let word = (i as u32 * magic) >> 20;
(word as usize, i as u32 * u32::from(bits) - word * filled)
}
}
#[inline]
pub(crate) fn mask(bits: u8) -> u64 {
(1u64 << bits) - 1
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StorageError {
InvalidBits(u8),
@@ -22,6 +63,9 @@ pub struct PackedStorage {
len: usize,
per_word: usize,
mask: u64,
/// Whether `len` is past what [`locate`] handles exactly; such a storage
/// falls back to division.
wide: bool,
words: Vec<u64>,
}
@@ -46,6 +90,7 @@ impl PackedStorage {
len,
per_word,
mask: (1_u64 << bits) - 1,
wide: len > MAGIC_LEN,
words,
})
}
@@ -70,17 +115,27 @@ impl PackedStorage {
&self.words
}
fn location(&self, index: usize) -> Result<(usize, usize), StorageError> {
/// The word and bit shift of an in-range index.
#[inline]
fn slot(&self, index: usize) -> (usize, u32) {
if self.wide || !MAGIC_BEATS_DIVISION {
(
index / self.per_word,
((index % self.per_word) * usize::from(self.bits)) as u32,
)
} else {
locate(self.bits, index)
}
}
fn location(&self, index: usize) -> Result<(usize, u32), StorageError> {
if index >= self.len {
return Err(StorageError::OutOfBounds {
index,
len: self.len,
});
}
Ok((
index / self.per_word,
(index % self.per_word) * usize::from(self.bits),
))
Ok(self.slot(index))
}
pub fn get(&self, index: usize) -> Result<u32, StorageError> {
@@ -93,8 +148,7 @@ impl PackedStorage {
/// `index` must be less than `len`.
pub fn read(&self, index: usize) -> u32 {
debug_assert!(index < self.len);
let word = index / self.per_word;
let shift = (index % self.per_word) * usize::from(self.bits);
let (word, shift) = self.slot(index);
((self.words[word] >> shift) & self.mask) as u32
}
@@ -103,8 +157,7 @@ impl PackedStorage {
/// palette index from the same container. Returns the previous value.
pub fn write(&mut self, index: usize, value: u32) -> u32 {
debug_assert!(index < self.len && u64::from(value) <= self.mask);
let word = index / self.per_word;
let shift = (index % self.per_word) * usize::from(self.bits);
let (word, shift) = self.slot(index);
let old = ((self.words[word] >> shift) & self.mask) as u32;
self.words[word] = (self.words[word] & !(self.mask << shift)) | (u64::from(value) << shift);
old
@@ -177,6 +230,37 @@ mod tests {
}
}
/// The shared multiply-shift index equals division for every width at the
/// sizes that use `PackedStorage`: 4,096-cell sections and 256-cell
/// heightmaps. A storage past the exact bound keeps the division path.
#[test]
fn multiply_shift_matches_division_at_section_and_heightmap_sizes() {
for bits in 1..=32u8 {
for len in [256usize, 4096] {
let per = 64 / usize::from(bits);
let mut storage = PackedStorage::new(bits, len).unwrap();
assert!(!storage.wide);
let mask = storage.mask;
for i in 0..len {
assert_eq!(
storage.slot(i),
(i / per, ((i % per) * usize::from(bits)) as u32),
"bits {bits} len {len} index {i}"
);
let value = (i as u64 & mask) as u32;
assert_eq!(storage.write(i, value), 0);
assert_eq!(storage.read(i), value);
assert_eq!(storage.get(i).unwrap(), value);
}
}
}
let mut wide = PackedStorage::new(1, MAGIC_LEN + 1).unwrap();
assert!(wide.wide);
wide.write(MAGIC_LEN, 1);
assert_eq!(wide.read(MAGIC_LEN), 1);
assert_eq!(wide.get(MAGIC_LEN).unwrap(), 1);
}
#[test]
fn imported_padding_is_preserved_and_invalid_writes_are_atomic() {
let mut storage = PackedStorage::from_words(5, 1, vec![u64::MAX]).unwrap();
+74 -49
View File
@@ -340,33 +340,33 @@ impl ChunkData {
/// Resident section: 4,096 blocks stored as numeric palette entries. Uniform
/// sections have no word array; property strings live in the shared registry.
/// Multiplicative hash for dense state IDs; section palettes are hashed on
/// every block write, where SipHash dominated generation profiles.
#[derive(Default)]
struct StateIdHasher(u64);
impl std::hash::Hasher for StateIdHasher {
fn finish(&self) -> u64 {
self.0
/// Palettes are almost always tiny, so a local id is a scan of that vector
/// (the resident container does the same) with the last lookup cached: a run
/// of one state costs a compare, and no section allocates a hash table.
fn id_in(
palette: &[BlockStateId],
counts: &[u16],
last: &mut Option<(BlockStateId, u32)>,
state: BlockStateId,
) -> Option<u32> {
if let Some((seen, id)) = *last
&& seen == state
&& counts[id as usize] != 0
{
return Some(id);
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = (self.0.rotate_left(8) ^ u64::from(b)).wrapping_mul(0x9e37_79b9_7f4a_7c15);
}
}
fn write_u32(&mut self, value: u32) {
self.0 = (self.0 ^ u64::from(value)).wrapping_mul(0x9e37_79b9_7f4a_7c15);
}
}
type LocalIds =
std::collections::HashMap<BlockStateId, u32, std::hash::BuildHasherDefault<StateIdHasher>>;
fn local_ids(state: BlockStateId) -> LocalIds {
let mut ids = LocalIds::default();
ids.insert(state, 0);
ids
// A dead slot is still on `free`; reviving it without removing it there
// would let the next new state overwrite it. Only live slots count.
let id = palette
.iter()
.zip(counts)
.position(|(&v, &count)| count != 0 && v == state)? as u32;
*last = Some((state, id));
Some(id)
}
pub struct BlockSection<'r> {
palette: Vec<BlockStateId>,
local_ids: LocalIds,
last: Option<(BlockStateId, u32)>,
counts: Vec<u16>,
free: Vec<u32>,
storage: Option<PackedStorage>,
@@ -383,7 +383,7 @@ impl<'r> BlockSection<'r> {
}
Ok(Self {
palette: vec![state],
local_ids: local_ids(state),
last: Some((state, 0)),
counts: vec![4096],
free: Vec::new(),
storage: None,
@@ -442,19 +442,18 @@ impl<'r> BlockSection<'r> {
if old == state {
return Ok(old);
}
let id = match self.local_ids.get(&state) {
Some(&id) => id,
let id = match id_in(&self.palette, &self.counts, &mut self.last, state) {
Some(id) => id,
None if self.counts[old_id as usize] == 1 => {
// Replacing the last occurrence can reuse its slot without repacking.
self.palette[old_id as usize] = state;
self.local_ids.remove(&old);
self.local_ids.insert(state, old_id);
self.last = Some((state, old_id));
return Ok(old);
}
None => {
if let Some(id) = self.free.pop() {
self.palette[id as usize] = state;
self.local_ids.insert(state, id);
self.last = Some((state, id));
id
} else {
let id = self.palette.len() as u32;
@@ -471,7 +470,7 @@ impl<'r> BlockSection<'r> {
}
self.palette.push(state);
self.counts.push(0);
self.local_ids.insert(state, id);
self.last = Some((state, id));
id
}
}
@@ -480,12 +479,14 @@ impl<'r> BlockSection<'r> {
self.counts[old_id as usize] -= 1;
self.counts[id as usize] += 1;
if self.counts[old_id as usize] == 0 {
self.local_ids.remove(&old);
self.free.push(old_id);
if self.last == Some((old, old_id)) {
self.last = None;
}
}
if self.counts[id as usize] == 4096 {
self.palette = vec![state];
self.local_ids = local_ids(state);
self.last = Some((state, 0));
self.counts = vec![4096];
self.free.clear();
self.storage = None;
@@ -495,24 +496,27 @@ impl<'r> BlockSection<'r> {
pub fn compact(&mut self) -> io::Result<()> {
let values: Vec<_> = (0..4096).map(|i| self.get(i).unwrap()).collect();
self.palette.clear();
self.local_ids.clear();
self.last = None;
self.counts.clear();
self.free.clear();
for state in &values {
if !self.local_ids.contains_key(state) {
self.local_ids.insert(*state, self.palette.len() as u32);
if let Some(id) = id_in(&self.palette, &self.counts, &mut self.last, *state) {
self.counts[id as usize] += 1;
} else {
self.palette.push(*state);
self.counts.push(0);
self.counts.push(1);
self.last = Some((*state, self.palette.len() as u32 - 1));
}
self.counts[self.local_ids[state] as usize] += 1;
}
let bits = bits_for_palette(self.palette.len(), 4);
self.storage = if bits == 0 {
None
} else {
let mut storage = PackedStorage::new(bits, 4096).map_err(|e| invalid(e.to_string()))?;
let mut last = None;
for (i, state) in values.iter().enumerate() {
storage.set(i, self.local_ids[state]).unwrap();
let id = id_in(&self.palette, &self.counts, &mut last, *state).unwrap();
storage.set(i, id).unwrap();
}
Some(storage)
};
@@ -597,18 +601,29 @@ mod tests {
if old == state {
return Ok(old);
}
let old_id = section.local_ids[&old];
if section.counts[old_id as usize] == 1 && !section.local_ids.contains_key(&state) {
// Dead slots stay in `palette` but are on the free list: the old map
// removed them, so only live entries count for either lookup.
let old_id = section
.palette
.iter()
.enumerate()
.find(|&(id, &s)| s == old && section.counts[id] != 0)
.map(|(id, _)| id as u32)
.unwrap();
let known = section
.palette
.iter()
.enumerate()
.find(|&(id, &s)| s == state && section.counts[id] != 0)
.map(|(id, _)| id as u32);
if section.counts[old_id as usize] == 1 && known.is_none() {
section.palette[old_id as usize] = state;
section.local_ids.remove(&old);
section.local_ids.insert(state, old_id);
return Ok(old);
}
let id = if let Some(id) = section.local_ids.get(&state) {
*id
let id = if let Some(id) = known {
id
} else if let Some(id) = section.free.pop() {
section.palette[id as usize] = state;
section.local_ids.insert(state, id);
id
} else {
let id = section.palette.len() as u32;
@@ -625,7 +640,6 @@ mod tests {
}
section.palette.push(state);
section.counts.push(0);
section.local_ids.insert(state, id);
id
};
section
@@ -637,12 +651,10 @@ mod tests {
section.counts[old_id as usize] -= 1;
section.counts[id as usize] += 1;
if section.counts[old_id as usize] == 0 {
section.local_ids.remove(&old);
section.free.push(old_id);
}
if section.counts[id as usize] == 4096 {
section.palette = vec![state];
section.local_ids = local_ids(state);
section.counts = vec![4096];
section.free.clear();
section.storage = None;
@@ -709,7 +721,6 @@ mod tests {
assert_eq!(base.palette, fast.palette);
assert_eq!(base.counts, fast.counts);
assert_eq!(base.free, fast.free);
assert_eq!(base.local_ids, fast.local_ids);
for index in 0..4096 {
assert_eq!(
baseline_get(&base, index).unwrap(),
@@ -721,6 +732,20 @@ mod tests {
}
}
/// A lookup must not revive a freed palette slot: the next new state pops
/// that slot off the free list and would silently overwrite it.
#[test]
fn lookups_do_not_revive_freed_palette_slots() {
let registry = benchmark_registry(8);
let mut section = BlockSection::uniform(BlockStateId(0), &registry).unwrap();
for (index, state) in [(3, 3), (0, 1), (0, 0), (1, 1), (2, 2)] {
section.set(index, BlockStateId(state)).unwrap();
}
assert_eq!(section.get(1).unwrap(), BlockStateId(1));
assert_eq!(section.get(2).unwrap(), BlockStateId(2));
assert_eq!(section.get(3).unwrap(), BlockStateId(3));
}
/// `set` and `get` against the pre-optimization implementations, on the same
/// edit stream. The result is checked before timing; 51 measured rounds
/// alternate order and report the median and best nanosecond-per-operation.
@@ -65,6 +65,14 @@ impl FeatureDecorationPlan {
Ok(Self { biomes, steps })
}
/// The step and the step-local index vanilla mixes into `set_feature_seed`.
pub(super) fn seed(&self, feature: FeatureIdentity) -> Option<(usize, usize)> {
self.steps
.iter()
.enumerate()
.find_map(|(step, data)| data.index(feature).map(|index| (index, step)))
}
/// Execute the feature branch of chunk decoration with structures disabled.
/// The callback must place the identified root with a biome-check context.
/// It receives one shared placer and the correctly reseeded random stream;
@@ -1744,6 +1744,8 @@ trait SeededPipeline: Send + Sync {
region: &mut [ProtoChunk; 9],
) -> Result<DecorateStats, GeneratorError>;
fn data(&self) -> &Arc<GeneratorData>;
/// The exact `end_island_decorated` origins in a chunk, without terrain.
fn end_island_origins(&self, chunk: ChunkPos) -> Vec<[i32; 3]>;
fn sea_level(&self) -> i32;
fn surface_sample(
&self,
@@ -1841,6 +1843,20 @@ where
fn data(&self) -> &Arc<GeneratorData> {
&self.data
}
fn end_island_origins(&self, chunk: ChunkPos) -> Vec<[i32; 3]> {
let Some(decoration) = &self.decoration else {
return Vec::new();
};
let mut resolver = self.source.resolver(
&self.climate,
self.router.get(RouterField::Erosion).as_ref(),
);
let mut context = SamplerContext::cached();
decoration.origins(self.seed, chunk, |position| {
let quart = [position[0] >> 2, position[1] >> 2, position[2] >> 2];
resolver.sample(&mut context, quart).ok() == Some("minecraft:small_end_islands")
})
}
fn sea_level(&self) -> i32 {
self.settings.sea_level
}
@@ -2174,6 +2190,8 @@ where
}
}
}
// Void cells still need a biome: the End's small islands are
// features over the void, and their biome is 2D there.
let biome = if let Some((height, _)) = top {
let name = resolver
.sample(
@@ -2511,6 +2529,10 @@ impl OverworldGenerator {
pub fn data(&self) -> &Arc<GeneratorData> {
self.pipeline.data()
}
/// The exact `end_island_decorated` origins in a chunk, without terrain.
pub fn end_island_origins(&self, chunk: ChunkPos) -> Vec<[i32; 3]> {
self.pipeline.end_island_origins(chunk)
}
pub fn sea_level(&self) -> i32 {
self.pipeline.sea_level()
}
@@ -26,7 +26,7 @@ use super::{
generator::{
DecorateStats, GenBlockStates, GeneratorData, GeneratorError, ProtoChunk, WorkerState,
},
placement::{LayerBlock, PlacementContext, PlacementQueryError},
placement::{FeaturePlacer, LayerBlock, PlacementContext, PlacementQueryError},
tags::TagDefinitions,
terrain::PreLightChunkStage,
write_zone::GenerationWriteZone,
@@ -252,6 +252,56 @@ impl Decoration {
})
.clone()
}
/// Vanilla's feature identity and the step-local index/step mixed into
/// `set_feature_seed`.
pub(super) fn seed(&self, name: &str) -> Option<(u32, i32, i32)> {
let identity = self.names.iter().position(|candidate| candidate == name)? as u32;
let (index, step) = self.plan.seed(FeatureIdentity(identity))?;
Some((identity, index as i32, step as i32))
}
/// Replays a feature's placement chain and the feature itself on a no-op
/// world. This advances vanilla's random stream but writes no blocks, so a
/// caller can reproduce the exact origins without generating terrain.
pub(super) fn origins(
&self,
world_seed: i64,
chunk: ChunkPos,
mut biome_allows: impl FnMut([i32; 3]) -> bool,
) -> Vec<[i32; 3]> {
let Some((identity, index, step)) = self.seed("minecraft:end_island_decorated") else {
return Vec::new();
};
let Ok(feature) = &self.features[identity as usize] else {
return Vec::new();
};
let blocks = self.blocks;
let mut random = WorldgenRandom::new(XoroshiroRandom::new(0));
let seed = random.set_decoration_seed(world_seed, chunk.x * 16, chunk.z * 16);
random.set_feature_seed(seed, index, step);
let mut world = OriginWorld {
air: blocks.air,
generation: WorldGenerationContext::new(0, 256, 0),
biome_allows: &mut biome_allows,
};
let mut origins = Vec::new();
let mut placer = FeaturePlacer::new();
let _ = placer.place_with_biome_check(
&feature.placement,
&mut world,
&mut random,
[chunk.x * 16, 0, chunk.z * 16],
FeatureIdentity(identity),
|world, random, position| {
origins.push(position);
feature
.feature
.place(world, random, position, blocks)
.map(|_| true)
.map_err(|_| ())
},
);
origins
}
/// Placed-feature roots that cannot run, with their decode errors.
pub(super) fn unsupported(&self) -> Vec<(String, String)> {
self.names
@@ -454,6 +504,44 @@ impl Decoration {
}
}
/// Advances a feature's random stream with no block storage; every write is
/// discarded. Placement modifiers still see the real biome and build bounds.
struct OriginWorld<'a> {
air: BlockStateId,
generation: WorldGenerationContext,
biome_allows: &'a mut dyn FnMut([i32; 3]) -> bool,
}
impl FeatureWorld for OriginWorld<'_> {
type Error = ();
fn block(&mut self, _: [i32; 3]) -> Result<BlockStateId, ()> {
Ok(self.air)
}
fn set_block(&mut self, _: [i32; 3], _: BlockStateId, _: i32) -> Result<bool, ()> {
Ok(false)
}
}
impl PlacementContext for OriginWorld<'_> {
fn generation(&self) -> WorldGenerationContext {
self.generation
}
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
Ok(self.generation.min_y)
}
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
Ok(self.generation.min_y + self.generation.height)
}
fn max_y(&mut self) -> Result<i32, PlacementQueryError> {
Ok(self.generation.min_y + self.generation.height - 1)
}
fn biome_allows_feature(
&mut self,
position: [i32; 3],
_: FeatureIdentity,
) -> Result<bool, PlacementQueryError> {
Ok((self.biome_allows)(position))
}
}
/// WorldGenRegion-style view: reads anywhere in the 3×3 region, writes within
/// the FEATURES step's write radius, biome lookups through the biome zoom.
struct World<'a> {
+1 -1
View File
@@ -39,7 +39,7 @@ Which settings each screen shows, their descriptions and costs are in `crates/ho
Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration.
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. Tree and chorus positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. The End's small islands are estimated above the void from the `end_island_decorated` feature. Tree, chorus and island positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
+26 -3
View File
@@ -51,7 +51,7 @@ Start the client (needs a signed-in Microsoft account that owns Minecraft: Java
horizon-launcher play
- `--instance`: The instance to play (its name or id; default: the selected one)
- `--instance`: The instance to play (its name or id; default: the selected one). A Java instance starts its Minecraft: Java Edition, downloaded with its Java the first time
- `--world`: Open or create this world (a folder under saves) at once
- `--creative`: Creative mode for a new world
- `--join`: Join a friend's world with its invite code
@@ -478,6 +478,28 @@ Import what a file is, as dropping it on the window does: a shared instance (a .
MCP: `instance_import`.
## `horizon-launcher instance others`
List the instances other launchers keep (Prism Launcher, MultiMC, the official launcher), read without changing anything there.
horizon-launcher instance others
- `--folder`: Look in this folder instead: a launcher's folder, its instances/ or one instance
MCP: `instance_others`.
## `horizon-launcher instance copy-from`
Copy another launcher's instance into a new Java instance: its Minecraft version and its game folder (worlds, mods, packs, options); the other launcher's copy stays as it is.
horizon-launcher instance copy-from <NAME>
- `NAME`: Its name, as `instance others` lists it
- `--folder`: Look in this folder instead: a launcher's folder, its instances/ or one instance
- `--as`: The new instance's name (default: the same)
MCP: `instance_copy_from`.
## `horizon-launcher worlds list`
The worlds: name, mode, version, last played and size (the default).
@@ -747,7 +769,7 @@ MCP: `mods_list`.
## `horizon-launcher mods search`
Search for packs Horizon loads.
Search for packs Horizon loads, or for what a Java instance runs (made for its Minecraft version and loader).
horizon-launcher mods search [TEXT]...
@@ -755,6 +777,7 @@ Search for packs Horizon loads.
- `--kind` (mod, resource, modpack, shader, datapack)
- `--everything`: Also what was made for other versions or runs only partly, each marked
- `--source` (modrinth, curseforge, horizon)
- `--instance`: The instance (default: the selected one)
MCP: `mods_search`.
@@ -772,7 +795,7 @@ MCP: `mods_check`.
## `horizon-launcher mods install`
Install a pack and what it needs into an instance, by its id or slug (checksums checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported as an instance of its own; a data pack goes into the world `--world` names.
Install a pack and what it needs into an instance, by its id or slug (checksums checked); a Java mod is ported into horizon-mods and turned on (a Java instance gets the jar made for its version and loader in mods/); a modpack is imported as an instance of its own; a data pack goes into the world `--world` names.
horizon-launcher mods install <PROJECT>
+15 -8
View File
@@ -6,8 +6,9 @@ is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions
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.
Native mods are native code with the player's rights, not sandboxed. The launcher turns them on only when the player
installs one (after a notice) and builds them only behind a red warning; the registry lists one only after a review; and
they play in a game of their own, built for the instance they are installed in.
## Pinned to a Nightly
@@ -40,8 +41,9 @@ mod shows one.
## 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
In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads
Rust and can take 20 to 30 minutes; later builds are faster) and turns on Settings → Game → **Native mods
(experimental)**, which stays off for players who never install one; `mods install` does the same. 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:
@@ -105,13 +107,19 @@ Measured 2026-10-29 on Windows 11 (Ryzen 7 9800X3D, 16 threads), the same mod an
| Crates in the launcher's Cargo home | 657 MB |
| First build: release profile, the whole game | 6 min 57 s of compiling, 8 min 3 s in all; `native/target/` 2.0 GB |
Measured 2026-10-28 on Linux (an x86_64 server with no Rust of its own), Zoom on Horizon `1149c92`, 3 jobs while CI
ran: `rust install` in 11 s (592 MB), the first build in 12 min 9 s. The built game ran Zoom's proof (under `xvfb-run`:
the game needs a display even with `--offscreen`).
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.
- **Off until the player installs one.** Settings → Game → Native mods stays off for a player who never installs a
native mod; the first install shows the notice and turns it on. 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.
@@ -133,4 +141,3 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
- 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.
- Linux: the toolchain is pinned, but a full game build on it is unproven. Windows builds and plays (above).
+9 -2
View File
@@ -181,9 +181,16 @@
"These mods will run with full access to your computer": "Diese Mods erhalten vollen Zugriff auf deinen 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.": "Sie können deine Dateien lesen, ändern oder löschen und deine Konten sowie dein Netzwerk nutzen wie jedes andere Programm, das du ausführst. Kompiliere sie nur, wenn du ihren Autoren vertraust: Die Mods-Seite nennt sie.",
"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.": "Der Build kompiliert das Spiel: beim ersten Mal etwa zehn Minuten und einige GB Speicherplatz, danach einige Minuten.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal 20 bis 30 Minuten und einige GB Speicherplatz (er lädt Rust herunter), danach wenige Minuten.",
"Delete old versions automatically": "Alte Versionen automatisch löschen",
"Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Behält die {count} neuesten, die verwendete und die von deinen Instanzen genutzten. Gelöschte Versionen lassen sich auf der Seite Versionen wieder installieren.",
"Installed versions use {size}.": "Installierte Versionen belegen {size}.",
"{size} on disk": "{size} auf dem Datenträger"
"{size} on disk": "{size} auf dem Datenträger",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Beim ersten Mal lädt „Spielen“ es für dein Konto von Mojang herunter, mit dem Java, das es braucht.",
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "{name} mit seinen Welten und Einstellungen in den Papierkorb verschieben? Von dort kannst du es zurückholen.",
"Delete…": "Löschen…",
"Keep": "Behalten",
"Move to Trash": "In den Papierkorb",
"Install and turn on native mods": "Installieren und native Mods einschalten",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller."
}
+4 -1
View File
@@ -185,7 +185,10 @@
"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.",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Jouer le télécharge depuis Mojang pour votre compte la première fois, avec le Java qu'il lui faut.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilation construit le jeu : 20 à 30 minutes et quelques Go de disque la première fois (elle télécharge Rust), quelques minutes ensuite.",
"Install and turn on native mods": "Installer et activer les mods natifs",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Les mods natifs sont compilés dans un jeu à part. En installer un active les mods natifs : vous compilez ensuite le jeu avec lui dans Paramètres > Mods natifs. La première compilation télécharge Rust et peut prendre 20 à 30 minutes ; les suivantes sont plus rapides.",
"Delete…": "Supprimer…",
"Keep": "Garder",
"Move to Trash": "Mettre à la corbeille",
+4 -1
View File
@@ -185,7 +185,10 @@
"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.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут.",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). При первом запуске «Играть» скачает его у Mojang для вашего аккаунта вместе с нужной ему Java.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Сборка компилирует игру: 20–30 минут и несколько ГБ на диске в первый раз (она скачивает Rust), затем несколько минут.",
"Install and turn on native mods": "Установить и включить нативные моды",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативные моды компилируются в отдельную игру. Установка такого мода включает нативные моды: затем вы собираете с ним игру в «Настройки > Нативные моды». Первая сборка скачивает Rust и может занять 20–30 минут; следующие быстрее.",
"Delete…": "Удалить…",
"Keep": "Оставить",
"Move to Trash": "В корзину",
+42
View File
@@ -1200,5 +1200,47 @@ pub fn session_for_game(
}
}
/// Who plays the real Minecraft: Java Edition (a Java instance), past the owner gate itself
/// ([`launch`]): the selected account when it owns the game, else one that does. With no answer
/// from the services, an account that owned it plays with its last token. Mojang's game takes the
/// token on its command line, as Mojang's launcher gives it; that command is never logged.
pub fn java_player() -> Result<crate::java_launch::Player, String> {
let mut accounts = Accounts::load();
let player = java_player_with(
&Endpoints::live(),
client_id().as_deref(),
store(),
&mut accounts,
);
let _ = accounts.save();
player
}
fn java_player_with(
ep: &Endpoints,
client_id: Option<&str>,
store: &dyn SecretStore,
accounts: &mut Accounts,
) -> Result<crate::java_launch::Player, String> {
let pass = launch(ep, client_id, store, accounts).map_err(|e| e.to_string())?;
let kept = || {
let order = accounts.selected().into_iter().chain(&accounts.accounts);
order
.into_iter()
.find_map(|a| Some((a.clone(), secrets_in(store, &a.uuid).filter(|s| s.owns)?)))
};
let (account, secrets) = pass
.session
.or(pass.owner)
.or_else(kept)
.ok_or_else(|| NoOwner.to_string())?;
Ok(crate::java_launch::Player {
name: account.name,
uuid: account.uuid,
token: secrets.minecraft_token,
xuid: secrets.xuid.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests;
+25
View File
@@ -704,3 +704,28 @@ fn offline_names_switch_with_accounts() {
r#"{"accounts":[],"selected":null}"#
);
}
#[test]
fn the_java_edition_plays_as_an_owner_even_offline() {
let (_, ep) = mock(|_| {});
let store = MemoryStore::default();
let mut accounts = signed_in(&store, &secrets("mc-token", true));
let player = java_player_with(&ep, Some("test-client"), &store, &mut accounts).unwrap();
assert_eq!(
(
player.name.as_str(),
player.uuid.as_str(),
player.token.as_str()
),
("Notch", UUID, "mc-token")
);
// No network: the last token of an account that owned the game.
let player = java_player_with(&offline(), Some("test-client"), &store, &mut accounts).unwrap();
assert_eq!(player.token, "mc-token");
// Never for an account that doesn't own it.
let mut accounts = signed_in(&store, &secrets("mc-token", false));
assert_eq!(
java_player_with(&offline(), Some("test-client"), &store, &mut accounts).unwrap_err(),
NoOwner.to_string()
);
}
+72 -2
View File
@@ -1306,6 +1306,69 @@ pub fn download_version_for_owner(
Ok(dir)
}
/// The command that starts a Java instance's Minecraft, for an account that owns the game: its
/// version downloaded from Mojang the first time (then it starts offline too), the Java it needs
/// (the instance's own, else one found here, else Mojang's, downloaded), and the game in the
/// instance's folder. The caller wraps it (`launch::wrap`) and never logs it: it holds the token.
pub fn java_game(
instance: &crate::instances::Instance,
log: &dyn Fn(String),
) -> Result<Command, String> {
let game = instance.java.as_ref().ok_or_else(|| {
format!(
"{} plays Horizon, not Minecraft: Java Edition",
instance.name
)
})?;
let player = account::java_player()?;
let minecraft = data_dir().join("minecraft");
let id = mojang::relative(&game.minecraft)?;
let versions = minecraft.join("versions").join(&id);
let downloaded = versions.join(".downloaded");
if !downloaded.is_file() {
let listed = crate::minecraft_versions::load()?;
let version = listed.find(&game.minecraft)?;
download_version_for_owner(&version.id, &version.sha1, log)?;
fs::write(&downloaded, "").map_err(|e| format!("{}: {e}", downloaded.display()))?;
}
let file = versions.join(format!("{}.json", game.minecraft));
let version: serde_json::Value = fs::read_to_string(&file)
.map_err(|e| e.to_string())
.and_then(|text| serde_json::from_str(&text).map_err(|e| e.to_string()))
.map_err(|e| format!("{}: {e}", file.display()))?;
let need = crate::java::Need::of(&version);
let found = crate::java::pick(
&need,
game.home.as_deref(),
&crate::java::mojang_runtimes(&crate::java::runtimes_dir()),
&crate::java::installed(),
)?;
let runtime = match found {
Some(runtime) => runtime,
None => {
log(format!("Downloading Java {} from Mojang", need.major));
crate::java::install(&need, log)?
}
};
let jvm_args: Vec<String> = game.jvm_args.split_whitespace().map(String::from).collect();
fs::create_dir_all(&instance.home).map_err(|e| format!("{}: {e}", instance.home.display()))?;
crate::java_launch::command(
&version,
&crate::java_launch::Launch {
minecraft: &minecraft,
game_dir: &instance.home,
natives: &minecraft.join("natives").join(&id),
java: &runtime.java(),
memory_mb: game
.memory_mb
.unwrap_or(crate::instances::DEFAULT_MEMORY_MB),
jvm_args: &jvm_args,
player: &player,
os: crate::java_launch::os_name(),
},
)
}
/// What [`download_for_owner`] does with the owner gate's answer: Mojang's files only for its
/// `owner`, an account the services said owns the game just now.
fn download_for(
@@ -2676,6 +2739,9 @@ pub enum Msg {
NativeProgress(String),
/// The native-mods build ended: the version built, or why not.
Native(Result<String, String>),
/// A Java instance's game is ready to start (`java_game`, wrapped): the instance's id and
/// game folder, and the command or why not.
JavaGame(String, PathBuf, Result<Command, String>),
}
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
@@ -2865,10 +2931,14 @@ pub struct Settings {
/// The window's language. Unset follows the system's (`i18n::Language::system`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<crate::i18n::Language>,
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
/// a game of their own. Off by default.
/// Native mods (experimental) are on (`native_mods`): mixin mods compiled into a game of
/// their own. Off until the player installs a native mod (`native_mods::turns_on`), or turns
/// them on in Settings.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods: bool,
/// The player has read what installing a native mod starts (`native_mods::needs_notice`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods_noticed: bool,
/// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them):
/// offline names play without an account that owns the game, in the window only
/// (`account::Accounts::play_as_with`, `account::launch_for_game`). Never a download: the game still
+1
View File
@@ -13,6 +13,7 @@ pub mod horizon;
pub mod images;
pub mod installed;
pub mod java;
pub mod java_instance;
pub mod modrinth;
use serde::{Deserialize, Serialize};
+8
View File
@@ -60,6 +60,14 @@ pub fn known_index() -> Option<Arc<Index>> {
.map(|(_, index)| index.clone())
}
/// Whether the registry's project `id` is a native mod, by the index already read (never the
/// network); false when none was read.
pub fn is_native_mod(id: &str) -> bool {
known_index()
.and_then(|index| Some(index.project(id)?.kind == index::Kind::NativeMod))
.unwrap_or(false)
}
/// The index: the one fetched in the last ten minutes, else fetched again (and kept in the game
/// folder), else the copy kept last time.
pub fn index() -> Result<Arc<Index>, String> {
+19 -1
View File
@@ -177,6 +177,24 @@ pub fn install(
return super::java::install(source, project, everything, home, progress);
}
let steps = plan(source, project, everything)?;
put(
source,
steps,
home,
&|kind| kind.folder().expect("plan keeps folder kinds"),
progress,
)
}
/// Downloads each step's file into `home`, in the folder `folder` names for its kind, and lists
/// it: what was already there at that version is kept, an older version replaced.
pub(super) fn put(
source: &dyn Source,
steps: Vec<Step>,
home: &Path,
folder: &dyn Fn(Kind) -> &'static str,
progress: &dyn Fn(&str, f32),
) -> Result<Vec<Entry>, String> {
let mut installed = Installed::load(home);
let mut done = Vec::new();
for step in steps {
@@ -190,7 +208,7 @@ pub fn install(
.version
.file()
.expect("pick keeps versions with a file");
let folder_name = p.kind.folder().expect("plan keeps folder kinds");
let folder_name = folder(p.kind);
let name = safe_name(&file.name)
.ok_or_else(|| format!("{:?} isn't a plain file name.", file.name))?;
let relative = format!("{folder_name}/{name}");
+177
View File
@@ -0,0 +1,177 @@
//! Mods and resource packs for a Java Edition instance: the source's own files, for the
//! instance's Minecraft version and loader, put in its `mods/` and `resourcepacks/` as they are
//! (nothing is ported). The instance's `mod-browser.json` lists them, as for Horizon's folder.
use super::installed::{self, Entry, Installed, Step};
use super::{details, Kind, Project, Query, Source, Version};
use std::fs;
use std::path::Path;
/// How deep required dependencies are followed.
const MAX_DEPTH: usize = 4;
/// What an instance runs: its Minecraft version and its loader (`fabric`, `quilt`, `neoforge`,
/// `forge`), none for vanilla.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
pub minecraft: String,
pub loader: Option<String>,
}
impl Target {
/// Whether a file made for `loader` runs here. Quilt loads Fabric mods
/// (<https://quiltmc.org/en/about/faq/>), so a Quilt instance takes them too.
pub fn accepts(&self, loader: &str) -> bool {
match self.loader.as_deref() {
Some(l) => l == loader || (l == "quilt" && loader == "fabric"),
None => false,
}
}
/// The folder files of `kind` go in, or why they don't go in this instance.
pub fn folder(&self, kind: Kind) -> Result<&'static str, String> {
match kind {
Kind::Mod if self.loader.is_none() => Err(format!(
"This instance plays vanilla Minecraft {}: give it a loader (Fabric, Quilt...) \
for mods.",
self.minecraft
)),
Kind::Mod => Ok("mods"),
Kind::ResourcePack => Ok("resourcepacks"),
Kind::Modpack => Err("A modpack is imported as its own instance.".into()),
Kind::DataPack => Err("A data pack goes in a world.".into()),
Kind::Shader => Err("Shader packs for Java instances aren't supported yet.".into()),
}
}
/// A search of `kind` for what runs here: made for its version and, for mods, its loader.
pub fn query(&self, text: &str, kind: Kind) -> Query {
let mut query = Query::new(text, kind);
query.filters = details::Filters {
game_version: Some(self.minecraft.clone()),
loader: match kind {
Kind::Mod => self.loader.clone(),
_ => None,
},
..details::Filters::default()
};
query
}
/// The newest version made for this instance, a file for its own loader before one it
/// only accepts (a Fabric file in a Quilt instance).
pub fn pick<'a>(&self, kind: Kind, versions: &'a [Version]) -> Option<&'a Version> {
let mut usable = versions.iter().filter(|v| {
v.blocked.is_none() && v.file().is_some() && v.game_versions.contains(&self.minecraft)
});
if kind != Kind::Mod {
return usable.find(|v| v.loaders.iter().any(|l| l == "minecraft"));
}
let own = self.loader.as_deref()?;
usable
.clone()
.find(|v| v.loaders.iter().any(|l| l == own))
.or_else(|| usable.find(|v| v.loaders.iter().any(|l| self.accepts(l))))
}
}
/// What installing `project` into the instance takes, its required dependencies first, each
/// checked against the instance before any byte is downloaded.
pub fn plan(source: &dyn Source, target: &Target, project: &Project) -> Result<Vec<Step>, String> {
let mut steps = Vec::new();
add(source, target, project.clone(), false, 0, &mut steps)?;
Ok(steps)
}
fn add(
source: &dyn Source,
target: &Target,
project: Project,
dependency: bool,
depth: usize,
steps: &mut Vec<Step>,
) -> Result<(), String> {
if steps.iter().any(|s| s.project.id == project.id) {
return Ok(());
}
if depth > MAX_DEPTH {
return Err(format!("{}'s dependencies go too deep.", project.title));
}
target.folder(project.kind)?;
let versions = source.versions(&project)?;
let Some(version) = target.pick(project.kind, &versions).cloned() else {
return Err(match versions.iter().find_map(|v| v.blocked.clone()) {
Some(why) => format!("{} can't be installed from here: {why}", project.title),
None => format!(
"{} has no version for Minecraft {}{}.",
project.title,
target.minecraft,
match (project.kind, &target.loader) {
(Kind::Mod, Some(l)) => format!(" with {l}"),
_ => String::new(),
}
),
});
};
for id in &version.required {
let needed = source
.project(id)
.map_err(|e| format!("{} needs {id}: {e}", project.title))?;
add(source, target, needed, true, depth + 1, steps)?;
}
steps.push(Step {
project,
version,
dependency,
});
Ok(())
}
/// Installs `project` (and what it needs) into the instance folder `home`; one already there is
/// updated to the version chosen.
pub fn install(
source: &dyn Source,
target: &Target,
project: &Project,
home: &Path,
progress: &dyn Fn(&str, f32),
) -> Result<Vec<Entry>, String> {
let steps = plan(source, target, project)?;
let folder = |kind| target.folder(kind).expect("plan checks every kind");
installed::put(source, steps, home, &folder, progress)
}
/// The newer version `entry` can be updated to, if any.
pub fn update_for(
source: &dyn Source,
target: &Target,
entry: &Entry,
) -> Result<Option<Version>, String> {
let project = source.project(&entry.project)?;
let versions = source.versions(&project)?;
Ok(target
.pick(project.kind, &versions)
.filter(|v| v.id != entry.version)
.cloned())
}
/// Removes what the browser installed in the instance for `name` (a project id or slug).
pub fn remove(home: &Path, name: &str) -> Result<Entry, String> {
let mut installed = Installed::load(home);
let entry = installed
.lookup(name)
.cloned()
.ok_or_else(|| format!("{name} wasn't installed by the browser."))?;
match fs::remove_file(home.join(&entry.file)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", entry.file)),
}
installed
.entries
.retain(|e| !(e.source == entry.source && e.project == entry.project));
installed.save(home)?;
Ok(entry)
}
#[cfg(test)]
mod tests;
+276
View File
@@ -0,0 +1,276 @@
use super::*;
use crate::browser::{download, modrinth, FileRef, SourceId};
use sha2::Digest;
use std::collections::HashMap;
use std::path::PathBuf;
/// A source in memory: projects, their versions (newest first), and the bytes each URL serves.
#[derive(Default)]
struct Fake {
projects: HashMap<String, Project>,
versions: HashMap<String, Vec<Version>>,
bytes: HashMap<String, Vec<u8>>,
}
impl Source for Fake {
fn id(&self) -> SourceId {
SourceId::Modrinth
}
fn search(&self, _: &Query) -> Result<Vec<Project>, String> {
Ok(self.projects.values().cloned().collect())
}
fn project(&self, id: &str) -> Result<Project, String> {
self.projects
.get(id)
.cloned()
.ok_or_else(|| "not found".into())
}
fn versions(&self, p: &Project) -> Result<Vec<Version>, String> {
Ok(self.versions.get(&p.id).cloned().unwrap_or_default())
}
fn fetch(
&self,
file: &FileRef,
folder: &Path,
progress: &dyn Fn(f32),
) -> Result<PathBuf, String> {
download::save(file, &self.bytes[&file.url][..], folder, progress)
}
}
impl Fake {
/// A new newest version of `id` (a project added on first use).
fn release(
&mut self,
id: &str,
kind: Kind,
number: &str,
games: &[&str],
loaders: &[&str],
needs: &[&str],
) {
self.projects.entry(id.into()).or_insert_with(|| Project {
source: SourceId::Modrinth,
id: id.into(),
slug: format!("{id}-slug"),
title: id.into(),
summary: String::new(),
author: String::new(),
downloads: 0,
page: String::new(),
icon: None,
license: String::new(),
kind,
game_versions: Vec::new(),
loaders: Vec::new(),
});
let body = format!("{id} {number}").into_bytes();
let url = format!("https://cdn.modrinth.com/data/{id}/versions/{number}/{id}-{number}.jar");
let file = FileRef {
url: url.clone(),
name: format!("{id}-{number}.jar"),
size: body.len() as u64,
sha512: Some(
sha2::Sha512::digest(&body)
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
),
sha256: None,
sha1: None,
primary: true,
};
let strings = |l: &[&str]| l.iter().map(|s| s.to_string()).collect();
self.bytes.insert(url, body);
self.versions.entry(id.into()).or_default().insert(
0,
Version {
id: format!("{id}@{number}"),
number: number.into(),
published: String::new(),
game_versions: strings(games),
loaders: strings(loaders),
files: vec![file],
required: strings(needs),
blocked: None,
},
);
}
}
fn target(minecraft: &str, loader: Option<&str>) -> Target {
Target {
minecraft: minecraft.into(),
loader: loader.map(Into::into),
}
}
fn home(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-java-instance-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
const NONE: &dyn Fn(&str, f32) = &|_, _| {};
#[test]
fn picks_the_newest_file_for_the_version_and_loader() {
let mut fake = Fake::default();
fake.release("sodium", Kind::Mod, "1", &["1.21.1"], &["fabric"], &[]);
fake.release("sodium", Kind::Mod, "2", &["1.21.1"], &["quilt"], &[]);
fake.release("sodium", Kind::Mod, "3", &["1.21.1"], &["fabric"], &[]);
fake.release(
"sodium",
Kind::Mod,
"4",
&["1.21.4"],
&["fabric", "quilt"],
&[],
);
let versions = &fake.versions["sodium"];
let pick = |t: Target| t.pick(Kind::Mod, versions).map(|v| v.number.clone());
assert_eq!(pick(target("1.21.1", Some("fabric"))).as_deref(), Some("3"));
// Quilt's own file first, else a Fabric one.
assert_eq!(pick(target("1.21.1", Some("quilt"))).as_deref(), Some("2"));
fake.versions
.get_mut("sodium")
.unwrap()
.retain(|v| v.number != "2");
let versions = &fake.versions["sodium"];
assert_eq!(
target("1.21.1", Some("quilt"))
.pick(Kind::Mod, versions)
.unwrap()
.number,
"3"
);
assert!(target("1.21.1", Some("neoforge"))
.pick(Kind::Mod, versions)
.is_none());
assert!(target("1.20.1", Some("fabric"))
.pick(Kind::Mod, versions)
.is_none());
assert!(target("1.21.1", None).pick(Kind::Mod, versions).is_none());
let mut packs = Fake::default();
packs.release(
"faithful",
Kind::ResourcePack,
"1",
&["1.21.1"],
&["minecraft"],
&[],
);
let versions = &packs.versions["faithful"];
assert!(target("1.21.1", None)
.pick(Kind::ResourcePack, versions)
.is_some());
assert!(target("1.21.4", None)
.pick(Kind::ResourcePack, versions)
.is_none());
}
#[test]
fn searches_for_what_runs_in_the_instance() {
let t = target("1.20.1", Some("quilt"));
assert_eq!(
modrinth::facets(&t.query("map", Kind::Mod)),
r#"[["project_type:mod"],["versions:1.20.1"],["categories:quilt","categories:fabric"]]"#
);
assert_eq!(
modrinth::facets(&t.query("", Kind::ResourcePack)),
r#"[["project_type:resourcepack"],["versions:1.20.1"]]"#
);
assert_eq!(
modrinth::facets(&target("1.21.1", Some("neoforge")).query("", Kind::Mod)),
r#"[["project_type:mod"],["versions:1.21.1"],["categories:neoforge"]]"#
);
}
#[test]
fn installs_jars_with_dependencies_then_updates_and_removes() {
let home = home("cycle");
let t = target("1.21.1", Some("fabric"));
let mut fake = Fake::default();
fake.release("fabric-api", Kind::Mod, "1", &["1.21.1"], &["fabric"], &[]);
fake.release(
"lib",
Kind::Mod,
"1",
&["1.21.1"],
&["fabric"],
&["fabric-api"],
);
fake.release(
"map",
Kind::Mod,
"1",
&["1.21.1"],
&["fabric"],
&["lib", "fabric-api"],
);
let map = fake.project("map").unwrap();
let steps = plan(&fake, &t, &map).unwrap();
let names: Vec<_> = steps
.iter()
.map(|s| (s.project.id.as_str(), s.dependency))
.collect();
assert_eq!(names, [("fabric-api", true), ("lib", true), ("map", false)]);
let done = install(&fake, &t, &map, &home, NONE).unwrap();
assert_eq!(done.len(), 3);
assert_eq!(fs::read(home.join("mods/map-1.jar")).unwrap(), b"map 1");
assert!(home.join("mods/fabric-api-1.jar").is_file());
assert_eq!(Installed::load(&home).entries.len(), 3);
fake.release("map", Kind::Mod, "2", &["1.21.1"], &["fabric"], &["lib"]);
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
assert_eq!(update_for(&fake, &t, &entry).unwrap().unwrap().number, "2");
install(&fake, &t, &map, &home, NONE).unwrap();
assert!(!home.join("mods/map-1.jar").exists());
assert!(home.join("mods/map-2.jar").is_file());
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
assert!(update_for(&fake, &t, &entry).unwrap().is_none());
remove(&home, "map-slug").unwrap();
assert!(!home.join("mods/map-2.jar").exists());
assert!(Installed::load(&home).lookup("map").is_none());
assert!(home.join("mods/lib-1.jar").is_file());
let _ = fs::remove_dir_all(&home);
}
#[test]
fn refuses_what_the_instance_cannot_run() {
let home = home("refuse");
let mut fake = Fake::default();
fake.release("forge-lib", Kind::Mod, "1", &["1.21.1"], &["forge"], &[]);
fake.release(
"map",
Kind::Mod,
"1",
&["1.21.1"],
&["fabric"],
&["forge-lib"],
);
let map = fake.project("map").unwrap();
let err = install(&fake, &target("1.21.1", Some("fabric")), &map, &home, NONE).unwrap_err();
assert_eq!(
err,
"forge-lib has no version for Minecraft 1.21.1 with fabric."
);
// Nothing is downloaded when one dependency can't be installed.
assert!(!home.join("mods").exists());
let err = plan(&fake, &target("1.21.1", None), &map).unwrap_err();
assert!(err.contains("vanilla Minecraft 1.21.1"), "{err}");
let err = plan(&fake, &target("1.20.1", Some("fabric")), &map).unwrap_err();
assert_eq!(err, "map has no version for Minecraft 1.20.1 with fabric.");
let _ = fs::remove_dir_all(&home);
}
+2
View File
@@ -42,6 +42,8 @@ pub(super) fn facets(query: &Query) -> String {
None => {}
}
let loaders: Vec<String> = match &f.loader {
// Quilt loads Fabric mods: what runs on Quilt.
Some(l) if l == "quilt" => vec!["categories:quilt".into(), "categories:fabric".into()],
Some(l) => vec![format!("categories:{l}")],
None => details::default_loaders(query.kind)
.iter()
+211 -24
View File
@@ -4,9 +4,11 @@ use clap::{Parser, Subcommand};
use horizon_launcher::account::{self, Accounts};
use horizon_launcher::backend::{self, Release};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
use horizon_launcher::other_launchers;
use horizon_launcher::server_list;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Parser)]
@@ -314,6 +316,25 @@ enum InstanceCmd {
#[arg(long)]
instance: Option<String>,
},
/// List the instances other launchers keep (Prism Launcher, MultiMC, the official
/// launcher), read without changing anything there
Others {
/// Look in this folder instead: a launcher's folder, its instances/ or one instance
#[arg(long)]
folder: Option<PathBuf>,
},
/// Copy another launcher's instance into a new Java instance: its Minecraft version and its
/// game folder (worlds, mods, packs, options); the other launcher's copy stays as it is
CopyFrom {
/// Its name, as `instance others` lists it
name: String,
/// Look in this folder instead: a launcher's folder, its instances/ or one instance
#[arg(long)]
folder: Option<PathBuf>,
/// The new instance's name (default: the same)
#[arg(long = "as")]
new_name: Option<String>,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
@@ -484,7 +505,8 @@ enum ModsCmd {
#[arg(long)]
instance: Option<String>,
},
/// Search for packs Horizon loads
/// Search for packs Horizon loads, or for what a Java instance runs (made for its
/// Minecraft version and loader)
Search {
text: Vec<String>,
#[arg(long, value_enum, default_value_t = KindArg::Resource)]
@@ -494,6 +516,9 @@ enum ModsCmd {
everything: bool,
#[arg(long, value_enum, default_value_t = SourceArg::Modrinth)]
source: SourceArg,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Say how well a Java mod runs in Horizon: its jar is downloaded (checksum checked),
/// carried over and read; the answer is kept
@@ -506,8 +531,9 @@ enum ModsCmd {
source: SourceArg,
},
/// Install a pack and what it needs into an instance, by its id or slug (checksums
/// checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported as
/// an instance of its own; a data pack goes into the world `--world` names
/// checked); a Java mod is ported into horizon-mods and turned on (a Java instance gets the
/// jar made for its version and loader in mods/); a modpack is imported as an instance of
/// its own; a data pack goes into the world `--world` names
Install {
project: String,
/// Install the project's data pack into this world of the instance (its name or folder)
@@ -617,7 +643,8 @@ enum Cmd {
Play {
#[arg(long)]
version: Option<String>,
/// The instance to play (its name or id; default: the selected one)
/// The instance to play (its name or id; default: the selected one). A Java instance
/// starts its Minecraft: Java Edition, downloaded with its Java the first time
#[arg(long)]
instance: Option<String>,
/// Open or create this world (a folder under saves) at once
@@ -1143,6 +1170,28 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Some(name) => instances::find(&name)?,
None => instances::selected(),
};
if instance.java.is_some() {
if version.is_some()
|| world.is_some()
|| join.is_some()
|| server.is_some()
|| creative
{
return Err(format!(
"{} plays Minecraft: Java Edition: --version, --world, --creative, --join \
and --server are for Horizon instances.",
instance.name
));
}
let mut cmd = backend::java_game(&instance, &|line| eprintln!("{line}"))?;
cmd.args(arg);
let started = std::time::Instant::now();
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
return played;
}
let mut cmd = backend::client(&instance.resolve_version(version.as_deref())?)?;
instances::apply(&mut cmd, &instance)?;
if let Some(code) = join {
@@ -1871,21 +1920,31 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> {
}
fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::browser::{horizon, installed, Kind, Query, SourceId};
use horizon_launcher::browser::{horizon, installed, java_instance, Kind, Query, SourceId};
use horizon_launcher::modpacks;
// What the browser installs goes into an instance's game folder; Java mods' checks are kept
// in the default one, shared by all.
let home = match &action {
let instance = match &action {
ModsCmd::List { instance }
| ModsCmd::Search { instance, .. }
| ModsCmd::Install { instance, .. }
| ModsCmd::Update { instance, .. }
| ModsCmd::Remove { instance, .. }
| ModsCmd::Enable { instance, .. }
| ModsCmd::Disable { instance, .. } => match instance {
Some(name) => instances::find(name)?.home,
None => instances::selected().home,
Some(name) => Some(instances::find(name)?),
None => Some(instances::selected()),
},
ModsCmd::Search { .. } | ModsCmd::Check { .. } => backend::game_home(),
ModsCmd::Check { .. } => None,
};
// A Java instance gets the source's own files, for its version and loader.
let java = instance
.as_ref()
.and_then(|i| i.java.as_ref())
.map(|j| j.target());
let home = match (&action, instance) {
(ModsCmd::Search { .. }, _) | (_, None) => backend::game_home(),
(_, Some(i)) => i.home,
};
let entry_json = |e: &installed::Entry| serde_json::to_value(e).unwrap_or_default();
let line = |label: &str, f: f32| progress(label.to_string())(f);
@@ -1928,6 +1987,7 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
kind,
everything,
source,
..
} => {
let kind = match kind {
KindArg::Resource => Kind::ResourcePack,
@@ -1936,6 +1996,23 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
KindArg::Shader => Kind::Shader,
KindArg::Datapack => Kind::DataPack,
};
if let Some(target) = &java {
let found = source.get()?.search(&target.query(&text.join(" "), kind))?;
if as_json {
json(found.iter().map(|p| serde_json::json!({
"id": p.id, "slug": p.slug, "title": p.title, "summary": p.summary,
"author": p.author, "downloads": p.downloads, "page": p.page,
"license": p.license,
})).collect());
}
for p in found.iter().filter(|_| !as_json) {
println!(
"{} ({}) {} downloads\n {}",
p.title, p.slug, p.downloads, p.summary
);
}
return Ok(());
}
let query = Query {
everything,
..Query::new(&text.join(" "), kind)
@@ -2101,7 +2178,10 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
println!("Play it: horizon-launcher modpack play {}", pack.slug);
return Ok(());
}
let done = installed::install(&*source, &p, everything, &home, &line)?;
let done = match &java {
Some(target) => java_instance::install(&*source, target, &p, &home, &line)?,
None => installed::install(&*source, &p, everything, &home, &line)?,
};
if as_json {
json(done.iter().map(entry_json).collect());
}
@@ -2111,6 +2191,17 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
println!(" not ported: {s}");
}
}
// A native mod turns Native mods on, with the window's notice, once.
let mut settings = backend::Settings::load();
let list = installed::Installed::load(&home).entries;
if !settings.native_mods && native_mods::turns_on(false, &list) {
if !settings.native_mods_noticed {
eprintln!("{}", native_mods::NOTICE);
}
settings.native_mods = true;
settings.native_mods_noticed = true;
settings.save();
}
}
ModsCmd::Update { project, check, .. } => {
let list = installed::Installed::load(&home).entries;
@@ -2133,14 +2224,24 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
println!("{}: {} isn't available here.", e.title, e.source.label());
continue;
};
match installed::update_for(&*source, e)? {
let newer = match &java {
Some(target) => java_instance::update_for(&*source, target, e)?,
None => installed::update_for(&*source, e)?,
};
match newer {
None => println!("{} {}: up to date.", e.title, e.number),
Some(v) if check => {
println!("{} {}: {} is available.", e.title, e.number, v.number)
}
Some(_) => {
let p = source.project(&e.project)?;
for done in installed::install(&*source, &p, true, &home, &line)? {
let done = match &java {
Some(target) => {
java_instance::install(&*source, target, &p, &home, &line)?
}
None => installed::install(&*source, &p, true, &home, &line)?,
};
for done in done {
println!("{} {}: {}", done.title, done.number, done.file);
}
}
@@ -2148,9 +2249,18 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
}
}
ModsCmd::Remove { project, .. } => {
let e = installed::remove(&home, &project)?;
let e = match java {
Some(_) => java_instance::remove(&home, &project)?,
None => installed::remove(&home, &project)?,
};
println!("Removed {} ({}).", e.title, e.file);
}
ModsCmd::Enable { .. } | ModsCmd::Disable { .. } if java.is_some() => {
return Err(
"A Java instance loads every mod in its mods/ folder: remove one to turn it off."
.into(),
);
}
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } => {
let e = installed::Installed::load(&home)
.lookup(&project)
@@ -2178,6 +2288,35 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
Ok(())
}
/// The other launchers' instances in `folder`, else in their default folders.
fn other_instances(folder: Option<&Path>) -> Result<Vec<other_launchers::Found>, String> {
use horizon_launcher::sources::{Folders, Os};
Ok(match folder {
Some(dir) if !dir.is_dir() => return Err(format!("{} isn't a folder.", dir.display())),
Some(dir) => other_launchers::in_folder(dir, None),
None => {
let folders = Folders::current().ok_or("No home folder.")?;
other_launchers::scan(Os::current(), &folders)
}
})
}
fn other_plays(f: &other_launchers::Found) -> String {
match &f.loader {
Some(l) => format!("Minecraft {} with {} {}", f.minecraft, l.name, l.version),
None => format!("Minecraft {}", f.minecraft),
}
}
fn other_json(f: &other_launchers::Found) -> serde_json::Value {
serde_json::json!({
"launcher": f.launcher, "name": f.name, "minecraft": f.minecraft,
"loader": f.loader.as_ref().map(|l| l.name),
"loader_version": f.loader.as_ref().map(|l| &l.version),
"game": f.game, "shared": f.shared,
})
}
fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
use instances::Icon;
let value = |i: &instances::Instance, selected: bool| {
@@ -2412,6 +2551,54 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
}
}
}
InstanceCmd::Others { folder } => {
let found = other_instances(folder.as_deref())?;
if as_json {
json(found.iter().map(other_json).collect());
} else if found.is_empty() {
println!("No other launcher's instance found.");
}
for f in found.iter().filter(|_| !as_json) {
println!(
"{} ({}, {})\n {}",
f.name,
f.launcher,
other_plays(f),
f.game.display()
);
}
}
InstanceCmd::CopyFrom {
name,
folder,
new_name,
} => {
let found = other_instances(folder.as_deref())?;
let matching: Vec<_> = found.iter().filter(|f| f.name == name).collect();
let f = match matching[..] {
[one] => one,
[] => {
return Err(format!(
"No other launcher has an instance called {name}: \
horizon-launcher instance others"
))
}
_ => {
return Err(format!(
"More than one launcher has an instance called {name}: say which \
with --folder."
))
}
};
let made = other_launchers::import(f, new_name.as_deref().unwrap_or(&f.name))?;
if let Some(l) = f.loader.as_ref().filter(|_| !as_json) {
println!(
"{} {} isn't installed for it yet: its mods are copied and wait for it.",
l.name, l.version
);
}
changed(made);
}
InstanceCmd::Import { file, instance } => {
use horizon_launcher::import::{self, Dropped};
use horizon_launcher::modpacks;
@@ -2946,15 +3133,15 @@ mod tests {
Some(ModsCmd::List { instance: None })
));
assert!(parse(&[]).is_none());
// Search and Check read no instance: checks are shared.
assert!(Cli::try_parse_from([
"horizon-launcher",
"mods",
"search",
"x",
"--instance",
"A"
])
.is_err());
// Search names one for a Java instance's version and loader; Check reads none: checks
// are shared.
assert!(matches!(
parse(&["search", "x", "--instance", "A"]),
Some(ModsCmd::Search { instance: Some(i), .. }) if i == "A"
));
assert!(
Cli::try_parse_from(["horizon-launcher", "mods", "check", "x", "--instance", "A"])
.is_err()
);
}
}
+2
View File
@@ -63,6 +63,8 @@ fn strings() -> (BTreeSet<String>, BTreeSet<String>) {
continue;
}
let s = literal(&file[at + 1..]);
// rustfmt puts a long string on the line after the call.
let before = before.trim_end();
let call = |name: &str| {
before.ends_with(&format!("{name}(")) && {
let head = &before[..before.len() - name.len() - 1];
+14 -4
View File
@@ -123,6 +123,16 @@ pub struct JavaGame {
pub jvm_args: String,
}
impl JavaGame {
/// What the mod browser installs for: its Minecraft version (vanilla: no loader).
pub fn target(&self) -> crate::browser::java_instance::Target {
crate::browser::java_instance::Target {
minecraft: self.minecraft.clone(),
loader: None,
}
}
}
/// 4 GiB: enough for vanilla and most modpacks, as other launchers start with.
pub const DEFAULT_MEMORY_MB: u32 = 4096;
@@ -174,14 +184,14 @@ pub struct Instance {
}
impl Instance {
/// Ok when it plays Horizon; a Java instance's refusal, until the launcher starts the real
/// game.
/// Ok when it plays Horizon; for a Java instance, which starts its own game
/// (`backend::java_game`), that Horizon's options don't apply to it.
fn horizon(&self) -> Result<(), String> {
match &self.java {
None => Ok(()),
Some(java) => Err(format!(
"{} plays Minecraft {} (Java Edition), which this launcher can't start yet: playing \
Java instances comes in a coming update.",
"{} plays Minecraft {} (Java Edition), not Horizon: Play starts it, without a \
Horizon version.",
self.name, java.minecraft
)),
}
+3
View File
@@ -193,6 +193,9 @@ impl Launcher {
InstanceKind::Default | InstanceKind::Own => (
self.page == Page::Play && selected.as_ref() == Some(&i.id),
match (&tag, &i.version) {
_ if i.java.is_some() => {
format!("Minecraft {}", i.java.as_ref().map_or("", |j| &j.minecraft))
}
(None, Some(own)) => trf("{version} is not installed", &[("version", own)]),
(None, None) => tr("No version installed").into(),
(Some(_), _) if !prepared => tr("Needs preparing").into(),
+103 -20
View File
@@ -22,6 +22,12 @@ pub struct Player {
pub xuid: String,
}
impl std::fmt::Debug for Player {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Player({}, <token hidden>)", self.name)
}
}
/// Everything the command needs besides the version JSON.
pub struct Launch<'a> {
/// Holds `versions/`, `libraries/`, `assets/`.
@@ -76,9 +82,86 @@ fn allowed(rules: &Value, os: &str) -> bool {
allow
}
/// The libraries this system uses, then the client jar.
/// A version file.
pub fn read(path: &Path) -> Result<Value, String> {
fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| serde_json::from_str(&text).map_err(|e| e.to_string()))
.map_err(|e| format!("{}: {e}", path.display()))
}
/// A library's name without its version: `group:artifact[:classifier]`.
fn library_key(library: &Value) -> String {
let mut parts = library["name"].as_str().unwrap_or_default().split(':');
let (group, artifact) = (
parts.next().unwrap_or_default(),
parts.next().unwrap_or_default(),
);
match parts.nth(1) {
Some(classifier) => format!("{group}:{artifact}:{classifier}"),
None => format!("{group}:{artifact}"),
}
}
/// A version with what it `inheritsFrom` (a loader's profile on the game's version) merged in, as
/// the official launcher reads it: the child's fields over the parent's, its libraries first (a
/// parent's library it names again is left out), its arguments after the parent's, and the
/// parent's client jar (`jar`).
pub fn resolve(version: Value, minecraft: &Path) -> Result<Value, String> {
let Some(parent) = version["inheritsFrom"].as_str() else {
return Ok(version);
};
let file = minecraft
.join("versions")
.join(mojang::relative(parent)?)
.join(format!("{parent}.json"));
let mut merged = resolve(read(&file)?, minecraft)?;
let jar = merged["jar"]
.as_str()
.or(merged["id"].as_str())
.unwrap_or(parent)
.to_string();
let own: Vec<Value> = version["libraries"].as_array().cloned().unwrap_or_default();
let named: std::collections::HashSet<String> = own.iter().map(library_key).collect();
let mut libraries = own;
libraries.extend(
merged["libraries"]
.as_array()
.into_iter()
.flatten()
.filter(|l| !named.contains(&library_key(l)))
.cloned(),
);
for side in ["game", "jvm"] {
let mut list = merged["arguments"][side]
.as_array()
.cloned()
.unwrap_or_default();
list.extend(
version["arguments"][side]
.as_array()
.into_iter()
.flatten()
.cloned(),
);
if !list.is_empty() {
merged["arguments"][side] = Value::Array(list);
}
}
for (key, value) in version.as_object().into_iter().flatten() {
if !matches!(key.as_str(), "libraries" | "arguments" | "inheritsFrom") {
merged[key] = value.clone();
}
}
merged["libraries"] = Value::Array(libraries);
merged["jar"] = jar.into();
Ok(merged)
}
/// The libraries this system uses, then the client jar (a loader's: the game's, `jar`).
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let jar = version["jar"].as_str().unwrap_or(id);
let mut paths: Vec<PathBuf> = Vec::new();
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
@@ -94,8 +177,8 @@ pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<Path
paths.push(
minecraft
.join("versions")
.join(mojang::relative(id)?)
.join(format!("{id}.jar")),
.join(mojang::relative(jar)?)
.join(format!("{jar}.jar")),
);
Ok(paths)
}
@@ -290,23 +373,23 @@ pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
.join(mojang::relative(file)?);
jvm.push(arg.replace("${path}", &path(&config)));
}
let (jvm_list, game) = match version["arguments"].as_object() {
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
None => (
vec![
"-Djava.library.path=${natives_directory}".into(),
"-cp".into(),
"${classpath}".into(),
],
version["minecraftArguments"]
.as_str()
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect(),
),
};
let arguments = &version["arguments"];
let (mut jvm_list, mut game) = (Vec::new(), Vec::new());
// Before 1.13: one line of game arguments, and the JVM's natives and classpath. A loader's
// profile on such a version adds its own lists after them.
if let Some(line) = version["minecraftArguments"].as_str() {
jvm_list.extend(
[
"-Djava.library.path=${natives_directory}",
"-cp",
"${classpath}",
]
.map(String::from),
);
game.extend(line.split_whitespace().map(String::from));
}
jvm_list.extend(list(&arguments["jvm"], launch.os));
game.extend(list(&arguments["game"], launch.os));
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
jvm.extend(launch.jvm_args.iter().cloned());
let mut cmd = Command::new(launch.java);
+83
View File
@@ -210,3 +210,86 @@ fn placeholders_fill_in_place() {
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
}
/// A loader's profile on its game version: its main class, its libraries first (one replacing
/// the game's), its JVM argument after the game's, and the game's jar.
#[test]
fn a_loader_profile_plays_on_its_game_version() {
let dir = scratch("loader");
let mc = dir.join("mc");
let write = |id: &str, version: &Value| {
let file = mc.join(format!("versions/{id}/{id}.json"));
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, version.to_string()).unwrap();
};
write("1.21.4", &modern());
let profile = json!({
"id": "fabric-loader-0.16.9-1.21.4", "inheritsFrom": "1.21.4",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "net.fabricmc:fabric-loader:0.16.9",
"downloads": {"artifact": {"path": "net/fabricmc/fabric-loader-0.16.9.jar"}}},
{"name": "a:common:2", "downloads": {"artifact": {"path": "a/common/2/common-2.jar"}}}
]
});
let version = resolve(profile, &mc).unwrap();
assert_eq!(version["id"], "fabric-loader-0.16.9-1.21.4");
assert_eq!(version["assetIndex"]["id"], "19", "the game's fields stay");
let cp = classpath(&version, &mc, "linux").unwrap();
let lib = |p: &str| mc.join("libraries").join(p);
assert_eq!(
cp,
[
lib("net/fabricmc/fabric-loader-0.16.9.jar"),
lib("a/common/2/common-2.jar"),
lib("a/not-mac/1/not-mac-1.jar"),
under(&mc, "versions/1.21.4/1.21.4.jar"),
]
);
let (game, natives, java, p) = (
dir.join("game"),
dir.join("natives"),
dir.join("java"),
player(),
);
let launch = Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 4096,
jvm_args: &[],
player: &p,
os: "linux",
};
let a = args(&command(&version, &launch).unwrap());
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
assert!(at("-cp") < at("-DFabricMcEmu= net.minecraft.client.main.Main "));
assert!(
at("-DFabricMcEmu= net.minecraft.client.main.Main ")
< at("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
assert_eq!(a[at("--version") + 1], "fabric-loader-0.16.9-1.21.4");
// On a version from before 1.13 the profile's JVM argument joins the old ones.
write(
"1.8.9",
&json!({"id": "1.8.9", "mainClass": "net.minecraft.client.main.Main",
"minecraftArguments": "--username ${auth_player_name}", "libraries": []}),
);
let old = resolve(
json!({"id": "fabric-loader-0.16.9-1.8.9", "inheritsFrom": "1.8.9",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"jvm": ["-Dfabric=1"]}, "libraries": []}),
&mc,
)
.unwrap();
let a = args(&command(&old, &launch).unwrap());
assert!(a.contains(&"-cp".into()) && a.contains(&"-Dfabric=1".into()));
assert_eq!(
a[a.iter().position(|x| x == "--username").unwrap() + 1],
"Notch"
);
fs::remove_dir_all(&dir).unwrap();
}
+1
View File
@@ -22,6 +22,7 @@ pub mod modpacks;
pub mod mojang;
pub mod native_mods;
pub mod nightly;
pub mod other_launchers;
pub mod packs;
pub mod process;
pub mod proxy;
+117 -19
View File
@@ -221,7 +221,8 @@ struct Launcher {
/// (`dev_options`) for this run.
dev_clicks: u8,
dev_options: bool,
/// The game is waiting for its session (a token renewal) before it starts.
/// The game is waiting for its session (a token renewal), or a Java instance for its files
/// and Java (`start_java`), before it starts.
starting_client: Option<String>,
/// Play was pressed on a version not prepared yet: it starts once this preparation ends well.
play_when_prepared: Option<String>,
@@ -677,6 +678,24 @@ impl Launcher {
Msg::Keychain(persistent) => self.keychain = persistent,
Msg::Owner(owns) => self.owner = Some(owns),
Msg::Playing(playing) => self.playing = playing,
Msg::JavaGame(id, home, cmd) => {
self.starting_client = None;
match cmd.and_then(|cmd| backend::spawn(Job::Client, cmd, self.tx.clone())) {
Ok(r) => {
self.client = Some(r);
self.client_home = Some(home);
self.client_run = Some((id, Instant::now()));
self.step_aside(cx);
}
Err(e) => {
if e == account::NoOwner.to_string() {
self.owner = Some(false);
self.owner_screen_open = true;
}
self.client_error = Some(e);
}
}
}
Msg::Session(session) => {
if let Some(tag) = self.starting_client.take() {
let mut cmd = backend::client_command(&tag);
@@ -851,6 +870,8 @@ impl Launcher {
}
}
Msg::PackDone(result) => self.pack_done(result),
// Old versions log their session; it never reaches the window.
Msg::Line(_, line) if line.contains("Session ID is") => {}
Msg::Line(job, line) => {
if job == Job::Client && line.starts_with(perf::GAME_FIRST_FRAME) {
if let Some(pressed) = self.play_pressed.take() {
@@ -1107,6 +1128,22 @@ impl Launcher {
}
fn play(&mut self) {
let java = self
.selected_instance()
.filter(|i| i.java.is_some())
.cloned();
if let Some(instance) = java {
// Mojang's game: no Horizon version or preparation, only no game already running.
// Joining and quick play are Horizon's.
self.joining = None;
self.quick_play = None;
if self.client.is_some() || self.starting_client.is_some() {
return;
}
self.starting_pack = None;
self.start_java(instance);
return;
}
let Some(tag) = self.selected().cloned() else {
return;
};
@@ -1117,6 +1154,26 @@ impl Launcher {
self.start_client(tag);
}
/// Starts a Java instance's Minecraft: its files and Java first (downloaded the first time,
/// their lines in the log), then `drain` starts the game when the command arrives.
fn start_java(&mut self, instance: instances::Instance) {
self.client_error = None;
self.crash = None;
self.prepare_log.clear();
self.client_started =
Some(std::time::SystemTime::now() - std::time::Duration::from_secs(2));
self.starting_client = Some(instance.id.clone());
let tx = self.tx.clone();
std::thread::spawn(move || {
let log = |line: String| {
let _ = tx.send(Msg::Line(Job::Client, line));
};
let cmd = backend::java_game(&instance, &log)
.map(|cmd| horizon_launcher::launch::wrap(cmd, &instance.launch));
let _ = tx.send(Msg::JavaGame(instance.id, instance.home, cmd));
});
}
/// Whether `tag` can start now ([`starts_now`]): prepared, no preparation under way, no game
/// running or starting.
pub(crate) fn can_start(&self, tag: &str) -> bool {
@@ -1124,9 +1181,11 @@ impl Launcher {
starts_now(backend::is_prepared(tag), self.preparing, running)
}
/// Whether the selected instance's version can start now.
/// Whether the selected instance's version can start now, from a shortcut (a server's Join,
/// a world's Play, a friend's code): Horizon's only, never a Java instance.
pub(crate) fn can_start_selected(&self) -> bool {
self.selected().is_some_and(|tag| self.can_start(tag))
self.selected_instance().is_none_or(|i| i.java.is_none())
&& self.selected().is_some_and(|tag| self.can_start(tag))
}
/// Starts the game: the session first (a network round trip when the token needs
@@ -2267,7 +2326,13 @@ impl Launcher {
fn play_page(&mut self, banner_width: f32, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let Some(tag) = self.selected().cloned() else {
// A Java instance plays Mojang's game: no Horizon version, nothing of Horizon's to prepare.
let java = self.selected_instance().and_then(|i| i.java.clone());
let Some(tag) = self
.selected()
.cloned()
.or_else(|| java.is_some().then(String::new))
else {
// No hero without a version: the page starts below the top bar like the others.
return div()
.flex()
@@ -2291,8 +2356,13 @@ impl Launcher {
let prepared = backend::is_prepared(&tag);
// Prepared without sounds (#1035): Prepare again is the fix, so it stands out.
let silent = prepared && !backend::has_sounds(&tag);
let version = backend::game_version(&tag);
let hero = self.hero(&tag, &version, prepared, cx);
let version = java
.as_ref()
.map_or_else(|| backend::game_version(&tag), |j| j.minecraft.clone());
let hero = match java {
Some(_) => self.hero("", &version, true, cx),
None => self.hero(&tag, &version, prepared, cx),
};
let running = self.client.is_some() || self.starting_client.is_some();
let play_label = if running { "Running" } else { "Play" };
// The label clears the icon in every language ("Играть" is wider than "Play").
@@ -2310,7 +2380,11 @@ impl Launcher {
.top(px(17.))
.child(blocks::icon("play", 22., t.on_face)),
);
match play_press(prepared, self.owner, self.preparing || running) {
match play_press(
prepared || java.is_some(),
self.owner,
self.preparing || running,
) {
PlayPress::Nothing => play = disabled(play),
PlayPress::Play => {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
@@ -2460,15 +2534,31 @@ impl Launcher {
.child(section_title("Ready to play", "check", &t).mb(px(12.)))
.children(self.instance_row(1, cx))
.children(self.export_panel(cx))
.children(self.launch_panel(cx))
.child(index_row(
.children(self.launch_panel(cx));
rows = match &java {
Some(java) => rows.child(index_row(
2,
tr("Resources"),
Some(div().child(resources_value).into_any_element()),
Some(prepare.into_any_element()),
tr("Game"),
Some(
div()
.child(trf(
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.",
&[("version", &java.minecraft)],
))
.into_any_element(),
),
None,
&t,
))
.child(index_row(
)),
None => rows
.child(index_row(
2,
tr("Resources"),
Some(div().child(resources_value).into_any_element()),
Some(prepare.into_any_element()),
&t,
))
.child(index_row(
3,
tr("Minecraft folder"),
Some(minecraft),
@@ -2478,9 +2568,12 @@ impl Launcher {
.into_any_element(),
),
&t,
));
)),
};
{
rows = rows.child(self.join_friend_row(self.can_start(&tag), cx));
if java.is_none() {
rows = rows.child(self.join_friend_row(self.can_start(&tag), cx));
}
// The top bar shows who plays; a row only when there is something to do about it:
// no account that owns the game (Play needs one), or offline.
if self.owner_shown() == Some(false) {
@@ -2759,7 +2852,8 @@ impl Launcher {
.child(body)
}
/// The Play page's focal point: what will start, and a line from its notes.
/// The Play page's focal point: what will start, and a line from its notes. No `tag`: a Java
/// instance's Minecraft.
fn hero(&self, tag: &str, version: &str, prepared: bool, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let summary = match &self.releases {
@@ -2769,7 +2863,11 @@ impl Launcher {
.and_then(|r| backend::notes_summary(&r.body)),
_ => None,
};
let title = "Horizon";
let title = if tag.is_empty() {
"Minecraft"
} else {
"Horizon"
};
// Never squeezed by the rows below: the ping notice can make it tall.
let mut hero = div()
.flex()
@@ -3244,7 +3342,7 @@ impl Launcher {
.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());
.child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), 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(
+74
View File
@@ -83,6 +83,8 @@ pub(super) struct Mods {
message: Option<Result<String, String>>,
updates: HashMap<(SourceId, String), String>,
confirm_remove: Option<String>,
/// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`).
native_notice: Option<(SourceId, String, Option<Project>)>,
/// Java mods' checks, by source and project.
pub(super) checks: HashMap<(SourceId, String), Checking>,
/// Horizon registry projects' trust and capabilities, by id ([`horizon::trust_and_capabilities`]).
@@ -164,6 +166,7 @@ impl Mods {
world: None,
busy: None,
message: None,
native_notice: None,
updates: HashMap::new(),
confirm_remove: None,
checks: HashMap::new(),
@@ -269,6 +272,7 @@ impl Launcher {
let mut to_check = Vec::new();
let mut loaded = Vec::new();
let mut done = false;
let mut native_on = false;
while let Ok(event) = self.mods.rx.try_recv() {
changed = true;
let m = &mut self.mods;
@@ -299,6 +303,7 @@ impl Launcher {
m.busy = None;
m.message = Some(result);
m.installed = installed::Installed::load(&m.home()).entries;
native_on = native_mods::turns_on(native_on, &m.installed);
m.installed_list.reset();
if modpack {
// The Modpacks page reads its list again.
@@ -326,6 +331,12 @@ impl Launcher {
}
}
}
// A native mod installed: Native mods turn on, so Settings offers its Build.
if native_on && !self.settings.native_mods {
self.settings.native_mods = true;
self.settings.native_mods_noticed = true;
self.settings.save();
}
// `HORIZON_LAUNCHER_PROJECT=<slug>` (with `HORIZON_LAUNCHER_PAGE=mods`): opens that
// project's page once a search finds it, for screenshots.
if self.mods.browse.open.is_none() {
@@ -578,6 +589,18 @@ impl Launcher {
if self.mods.busy.is_some() {
return;
}
// The first native mod: what it starts, before anything is installed.
let first_native = source == SourceId::Horizon
&& native_mods::needs_notice(
self.settings.native_mods,
self.settings.native_mods_noticed,
)
&& horizon::is_native_mod(&id);
if first_native {
self.mods.native_notice = Some((source, id, known));
return;
}
self.mods.native_notice = None;
self.mods.message = None;
self.mods.busy = Some((id.clone(), format!("Asking {}…", source.label()), 0.));
self.mods.updates.remove(&(source, id.clone()));
@@ -849,6 +872,7 @@ impl Launcher {
.child(header)
.child(div().flex().flex_col().child(kinds).child(body))
};
let body = body.children(self.native_notice(cx));
if let Some(project) = self.mods_project_page(cx) {
return page(body.children(self.mods_busy_plate()).child(project));
}
@@ -926,6 +950,56 @@ impl Launcher {
}
/// The install under way and the last install's answer.
/// The one-time notice before the first native mod is installed: what the build takes. Its
/// button installs the mod; Native mods then turn on.
fn native_notice(&self, cx: &mut Context<Self>) -> Option<Div> {
self.mods.native_notice.as_ref()?;
let t = self.theme;
Some(
plate(&t)
.flex()
.flex_col()
.gap(px(10.))
.p(px(16.))
.text_size(px(theme::UI.px))
.text_color(t.ink)
.child(tr(native_mods::NOTICE).to_string())
.child(
div()
.flex()
.gap(px(8.))
.child(
button(
"native-notice-install",
"Install and turn on native mods",
Kind::Primary,
&t,
)
.on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.settings.native_mods_noticed = true;
this.settings.save();
if let Some((source, id, known)) =
this.mods.native_notice.take()
{
this.mods_install(source, id, known);
}
cx.notify();
},
)),
)
.child(
button("native-notice-cancel", "Cancel", Kind::Secondary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.mods.native_notice = None;
cx.notify();
}),
),
),
),
)
}
pub(super) fn mods_busy_plate(&self) -> Vec<Div> {
let t = self.theme;
let mut out = Vec::new();
+56
View File
@@ -33,6 +33,26 @@ pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06";
const PROFILE: &str = "release";
const FEATURES: &str = "horizon-client/mods";
/// Whether Native mods are on after an install into a game folder whose list is `installed`:
/// installing a native mod turns them on, so the player only has to build. A player who never
/// installs one keeps them off; one already on stays on.
pub fn turns_on(on: bool, installed: &[crate::browser::installed::Entry]) -> bool {
on || installed.iter().any(|e| {
e.file
.strip_prefix(INSTALLED)
.is_some_and(|rest| rest.starts_with('/'))
})
}
/// Whether installing a native mod shows its notice first (what the first build takes): once,
/// for a player whose Native mods are still off.
pub fn needs_notice(on: bool, noticed: bool) -> bool {
!on && !noticed
}
/// The notice before the first native mod is installed.
pub const NOTICE: &str = "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.";
/// Whether an installed version is a native-mods build.
pub fn is_native(version: &str) -> bool {
version.starts_with(PREFIX)
@@ -778,6 +798,42 @@ pub fn prune(keep: &str) {
mod tests {
use super::*;
/// Ludovic's decision (2026-10-29): installing a native mod turns Native mods on, after a
/// notice shown once; players who never install one keep them off.
#[test]
fn installing_a_native_mod_turns_native_mods_on() {
let entry = |file: &str| {
serde_json::from_value::<crate::browser::installed::Entry>(serde_json::json!({
"source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod",
"version": "1", "number": "1", "file": file, "checksum": "", "page": ""
}))
.unwrap()
};
let plain = [
entry("horizon-mods/zoom.hmod"),
entry("resourcepacks/x.zip"),
];
assert!(!turns_on(false, &[]));
assert!(!turns_on(false, &plain), "no native mod: they stay off");
assert!(!turns_on(
false,
&[entry("horizon-native-mods-old/x.hnmod")]
));
let native = [
plain[0].clone(),
entry("horizon-native-mods/veinminer.hnmod"),
];
assert!(turns_on(false, &native));
assert!(turns_on(true, &plain), "on stays on");
// The notice: before the first one only.
assert!(needs_notice(false, false));
assert!(!needs_notice(false, true));
assert!(!needs_notice(true, false));
// Settings without the fields read as off and not noticed.
let s: backend::Settings = serde_json::from_str("{}").unwrap();
assert!(!s.native_mods && !s.native_mods_noticed);
}
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);
+308
View File
@@ -0,0 +1,308 @@
//! Instances other launchers keep, found without changing anything there, and copied into new
//! Minecraft: Java Edition instances. Built from the formats' public descriptions only:
//!
//! - Prism Launcher and MultiMC (the same instance format: an instance folder copied from one
//! works in the other): `instances/<id>/` holds `instance.cfg` (INI, its `name`),
//! `mmc-pack.json` (`components`, each a `uid` and a `version`: `net.minecraft` is the game,
//! the others its loader) and the game folder, `minecraft/` or `.minecraft/`.
//! <https://prismlauncher.org/wiki/getting-started/migrating-multimc/>,
//! <https://prismlauncher.org/wiki/getting-started/data-location/>,
//! <https://prismlauncher.org/wiki/overview/troubleshooting/>
//! - The official launcher: `launcher_profiles.json`'s `profiles`, each a `name`, a `type`
//! (`custom`, `latest-release`, `latest-snapshot`), the version it plays (`lastVersionId`, an
//! id of `versions/<id>/<id>.json`) and its game folder (`gameDir`, else the launcher's own).
//! <https://minecraft.wiki/w/Launcher_profiles.json>
use crate::instances::{self, Instance};
use crate::sources::{self, Folders, Os};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
const OFFICIAL: &str = "the official launcher";
/// A mod loader and its version, as the other launcher names it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Loader {
/// `fabric`, `quilt`, `neoforge` or `forge`.
pub name: &'static str,
pub version: String,
}
/// An instance found in another launcher.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Found {
pub launcher: &'static str,
pub name: String,
/// The Minecraft version: an id (`1.21.1`), `latest` or `latest-snapshot`.
pub minecraft: String,
pub loader: Option<Loader>,
/// Its game folder: saves, mods, packs, options.
pub game: PathBuf,
/// The game folder is the launcher's own (the official launcher's default): only what an
/// instance holds is copied, not the launcher's versions, libraries and assets.
pub shared: bool,
}
/// What a game folder shared with a launcher gives a new instance.
const SHARED: [&str; 9] = [
"saves",
"mods",
"config",
"resourcepacks",
"shaderpacks",
"screenshots",
"options.txt",
"servers.dat",
"optionsshaders.txt",
];
/// What is never copied from an instance's own game folder.
const SKIPPED: [&str; 2] = ["logs", "crash-reports"];
fn loader_of_uid(uid: &str) -> Option<&'static str> {
match uid {
"net.fabricmc.fabric-loader" => Some("fabric"),
"org.quiltmc.quilt-loader" => Some("quilt"),
"net.neoforged" => Some("neoforge"),
"net.minecraftforge" => Some("forge"),
_ => None,
}
}
/// `key=value` of an INI file's lines, outside any section or in `[General]`.
fn ini_value(text: &str, key: &str) -> Option<String> {
let mut section = "";
for line in text.lines().map(str::trim) {
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
section = s;
continue;
}
if !matches!(section, "" | "General") {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
return Some(v.trim().to_string());
}
}
}
None
}
/// One Prism or MultiMC instance folder.
pub fn prism_instance(dir: &Path, launcher: &'static str) -> Option<Found> {
let pack: Value =
serde_json::from_str(&fs::read_to_string(dir.join("mmc-pack.json")).ok()?).ok()?;
let components = pack["components"].as_array()?;
let version = |uid: &str| {
components
.iter()
.find(|c| c["uid"] == uid)
.and_then(|c| c["version"].as_str())
.map(String::from)
};
let minecraft = version("net.minecraft")?;
let loader = components.iter().find_map(|c| {
Some(Loader {
name: loader_of_uid(c["uid"].as_str()?)?,
version: c["version"].as_str()?.to_string(),
})
});
let game = ["minecraft", ".minecraft"]
.into_iter()
.map(|g| dir.join(g))
.find(|g| g.is_dir())
.unwrap_or_else(|| dir.join("minecraft"));
let folder = dir.file_name()?.to_string_lossy().into_owned();
let name = fs::read_to_string(dir.join("instance.cfg"))
.ok()
.and_then(|t| ini_value(&t, "name"))
.filter(|n| !n.is_empty())
.unwrap_or(folder);
Some(Found {
launcher,
name,
minecraft,
loader,
game,
shared: false,
})
}
/// Every instance in a Prism or MultiMC folder (its root, or its `instances/` itself).
pub fn prism_instances(root: &Path, launcher: &'static str) -> Vec<Found> {
let instances = if root.join("instances").is_dir() {
root.join("instances")
} else {
root.to_path_buf()
};
let mut found: Vec<Found> = fs::read_dir(&instances)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| prism_instance(&e.path(), launcher))
.collect();
found.sort_by_key(|f| f.name.to_lowercase());
found
}
/// The game version and loader a version id of the official launcher plays: a loader's
/// profile names the game's version in `inheritsFrom`, and the loader in its id
/// (`fabric-loader-0.16.9-1.21.1`, `quilt-loader-0.27.1-1.21.1`, `neoforge-21.1.77`,
/// `1.20.1-forge-47.3.0`).
fn official_version(root: &Path, id: &str) -> (String, Option<Loader>) {
let file = root.join("versions").join(id).join(format!("{id}.json"));
let parent = fs::read_to_string(file)
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v["inheritsFrom"].as_str().map(String::from));
let Some(minecraft) = parent else {
return (id.to_string(), None);
};
let after = |prefix: &str| {
id.strip_prefix(prefix)
.map(|rest| rest.strip_suffix(&format!("-{minecraft}")).unwrap_or(rest))
.map(String::from)
};
let loader = after("fabric-loader-")
.map(|v| ("fabric", v))
.or_else(|| after("quilt-loader-").map(|v| ("quilt", v)))
.or_else(|| after("neoforge-").map(|v| ("neoforge", v)))
.or_else(|| {
id.split_once("-forge-")
.map(|(_, v)| ("forge", v.to_string()))
})
.map(|(name, version)| Loader { name, version });
(minecraft, loader)
}
/// The official launcher's profiles in its folder `root`.
pub fn official_profiles(root: &Path) -> Vec<Found> {
let Some(text) = fs::read_to_string(root.join("launcher_profiles.json")).ok() else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<Value>(&text) else {
return Vec::new();
};
let mut found: Vec<Found> = json["profiles"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, p)| {
let kind = p["type"].as_str().unwrap_or("custom");
let id = p["lastVersionId"].as_str().unwrap_or_default();
let (minecraft, loader) = match kind {
"latest-release" => ("latest".to_string(), None),
"latest-snapshot" => ("latest-snapshot".to_string(), None),
_ if id.is_empty() => return None,
_ => official_version(root, id),
};
let name = p["name"].as_str().unwrap_or_default().trim();
let name = match (name, kind) {
("", "latest-release") => "Latest release".to_string(),
("", "latest-snapshot") => "Latest snapshot".to_string(),
("", _) => id.to_string(),
(n, _) => n.to_string(),
};
let game = p["gameDir"]
.as_str()
.filter(|d| !d.is_empty())
.map(PathBuf::from);
Some(Found {
launcher: OFFICIAL,
name,
minecraft,
loader,
shared: game.as_ref().is_none_or(|g| g == root),
game: game.unwrap_or_else(|| root.to_path_buf()),
})
})
.collect();
found.sort_by_key(|f| f.name.to_lowercase());
found
}
/// What one folder holds: an official launcher's profiles, a Prism or MultiMC root (or its
/// `instances/`), or a single instance folder.
pub fn in_folder(dir: &Path, launcher: Option<&'static str>) -> Vec<Found> {
if dir.join("launcher_profiles.json").is_file() {
return official_profiles(dir);
}
let named = |names: &[&str]| {
dir.ancestors()
.filter_map(Path::file_name)
.any(|n| names.iter().any(|m| n == *m))
};
let launcher = launcher.unwrap_or(if named(&["multimc", "MultiMC"]) {
"MultiMC"
} else if named(&["PrismLauncher"]) {
"Prism Launcher"
} else {
"Prism Launcher or MultiMC"
});
match prism_instance(dir, launcher) {
Some(one) => vec![one],
None => prism_instances(dir, launcher),
}
}
/// Every instance the launchers on this computer keep in their default folders.
pub fn scan(os: Os, folders: &Folders) -> Vec<Found> {
let mut found = Vec::new();
let mut seen: Vec<PathBuf> = Vec::new();
for (launcher, dir) in sources::known_folders(os, folders) {
if seen.contains(&dir) || !dir.is_dir() {
continue;
}
seen.push(dir.clone());
match launcher {
OFFICIAL => found.extend(official_profiles(&dir)),
"Prism Launcher" | "MultiMC" => found.extend(prism_instances(&dir, launcher)),
_ => {}
}
}
found
}
/// Copies `found`'s game folder into `to`: all of it from an instance's own folder but its
/// logs, and only what an instance holds from a folder shared with the launcher. Nothing in
/// the other launcher's folder changes.
pub fn copy_game(found: &Found, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
let Ok(entries) = fs::read_dir(&found.game) else {
return Ok(());
};
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
let wanted = if found.shared {
SHARED.contains(&name.as_ref())
} else {
!SKIPPED.contains(&name.as_ref())
};
if !wanted {
continue;
}
if entry.file_type()?.is_dir() {
crate::backend::copy_dir(&entry.path(), &to.join(name.as_ref()))?;
} else {
fs::copy(entry.path(), to.join(name.as_ref()))?;
}
}
Ok(())
}
/// A new Java instance named `name` playing what `found` plays, with a copy of its game folder.
pub fn import(found: &Found, name: &str) -> Result<Instance, String> {
let versions = crate::minecraft_versions::load()?;
let version = versions.find(&found.minecraft)?.id.clone();
let instance = instances::create_java(name, &version)?;
if let Err(e) = copy_game(found, &instance.home) {
let _ = instances::delete(&instance.id);
return Err(format!("Could not copy {}: {e}", found.game.display()));
}
Ok(instance)
}
#[cfg(test)]
mod tests;
+186
View File
@@ -0,0 +1,186 @@
use super::*;
use serde_json::json;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-other-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write(path: &Path, text: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
#[test]
fn prism_and_multimc_instances() {
let root = scratch("prism");
let fabric = root.join("instances/Fabric 1.21.1");
write(
&fabric.join("instance.cfg"),
"[General]\nInstanceType=OneSix\nname=My Fabric\niconKey=default\n",
);
let pack = json!({"formatVersion": 1, "components": [
{"uid": "org.lwjgl3", "version": "3.3.3", "dependencyOnly": true},
{"uid": "net.minecraft", "version": "1.21.1", "important": true},
{"uid": "net.fabricmc.intermediary", "version": "1.21.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.16.9"}
]});
write(&fabric.join("mmc-pack.json"), &pack.to_string());
write(&fabric.join("minecraft/mods/sodium.jar"), "jar");
// MultiMC's game folder, and no instance.cfg: the folder's name.
let old = root.join("instances/old");
write(
&old.join("mmc-pack.json"),
&json!({"components": [{"uid": "net.minecraft", "version": "1.12.2"},
{"uid": "net.minecraftforge", "version": "14.23.5.2860"}]})
.to_string(),
);
fs::create_dir_all(old.join(".minecraft")).unwrap();
// Not an instance.
fs::create_dir_all(root.join("instances/_MMC_TEMP")).unwrap();
let found = prism_instances(&root, "Prism Launcher");
assert_eq!(
found,
[
Found {
launcher: "Prism Launcher",
name: "My Fabric".into(),
minecraft: "1.21.1".into(),
loader: Some(Loader {
name: "fabric",
version: "0.16.9".into()
}),
game: fabric.join("minecraft"),
shared: false,
},
Found {
launcher: "Prism Launcher",
name: "old".into(),
minecraft: "1.12.2".into(),
loader: Some(Loader {
name: "forge",
version: "14.23.5.2860".into()
}),
game: old.join(".minecraft"),
shared: false,
},
]
);
assert_eq!(in_folder(&fabric, None)[0].name, "My Fabric");
assert_eq!(in_folder(&root.join("instances"), Some("MultiMC")).len(), 2);
let named = root.join("MultiMC/instances/old");
fs::create_dir_all(named.parent().unwrap()).unwrap();
fs::rename(&old, &named).unwrap();
assert_eq!(in_folder(&named, None)[0].launcher, "MultiMC");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn official_launcher_profiles() {
let root = scratch("official");
let custom = root.join("custom");
let profiles = json!({"profiles": {
"a": {"type": "latest-release", "lastVersionId": "latest-release", "name": ""},
"b": {"type": "custom", "name": "Fabric", "lastVersionId": "fabric-loader-0.16.9-1.21.1",
"gameDir": custom.to_string_lossy()},
"c": {"type": "custom", "name": "Old Forge", "lastVersionId": "1.20.1-forge-47.3.0"},
"d": {"type": "custom", "name": "", "lastVersionId": "1.21.4"},
"e": {"type": "custom", "name": "Broken"}
}});
write(&root.join("launcher_profiles.json"), &profiles.to_string());
let profile = |id: &str, parent: &str| {
let file = root.join(format!("versions/{id}/{id}.json"));
write(
&file,
&json!({"id": id, "inheritsFrom": parent}).to_string(),
);
};
profile("fabric-loader-0.16.9-1.21.1", "1.21.1");
profile("1.20.1-forge-47.3.0", "1.20.1");
let found = in_folder(&root, None);
let short: Vec<_> = found
.iter()
.map(|f| {
(
f.name.as_str(),
f.minecraft.as_str(),
f.loader.as_ref().map(|l| (l.name, l.version.as_str())),
f.shared,
)
})
.collect();
assert_eq!(
short,
[
("1.21.4", "1.21.4", None, true),
("Fabric", "1.21.1", Some(("fabric", "0.16.9")), false),
("Latest release", "latest", None, true),
("Old Forge", "1.20.1", Some(("forge", "47.3.0")), true),
]
);
assert_eq!(found[1].game, custom);
assert_eq!(found[0].game, root);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn copies_an_instance_but_only_what_a_shared_folder_holds_for_one() {
let root = scratch("copy");
let game = root.join("game");
for f in [
"saves/World/level.dat",
"mods/a.jar",
"kubejs/startup.js",
"options.txt",
"logs/latest.log",
"versions/1.21.1/1.21.1.jar",
"launcher_profiles.json",
] {
write(&game.join(f), f);
}
let mut found = Found {
launcher: "Prism Launcher",
name: "x".into(),
minecraft: "1.21.1".into(),
loader: None,
game: game.clone(),
shared: false,
};
let listed = |dir: &Path| {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
};
let own = root.join("own");
copy_game(&found, &own).unwrap();
assert_eq!(
listed(&own),
[
"kubejs",
"launcher_profiles.json",
"mods",
"options.txt",
"saves",
"versions"
]
);
assert_eq!(
fs::read_to_string(own.join("saves/World/level.dat")).unwrap(),
"saves/World/level.dat"
);
found.shared = true;
let shared = root.join("shared");
copy_game(&found, &shared).unwrap();
assert_eq!(listed(&shared), ["mods", "options.txt", "saves"]);
// The other launcher's folder is left as it was.
assert!(game.join("logs/latest.log").is_file());
let _ = fs::remove_dir_all(&root);
}