mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266c4576a5 | ||
|
|
a7841ccb18 | ||
|
|
c9a103852c | ||
|
|
a15faef940 | ||
|
|
58cd73ee0b | ||
|
|
e2a3e92424 | ||
|
|
6f8c7a0e5d | ||
|
|
04cbda1b14 | ||
|
|
2de8bbd84b | ||
|
|
a7177cfbd7 | ||
|
|
b558a845a0 | ||
|
|
9f1a998403 | ||
|
|
527cd7225c | ||
|
|
f64fdeaa75 | ||
|
|
a0c79c8b26 | ||
|
|
0284d3bb16 | ||
|
|
72b4d8ecbb | ||
|
|
1ed31e54eb | ||
|
|
b3aaa04129 | ||
|
|
bb38695695 | ||
|
|
6bda112225 | ||
|
|
77fa9fb89f | ||
|
|
153d18a102 | ||
|
|
e470f3a0c9 | ||
|
|
f93c28a230 | ||
|
|
b9721d816d | ||
|
|
5091896e2a | ||
|
|
d4120a9ef0 | ||
|
|
795a0db7e4 | ||
|
|
a9f8e64331 | ||
|
|
01d0f45c2d | ||
|
|
1149c92cb8 | ||
|
|
99008baad9 | ||
|
|
e1bc2cbea3 | ||
|
|
7b93b310a0 | ||
|
|
0112a6feac | ||
|
|
9d32bebad3 | ||
|
|
e24394ca3a | ||
|
|
a5ee1e588c | ||
|
|
752fc2170a | ||
|
|
356b69aea7 | ||
|
|
fdefcfeac0 | ||
|
|
9fcfdaa1ea | ||
|
|
3be828d012 | ||
|
|
1deb3bd36c | ||
|
|
2ad7b68710 | ||
|
|
203fb2db89 | ||
|
|
2f7edd1524 | ||
|
|
32a0a11fed | ||
|
|
0580626784 | ||
|
|
65bca4ce98 | ||
|
|
2812cf201d | ||
|
|
9bbc54bd4c | ||
|
|
4fdc93257f | ||
|
|
b38fe43b09 | ||
|
|
35581f52e2 | ||
|
|
618d20bd08 | ||
|
|
985ece811b | ||
|
|
c734c72fc9 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 |
No files matched your search
@@ -1409,6 +1409,8 @@ struct Game<'a> {
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
dialogs: dialogs::DialogState,
|
||||
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
|
||||
integrated_starting: Option<integrated::Starting>,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -5054,6 +5056,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
@@ -5078,6 +5081,11 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
@@ -11575,6 +11583,27 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
// the window doesn't see yet: the title alone, over the menu background.
|
||||
if self.integrated_starting.is_some() {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
let (w, h) = (hud.width, hud.height);
|
||||
horizon_client::ui::menu_background(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
false,
|
||||
);
|
||||
horizon_client::level_loading::draw(&mut hud, &self.gui.atlas, None, None);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
@@ -11795,7 +11824,8 @@ impl Game<'_> {
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
(
|
||||
@@ -12105,7 +12135,8 @@ impl Game<'_> {
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
fn menu_blur(&self) -> Option<f32> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
@@ -12764,7 +12795,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.perf.cancel_world_opening();
|
||||
return;
|
||||
}
|
||||
if (self.pending_entry.is_some() || self.world_loader.is_some())
|
||||
if (self.pending_entry.is_some()
|
||||
|| self.world_loader.is_some()
|
||||
|| self.integrated_starting.is_some())
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
@@ -12984,6 +13017,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
// ConnectScreen's Cancel, under the prompts and dialogs above.
|
||||
WindowEvent::MouseInput { state, button, .. } if self.remote_loading() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.connect_screen_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -13769,6 +13808,7 @@ impl<'a> Game<'a> {
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -14243,6 +14283,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if !loader.ready() {
|
||||
|
||||
@@ -1029,10 +1029,7 @@ impl Game<'_> {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
self.ui.difficulty = self.level.difficulty_id();
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
|
||||
@@ -2573,6 +2573,9 @@ impl Game<'_> {
|
||||
)));
|
||||
}
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
|
||||
@@ -131,6 +131,8 @@ impl Game<'_> {
|
||||
map.insert("item".into(), save::item_to_nbt(item));
|
||||
}
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
// The sherds and item go to the guests (BlockEntityData).
|
||||
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
|
||||
self.consume_held();
|
||||
self.audio.play(
|
||||
"minecraft:block.decorated_pot.insert",
|
||||
|
||||
@@ -169,6 +169,16 @@ enum Publish {
|
||||
/// of the connection and the world's name, or why it failed.
|
||||
type Ready = std::result::Result<(Pipe, String), String>;
|
||||
|
||||
/// An integrated server loading its world: the window keeps drawing its
|
||||
/// loading screen and joins once the server is ready, as vanilla's client
|
||||
/// does while its server prepares (`Minecraft.doWorldLoad`), instead of
|
||||
/// waiting for it frozen for the whole load.
|
||||
pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
struct Launch {
|
||||
folder: String,
|
||||
@@ -197,8 +207,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.published().is_some())
|
||||
}
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
|
||||
/// until it can be joined, then join it through memory.
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
|
||||
/// window joins it through memory once it is ready (`poll_integrated_start`).
|
||||
pub(super) fn open_integrated(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -267,20 +277,53 @@ impl Game<'_> {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
let mut handle = Integrated {
|
||||
let handle = Integrated {
|
||||
flags,
|
||||
thread: Some(thread),
|
||||
tasks,
|
||||
world_name: String::new(),
|
||||
};
|
||||
let pipe = match ready.recv() {
|
||||
Ok(Ok((pipe, world_name))) => {
|
||||
handle.world_name = world_name;
|
||||
pipe
|
||||
self.integrated_starting = Some(Starting {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
while self.integrated_starting.is_some() {
|
||||
self.poll_integrated_start()?;
|
||||
if self.integrated_starting.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err("the integrated server stopped".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The starting server's answer, polled each frame: joined once it is ready.
|
||||
pub(super) fn poll_integrated_start(&mut self) -> Result<()> {
|
||||
let Some(starting) = &self.integrated_starting else {
|
||||
return Ok(());
|
||||
};
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.integrated_starting = None;
|
||||
return Err("the integrated server stopped".into());
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
@@ -64,6 +65,10 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != Hand::Main {
|
||||
return false;
|
||||
}
|
||||
self.eject_record(pos);
|
||||
return true;
|
||||
}
|
||||
@@ -100,6 +105,30 @@ impl Game<'_> {
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `JukeboxBlock.useItemOn` on this client's level for the main hand:
|
||||
/// whether the jukebox at `pos` takes the click (`has_record` pops the
|
||||
/// disc out, or a `jukebox_playable` item goes in). The server does the
|
||||
/// rest, see `use_jukebox`.
|
||||
pub(super) fn jukebox_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
|
||||
return false;
|
||||
}
|
||||
let has_record = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_record"))
|
||||
.is_some_and(|v| v == "true");
|
||||
has_record
|
||||
|| held.is_some_and(|stack| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.jukebox_song.is_some())
|
||||
})
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
self.stop_sound(pos);
|
||||
|
||||
@@ -2040,6 +2040,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| (g.chunk(), radius))
|
||||
.chain(
|
||||
self.arrival_chunks(self.dim)
|
||||
.into_iter()
|
||||
// An arrival keeps only its landing spot lit enough to
|
||||
// resolve (the 3x3 `resolve_arrivals` checks), where it
|
||||
// then ticks without a player.
|
||||
.map(|chunk| (chunk, 1)),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -1038,12 +1038,23 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
|
||||
// under spawn protection (the ack puts the block back).
|
||||
if self.under_spawn_protection(host, gi, a.pos) {
|
||||
self.spawn_protection_message(host, conn, a.pos);
|
||||
return;
|
||||
}
|
||||
// mayInteract: a block outside the world border can't be
|
||||
// dug (the ack puts it back on the client).
|
||||
if !self
|
||||
.border
|
||||
.contains(f64::from(a.pos[0]), f64::from(a.pos[2]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
@@ -1070,6 +1081,15 @@ impl Game<'_> {
|
||||
1 => {}
|
||||
3 => {
|
||||
host.hub.ack(conn, a.sequence);
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
// Too far (handleBlockBreakAction's "too far"): the
|
||||
// dig drops and the ack has the client undo its
|
||||
// prediction.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = None;
|
||||
play.mining = None;
|
||||
return;
|
||||
}
|
||||
self.guest_stop_digging(host, conn, a.pos);
|
||||
}
|
||||
2 => {
|
||||
@@ -1162,6 +1182,14 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
let inside = p
|
||||
.cursor
|
||||
.iter()
|
||||
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
|
||||
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
|
||||
return;
|
||||
}
|
||||
let face = direction(p.face);
|
||||
let hit = Hit {
|
||||
pos: p.pos,
|
||||
@@ -1175,9 +1203,14 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
// handleUseItemOn: under spawn protection nothing is used or
|
||||
// placed; the player is told and the blocks below go back.
|
||||
// mayInteract: nothing is used on a block outside the world
|
||||
// border either.
|
||||
if self.under_spawn_protection(host, gi, p.pos) {
|
||||
self.spawn_protection_message(host, conn, p.pos);
|
||||
} else {
|
||||
} else if self
|
||||
.border
|
||||
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
|
||||
{
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
@@ -1384,6 +1417,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
p.difficulty as u8
|
||||
};
|
||||
// The level data holds the world's difficulty: it
|
||||
// survives the reopen, so it follows the change.
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
@@ -3041,10 +3077,10 @@ impl Game<'_> {
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// Entity.take: the clients play the pickup sound themselves
|
||||
// (handleTakeItemEntity).
|
||||
for (item, count) in taken {
|
||||
host.hub.take_item(item as i32, id, count as i32, position);
|
||||
host.hub
|
||||
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,9 @@ impl Game<'_> {
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
// A pending portal arrival keeps its destination level
|
||||
// running so it can land and tick (the portal ticket).
|
||||
|| !self.arrival_chunks(*d).is_empty()
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
|
||||
@@ -267,6 +267,7 @@ impl Game<'_> {
|
||||
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
|
||||
);
|
||||
}
|
||||
self.list_native_mods();
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
@@ -302,8 +303,14 @@ impl Game<'_> {
|
||||
.filter_map(|p| horizon_mods::Package::read(p).ok())
|
||||
.map(|p| p.manifest.id)
|
||||
.collect();
|
||||
// A native mod of the same id (one built into this game) takes over.
|
||||
let mut native = Vec::new();
|
||||
self.native_mods(&mut native);
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let on = |id: &str| {
|
||||
(enabled.contains(id) || dev_ids.iter().any(|d| d == id))
|
||||
&& !native.iter().any(|n| n.id == id)
|
||||
};
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
@@ -349,6 +356,7 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -365,9 +373,11 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
native: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list_native_mods();
|
||||
}
|
||||
/// The chat's tree after the running mods changed.
|
||||
fn sync_mod_commands(&mut self) {
|
||||
|
||||
@@ -45,14 +45,28 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
}
|
||||
/// `Minecraft.setLevel` -> `updateLevelInEngines`: `SoundManager.stop()`.
|
||||
/// The music manager keeps its stopped track and, on its next tick, waits
|
||||
/// the level's music delay before the next one.
|
||||
pub(super) fn stop_sounds_for_new_level(&mut self) {
|
||||
self.ambience.clear(&mut self.audio);
|
||||
self.audio.stop_all();
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
pub(super) fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
let empty = BackgroundMusic::default();
|
||||
// The title screen has no player (and no level for the camera probe).
|
||||
// A server's client has its player from handleLogin on, before the
|
||||
// first teleport makes it here: the camera is still at the origin.
|
||||
let eye = if self.title.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.eye_position()
|
||||
self.eye_position().or_else(|| {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.logged_in)
|
||||
.then_some([0.0; 3])
|
||||
})
|
||||
};
|
||||
let (background, probe_volume) = match eye {
|
||||
Some(eye) => {
|
||||
|
||||
@@ -19,7 +19,8 @@ use horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
hud::{HudBatch, rgba},
|
||||
keybinds, mod_list,
|
||||
keybinds,
|
||||
mod_list::{self, Installed, NativePage},
|
||||
text::Text,
|
||||
};
|
||||
use std::{fs, path::PathBuf, time::Duration};
|
||||
@@ -66,6 +67,19 @@ pub(crate) struct NativePlayer {
|
||||
pub(crate) dimension: &'static str,
|
||||
}
|
||||
|
||||
/// A native mod as Options > Mods lists it (`native_mods`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeMod {
|
||||
/// Its id: its settings folder and its keys' lines.
|
||||
pub(crate) id: String,
|
||||
/// `Name 1.0`.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
/// Its keys: name, label and default key (`key.keyboard.z`). The page
|
||||
/// lists them first; `native_mod_key` reads the player's choice.
|
||||
pub(crate) keys: Vec<[String; 3]>,
|
||||
}
|
||||
|
||||
/// The mappings `native_mapping` answers for, with the key their default
|
||||
/// binding is (none: a mouse button, attack or use).
|
||||
#[allow(dead_code)]
|
||||
@@ -122,12 +136,31 @@ impl Game<'_> {
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
let _ = minimap;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
}
|
||||
/// The buttons of mod `mod_id`'s settings page, under its keys: push
|
||||
/// their labels when it is yours. Asked again after each click.
|
||||
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
let _ = (mod_id, buttons);
|
||||
}
|
||||
/// Button `button` of mod `mod_id`'s settings page was clicked.
|
||||
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
|
||||
let _ = (mod_id, button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
|
||||
/// turned off does nothing (check this first in each hook) until the
|
||||
/// launcher's next build leaves it out.
|
||||
pub(super) fn native_on(&self, mod_id: &str) -> bool {
|
||||
!self.settings.mods.disabled.contains(mod_id)
|
||||
}
|
||||
/// Playing, with nothing over the world: keys and zoom apply.
|
||||
pub(super) fn native_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
@@ -241,6 +274,48 @@ impl Game<'_> {
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// Options > Mods' rows for the native mods, after the others.
|
||||
pub(super) fn list_native_mods(&mut self) {
|
||||
let mut mods = Vec::new();
|
||||
self.native_mods(&mut mods);
|
||||
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
|
||||
// native row stands for it, and alone toggles its `mod.<id>` line.
|
||||
self.settings
|
||||
.mods
|
||||
.installed
|
||||
.retain(|row| row.native.is_none() && !mods.iter().any(|m| m.id == row.id));
|
||||
for m in mods {
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&m.id, &mut buttons);
|
||||
let page = NativePage {
|
||||
keys: m.keys,
|
||||
buttons,
|
||||
};
|
||||
self.settings.mods.installed.push(Installed {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(page),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A click on button `button` of native mod `i`'s page (its index in
|
||||
/// Options > Mods), and the page's labels after it.
|
||||
pub(super) fn native_settings_event(&mut self, i: usize, button: usize) {
|
||||
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.native_settings_click(&id, button);
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&id, &mut buttons);
|
||||
let row = self.settings.mods.installed.get_mut(i);
|
||||
if let Some(page) = row.and_then(|m| m.native.as_mut()) {
|
||||
page.buttons = buttons;
|
||||
}
|
||||
}
|
||||
/// A key event for `native_key`.
|
||||
pub(super) fn native_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat {
|
||||
|
||||
@@ -973,6 +973,18 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
/// the area loaded without a player.
|
||||
pub(super) fn arrival_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
|
||||
self.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.filter(|a| a.plan.dim == dim)
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
|
||||
@@ -246,6 +246,7 @@ impl Game<'_> {
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
menu::Input,
|
||||
mobs::{Mob, MobType, Who},
|
||||
@@ -115,6 +115,9 @@ pub(super) struct Remote {
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
joined: bool,
|
||||
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
|
||||
/// while here the player is made at the first teleport.
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
@@ -303,6 +306,7 @@ impl Game<'_> {
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
super::world_loading::preload_server_loot(&self.options.resources);
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -453,6 +457,7 @@ impl Game<'_> {
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
joined: false,
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
@@ -1046,6 +1051,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
// A new ClientLevel: Minecraft.setLevel stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
@@ -1148,6 +1155,23 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// A decorated pot is no block entity here: its tag is kept with the chunk.
|
||||
if name == "minecraft:decorated_pot" {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !fields.is_empty()
|
||||
{
|
||||
let mut full = fields.clone();
|
||||
full.insert("id".into(), Tag::String(name.into()));
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
|
||||
// The section is drawn again with the pot's sherds.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(entity) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
@@ -1162,7 +1186,22 @@ impl Game<'_> {
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
|
||||
if let Some((_, mut loaded)) =
|
||||
horizon_client::save::decode_block_entity(&Tag::Compound(full))
|
||||
{
|
||||
// The turn of a vault's item and a trial spawner's mob is the client's
|
||||
// own, not in the tag: an update keeps it (VaultClientData).
|
||||
match (self.world.block_entities.get(&key), &mut loaded) {
|
||||
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1733,6 +1772,7 @@ impl Game<'_> {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.logged_in = true;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
@@ -1753,7 +1793,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
let (music, _) = self.situational_music();
|
||||
self.music.stop_playing(music.as_ref(), &mut self.audio);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = None;
|
||||
@@ -2234,21 +2279,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::TakeItemEntity(p) => {
|
||||
// An orb's sound comes from the server, at the orb.
|
||||
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
|
||||
// the item, whoever collects it (the server sends none). An
|
||||
// orb's sound comes from the server, at the orb; an arrow this
|
||||
// client does not track as an item sounds at this player.
|
||||
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
|
||||
if !orb
|
||||
&& self
|
||||
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
|
||||
let at = match item {
|
||||
Some(d) => Some([d.position.x, d.position.y, d.position.z]),
|
||||
None if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.entity_id == p.player)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
.is_some_and(|r| r.entity_id == p.player) =>
|
||||
{
|
||||
self.eye_position()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !orb && let Some(at) = at {
|
||||
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
|
||||
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
|
||||
}
|
||||
// ItemPickupParticle toward the collector (this player, another one or a mob).
|
||||
let collector = if self
|
||||
@@ -2349,6 +2403,17 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 3 on this player: its death
|
||||
// sound, which Player.playSound plays for this client alone
|
||||
// (the server's goes to everyone else).
|
||||
if p.event == 3
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
&& let Some(pl) = self.player.as_ref()
|
||||
{
|
||||
let at = [pl.position.x, pl.position.y, pl.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
@@ -4620,6 +4685,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
@@ -4627,9 +4693,26 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
|
||||
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
|
||||
/// player has infinite materials.
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
|
||||
let swings = self.swings_started;
|
||||
let done = self.remote_use_item_on(hand);
|
||||
if done
|
||||
&& self.swings_started != swings
|
||||
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
|
||||
&& (Some(after) != before || self.creative)
|
||||
{
|
||||
self.heights.item_used(hand);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
|
||||
/// (the block reacts, or a block item is placed).
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
|
||||
let held = self.with_hand(hand, |g| g.held().cloned());
|
||||
let held_block = held
|
||||
.as_ref()
|
||||
@@ -4801,6 +4884,9 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5003,7 +5089,9 @@ impl Game<'_> {
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
fn remote_use_hands(&mut self) {
|
||||
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
|
||||
if self.remote_use_item(Hand::Main) {
|
||||
self.heights.item_used(Hand::Main);
|
||||
return;
|
||||
}
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
@@ -5015,7 +5103,9 @@ impl Game<'_> {
|
||||
if off_target {
|
||||
return;
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
if self.remote_use_item(Hand::Off) {
|
||||
self.heights.item_used(Hand::Off);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
@@ -5914,26 +6004,42 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
let shown = horizon_client::net::shown_address(&r.session.address);
|
||||
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
batch.text(
|
||||
atlas,
|
||||
line,
|
||||
(w - tw) / 2.0,
|
||||
h / 2.0 - 12.0 + i as f32 * 12.0,
|
||||
if i == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
},
|
||||
true,
|
||||
);
|
||||
if r.joined {
|
||||
// LevelLoadingScreen: the world's terrain comes in.
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
horizon_client::level_loading::draw(batch, atlas, None, None);
|
||||
return true;
|
||||
}
|
||||
let _ = r.joined;
|
||||
let cancel = self.connect_cancel((w, h)).is_some();
|
||||
let status = horizon_client::text::Text::literal(r.status.as_str());
|
||||
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
|
||||
true
|
||||
}
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
self.remote.is_some() && self.player.is_none()
|
||||
}
|
||||
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
|
||||
/// joined through `ConnectScreen`), until the login is done.
|
||||
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
(self.player.is_none() && !r.joined && self.integrated.is_none())
|
||||
.then(|| horizon_client::level_loading::connect_cancel(w, h))
|
||||
}
|
||||
/// A click on `ConnectScreen`: Cancel aborts the connection
|
||||
/// (`connect.aborted`) and goes back. Escape does nothing there
|
||||
/// (`shouldCloseOnEsc` is false).
|
||||
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
|
||||
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.leave_server(None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The server's death message; chat uses the shared ChatComponent.
|
||||
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
|
||||
let Some(r) = &self.remote else { return };
|
||||
|
||||
@@ -93,4 +93,45 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
/// packet brings it.
|
||||
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
|
||||
return;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let ages = match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if ages {
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +393,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
Event::ModSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,27 @@ pub(super) fn world_data_used() {
|
||||
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
|
||||
}
|
||||
|
||||
/// A server's world opens: the loot tables its first placed block or broken
|
||||
/// container needs (`loot_data`) are read on a helper thread while the
|
||||
/// connection is made, so the game thread never waits on them (~0.6 s on a
|
||||
/// phone). Kept from then on, as a local world keeps them.
|
||||
pub(super) fn preload_server_loot(resources: &std::path::Path) {
|
||||
world_data_used();
|
||||
if horizon_client::structure_blocks::loaded_loot_data().is_some() {
|
||||
return;
|
||||
}
|
||||
let resources = resources.to_owned();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Loot preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: loot preload: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
|
||||
@@ -28,6 +28,14 @@ pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.byId`: an unknown id is white (`OutOfBoundsStrategy.ZERO`).
|
||||
fn dye_name(id: i32) -> &'static str {
|
||||
usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| crate::block_entity_render::DYES.get(i))
|
||||
.map_or("white", |d| d.0)
|
||||
}
|
||||
|
||||
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
|
||||
Arc::from(
|
||||
builtin
|
||||
@@ -61,6 +69,25 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
// BannerPatternLayers: each layer's pattern is a synced registry entry.
|
||||
Component::BannerPatterns(layers) => {
|
||||
let layers: Option<Vec<Tag>> = layers
|
||||
.iter()
|
||||
.map(|(pattern, color)| {
|
||||
let pattern = synced.name("minecraft:banner_pattern", *pattern)?;
|
||||
Some(Tag::compound([
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
("color", Tag::String(dye_name(*color).into())),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let Some(layers) = layers else {
|
||||
continue;
|
||||
};
|
||||
Tag::List(layers)
|
||||
}
|
||||
// A shield's `base_color`: the dye's id.
|
||||
Component::Int(v) if *kind == component::BASE_COLOR => Tag::String(dye_name(*v).into()),
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
|
||||
@@ -824,6 +824,7 @@ impl ChunkEncoder {
|
||||
tag: block_entities
|
||||
.get(&pos)
|
||||
.and_then(entity_tag)
|
||||
.or_else(|| pot_tag(chunk, pos))
|
||||
.unwrap_or(Tag::End),
|
||||
});
|
||||
}
|
||||
@@ -938,6 +939,28 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if !b.layers.is_empty() {
|
||||
entries.push(("patterns", crate::banner::layers_to_nbt(&b.layers)));
|
||||
}
|
||||
if let Some(name) = &b.name {
|
||||
entries.push(("CustomName", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("components", Tag::Compound(b.components.clone())));
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
// VaultBlockEntity.getUpdateTag: the shared data, which holds the display item.
|
||||
BlockEntity::Vault(v) => Some(v.update_tag()),
|
||||
// TrialSpawnerStateData.getUpdateTag: the spawn data the mob is drawn from.
|
||||
// next_mob_spawns_at (ACTIVE only) is left out: nothing in the client
|
||||
// reads it, and the block state it depends on is not known here.
|
||||
BlockEntity::TrialSpawner(t) => t
|
||||
.next_spawn_data
|
||||
.as_ref()
|
||||
.map(|d| Tag::compound([("spawn_data", d.encode())])),
|
||||
// SkullBlockEntity: the profile is the whole reason a head differs.
|
||||
BlockEntity::Skull(s) => {
|
||||
let mut entries = Vec::new();
|
||||
@@ -962,6 +985,22 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The update tag of the decorated pot at `pos` (DecoratedPotBlockEntity.getUpdateTag,
|
||||
/// saveCustomOnly). A pot is not a block entity here: its tag is the chunk's saved one.
|
||||
pub fn pot_tag(chunk: &Chunk, pos: (i32, i32, i32)) -> Option<Tag> {
|
||||
let at = chunk.saved.as_ref()?.block_entities.iter().find(|t| {
|
||||
t.get("id") == Some(&Tag::String("minecraft:decorated_pot".into()))
|
||||
&& t.get("x") == Some(&Tag::Int(pos.0))
|
||||
&& t.get("y") == Some(&Tag::Int(pos.1))
|
||||
&& t.get("z") == Some(&Tag::Int(pos.2))
|
||||
})?;
|
||||
let entries: Vec<_> = ["sherds", "LootTable", "LootTableSeed", "item"]
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, at.get(k)?.clone())))
|
||||
.collect();
|
||||
(!entries.is_empty()).then(|| Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// Light sections run from one below the world to one above it. A layer
|
||||
/// exists where vanilla's light storage keeps one: within one section of a
|
||||
/// non-air section of this column or a neighbouring one (`around`, bit i for
|
||||
|
||||
@@ -381,7 +381,7 @@ fn serve_legacy(mut stream: TcpStream, shared: &Shared) -> Result<(), String> {
|
||||
let body = if data.len() == 1 {
|
||||
format!("{motd}\u{a7}{online}\u{a7}{max}")
|
||||
} else {
|
||||
if data[1] != 1 {
|
||||
if data.get(1) != Some(&1) {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > 2 {
|
||||
|
||||
@@ -459,6 +459,22 @@ impl Guest {
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(eye, range + buffer, pos)
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
|
||||
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
|
||||
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
|
||||
/// guest's body in its pose (crouching, swimming, gliding).
|
||||
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
|
||||
let eye = self
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(
|
||||
[self.position[0], self.position[1] + eye, self.position[2]],
|
||||
range + 1.0,
|
||||
pos,
|
||||
)
|
||||
}
|
||||
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
|
||||
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
|
||||
/// 45 off hand.
|
||||
@@ -523,6 +539,14 @@ pub fn firework_components(
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.getId` of a dye's name.
|
||||
fn dye_id(name: &str) -> Option<i32> {
|
||||
crate::block_entity_render::DYES
|
||||
.iter()
|
||||
.position(|d| d.0 == name)
|
||||
.map(|i| i as i32)
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
@@ -595,6 +619,36 @@ fn network_stack(
|
||||
));
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers: each pattern a reference into the synced registry;
|
||||
// a layer that has none leaves the stack without the component.
|
||||
let layers: Option<Vec<(i32, i32)>> = d
|
||||
.banner_patterns
|
||||
.iter()
|
||||
.map(|layer| {
|
||||
let name = if layer.pattern.contains(':') {
|
||||
layer.pattern.to_string()
|
||||
} else {
|
||||
format!("minecraft:{}", layer.pattern)
|
||||
};
|
||||
Some((
|
||||
synced_id("minecraft:banner_pattern", &name)?,
|
||||
dye_id(&layer.color)?,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if let Some(layers) = layers.filter(|layers| !layers.is_empty()) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BANNER_PATTERNS,
|
||||
horizon_protocol::item::Component::BannerPatterns(layers),
|
||||
));
|
||||
}
|
||||
// A shield's `base_color`.
|
||||
if let Some(id) = d.base_color.as_deref().and_then(dye_id) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BASE_COLOR,
|
||||
horizon_protocol::item::Component::Int(id),
|
||||
));
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -771,4 +825,43 @@ mod tests {
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
|
||||
fn guest_at(position: [f64; 3]) -> Guest {
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 0,
|
||||
uuid: 0,
|
||||
name: "guest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: String::new(),
|
||||
sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:1".parse().unwrap(),
|
||||
},
|
||||
1,
|
||||
position,
|
||||
10,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_action_must_be_within_reach() {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
// Survival (4.5 + 1 from the eyes): five blocks off horizontally,
|
||||
// not one further.
|
||||
assert!(g.may_reach_block([5, 64, 0]));
|
||||
assert!(!g.may_reach_block([6, 64, 0]));
|
||||
// Creative's 5.0 reaches one block further.
|
||||
g.game_mode = CREATIVE;
|
||||
assert!(g.may_reach_block([6, 64, 0]));
|
||||
assert!(!g.may_reach_block([7, 64, 0]));
|
||||
// With no body yet the sneak flag gives the eye height: a block at
|
||||
// the edge above is gone once crouched.
|
||||
g.game_mode = SURVIVAL;
|
||||
assert!(g.may_reach_block([0, 71, 0]));
|
||||
g.sneaking = true;
|
||||
assert!(!g.may_reach_block([0, 71, 0]));
|
||||
}
|
||||
}
|
||||
@@ -1566,10 +1566,15 @@ impl Hub {
|
||||
}
|
||||
// Block entities with data the client shows.
|
||||
for &(x, y, z) in &positions {
|
||||
let Some(entity) = world.block_entities.get(&(x, y, z)) else {
|
||||
continue;
|
||||
// A decorated pot is no block entity: its tag is the chunk's saved one.
|
||||
let tag = match world.block_entities.get(&(x, y, z)) {
|
||||
Some(entity) => super::chunks::entity_tag(entity),
|
||||
None => world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.and_then(|c| super::chunks::pot_tag(c, (x, y, z))),
|
||||
};
|
||||
let Some(tag) = super::chunks::entity_tag(entity) else {
|
||||
let Some(tag) = tag else {
|
||||
continue;
|
||||
};
|
||||
let state = world.block(x, y, z).0;
|
||||
@@ -2216,13 +2221,16 @@ impl Hub {
|
||||
// game's, run after this pass. A move queued behind it is checked
|
||||
// against the block the player just broke and falls through, so it
|
||||
// would be sent back; it waits for the next tick, when the block is
|
||||
// gone, as vanilla handles the two packets in order.
|
||||
// gone, as vanilla handles the packets in order. So does everything
|
||||
// queued before that move: the arm swing (Punch) comes between the dig
|
||||
// and the move.
|
||||
S::PlayerAction(p) if p.action <= 3 => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
|
||||
if matches!(
|
||||
self.guests[i].inbox.front(),
|
||||
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
|
||||
) {
|
||||
if self.guests[i]
|
||||
.inbox
|
||||
.iter()
|
||||
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,35 @@ pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progres
|
||||
batch.fill(atlas, left, top, done, 2.0, opaque(0x00FF00));
|
||||
}
|
||||
|
||||
/// `ConnectScreen`'s Cancel button corner on a GUI `w` x `h`:
|
||||
/// (width / 2 - 100, height / 4 + 120 + 12), 200 x 20.
|
||||
pub fn connect_cancel(w: f32, h: f32) -> (f32, f32) {
|
||||
((w / 2.0).trunc() - 100.0, (h / 4.0).trunc() + 132.0)
|
||||
}
|
||||
|
||||
/// `ConnectScreen.extractRenderState` over the menu background: the status
|
||||
/// centred at (width / 2, height / 2 - 50) and, while the connection can
|
||||
/// still be cancelled, Cancel.
|
||||
pub fn draw_connecting(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
status: &Text,
|
||||
cursor: (f32, f32),
|
||||
cancel: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let tw = atlas.font.width_text(status);
|
||||
let (x, y) = (((w - tw) / 2.0).trunc(), (h / 2.0).trunc() - 50.0);
|
||||
batch.text_styled(atlas, status, x, y, u32::MAX, true);
|
||||
if cancel {
|
||||
let (x, y) = connect_cancel(w, h);
|
||||
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
|
||||
let label = Text::translate("gui.cancel", &[]);
|
||||
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1456,8 +1456,12 @@ fn hand_base(
|
||||
| Species::WitherSkeleton
|
||||
);
|
||||
let arm = if left { "left_arm" } else { "right_arm" };
|
||||
// `SkeletonModel.translateToHand` shifts the arm by 1, `PlayerModel.translateToHand`
|
||||
// by 0.5 for slim arms: the hand is the middle of the arm.
|
||||
let offset = if skeleton {
|
||||
if left { -1.0 } else { 1.0 }
|
||||
} else if avatar(m) && m.slim {
|
||||
if left { -0.5 } else { 0.5 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Which of the installed Horizon mods are on (`docs/mods.md`), kept in
|
||||
//! `horizon-mods.txt` beside `options.txt` as `mod.<id>:true` lines. Every
|
||||
//! mod is off until the player turns it on in Options > Mods.
|
||||
//! mod is off until the player turns it on in Options > Mods. A native mod is
|
||||
//! built in, so on until turned off (`mod.<id>:false`); the launcher builds
|
||||
//! only the native mods whose line is `true`.
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
@@ -15,6 +17,9 @@ pub const DIR: &str = "horizon-mods";
|
||||
pub struct ModList {
|
||||
/// Ids of the mods the player turned on.
|
||||
pub enabled: BTreeSet<String>,
|
||||
/// Ids of the native mods the player turned off: they rest
|
||||
/// (`native_on`) until the launcher's next build leaves them out.
|
||||
pub disabled: BTreeSet<String>,
|
||||
/// The keys the player chose for mods' keys, mod id → key name → key
|
||||
/// (`key.<id>.<name>:key.keyboard.x` lines).
|
||||
pub keys: BTreeMap<String, BTreeMap<String, String>>,
|
||||
@@ -36,6 +41,18 @@ pub struct Installed {
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
}
|
||||
|
||||
/// A native mod's settings page: its keys as Key Binds rows, then its
|
||||
/// buttons. The keys the player chooses are kept in `ModList::keys`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NativePage {
|
||||
/// Each key's name, label and default key (`key.keyboard.z`).
|
||||
pub keys: Vec<[String; 3]>,
|
||||
pub buttons: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModList {
|
||||
@@ -45,13 +62,15 @@ impl ModList {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let enabled = text
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.filter(|(_, v)| v.trim() == "true")
|
||||
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let ids = |value: &str| -> BTreeSet<String> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.filter(|(_, v)| v.trim() == value)
|
||||
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
let (enabled, disabled) = (ids("true"), ids("false"));
|
||||
let mut keys: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||||
for (k, v) in text.lines().filter_map(|l| l.split_once(':')) {
|
||||
// Mod ids have no dots; a key's name may.
|
||||
@@ -66,17 +85,19 @@ impl ModList {
|
||||
}
|
||||
Self {
|
||||
enabled,
|
||||
disabled,
|
||||
keys,
|
||||
installed: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let on = self.enabled.iter().map(|id| format!("mod.{id}:true\n"));
|
||||
let off = self.disabled.iter().map(|id| format!("mod.{id}:false\n"));
|
||||
let keys = self.keys.iter().flat_map(|(id, keys)| {
|
||||
keys.iter()
|
||||
.map(move |(name, key)| format!("key.{id}.{name}:{key}\n"))
|
||||
});
|
||||
on.chain(keys).collect()
|
||||
on.chain(off).chain(keys).collect()
|
||||
}
|
||||
/// Mod `id`'s chosen keys, for `Mods::set_bindings`.
|
||||
pub fn keys_of(&self, id: &str) -> Vec<(&str, &str)> {
|
||||
@@ -86,22 +107,40 @@ impl ModList {
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE);
|
||||
if self.enabled.is_empty() && self.keys.is_empty() && !path.exists() {
|
||||
if self.enabled.is_empty()
|
||||
&& self.disabled.is_empty()
|
||||
&& self.keys.is_empty()
|
||||
&& !path.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(path, self.serialize());
|
||||
}
|
||||
pub fn is_on(&self, i: usize) -> bool {
|
||||
self.installed
|
||||
.get(i)
|
||||
.is_some_and(|m| m.dev || self.enabled.contains(&m.id))
|
||||
self.installed.get(i).is_some_and(|m| {
|
||||
m.dev
|
||||
|| if m.native.is_some() {
|
||||
!self.disabled.contains(&m.id)
|
||||
} else {
|
||||
self.enabled.contains(&m.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Turns installed mod `i` on or off (a dev mod stays on).
|
||||
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
|
||||
/// line says `false` when off, so that the launcher's next build leaves
|
||||
/// it out, and `true` when on again.
|
||||
pub fn toggle(&mut self, i: usize) {
|
||||
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
|
||||
return;
|
||||
};
|
||||
if !self.enabled.remove(&m.id) {
|
||||
if m.native.is_some() {
|
||||
if self.disabled.remove(&m.id) {
|
||||
self.enabled.insert(m.id.clone());
|
||||
} else {
|
||||
self.enabled.remove(&m.id);
|
||||
self.disabled.insert(m.id.clone());
|
||||
}
|
||||
} else if !self.enabled.remove(&m.id) {
|
||||
self.enabled.insert(m.id.clone());
|
||||
}
|
||||
}
|
||||
@@ -132,7 +171,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_installed_mods_but_not_dev_ones() {
|
||||
fn toggles_installed_and_native_mods_but_not_dev_ones() {
|
||||
let installed = |id: &str, dev| Installed {
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
@@ -140,6 +179,7 @@ mod tests {
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
installed: vec![installed("a", false), installed("b", true)],
|
||||
@@ -152,5 +192,18 @@ mod tests {
|
||||
assert_eq!(list.serialize(), "mod.a:true\n");
|
||||
list.toggle(0);
|
||||
assert!(!list.is_on(0));
|
||||
list.installed.push(Installed {
|
||||
native: Some(NativePage::default()),
|
||||
..installed("c", false)
|
||||
});
|
||||
// On until turned off; off, the launcher leaves it out.
|
||||
assert!(list.is_on(2));
|
||||
list.toggle(2);
|
||||
assert!(!list.is_on(2));
|
||||
assert_eq!(list.serialize(), "mod.c:false\n");
|
||||
assert_eq!(ModList::parse(&list.serialize()).disabled, list.disabled);
|
||||
list.toggle(2);
|
||||
assert!(list.is_on(2));
|
||||
assert_eq!(list.serialize(), "mod.c:true\n");
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,34 @@ mod tests {
|
||||
assert_eq!(m.current(&engine), Some("minecraft:music.menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_level_stops_menu_music_and_waits_for_game_music() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut m = MusicManager::new(2);
|
||||
let menu = Music::menu();
|
||||
m.start_playing(
|
||||
&menu,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
// Minecraft.setLevel -> SoundManager.stop(); the next tick has a player.
|
||||
engine.stop_all(&mut backend);
|
||||
let game = Music::game();
|
||||
m.tick(
|
||||
Some(&game),
|
||||
1.0,
|
||||
true,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
assert!(m.current_instance().is_none());
|
||||
assert!((12000..=24000).contains(&m.next_song_delay()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_music_volume_fades_out_and_stops() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
|
||||
@@ -262,13 +262,15 @@ impl Session {
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// `Connection.disconnect`: the socket closes and the game goes on at once.
|
||||
/// The reader thread is left to end on its own: on Windows a shutdown
|
||||
/// doesn't wake a read already waiting, so joining it held the window until
|
||||
/// the server dropped the connection, many seconds later.
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(w) = self.writer.lock().unwrap().take() {
|
||||
w.shutdown();
|
||||
}
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
drop(self.thread.take());
|
||||
}
|
||||
}
|
||||
impl Drop for Session {
|
||||
|
||||
@@ -820,6 +820,9 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `selected_key` while a native mod's key waits (`selected_native_key`).
|
||||
const NATIVE_KEY: &str = "horizon.nativeModKey";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Main,
|
||||
@@ -834,6 +837,8 @@ pub enum Page {
|
||||
Font,
|
||||
Packs,
|
||||
Mods,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
@@ -872,6 +877,9 @@ enum Act {
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
@@ -931,6 +939,9 @@ pub enum Event {
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
|
||||
/// mod and refreshes the page.
|
||||
NativeButton(usize, usize),
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
@@ -963,8 +974,11 @@ pub struct OptionsUi {
|
||||
slider_value: Option<(Opt, f32, Instant)>,
|
||||
/// The pointer's last y while a scrollbar is held.
|
||||
pub(crate) scrollbar_drag_y: Option<f32>,
|
||||
/// Key Binds: the mapping waiting for a key.
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Controller Binds: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
@@ -1002,6 +1016,7 @@ impl Default for OptionsUi {
|
||||
slider_value: None,
|
||||
scrollbar_drag_y: None,
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
languages: Vec::new(),
|
||||
@@ -1167,6 +1182,7 @@ impl OptionsUi {
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
@@ -1225,6 +1241,43 @@ impl OptionsUi {
|
||||
filtered
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::Extras => {
|
||||
@@ -1506,7 +1559,7 @@ impl OptionsUi {
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings {
|
||||
if installed.has_settings || installed.native.is_some() {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
@@ -1526,6 +1579,7 @@ impl OptionsUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
@@ -2556,8 +2610,17 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::ModSettings(i));
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::PickShaderPack(i) => {
|
||||
if let Some(pack) = s.shader_packs.get(i) {
|
||||
@@ -2755,6 +2818,18 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
@@ -3100,6 +3175,7 @@ mod tests {
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
|
||||
@@ -2673,6 +2673,21 @@ pub struct Level {
|
||||
pub player_extra: Option<Tag>,
|
||||
}
|
||||
impl Level {
|
||||
/// `PrimaryLevelData.setDifficulty`: the level data holds the world's
|
||||
/// difficulty, so the game and the file follow each other and a world
|
||||
/// that was set to hard is hard when it is opened again.
|
||||
pub fn set_difficulty(&mut self, difficulty: u8) {
|
||||
self.difficulty = crate::title::DIFFICULTIES[difficulty as usize % 4].to_owned();
|
||||
}
|
||||
|
||||
/// `PrimaryLevelData.getDifficulty`: the id of the level data's
|
||||
/// difficulty (peaceful 0 .. hard 3), normal when it is not one of them.
|
||||
pub fn difficulty_id(&self) -> u8 {
|
||||
crate::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.difficulty)
|
||||
.map_or(2, |i| i as u8)
|
||||
}
|
||||
/// Initialize the offline single-player identity before saving or dropping items.
|
||||
pub fn ensure_player_uuid(&mut self) -> [i32; 4] {
|
||||
*self.player_uuid.get_or_insert_with(|| {
|
||||
@@ -4916,6 +4931,36 @@ mod tests {
|
||||
section(Tag::Byte(0), None, Some(Tag::ByteArray(varied))),
|
||||
]));
|
||||
}
|
||||
/// #2571: the difficulty is a property of the world: the level data
|
||||
/// holds it, so a world that was set to hard is hard when it is opened
|
||||
/// again, and a world that was downloaded keeps the difficulty it was
|
||||
/// made with.
|
||||
#[test]
|
||||
fn the_level_data_holds_the_difficulty() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-difficulty-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut level = Level {
|
||||
name: "Test".into(),
|
||||
..Level::default()
|
||||
};
|
||||
// The world's difficulty is the level data's.
|
||||
level.set_difficulty(3);
|
||||
assert_eq!(level.difficulty, "hard");
|
||||
assert_eq!(level.difficulty_id(), 3);
|
||||
// It survives the save, so the world comes back with it.
|
||||
level.write(&dir).unwrap();
|
||||
let back = Level::read(&dir).unwrap();
|
||||
assert_eq!(back.difficulty, "hard");
|
||||
assert_eq!(back.difficulty_id(), 3);
|
||||
// A world whose difficulty is not one of ours is normal.
|
||||
let other = Level {
|
||||
difficulty: "extreme".into(),
|
||||
..Level::default()
|
||||
};
|
||||
assert_eq!(other.difficulty_id(), 2);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_round_trips_through_the_vanilla_files() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-level-{}", std::process::id()));
|
||||
|
||||
@@ -407,6 +407,54 @@ fn sprint_commands_survive_keyboard_input_changes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_behind_a_dig_and_its_swing_waits_for_the_next_tick() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Digger", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
s.hub.guests[0].loaded = true;
|
||||
let at = s.hub.guests[0].position;
|
||||
let below = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32 - 1,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
// A creative break as the client sends it in one tick: the dig, the arm swing,
|
||||
// then the move into the block it predicted gone.
|
||||
let inbox = &mut s.hub.guests[0].inbox;
|
||||
inbox.push_back(S::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: below,
|
||||
direction: 1,
|
||||
sequence: 1,
|
||||
}));
|
||||
inbox.push_back(S::Punch(Empty));
|
||||
inbox.push_back(S::MovePlayerPos(play::MovePlayerPos {
|
||||
x: at[0],
|
||||
y: at[1] - 0.0784,
|
||||
z: at[2],
|
||||
flags: 0,
|
||||
}));
|
||||
s.tick(&[]);
|
||||
// The game breaks the block after the hub's pass: the move waits, or it would be
|
||||
// checked against the block and sent back.
|
||||
let g = &s.hub.guests[0];
|
||||
assert_eq!(g.position, at, "the move was handled before the dig");
|
||||
assert!(
|
||||
matches!(g.inbox.back(), Some(S::MovePlayerPos(_))),
|
||||
"the move left the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_join() {
|
||||
let Some(resources) = resources() else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! serialized LevelChunkSections with their PalettedContainers, block
|
||||
//! entities) and ClientboundLightUpdatePacketData (sky/block DataLayers).
|
||||
use crate::{
|
||||
Result,
|
||||
Error, Result,
|
||||
buf::{BitSet, Bytes, Codec, Longs, Reader, VarInt, Writer},
|
||||
packet,
|
||||
};
|
||||
@@ -83,6 +83,11 @@ impl Paletted {
|
||||
pub fn read(r: &mut Reader, strategy: Strategy, global_bits: u8) -> Result<Self> {
|
||||
let wire = r.u8()?;
|
||||
let (has_palette, bits) = strategy.configuration(wire, global_bits);
|
||||
// SimpleBitStorage holds 1 to 32 bits; wider wire values would
|
||||
// divide by zero or overflow a shift.
|
||||
if bits > 32 {
|
||||
return Err(Error::new(format!("{bits} bits per entry, more than 32")));
|
||||
}
|
||||
let palette = if !has_palette {
|
||||
Vec::new()
|
||||
} else if bits == 0 {
|
||||
@@ -425,6 +430,23 @@ mod tests {
|
||||
assert_eq!(g.get(4095), 4095 * 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_bits_wider_than_storage_are_rejected() {
|
||||
for wire in [33u8, 200] {
|
||||
let data = [wire];
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(Paletted::read(&mut r, Strategy::Blocks, 16).is_err());
|
||||
}
|
||||
// 15- and 16-bit global palettes still read (1024 longs of zeroes).
|
||||
for (wire, global) in [(15u8, 15u8), (16, 16)] {
|
||||
let mut data = vec![wire];
|
||||
data.resize(1 + 1024 * 8, 0);
|
||||
let mut r = Reader::new(&data);
|
||||
let p = Paletted::read(&mut r, Strategy::Blocks, global).unwrap();
|
||||
assert_eq!(p.bits, wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_data_round_trips() {
|
||||
let mut states = vec![0; BLOCKS];
|
||||
|
||||
@@ -187,6 +187,9 @@ fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
|
||||
addrs.sort_by_key(SocketAddr::is_ipv6);
|
||||
addrs
|
||||
}
|
||||
/// RFC 8305's connection attempt delay: how long an address has alone before the next
|
||||
/// one is tried beside it.
|
||||
const ATTEMPT_DELAY: Duration = Duration::from_millis(250);
|
||||
/// Both halves before the split.
|
||||
pub struct Connection {
|
||||
pub reader: ConnReader,
|
||||
@@ -206,19 +209,54 @@ impl Connection {
|
||||
let addrs = crate::resolver::resolve(host, port)?;
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
/// Each address starts once the earlier ones fail or [`ATTEMPT_DELAY`] after the last
|
||||
/// start, alongside those still trying, and the first to connect wins: a dead address
|
||||
/// in a round-robin set costs 250 ms instead of the whole timeout.
|
||||
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
|
||||
// Report the first address's failure: it is the one vanilla tries, and the IPv6
|
||||
// fallbacks after it often fail only because the client has no IPv6 route.
|
||||
let mut first = None;
|
||||
for addr in ipv4_first(addrs) {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => return Self::from_stream(stream),
|
||||
Err(e) => {
|
||||
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
|
||||
let addrs = ipv4_first(addrs);
|
||||
let (send, recv) = std::sync::mpsc::channel();
|
||||
let mut errors: Vec<Option<std::io::Error>> = addrs.iter().map(|_| None).collect();
|
||||
let (mut started, mut pending) = (0, 0);
|
||||
loop {
|
||||
if let Some(&addr) = addrs.get(started) {
|
||||
let (send, index) = (send.clone(), started);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("horizon-connect".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send((index, TcpStream::connect_timeout(&addr, timeout)));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => pending += 1,
|
||||
Err(e) => errors[index] = Some(e),
|
||||
}
|
||||
started += 1;
|
||||
}
|
||||
if pending == 0 {
|
||||
if started < addrs.len() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let reply = if started < addrs.len() {
|
||||
match recv.recv_timeout(ATTEMPT_DELAY) {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
recv.recv().expect("a sender is held here")
|
||||
};
|
||||
pending -= 1;
|
||||
match reply {
|
||||
(_, Ok(stream)) => return Self::from_stream(stream),
|
||||
(index, Err(e)) => errors[index] = Some(e),
|
||||
}
|
||||
}
|
||||
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
|
||||
// Report the first address's failure: it is the one vanilla tries, and the IPv6
|
||||
// fallbacks after it often fail only because the client has no IPv6 route.
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(e) => Err(Error::new(format!("Connection refused: {e}"))),
|
||||
None => Err(Error::new(format!("Unknown host {host}"))),
|
||||
}
|
||||
}
|
||||
pub fn from_stream(stream: TcpStream) -> Result<Self> {
|
||||
stream.set_nodelay(true)?;
|
||||
@@ -281,6 +319,13 @@ impl ConnReader {
|
||||
loop {
|
||||
if let Some(frame) = frame::split(&mut self.buffer)? {
|
||||
let body = frame::decompress(frame, self.threshold)?;
|
||||
if body.is_empty() {
|
||||
// A frame with no packet in it. Vanilla's PacketDecoder
|
||||
// only decodes a packet from a readable buffer, so an
|
||||
// empty compressed frame is skipped and the connection
|
||||
// stays up.
|
||||
continue;
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let id = r.varint()?;
|
||||
let start = r.position();
|
||||
@@ -400,6 +445,42 @@ mod tests {
|
||||
assert_eq!(peer(&c), v6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_address_does_not_hold_up_the_next() {
|
||||
// TEST-NET-1 never answers (or is unreachable at once).
|
||||
let dead: SocketAddr = "192.0.2.1:25565".parse().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let live = listener.local_addr().unwrap();
|
||||
let start = Instant::now();
|
||||
let c = Connection::connect_addresses("test", vec![dead, live], Duration::from_secs(10))
|
||||
.unwrap();
|
||||
assert_eq!(peer(&c), live);
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"{:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_failure_reports_the_first_address() {
|
||||
let closed = |_| {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
};
|
||||
let error = |host, addrs| {
|
||||
Connection::connect_addresses(host, addrs, Duration::from_secs(5))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let refused = error("test", (0..3).map(closed).collect());
|
||||
assert!(refused.starts_with("Connection refused"), "{refused}");
|
||||
assert_eq!(error("nowhere", Vec::new()), "Unknown host nowhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_with_compression_and_encryption() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
@@ -496,4 +577,30 @@ mod tests {
|
||||
assert_eq!(c.recv().unwrap(), (5, vec![4, 3]));
|
||||
client.join().unwrap();
|
||||
}
|
||||
|
||||
/// A server may send a compressed frame that carries no packet: the
|
||||
/// uncompressed length is 0 and the frame holds nothing else. Vanilla
|
||||
/// skips it (its PacketDecoder only reads a packet from a readable
|
||||
/// buffer), so the client stays connected.
|
||||
#[test]
|
||||
fn an_empty_compressed_frame_is_skipped() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut c = Connection::from_stream(stream).unwrap();
|
||||
c.set_compression(64);
|
||||
// The empty frame: its length prefix, then the uncompressed
|
||||
// length 0 and nothing else.
|
||||
c.writer.send_contents(&[0]).unwrap();
|
||||
let mut body = vec![3];
|
||||
body.extend_from_slice(b"after");
|
||||
c.writer.send(&body).unwrap();
|
||||
});
|
||||
let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
|
||||
c.set_compression(64);
|
||||
let (id, payload) = c.recv().unwrap();
|
||||
assert_eq!((id, payload.as_slice()), (3, &b"after"[..]));
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! zlib compression above a threshold (CompressionEncoder / CompressionDecoder).
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Reader, varint_size, write_varint},
|
||||
buf::{Reader, write_varint},
|
||||
};
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use std::io::{Read, Write};
|
||||
@@ -65,6 +65,11 @@ pub fn split(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
|
||||
};
|
||||
len |= usize::from(b & 0x7F) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
if len == 0 {
|
||||
// Varint21FrameDecoder: a frame with no bytes is a
|
||||
// corrupted frame, not an empty packet.
|
||||
return Err(Error::new("Frame length cannot be zero"));
|
||||
}
|
||||
let start = i + 1;
|
||||
if buffer.len() < start + len {
|
||||
return Ok(None);
|
||||
@@ -85,7 +90,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(&frame);
|
||||
let size = r.varint()?;
|
||||
if size == 0 {
|
||||
return Ok(frame[1..].to_vec());
|
||||
return Ok(frame[r.position()..].to_vec());
|
||||
}
|
||||
let size = usize::try_from(size).map_err(|_| Error::new("negative uncompressed length"))?;
|
||||
if size < threshold {
|
||||
@@ -98,7 +103,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
|
||||
"Badly compressed packet - size of {size} is larger than protocol maximum"
|
||||
)));
|
||||
}
|
||||
let start = varint_size(size as i32);
|
||||
let start = r.position();
|
||||
let mut out = Vec::with_capacity(size);
|
||||
ZlibDecoder::new(&frame[start..])
|
||||
.take(size as u64 + 1)
|
||||
@@ -163,4 +168,20 @@ mod tests {
|
||||
frame.extend(z.finish().unwrap());
|
||||
assert!(decompress(frame, Some(256)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_noncanonical_length_prefix_still_reads() {
|
||||
// The data-length VarInt is read, not measured: zero as `0x80 0x00`.
|
||||
assert_eq!(
|
||||
decompress(vec![0x80, 0x00, 7, 1, 2], Some(256)).unwrap(),
|
||||
[7, 1, 2]
|
||||
);
|
||||
// And a compressed packet whose size was written wide: 300.
|
||||
let body = vec![9; 300];
|
||||
let mut frame = vec![0xAC, 0x82, 0x00];
|
||||
let mut z = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
z.write_all(&body).unwrap();
|
||||
frame.extend(z.finish().unwrap());
|
||||
assert_eq!(decompress(frame, Some(256)).unwrap(), body);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ pub mod component {
|
||||
pub const ATTACK_RANGE: i32 = 30;
|
||||
pub const STORED_ENCHANTMENTS: i32 = 45;
|
||||
pub const PROFILE: i32 = 72;
|
||||
pub const BANNER_PATTERNS: i32 = 74;
|
||||
pub const BASE_COLOR: i32 = 75;
|
||||
pub const DYED_COLOR: i32 = 47;
|
||||
pub const MAP_ID: i32 = 48;
|
||||
pub const MAP_POST_PROCESSING: i32 = 50;
|
||||
@@ -112,6 +114,10 @@ pub enum Component {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
|
||||
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
|
||||
/// registry name: that value stays [`Component::Raw`].
|
||||
BannerPatterns(Vec<(i32, i32)>),
|
||||
/// A component Horizon reads past without interpreting it: its encoded
|
||||
/// bytes, written back as they came.
|
||||
Raw(Vec<u8>),
|
||||
@@ -349,6 +355,19 @@ impl Component {
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// DyeColor.STREAM_CODEC: its id.
|
||||
BASE_COLOR => Self::Int(r.varint()?),
|
||||
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
|
||||
// reference's id plus one, or 0 and the pattern itself) and a dye.
|
||||
BANNER_PATTERNS => {
|
||||
let start = r.position();
|
||||
skip::component(BANNER_PATTERNS, r)?;
|
||||
let bytes = r.since(start);
|
||||
match Self::banner_layers(bytes) {
|
||||
Some(layers) => Self::BannerPatterns(layers),
|
||||
None => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let start = r.position();
|
||||
skip::component(other, r)?;
|
||||
@@ -356,9 +375,32 @@ impl Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// The layers of encoded `BannerPatternLayers` whose patterns are all
|
||||
/// registry references (`None` when one is direct).
|
||||
fn banner_layers(bytes: &[u8]) -> Option<Vec<(i32, i32)>> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let n = r.count(r.remaining()).ok()?;
|
||||
let mut layers = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let pattern = r.varint().ok()?;
|
||||
let color = r.varint().ok()?;
|
||||
if pattern < 1 {
|
||||
return None;
|
||||
}
|
||||
layers.push((pattern - 1, color));
|
||||
}
|
||||
(r.remaining() == 0).then_some(layers)
|
||||
}
|
||||
pub(crate) fn write(&self, kind: i32, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Raw(bytes) => w.bytes(bytes),
|
||||
Self::BannerPatterns(layers) => {
|
||||
w.varint(layers.len() as i32);
|
||||
for (pattern, color) in layers {
|
||||
w.varint(pattern + 1);
|
||||
w.varint(*color);
|
||||
}
|
||||
}
|
||||
Self::Trim { material, pattern } => {
|
||||
w.varint(material + 1);
|
||||
w.varint(pattern + 1);
|
||||
|
||||
@@ -13,7 +13,7 @@ const TYPE_SRV: u16 = 33;
|
||||
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
|
||||
/// Windows.
|
||||
/// Windows, where it leaves out the scoped IPv6 ones.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
std::fs::read_to_string("/etc/resolv.conf")
|
||||
@@ -27,7 +27,7 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
|
||||
continue;
|
||||
}
|
||||
for &ip in adapter.dns_servers() {
|
||||
for &ip in adapter.dns_servers().iter().filter(|ip| !scoped(**ip)) {
|
||||
let server = SocketAddr::new(ip, 53);
|
||||
if !servers.contains(&server) {
|
||||
servers.push(server);
|
||||
@@ -42,6 +42,13 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Link-local and site-local IPv6 addresses (such as Windows' `fec0:0:0:ffff::1`
|
||||
/// defaults), which only work with a scope the adapter's list doesn't carry.
|
||||
#[cfg(any(windows, test))]
|
||||
fn scoped(ip: std::net::IpAddr) -> bool {
|
||||
matches!(ip, std::net::IpAddr::V6(v6) if matches!(v6.segments()[0] & 0xffc0, 0xfe80 | 0xfec0))
|
||||
}
|
||||
|
||||
/// `nameserver` lines of resolv.conf, in order.
|
||||
#[cfg(any(unix, test))]
|
||||
fn name_servers(conf: &str) -> Vec<SocketAddr> {
|
||||
@@ -303,6 +310,17 @@ mod tests {
|
||||
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_skips_scoped_ipv6_name_servers() {
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
for s in ["fe80::1", "fec0:0:0:ffff::1", "febf::1"] {
|
||||
assert!(scoped(ip(s)), "{s}");
|
||||
}
|
||||
for s in ["1.1.1.1", "2606:4700:4700::1111", "::1", "fc00::1"] {
|
||||
assert!(!scoped(ip(s)), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A local name server: a silent one first, then one that answers truncated
|
||||
/// over UDP and in full over TCP, like a real server with a big answer.
|
||||
#[test]
|
||||
|
||||
@@ -1264,6 +1264,29 @@ fn download_for_owner(
|
||||
download_for(pins, dir, pass, !accounts.accounts.is_empty(), fetch, log)
|
||||
}
|
||||
|
||||
/// Downloads Minecraft `id` (any version on Mojang's list, its version file's sha1 from it) into
|
||||
/// Horizon's own game folder, in the official launcher's layout, for an account that owns the
|
||||
/// game (asked again now). Returns that folder.
|
||||
pub fn download_version_for_owner(
|
||||
id: &str,
|
||||
manifest_sha1: &str,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<PathBuf, String> {
|
||||
let dir = data_dir().join("minecraft");
|
||||
let fetch = |url: &str| -> Result<Vec<u8>, String> {
|
||||
let mut body = Vec::new();
|
||||
get(url)?
|
||||
.into_reader()
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(body)
|
||||
};
|
||||
let pins = sources::Pins::for_version(id, manifest_sha1);
|
||||
download_for_owner(&pins, &dir, &fetch, log)
|
||||
.map_err(|e| e.trim_start_matches("\n • ").to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
|
||||
//! official launcher reads it: the main class, the classpath of this system's libraries and the
|
||||
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
|
||||
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
|
||||
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
|
||||
//!
|
||||
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
|
||||
//! one game folder. Nothing here reaches the network.
|
||||
|
||||
use crate::mojang;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub uuid: String,
|
||||
pub token: String,
|
||||
pub xuid: String,
|
||||
}
|
||||
|
||||
/// Everything the command needs besides the version JSON.
|
||||
pub struct Launch<'a> {
|
||||
/// Holds `versions/`, `libraries/`, `assets/`.
|
||||
pub minecraft: &'a Path,
|
||||
/// The instance's game folder (saves, options.txt, mods).
|
||||
pub game_dir: &'a Path,
|
||||
/// Where an old version's natives are unpacked.
|
||||
pub natives: &'a Path,
|
||||
/// The `java` program.
|
||||
pub java: &'a Path,
|
||||
pub memory_mb: u32,
|
||||
/// The player's own JVM arguments.
|
||||
pub jvm_args: &'a [String],
|
||||
pub player: &'a Player,
|
||||
/// `osx`, `linux` or `windows`, as version files name systems.
|
||||
pub os: &'a str,
|
||||
}
|
||||
|
||||
/// This system's name in version files.
|
||||
pub fn os_name() -> &'static str {
|
||||
match crate::sources::Os::current() {
|
||||
crate::sources::Os::Linux => "linux",
|
||||
crate::sources::Os::MacOs => "osx",
|
||||
crate::sources::Os::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
|
||||
/// Every optional feature (demo, custom resolution, quick play) is off.
|
||||
fn allowed(rules: &Value, os: &str) -> bool {
|
||||
let Some(rules) = rules.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let arch_matches = |arch: &str| match arch {
|
||||
"x86" => cfg!(target_arch = "x86"),
|
||||
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
|
||||
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
|
||||
_ => false,
|
||||
};
|
||||
let mut allow = false;
|
||||
for rule in rules {
|
||||
let system = &rule["os"];
|
||||
let matches = system["name"].as_str().is_none_or(|n| n == os)
|
||||
&& system["arch"].as_str().is_none_or(arch_matches)
|
||||
&& rule["features"]
|
||||
.as_object()
|
||||
.is_none_or(|f| f.values().all(|v| v == false));
|
||||
if matches {
|
||||
allow = rule["action"] == "allow";
|
||||
}
|
||||
}
|
||||
allow
|
||||
}
|
||||
|
||||
/// The libraries this system uses, then the client 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 mut paths: Vec<PathBuf> = Vec::new();
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
|
||||
let path = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.push(
|
||||
minecraft
|
||||
.join("versions")
|
||||
.join(mojang::relative(id)?)
|
||||
.join(format!("{id}.jar")),
|
||||
);
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
|
||||
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
|
||||
pub fn unpack_natives(
|
||||
version: &Value,
|
||||
minecraft: &Path,
|
||||
os: &str,
|
||||
to: &Path,
|
||||
) -> Result<(), String> {
|
||||
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = mojang::native_classifier(library, os) else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
let exclude: Vec<&str> = library["extract"]["exclude"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(name) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let text = name.to_string_lossy().replace('\\', "/");
|
||||
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
|
||||
continue;
|
||||
}
|
||||
let out = to.join(&name);
|
||||
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
|
||||
/// the objects copied there once; the asset store itself for every other version.
|
||||
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
|
||||
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
|
||||
let assets = minecraft.join("assets");
|
||||
let index = assets
|
||||
.join("indexes")
|
||||
.join(mojang::relative(&format!("{id}.json"))?);
|
||||
let Ok(text) = fs::read_to_string(&index) else {
|
||||
return Ok(assets);
|
||||
};
|
||||
let index: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
|
||||
let to = if index["map_to_resources"] == true {
|
||||
game_dir.join("resources")
|
||||
} else if index["virtual"] == true {
|
||||
assets.join("virtual").join(mojang::relative(id)?)
|
||||
} else {
|
||||
return Ok(assets);
|
||||
};
|
||||
for (name, object) in index["objects"].as_object().into_iter().flatten() {
|
||||
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
|
||||
continue;
|
||||
};
|
||||
let out = to.join(mojang::relative(name)?);
|
||||
if !out.is_file() {
|
||||
let from = assets.join("objects").join(&hash[..2]).join(hash);
|
||||
fs::create_dir_all(out.parent().unwrap())
|
||||
.and_then(|_| fs::copy(&from, &out))
|
||||
.map_err(|e| format!("{}: {e}", from.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
|
||||
fn list(args: &Value, os: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for arg in args.as_array().into_iter().flatten() {
|
||||
match arg {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Array(values) => {
|
||||
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `${name}` replaced by its value; an unknown one stays as written.
|
||||
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = arg;
|
||||
while let Some(start) = rest.find("${") {
|
||||
let Some(len) = rest[start..].find('}') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..start]);
|
||||
let key = &rest[start + 2..start + len];
|
||||
match vars.get(key) {
|
||||
Some(value) => out.push_str(value),
|
||||
None => out.push_str(&rest[start..start + len + 1]),
|
||||
}
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
|
||||
/// Unpacks natives and lays out old assets first.
|
||||
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
|
||||
let id = version["id"].as_str().ok_or("The version file has no id")?;
|
||||
let main = version["mainClass"]
|
||||
.as_str()
|
||||
.ok_or("The version file has no mainClass")?;
|
||||
let separator = if cfg!(windows) { ";" } else { ":" };
|
||||
let classpath = classpath(version, launch.minecraft, launch.os)?;
|
||||
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
|
||||
let assets = launch.minecraft.join("assets");
|
||||
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
|
||||
let path = |p: &Path| p.to_string_lossy().into_owned();
|
||||
let p = launch.player;
|
||||
let vars: HashMap<&str, String> = HashMap::from([
|
||||
("auth_player_name", p.name.clone()),
|
||||
("auth_uuid", p.uuid.clone()),
|
||||
("auth_access_token", p.token.clone()),
|
||||
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
|
||||
("auth_xuid", p.xuid.clone()),
|
||||
("clientid", String::new()),
|
||||
("user_type", "msa".into()),
|
||||
("user_properties", "{}".into()),
|
||||
("version_name", id.to_string()),
|
||||
(
|
||||
"version_type",
|
||||
version["type"].as_str().unwrap_or("release").into(),
|
||||
),
|
||||
("game_directory", path(launch.game_dir)),
|
||||
("assets_root", path(&assets)),
|
||||
("game_assets", path(&game_assets)),
|
||||
(
|
||||
"assets_index_name",
|
||||
version["assetIndex"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or("legacy")
|
||||
.into(),
|
||||
),
|
||||
("natives_directory", path(launch.natives)),
|
||||
(
|
||||
"library_directory",
|
||||
path(&launch.minecraft.join("libraries")),
|
||||
),
|
||||
("classpath_separator", separator.into()),
|
||||
(
|
||||
"classpath",
|
||||
classpath
|
||||
.iter()
|
||||
.map(|c| path(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator),
|
||||
),
|
||||
("launcher_name", "horizon-launcher".into()),
|
||||
("launcher_version", env!("CARGO_PKG_VERSION").into()),
|
||||
]);
|
||||
let mut jvm = vec![
|
||||
format!("-Xmx{}M", launch.memory_mb),
|
||||
// Whatever the logging setup, never look names up in log messages.
|
||||
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
|
||||
];
|
||||
let logging = &version["logging"]["client"];
|
||||
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
|
||||
{
|
||||
let config = launch
|
||||
.minecraft
|
||||
.join("assets")
|
||||
.join("log_configs")
|
||||
.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(),
|
||||
),
|
||||
};
|
||||
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
|
||||
jvm.extend(launch.jvm_args.iter().cloned());
|
||||
let mut cmd = Command::new(launch.java);
|
||||
cmd.current_dir(launch.game_dir)
|
||||
.args(&jvm)
|
||||
.arg(main)
|
||||
.args(game.iter().map(|a| fill(a, &vars)));
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "java_launch/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
|
||||
//! a scratch folder.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// `rel` (`a/b/c`) under `base`, one part at a time as the code joins them (Windows paths).
|
||||
fn under(base: &Path, rel: &str) -> PathBuf {
|
||||
rel.split('/')
|
||||
.fold(base.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn player() -> Player {
|
||||
Player {
|
||||
name: "Notch".into(),
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
|
||||
token: "mc-token".into(),
|
||||
xuid: "2535400000000000".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn args(cmd: &Command) -> Vec<String> {
|
||||
cmd.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn modern() -> Value {
|
||||
json!({
|
||||
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "19"},
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
|
||||
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
|
||||
"--userType", "${user_type}",
|
||||
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
|
||||
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
|
||||
"value": ["--width", "${resolution_width}"]}],
|
||||
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
|
||||
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
|
||||
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
|
||||
"-cp", "${classpath}"]
|
||||
},
|
||||
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
|
||||
"file": {"id": "client-1.21.2.xml"}}},
|
||||
"libraries": [
|
||||
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
|
||||
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
|
||||
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
|
||||
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
|
||||
let dir = scratch("modern");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let java = dir.join("java");
|
||||
let p = player();
|
||||
let extra = vec!["-XX:+UseZGC".to_string()];
|
||||
let launch = |os| Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 6144,
|
||||
jvm_args: &extra,
|
||||
player: &p,
|
||||
os,
|
||||
};
|
||||
let cmd = command(&modern(), &launch("osx")).unwrap();
|
||||
let a = args(&cmd);
|
||||
let at = |s: &str| {
|
||||
a.iter()
|
||||
.position(|x| x == s)
|
||||
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
|
||||
};
|
||||
assert_eq!(a[0], "-Xmx6144M");
|
||||
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
|
||||
let config = under(&mc, "assets/log_configs/client-1.21.2.xml");
|
||||
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
|
||||
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
|
||||
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
|
||||
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
|
||||
assert_eq!(a[at("--username") + 1], "Notch");
|
||||
assert_eq!(a[at("--accessToken") + 1], "mc-token");
|
||||
assert_eq!(a[at("--assetIndex") + 1], "19");
|
||||
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
|
||||
assert_eq!(a[at("--userType") + 1], "msa");
|
||||
// Features are off: no demo, no custom size.
|
||||
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
|
||||
let lib = |p: &str| {
|
||||
under(&mc.join("libraries"), p)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
assert_eq!(
|
||||
cp,
|
||||
[
|
||||
lib("a/common/1/common-1.jar"),
|
||||
lib("a/mac/1/mac-1.jar"),
|
||||
under(&mc, "versions/1.21.4/1.21.4.jar")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
|
||||
|
||||
let linux = args(&command(&modern(), &launch("linux")).unwrap());
|
||||
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
|
||||
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
|
||||
// The x86-only rule holds on 32-bit Windows alone.
|
||||
let windows = args(&command(&modern(), &launch("windows")).unwrap());
|
||||
assert_eq!(
|
||||
windows.contains(&"-Xss1M".into()),
|
||||
cfg!(target_arch = "x86")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
|
||||
/// virtual asset index laid out by name.
|
||||
#[test]
|
||||
fn an_old_version_gets_its_natives_and_assets_by_name() {
|
||||
let dir = scratch("old");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
|
||||
fs::create_dir_all(jar.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for (name, body) in [
|
||||
("liblwjgl.dylib", b"lib".as_slice()),
|
||||
("META-INF/MANIFEST.MF", b"m"),
|
||||
] {
|
||||
zip.start_file(name, opts).unwrap();
|
||||
zip.write_all(body).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let hash = "0123456789abcdef0123456789abcdef01234567";
|
||||
let object = mc.join("assets/objects/01").join(hash);
|
||||
fs::create_dir_all(object.parent().unwrap()).unwrap();
|
||||
fs::write(&object, b"ogg").unwrap();
|
||||
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
|
||||
fs::write(
|
||||
mc.join("assets/indexes/1.8.json"),
|
||||
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let version = json!({
|
||||
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "1.8"},
|
||||
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
|
||||
"libraries": [{
|
||||
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
|
||||
"natives": {"osx": "natives-osx"},
|
||||
"extract": {"exclude": ["META-INF/"]},
|
||||
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
|
||||
}]
|
||||
});
|
||||
let (java, p) = (dir.join("java"), player());
|
||||
let launch = Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 2048,
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "osx",
|
||||
};
|
||||
let a = args(&command(&version, &launch).unwrap());
|
||||
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
|
||||
assert!(!natives.join("META-INF").exists());
|
||||
let virtual_dir = under(&mc, "assets/virtual/1.8");
|
||||
assert_eq!(
|
||||
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
|
||||
b"ogg"
|
||||
);
|
||||
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
|
||||
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
a[at("--session") + 1],
|
||||
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
|
||||
);
|
||||
assert_eq!(a[at("--userProperties") + 1], "{}");
|
||||
assert_eq!(a[at("--unknown") + 1], "${nothing}");
|
||||
assert_eq!(a[0], "-Xmx2048M");
|
||||
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
|
||||
// Natives are not on the classpath; the client jar is.
|
||||
let cp = &a[at("-cp") + 1];
|
||||
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_fill_in_place() {
|
||||
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
|
||||
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
|
||||
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod i18n;
|
||||
pub mod import;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
pub mod launch;
|
||||
pub mod minecraft_versions;
|
||||
pub mod modpacks;
|
||||
|
||||
+49
-12
@@ -60,9 +60,15 @@ pub fn download(
|
||||
fetch_manifest(pins, &manifest, fetch, log)?;
|
||||
let manifest = read_json(&manifest)?;
|
||||
let client = &manifest["downloads"]["client"];
|
||||
// A version Horizon doesn't pin takes its client's sha1 from its version file, itself
|
||||
// checked against the sha1 Mojang's version list gives (`Pins::for_version`).
|
||||
let client_sha1 = match pins.client_sha1.as_str() {
|
||||
"" => text(client, "sha1")?,
|
||||
pinned => pinned.to_string(),
|
||||
};
|
||||
let mut game = vec![File {
|
||||
url: text(client, "url")?,
|
||||
sha1: pins.client_sha1.clone(),
|
||||
sha1: client_sha1,
|
||||
size: client["size"].as_u64(),
|
||||
path: versions.join(format!("{v}.jar")),
|
||||
}];
|
||||
@@ -70,22 +76,40 @@ pub fn download(
|
||||
.as_array()
|
||||
.ok_or("The version file has no libraries")?;
|
||||
for library in libraries.iter().filter(|l| allowed(l, os)) {
|
||||
let artifact = &library["downloads"]["artifact"];
|
||||
if artifact.is_null() {
|
||||
continue;
|
||||
// Before 1.19 a library's natives for this system are a classifier of its own.
|
||||
let native = native_classifier(library, os)
|
||||
.map(|key| &library["downloads"]["classifiers"][key.as_str()]);
|
||||
for artifact in [Some(&library["downloads"]["artifact"]), native]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_null())
|
||||
{
|
||||
game.push(File {
|
||||
url: text(artifact, "url")?,
|
||||
sha1: text(artifact, "sha1")?,
|
||||
size: artifact["size"].as_u64(),
|
||||
path: dir
|
||||
.join("libraries")
|
||||
.join(relative(&text(artifact, "path")?)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
let libraries = game.len() - 1;
|
||||
// The logging setup the game is started with (`logging.client.argument`): for 1.7 to 1.18,
|
||||
// Mojang's fixed one is what keeps Log4Shell out.
|
||||
let logging = &manifest["logging"]["client"]["file"];
|
||||
if !logging.is_null() {
|
||||
game.push(File {
|
||||
url: text(artifact, "url")?,
|
||||
sha1: text(artifact, "sha1")?,
|
||||
size: artifact["size"].as_u64(),
|
||||
url: text(logging, "url")?,
|
||||
sha1: text(logging, "sha1")?,
|
||||
size: logging["size"].as_u64(),
|
||||
path: dir
|
||||
.join("libraries")
|
||||
.join(relative(&text(artifact, "path")?)?),
|
||||
.join("assets/log_configs")
|
||||
.join(relative(&text(logging, "id")?)?),
|
||||
});
|
||||
}
|
||||
log(format!(
|
||||
"Downloading Minecraft {v} from Mojang: the game and {} libraries",
|
||||
game.len() - 1
|
||||
"Downloading Minecraft {v} from Mojang: the game and {libraries} libraries"
|
||||
));
|
||||
fetch_all(&game, jobs, fetch, cancel, log)?;
|
||||
let index = &manifest["assetIndex"];
|
||||
@@ -244,7 +268,7 @@ fn text(v: &Value, key: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// A path from the version file, refused if it could leave the folder.
|
||||
fn relative(path: &str) -> Result<PathBuf, String> {
|
||||
pub(crate) fn relative(path: &str) -> Result<PathBuf, String> {
|
||||
let safe = !path.contains('\\')
|
||||
&& Path::new(path)
|
||||
.components()
|
||||
@@ -265,6 +289,19 @@ fn allowed(library: &Value, os: &str) -> bool {
|
||||
.is_some_and(|rule| rule["action"] == "allow")
|
||||
}
|
||||
|
||||
/// The classifier holding a library's natives for `os` (`natives-osx`), from its `natives` map;
|
||||
/// `${arch}` is this system's word size, as old versions name Windows' natives.
|
||||
pub(crate) fn native_classifier(library: &Value, os: &str) -> Option<String> {
|
||||
let arch = if cfg!(target_pointer_width = "64") {
|
||||
"64"
|
||||
} else {
|
||||
"32"
|
||||
};
|
||||
library["natives"][os]
|
||||
.as_str()
|
||||
.map(|key| key.replace("${arch}", arch))
|
||||
}
|
||||
|
||||
/// The asset index's objects, once each.
|
||||
fn objects(index: &Value, dir: &Path) -> Result<Vec<File>, String> {
|
||||
let objects = index["objects"]
|
||||
|
||||
@@ -8,6 +8,7 @@ const COMMON: &[u8] = b"a library every system uses";
|
||||
const LINUX: &[u8] = b"a linux native";
|
||||
const SOUND: &[u8] = b"a fake ogg";
|
||||
const LANG: &[u8] = b"{}";
|
||||
const LOGGING: &[u8] = b"<Configuration/>";
|
||||
/// Never cancelled.
|
||||
static GO: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -456,3 +457,113 @@ fn a_cancelled_download_goes_on_where_it_stopped() {
|
||||
assert_eq!(asked.len(), 6, "{asked:#?}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A version Horizon doesn't pin, as Mojang's list names it (its version file's sha1): the
|
||||
/// client's sha1 comes from that checked file, and an old version's natives for this system
|
||||
/// (a classifier of their library, `${arch}` filled in) come with the libraries.
|
||||
#[test]
|
||||
fn any_listed_version_comes_with_its_natives() {
|
||||
let native = |name: &str, data: &[u8]| {
|
||||
serde_json::json!({
|
||||
"path": format!("org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"),
|
||||
"sha1": sha1_hex(data),
|
||||
"url": format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar"),
|
||||
})
|
||||
};
|
||||
let index = serde_json::json!({"objects": {}}).to_string();
|
||||
let manifest = serde_json::json!({
|
||||
"id": "1.12.2",
|
||||
"assetIndex": {"id": "1.12", "sha1": sha1_hex(index.as_bytes()),
|
||||
"url": "https://piston-meta.mojang.com/v1/packages/i/1.12.json"},
|
||||
"downloads": {"client": {"sha1": sha1_hex(CLIENT), "size": CLIENT.len(), "url": "https://piston-data.mojang.com/old.jar"}},
|
||||
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {
|
||||
"id": "client-1.12.xml", "sha1": sha1_hex(LOGGING), "size": LOGGING.len(),
|
||||
"url": "https://piston-data.mojang.com/client-1.12.xml"}}},
|
||||
"libraries": [{
|
||||
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
|
||||
"natives": {"osx": "natives-osx", "windows": "natives-windows-${arch}"},
|
||||
"downloads": {"classifiers": {
|
||||
"natives-osx": native("natives-osx", b"osx natives"),
|
||||
"natives-windows-64": native("natives-windows-64", b"win64 natives"),
|
||||
"natives-windows-32": native("natives-windows-32", b"win32 natives"),
|
||||
}},
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
let sha1 = sha1_hex(manifest.as_bytes());
|
||||
let lib = |name: &str| format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar");
|
||||
let server = Server {
|
||||
files: HashMap::from([
|
||||
(
|
||||
format!("https://piston-meta.mojang.com/v1/packages/{sha1}/1.12.2.json"),
|
||||
manifest.into_bytes(),
|
||||
),
|
||||
(
|
||||
"https://piston-data.mojang.com/old.jar".into(),
|
||||
CLIENT.to_vec(),
|
||||
),
|
||||
(
|
||||
"https://piston-data.mojang.com/client-1.12.xml".into(),
|
||||
LOGGING.to_vec(),
|
||||
),
|
||||
(lib("natives-osx"), b"osx natives".to_vec()),
|
||||
(lib("natives-windows-64"), b"win64 natives".to_vec()),
|
||||
(lib("natives-windows-32"), b"win32 natives".to_vec()),
|
||||
(
|
||||
"https://piston-meta.mojang.com/v1/packages/i/1.12.json".into(),
|
||||
index.into_bytes(),
|
||||
),
|
||||
]),
|
||||
asked: Mutex::new(Vec::new()),
|
||||
};
|
||||
let pins = Pins::for_version("1.12.2", &sha1);
|
||||
let platform = |os: &str| {
|
||||
let dir = scratch(&format!("any-{os}"));
|
||||
download(&pins, &dir, os, 2, &|url| server.fetch(url), &GO, &|_| {}).unwrap();
|
||||
dir
|
||||
};
|
||||
let mac = platform("osx");
|
||||
assert_eq!(
|
||||
fs::read(mac.join("versions/1.12.2/1.12.2.jar")).unwrap(),
|
||||
CLIENT
|
||||
);
|
||||
let natives = |dir: &Path, name: &str| {
|
||||
dir.join(format!(
|
||||
"libraries/org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"
|
||||
))
|
||||
};
|
||||
assert!(natives(&mac, "natives-osx").is_file());
|
||||
assert_eq!(
|
||||
fs::read(mac.join("assets/log_configs/client-1.12.xml")).unwrap(),
|
||||
LOGGING
|
||||
);
|
||||
assert!(!natives(&mac, "natives-windows-64").exists());
|
||||
let windows = platform("windows");
|
||||
assert!(natives(&windows, "natives-windows-64").is_file());
|
||||
assert!(!natives(&windows, "natives-windows-32").exists());
|
||||
// Linux has no natives in this library: nothing to fetch for it, and no error.
|
||||
let linux = platform("linux");
|
||||
assert!(!linux.join("libraries").exists());
|
||||
assert!(linux.join("assets/log_configs/client-1.12.xml").is_file());
|
||||
for dir in [mac, windows, linux] {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
// A client that isn't the one its checked version file names is refused.
|
||||
let wrong = Server {
|
||||
files: server
|
||||
.files
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(url, body)| match url.ends_with("old.jar") {
|
||||
true => (url, b"another jar".to_vec()),
|
||||
false => (url, body),
|
||||
})
|
||||
.collect(),
|
||||
asked: Mutex::new(Vec::new()),
|
||||
};
|
||||
let dir = scratch("any-wrong");
|
||||
let refused = download(&pins, &dir, "osx", 1, &|url| wrong.fetch(url), &GO, &|_| {});
|
||||
assert!(refused.unwrap_err().contains("old.jar"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -492,6 +492,16 @@ fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
/// `horizon-mixins apply`: the mods in `mods` applied to `source`, into `out`. It runs
|
||||
/// `cargo metadata`, so it gets the launcher's toolchain like a build does: on a computer with no
|
||||
/// Rust of its own, a plain command found no cargo (E_METADATA).
|
||||
fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) -> Command {
|
||||
let mut cmd = rust.command(tool);
|
||||
cmd.arg("apply").arg("--workspace").arg(source);
|
||||
cmd.arg("--mods").arg(mods).arg("--out").arg(out);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in
|
||||
/// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly
|
||||
/// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out:
|
||||
@@ -614,10 +624,11 @@ pub fn build(
|
||||
let real = plain(root().canonicalize().map_err(|e| e.to_string())?);
|
||||
let fresh = real.join("stage.new");
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let mut apply = Command::new(&tool);
|
||||
apply.arg("apply").arg("--workspace").arg(&source);
|
||||
apply.arg("--mods").arg(&incoming).arg("--out").arg(&fresh);
|
||||
run(apply, "horizon-mixins", |_| {})?;
|
||||
run(
|
||||
apply(&rust, &tool, &source, &incoming, &fresh),
|
||||
"horizon-mixins",
|
||||
|_| {},
|
||||
)?;
|
||||
let stage = root().join("stage");
|
||||
sync(&fresh, &stage)?;
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
@@ -839,6 +850,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_mixins_finds_the_launchers_cargo() {
|
||||
let rust = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let cmd = apply(
|
||||
&rust,
|
||||
Path::new("/tools/horizon-mixins"),
|
||||
Path::new("/src"),
|
||||
Path::new("/mods"),
|
||||
Path::new("/out"),
|
||||
);
|
||||
let path = cmd
|
||||
.get_envs()
|
||||
.find(|(k, _)| *k == "PATH")
|
||||
.and_then(|(_, v)| v)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::env::split_paths(path).next(),
|
||||
Some(rust.dir.join("bin"))
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"apply",
|
||||
"--workspace",
|
||||
"/src",
|
||||
"--mods",
|
||||
"/mods",
|
||||
"--out",
|
||||
"/out"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_one_bundle_or_a_folder_of_them() {
|
||||
let one = dir("one");
|
||||
|
||||
@@ -33,6 +33,16 @@ impl Pins {
|
||||
})
|
||||
}
|
||||
|
||||
/// Any version from Mojang's version list, by its id and its version file's sha1 (the
|
||||
/// list's): the client's sha1 then comes from that checked version file.
|
||||
pub fn for_version(id: &str, manifest_sha1: &str) -> Pins {
|
||||
Pins {
|
||||
version: id.to_string(),
|
||||
client_sha1: String::new(),
|
||||
manifests: vec![manifest_sha1.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_accepted(&self, path: &Path) -> bool {
|
||||
sha1_file(path).is_some_and(|sha1| self.manifests.contains(&sha1))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,19 @@ def scripted(s):
|
||||
s.tick(5)
|
||||
s.add('stopall')
|
||||
s.tick(5)
|
||||
# Joining a world: the menu-like track plays, Minecraft.setLevel stops every
|
||||
# sound, and the game music (not replacing) waits its own delay; a respawn
|
||||
# into another dimension then stops the music manager's track too.
|
||||
s.add('start', 11, 'DEFAULT')
|
||||
s.situation(end, 20, 600, 1)
|
||||
s.tick(110)
|
||||
s.add('stopall')
|
||||
s.situation(water, 12000, 24000, 0)
|
||||
s.tick(3, loading=1)
|
||||
s.tick(5)
|
||||
s.add('stopall')
|
||||
s.add('stop')
|
||||
s.tick(5)
|
||||
# Events that cannot play.
|
||||
for event in ODD:
|
||||
s.add('start', 7, 'DEFAULT')
|
||||
|
||||
Reference in new issue
Block a user