Fix multiplayer pause menu and World Options permissions

This commit is contained in:
LookImEcho-Rick committed 2026-10-16 23:39:36 -05:00
1 parent 99d5ebea1a
commit aeaccb0413
12 files changed
+599 -49

No files matched your search

@@ -0,0 +1,40 @@
//! Packet payload oracle companion for tools/test_world_options_parity.py.
use horizon_protocol::{buf::Reader, packets::play::*};
use std::{error::Error, fmt::Write};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
let fields: Vec<&str> = line.split('\t').collect();
let entries = if fields.len() == 3 {
vec![GameRuleEntry {
name: format!("minecraft:{}", fields[1]),
value: fields[2].into(),
}]
} else {
vec![]
};
let bytes = match fields[0] {
"difficulty" => Serverbound::ChangeDifficulty(ChangeDifficultyRequest {
difficulty: fields[1].parse()?,
})
.encode(),
"lock" => Serverbound::LockDifficulty(LockDifficulty {
locked: fields[1].parse()?,
})
.encode(),
"set" => Serverbound::SetGameRule(SetGameRule { entries }).encode(),
"values" => Clientbound::GameRuleValues(GameRuleValues { entries }).encode(),
_ => return Err("unknown case".into()),
};
let mut reader = Reader::new(&bytes);
reader.varint()?;
for byte in reader.rest() {
write!(output, "{byte:02x}")?;
}
output.push('\n');
}
std::fs::write(&args[2], output)?;
Ok(())
}
+51 -3
View File
@@ -1173,6 +1173,7 @@ pub mod pause {
quit_label: &str,
opened: Instant,
friends: &FriendsView,
remote: bool,
) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
@@ -1207,11 +1208,11 @@ pub mod pause {
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
let lw = ui.button_width("World Options", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
"World Options",
Kind::Secondary,
true,
);
@@ -1235,7 +1236,11 @@ pub mod pause {
x,
y,
"Leave",
"Saves the world, then returns to the title.",
if remote {
"Disconnects from the server, then returns to the title."
} else {
"Saves the world, then returns to the title."
},
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
@@ -1363,6 +1368,7 @@ pub mod pause {
world: &str,
quit_label: &str,
friends: &FriendsView,
remote: bool,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
@@ -1371,6 +1377,7 @@ pub mod pause {
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
friends,
remote,
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
@@ -1391,6 +1398,47 @@ mod tests {
use super::*;
use horizon_world::nbt::Tag;
#[test]
fn pause_world_options_and_disconnect_are_clickable_on_servers() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let friends = FriendsView::default();
for remote in [false, true] {
let quit = if remote {
"Disconnect"
} else {
"Save and quit"
};
for action in [9u8, 2] {
let mut batch = HudBatch::new([1280, 720]);
let (rect, density) = {
let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0));
pause::draw(
&mut ui,
"World",
quit,
Instant::now() - std::time::Duration::from_secs(5),
&friends,
remote,
);
let hit = ui
.hits
.iter()
.find(|h| h.id == id("pause", u64::from(action)))
.expect("pause action available");
(hit.rect, ui.density)
};
let k = density / batch.scale;
let x = (rect[0] + rect[2]) / 2.0 * k;
let y = (rect[1] + rect[3]) / 2.0 * k;
assert_eq!(
pause::click(&atlas, &mut batch, x, y, "World", quit, &friends, remote),
Some(action)
);
}
}
}
fn world(saves: &std::path::Path, folder: &str, last_played: i64) {
crate::save::write_dat(
&saves.join(folder).join("level.dat"),
@@ -540,6 +540,70 @@ impl Game<'_> {
}
}
S::ChatCommand(c) => self.guest_command(host, conn, &c.command),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
self.ui.difficulty = if self.level.hardcore {
3
} else {
p.difficulty as u8
};
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
}
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(self.ui.difficulty),
locked: self.level.difficulty_locked,
}));
}
}
S::LockDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
self.level.difficulty_locked = p.locked;
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(self.ui.difficulty),
locked: self.level.difficulty_locked,
}));
}
S::ClientCommand(p) if p.action == 2 && host.hub.guests[gi].permission >= 2 => {
host.hub.send_to(
conn,
&C::GameRuleValues(play::GameRuleValues {
entries: horizon_client::gamerules::RULES
.iter()
.map(|r| play::GameRuleEntry {
name: format!("minecraft:{}", r.name),
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
})
.collect(),
}),
);
}
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
for entry in p.entries {
if self.ui.rules.set_string(&entry.name, &entry.value) {
let message = horizon_client::text::Text::tr(
"commands.gamerule.set",
&[&entry.name, &entry.value],
);
let content =
horizon_client::lan::data::component_nbt(&message.component());
for g in &host.hub.guests {
if g.permission >= 2 {
g.send(&C::SystemChat(play::SystemChat {
content: content.clone(),
overlay: false,
}));
}
}
if self.options.dedicated.is_none() && self.allow_commands() {
self.system_message(message);
}
}
}
self.sync_rules();
}
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(mode) = usize::try_from(p.mode)
.ok()
+232 -37
View File
@@ -4,6 +4,7 @@
//! difficulty, LAN on/off with its port, command access for guests and
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
@@ -16,9 +17,12 @@ use horizon_client::{
text::{Text, named_color},
ui::{self, EditBox},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
use winit::{event::KeyEvent, keyboard::PhysicalKey};
pub(super) struct WorldOptions {
remote: bool,
gamemaster: bool,
default_mode: i32,
personal_mode: i32,
cheats: bool,
@@ -109,6 +113,36 @@ fn labelled(key: &str, value: Text) -> Text {
}
impl WorldOptions {
/// Vanilla 26.3 WorldOptionsScreen.generalOptions: guests see difficulty,
/// game rules and restrictions; game modes, commands and LAN are host-only.
fn visible_buttons(&self, w: f32) -> Vec<(Button, f32, f32, f32)> {
if !self.remote {
return Self::buttons(w, self.top());
}
let x = Self::left(w);
let top = self.top();
vec![
(Button::Difficulty, x, top + 29.0, 130.0),
(Button::Lock, x + 130.0, top + 29.0, 20.0),
(Button::GameRules, x + 158.0, top + 29.0, 150.0),
(Button::Restrictions, x, top + 53.0, 150.0),
]
}
fn content_height(&self) -> f32 {
if self.remote { 81.0 } else { CONTENT }
}
fn scroll_limit(&self, h: f32) -> f32 {
(self.content_height() - (h - HEADER - FOOTER)).max(0.0)
}
fn server_difficulty(&mut self, difficulty: u8, locked: bool) {
// A server update is the new baseline, not a local edit. In particular,
// the reply to Apply may arrive after the screen has already reopened.
self.difficulty = difficulty;
self.difficulty_locked = locked;
self.locked = locked || self.hardcore;
self.initial.difficulty = self.difficulty;
self.initial.locked = self.locked;
}
fn parsed_port(&self) -> Option<u16> {
if self.port.value.is_empty() {
return Some(self.hint);
@@ -176,16 +210,13 @@ impl WorldOptions {
/// scroll area.
fn hit(&self, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<Button> {
let inside = y >= HEADER && y < h - FOOTER;
Self::buttons(w, self.top())
self.visible_buttons(w)
.into_iter()
.filter(|_| inside)
.chain(Self::footer(w, h))
.find(|(b, bx, by, bw)| self.active(*b) && ui::hovered((x, y), *bx, *by, *bw, 20.0))
.map(|b| b.0)
}
fn max_scroll(h: f32) -> f32 {
(CONTENT - (h - HEADER - FOOTER)).max(0.0)
}
fn label(&self, b: Button) -> Text {
let mode = |m: i32| {
Text::translate(
@@ -230,6 +261,17 @@ impl WorldOptions {
}
/// WorldOptionsScreen.updateButton and the update*Button methods.
fn active(&self, b: Button) -> bool {
if self.remote {
return match b {
Button::Difficulty | Button::Lock => {
self.gamemaster && !self.hardcore && !self.difficulty_locked
}
Button::GameRules => self.gamemaster,
Button::Restrictions | Button::Cancel => true,
Button::Apply => self.gamemaster && self.changed(),
_ => false,
};
}
match b {
Button::DefaultMode | Button::PersonalMode => self.cheats && !self.hardcore,
// An owner's commands come with Allow Commands.
@@ -405,6 +447,34 @@ impl Restrictions {
}
impl Game<'_> {
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
if let Some(o) = self.world_options.as_mut() {
let lost = o.gamemaster && !permission;
o.gamemaster = permission;
if lost && o.remote {
self.close_world_options();
}
}
}
pub(super) fn remote_world_options_difficulty(&mut self, difficulty: u8, locked: bool) {
if let Some(o) = self.world_options.as_mut() {
o.server_difficulty(difficulty, locked);
}
}
pub(super) fn remote_world_options_rules(&mut self, entries: Vec<play::GameRuleEntry>) {
let Some(o) = self
.world_options
.as_mut()
.filter(|o| o.view == View::GameRules)
else {
return;
};
let mut rules = horizon_client::gamerules::GameRules::default();
for entry in entries {
rules.set_string(&entry.name, &entry.value);
}
o.rules = Some(RulesScreen::new(&rules));
}
pub(super) fn open_world_options(&mut self) {
let hint = horizon_client::lan::available_port();
let mut port = EditBox::new(0.0, 0.0, 150.0, 20.0);
@@ -421,6 +491,8 @@ impl Game<'_> {
port.set_value(&p.to_string());
}
let mut options = WorldOptions {
remote: self.external_server(),
gamemaster: self.ui.registries.permission >= 2,
default_mode: state.default_mode,
personal_mode: state.personal_mode,
cheats: state.cheats,
@@ -463,7 +535,10 @@ impl Game<'_> {
pub(super) fn world_options_escape(&mut self) {
match self.world_options.as_mut() {
Some(o) if o.view == View::GameRules => {
if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
if o.rules.is_none() {
// A server may be slow to answer REQUEST_GAMERULE_VALUES.
o.view = View::Options;
} else if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
self.close_game_rules(exit);
}
}
@@ -483,6 +558,20 @@ impl Game<'_> {
let Exit::Apply(changes) = exit else {
return;
};
if self.remote.is_some() {
if self.ui.registries.permission >= 2 && !changes.is_empty() {
self.send(S::SetGameRule(play::SetGameRule {
entries: changes
.into_iter()
.map(|(name, value)| play::GameRuleEntry {
name: format!("minecraft:{name}"),
value: value.to_string(),
})
.collect(),
}));
}
return;
}
if !self.allow_commands() || changes.is_empty() {
return;
}
@@ -508,6 +597,16 @@ impl Game<'_> {
View::GameRules => {
if let Some(rules) = o.rules.as_mut() {
rules.draw(batch, &self.atlas, self.cursor);
} else {
ui::menu_background(
batch,
&self.atlas,
0.0,
0.0,
batch.width,
batch.height,
true,
);
}
}
}
@@ -590,7 +689,7 @@ impl Game<'_> {
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
let atlas = &self.atlas;
let (w, h) = (batch.width, batch.height);
o.scroll = o.scroll.clamp(0.0, WorldOptions::max_scroll(h));
o.scroll = o.scroll.clamp(0.0, o.scroll_limit(h));
// extractMenuBackground: the in-world background, the list
// background under the content, then the separators over it.
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -612,6 +711,9 @@ impl Game<'_> {
("options.worldOptions.general.title", GENERAL_TOP),
("options.worldOptions.multiplayer.title", MULTIPLAYER_TOP),
] {
if o.remote && key == "options.worldOptions.multiplayer.title" {
continue;
}
// A FocusableTextWidget padded by 4, centred over both columns.
let t = Text::translate(key, &[]).underlined().bold();
let tw = atlas.font.width_text(&t);
@@ -619,7 +721,7 @@ impl Game<'_> {
batch.text_styled(atlas, &t, tx, top + y + 4.0, u32::MAX, true);
}
let inside = cursor.1 >= HEADER && cursor.1 < h - FOOTER;
for (b, bx, by, bw) in WorldOptions::buttons(w, top) {
for (b, bx, by, bw) in o.visible_buttons(w) {
let active = o.active(b);
let hover = active && inside && ui::hovered(cursor, bx, by, bw, 20.0);
if b == Button::Lock {
@@ -637,32 +739,34 @@ impl Game<'_> {
}
// The port box beside the LAN button: editable only with LAN on,
// its hint the port to use (setPortError colours the text).
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.port.focused = o.lan && o.port.focused;
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
rgba(0xE0, 0xE0, 0xE0, 255)
} else {
rgba(0xDF, 0x50, 0x50, 255)
};
o.port.draw(batch, atlas, None);
if o.port.value.is_empty() && !o.port.focused {
let hint = if o.lan {
Text::literal(o.hint.to_string())
if !o.remote {
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.port.focused = o.lan && o.port.focused;
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
rgba(0xE0, 0xE0, 0xE0, 255)
} else {
Text::translate("lanServer.port", &[])
rgba(0xDF, 0x50, 0x50, 255)
};
// EditBox.DEFAULT_HINT_STYLE: dark gray.
batch.text_styled(
atlas,
&hint,
o.port.text_x(),
o.port.text_y(),
rgba(0x55, 0x55, 0x55, 255),
true,
);
o.port.draw(batch, atlas, None);
if o.port.value.is_empty() && !o.port.focused {
let hint = if o.lan {
Text::literal(o.hint.to_string())
} else {
Text::translate("lanServer.port", &[])
};
// EditBox.DEFAULT_HINT_STYLE: dark gray.
batch.text_styled(
atlas,
&hint,
o.port.text_x(),
o.port.text_y(),
rgba(0x55, 0x55, 0x55, 255),
true,
);
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::scrollbar(
@@ -671,7 +775,7 @@ impl Game<'_> {
(w / 2.0).floor() + 166.0,
HEADER,
h - FOOTER,
CONTENT,
o.content_height(),
o.scroll.trunc(),
);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
@@ -687,7 +791,7 @@ impl Game<'_> {
let h = self.gui_size().1;
match self.world_options.as_mut() {
Some(o) if o.view == View::Options => {
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, WorldOptions::max_scroll(h));
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, o.scroll_limit(h));
}
Some(o) => {
if let Some(rules) = o.rules.as_mut() {
@@ -760,7 +864,8 @@ impl Game<'_> {
}
}
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
o.port.focused = o.lan
o.port.focused = !o.remote
&& o.lan
&& inside
&& ui::hovered(
self.cursor,
@@ -789,7 +894,12 @@ impl Game<'_> {
Some(Button::Lock) => o.locked = !o.locked,
Some(Button::Restrictions) => o.view = View::Restrictions,
Some(Button::GameRules) => {
o.rules = Some(RulesScreen::new(&self.ui.rules));
if self.remote.is_some() {
o.rules = None;
self.send(S::ClientCommand(play::ClientCommand { action: 2 }));
} else {
o.rules = Some(RulesScreen::new(&self.ui.rules));
}
o.view = View::GameRules;
}
Some(Button::Lan) => {
@@ -915,6 +1025,19 @@ impl Game<'_> {
}
fn apply_world_options(&mut self, before: &Snapshot, wanted: &Snapshot) {
if self.external_server() {
if self.ui.registries.permission >= 2 {
if wanted.difficulty != before.difficulty {
self.send(S::ChangeDifficulty(play::ChangeDifficultyRequest {
difficulty: i32::from(wanted.difficulty),
}));
}
if wanted.locked && !before.locked {
self.send(S::LockDifficulty(play::LockDifficulty { locked: true }));
}
}
return;
}
if let Some(server) = &self.integrated {
// The server applies them; the window tells what came of
// Open to LAN (ShareToLanScreen's messages).
@@ -985,6 +1108,8 @@ mod tests {
fn options(cheats: bool, hardcore: bool) -> WorldOptions {
let mut o = WorldOptions {
remote: false,
gamemaster: cheats,
default_mode: 1,
personal_mode: 1,
cheats,
@@ -1038,8 +1163,8 @@ mod tests {
assert_eq!(at(Button::Force), (217.0, 223.0, 150.0));
assert_eq!(at(Button::Apply), (59.0, 213.0, 150.0));
// 218 pixels of content over 174 between the separators.
assert_eq!(WorldOptions::max_scroll(240.0), 44.0);
assert_eq!(WorldOptions::max_scroll(400.0), 0.0);
assert_eq!(options(true, false).scroll_limit(240.0), 44.0);
assert_eq!(options(true, false).scroll_limit(400.0), 0.0);
}
#[test]
@@ -1083,4 +1208,74 @@ mod tests {
let o = options(true, true);
assert!(o.locked && !o.active(Button::Lock));
}
#[test]
fn remote_options_follow_vanilla_guest_layout_and_permissions() {
let mut o = options(false, false);
o.remote = true;
// Vanilla omits host-only rows instead of disabling the whole screen.
let rows = o.visible_buttons(427.0);
assert_eq!(
rows.iter().map(|b| b.0).collect::<Vec<_>>(),
vec![
Button::Difficulty,
Button::Lock,
Button::GameRules,
Button::Restrictions,
]
);
assert_eq!(rows[0], (Button::Difficulty, 59.0, 62.0, 130.0));
assert_eq!(rows[2], (Button::GameRules, 217.0, 62.0, 150.0));
assert_eq!(rows[3], (Button::Restrictions, 59.0, 86.0, 150.0));
assert_eq!(o.scroll_limit(240.0), 0.0);
// A non-operator cannot activate the difficulty or rule controls.
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
assert_eq!(o.hit((240.0, 70.0), (427.0, 240.0)), None);
assert_eq!(
o.hit((100.0, 90.0), (427.0, 240.0)),
Some(Button::Restrictions)
);
o.gamemaster = true;
assert_eq!(
o.hit((100.0, 70.0), (427.0, 240.0)),
Some(Button::Difficulty)
);
assert_eq!(
o.hit((240.0, 70.0), (427.0, 240.0)),
Some(Button::GameRules)
);
o.difficulty_locked = true;
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
// Remote hardcore does not hide an operator's Game Rules screen.
o.hardcore = true;
assert!(o.active(Button::GameRules));
assert!(!o.active(Button::Lock));
for b in [
Button::DefaultMode,
Button::PersonalMode,
Button::Cheats,
Button::Lan,
Button::CommandAccess,
Button::Force,
] {
assert!(!o.active(b));
}
}
#[test]
fn a_delayed_server_reply_does_not_mark_reopened_options_as_edited() {
let mut o = options(false, false);
o.remote = true;
o.gamemaster = true;
// Reopened while the previous Apply's reply was still in flight.
o.server_difficulty(3, false);
assert_eq!(o.difficulty, 3);
assert!(!o.active(Button::Apply));
o.difficulty = 0;
assert!(o.active(Button::Apply));
o.server_difficulty(3, true);
assert!(!o.active(Button::Apply));
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
}
+10 -3
View File
@@ -248,6 +248,7 @@ impl Game<'_> {
progress: 0.0,
total: 0,
};
self.ui.registries.permission = 0;
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
@@ -882,6 +883,7 @@ impl Game<'_> {
self.debug_charts.ping.log(delay);
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
if let Some(r) = self.remote.as_mut() {
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
@@ -1199,13 +1201,12 @@ impl Game<'_> {
}
}
C::EntityEvent(p) => {
// EntityEvent.PERMISSION_LEVEL_ALL..OWNERS (24..=28) for this
// player: LocalPlayer.setPermissionLevel, which the commands
// the chat offers and checks follow.
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
@@ -1229,6 +1230,12 @@ impl Game<'_> {
self.potions.effects.list.retain(|e| e.id != id);
}
}
C::ChangeDifficulty(p) => {
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
self.level.difficulty_locked = p.locked;
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
}
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
C::SetHealth(p) => {
if let Some(r) = self.remote.as_mut() {
r.health = (p.health, p.food, p.saturation);
+47 -6
View File
@@ -8,7 +8,8 @@ use horizon_client::{
};
/// PauseScreen buttons: (x, y, width, label key, action id, active).
fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
let (grid_w, grid_h) = (212.0, 166.0);
let x0 = ((w - grid_w) * 0.5).floor();
@@ -26,9 +27,21 @@ fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)>
9,
true,
),
(x0 + 4.0, y0 + 146.0, 204.0, "menu.returnToMenu", 2, true),
(
x0 + 4.0,
y0 + 146.0,
204.0,
if remote {
"menu.disconnect"
} else {
"menu.returnToMenu"
},
2,
true,
),
]
}
/// The icon row: report bugs, feedback, friends, player reporting.
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
@@ -52,6 +65,10 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Singleplayer also has a connection, backed by the integrated server.
pub(super) fn external_server(&self) -> bool {
self.remote.is_some() && self.integrated.is_none()
}
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused
@@ -63,7 +80,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
return (server.world_name.clone(), "Save and quit");
}
if self.remote.is_some() {
if self.external_server() {
("Multiplayer".to_owned(), "Disconnect")
} else {
(self.level.name.clone(), "Save and quit")
@@ -76,7 +93,14 @@ impl Game<'_> {
self.tick_friends();
let friends = self.friends_view();
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened, &friends);
horizon_client::front::pause::draw(
&mut ui,
&world,
quit,
opened,
&friends,
self.external_server(),
);
ui.finish();
return;
}
@@ -93,7 +117,7 @@ impl Game<'_> {
u32::MAX,
true,
);
for (x, y, bw, key, _, active) in pause_buttons(w, h) {
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -139,9 +163,10 @@ impl Game<'_> {
&world,
quit,
&self.friends_view(),
self.external_server(),
)
} else {
pause_buttons(w, h)
pause_buttons(w, h, self.external_server())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -295,3 +320,19 @@ impl Game<'_> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::pause_buttons;
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
}
}
}
+25
View File
@@ -143,6 +143,15 @@ impl GameRules {
pub fn bool(&self, name: &str) -> bool {
matches!(self.get(name), Some(Value::Bool(true)))
}
/// Parse a network rule value, retaining type and range validation.
pub fn set_string(&mut self, name: &str, text: &str) -> bool {
let value = match self.get(name) {
Some(Value::Bool(_)) => text.parse::<bool>().ok().map(Value::Bool),
Some(Value::Int(_)) => text.parse::<i32>().ok().map(Value::Int),
None => None,
};
value.is_some_and(|v| self.set(name, v))
}
pub fn int(&self, name: &str) -> i32 {
match self.get(name) {
Some(Value::Int(v)) => v,
@@ -250,6 +259,22 @@ impl GameRules {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_values_validate_rule_names_types_and_bounds() {
let mut rules = GameRules::default();
assert!(rules.set_string("minecraft:keep_inventory", "true"));
assert!(rules.bool("keep_inventory"));
assert!(!rules.set_string("minecraft:keep_inventory", "1"));
assert!(rules.bool("keep_inventory"));
assert!(rules.set_string("minecraft:random_tick_speed", "7"));
assert_eq!(rules.int("random_tick_speed"), 7);
assert!(!rules.set_string("minecraft:random_tick_speed", "-1"));
assert!(!rules.set_string("minecraft:random_tick_speed", "999999999999"));
assert!(!rules.set_string("minecraft:unknown", "true"));
assert!(!rules.set_string("other:keep_inventory", "false"));
assert_eq!(rules.int("random_tick_speed"), 7);
}
#[test]
fn defaults_bounds_and_round_trip() {
let mut rules = GameRules::default();
@@ -75,6 +75,9 @@ packet!(BlockDestruction { id: i32 => VarInt, pos: [i32; 3] => Pos, progress: u8
packet!(BlockEntityData { pos: [i32; 3] => Pos, kind: i32 => VarInt, tag: Tag => Nbt });
packet!(BlockUpdate { pos: [i32; 3] => Pos, state: i32 => VarInt });
packet!(ChangeDifficulty { difficulty: i32 => VarInt, locked: bool => Bool });
// ResourceKey<GameRule<?>> is an identifier, followed by its string value.
packet!(GameRuleEntry { name: String => Ident, value: String => Str });
packet!(GameRuleValues { entries: Vec<GameRuleEntry> => List<GameRuleEntry> });
packet!(ChunkBatchFinished { size: i32 => VarInt });
packet!(ContainerClose { container: i32 => VarInt });
packet!(ContainerSetContent {
@@ -999,6 +1002,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
EntityPositionSync(EntityPositionSync) = ENTITY_POSITION_SYNC,
ForgetLevelChunk(ForgetLevelChunk) = FORGET_LEVEL_CHUNK,
GameEvent(GameEvent) = GAME_EVENT,
GameRuleValues(GameRuleValues) = GAME_RULE_VALUES,
HurtAnimation(HurtAnimation) = HURT_ANIMATION,
KeepAlive(KeepAlive) = KEEP_ALIVE,
InitializeBorder(InitializeBorder) = INITIALIZE_BORDER,
@@ -1097,6 +1101,9 @@ packet!(Chat {
last_seen: LastSeenUpdate => LastSeenUpdate,
});
packet!(ChatCommand { command: String => Str });
packet!(ChangeDifficultyRequest { difficulty: i32 => VarInt });
packet!(LockDifficulty { locked: bool => Bool });
packet!(SetGameRule { entries: Vec<GameRuleEntry> => List<GameRuleEntry> });
packet!(ChunkBatchReceived { chunks_per_tick: f32 => Float });
packet!(
/// ServerboundClientCommandPacket.Action: 0 respawn, 1 request stats, 2 request game rules.
@@ -1180,6 +1187,9 @@ impl Codec<[f32; 3]> for Cursor {
}
packet_enum!(Serverbound, crate::ids::play::serverbound {
ChangeDifficulty(ChangeDifficultyRequest) = CHANGE_DIFFICULTY,
LockDifficulty(LockDifficulty) = LOCK_DIFFICULTY,
SetGameRule(SetGameRule) = SET_GAME_RULE,
AcceptTeleportation(AcceptTeleportation) = ACCEPT_TELEPORTATION,
Attack(Attack) = ATTACK,
ChangeGameMode(ChangeGameMode) = CHANGE_GAME_MODE,
@@ -1220,6 +1230,46 @@ mod tests {
use super::*;
use crate::{chunk::BlockEntityInfo, packets::round_trip, text::literal};
#[test]
fn world_options_packets_match_vanilla_stream_codecs() {
// mcsrc.dev 26.3: difficulty is a VarInt, locking is a Boolean,
// rule entries are ResourceKey identifiers followed by UTF-8 values.
let difficulty = Serverbound::ChangeDifficulty(ChangeDifficultyRequest { difficulty: 3 });
assert_eq!(&difficulty.encode()[1..], &[3]);
round_trip(difficulty, Serverbound::encode, Serverbound::decode);
let lock = Serverbound::LockDifficulty(LockDifficulty { locked: true });
assert_eq!(&lock.encode()[1..], &[1]);
round_trip(lock, Serverbound::encode, Serverbound::decode);
let payload = b"\x01\x18minecraft:keep_inventory\x04true";
let entry = GameRuleEntry {
name: "minecraft:keep_inventory".into(),
value: "true".into(),
};
let set = Serverbound::SetGameRule(SetGameRule {
entries: vec![entry.clone()],
});
assert_eq!(&set.encode()[1..], payload);
assert_eq!(
Serverbound::decode(crate::ids::play::serverbound::SET_GAME_RULE, payload).unwrap(),
set
);
let values = Clientbound::GameRuleValues(GameRuleValues {
entries: vec![entry],
});
assert_eq!(&values.encode()[1..], payload);
assert_eq!(
Clientbound::decode(crate::ids::play::clientbound::GAME_RULE_VALUES, payload).unwrap(),
values
);
assert!(
Serverbound::decode(
crate::ids::play::serverbound::SET_GAME_RULE,
&payload[..payload.len() - 1]
)
.is_err()
);
}
fn spawn() -> SpawnInfo {
SpawnInfo {
dimension_type: 0,
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

@@ -0,0 +1,55 @@
package dev.horizonlab.parity;
import io.netty.buffer.Unpooled;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundGameRuleValuesPacket;
import net.minecraft.network.protocol.game.ServerboundChangeDifficultyPacket;
import net.minecraft.network.protocol.game.ServerboundLockDifficultyPacket;
import net.minecraft.network.protocol.game.ServerboundSetGameRulePacket;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.Difficulty;
/** Encode the same settings through the pinned game's own StreamCodecs. */
public final class VanillaWorldOptionsOracle {
public static void main(String[] args) throws Exception {
var output = new ArrayList<String>();
for (String line : Files.readAllLines(Path.of(args[0]))) {
String[] fields = line.split("\t");
var buffer = new FriendlyByteBuf(Unpooled.buffer());
switch (fields[0]) {
case "difficulty" -> ServerboundChangeDifficultyPacket.STREAM_CODEC.encode(buffer,
new ServerboundChangeDifficultyPacket(Difficulty.byId(Integer.parseInt(fields[1]))));
case "lock" -> ServerboundLockDifficultyPacket.STREAM_CODEC.encode(buffer,
new ServerboundLockDifficultyPacket(Boolean.parseBoolean(fields[1])));
case "set", "values" -> {
var entries = fields.length == 1 ? List.<ServerboundSetGameRulePacket.Entry>of()
: List.of(new ServerboundSetGameRulePacket.Entry(ResourceKey.create(
Registries.GAME_RULE, Identifier.withDefaultNamespace(fields[1])), fields[2]));
if (fields[0].equals("set")) {
ServerboundSetGameRulePacket.STREAM_CODEC.encode(buffer,
new ServerboundSetGameRulePacket(entries));
} else {
var values = entries.isEmpty() ? Map.<ResourceKey<net.minecraft.world.level.gamerules.GameRule<?>>, String>of()
: Map.of(entries.getFirst().gameRuleKey(), entries.getFirst().value());
ClientboundGameRuleValuesPacket.STREAM_CODEC.encode(buffer,
new ClientboundGameRuleValuesPacket(values));
}
}
default -> throw new IllegalArgumentException(line);
}
byte[] bytes = new byte[buffer.readableBytes()];
buffer.readBytes(bytes);
output.add(HexFormat.of().formatHex(bytes));
buffer.release();
}
Files.write(Path.of(args[1]), output);
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Compare World Options packet payloads with the pinned vanilla codecs."""
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
def cases(build):
lines = [f'difficulty\t{i}' for i in range(4)] + ['lock\tfalse', 'lock\ttrue']
for kind in ('set', 'values'):
lines.append(kind)
for name, values in [('keep_inventory', ['false', 'true']),
('random_tick_speed', ['0', '3', '2147483647'])]:
lines.extend(f'{kind}\t{name}\t{value}' for value in values)
path = build / 'cases.txt'
path.write_text('\n'.join(lines) + '\n')
return path, len(lines)
if __name__ == '__main__':
harness.differential(
'world-options-parity', 'VanillaWorldOptionsOracle', 'world_options_fixture', cases,
'ChangeDifficulty, LockDifficulty, SetGameRule and GameRuleValues payloads',
sources=[lab.ROOT / 'crates/horizon-protocol/src/packets/play.rs'], expected_lines=18)