Merge main into #6888

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 09:21:21 +01:00
commit a8618bd7ed
154 files changed
+9131 -968

No files matched your search

+2 -1
View File
@@ -26,6 +26,7 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
@@ -174,7 +175,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
Generated
+9
View File
@@ -1399,6 +1399,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1543,6 +1544,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1647,6 +1655,7 @@ dependencies = [
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
+2
View File
@@ -17,6 +17,8 @@ members = [
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+92 -18
View File
@@ -239,8 +239,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +323,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +380,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +423,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+74 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
+2
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -389,6 +389,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -729,6 +732,20 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+39 -2
View File
@@ -120,10 +120,21 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -160,8 +171,12 @@ impl Default for Extras {
chat_indicator: true,
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
}
}
}
@@ -240,8 +255,12 @@ impl Extras {
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
_ => {}
}
}
@@ -249,7 +268,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -287,8 +306,12 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -402,6 +425,10 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -425,6 +452,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
}
@@ -469,6 +498,7 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
@@ -490,6 +520,7 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
@@ -522,6 +553,7 @@ impl Item {
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
@@ -535,7 +567,7 @@ impl Item {
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
@@ -588,6 +620,9 @@ impl Item {
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -623,6 +658,7 @@ impl Item {
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
@@ -655,6 +691,7 @@ impl Item {
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
+76 -1
View File
@@ -525,6 +525,9 @@ const COMPACT_BUDGET: u64 = 32 << 20;
/// A large translucent page starts to shrink (`Renderer::shrink_translucent`)
/// this long after terrain last arrived, and `SHRINK_RETRY` after a try found
/// no room.
/// `Renderer::remove_columns` filters each store once rather than looking up every
/// section key from this many keys, or from as many as there are resident sections.
const BULK_REMOVAL_KEYS: usize = 256;
const SHRINK_SETTLE: std::time::Duration = std::time::Duration::from_secs(1);
const SHRINK_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
/// Not before then may a large translucent page start to shrink.
@@ -1024,6 +1027,8 @@ pub struct Renderer {
pack_atlas: Option<pack_atlas::PackAtlas>,
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -3857,6 +3862,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_terrain_flat: false,
separate_ao: false,
hud,
menu_blur: None,
post_effects: None,
@@ -4133,6 +4139,17 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether the running native pack gives any block a `[[lights]]` colour.
pub fn has_light_colors(&self) -> bool {
self.native
.as_ref()
.is_some_and(|native| !native.light_colors.is_empty())
}
/// The running native pack's `[[lights]]` colour of the block `name`.
pub fn light_color(&self, name: &str) -> Option<[f32; 3]> {
let native = self.native.as_ref()?;
horizon_shader_api::manifest::light_color(&native.light_colors, name)
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
@@ -4730,6 +4747,18 @@ impl Renderer {
}
}
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
let columns: Vec<(i32, i32)> = columns.into_iter().collect();
// Most of the view at once (a teleport at render distance 32 drops some 3700
// columns): a pass over each store instead of a lookup per section key, which
// took 10 ms of one frame. Far terrain fades the sections out instead.
let keys = columns.len() * self.layout.sections();
if self.lods.is_empty()
&& self.lod_cutout.is_empty()
&& keys >= self.sections.len().max(BULK_REMOVAL_KEYS)
{
self.remove_columns_at_once(&columns);
return;
}
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
@@ -4759,6 +4788,35 @@ impl Renderer {
if block_entities {
self.block_entity_revision = next_block_entity_revision();
}
self.release_meshes(released);
}
/// `remove_columns` for a large share of the resident sections: each store filtered
/// once by column.
fn remove_columns_at_once(&mut self, columns: &[(i32, i32)]) {
let gone: crate::fast_hash::FxHashSet<(i32, i32)> = columns.iter().copied().collect();
let complete = self.complete_column();
for &(x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
if self.block_entities.len() != before {
self.block_entity_revision = next_block_entity_revision();
}
let before = self.light_blocks.len();
self.light_blocks.retain(|key, _| kept(key));
self.light_revision += (before - self.light_blocks.len()) as u64;
self.section_uploaded.retain(|key, _| kept(key));
let released = self.sections.remove_columns(&gone);
self.release_meshes(released);
}
/// Gives the removed sections' quads back to their arenas; the sections themselves
/// (their sort centres) drop on a thread of their own when there are many.
fn release_meshes(&mut self, released: Vec<SectionGpu>) {
if let Some(flat) = &mut self.flat {
for id in released.iter().filter_map(|s| s.flat_id) {
flat.remove(id);
@@ -4775,6 +4833,9 @@ impl Renderer {
if let Some(arena) = &mut self.translucent_arena {
arena.release_all(released.iter().filter_map(|s| s.vertex_ranges(true).1));
}
if released.len() >= BULK_REMOVAL_KEYS {
arena::free_later(released);
}
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
@@ -5075,6 +5136,16 @@ impl Renderer {
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
self.separate_ao = false;
}
/// Sets whether shader pack terrain inputs pass vertex alpha to `hz_Color.a` (`separateAo=true`).
pub fn set_separate_ao(&mut self, separate_ao: bool) {
if self.separate_ao != separate_ao {
self.separate_ao = separate_ao;
if let Some(atlas) = &mut self.pack_atlas {
atlas.separate_ao = separate_ao;
}
}
}
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
@@ -5157,7 +5228,7 @@ impl Renderer {
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
self.pack_atlas = pack_atlas::PackAtlas::new(
let mut atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
@@ -5165,6 +5236,10 @@ impl Renderer {
layers,
self.sprite_textures[0].width(),
);
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_atlas = atlas;
self.queue.submit([encoder.finish()]);
}
// Flat draws whenever the device has them (`render` keeps them on with a pack).
@@ -23,6 +23,8 @@ pub struct NativePack {
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -136,6 +138,7 @@ impl NativePack {
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
light_colors: report.manifest.lights.clone(),
frame,
sky,
material,
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
pub sprite: u32,
/// Layers per sprite array.
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
}
impl PackAtlas {
@@ -57,6 +59,7 @@ impl PackAtlas {
columns,
sprite,
split,
separate_ao: false,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
@@ -179,7 +182,12 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
"hz_v_color.a"
} else {
"1.0"
};
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
@@ -189,7 +197,7 @@ impl PackAtlas {
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -313,4 +321,50 @@ mod tests {
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let mut atlas = PackAtlas {
texture,
view,
columns: 1,
sprite: 16,
split: 1,
separate_ao: false,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
inputs_default
.body
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
);
atlas.separate_ao = true;
let inputs_separate = atlas.terrain_inputs(false);
assert!(
inputs_separate
.body
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
);
}
}
+104 -23
View File
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
enum Source {
Color(usize),
Depth,
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
Depth1,
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
Depth2,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
Normals,
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
Specular,
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
/// `.mcmeta` says.
Custom {
@@ -81,10 +87,18 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
@@ -97,9 +111,12 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
}
}
@@ -387,6 +404,8 @@ struct Targets {
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -493,6 +512,8 @@ pub struct PackPost {
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -535,6 +556,8 @@ pub struct PackPost {
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
dummy_black: wgpu::TextureView,
dummy_normal: wgpu::TextureView,
dummy_3d: wgpu::TextureView,
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
@@ -649,11 +672,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
match name {
"depthtex0" | "gdepthtex" => Source::Depth,
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
"depthtex1" | "depthtex2" => Source::Depth1,
"depthtex1" => Source::Depth1,
"depthtex2" => Source::Depth2,
"noisetex" => Source::Noise,
"texture" | "gtexture" | "tex" => Source::Atlas,
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1075,7 +1100,7 @@ fn bindings(
Source::Atlas => geometry.is_none(),
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 => group == Group::Gbuffers,
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
_ => false,
};
@@ -1086,7 +1111,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
@@ -2080,13 +2105,20 @@ impl PackPost {
}
}
}
let depth1 = water.is_some()
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
let every_pass = || {
(passes
.iter()
@@ -2161,7 +2193,7 @@ impl PackPost {
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
@@ -2250,6 +2282,8 @@ impl PackPost {
});
}
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
passes,
@@ -2268,6 +2302,7 @@ impl PackPost {
weather_generation: 0,
hand,
depth1,
depth2,
shadow_targets,
shadow_terrain,
shadow_water,
@@ -2302,6 +2337,14 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
device,
queue,
"Shader pack normal stand-in",
1,
&flat_normal,
),
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
@@ -2507,6 +2550,27 @@ impl PackPost {
}
_ => &targets.depth,
},
Source::Depth2 => match &targets.depth2 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => match &targets.depth1 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => &targets.depth,
},
},
Source::Noise => &self.noise,
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
&self.dummy_3d
@@ -2514,6 +2578,8 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -2685,16 +2751,6 @@ impl PackPost {
return;
};
let translucent = list.translucent();
if list == TerrainList::Translucent
&& let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
@@ -2972,6 +3028,22 @@ impl PackPost {
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
self.draw_entities(device, queue, encoder, size, false);
self.draw_clouds(device, queue, encoder, size);
if let Some(t) = &self.targets {
if let Some((copy, _)) = &t.depth1 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
if let Some((copy, _)) = &t.depth2 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
}
self.draw_hand(device, queue, encoder, size, frame);
}
@@ -3362,6 +3434,11 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let depth2 = self.depth2.then(|| {
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
let view = t.create_view(&Default::default());
(t, view)
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -3395,6 +3472,7 @@ impl PackPost {
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
depth2,
mips,
});
self.forget_groups();
@@ -4078,7 +4156,10 @@ mod tests {
let main = |n| source(n, false, false, false);
assert!(main("gaux1") == Source::Color(4));
assert!(main("gdepthtex") == Source::Depth);
assert!(main("depthtex2") == Source::Depth1);
assert!(main("depthtex1") == Source::Depth1);
assert!(main("depthtex2") == Source::Depth2);
assert!(main("normals") == Source::Normals);
assert!(main("specular") == Source::Specular);
assert!(main("noisetex") == Source::Noise);
// Without the shadow pass, the shadow buffers are stand-ins.
assert!(main("shadowcolor0") == Source::Dummy);
@@ -5,7 +5,10 @@
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
streaming::in_view_distance,
};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -141,6 +144,24 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
let c = self.column_index[&(key.0, key.2)] as usize;
@@ -512,7 +533,19 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(3) {
if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
} else {
r.insert(key, step);
+23 -7
View File
@@ -1347,6 +1347,8 @@ pub struct Mesher<'a> {
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1434,7 +1436,11 @@ impl Mesher<'_> {
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let shade = (self.nether_shading, self.shader_pack_lighting);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
self.separate_ao,
);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
}
if !state.model {
@@ -1582,19 +1588,23 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
(gray, coords)
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
};
(gray, coords, brightness)
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
};
([lighting::gray(directional); 4], [coords; 4])
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
@@ -1637,12 +1647,18 @@ impl Mesher<'_> {
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
color,
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
uv[i],
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting): (bool, bool),
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
if separate_ao {
pack_rgb(rgb)
} else {
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
sprite_layers.push(layer);
}
let color = fluid_face(color, face);
let color = if separate_ao {
color
} else {
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
@@ -72,6 +72,7 @@ fn mesh(
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
@@ -132,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -158,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
// Place stone with neighbouring blocks to create AO variations.
chunk.set(8, MIN_Y + 8, 8, stone);
chunk.set(9, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: true,
separate_ao: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
assert!(!mesh.solid.is_empty());
for v in &mesh.solid {
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
// Alpha holds the AO factor, which must be non-zero.
assert!(v.color >> 24 > 0);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -209,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
+54 -4
View File
@@ -1308,6 +1308,10 @@ enum Act {
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
CopyAgentDetails,
/// AI Agent: start or stop the repro recording.
ToggleRecording,
/// AI Agent: write the repro bundle now.
ExportRepro,
}
enum WKind {
Button {
@@ -1435,6 +1439,10 @@ pub enum Event {
Credits,
/// Copy how an agent connects (the AI Agent page).
CopyAgentDetails,
/// The AI Agent page's recording button: start or stop.
ToggleRecording,
/// The AI Agent page's export button.
ExportRepro,
/// The narrator option changed (after its `Changed`): the option's
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
Narrator,
@@ -1515,6 +1523,8 @@ pub struct OptionsUi {
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// A repro recording is running: the AI Agent page's button says stop.
pub agent_recording: bool,
/// Keyboard focus: the interactive target currently focused.
focus: Option<FocusId>,
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
@@ -1573,6 +1583,7 @@ impl Default for OptionsUi {
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
agent_recording: false,
focus: None,
focus_from_keyboard: false,
can_change_value: false,
@@ -2378,6 +2389,30 @@ impl OptionsUi {
Act::CopyAgentDetails,
)),
)];
rows.push(Row::Buttons(
button(
0.0,
0.0,
150.0,
tr(if self.agent_recording {
"horizon.agent.record_stop"
} else {
"horizon.agent.record_session"
}),
Act::ToggleRecording,
),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.agent.export_repro"),
Act::ExportRepro,
)),
));
rows.push(Row::Buttons(
self.extra_button(s, crate::extras::Item::ReproKeys),
None,
));
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
rows
}
@@ -3388,6 +3423,9 @@ impl OptionsUi {
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
}
let mut tooltip = None;
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
// keeps it against that widget, not at the pointer.
let mut tooltip_rect: Option<[f32; 4]> = None;
for (wd_idx, wd) in widgets.iter().enumerate() {
if !visible(wd) {
continue;
@@ -3397,9 +3435,9 @@ impl OptionsUi {
.as_ref()
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
let start = batch.vertices.len();
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|| is_focused;
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
let hover = pointer_over || is_focused;
// A pack's option: its label within the widget (the name shortened), and on hover
// the whole label when it was, then what the pack says of it.
let shader = s.shader_options.as_ref();
@@ -3443,6 +3481,7 @@ impl OptionsUi {
.collect();
if !lines.is_empty() {
tooltip = Some(lines);
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
}
if hover && let Some(tip) = tooltip_text(s, wd) {
@@ -3457,6 +3496,7 @@ impl OptionsUi {
.collect::<Vec<_>>(),
_ => atlas.font.split(&tip, 170.0),
});
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
@@ -3628,7 +3668,10 @@ impl OptionsUi {
self.settings_search = search;
}
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
match tooltip_rect {
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
None => ui::tooltip(batch, atlas, &lines, cursor),
}
}
}
/// Mouse press/drag. Returns events for the game.
@@ -4026,6 +4069,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
}
@@ -4081,8 +4126,11 @@ impl OptionsUi {
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
} else if split_drop {
vec![Event::ButtonClick]
} else {
Vec::new()
}
@@ -4967,6 +5015,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
return events;
+633 -12
View File
@@ -43,20 +43,74 @@ pub struct SplitUi {
pub main: String,
pub names: Vec<String>,
pub apply: Option<Draft>,
/// Identify pressed: every view says which window it is in.
pub identify: bool,
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
pub names_shown: bool,
pub toggle_names: bool,
edit: Option<Draft>,
selected: Option<usize>,
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
drag: Option<(usize, Option<(f32, f32)>)>,
/// The screen's size at the last press: where a drag's release lands.
size: (f32, f32),
}
#[derive(Clone)]
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
#[derive(Clone, Debug, PartialEq)]
enum Drop {
At(Spot),
/// Place `k` among the players of a window (before its `k`th; past the last: after them).
Place(u8, usize),
}
#[derive(Clone, Debug, PartialEq)]
enum Target {
Player(usize),
Move(Spot),
Two,
Three,
Identify,
/// The players' names in their views' corners, on or off.
Names,
Revert,
Apply,
Done,
Switch,
}
type Button = ([f32; 4], String, Target, bool);
/// How a layout's square button is drawn.
enum Glyph {
Auto,
/// The panes in a 14 x 10 window, 1 px apart.
Panes(&'static [[f32; 4]]),
}
/// The glyph for a layout: 2 sharing (`splitscreen_two`: Auto, side by side, top and bottom) or
/// 3 (`splitscreen_three`: one wide on top, one tall on the left, side by side).
fn layout_glyph(three: bool, layout: u8) -> Glyph {
match (three, layout) {
(false, 0) => Glyph::Auto,
(false, 1) => Glyph::Panes(&[[0.0, 0.0, 6.5, 10.0], [7.5, 0.0, 6.5, 10.0]]),
(false, _) => Glyph::Panes(&[[0.0, 0.0, 14.0, 4.5], [0.0, 5.5, 14.0, 4.5]]),
(true, 0) => Glyph::Panes(&[
[0.0, 0.0, 14.0, 4.5],
[0.0, 5.5, 6.5, 4.5],
[7.5, 5.5, 6.5, 4.5],
]),
(true, 1) => Glyph::Panes(&[
[0.0, 0.0, 6.5, 10.0],
[7.5, 0.0, 6.5, 4.5],
[7.5, 5.5, 6.5, 4.5],
]),
(true, _) => Glyph::Panes(&[
[0.0, 0.0, 4.0, 10.0],
[5.0, 0.0, 4.0, 10.0],
[10.0, 0.0, 4.0, 10.0],
]),
}
}
pub enum Click {
Nothing,
Pressed,
@@ -69,6 +123,7 @@ impl SplitUi {
self.edit = None;
self.selected = None;
self.apply = None;
self.drag = None;
}
fn draft(&self) -> Option<&Draft> {
self.edit.as_ref().or(self.live.as_ref())
@@ -77,9 +132,12 @@ impl SplitUi {
if self.idle {
return Default::default();
}
self.draft().map_or_else(Default::default, |draft| {
split_map::project(draft, &self.monitors, &self.main, w, h)
})
self.draft()
.map_or_else(Default::default, |draft| self.map_of(draft, w, h))
}
/// `draft` on the map (the one shown, or one a drag would make).
fn map_of(&self, draft: &Draft, w: f32, h: f32) -> split_map::Map {
split_map::project(draft, &self.monitors, &self.main, w, h)
}
fn editing(&mut self) -> Option<&mut Draft> {
if self.edit.is_none() {
@@ -123,6 +181,66 @@ impl SplitUi {
spots
}
/// Where player `i` can be dropped: each place among the players of each window ("Window 1
/// (2 of 3)"), a window of its own, a new window, a monitor no window fills. Never into
/// another player's place: it joins them.
fn stops(&self, i: usize) -> Vec<(String, Drop)> {
let Some(draft) = self.draft() else {
return Vec::new();
};
let mut stops = Vec::new();
for w in &draft.windows {
let others = draft.sharing(i, w.number).len();
if others == 0 {
stops.push((tr("window", &[&w.number]), Drop::At(Spot::Window(w.number))));
}
for k in (0..=others).filter(|_| others > 0) {
let label = tr("window_place", &[&w.number, &(k + 1), &(others + 1)]);
stops.push((label, Drop::Place(w.number, k)));
}
}
stops.push((tr("new_window", &[]), Drop::At(Spot::New)));
for m in &self.monitors {
let filled = draft
.windows
.iter()
.any(|w| w.placement == Placement::Fill(m.name.clone()));
if m.name != self.main && !filled {
let label = tr("fill_monitor", &[&self.monitor_name(&m.name)]);
stops.push((label, Drop::At(Spot::Fill(m.name.clone()))));
}
}
stops
}
/// Where a tile dropped at `(x, y)` goes: the window on top there, else the monitor's (a new
/// window on Window 1's monitor, the one filling another or a new one filling it).
fn drop_spot(&self, (x, y): (f32, f32), w: f32, h: f32) -> Option<Spot> {
let draft = self.draft()?;
let (monitors, windows, _) = self.map(w, h);
let inside = |[rx, ry, rw, rh]: [f32; 4]| x >= rx && x < rx + rw && y >= ry && y < ry + rh;
if let Some((n, _)) = windows.iter().rev().find(|(_, r)| inside(*r)) {
return Some(Spot::Window(*n));
}
let m = &self.monitors[monitors.iter().position(|r| inside(*r))?];
if m.name == self.main {
return Some(Spot::New);
}
Some(
match draft
.windows
.iter()
.find(|w| w.placement == Placement::Fill(m.name.clone()))
{
Some(win) => Spot::Window(win.number),
None => Spot::Fill(m.name.clone()),
},
)
}
/// Every button: the players' tiles (window by window, as they stack), the move row, the
/// switch, Names and the layouts, and Identify, Revert, Apply and Done. Splitscreen off or
/// idle: the switch and Done.
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
let row = |out: &mut Vec<Button>, y: f32, items: Vec<(f32, String, Target, bool)>| {
let total: f32 = items.iter().map(|i| i.0 + 4.0).sum::<f32>() - 4.0;
@@ -197,10 +315,43 @@ impl SplitUi {
.collect();
row(&mut out, h - BOTTOM + 4.0, items);
}
out.push(switch);
// Names, top left: each player's name in the corner of their view.
let names = (
[6.0, 6.0, 80.0, 20.0],
tr(
"switch",
&[
&tr("names", &[]),
&tr(if self.names_shown { "on" } else { "off" }, &[]),
],
),
Target::Names,
true,
);
out.extend([switch, names]);
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
// as the layout; the label is for the control socket and the tests).
let mut footer = Vec::new();
if let Some(draft) = self.draft() {
let sharing = |k: usize| {
draft
.windows
.iter()
.any(|win| draft.players.iter().filter(|&&n| n == win.number).count() == k)
};
if sharing(2) {
let two = tr(&format!("layout.two.{}", draft.two.min(2)), &[]);
footer.push((20.0, tr("layout.two", &[&two]), Target::Two, true));
}
if sharing(3) {
let three = tr(&format!("layout.three.{}", draft.three.min(2)), &[]);
footer.push((20.0, tr("layout.three", &[&three]), Target::Three, true));
}
}
// Narrow enough for two squares in the narrowest view (320 GUI pixels).
let editing = self.changed();
footer.extend([
(64.0, tr("identify", &[]), Target::Identify, true),
(64.0, tr("revert", &[]), Target::Revert, editing),
(64.0, tr("apply", &[]), Target::Apply, editing),
done,
@@ -209,6 +360,25 @@ impl SplitUi {
out
}
/// What a button does, for the tooltip while the pointer is on it; for one that can't be
/// pressed, why not.
fn help(&self, (_, label, target, active): &Button) -> Option<String> {
let auto = self.draft().is_some_and(|d| d.two == 0);
Some(match target {
Target::Player(_) => tr("help.player", &[label]),
Target::Move(_) | Target::Done => return None,
Target::Two if auto => tr("help.layout_auto", &[label]),
Target::Two | Target::Three => tr("help.layout", &[label]),
Target::Switch if !*active => tr("switch_disabled", &[]),
Target::Switch => tr("help.switch", &[]),
Target::Identify => tr("help.identify", &[]),
Target::Names => tr("help.names", &[]),
Target::Revert if *active => tr("help.revert", &[]),
Target::Apply if *active => tr("help.apply", &[]),
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
})
}
fn warnings(&self) -> Vec<String> {
let Some(draft) = self.draft() else {
return Vec::new();
@@ -339,6 +509,23 @@ impl SplitUi {
for b in buttons.iter().filter(|b| !matches!(b.2, Target::Player(_))) {
self.draw_button(batch, atlas, cursor, b);
}
// The tile being dragged, under the pointer.
if let Some((i, Some((x, y)))) = self.drag
&& let Some(name) = self.names.get(i)
{
let label = Text::colored(name.as_str(), crate::text::YELLOW);
ui::button(
batch,
atlas,
x - 30.0,
y - 10.0,
60.0,
20.0,
&label,
true,
true,
);
}
// The monitors' names under them, where no window covers them.
for (n, [x, y, _, mh]) in monitors.into_iter().enumerate() {
let label = Text::literal(tr("monitor", &[&(n + 1)]));
@@ -360,8 +547,15 @@ impl SplitUi {
if matches!(target, Target::Player(_)) {
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
}
let glyph = self.draft().and_then(|d| match target {
Target::Two => Some(layout_glyph(false, d.two)),
Target::Three => Some(layout_glyph(true, d.three)),
_ => None,
});
// The selected player's name in yellow: picked, not just under the pointer.
let text = if selected {
let text = if glyph.is_some() {
Text::literal("")
} else if selected {
Text::colored(label.as_str(), crate::text::YELLOW)
} else {
Text::literal(label.as_str())
@@ -377,6 +571,27 @@ impl SplitUi {
*active,
hover || selected,
);
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
// control socket, which lists widgets by what they say.
if glyph.is_some() {
crate::widgets::record(label, [x, y, bw, bh], *active);
}
let (gx, gy) = (
(x + (bw - 14.0) / 2.0).floor(),
(y + (bh - 10.0) / 2.0).floor(),
);
match glyph {
Some(Glyph::Auto) => {
let a = Text::literal("A");
ui::centered_text(batch, atlas, &a, x, y + 6.0, bw, rgba(255, 255, 255, 255));
}
Some(Glyph::Panes(panes)) => {
for &[px, py, pw, ph] in panes {
batch.fill(atlas, gx + px, gy + py, pw, ph, rgba(230, 230, 230, 255));
}
}
None => {}
}
}
fn draw_notes(
&self,
@@ -417,22 +632,29 @@ impl SplitUi {
color,
);
}
if buttons.iter().any(|(r, _, t, active)| {
!active && matches!(t, Target::Switch) && ui::hovered(cursor, r[0], r[1], r[2], r[3])
}) {
// What the pointer's button does (disabled ones too, to say why), as the options screens
// show theirs. Not the selected player's tile: the pointer is still on it after the click.
let help = buttons
.iter()
.filter(|b| ui::hovered(cursor, b.0[0], b.0[1], b.0[2], b.0[3]))
.filter(|b| !matches!(b.2, Target::Player(i) if Some(i) == self.selected))
.find_map(|b| self.help(b));
if let Some(help) = help {
ui::tooltip(
batch,
atlas,
&atlas
.font
.split(&Text::literal(tr("switch_disabled", &[])), 170.0),
&atlas.font.split(&Text::literal(help), 170.0),
cursor,
);
}
}
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
self.size = (w, h);
if drag {
if let Some((_, at)) = &mut self.drag {
*at = Some((x, y));
}
return Click::Nothing;
}
// The topmost: the last drawn.
@@ -449,12 +671,25 @@ impl SplitUi {
match target {
Target::Player(i) => {
self.selected = Some(i);
self.drag = Some((i, None));
}
Target::Move(spot) => {
if let Some(i) = self.selected {
self.place(i, spot);
}
}
Target::Two => {
if let Some(draft) = self.editing() {
draft.two = (draft.two + 1) % 3;
}
}
Target::Three => {
if let Some(draft) = self.editing() {
draft.three = (draft.three + 1) % 3;
}
}
Target::Identify => self.identify = true,
Target::Names => self.toggle_names = true,
Target::Revert => self.edit = None,
Target::Apply => {
// Windows emptied in the draft close now, not as players moved.
@@ -475,6 +710,73 @@ impl SplitUi {
Click::Pressed
}
/// The mouse let go: a tile dragged somewhere goes there (a draft). True when it moved.
pub fn release(&mut self) -> bool {
let (w, h) = self.size;
let Some((i, Some(at))) = self.drag.take() else {
return false;
};
let before = self.draft().cloned();
match self.drag_target(i, at, w, h) {
Some(Drop::At(spot)) => self.place(i, spot),
Some(Drop::Place(n, k)) => self.place_at(i, n, k),
None => return false,
}
self.draft().cloned() != before
}
/// Where player `i` dropped at `at` goes, as the map shows it: the window drawn on top under
/// the pointer, and in it the place (among its players) that puts the player's view under the
/// pointer, else the nearest. Dropped back where its view was, it goes back there: a move and
/// its reverse cancel out. Outside every window: `drop_spot` (a new window, a monitor).
fn drag_target(&self, i: usize, at: (f32, f32), w: f32, h: f32) -> Option<Drop> {
let base = self.edit.as_ref().or(self.live.as_ref())?;
let open = self.open_now();
let inside = |[x, y, rw, rh]: [f32; 4]| ui::hovered(at, x, y, rw, rh);
let (_, windows, tiles) = self.map_of(base, w, h);
// Where it is now: no move.
if tiles.get(i).is_some_and(|t| inside(t.1)) {
return None;
}
let Some(&(n, _)) = windows.iter().rev().find(|(_, r)| inside(*r)) else {
let spot = self.drop_spot(at, w, h)?;
let here = base.players.get(i).copied();
return (!matches!(spot, Spot::Window(n) if Some(n) == here)).then_some(Drop::At(spot));
};
// Each place in that window, by how far its view would be from the pointer (0 inside).
let distance = |[x, y, rw, rh]: [f32; 4]| {
let dx = (x - at.0).max(at.0 - x - rw).max(0.0);
let dy = (y - at.1).max(at.1 - y - rh).max(0.0);
dx * dx + dy * dy
};
self.stops(i)
.into_iter()
.filter(|(_, drop)| matches!(drop, Drop::Place(m, _) | Drop::At(Spot::Window(m)) if *m == n))
.filter_map(|(_, drop)| {
let mut draft = base.clone();
match &drop {
Drop::At(spot) => draft.place_player(i, spot.clone()),
Drop::Place(m, k) => {
draft.place_player(i, Spot::Window(*m));
draft.put_at(i, *k);
}
}
draft.drop_empty(&open);
let view = self.map_of(&draft, w, h).2.get(i)?.1;
Some((distance(view), drop))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, drop)| drop)
}
/// Player `i` to place `k` among the players of window `n` in the draft.
fn place_at(&mut self, i: usize, n: u8, k: usize) {
self.place(i, Spot::Window(n));
if let Some(draft) = self.editing() {
draft.put_at(i, k);
}
}
fn changed(&self) -> bool {
// A free window's spot moves under the draft as it's dragged around: not a change.
let windows = |d: &Draft| -> Vec<(u8, Option<String>)> {
@@ -618,6 +920,323 @@ mod tests {
press(&mut ui, "Done");
assert_eq!(ui.selected, None);
}
#[test]
fn tiles_split_the_window_on_the_map() {
let ui = screen(2);
let (monitors, windows, tiles) = ui.map(SIZE.0, SIZE.1);
assert_eq!(monitors.len(), 2);
assert_eq!(windows.len(), 1);
// Two halves of Window 1, side by side.
let (a, b) = (tiles[0].1, tiles[1].1);
assert!((a[2] - b[2]).abs() < 0.01);
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
}
#[test]
fn a_window_over_another_takes_the_click() {
let mut ui = screen(2);
// P2 in a new window, opened over Window 1's corner.
press(&mut ui, "P2");
press(&mut ui, "New window");
press(&mut ui, "Apply");
ui.live = ui.apply.take();
press(&mut ui, "P2");
assert_eq!(ui.selected, Some(1));
}
#[test]
fn one_window_button_cycles_through_the_windows() {
let mut ui = screen(4);
// P2, P3, P4 each in a new window: Windows 2, 3, 4.
for p in ["P2", "P3", "P4"] {
press(&mut ui, p);
press(&mut ui, "New window");
}
press(&mut ui, "P2");
let windows = |ui: &SplitUi| -> Vec<String> {
labels(ui)
.into_iter()
.filter(|l| l.starts_with("Window "))
.collect()
};
assert_eq!(windows(&ui), ["Window 3"]);
press(&mut ui, "Window 3");
assert_eq!(windows(&ui), ["Window 4"]);
press(&mut ui, "Window 4");
// Past the last: back to Window 1, then on.
assert_eq!(windows(&ui), ["Window 1"]);
press(&mut ui, "Window 1");
assert_eq!(ui.edit.as_ref().unwrap().players[1], 1);
assert!(labels(&ui).contains(&"New window".to_owned()));
}
#[test]
fn windows_hold_still_until_apply() {
let mut ui = screen(3);
// Playing now: P2 in Window 2, P3 in Window 3.
let live = ui.live.as_mut().unwrap();
live.place_player(1, Spot::New);
live.place_player(2, Spot::New);
let numbers = |ui: &SplitUi| -> Vec<u8> {
ui.draft()
.unwrap()
.windows
.iter()
.map(|w| w.number)
.collect()
};
let next = |ui: &SplitUi| {
labels(ui)
.into_iter()
.find(|l| l.starts_with("Window "))
.unwrap()
};
// P2 into Window 1: Window 2 is open now, so it stays (empty) until Apply, and the
// window button's list holds still: from Window 1 on to 2, then 3.
press(&mut ui, "P2");
let to = next(&ui);
press(&mut ui, &to);
assert_eq!(ui.edit.as_ref().unwrap().players[1], 3);
press(&mut ui, "Window 1");
assert_eq!(
(numbers(&ui), next(&ui)),
(vec![1, 2, 3], "Window 2".to_owned())
);
// A window the draft adds goes as soon as nobody is in it: never open, nothing to close.
press(&mut ui, "New window");
assert_eq!(numbers(&ui), [1, 2, 3, 4]);
let to = next(&ui); // Window 1
press(&mut ui, &to);
assert_eq!(numbers(&ui), [1, 2, 3]);
// Apply closes the emptied one.
press(&mut ui, "Apply");
let applied = ui.apply.as_ref().unwrap();
assert_eq!(
applied.windows.iter().map(|w| w.number).collect::<Vec<_>>(),
[1, 3]
);
}
#[test]
fn names_says_what_the_game_shows_and_asks_it_to_change() {
let mut ui = screen(2);
ui.names_shown = true;
assert!(labels(&ui).contains(&"Names: On".to_owned()));
press(&mut ui, "Names: On");
assert!(ui.toggle_names);
// Nothing else changes: no draft.
assert_eq!(ui.edit, None);
}
#[test]
fn dropped_on_a_player_in_another_window_it_joins_that_window() {
// Adam (P1) in Window 1, Sam (P2) in Window 2: Sam dropped on Adam joins Window 1;
// Adam stays there too (they used to swap).
let mut ui = screen(2);
ui.live.as_mut().unwrap().place_player(1, Spot::New);
let (p2, p1) = (rect(&ui, "P2"), rect(&ui, "P1"));
ui.click((p2[0] + 2.0, p2[1] + 2.0), SIZE, false);
ui.click((p1[0] + 2.0, p1[1] + 2.0), SIZE, true);
assert!(ui.release());
let draft = ui.edit.as_ref().unwrap();
assert_eq!(draft.players, [1, 1]);
// Its first half: before Adam.
assert_eq!(ordered(&draft.order, 2), [1, 0]);
}
#[test]
fn any_move_and_its_reverse_leave_nothing_to_apply() {
// A fixed pseudo-random walk: layouts of 2 to 5 players over up to 3 windows, then one
// player moved anywhere (another window, a place among its players, a new window) and
// moved back to its window and its place there.
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: usize| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % n as u64) as usize
};
for case in 0..2000 {
let players = 2 + next(4);
let mut ui = screen(players);
let live = ui.live.as_mut().unwrap();
for i in 1..players {
match next(3) {
0 => live.place_player(i, Spot::New),
1 => {
let n = live.windows[next(live.windows.len())].number;
live.place_player(i, Spot::Window(n));
}
_ => {}
}
let n = live.players[i];
let k = next(live.sharing(i, n).len() + 1);
live.put_at(i, k);
}
let i = next(players);
let live = ui.live.clone().unwrap();
let home = live.players[i];
// Its place: how many of its window's players come before it.
let place = ordered(&live.order, players)
.into_iter()
.filter(|&p| live.players[p] == home)
.position(|p| p == i)
.unwrap();
let to = match next(3) {
0 => Drop::At(Spot::New),
1 => {
let n = live.windows[next(live.windows.len())].number;
let k = next(live.sharing(i, n).len() + 1);
Drop::Place(n, k)
}
_ => Drop::At(Spot::Window(live.windows[next(live.windows.len())].number)),
};
match to.clone() {
Drop::At(spot) => ui.place(i, spot),
Drop::Place(n, k) => ui.place_at(i, n, k),
}
ui.place_at(i, home, place);
assert!(
!ui.changed(),
"case {case}: {players} players, P{} to {to:?} and back\nlive {live:?}\nedit {:?}",
i + 1,
ui.edit
);
}
}
#[test]
fn the_switch_turns_off_only_once_p1_plays_alone() {
let mut ui = screen(2);
ui.on = true;
let switch = |ui: &SplitUi| {
ui.buttons(SIZE.0, SIZE.1)
.into_iter()
.find(|b| b.2 == Target::Switch)
.unwrap()
.3
};
assert!(!switch(&ui), "two players: off is greyed");
ui.names.truncate(1);
assert!(switch(&ui));
}
#[test]
fn three_in_a_window_dragged_and_back_nothing_changed() {
let mut ui = screen(3);
let centre = |r: [f32; 4]| (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
// Pressed, P3 is selected and the map makes room for the move row: measure after.
ui.click(centre(rect(&ui, "P3")), SIZE, false);
let (p3, p1) = (rect(&ui, "P3"), rect(&ui, "P1"));
// P3 to where P1's view is: it takes that place.
ui.click(centre(p1), SIZE, true);
assert!(ui.release());
assert_eq!(ordered(&ui.edit.as_ref().unwrap().order, 3)[0], 2);
assert!(rect(&ui, "P3") != p3, "it moved on the map");
// Back to where its view was: as it was.
let now = rect(&ui, "P3");
ui.click(centre(now), SIZE, false);
ui.click(centre(p3), SIZE, true);
assert!(ui.release());
assert!(!ui.changed(), "{:?}", ui.edit);
}
#[test]
fn dragged_back_where_it_was_nothing_changed() {
let mut ui = screen(2);
ui.live.as_mut().unwrap().place_player(1, Spot::New);
let window2 = ui
.map(SIZE.0, SIZE.1)
.1
.iter()
.find(|(n, _)| *n == 2)
.unwrap()
.1;
let (p2, p1) = (rect(&ui, "P2"), rect(&ui, "P1"));
ui.click((p2[0] + 2.0, p2[1] + 2.0), SIZE, false);
ui.click((p1[0] + 2.0, p1[1] + 2.0), SIZE, true);
assert!(ui.release());
// Back into Window 2, emptied in the draft: where it was, so nothing to apply.
let p2 = rect(&ui, "P2");
ui.click((p2[0] + 2.0, p2[1] + 2.0), SIZE, false);
ui.click(
(window2[0] + window2[2] / 2.0, window2[1] + window2[3] / 2.0),
SIZE,
true,
);
assert!(ui.release(), "dropped into Window 2");
assert_eq!(ui.draft().unwrap().players, [1, 2]);
assert!(!ui.changed());
}
#[test]
fn a_tile_under_a_window_is_pressed_where_it_shows() {
// The left half of a tile hidden: its right half is left.
let r = uncovered([0.0, 0.0, 10.0, 4.0], [[-5.0, -1.0, 10.0, 9.0]].into_iter());
assert_eq!(r, [5.0, 0.0, 5.0, 4.0]);
// All of it hidden: nothing to press.
let r = uncovered([0.0, 0.0, 10.0, 4.0], [[-1.0, -1.0, 20.0, 9.0]].into_iter());
assert_eq!(r[2] * r[3], 0.0);
// P3 in a new window over the middle of Window 1: P2's tile, partly under it, still
// answers at the middle of what shows of it.
let mut ui = screen(3);
press(&mut ui, "P3");
press(&mut ui, "New window");
press(&mut ui, "Apply");
ui.live = ui.apply.take();
let [x, y, w, h] = rect(&ui, "P2");
ui.click((x + w / 2.0, y + h / 2.0), SIZE, false);
assert_eq!(ui.selected, Some(1));
}
#[test]
fn drag_a_player_onto_a_monitor() {
let mut ui = screen(3);
let (monitors, _, _) = ui.map(SIZE.0, SIZE.1);
let tv = monitors[1];
let p3 = rect(&ui, "P3");
ui.click((p3[0] + 2.0, p3[1] + 2.0), SIZE, false);
ui.click((tv[0] + 5.0, tv[1] + 5.0), SIZE, true);
assert!(ui.release());
assert_eq!(ui.edit.as_ref().unwrap().players, [1, 1, 2]);
// P2 dropped on P3 (its tile fills that window): it joins P3 there, nobody swaps.
let p2 = rect(&ui, "P2");
ui.click((p2[0] + 2.0, p2[1] + 2.0), SIZE, false);
ui.click((tv[0] + 5.0, tv[1] + 5.0), SIZE, true);
assert!(ui.release());
assert_eq!(ui.edit.as_ref().unwrap().players, [1, 2, 2]);
// A click without moving only selects.
let p1 = rect(&ui, "P1");
ui.click((p1[0] + 2.0, p1[1] + 2.0), SIZE, false);
assert!(!ui.release());
}
#[test]
fn layout_squares_show_while_a_window_has_that_many() {
let labels = |ui: &SplitUi| labels(ui).into_iter().filter(|l| l.contains("sharing"));
// Three in Window 1: only the 3's square, drawn as its panes.
let mut ui = screen(3);
assert_eq!(
labels(&ui).collect::<Vec<_>>(),
["3 sharing: One wide on top"]
);
press(&mut ui, "3 sharing: One wide on top");
assert_eq!(ui.edit.as_ref().unwrap().three, 1);
assert!(matches!(layout_glyph(true, 1), Glyph::Panes(p) if p.len() == 3));
// Two: only the 2's, Auto a letter, explained under the pointer.
let ui = screen(2);
assert_eq!(labels(&ui).collect::<Vec<_>>(), ["2 sharing: Auto"]);
assert!(matches!(layout_glyph(false, 0), Glyph::Auto));
let buttons = ui.buttons(SIZE.0, SIZE.1);
let two = buttons.iter().find(|b| b.2 == Target::Two).unwrap();
assert_eq!(
ui.help(two).as_deref(),
Some("2 sharing: Auto (side by side when the window is wide). Press to change.")
);
// Alone: neither.
assert_eq!(labels(&screen(1)).count(), 0);
}
#[test]
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
// The language table is process-wide. Run this test alone in a child so
@@ -672,6 +1291,8 @@ mod tests {
lang::set(lang::Language::load(&[], "fr_fr"));
assert!(labels(&ui).contains(&"Nouvelle fenêtre".to_owned()));
assert!(labels(&ui).contains(&"Appliquer".to_owned()));
assert!(labels(&ui).contains(&"Noms : Désactivé".to_owned()));
assert!(labels(&ui).contains(&"2 par fenêtre : Auto".to_owned()));
assert!(
labels(&ui).contains(&"Élodie %s".to_owned()),
"player names remain literal"
@@ -234,6 +234,7 @@ impl<G: ChunkGenerator> Saves for G {
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
separate_ao: bool,
zoom: BiomeZoom,
blend_radius: i32,
smooth_lighting: bool,
@@ -465,6 +466,9 @@ pub struct Streaming {
/// A shader pack lights the faces: sections mesh without face shading
/// (`Mesher::shader_pack_lighting`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
/// (`Mesher::separate_ao`).
pub separate_ao: bool,
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
pub biome_zoom: BiomeZoom,
pub biome_blend: i32,
@@ -878,6 +882,7 @@ impl<G: ChunkGenerator> Jobs<G> {
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
separate_ao: settings.separate_ao,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
@@ -979,6 +984,7 @@ impl Streaming {
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
biome_zoom: BiomeZoom::new(0),
biome_blend: 2,
smooth_lighting: true,
@@ -1920,6 +1926,7 @@ impl Streaming {
MeshSettings {
nether: self.nether_shading,
shader_pack_lighting: self.shader_pack_lighting,
separate_ao: self.separate_ao,
zoom: self.biome_zoom,
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
+114 -9
View File
@@ -868,7 +868,77 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
if lines.is_empty() {
return;
}
crate::widgets::frame_tooltip(lines);
let (w, h) = tooltip_size(atlas, lines);
let mut x = cursor.0 + 12.0;
let mut y = cursor.1 - 12.0;
if x + w > batch.width {
x = (cursor.0 - 24.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
}
tooltip_box(batch, atlas, lines, x, y, w, h);
}
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
/// pointer where it is, and its tooltip stays against the widget at `rect`.
pub fn tooltip_menu(
batch: &mut HudBatch,
atlas: &HudAtlas,
lines: &[Text],
cursor: (f32, f32),
rect: [f32; 4],
) {
if lines.is_empty() {
return;
}
let (w, h) = tooltip_size(atlas, lines);
let (x, y) = menu_tooltip_position(
(batch.width as i32, batch.height as i32),
(cursor.0 as i32, cursor.1 as i32),
rect.map(|v| v as i32),
(w.ceil() as i32, h.ceil() as i32),
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
/// `MAX_OVERLAP_WITH_WIDGET` 3 and `MAX_DISTANCE_TO_WIDGET` 5 of the vertical
/// offset from the widget's top or bottom edge.
pub fn menu_tooltip_position(
screen: (i32, i32),
cursor: (i32, i32),
rect: [i32; 4],
size: (i32, i32),
) -> (i32, i32) {
let [_, y, _, height] = rect;
let height = height.max(1);
let mut pos = (cursor.0 + 12, cursor.1);
if pos.0 + size.0 > screen.0 - 5 {
pos.0 = (cursor.0 - 12 - size.0).max(9);
}
pos.1 += 3;
// `bottom() + 3 + getOffset(0, 0, height)` is the widget's edge, `height`
// further down; below that the tooltip goes under it, else over it.
let below = size.1 + 6;
if y + 2 * height + below <= screen.1 - 5 {
pos.1 += widget_offset(pos.1, y, height);
} else {
pos.1 -= below + widget_offset(pos.1, y + height, height);
}
pos
}
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
/// The distance over the height is an int division in vanilla, so the lerp
/// is `height - 3` until the pointer is a whole height away, then 5.
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
}
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
let w = lines
.iter()
.map(|l| atlas.font.width_text(l))
@@ -878,14 +948,18 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
} else {
10.0 * lines.len() as f32
};
let mut x = cursor.0 + 12.0;
let mut y = cursor.1 - 12.0;
if x + w > batch.width {
x = (cursor.0 - 24.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
}
(w, h)
}
fn tooltip_box(
batch: &mut HudBatch,
atlas: &HudAtlas,
lines: &[Text],
x: f32,
y: f32,
w: f32,
h: f32,
) {
crate::widgets::frame_tooltip(lines);
nine_slice(
batch,
atlas,
@@ -2086,4 +2160,35 @@ mod tests {
assert!(!delete_words(&mut v, 1));
assert_eq!(v, "stone");
}
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
/// widget's top or bottom edge.
#[test]
fn a_menu_tooltip_stays_by_its_widget() {
let screen = (427, 240);
let widget = [100, 100, 150, 20];
let size = (80, 10);
// Pointer at the widget's top edge: the tooltip drops 3 px, then
// `getOffset`'s int division gives `height - 3` = 17 more.
assert_eq!(
menu_tooltip_position(screen, (100, 100), widget, size),
(112, 120)
);
// Near the right edge its left side mirrors to the pointer's left.
assert_eq!(
menu_tooltip_position(screen, (400, 100), widget, size),
(308, 120)
);
// A whole widget height from the edge, the offset is 5.
assert_eq!(
menu_tooltip_position(screen, (100, 117), widget, size),
(112, 125)
);
// A widget too close to the bottom: the tooltip goes above it,
// 203 - (10 + 6) - 17.
assert_eq!(
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
(112, 170)
);
}
}
@@ -330,11 +330,31 @@
"horizon.splitscreen.setup.status.alone": "No other local players.",
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
"horizon.splitscreen.setup.note.select": "Click a player to choose its window.",
"horizon.splitscreen.setup.note.select": "Click a player, or drag it to a window or a monitor.",
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
"horizon.splitscreen.setup.note.place": "Where should %s play?",
"horizon.splitscreen.setup.this_player": "this player",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
"horizon.splitscreen.setup.window_place": "Window %s (%s of %s)",
"horizon.splitscreen.setup.names": "Names",
"horizon.splitscreen.setup.identify": "Identify",
"horizon.splitscreen.setup.layout.two": "2 sharing: %s",
"horizon.splitscreen.setup.layout.three": "3 sharing: %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Side by side",
"horizon.splitscreen.setup.layout.two.2": "Top and bottom",
"horizon.splitscreen.setup.layout.three.0": "One wide on top",
"horizon.splitscreen.setup.layout.three.1": "One tall on the left",
"horizon.splitscreen.setup.layout.three.2": "Side by side",
"horizon.splitscreen.setup.help.player": "Click or drag %s to move them.",
"horizon.splitscreen.setup.help.layout_auto": "%s (side by side when the window is wide). Press to change.",
"horizon.splitscreen.setup.help.layout": "%s. Press to change.",
"horizon.splitscreen.setup.help.switch": "Splitscreen for this computer, on or off.",
"horizon.splitscreen.setup.help.identify": "Each player's view shows which window it is in.",
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
@@ -365,5 +385,20 @@
"horizon.agent.off": "Off: agents can't reach this game.",
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"horizon.extras.repro_keys": "Recording shortcuts",
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
"horizon.agent.record_session": "Start Recording Session",
"horizon.agent.record_stop": "Stop Recording Session",
"horizon.agent.export_repro": "Export Repro Bundle",
"horizon.repro.started": "Recording started; F3+R stops it and writes the bundle.",
"horizon.repro.stopped": "Recording stopped: %s",
"horizon.repro.bundle": "Repro bundle: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "There is no recording to export yet.",
"horizon.repro.replay_started": "Replay started: %s"
}
@@ -313,7 +313,7 @@
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur pour choisir sa fenêtre.",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur, ou faites-le glisser vers une fenêtre ou un écran.",
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
"horizon.splitscreen.setup.this_player": "ce joueur",
@@ -323,6 +323,26 @@
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.splitscreen.setup.window_place": "Fenêtre %s (%s sur %s)",
"horizon.splitscreen.setup.names": "Noms",
"horizon.splitscreen.setup.identify": "Identifier",
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
"horizon.splitscreen.setup.layout.three.1": "Un haut à gauche",
"horizon.splitscreen.setup.layout.three.2": "Côte à côte",
"horizon.splitscreen.setup.help.player": "Cliquez sur %s ou faites-le glisser pour le déplacer.",
"horizon.splitscreen.setup.help.layout_auto": "%s (côte à côte si la fenêtre est large). Appuyez pour changer.",
"horizon.splitscreen.setup.help.layout": "%s. Appuyez pour changer.",
"horizon.splitscreen.setup.help.switch": "L'écran partagé sur cet ordinateur, activé ou désactivé.",
"horizon.splitscreen.setup.help.identify": "La vue de chaque joueur indique sa fenêtre.",
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
@@ -219,6 +219,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
@@ -129,6 +129,7 @@ fn scene(
let mut mesher = Mesher {
nether_shading: row["nether"].as_bool().unwrap_or(false),
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables,
@@ -204,6 +204,9 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
@@ -454,6 +457,23 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
// Repro recordings
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
["horizon-ctl record-status"], &[], mcp: true, local: false),
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]", "replay a recording, best effort",
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. Only input and movement commands replay (never world, network or account ones). --in-place feeds the input into the world already open (a server recording needs it); --keep-world leaves the copied world behind when it ends. --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("keep-world", Ty::Bool, "leave the copied world in saves/"), flag("timeout", Ty::Int, "give up after this many seconds (default 300)")], mcp: true, local: false),
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
];
pub fn find(name: &str) -> Option<&'static Cmd> {
@@ -314,13 +314,26 @@ fn usage(name: &str) -> String {
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
}
/// Screenshot paths are written by the game process: make them absolute here.
/// Paths are written by the game process: make them absolute here.
fn absolute_paths(words: &mut [String]) {
if words.first().map(String::as_str) == Some("screenshot")
&& let Some(path) = words.get_mut(1)
&& let Ok(cwd) = std::env::current_dir()
{
*path = cwd.join(&*path).display().to_string();
let Ok(cwd) = std::env::current_dir() else {
return;
};
match words.first().map(String::as_str) {
Some("screenshot") => {
if let Some(path) = words.get_mut(1) {
*path = cwd.join(&*path).display().to_string();
}
}
// `repro-export [NAME] [PATH]`: the second positional is the zip.
Some("repro-export") => {
if let Some(path) = words.get_mut(2)
&& (path.contains('/') || path.ends_with(".zip"))
{
*path = cwd.join(&*path).display().to_string();
}
}
_ => {}
}
}
@@ -4,20 +4,14 @@
//! shared table, run through the same `Session` as the CLI.
use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
}
fn tools() -> Vec<Value> {
@@ -143,15 +137,10 @@ impl Session {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -171,52 +160,13 @@ impl Session {
}
pub fn serve(session: &mut Session) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| session.handle(method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -255,28 +205,66 @@ mod tests {
#[test]
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
"tools/call",
&json!({ "name": "goto", "arguments": { "x": 1 } }),
let mut app = |method: &str, params: &Value| s.handle(method, params);
let server = server();
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
// A client that opens with the handshake revision.
let init = server
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
// A modern client: stateless, and the same tools.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let params = json!({ "_meta": Value::Object(meta) });
let list = server
.handle_line(&line("tools/list", params), &mut app)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
let r = server
.handle_line(
&line(
"tools/call",
json!({ "name": "goto", "arguments": { "x": 1 } }),
),
&mut app,
)
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(
r["content"][0]["text"]
r["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("y is required")
);
// No game: a connection error, reported as a tool error.
let r = s
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
let r = server
.handle_line(
&line("tools/call", json!({ "name": "state", "arguments": {} })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(r["result"]["isError"], true);
// Methods this server does not have are the protocol's to refuse.
assert_eq!(
server
.handle_line(&line("nope/nope", json!({})), &mut app)
.unwrap()["error"]["code"],
json!(-32601)
);
}
}
@@ -29,6 +29,17 @@ pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
Some((block_entity_nbt::id(data)?, data))
}
/// `JukeboxBlock.setPlacedBy`: the item's `minecraft:block_entity_data`
/// carries a `RecordItem`, so the placed jukebox holds a record and its
/// `has_record` state has to say so. Vanilla reads the item's data, not the
/// block entity the placement just filled.
pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
@@ -602,6 +613,40 @@ mod tests {
assert!(!op_warning(&chest));
}
/// `JukeboxBlock.setPlacedBy` looks for a `RecordItem` in the placed
/// item's block entity data, whatever the type it names: only that key
/// turns the box's `has_record` on.
#[test]
fn a_jukebox_item_with_a_record_in_its_data_says_so() {
let jukebox = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:jukebox", 1)
}
};
// A plain jukebox, and one whose data is only the type id: no record.
assert!(!carries_record(&crate::items::ItemStack::new(
"minecraft:jukebox",
1
)));
assert!(!carries_record(&jukebox("{id:\"minecraft:jukebox\"}")));
assert!(!carries_record(&jukebox(
"{id:\"minecraft:jukebox\",Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}"
)));
// Creative's Ctrl + Pick Block on a jukebox holding a disc.
assert!(carries_record(&jukebox(
"{id:\"minecraft:jukebox\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
// The key alone counts: `TypedEntityData.contains` never checks the
// type, and the state it sets is the one the block is placed as.
assert!(carries_record(&jukebox(
"{id:\"minecraft:chest\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
+17
View File
@@ -28,6 +28,10 @@ pub enum DebugAction {
NetworkCharts,
LightmapTexture,
ImprovedTransparency,
/// F3+R: start a repro recording, or stop it and write its bundle.
RecordSession,
/// F3+E: write the bundle now.
ExportRepro,
}
/// The mappings `handleDebugKeys` tests, in its order.
@@ -69,6 +73,14 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
),
];
/// Horizon's own debug keys: fixed, so they never enter the options table that
/// vanilla parity compares (`MAPPINGS`). Off until the AI Agent page enables
/// them; `Game` decides that in `debug_action`.
const HORIZON_ACTIONS: [(&str, DebugAction); 2] = [
("key.keyboard.r", DebugAction::RecordSession),
("key.keyboard.e", DebugAction::ExportRepro),
];
/// GLFW's key actions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyAction {
@@ -215,6 +227,11 @@ impl DebugKeys {
did |= host.debug_action(action, shift);
}
}
for (name, action) in HORIZON_ACTIONS {
if key == name {
did |= host.debug_action(action, shift);
}
}
did
}
/// `KeyboardHandler.tick`.
+100 -21
View File
@@ -2384,26 +2384,37 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
const THROWABLE_ITEMS: [&str; 5] = [
"minecraft:snowball",
"minecraft:egg",
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:lingering_potion",
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
if let ProjectileKind::Thrown(t) = kind {
return Some(t.item);
/// The item a throwable flies as: its stack (a potion's), the mod type's
/// item, or the item its entity type is named after.
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
match kind {
ProjectileKind::Thrown(t) => Some(t.item),
ProjectileKind::Potion(stack) => Some(&stack.item),
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
}
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
/// A potion flies as the item its entity type names, which is the item a
/// summoned one has when it names none (`getDefaultItem`).
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
match THROWABLES.iter().find(|(_, i)| *i == id) {
Some((kind, _)) => Some(kind.clone()),
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
if !THROWABLE_ITEMS.contains(&id) {
return crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown);
}
match id {
"minecraft:snowball" => Some(ProjectileKind::Snowball),
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
_ => Some(ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new(id, 1),
))),
}
}
@@ -2441,13 +2452,14 @@ pub fn throwable_to_nbt(
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
// the loaded item stays (a blue egg's, a renamed one's, a potion's
// effects); when there is none, `getItem`'s default is written.
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
let stack = match &p.kind {
ProjectileKind::Potion(stack) => (**stack).clone(),
_ => crate::items::ItemStack::new(item, 1),
};
put(&mut tag, "Item", item_to_nbt(&stack));
}
Some(tag)
}
@@ -2455,7 +2467,14 @@ pub fn throwable_to_nbt(
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = throwable_kind(&kind(tag)?)?;
let mut kind = throwable_kind(&kind(tag)?)?;
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
// and all; a tag without one keeps `getItem`'s default.
if let ProjectileKind::Potion(stack) = &mut kind
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
{
**stack = item;
}
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
@@ -4789,6 +4808,66 @@ mod fireball_nbt_tests {
assert!(back.get("Item").is_none());
}
#[test]
fn a_summoned_potion_flies_as_the_item_its_compound_names() {
// `ThrowableItemProjectile.getDefaultItem`: the item its entity type is
// named after, so a bare summon is a splash potion with no effects and
// the chunk saves it (ThrowableProjectile is not transient).
for id in ["minecraft:splash_potion", "minecraft:lingering_potion"] {
let bare = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
]);
let p = throwable_from_nbt(&bare, None).unwrap();
assert_eq!(p.kind.id(), id);
assert_eq!(
p.kind,
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(item.potion(), None);
}
// `readAdditionalSaveData`: the `Item` it flies as, `potion_contents`
// and all, so what it splashes is the potion's.
let harming = Tag::compound([
("id", Tag::String("minecraft:splash_potion".into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:splash_potion", 1)
.with_potion("minecraft:harming"),
),
),
]);
let p = throwable_from_nbt(&harming, None).unwrap();
let ProjectileKind::Potion(stack) = &p.kind else {
panic!("a potion");
};
assert_eq!(stack.potion(), Some("minecraft:harming"));
// The stack stays in the tag it was loaded from.
let tag = throwable_to_nbt(&p, [1; 4], None, Some(&harming)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(item.potion(), Some("minecraft:harming"));
// A lingering potion is its own entity type.
let lingering = Tag::compound([
("id", Tag::String("minecraft:lingering_potion".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:lingering_potion", 1)
.with_potion("minecraft:poison"),
),
),
]);
let p = throwable_from_nbt(&lingering, None).unwrap();
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
+119 -28
View File
@@ -142,6 +142,7 @@ mod hand;
mod headless;
mod ime;
mod initial_spawn;
mod input_trace;
mod integrated;
mod interactions;
mod item_draw;
@@ -206,6 +207,7 @@ mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_inventories;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
mod native_menus;
@@ -269,6 +271,7 @@ mod remote_stations;
mod remote_vehicles;
mod render_control;
mod render_tick;
mod repro;
mod respawn_anchor;
mod saves_control;
mod saving;
@@ -393,6 +396,7 @@ pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
@@ -855,6 +859,16 @@ struct Hit {
face: [i32; 3],
point: [f64; 3],
}
impl Hit {
/// The block the clicked face leads to (`pos.relative(direction)`).
fn beside(&self) -> [i32; 3] {
[
self.pos[0] + self.face[0],
self.pos[1] + self.face[1],
self.pos[2] + self.face[2],
]
}
}
/// What a falling block's tick did (`FallingBlockEntity.tick`).
enum FallStep {
Falling,
@@ -2908,6 +2922,10 @@ struct Game<'a> {
ui_wait: ui_control::UiWait,
/// What happened, for `events` and `wait-for` (game/events.rs).
events: events::Events,
/// Who caused each input event: a person, an agent or the game (game/input_trace.rs).
input_trace: input_trace::InputTrace,
/// A repro recording, export or replay (game/repro.rs).
repro: repro::Repro,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
/// The last water plane `water_plane` found, kept while none is in reach.
@@ -6582,6 +6600,7 @@ impl Game<'_> {
Some(BlockEntity::StructureBlock(_)) => self.structure_block_placed(key),
Some(BlockEntity::JigsawBlock(_)) => self.jigsaw_block_placed(key),
Some(BlockEntity::TestBlock(_)) => self.test_block_placed(key),
Some(BlockEntity::Jukebox(_)) => self.jukebox_placed(target, &stack),
Some(BlockEntity::Sign(sign)) if !sign.waxed => self.open_sign_editor(key, true),
_ => {}
}
@@ -7296,6 +7315,7 @@ impl Game<'_> {
None => 0.0,
};
self.ticks += 1;
self.repro_tick();
// An in-frame LAN host's player has the entity id its guests see it by.
if !self.serving()
&& let (Some(host), Some(p)) = (&self.lan, self.player.as_mut())
@@ -8138,7 +8158,12 @@ impl Game<'_> {
// MouseHandler.grabMouse never captures an inactive window, and vanilla
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
// are screens over the world that keep the pointer.
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
if capture
&& (!self.focused
|| self.server_packs.prompt.is_some()
|| self.dialog_open()
|| self.command_prompt.is_some())
{
return;
}
// The container screen's press state belongs to the screen: a screen
@@ -8434,7 +8459,10 @@ impl Game<'_> {
} else {
self.hud_layers(size)
};
self.draw_dialog(&mut batch);
// Vanilla's confirmation replaces the dialog as the screen.
if self.command_prompt.is_none() {
self.draw_dialog(&mut batch);
}
self.draw_pack_prompt(&mut batch);
self.draw_code_of_conduct(&mut batch);
self.draw_link_prompt(&mut batch);
@@ -9166,6 +9194,10 @@ impl Game<'_> {
}
}
fn craft_result(&self) -> Option<ItemStack> {
// A server's result slot is the server's (`CraftingMenu.slotChangedCraftingGrid`).
if let Some(r) = &self.remote {
return r.craft_result.clone();
}
self.craft_recipe().map(|(_, result)| result)
}
/// The recipe id and result of the crafting grid.
@@ -12534,6 +12566,8 @@ impl Game<'_> {
std::thread::sleep(Duration::from_millis(1));
}
}
// A repro recording is finished and bundled before the process may go away.
self.repro_on_exit();
if self.title.is_some() || self.saved_for_exit {
return Ok(());
}
@@ -12560,6 +12594,9 @@ impl Game<'_> {
Ok(())
}
fn close_world(&mut self) -> Result<()> {
// A recording cannot cross into another world, and a replay feeding the
// title screen is nonsense.
self.repro_world_closed();
self.clear_waypoints();
self.post_effects.clear();
self.spectated_post_effect = None;
@@ -12836,24 +12873,29 @@ impl Game<'_> {
}
}
/// `BucketItem.use`'s place position: a water bucket fills the clicked
/// block itself when that block is a liquid container (a waterloggable
/// stair, slab, fence, ...); every other bucket empties into the block the
/// clicked face leads to. `SolidBucketItem` places like a block item, into
/// a replaceable clicked block.
/// block itself when that block can hold it (`canBlockContainFluid`: a
/// waterloggable stair, slab, fence, ..., but not a double slab, kelp or
/// seagrass); every other bucket empties into the block the clicked face
/// leads to. `SolidBucketItem` places like a block item, into a
/// replaceable clicked block.
pub(super) fn bucket_target(&self, content: BucketContent, hit: &Hit) -> [i32; 3] {
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let behaviours = self.res.behaviours();
if content == BucketContent::Powder {
if self.res.facts().get(clicked).replaceable {
return hit.pos;
}
} else if content.is_water() && self.res.behaviours().is_liquid_container(clicked) {
} else if content.is_water()
&& behaviours.is_liquid_container(clicked)
&& behaviours.can_place_liquid(
clicked,
horizon_world::fluid::FluidKind::Water,
self.creative,
)
{
return hit.pos;
}
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
hit.beside()
}
/// The cauldron at `pos`, and whether water is above it
/// (`CauldronInteractions.isUnderWater`).
@@ -13305,18 +13347,24 @@ impl Game<'_> {
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
// `Player.isShiftKeyDown`: only air takes a fluid under Shift.
let [x, y, z] = hit.beside();
let beside = Some(BlockPos::new(x, y, z));
// `Player.isShiftKeyDown`: a block that refuses the fluid (any
// non-air block under Shift) passes it to the block beside, where
// Shift no longer counts.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, pos, content, sound, sneaking, creative)
b.bucket_empty(
level, ticks, pos, beside, content, sound, sneaking, creative,
)
});
self.process_level_events()?;
if !emptied {
let Some(emptied) = emptied else {
return Ok(UseResult::Fail);
}
};
if self.dim == Dim::Nether && content.is_water() {
self.evaporation_smoke(target);
self.evaporation_smoke([emptied.x, emptied.y, emptied.z]);
}
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
// actor's client already played the empty / evaporate sound from
@@ -14659,7 +14707,14 @@ impl Game<'_> {
if self.enough_slept() {
if self.ui.rules.bool("advance_time") {
let t = self.world.day_time;
let target = t + 24000 - t.rem_euclid(24000);
let mut target = t + 24000 - t.rem_euclid(24000);
// SleepFinishedTimeEvent: the mods may move the wake-up time,
// never before now (`setTimeAddition` refuses a lower one).
let mut asked = target;
self.native_sleep_finished_time(self.dim.id(), t, &mut asked);
if asked >= t {
target = asked;
}
let mut cancel = false;
self.native_time_skip(self.dim.id(), t, target, "sleep", &mut cancel);
if !cancel {
@@ -15602,7 +15657,7 @@ impl Game<'_> {
lines.extend(g.lines);
let on_top = g.on_top;
let colored_lights = if self.title.is_none() {
self.native_frame_visuals(camera, &mut lines)
self.native_frame_visuals(camera, alpha, &mut lines)
} else {
Vec::new()
};
@@ -16846,7 +16901,7 @@ impl ApplicationHandler for Game<'_> {
} else {
None
};
self.handle_window_event(event_loop, id, event);
self.handle_window_event(event_loop, id, event, input_trace::InputOrigin::User);
if let Some((event, modifiers)) = key {
self.native_input_key(&event, modifiers);
}
@@ -16863,6 +16918,10 @@ impl ApplicationHandler for Game<'_> {
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
// MouseHandler.onMove with the pointer grabbed; applied once per frame.
self.note_input(
input_trace::InputOrigin::User,
input_trace::TraceKind::Motion { x, y },
);
self.mouse.on_move(0.0, 0.0, x, y, true, true);
self.last_input = Instant::now();
}
@@ -16888,6 +16947,7 @@ impl Game<'_> {
event_loop: &dyn EventLoopCtl,
id: WindowId,
mut event: WindowEvent,
origin: input_trace::InputOrigin,
) {
// A headless game has no window: its events (the frame, the control socket's input)
// carry `WindowId::dummy()`.
@@ -16910,6 +16970,9 @@ impl Game<'_> {
{
*button = MouseButton::Right;
}
// Who caused it, before the event is handled: the trace wants the
// window's own keys, never the ones the socket made up.
self.trace_window_event(origin, &event);
if let WindowEvent::KeyboardInput { event, .. } = &event
&& let PhysicalKey::Code(key) = event.physical_key
{
@@ -17808,9 +17871,9 @@ impl Game<'_> {
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.command_prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
&& let PhysicalKey::Code(key) = event.physical_key
{
self.answer_command_prompt(false);
self.command_prompt_key(key);
}
}
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
@@ -18380,13 +18443,23 @@ impl Game<'_> {
// Offscreen frames don't wait for the window to be drawn: an
// occluded window (a locked macOS session) gets no redraws.
let id = w.id();
self.handle_window_event(event_loop, id, WindowEvent::RedrawRequested);
self.handle_window_event(
event_loop,
id,
WindowEvent::RedrawRequested,
input_trace::InputOrigin::Game,
);
} else {
w.request_redraw();
}
} else if self.options.headless {
// The headless loop paces the frames itself (`run_headless`).
self.handle_window_event(event_loop, WindowId::dummy(), WindowEvent::RedrawRequested);
self.handle_window_event(
event_loop,
WindowId::dummy(),
WindowEvent::RedrawRequested,
input_trace::InputOrigin::Game,
);
return;
}
// A window the compositor hides gets no `RedrawRequested` at all, so
@@ -18819,6 +18892,8 @@ impl<'a> Game<'a> {
flight: None,
ui_wait: Default::default(),
events: Default::default(),
input_trace: Default::default(),
repro: Default::default(),
agent: agent::Agent::default(),
view_frustum: None,
entity_culler: None,
@@ -19526,7 +19601,7 @@ struct CoreTables {
item_renderer: horizon_client::item_render::ItemRenderer,
visibility: horizon_world::block_render::FaceVisibility,
gameplay: GameplayTables,
biomes: Biomes,
biomes: Arc<Biomes>,
environment: Environment,
collision: BlockCollisionRegistry<'static>,
}
@@ -19550,6 +19625,7 @@ fn load_core(
headless: bool,
parallel: bool,
progress: &Progress,
launch_world: Option<&str>,
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
let assets = match startup::time("core.assets", || Assets::load(root)) {
Ok(assets) => assets,
@@ -19594,7 +19670,17 @@ fn load_core(
.map_err(|e| e.to_string())
});
let biomes = Step::spawn(scope, parallel, || {
startup::time("core.biomes", || Biomes::load(root)).map_err(|e| e.to_string())
let biomes = Arc::new(
startup::time("core.biomes", || Biomes::load(root))
.map_err(|e| e.to_string())?,
);
// A launch that names a world: its data preload needs only the
// block registry and the biomes, so it starts here rather than
// after the block models and facts (~250 ms later).
if let Some(folder) = launch_world {
world_loading::preload_launch_world(folder, root, registry, biomes.clone());
}
Ok::<_, String>(biomes)
});
let environment = Step::spawn(scope, parallel, || {
startup::time("core.environment", || Environment::load(root))
@@ -19733,7 +19819,13 @@ fn load_resources(
let (core, loaded_gui) = loop {
let (ui, core) = std::thread::scope(|scope| {
let core = Step::spawn(scope, parallel, || {
startup::time("core", || load_core(&root, headless, parallel, progress))
// A failed pack starts core again from the game's own
// resources, which the world's preload must read: with packs
// it waits for the core that loaded.
let early = launch_world.filter(|_| root == vanilla_root);
startup::time("core", || {
load_core(&root, headless, parallel, progress, early)
})
});
let ui = load_ui(&root).map(|ui| offer(ui, &root));
(ui, core.join())
@@ -19778,7 +19870,6 @@ fn load_resources(
} = core;
let assets = Arc::new(assets);
let render = Arc::new(render);
let biomes = Arc::new(biomes);
if let Some(folder) = launch_world {
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
}
+21 -5
View File
@@ -466,7 +466,7 @@ impl Game<'_> {
json!({ "kind": kind, "block": block, "entity": entity })
}
fn state_value(&self) -> Value {
pub(super) fn state_value(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
"position": [p.position.x, p.position.y, p.position.z],
@@ -497,7 +497,7 @@ impl Game<'_> {
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
});
json!({
let mut state = json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
@@ -527,7 +527,13 @@ impl Game<'_> {
"looking_at": self.looking_at(),
"busy": self.agent.task.as_ref().map(|t| t.name),
"ticks": self.ticks,
})
});
// Who caused the input, and whether a repro recording runs.
let mut input = self.input_trace.summary();
self.repro
.fill_summary(&mut input, self.input_trace.events());
state["input"] = input;
state
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
@@ -664,7 +670,7 @@ impl Game<'_> {
}
}
fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
pub(super) fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
let Some(p) = &self.player else {
return json!({ "entities": [] });
};
@@ -718,7 +724,7 @@ impl Game<'_> {
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
}
fn inventory_value(&self) -> Value {
pub(super) fn inventory_value(&self) -> Value {
let armor = ["head", "chest", "legs", "feet"];
json!({
"selected": self.selected + 1,
@@ -1009,6 +1015,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
if i == 0 {
self.handle_window_event(
@@ -1019,6 +1026,7 @@ impl Game<'_> {
state: ElementState::Pressed,
button,
},
super::input_trace::InputOrigin::Agent,
);
}
}
@@ -1030,6 +1038,7 @@ impl Game<'_> {
state: ElementState::Released,
button,
},
super::input_trace::InputOrigin::Agent,
);
Ok(())
}
@@ -1050,6 +1059,11 @@ impl Game<'_> {
Ok(Handled::Deferred)
}
/// A deferred agent action (goto, break, wait...) is still running.
pub(super) fn agent_busy(&self) -> bool {
self.agent.task.is_some()
}
/// Agent commands; `Handled::No` when `words[0]` is not one of them.
pub(super) fn agent_command(
&mut self,
@@ -1589,6 +1603,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
for _ in 0..steps.abs() as u32 {
self.handle_window_event(
@@ -1599,6 +1614,7 @@ impl Game<'_> {
delta: MouseScrollDelta::LineDelta(0.0, steps.signum()),
phase: TouchPhase::Moved,
},
super::input_trace::InputOrigin::Agent,
);
}
ok(json!({ "ok": true, "screen": self.screen_label() }))
@@ -96,11 +96,15 @@ impl Game<'_> {
.to_string_lossy()
.into_owned(),
});
status_lines(
let mut lines = status_lines(
server,
self.agent_last.map(|t| t.elapsed().as_secs()),
self.options.control.is_some() && !self.agent_control_on,
)
);
if let Some(line) = self.repro.agent_line(self.input_trace.events()) {
lines.push(line);
}
lines
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
+13 -1
View File
@@ -146,10 +146,22 @@ impl Game<'_> {
}
ClickEvent::OpenUrl(url) => self.click_url(url),
// closeContainerOnServer, then clickCommandAction with no screen
// after the command: the book closes and the command runs.
// after the command: the book closes and the command runs. A
// lectern's screen goes with its menu; a book is still the screen
// a confirmation returns to.
ClickEvent::RunCommand(command) => {
let book = if lectern.is_none() {
self.book.take()
} else {
None
};
self.close_book();
self.click_command(&command);
if let Some(book) = book {
self.command_prompt_returns_to(super::remote_chat::Behind::Book(Box::new(
book,
)));
}
}
// Screen.defaultHandleClickEvent: copied, the book stays open.
ClickEvent::CopyToClipboard(value) => {
+108 -18
View File
@@ -1,7 +1,7 @@
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
//! `/unmute <player>` (or `unmute` on the console).
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
use super::{Game, dedicated, lan::LanHost};
use horizon_chat_filter::Action;
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
@@ -9,8 +9,11 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::{literal, translatable};
use horizon_server_files::user_lists::{BanEntry, NameAndId};
/// The operator level `/unmute` needs, as `/ban`'s.
const UNMUTE_LEVEL: u8 = 3;
/// The operator level the mute commands need, as `/ban`'s.
const MUTE_LEVEL: u8 = 3;
/// The commands of `mute_command`.
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
@@ -155,27 +158,114 @@ impl Game<'_> {
allowed
}
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
/// for the others.
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
/// A mute command from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command for
/// the others.
pub(super) fn guest_mute_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (permission, by) = (g.permission, g.name.clone());
let reply = if permission < UNMUTE_LEVEL {
translatable("command.unknown.command", &[])
} else if words.len() != 2 {
literal("/unmute <player>")
let replies = if permission < MUTE_LEVEL {
vec![translatable("command.unknown.command", &[])]
} else {
literal(&self.unmute(host, words[1], &by))
let words: Vec<&str> = words.iter().copied().filter(|w| !w.is_empty()).collect();
self.mute_command(host, &words, &by)
.iter()
.map(|line| literal(line))
.collect()
};
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: reply,
overlay: false,
}),
for content in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content,
overlay: false,
}),
);
}
}
/// `/mute <player> [reason]`, `/tempmute <player> <duration> [reason]`, `/mutelist` and
/// `/unmute <player>` (`words`, the command first) by `by`: the lines to answer.
pub(super) fn mute_command(
&mut self,
host: &mut LanHost,
words: &[&str],
by: &str,
) -> Vec<String> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec!["There are no mutes".into()];
}
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
for m in mutes {
let by = m.by.as_deref().unwrap_or("the chat filter");
let until = m.until.map_or_else(
|| "until unmuted".into(),
|u| {
let left = horizon_chat_filter::duration_text(u - now);
format!("for {}", left.replacen(' ', " more ", 1))
},
);
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
}
lines
}
["unmute", name] => vec![self.unmute(host, name, by)],
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d")],
},
["mute", ..] => vec!["/mute <player> [reason]".into()],
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
["unmute", ..] => vec!["/unmute <player>".into()],
_ => vec!["/mutelist".into()],
}
}
/// Mutes `name` for `time` milliseconds (`None`: until `/unmute`), logged; what to answer.
fn mute(
&mut self,
host: &mut LanHost,
name: &str,
time: Option<i64>,
reason: Option<String>,
by: &str,
) -> String {
// A player here, else one the server has seen (the user cache).
let user = host
.hub
.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))
.map(|g| (g.uuid, g.name.clone()))
.or_else(|| self.resolve_name(name).map(|u| (u.id, u.name)));
let Some((uuid, name)) = user else {
return format!("No player was found named {name}");
};
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
);
let why = reason
.as_deref()
.map(|r| format!(": {r}"))
.unwrap_or_default();
host.hub.mute(uuid, &name, until, by, reason);
if self.options.dedicated.is_some() {
dedicated::log(
"INFO",
&format!("[chat-filter] {by} muted {name} {what}{why}"),
);
}
format!("Muted {name} {what}{why}")
}
/// Lifts `name`'s mute, logged; what to answer.
+6 -8
View File
@@ -1369,11 +1369,14 @@ impl Game<'_> {
let count = Self::int(ctx, "count").unwrap_or(1);
let targets = self.entities(ctx, "targets", true)?;
let max_stack = self.items().get(&id).map_or(64, |i| i.max_stack) as i32;
if count > max_stack * 100 {
// A native mod's item can stack far past vanilla's 99: saturate rather
// than overflow on a size near i32::MAX.
let limit = max_stack.saturating_mul(100);
if count > limit {
return Err(CommandError::new(Text::translate(
"commands.give.failed.toomanyitems",
&[
Text::literal((max_stack * 100).to_string()),
Text::literal(limit.to_string()),
self.item_display_name(&id),
],
)));
@@ -1828,12 +1831,7 @@ impl Game<'_> {
} else {
pos[1]
};
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
Some([
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
(-(dy.atan2(horizontal).to_degrees())) as f32,
])
Some(world_cmds::look_at([pos[0], eye, pos[2]], f))
};
// A server's player in another level than the source's (`execute
// in`, the console's Overworld) moves there (ServerPlayer.teleport
@@ -1,6 +1,6 @@
//! `/recipe`, `/damage`, `/rotate`, `/swing`, `/playsound`, `/stopsound`,
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
use super::{Arg, Context, Game, R, Target, err};
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
use horizon_client::{
mobs::{Damage, DamageKind, Who},
scoreboard_cmd::{Holder, Tracker},
@@ -1612,11 +1612,38 @@ fn text_error(key: &str, args: &[Text]) -> horizon_client::command::CommandError
horizon_client::command::CommandError::new(Text::translate(key, args))
}
/// `Entity.lookAt`: yaw and pitch from `from` towards `to`.
fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
/// `Entity.lookAt`: yaw and pitch from `from` towards `to`, the yaw wrapped
/// to -180..180 as `Mth.wrapDegrees` does.
pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
let (dx, dy, dz) = (to[0] - from[0], to[1] - from[1], to[2] - from[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
let pitch = (-(dy.atan2(horizontal).to_degrees())) as f32;
let yaw = (dz.atan2(dx).to_degrees() - 90.0) as f32;
[yaw.rem_euclid(360.0), pitch]
// `Entity.lookAt` turns radians to degrees with the float constant
// `57.2957763671875`, not `Math.toDegrees`.
let pitch = wrap_degrees((-(dy.atan2(horizontal) * 57.295_776_367_187_5)) as f32);
let yaw = (dz.atan2(dx) * 57.295_776_367_187_5) as f32 - 90.0;
[wrap_degrees(yaw), pitch]
}
#[cfg(test)]
mod tests {
use super::look_at;
fn assert_angle(got: f32, want: f32) {
assert!((got - want).abs() < 1.0e-4, "{got} is not {want}");
}
/// `Entity.lookAt` wraps the yaw with `Mth.wrapDegrees`, so a target to
/// the north-west turns the entity to 135 degrees, not to -225, and a
/// target due north leaves -180 rather than 180.
#[test]
fn a_facing_target_wraps_the_yaw_to_180() {
let at = [0.0, 64.0, 0.0];
assert_angle(look_at(at, [-1.0, 64.0, -1.0])[0], 135.0);
assert_angle(look_at(at, [0.0, 64.0, -1.0])[0], -180.0);
assert_angle(look_at(at, [1.0, 64.0, 0.0])[0], -90.0);
assert_angle(look_at(at, [0.0, 64.0, 1.0])[0], 0.0);
assert_angle(look_at(at, [-1.0, 64.0, 0.0])[0], 90.0);
// The pitch is the elevation, unchanged; its own wrap is a no-op.
assert_angle(look_at(at, [0.0, 65.0, 1.0])[1], -45.0);
}
}
+38 -5
View File
@@ -157,24 +157,31 @@ impl Game<'_> {
}
horizon_client::widgets::end_frame(self.frames);
self.narrate_screen();
self.poll_repro(event_loop);
self.advance_agent();
for request in self.ui_wait.ready() {
self.answer(request, event_loop);
}
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
// Once per line received, not once per handler: a deferred `ui`
// comes back through `ui_wait` and must not be traced twice.
self.note_socket_line(&request.line);
self.agent_command_seen();
self.answer(request, event_loop);
if self.pending_save.is_some() {
break;
}
}
if self.control.is_some() {
// A recording made from the F3 keys or the Settings page has no socket,
// and still wants its events.
if self.control.is_some() || self.repro.recording_active() {
self.watch_events();
}
if self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
{
self.ui.options.agent_status = self.agent_status_lines();
self.ui.options.agent_recording = self.repro.recording_active();
}
}
@@ -273,6 +280,15 @@ impl Game<'_> {
result
}
/// A recorded control line fed back by a replay: not new input, so the
/// trace does not see it, and its reply has no one waiting.
pub(super) fn replay_line(&mut self, line: &str, event_loop: &dyn EventLoopCtl) {
let (reply, _) = std::sync::mpsc::channel();
if let Err(e) = self.control_command(line, &reply, event_loop) {
log::warn!("Horizon: replay: {line}: {e}");
}
}
fn control_command_inner(
&mut self,
line: &str,
@@ -301,6 +317,12 @@ impl Game<'_> {
if let Some(value) = self.events_command(&words, reply)? {
return Ok(value);
}
if let Some(value) = self.input_trace.command(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.repro_command(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
@@ -333,6 +355,8 @@ impl Game<'_> {
.chain(super::events::HELP)
.chain(super::saves_control::HELP)
.chain(super::pad::HELP)
.chain(super::input_trace::HELP)
.chain(super::repro::HELP)
.collect::<Vec<_>>() }))),
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
"section-draws" => {
@@ -429,10 +453,10 @@ impl Game<'_> {
)
.into());
}
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
if key == KeyCode::Escape && arg(2)? != "up" {
self.answer_command_prompt(false);
if arg(2)? != "up" {
self.command_prompt_key(key);
}
return ok();
}
@@ -1151,7 +1175,7 @@ impl Game<'_> {
json!({ "ok": true, "players": players })
}
fn control_status(&self) -> Value {
pub(super) fn control_status(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
"position": [p.position.x, p.position.y, p.position.z],
@@ -1321,6 +1345,11 @@ impl Game<'_> {
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
// Who caused the input, and whether a repro recording runs.
let mut input = self.input_trace.summary();
self.repro
.fill_summary(&mut input, self.input_trace.events());
status["input"] = input;
status
}
}
@@ -1351,6 +1380,10 @@ fn released_modifier(line: &str) -> Option<KeyCode> {
fn key_code(name: &str) -> Option<KeyCode> {
let name = name.to_ascii_lowercase();
// The game's own names (`key.keyboard.w`), which a recording writes.
if let Some(code) = horizon_client::keybinds::key_code(&name) {
return Some(code);
}
let letters = [
KeyCode::KeyA,
KeyCode::KeyB,
+30 -77
View File
@@ -4638,84 +4638,37 @@ impl Game<'_> {
self.combat.explosion_in_water = false;
result?;
}
(
ProjectileKind::Potion {
effect,
ticks,
amplifier,
..
},
_,
) => {
// AbstractThrownPotion.applySplash: within 4 blocks, scaled by distance.
if let Some(pv) = self.player_view() {
let d2 = (pv.position.x - at.x).powi(2)
+ (pv.position.y - at.y).powi(2)
+ (pv.position.z - at.z).powi(2);
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
match *effect {
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
}
// AbstractThrownPotion.onHit, then onHitAsPotion from the stack it
// flies as: the splash happens where the hit was, a lingering
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
// and the potion that lands names the hurt it deals.
(ProjectileKind::Potion(stack), hit) => {
let block = match hit {
Hit::Block { pos, face, .. } => Some((
pos,
Direction::approximate_nearest(Vec3::new(
f64::from(face[0]),
f64::from(face[1]),
f64::from(face[2]),
)),
)),
_ => None,
};
let mob = match hit {
Hit::Entity {
who: Who::Player, ..
} => self.player.as_ref().map(|pv| pv.position),
Hit::Entity {
who: Who::Mob(id), ..
} => self.mobs.iter().find(|m| m.id == id).map(|m| m.position),
_ => None,
};
let owner = self.potion_owner(p.owner);
self.splash(stack, at, mob, block, p.age, &owner);
if stack.item.as_ref() != "minecraft:experience_bottle" {
let item = stack.item.to_string();
self.native_potion_splash(&item, [at.x, at.y, at.z]);
}
// A witch's healing potions reach the raiders around the splash.
if matches!(
*effect,
"minecraft:instant_health" | "minecraft:regeneration"
) {
let center = at;
for m in &mut self.mobs {
let d2 = m.position.distance_squared(center);
if !m.alive()
|| d2 >= 16.0
|| !horizon_client::mobs::raider::is_raider(m.species())
{
continue;
}
let scale = 1.0 - d2.sqrt() / 4.0;
if *effect == "minecraft:instant_health" {
let heal = (4 << *amplifier) as f64 * scale;
m.health = (m.health + heal as f32).min(m.attrs.max_health);
} else {
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if t > 20 {
m.add_effect(effect, t, *amplifier);
}
}
}
}
self.audio.play(
"minecraft:entity.splash_potion.break",
Some(atp),
1.0,
self.world.random.next_float() * 0.1 + 0.9,
);
}
(ProjectileKind::LlamaSpit, Hit::Entity { who, .. }) => match who {
Who::Player => {
@@ -710,6 +710,18 @@ impl Game<'_> {
self.toggle_debug_options();
true
}
// Horizon's own: a repro recording for a bug report (game/repro.rs).
// Off unless the AI Agent page enables the shortcuts.
DebugAction::RecordSession if !self.settings.extras.repro_keys => false,
DebugAction::ExportRepro if !self.settings.extras.repro_keys => false,
DebugAction::RecordSession => {
self.toggle_recording_key();
true
}
DebugAction::ExportRepro => {
self.export_repro_key();
true
}
// Screens and overlays Horizon does not draw yet (the profiler and
// its chart, the texture dump): the keys are used up as in vanilla.
DebugAction::DumpDynamicTextures
+8 -4
View File
@@ -1457,13 +1457,17 @@ impl Game<'_> {
}
"whitelist" => self.console_whitelist(state, &rest),
// The chat filter's (game/chat_filter.rs), on servers that filter.
"unmute"
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
command
if super::chat_filter::MUTE_COMMANDS.contains(&command)
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
{
if let Some(mut host) = self.lan.take() {
let reply = self.unmute(&mut host, rest[0], "Server");
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
let replies = self.mute_command(&mut host, &words, "Server");
self.lan = Some(host);
self.console_reply(lit(&reply));
for reply in replies {
self.console_reply(lit(&reply));
}
}
}
"ban" if !rest.is_empty() => {
+23
View File
@@ -4,6 +4,7 @@
//! connecting screen in configuration), its inputs, and the actions its
//! buttons send back.
use super::Game;
use super::remote_chat::Behind;
use horizon_client::{
Result,
clipboard::EditShortcut,
@@ -35,6 +36,9 @@ struct Open {
selecting: Option<usize>,
}
/// A dialog a clicked command closed, kept for its confirmation's no.
pub(super) struct ClosedDialog(Box<Open>);
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
enum Field {
Line(ui::EditBox),
@@ -252,6 +256,12 @@ impl Game<'_> {
}
}
/// The confirmation's no: the dialog again, with what was typed in it.
pub(super) fn reopen_dialog(&mut self, dialog: ClosedDialog) {
self.dialogs.open = Some(*dialog.0);
self.dialogs.waiting = None;
}
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
@@ -266,6 +276,14 @@ impl Game<'_> {
}
return Ok(());
}
// openSendConfirmationWindow's `currentScreen`: a command that asks
// first still has this dialog to go back to.
let closing = match (&click, &after) {
(Some(Click::RunCommand(_)), AfterAction::Close | AfterAction::WaitForResponse) => {
self.dialogs.open.take()
}
_ => None,
};
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -284,6 +302,11 @@ impl Game<'_> {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
self.send_unattended_command(&command);
if let Some(open) = closing {
self.command_prompt_returns_to(Behind::Dialog(ClosedDialog(Box::new(
open,
))));
}
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
+2 -2
View File
@@ -1273,10 +1273,10 @@ impl Game<'_> {
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
b.bucket_empty(level, ticks, target_pos, None, content, sound, false, false)
});
self.process_level_events()?;
if !emptied {
if emptied.is_none() {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
}
if self.dim == super::Dim::Nether
@@ -12,57 +12,95 @@ impl Game<'_> {
self.luminance
.get_or_init(|| Luminance::new(self.res.facts(), self.pack.assets.registry))
}
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
/// The light sources this frame while the extra is on: the player's and mobs' held items and
/// fire, and glowing dropped items, each with the item that gives the light (none for fire).
pub(super) fn dynamic_light_sources(&self, alpha: f64) -> Vec<(Source, Option<String>)> {
let mut sources = Vec::new();
if !self.settings.extras.dynamic_lights {
self.dynamic_lights = out;
return;
return sources;
}
let lum = self.luminance();
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
let mut sources = Vec::new();
// The brighter of two hands with what it holds, or fire if it outshines both.
let brighter = |items: [Option<&str>; 2], on_fire: bool| {
let (level, item) = items
.into_iter()
.flatten()
.map(|i| (lum.of(i), Some(i)))
.max_by_key(|(level, _)| *level)
.unwrap_or((0, None));
if on_fire && BURNING > level {
(BURNING, None)
} else {
// Only a lit source names its item: nothing is allocated for a dark one.
(level, item.filter(|_| level > 0).map(str::to_owned))
}
};
if let Some(p) = &self.player
&& !p.spectator
{
let feet = self
.previous_position
.lerp(p.position, self.clock.camera_fraction());
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
let (level, item) = brighter(
[
self.stack_in(self.selected).map(|s| &*s.item),
self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item),
],
p.fire_ticks > 0,
);
// About where the hands are.
sources.push(Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
});
sources.push((
Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
},
item,
));
}
for m in self.mobs.iter().filter(|m| !m.removed) {
let level = held(m.extra.main_hand.as_deref())
.max(held(m.extra.off_hand.as_deref()))
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
let (level, item) = brighter(
[m.extra.main_hand.as_deref(), m.extra.off_hand.as_deref()],
m.fire_ticks > 0,
);
if level == 0 {
continue;
}
let at = m.previous.lerp(m.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
});
sources.push((
Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
},
item,
));
}
for item in &self.dropped {
let level = lum
.of(&item.stack.item)
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
for dropped in &self.dropped {
let (level, item) =
brighter([Some(&*dropped.stack.item), None], dropped.fire_ticks > 0);
if level == 0 {
continue;
}
let at = item.previous.lerp(item.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
});
let at = dropped.previous.lerp(dropped.position, alpha);
sources.push((
Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
},
item,
));
}
sources
}
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
/// [`dynamic_light::MAX`] of them.
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
let sources: Vec<Source> = self
.dynamic_light_sources(alpha)
.into_iter()
.map(|(s, _)| s)
.collect();
dynamic_light::select(&sources, camera, &mut out);
self.dynamic_lights = out;
}
+10 -1
View File
@@ -64,6 +64,14 @@ impl Events {
}
self.log.push_back(event);
}
/// An event from outside the once-a-turn comparison (`user-input`).
pub(super) fn note(&mut self, tick: u64, event: Value) {
self.push(tick, event);
}
/// Whether a `wait-for` is waiting: its agent is still watching.
pub(super) fn has_waits(&self) -> bool {
!self.waits.is_empty()
}
fn since(&self, seq: u64) -> impl Iterator<Item = &Value> {
self.log
.iter()
@@ -181,7 +189,7 @@ impl Game<'_> {
}
}
fn loaded(&self) -> bool {
pub(super) fn loaded(&self) -> bool {
self.title.is_none()
&& self.player.is_some()
&& self.streaming.pending() + self.pending_uploads.len() == 0
@@ -207,6 +215,7 @@ impl Game<'_> {
None => self.ui.chat.recent_lines(new_chat),
};
for event in diff(before, &now, &lines) {
self.repro.note_event(self.ticks, &event);
self.events.push(self.ticks, event);
}
}
@@ -0,0 +1,675 @@
//! Who caused an input event: a person at the keyboard (`user`), an agent on the
//! control socket (`agent`), or the game itself (`game`, made-up input no one
//! asked for). Agents read this with `input-log` and the `input` section of
//! `state` and `status`, so a script can tell that a person took over instead of
//! mistaking their keystrokes for its own. A `user-input` event also lands in
//! `events` (and so in `wait-for event user-input`) when a person steps in
//! while an agent was driving.
//!
//! The events are also what a repro recording writes to `inputs.jsonl`
//! (game/repro.rs).
use super::{Game, Screen};
use horizon_client::{Result, keybinds};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::VecDeque,
sync::mpsc::Sender,
time::{Duration, Instant},
};
use winit::{
event::{ElementState, MouseScrollDelta, WindowEvent},
keyboard::PhysicalKey,
};
pub(super) const HELP: &[&str] = &[
"input-log [--since SEQ] [--limit N] [--origin user|agent|game] recent input and who caused it",
];
/// Input events kept for `input-log`.
const KEPT: usize = 512;
/// A person's input this soon after an agent command is an intrusion.
const INTRUSION_GAP: Duration = Duration::from_secs(5);
/// At most one `user-input` event per this long, so holding a key does not flood.
const INTRUSION_QUIET: Duration = Duration::from_secs(2);
/// Who caused an input event.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum InputOrigin {
/// The window's own events: someone at this computer's keyboard, mouse or pad.
User,
/// The control socket: `horizon-ctl`, an MCP client, or a script.
Agent,
/// Neither: input the game made up itself (pad menus, UI tasks).
Game,
}
impl InputOrigin {
pub(super) fn name(self) -> &'static str {
match self {
InputOrigin::User => "user",
InputOrigin::Agent => "agent",
InputOrigin::Game => "game",
}
}
fn index(self) -> usize {
self as usize
}
fn parse(name: &str) -> Option<Self> {
match name {
"user" => Some(InputOrigin::User),
"agent" => Some(InputOrigin::Agent),
"game" => Some(InputOrigin::Game),
_ => None,
}
}
}
/// One input event, as `input-log` shows it and the recording writes it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct TraceEvent {
pub seq: u64,
pub tick: u64,
/// Milliseconds since the first event this run; the clock a replay keeps.
pub ms: u64,
pub origin: InputOrigin,
#[serde(flatten)]
pub kind: TraceKind,
}
/// What was pressed, clicked or typed. Key and button names are the game's own
/// (`key.keyboard.w`, `key.mouse.left`), so a recording can feed them back.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub(super) enum TraceKind {
Key {
key: String,
/// `down`, `up` or `repeat` (the system's auto-repeat).
action: String,
},
Mouse {
button: String,
action: String,
},
Wheel {
x: f64,
y: f64,
/// The window gave lines (a notched wheel) rather than pixels.
lines: bool,
},
/// The pointer's screen position, kept while a screen is open: GUI clicks
/// are replayed at it.
Cursor {
x: f64,
y: f64,
},
/// Relative mouse motion, which turns the view.
Motion {
x: f64,
y: f64,
},
Pad {
button: String,
action: String,
},
/// A control-socket line: `key`, `mouse`, `goto`, anything an agent ran.
Command {
line: String,
},
}
impl TraceKind {
pub(super) fn name(&self) -> &'static str {
match self {
TraceKind::Key { .. } => "key",
TraceKind::Mouse { .. } => "mouse",
TraceKind::Wheel { .. } => "wheel",
TraceKind::Cursor { .. } => "cursor",
TraceKind::Motion { .. } => "motion",
TraceKind::Pad { .. } => "pad",
TraceKind::Command { .. } => "command",
}
}
}
/// Commands that only read. Polling them must not flood the ring or make an
/// agent that is merely watching look like it acted.
const READ_ONLY: &[&str] = &[
"state",
"status",
"events",
"wait-for",
"input-log",
"record-status",
"record-list",
"looking-at",
"entities",
"block",
"blocks",
"light",
"inventory",
"screen",
"menu",
"messages",
"worlds",
"servers",
"options-get",
"stats",
"logs",
"ui",
];
/// Commands that steer the session rather than the game: recording them as
/// input would put `record-stop` in a replay it must not run.
const SESSION: &[&str] = &[
"record-start",
"record-stop",
"record-replay",
"repro-export",
"quit",
"launch",
"mcp",
"bench",
"sleep",
];
/// Lines whose arguments can carry someone's data: the trace keeps the command
/// name and drops the rest.
const PRIVATE_ARGUMENTS: &[&str] = &[
"chat", "command", "type", "signedit", "connect", "book", "publish",
];
/// Whether `line` only reads.
fn is_read_only(line: &str) -> bool {
line.split_whitespace()
.next()
.is_some_and(|name| READ_ONLY.contains(&name))
}
/// Whether `line` steers the session, not the game.
fn is_session_command(line: &str) -> bool {
line.split_whitespace()
.next()
.is_some_and(|name| SESSION.contains(&name))
}
/// The line as the trace keeps it: a command whose arguments are private loses
/// them (`chat <redacted>`).
fn redact_command(line: &str) -> String {
match line.split_whitespace().next() {
Some(name) if PRIVATE_ARGUMENTS.contains(&name) => format!("{name} <redacted>"),
_ => line.to_owned(),
}
}
/// The recent input, the counters and the recording's line writer.
#[derive(Default)]
pub(super) struct InputTrace {
seq: u64,
log: VecDeque<TraceEvent>,
counts: [u64; 3],
last: Option<InputOrigin>,
last_user: Option<Instant>,
last_agent: Option<Instant>,
/// The person's events since the last agent command.
user_since_agent: u64,
quiet_until: Option<Instant>,
/// The first event's wall clock and tick.
started: Option<(Instant, u64)>,
/// The recording's `inputs.jsonl` writer; `None` when nothing records.
writer: Option<Sender<String>>,
written: u64,
/// A replay feeds events back: they are not new input and are not recorded.
suppress: bool,
}
impl InputTrace {
/// Record one event; whether it is a person intruding on an agent's run.
/// `agent_active`: an agent is watching or waiting, not only recently heard.
fn record(
&mut self,
tick: u64,
origin: InputOrigin,
kind: TraceKind,
agent_active: bool,
) -> bool {
if self.suppress {
return false;
}
let now = Instant::now();
let started = self.started.get_or_insert((now, tick)).0;
let ms = now.duration_since(started).as_millis() as u64;
let intrusion = origin == InputOrigin::User
&& (agent_active
|| self
.last_agent
.is_some_and(|t| now.duration_since(t) < INTRUSION_GAP))
&& self.quiet_until.is_none_or(|t| now >= t);
if intrusion {
self.quiet_until = Some(now + INTRUSION_QUIET);
}
self.seq += 1;
let event = TraceEvent {
seq: self.seq,
tick,
ms,
origin,
kind,
};
if self.writer.is_some() {
let mut failed = false;
match serde_json::to_string(&event) {
Ok(line) => {
if let Some(writer) = &self.writer {
failed = writer.send(line).is_err();
}
}
Err(e) => log::warn!("Horizon: input trace: {e}"),
}
if failed {
self.writer = None;
log::warn!("Horizon: input trace stopped: its writer is gone");
} else {
self.written += 1;
}
}
if self.log.len() == KEPT {
self.log.pop_front();
}
self.log.push_back(event);
self.counts[origin.index()] += 1;
self.last = Some(origin);
match origin {
InputOrigin::User => {
self.last_user = Some(now);
self.user_since_agent += 1;
}
InputOrigin::Agent => {
self.last_agent = Some(now);
self.user_since_agent = 0;
}
InputOrigin::Game => {}
}
intrusion
}
/// An agent line that is not input (a poll, a wait): it still means an
/// agent is watching, so a person's next key is an intrusion.
pub(super) fn agent_activity(&mut self) {
if self.suppress {
return;
}
self.last_agent = Some(Instant::now());
self.user_since_agent = 0;
}
/// The `input` section of `state` and `status`.
pub(super) fn summary(&self) -> Value {
json!({
"last_origin": self.last.map(InputOrigin::name),
"counts": {
"user": self.counts[InputOrigin::User.index()],
"agent": self.counts[InputOrigin::Agent.index()],
"game": self.counts[InputOrigin::Game.index()],
},
"user_since_agent": self.user_since_agent,
"last_user_age_s": self
.last_user
.map(|t| (t.elapsed().as_secs_f64() * 10.0).round() / 10.0),
"events": self.seq,
})
}
/// Start writing the events to a recording.
pub(super) fn start_file(&mut self, writer: Sender<String>) {
self.writer = Some(writer);
self.written = 0;
}
/// Stop writing; how many events the recording received.
pub(super) fn stop_file(&mut self) -> u64 {
self.writer = None;
std::mem::take(&mut self.written)
}
/// How many events this run has seen.
pub(super) fn events(&self) -> u64 {
self.seq
}
/// A replay's own events are not new input.
pub(super) fn set_suppress(&mut self, suppress: bool) {
self.suppress = suppress;
}
/// `input-log`.
pub(super) fn command(&self, words: &[&str]) -> Result<Option<Value>> {
if words[0] != "input-log" {
return Ok(None);
}
let mut since = 0;
let mut limit = usize::MAX;
let mut origin = None;
let mut i = 1;
while i < words.len() {
let value = words
.get(i + 1)
.ok_or_else(|| format!("{} takes a value", words[i]))?;
match words[i] {
"--since" => since = value.parse().map_err(|_| "--since takes a number")?,
"--limit" => limit = value.parse().map_err(|_| "--limit takes a number")?,
"--origin" => {
origin = Some(
InputOrigin::parse(value).ok_or("--origin takes user, agent or game")?,
)
}
other => return Err(format!("input-log: unknown argument {other}").into()),
}
i += 2;
}
let mut events: Vec<&TraceEvent> = self
.log
.iter()
.filter(|e| e.seq > since && origin.is_none_or(|o| e.origin == o))
.collect();
if events.len() > limit {
events.drain(..events.len() - limit);
}
Ok(Some(json!({
"events": events,
"last": self.seq,
"input": self.summary(),
})))
}
}
impl Game<'_> {
/// An agent's control line: input unless it only reads, which still counts
/// as the agent watching.
pub(super) fn note_socket_line(&mut self, line: &str) {
if is_read_only(line) || is_session_command(line) {
self.input_trace.agent_activity();
return;
}
self.note_input(
InputOrigin::Agent,
TraceKind::Command {
line: redact_command(line),
},
);
}
/// Whether anything can read the trace: the control socket (agent control
/// on), the repro shortcuts, or a recording under way. Otherwise every key
/// and mouse motion skips it, so a player who uses none of them pays
/// nothing per input event.
fn tracing_input(&self) -> bool {
self.control.is_some() || self.settings.extras.repro_keys || self.repro.recording_active()
}
/// Record an input event and, when a person steps into an agent's run, tell
/// every watcher once.
pub(super) fn note_input(&mut self, origin: InputOrigin, kind: TraceKind) {
if !self.tracing_input() {
return;
}
let kind_name = kind.name();
// An agent waiting on a task or a wait, or a client still connected, is
// watching even when its last line was minutes ago.
let agent_active = self.control.as_ref().is_some_and(|c| c.clients() > 0)
|| self.agent_busy()
|| self.events.has_waits();
if self
.input_trace
.record(self.ticks, origin, kind, agent_active)
{
let event = json!({ "event": "user-input", "input": kind_name });
self.repro.note_event(self.ticks, &event);
self.events.note(self.ticks, event);
log::info!(
"Horizon: {kind_name} input from a person while an agent was driving \
(input-log has the run)"
);
}
}
/// Keep the window events that are input. Agent-made window events are left
/// out: the control-socket line that caused them is recorded instead.
pub(super) fn trace_window_event(&mut self, origin: InputOrigin, event: &WindowEvent) {
if origin == InputOrigin::Agent || !self.tracing_input() {
return;
}
match event {
WindowEvent::KeyboardInput {
event,
is_synthetic,
..
} => {
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = keybinds::key_name(code) else {
return;
};
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => "up",
(_, true) => "repeat",
(ElementState::Pressed, false) => "down",
};
// The platform made this one up (an IME's dead key): not a person.
let origin = if *is_synthetic {
InputOrigin::Game
} else {
origin
};
self.note_input(
origin,
TraceKind::Key {
key: name.to_owned(),
action: action.to_owned(),
},
);
}
WindowEvent::MouseInput { state, button, .. } => self.note_input(
origin,
TraceKind::Mouse {
button: keybinds::mouse_name(*button),
action: match state {
ElementState::Pressed => "down".to_owned(),
ElementState::Released => "up".to_owned(),
},
},
),
WindowEvent::MouseWheel { delta, .. } => {
let (x, y, lines) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(*x), f64::from(*y), true),
MouseScrollDelta::PixelDelta(p) => (p.x, p.y, false),
};
self.note_input(origin, TraceKind::Wheel { x, y, lines });
}
// GUI clicks land where the pointer is; in the world the pointer is
// grabbed and turns the view through `device_event` instead.
WindowEvent::CursorMoved { position, .. } if self.screen != Screen::Playing => {
self.note_input(
origin,
TraceKind::Cursor {
x: position.x,
y: position.y,
},
);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(name: &str) -> TraceKind {
TraceKind::Key {
key: name.to_owned(),
action: "down".to_owned(),
}
}
#[test]
fn origins_round_trip() {
for origin in [InputOrigin::User, InputOrigin::Agent, InputOrigin::Game] {
assert_eq!(InputOrigin::parse(origin.name()), Some(origin));
}
assert_eq!(InputOrigin::parse("nobody"), None);
}
#[test]
fn the_ring_keeps_the_newest_and_counts_every_origin() {
let mut trace = InputTrace::default();
for i in 0..=(KEPT as u64 + 10) {
trace.record(i, InputOrigin::User, key("key.keyboard.w"), false);
}
assert_eq!(trace.log.len(), KEPT);
assert_eq!(trace.seq, KEPT as u64 + 11);
assert_eq!(trace.log.front().unwrap().seq, 12);
assert_eq!(trace.counts[InputOrigin::User.index()], KEPT as u64 + 11);
}
#[test]
fn a_person_after_an_agent_is_an_intrusion_once() {
let mut trace = InputTrace::default();
trace.record(1, InputOrigin::Agent, key("key.keyboard.w"), false);
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), false));
// The quiet period keeps a held key from flooding the event log.
assert!(!trace.record(3, InputOrigin::User, key("key.keyboard.a"), false));
assert_eq!(trace.user_since_agent, 2);
// An agent command resets the count.
trace.record(4, InputOrigin::Agent, key("key.keyboard.w"), false);
assert_eq!(trace.user_since_agent, 0);
}
#[test]
fn no_intrusion_without_an_agent() {
let mut trace = InputTrace::default();
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
// The game's own input is not a person either.
trace.record(2, InputOrigin::Agent, key("key"), false);
assert!(!trace.record(3, InputOrigin::Game, key("key.keyboard.w"), false));
}
#[test]
fn input_log_filters_since_origin_and_limit() {
let mut trace = InputTrace::default();
for (i, origin) in [
InputOrigin::Agent,
InputOrigin::User,
InputOrigin::Game,
InputOrigin::User,
]
.into_iter()
.enumerate()
{
trace.record(i as u64, origin, key("key.keyboard.w"), false);
}
let all = trace.command(&["input-log"]).unwrap().unwrap();
assert_eq!(all["events"].as_array().unwrap().len(), 4);
assert_eq!(all["last"], 4);
let users = trace
.command(&["input-log", "--origin", "user"])
.unwrap()
.unwrap();
assert_eq!(users["events"].as_array().unwrap().len(), 2);
assert!(
users["events"]
.as_array()
.unwrap()
.iter()
.all(|e| e["origin"] == "user")
);
let newest = trace
.command(&["input-log", "--since", "2", "--limit", "1"])
.unwrap()
.unwrap();
let events = newest["events"].as_array().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["seq"], 4);
}
#[test]
fn the_recording_writer_gets_one_json_line_per_event() {
let (tx, rx) = std::sync::mpsc::channel();
let mut trace = InputTrace::default();
trace.start_file(tx);
trace.record(7, InputOrigin::User, key("key.keyboard.w"), false);
trace.record(8, InputOrigin::Agent, key("key.keyboard.s"), false);
assert_eq!(trace.stop_file(), 2);
let lines: Vec<String> = rx.try_iter().collect();
assert_eq!(lines.len(), 2);
let first: Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(first["tick"], 7);
assert_eq!(first["origin"], "user");
assert_eq!(first["kind"], "key");
assert_eq!(first["key"], "key.keyboard.w");
}
#[test]
fn private_arguments_are_dropped() {
assert_eq!(redact_command("chat /login hunter2"), "chat <redacted>");
assert_eq!(
redact_command("connect friend:ABCD-1234"),
"connect <redacted>"
);
assert_eq!(
redact_command("key key.keyboard.w down"),
"key key.keyboard.w down"
);
}
#[test]
fn polls_are_read_only() {
for line in [
"state",
"wait-for event death",
"record-status",
"input-log --origin user",
"ui",
] {
assert!(is_read_only(line), "{line}");
}
for line in ["key w down", "record-start x", "mouse left click"] {
assert!(!is_read_only(line), "{line}");
}
}
#[test]
fn session_commands_are_not_input() {
for line in [
"record-start x",
"record-stop --export",
"record-replay x",
"repro-export x",
"quit",
] {
assert!(is_session_command(line), "{line}");
}
assert!(!is_session_command("key w down"));
}
#[test]
fn an_agent_still_watching_makes_a_later_person_an_intrusion() {
let mut trace = InputTrace::default();
trace.record(1, InputOrigin::Agent, key("key"), false);
// The agent is waiting on a task or a wait: no five-second window.
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), true));
}
#[test]
fn a_suppressed_replay_records_nothing() {
let mut trace = InputTrace::default();
trace.set_suppress(true);
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
assert_eq!(trace.seq, 0);
assert!(trace.summary()["last_origin"].is_null());
}
}
+11
View File
@@ -4,6 +4,7 @@
//! signal while playing, the comparator output, and saving.
use super::Game;
use horizon_client::{
block_entity_item,
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
@@ -42,6 +43,16 @@ impl Game<'_> {
});
}
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
/// `block_entity_data` carries a `RecordItem` keeps that record, so
/// `has_record` turns on: the model draws the disc and `useWithoutItem`
/// pops it out. Vanilla reads the item's data, after
/// `updateCustomBlockEntityTag` has filled the block entity.
pub(super) fn jukebox_placed(&mut self, pos: [i32; 3], stack: &ItemStack) {
if block_entity_item::carries_record(stack) {
self.set_has_record(pos, true);
}
}
/// `onSongChanged`: neighbours (the signal) and comparators.
fn jukebox_changed(&mut self, pos: [i32; 3]) {
let p = BlockPos::new(pos[0], pos[1], pos[2]);
@@ -2643,12 +2643,15 @@ impl Game<'_> {
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
/// handleSetCreativeModeSlot's `validData` then ignores a stack beyond its own stack size.
/// A native mod can raise an item's stack size past 99 (`item_defaults::MAX_STACK_SIZE`), so
/// the count is held to what the wire carries instead. handleSetCreativeModeSlot's `validData`
/// then ignores a stack beyond its own stack size.
fn creative_stack_valid(&self, host: &mut LanHost, gi: usize, s: &ItemStack) -> bool {
if s.count > 99 {
let max = horizon_world::item_defaults::MAX_STACK_SIZE as u32;
if s.count > max {
let g = &host.hub.guests[gi];
let conn = g.conn;
let reason = format!("Invalid packet: count {} is not in 1..99", s.count);
let reason = format!("Invalid packet: count {} is not in 1..={max}", s.count);
eprintln!("{} sent an invalid creative stack: {reason}", g.name);
host.hub
.kick(conn, horizon_protocol::text::literal(&reason));
@@ -3115,9 +3118,9 @@ impl Game<'_> {
);
return;
}
// The chat filter's `/unmute` (game/chat_filter.rs), on servers that filter.
if words[0] == "unmute" && host.hub.chat_filter_on() {
self.guest_unmute(host, conn, &words);
// The chat filter's mute commands (game/chat_filter.rs), on servers that filter.
if super::chat_filter::MUTE_COMMANDS.contains(&words[0]) && host.hub.chat_filter_on() {
self.guest_mute_command(host, conn, &words);
return;
}
// A native mod's command, for every player its level allows,
@@ -623,6 +623,17 @@ impl Game<'_> {
K::WitherSkull { dangerous: true } => {
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
}
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion's
// `potion_contents`, whose effects the guest's splash and
// whose colour its drawing need. A plain splash potion is the
// client's default and is not sent.
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some((**stack).clone()))),
));
}
_ => {}
}
out.push(e);
+70 -28
View File
@@ -450,6 +450,7 @@
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::native_data_maps::NativeDataMapType;
use super::native_item_energy::ItemEnergyBuffer;
use super::{
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
@@ -2194,6 +2195,22 @@ impl Game<'_> {
pub(super) fn native_item_fluid_tank(&self, item: &str, capacity: &mut Option<i32>) {
mod_api::dispatch_item_fluid_tank(self, mod_api::mods(), item, capacity);
}
/// Item `item` (`mod_id:name`) holds energy (`Capabilities.Energy.ITEM`):
/// set `buffer` when the item is yours, as `SimpleEnergyHandler` is built:
/// its capacity and the most one insert and one extract move. A capacity
/// under 1 leaves the buffer out, and the limits are clamped to
/// 0..=capacity; vanilla's items have none. Asked at each call of the item
/// energy helpers (`native_item_energy`, `native_item_energy_insert`,
/// `native_item_energy_extract`, in `native_item_energy.rs`). The amount
/// is kept in the stack's `minecraft:custom_data`, as the int
/// `horizon_energy`. API @API:item_energy_buffer@.
pub(super) fn native_item_energy_buffer(
&self,
item: &str,
buffer: &mut Option<ItemEnergyBuffer>,
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -4391,6 +4408,29 @@ impl Game<'_> {
mod_api::dispatch_lightning_strike(self, mod_api::mods(), player, pos, cause, cancel);
}
/// Enough players have slept through the night in level `dimension`
/// and the clock is about to move to the morning (NeoForge's
/// `SleepFinishedTimeEvent`, vanilla's `ServerLevel.tick` when it moves
/// the clock to `wake_up_from_sleep`): `new_time` comes in as the next
/// morning's day time and may be changed to wake everyone at another
/// time. A time before `current` (the day time now) is refused, as
/// `setTimeAddition` refuses one, and the morning stays. Unlike
/// NeoForge, which refuses a bad value as it is set, the check runs once
/// after every mod, so a later mod sees the value an earlier one left.
/// On the server, only when the `advance_time` rule is on;
/// `native_time_skip` then hears the jump to the time chosen here and
/// may still cancel it. The players wake and the weather resets as
/// usual either way.
/// API @API:sleep_finished_time@.
pub(super) fn native_sleep_finished_time(
&mut self,
dimension: &str,
current: i64,
new_time: &mut i64,
) {
mod_api::dispatch_sleep_finished_time(self, mod_api::mods(), dimension, current, new_time);
}
/// The overworld clock is about to jump, from sleep or `/time`. Set
/// `cancel` to keep `from`. @API:native_time_skip@.
pub(super) fn native_time_skip(
@@ -7721,37 +7761,39 @@ impl Game<'_> {
return false;
};
let atlas = &self.gui.atlas;
let (w, h) = (s.screen.width, s.screen.height);
batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255));
batch.fill(
atlas,
ox + 1.0,
oy + 1.0,
w - 2.0,
h - 2.0,
rgba(198, 198, 198, 255),
);
let container = self.native_screen_slots().unwrap_or_default();
for i in (0..36).chain(container) {
let (x, y) = self.slot_position(i);
let (x, y) = (ox + x - 1.0, oy + y - 1.0);
batch.fill(atlas, x, y, 18.0, 18.0, rgba(55, 55, 55, 255));
if !s.screen.own_background {
let (w, h) = (s.screen.width, s.screen.height);
batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
17.0,
17.0,
rgba(255, 255, 255, 255),
);
batch.fill(
atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(139, 139, 139, 255),
ox + 1.0,
oy + 1.0,
w - 2.0,
h - 2.0,
rgba(198, 198, 198, 255),
);
let container = self.native_screen_slots().unwrap_or_default();
for i in (0..36).chain(container) {
let (x, y) = self.slot_position(i);
let (x, y) = (ox + x - 1.0, oy + y - 1.0);
batch.fill(atlas, x, y, 18.0, 18.0, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
17.0,
17.0,
rgba(255, 255, 255, 255),
);
batch.fill(
atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(139, 139, 139, 255),
);
}
}
self.native_screen_draw(&s.block, &s.data, batch, [ox, oy]);
if s.screen.own_labels {
@@ -0,0 +1,341 @@
//! Energy held by item stacks for native mods (docs/native-mods.md): a mod's
//! batteries and charged tools (`native_item_energy_buffer`), read, filled
//! and drained. Vanilla has no energy; the contract is NeoForge's
//! `Capabilities.Energy.ITEM`, an `EnergyHandler` reached through an
//! `ItemAccess`: it acts on one item of a stack at a time and may change it.
//! So do these, as the item fluid helpers (`native_item_fluids.rs`) do: they
//! take a stack and answer without changing it, and the caller shrinks its
//! stack by one and hands out the changed item they return. They hold no
//! world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::{Compound, JavaString, Tag};
/// The buffer a mod item declares (`native_item_energy_buffer`), as
/// NeoForge's `SimpleEnergyHandler` is built: its capacity and the most one
/// insert and one extract move.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ItemEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
}
/// What one item of a stack holds (`native_item_energy`).
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeItemEnergy {
pub(crate) amount: i32,
pub(crate) capacity: i32,
/// The most one insert moves.
pub(crate) max_insert: i32,
/// The most one extract moves.
pub(crate) max_extract: i32,
}
const CUSTOM_DATA: &str = "minecraft:custom_data";
/// The int under `minecraft:custom_data` that keeps the amount, as a mod
/// block's buffer keeps it under `energy`.
const ENERGY: &str = "horizon_energy";
/// The buffer as declared, or none when its capacity is under 1; the limits
/// are clamped to 0..=capacity, as a block's are.
fn checked(buffer: ItemEnergyBuffer) -> Option<ItemEnergyBuffer> {
(buffer.capacity >= 1).then(|| ItemEnergyBuffer {
capacity: buffer.capacity,
max_insert: buffer.max_insert.clamp(0, buffer.capacity),
max_extract: buffer.max_extract.clamp(0, buffer.capacity),
})
}
/// The amount a stack keeps in its `custom_data`: anything but an int, or a
/// negative, reads as empty.
fn read_energy(stack: &ItemStack) -> i32 {
let Some(Tag::Compound(data)) = stack.data().and_then(|d| d.other(CUSTOM_DATA)) else {
return 0;
};
match data.get(&JavaString::from(ENERGY)) {
Some(Tag::Int(energy)) => (*energy).max(0),
_ => 0,
}
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `energy`, its other data kept. An empty item
/// carries nothing, so empty batteries stack with new ones.
fn with_energy(stack: &ItemStack, energy: i32) -> ItemStack {
let mut one = one_of(stack);
let mut data = match one.data().and_then(|d| d.other(CUSTOM_DATA)) {
Some(Tag::Compound(data)) => data.clone(),
_ => Compound::new(),
};
if energy > 0 {
data.insert(JavaString::from(ENERGY), Tag::Int(energy));
} else {
data.remove(&JavaString::from(ENERGY));
}
let value = (!data.is_empty()).then_some(Tag::Compound(data));
one.data_mut().set_other(CUSTOM_DATA, value);
// What a server sent for the stack no longer describes it; kept, an
// emptied battery would not stack with a new one.
one.data_mut().wire = Default::default();
one.tidy();
one
}
/// What one item of `stack` holds, if it has a buffer.
fn contents(stack: &ItemStack, buffer: Option<ItemEnergyBuffer>) -> Option<NativeItemEnergy> {
let buffer = buffer?;
(stack.count > 0).then(|| NativeItemEnergy {
amount: read_energy(stack),
capacity: buffer.capacity,
max_insert: buffer.max_insert,
max_extract: buffer.max_extract,
})
}
/// `SimpleEnergyHandler.insert` on one item of `stack`: the room left (none
/// when it holds more than its capacity), the call's limit and what was
/// offered, whichever is least; and the item it becomes.
fn insert_one(
stack: &ItemStack,
buffer: Option<ItemEnergyBuffer>,
offered: i32,
) -> Option<(i32, ItemStack)> {
let held = contents(stack, buffer)?;
let took = (held.capacity.saturating_sub(held.amount))
.min(offered)
.min(held.max_insert)
.max(0);
if took == 0 {
return Some((0, one_of(stack)));
}
Some((took, with_energy(stack, held.amount + took)))
}
/// `SimpleEnergyHandler.extract` on one item of `stack`: what it holds, the
/// call's limit and what was asked, whichever is least; and the item it
/// becomes.
fn extract_one(
stack: &ItemStack,
buffer: Option<ItemEnergyBuffer>,
asked: i32,
) -> Option<(i32, ItemStack)> {
let held = contents(stack, buffer)?;
let gave = held.amount.min(asked).min(held.max_extract).max(0);
if gave == 0 {
return Some((0, one_of(stack)));
}
Some((gave, with_energy(stack, held.amount - gave)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The buffer a mod gave item `item`, checked; none for any other item.
fn native_item_energy_of(&self, item: &str) -> Option<ItemEnergyBuffer> {
if item.starts_with("minecraft:") {
return None;
}
let mut buffer = None;
self.native_item_energy_buffer(item, &mut buffer);
buffer.and_then(checked)
}
/// `Capabilities.Energy.ITEM`, read: the amount, capacity and limits per
/// call of one item of `stack`, which keeps the amount in its
/// `custom_data`. None for an item without a buffer, and for an empty
/// stack.
pub(in crate::game) fn native_item_energy(
&self,
stack: &ItemStack,
) -> Option<NativeItemEnergy> {
contents(stack, self.native_item_energy_of(&stack.item))
}
/// `EnergyHandler.insert`: up to `amount` into one item of `stack`, no
/// more than its room and its limit per call. The amount that went in,
/// and the single item it becomes (the item itself when it took
/// nothing). The stack given is not changed: shrink it by one and hand
/// out the result, or leave the result unused to simulate. None where
/// the item has no buffer.
pub(in crate::game) fn native_item_energy_insert(
&self,
stack: &ItemStack,
amount: i32,
) -> Option<(i32, ItemStack)> {
insert_one(stack, self.native_item_energy_of(&stack.item), amount)
}
/// `EnergyHandler.extract`: up to `amount` out of one item of `stack`,
/// no more than it holds and its limit per call. The amount that came
/// out, and the single item it becomes. The stack given is not changed,
/// as with `native_item_energy_insert`. None where the item has no
/// buffer.
pub(in crate::game) fn native_item_energy_extract(
&self,
stack: &ItemStack,
amount: i32,
) -> Option<(i32, ItemStack)> {
extract_one(stack, self.native_item_energy_of(&stack.item), amount)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn battery(count: u32) -> ItemStack {
ItemStack::new("mymod:battery", count)
}
fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> Option<ItemEnergyBuffer> {
checked(ItemEnergyBuffer {
capacity,
max_insert,
max_extract,
})
}
#[test]
fn an_insert_stops_at_the_room_the_limit_and_the_offer() {
let b = buffer(1000, 300, 1000);
let (took, one) = insert_one(&battery(5), b, 200).unwrap();
assert_eq!((took, one.count, read_energy(&one)), (200, 1, 200));
let (took, one) = insert_one(&one, b, 5000).unwrap();
assert_eq!((took, read_energy(&one)), (300, 500));
let (_, one) = insert_one(&one, b, 300).unwrap();
let (_, one) = insert_one(&one, b, 300).unwrap();
assert_eq!(read_energy(&one), 1000);
assert_eq!(insert_one(&one, b, 1).unwrap().0, 0);
// Nothing offered, or a negative offer, moves nothing.
assert_eq!(insert_one(&battery(1), b, 0).unwrap(), (0, battery(1)));
assert_eq!(insert_one(&battery(1), b, -50).unwrap(), (0, battery(1)));
}
#[test]
fn an_extract_stops_at_what_is_held_the_limit_and_the_ask() {
let b = buffer(1000, 1000, 150);
let (_, full) = insert_one(&battery(1), b, 400).unwrap();
let (gave, rest) = extract_one(&full, b, 100).unwrap();
assert_eq!((gave, read_energy(&rest)), (100, 300));
let (gave, rest) = extract_one(&rest, b, 1000).unwrap();
assert_eq!((gave, read_energy(&rest)), (150, 150));
let (gave, empty) = extract_one(&rest, b, 1000).unwrap();
assert_eq!(gave, 150);
// Nothing left, so it is a new battery again and stacks with one.
assert_eq!(empty, battery(1));
assert_eq!(extract_one(&empty, b, 10).unwrap(), (0, battery(1)));
}
#[test]
fn a_zero_limit_closes_that_way_only() {
// A generator's output cell: it gives, and takes nothing.
let source = buffer(500, 0, 500);
assert_eq!(insert_one(&battery(1), source, 100).unwrap().0, 0);
let sink = buffer(500, 500, 0);
let (_, charged) = insert_one(&battery(1), sink, 100).unwrap();
assert_eq!(extract_one(&charged, sink, 100).unwrap().0, 0);
assert_eq!(extract_one(&charged, source, 100).unwrap().0, 100);
}
#[test]
fn a_buffer_is_checked_as_a_blocks_is() {
assert_eq!(buffer(0, 10, 10), None);
assert_eq!(buffer(-5, 10, 10), None);
assert_eq!(
buffer(100, 500, -3),
Some(ItemEnergyBuffer {
capacity: 100,
max_insert: 100,
max_extract: 0,
})
);
}
#[test]
fn items_without_a_buffer_hold_nothing() {
assert_eq!(contents(&battery(1), None), None);
assert_eq!(insert_one(&battery(1), None, 10), None);
assert_eq!(extract_one(&battery(1), None, 10), None);
assert_eq!(contents(&battery(0), buffer(100, 10, 10)), None);
}
#[test]
fn what_a_stack_holds_reads_back_with_its_limits() {
let b = buffer(2000, 50, 75);
let (_, one) = insert_one(&battery(1), b, 50).unwrap();
assert_eq!(
contents(&one, b),
Some(NativeItemEnergy {
amount: 50,
capacity: 2000,
max_insert: 50,
max_extract: 75,
})
);
// A damaged or foreign value reads as empty.
let mut odd = battery(1);
odd.data_mut().set_other(
CUSTOM_DATA,
Some(Tag::compound([(
ENERGY,
Tag::String(JavaString::from("9")),
)])),
);
assert_eq!(read_energy(&odd), 0);
odd.data_mut()
.set_other(CUSTOM_DATA, Some(Tag::compound([(ENERGY, Tag::Int(-40))])));
assert_eq!(read_energy(&odd), 0);
}
#[test]
fn more_than_the_capacity_takes_nothing_and_still_gives() {
// A mod that shrank its capacity after the item was charged.
let mut over = battery(1);
over.data_mut()
.set_other(CUSTOM_DATA, Some(Tag::compound([(ENERGY, Tag::Int(900))])));
let b = buffer(500, 500, 500);
assert_eq!(insert_one(&over, b, 100).unwrap().0, 0);
let (gave, rest) = extract_one(&over, b, 1000).unwrap();
assert_eq!((gave, read_energy(&rest)), (500, 400));
}
#[test]
fn an_emptied_battery_from_a_server_stacks_with_a_new_one() {
let b = buffer(100, 100, 100);
let mut sent = battery(2);
sent.data_mut().wire =
horizon_client::item_data::Wire::new(battery(2), Arc::from(vec![1u8]));
let (_, charged) = insert_one(&sent, b, 40).unwrap();
let (_, emptied) = extract_one(&charged, b, 40).unwrap();
assert!(emptied.same_item_same_components(&battery(1)));
}
#[test]
fn a_battery_keeps_the_rest_of_its_custom_data() {
let b = buffer(1000, 1000, 1000);
let mut stack = battery(1);
stack.data_mut().set_other(
CUSTOM_DATA,
Some(Tag::compound([(
"owner",
Tag::String(JavaString::from("Alex")),
)])),
);
let (_, charged) = insert_one(&stack, b, 10).unwrap();
let Some(Tag::Compound(data)) = charged.data().and_then(|d| d.other(CUSTOM_DATA)) else {
panic!("no custom data");
};
assert!(data.contains_key(&JavaString::from("owner")));
assert_eq!(data.get(&JavaString::from(ENERGY)), Some(&Tag::Int(10)));
let (_, emptied) = extract_one(&charged, b, 10).unwrap();
assert_eq!(emptied, stack);
}
}
@@ -14,9 +14,10 @@ impl Game<'_> {
pub(super) fn native_frame_visuals(
&self,
camera: glam::DVec3,
alpha: f64,
lines: &mut Vec<gizmos::Line>,
) -> Vec<dynamic_light::Colored> {
let particle_lights = self
let mut particle_lights = self
.renderer
.as_ref()
.and_then(|r| r.particle_lights())
@@ -24,6 +25,26 @@ impl Game<'_> {
self.particles
.lights(entries, self.clock.camera_fraction() as f32)
});
// Held and dropped items whose block the pack's `[[lights]]` colour shine in that colour,
// as far as their dynamic light reaches.
if let Some(renderer) = self.renderer.as_ref().filter(|r| r.has_light_colors()) {
for (source, item) in self.dynamic_light_sources(alpha) {
let Some(color) = item.and_then(|i| renderer.light_color(&i)) else {
continue;
};
let top = color[0].max(color[1]).max(color[2]);
if source.level == 0 || top <= 0.0 {
continue;
}
let [r, g, b] = color.map(|c| (c / top * 255.0).round() as u32);
particle_lights.push((
source.at.to_array(),
r << 16 | g << 8 | b,
f32::from(source.level),
1.0,
));
}
}
let Some(player) = self.native_player() else {
return dynamic_light::select_colored(particle_lights.into_iter(), camera);
};
@@ -331,6 +331,9 @@ impl Game<'_> {
let text = self.agent_details();
self.clipboard.set(&text);
}
// The AI Agent page's repro buttons (game/repro.rs).
Event::ToggleRecording => self.toggle_recording_key(),
Event::ExportRepro => self.export_repro_key(),
Event::Close if self.ui.options.resource_error.is_some() => {}
Event::Close => {
self.settings.save(&Self::options_path());
+38
View File
@@ -45,6 +45,9 @@ pub(super) struct Pads {
late_releases: Vec<Button>,
/// The merged state of the last poll.
pub(super) state: PadState,
/// The hardware's buttons at the last poll, for `input-log`'s `pad`
/// events: the merged state hides which source moved.
last_hardware: PadState,
/// The pad drove the game last (the mouse and keyboard hand control back).
pub(super) in_use: bool,
pub(super) style: GlyphStyle,
@@ -125,6 +128,29 @@ mod hardware {
}
impl Pads {
/// The digital buttons the person's pad changed since the last poll.
pub(super) fn hardware_edges(&mut self, hardware: PadState) -> Vec<(Button, bool)> {
let edges = Button::ALL
.into_iter()
.filter_map(|button| {
let was = self.last_hardware.held(button);
let now = hardware.held(button);
(was != now).then_some((button, now))
})
.collect();
self.last_hardware = hardware;
edges
}
/// A replay's pad button, in place of `pad`'s line.
pub(super) fn inject_button(&mut self, button: Button, down: bool) {
self.injected.set(button, down);
}
/// A replay ended: let go of everything it held.
pub(super) fn clear_injected(&mut self) {
self.injected = PadState::default();
}
/// Include releases delayed until the next simulation tick.
pub(super) fn holds_action(&self, settings: &gamepad::PadSettings, action: &str) -> bool {
settings.binding(action).is_some_and(|button| {
@@ -159,6 +185,18 @@ impl Game<'_> {
Some(state) => state,
None => self.pad.read(),
};
// A person's pad, for `input-log` and the repro recording; the virtual
// pad is an agent's `pad` line and is recorded as that.
let hardware_edges = self.pad.hardware_edges(hardware);
for (button, down) in hardware_edges {
self.note_input(
super::input_trace::InputOrigin::User,
super::input_trace::TraceKind::Pad {
button: button.name().to_owned(),
action: if down { "down" } else { "up" }.to_owned(),
},
);
}
self.ui.options.pad_style = self.settings.controller.glyphs.resolve(self.pad.style);
let state = hardware.merge(&self.pad.injected);
self.pad.virtual_input = self.pad.injected != PadState::default();
+1 -1
View File
@@ -328,7 +328,7 @@ impl Game<'_> {
event: WindowEvent,
) {
let was = std::mem::replace(&mut self.pad.menu.synthetic, true);
self.handle_window_event(event_loop, id, event);
self.handle_window_event(event_loop, id, event, super::input_trace::InputOrigin::Game);
self.pad.menu.synthetic = was;
}
+68 -16
View File
@@ -117,6 +117,21 @@ impl PotionCloud {
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// `Projectile.getOwner` of a thrown potion: what its `indirectMagic` damage
/// is named after and who it counts against. Nothing for a dispenser's or a
/// summoned potion nobody owns, the potion itself then naming the death
/// message.
#[derive(Clone, Debug, Default)]
pub(super) struct PotionOwner {
/// The thrower's name, when it has one.
name: Option<Text>,
/// The mob that threw it (a witch): the hurt is its kill credit, as
/// `indirectMagic(potion, owner)` gives it. A player's throw is the
/// potion's own and only `name`s the message.
attacker: Option<u32>,
/// A player threw it: the water damage `indirectMagic` deals is theirs.
player: bool,
}
/// A mob's effect ids carry the `minecraft:` namespace; a mod's has its own.
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
brewing::effect(id).map(|e| brewing::namespaced(e.id))
@@ -1166,7 +1181,13 @@ impl Game<'_> {
let thrower = owner.is_some().then_some(Who::Player);
self.projectile_event("minecraft:projectile_land", thrower, heard);
let item = stack.item.to_string();
self.splash(&stack, at, mob, block, age, owner);
// A potion a dispenser threw has nobody to name.
let owner = PotionOwner {
player: owner.is_some(),
name: owner,
attacker: None,
};
self.splash(&stack, at, mob, block, age, &owner);
if item != "minecraft:experience_bottle" {
self.native_potion_splash(&item, [at.x, at.y, at.z]);
}
@@ -1305,6 +1326,12 @@ impl Game<'_> {
at: [f64; 3],
source: &Text,
) {
// `indirectMagic(cloud, owner)` names the cloud in the death message
// (it gives no kill credit).
let cloud_owner = PotionOwner {
name: Some(source.clone()),
..Default::default()
};
// MobEffectInstance.withScaledDuration: floor(duration * scale), at least 1; an infinite
// effect stays infinite.
let scaled = |duration: i32| {
@@ -1322,7 +1349,7 @@ impl Game<'_> {
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
g.apply_indirect_effect_action(a, &cloud_owner);
}
} else {
let mut instance = EffectInstance::new(e.id, scaled(*duration), *amp);
@@ -1480,11 +1507,22 @@ impl Game<'_> {
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
/// potion or a cloud): indirectMagic, not plain magic, credited to the
/// mob that threw it when there is one.
fn apply_indirect_effect_action(&mut self, a: EffectAction, owner: &PotionOwner) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
match (owner.attacker, &owner.name) {
(Some(id), Some(name)) => {
self.note_named_attacker("minecraft:indirect_magic", id, name.clone())
}
_ => {
let name = owner.name.clone().unwrap_or_else(|| {
Text::translate("entity.minecraft.splash_potion", &[])
});
self.note_direct_entity("minecraft:indirect_magic", name);
}
}
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
@@ -1497,7 +1535,7 @@ impl Game<'_> {
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
age: u32,
source: &Text,
owner: &PotionOwner,
) {
if let Some(p) = self.player.as_ref() {
let w = f64::from(p.dimensions.width) / 2.0;
@@ -1515,7 +1553,7 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_indirect_effect_action(a, source);
self.apply_indirect_effect_action(a, owner);
}
} else {
let d = (intensity * f64::from(*duration) + 0.5) as i32;
@@ -1529,18 +1567,36 @@ impl Game<'_> {
}
}
}
/// `Projectile.getOwner` of a thrown potion, as [`splash`](Self::splash)
/// names it: a player's, a mob's (a witch keeps the kill credit), or
/// nobody's (a dispenser's, a summoned potion's).
pub(super) fn potion_owner(&self, owner: Option<Who>) -> PotionOwner {
match owner {
Some(Who::Player) => PotionOwner {
name: Some(self.player_display_text()),
attacker: None,
player: true,
},
Some(Who::Mob(id)) => PotionOwner {
name: self.mob_display_name(id),
attacker: Some(id),
player: false,
},
None => PotionOwner::default(),
}
}
/// `AbstractThrownPotion.onHit` with `ThrownSplashPotion.onHitAsPotion`, or
/// `ThrownLingeringPotion.onHitAsPotion` for a lingering potion, read from
/// the stack's `potion_contents`; `mob` is where it hit a mob, `hit` the
/// block and face it hit, `age` its age.
fn splash(
pub(super) fn splash(
&mut self,
stack: &ItemStack,
at: Vec3,
mob: Option<Vec3>,
hit: Option<([i32; 3], Direction)>,
age: u32,
owner: Option<Text>,
owner: &PotionOwner,
) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
@@ -1559,7 +1615,7 @@ impl Game<'_> {
if let Some((cell, face)) = hit {
self.potion_hit_block(stack, cell, face);
}
self.affect_entities_around(stack, at, owner.is_some());
self.affect_entities_around(stack, at, owner.player);
let lingering = &*stack.item == "minecraft:lingering_potion";
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
@@ -1623,7 +1679,7 @@ impl Game<'_> {
radius_per_tick: -3.0 / 600.0,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
source: owner.name.clone().unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
@@ -1633,11 +1689,7 @@ impl Game<'_> {
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, owner));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.stations().enchantments.entity_tags;
let mut hits = Vec::new();
+68 -19
View File
@@ -487,6 +487,9 @@ pub(super) struct Remote {
predictions: HashMap<[i32; 3], (i32, i32)>,
/// Open container: (window id, menu name).
pub(super) menu: Option<(i32, String)>,
/// The crafting result slot of the open menu as the server last set it
/// (`CraftingMenu.slotChangedCraftingGrid`): the client never computes it.
pub(super) craft_result: Option<ItemStack>,
/// The open menu's title as the server named it, with its styles.
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
@@ -977,6 +980,7 @@ impl Game<'_> {
sent_info: info,
predictions: HashMap::new(),
menu: None,
craft_result: None,
menu_title: None,
state_id: 0,
use_target: None,
@@ -1708,7 +1712,17 @@ impl Game<'_> {
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
@@ -1898,6 +1912,9 @@ impl Game<'_> {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
@@ -2124,28 +2141,37 @@ impl Game<'_> {
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
self.forget_remote_columns(&mut gone);
}
/// Forgets the `gone` columns (and empties the set): their chunks (freed on a thread
/// of their own when there are many), their sections in the renderer and their block
/// entities, each store once for all of them.
fn forget_remote_columns(
&mut self,
gone: &mut horizon_client::fast_hash::FxHashSet<(i32, i32)>,
) {
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in &gone {
for &(x, z) in gone.iter() {
freed.extend(self.streaming.forget(&mut self.world, x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
}
horizon_client::chunk::free_columns(freed, gone.len());
// One pass for all of them.
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
gone.clear();
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
@@ -2812,7 +2838,14 @@ impl Game<'_> {
return;
};
if internal == 200 {
return; // the result slot is recomputed from the grid
// ClientboundContainerSetSlotPacket for the open menu's slot 0: the
// server's result of its own recipes (a world's data packs' too).
if container == self.remote_menu().0
&& let Some(r) = self.remote.as_mut()
{
r.craft_result = item;
}
return;
}
let slot = self.slot_mut(internal);
let changed = !super::hand::matches_for_swap(slot.as_ref(), item.as_ref());
@@ -3012,15 +3045,9 @@ impl Game<'_> {
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ChunksBiomes(p) => self.remote_chunks_biomes(&p),
// `poll_remote` forgets a run of them at once.
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns([(x, z)]);
}
self.forget_remote_columns(&mut [(p.pos[0], p.pos[1])].into_iter().collect());
}
C::ChunkBatchFinished(_) => {
// ChunkBatchSizeCalculator's ceiling; meshing runs on workers.
@@ -5488,6 +5515,7 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.repro_tick();
// Hud.tick ends with ChatComponent.tick.
self.ui.chat.tick(&self.gui.atlas, self.ticks);
self.tick_sign_editor();
@@ -7651,18 +7679,24 @@ impl Game<'_> {
};
let target = self.bucket_target(content, &hit);
let pos = horizon_core::position::BlockPos::new(target[0], target[1], target[2]);
let [x, y, z] = hit.beside();
let beside = Some(horizon_core::position::BlockPos::new(x, y, z));
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
// A water bucket on a liquid container fills it server-side only.
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let client_change =
!(content.is_water() && self.res.behaviours().is_liquid_container(clicked));
self.with_level(|b, level, ticks| {
let mut level = DryLevel::new(level);
let emptied =
b.bucket_empty(&mut level, ticks, pos, content, sound, sneaking, creative);
let emptied = b.bucket_empty(
&mut level, ticks, pos, beside, content, sound, sneaking, creative,
);
// Water poured into a liquid container (where it went, which may
// be the block beside a refusing one) fills it server-side only.
let client_change = !(content.is_water()
&& emptied.is_some_and(|at| {
use horizon_world::level::LevelAccess as _;
b.is_liquid_container(level.block_state(at))
}));
(
emptied,
emptied.is_some(),
client_change.then(|| level.changed.into_iter().collect()),
std::mem::take(&mut level.sounds),
)
@@ -8753,6 +8787,20 @@ impl Game<'_> {
self.send_clicks(container, sent);
}
fn send_clicks(&mut self, container: i32, clicks: Vec<Predicted>) {
// A result the engine claims to have changed is the one the server
// agrees to (`click_agreed`), so it sends none: keep it. A result the
// engine doesn't claim (a recipe it doesn't know) the server sends.
if matches!(
self.remote_menu().1.as_str(),
"inventory" | "minecraft:crafting"
) && let Some(r) = self.remote.as_mut()
{
for (.., changed, _) in &clicks {
if let Some((_, result)) = changed.iter().find(|(slot, _)| *slot == 0) {
r.craft_result = result.clone();
}
}
}
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
let hashed = |g: &Self, s: &Option<ItemStack>| g.hashed_stack(s);
for (slot, button, input, changed, carried) in clicks {
@@ -8861,6 +8909,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
r.craft_result = None;
}
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
+66 -11
View File
@@ -5,7 +5,7 @@
//! markMessageAsProcessed). Without a signed-in
//! account, or on a server that plays the account under another profile
//! (offline mode), there is no session and everything goes unsigned.
use super::{Game, Screen};
use super::{Game, Screen, books::BookScreen, dialogs::ClosedDialog};
use horizon_client::{
auth::{self, ChatKeyPair},
command::{Dispatcher, Registries},
@@ -18,6 +18,7 @@ use horizon_protocol::{
secure_chat::{Body, Encoder, LastSeenTracker, Signature},
};
use std::sync::Arc;
use winit::keyboard::KeyCode;
/// ClientPacketListener.sendChatAcknowledgement's threshold.
const ACK_AFTER: i32 = 64;
@@ -62,6 +63,13 @@ enum CommandAccept {
Copy,
}
/// The screen a clicked command left, which answering no returns to
/// (openSendConfirmationWindow's `currentScreen`).
pub(super) enum Behind {
Book(Box<BookScreen>),
Dialog(ClosedDialog),
}
/// The `ConfirmScreen` of ClientPacketListener.openSendConfirmationWindow.
pub(super) struct CommandPrompt {
command: String,
@@ -69,6 +77,9 @@ pub(super) struct CommandPrompt {
accept: CommandAccept,
/// A screen is open behind it: the second button says Back, not Cancel.
over_screen: bool,
behind: Option<Behind>,
/// The button Tab or an arrow key moved to.
focus: Option<usize>,
}
/// `FriendlyByteBuf.readUtf(256)` counts UTF-16 code units, not Unicode scalar
@@ -299,7 +310,8 @@ impl Game<'_> {
} else {
CommandCheck::ParseErrors
};
let over_screen = self.screen != Screen::Playing;
// screenAfterCommand: what stays once it has run, a dialog included.
let over_screen = self.screen != Screen::Playing || self.dialog_open();
let (message, accept) = match check {
CommandCheck::NoIssues => {
self.send(S::ChatCommand(play::ChatCommand {
@@ -329,7 +341,18 @@ impl Game<'_> {
message,
accept,
over_screen,
behind: None,
focus: None,
});
self.capture(false);
}
/// The command just clicked left this screen: if it asks, no returns there.
pub(super) fn command_prompt_returns_to(&mut self, behind: Behind) {
if let Some(prompt) = self.command_prompt.as_mut() {
prompt.behind = Some(behind);
prompt.over_screen = true;
}
}
/// The translation key of the open confirmation's message.
@@ -341,14 +364,16 @@ impl Game<'_> {
/// its two buttons.
fn command_prompt_layout(&self, prompt: &CommandPrompt, size: (f32, f32)) -> Choice {
let command = Text::literal(&prompt.command).with_color(0xFF_FF55);
Choice::new(
let mut choice = Choice::new(
&self.gui.atlas.font,
size,
&Text::translate("multiplayer.confirm_command.title", &[]),
&Text::translate(prompt.message, &[command]),
2,
150.0,
)
);
choice.focus = prompt.focus;
choice
}
pub(super) fn draw_command_prompt(&self, batch: &mut HudBatch) {
@@ -392,11 +417,8 @@ impl Game<'_> {
let Some(prompt) = self.command_prompt.take() else {
return;
};
if !yes {
return;
}
match prompt.accept {
CommandAccept::Run => {
match (yes, prompt.accept, prompt.behind) {
(true, CommandAccept::Run, _) => {
if self.remote.is_some() {
self.send(S::ChatCommand(play::ChatCommand {
command: prompt.command,
@@ -404,11 +426,44 @@ impl Game<'_> {
}
}
// Gui.openChatAndAddText.
CommandAccept::Suggest => self.open_chat(&format!("/{}", prompt.command)),
CommandAccept::Copy => {
(true, CommandAccept::Suggest, _) => self.open_chat(&format!("/{}", prompt.command)),
(true, CommandAccept::Copy, _) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&format!("/{}", prompt.command));
}
(false, _, Some(Behind::Book(book))) => self.book = Some(*book),
(false, _, Some(Behind::Dialog(dialog))) => self.reopen_dialog(dialog),
(false, _, None) => {}
}
if self.screen == Screen::Playing && !self.screen_over_playing() {
self.capture(true);
}
}
/// ConfirmScreen.keyPressed, and what its buttons take from the keyboard
/// (Screen's focus navigation, AbstractButton's Enter and Space).
pub(super) fn command_prompt_key(&mut self, key: KeyCode) {
let (size, shift) = (self.gui_size(), self.has_shift_down());
let Some(prompt) = &self.command_prompt else {
return;
};
let mut choice = self.command_prompt_layout(prompt, size);
match key {
KeyCode::Escape => return self.answer_command_prompt(false),
KeyCode::Tab => choice.tab(shift),
KeyCode::ArrowLeft | KeyCode::ArrowRight => choice.arrow(key == KeyCode::ArrowLeft),
KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space => {
if let Some(button) = choice.pressed() {
self.audio.play_button_click();
self.answer_command_prompt(button == 0);
}
return;
}
_ => return,
}
let focus = choice.focus;
if let Some(prompt) = self.command_prompt.as_mut() {
prompt.focus = focus;
}
}
@@ -918,11 +918,13 @@ mod tests {
#[test]
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99F
// (`getAirDrag`, a float widened to the double it scales by).
let drag = f64::from(0.99_f32);
let mut p = thrown(ProjectileKind::Egg);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
assert!((p.velocity.x - 1.5 * drag).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * drag)).abs() < 1e-9, "{p:?}");
// A wind charge keeps its speed: no gravity and no drag
// (AbstractWindCharge: acceleration 0, inertia 1).
let mut p = thrown(ProjectileKind::WindCharge);
File diff suppressed because it is too large. Load diff
@@ -140,6 +140,8 @@ pub(super) struct PackIds {
vignette: bool,
/// `oldLighting`: `true` keeps the game's face shading in the block colours.
old_lighting: bool,
/// `separateAo`: `true` passes AO factor in vertex alpha and keeps pure tint in RGB.
pub(super) separate_ao: bool,
}
impl Game<'_> {
@@ -158,6 +160,11 @@ impl Game<'_> {
self.streaming.shader_pack_lighting = pack_lighting;
self.all_sections_changed();
}
let separate_ao = self.pack_ids.as_ref().is_some_and(|ids| ids.separate_ao);
if self.streaming.separate_ao != separate_ao {
self.streaming.separate_ao = separate_ao;
self.all_sections_changed();
}
let picked = picked_pack(&self.settings).to_owned();
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
@@ -209,6 +216,10 @@ impl Game<'_> {
note.message
);
}
let separate_ao = properties
.get("separateAo")
.is_some_and(|v| v.trim() == "true");
renderer.set_separate_ao(separate_ao);
let folder = dimension_folder(&pack, dimension);
let inputs = renderer.pack_inputs();
let mut post = load_post(&pack, &folder, &properties, &macros, &inputs);
@@ -246,6 +257,7 @@ impl Game<'_> {
old_lighting: properties
.get("oldLighting")
.is_some_and(|v| v.trim() == "true"),
separate_ao,
entities: entities.plain(),
missing_entity: if text.is_some() { 65535 } else { 0 },
missing_block: if ids.is_empty() { 0 } else { 65535 },
+5 -1
View File
@@ -745,10 +745,14 @@ impl ApplicationHandler for Split<'_> {
if changed || self.views() != self.applied {
self.relayout(event_loop);
}
// Each player's badge: who it is, shown a moment when something changes for it.
// Each player's badge: who it is, always with Names on (P1's Setup), else a moment when
// something changes for it.
let names = self.host.settings.extras.splitscreen_names;
self.host.split_hud.badge = Some("P1".into());
self.host.split_hud.names = names;
for g in &mut self.guests {
g.game.split_hud.badge = Some(g.name.clone().unwrap_or_else(|| "New player".into()));
g.game.split_hud.names = names;
}
let master = self.host.audio.master;
for i in 0..self.guests.len() {
+7 -2
View File
@@ -11,6 +11,8 @@ use std::time::Duration;
pub(in crate::game) struct Hud {
pub(in crate::game) menu: bool,
pub(super) badge: Option<String>,
/// The badge's name stays (P1's Setup, Names); off, it shows only with a place.
pub(super) names: bool,
pub(super) place: Option<(String, Instant)>,
pub(super) prompt: Option<Prompt>,
pub(super) notice: Option<Notice>,
@@ -99,8 +101,11 @@ impl Game<'_> {
// The badge, top left: who, and a moment where. F1 hides it with the rest of the HUD.
if let (false, Some(badge)) = (hidden, &self.split_hud.badge) {
let place = self.split_hud.place.as_ref();
if let Some((place, _)) = place.filter(|(_, until)| Instant::now() < *until) {
let text = format!("{badge} · {place}");
let text = match place.filter(|(_, until)| Instant::now() < *until) {
Some((place, _)) => Some(format!("{badge} · {place}")),
None => self.split_hud.names.then(|| badge.clone()),
};
if let Some(text) = text {
batch.text(atlas, &text, 2.0, 2.0, rgba(255, 255, 255, 230), true);
}
}
+21 -4
View File
@@ -39,11 +39,12 @@ impl<'a> Split<'a> {
}
})
.collect();
let e = &self.host.settings.extras;
layout::Draft {
windows,
players: (0..self.players()).map(|i| self.window_number(i)).collect(),
two: 0,
three: 0,
two: e.splitscreen_two,
three: e.splitscreen_three,
order: self.player_order(),
}
}
@@ -108,6 +109,14 @@ impl<'a> Split<'a> {
.into_iter()
.map(|i| self.seat_of(i))
.collect();
// How 2 and 3 share a window: P1's settings, saved in its options file (its turn).
self.turn(0);
let e = &mut self.host.settings.extras;
if (e.splitscreen_two, e.splitscreen_three) != (draft.two, draft.three) {
e.splitscreen_two = draft.two;
e.splitscreen_three = draft.three;
self.host.settings.save(&Game::options_path());
}
}
/// P1's Setup screen: the arrangement now, the monitors and the players' names; Apply
@@ -130,7 +139,15 @@ impl<'a> Split<'a> {
setup.reset();
}
let applied = setup.apply.take();
let identify = std::mem::take(&mut setup.identify);
if std::mem::take(&mut setup.toggle_names) {
let e = &mut self.host.settings.extras;
e.splitscreen_names = !e.splitscreen_names;
self.host.settings.save(&Game::options_path());
}
let names_shown = self.host.settings.extras.splitscreen_names;
let setup = &mut self.host.ui.options.split_setup;
setup.names_shown = names_shown;
setup.idle = self.surfaces.is_empty();
setup.live = Some(live);
setup.monitors = monitors;
@@ -140,8 +157,8 @@ impl<'a> Split<'a> {
if let Some(draft) = applied {
self.apply(draft);
}
// Every view says where it is: after a change, or asked.
if changed {
// Every view says where it is: after a change, or asked (Identify).
if changed || identify {
for i in 0..self.players() {
let label = horizon_client::lang::tr(
"horizon.splitscreen.setup.window",
@@ -289,7 +289,15 @@ impl<'a> Split<'a> {
})
.collect();
let sizes: Vec<_> = self.surfaces.iter().map(|s| s.size).collect();
layout::place_ordered(&windows, &self.player_order(), &sizes, 0, 0)
// How 2 and 3 players share a window: P1's settings (Setup's layout squares).
let e = &self.host.settings.extras;
layout::place_ordered(
&windows,
&self.player_order(),
&sizes,
e.splitscreen_two,
e.splitscreen_three,
)
}
/// The players (indices) in the order they fill a window they share (`order`, by seat;
@@ -585,7 +585,11 @@ impl Loading {
Event::NativeSettings(_) | Event::NativeButton(..) => {}
// The startup menu runs before the game opens a control socket,
// and before its sound engine (no clicks, no volume previews).
Event::ButtonClick | Event::CopyAgentDetails | Event::Volume(..) => {}
Event::ButtonClick
| Event::CopyAgentDetails
| Event::ToggleRecording
| Event::ExportRepro
| Event::Volume(..) => {}
}
}
}
@@ -3,7 +3,7 @@
//! sender is told in private, and the game logs it and bans when the escalation says so
//! (`HostEvent::ChatFiltered`).
use super::{HostEvent, Hub, chat::now_millis};
use horizon_chat_filter::{Action, Guard};
use horizon_chat_filter::{Action, Guard, Mute, duration_text};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
text::literal,
@@ -38,30 +38,45 @@ impl Place {
}
}
/// What the sender reads when their text was refused (`hit` false: refused because they are muted).
pub fn notice(place: Place, hit: bool, action: Action, now: i64) -> String {
let minutes = |until: i64| (until - now + 59_999) / 60_000;
/// What the sender reads when their text held a slur.
pub fn notice(place: Place, action: Action, now: i64) -> String {
let refused = match place {
Place::Chat | Place::Command => "That message wasn't sent",
Place::Sign => "That sign wasn't changed",
Place::Anvil => "That name wasn't used",
Place::Book => "That book wasn't signed",
};
match (hit, action) {
(false, Action::Muted { until }) => {
format!(
"You're muted for {} more minutes: slurs aren't allowed here.",
minutes(until)
)
}
(_, Action::Muted { until }) => format!(
"{refused}: slurs aren't allowed here. You're muted for {} minutes.",
minutes(until)
match action {
Action::Muted { until } => format!(
"{refused}: slurs aren't allowed here. You're muted for {}.",
duration_text(until - now)
),
_ => format!("{refused}: slurs aren't allowed here."),
}
}
/// What a muted player reads: when they try to chat, and when an operator mutes them.
pub fn muted_notice(mute: &Mute, now: i64) -> String {
let time = match mute.until {
// "2 more minutes".
Some(until) => format!(
"for {}",
duration_text(until - now).replacen(' ', " more ", 1)
),
None => "until an operator unmutes you".into(),
};
let why = match (&mute.by, &mute.reason) {
(None, _) => "slurs aren't allowed here",
(Some(_), Some(reason)) => reason,
(Some(_), None) => "",
};
if why.is_empty() {
format!("You're muted {time}.")
} else {
format!("You're muted {time}: {why}.")
}
}
/// The text in red.
fn red(text: &str) -> Tag {
let mut t = literal(text);
@@ -98,15 +113,12 @@ impl Hub {
};
let (uuid, now) = (self.guests[i].uuid, now_millis());
let hit = texts.iter().any(|t| guard.filter.matches(t));
let muted = place
.is_chat()
.then(|| guard.muted_until(uuid, now))
.flatten();
let muted = place.is_chat().then(|| guard.muted(uuid, now)).flatten();
if !hit {
let Some(until) = muted else {
let Some(mute) = muted else {
return true;
};
let text = notice(place, false, Action::Muted { until }, now);
let text = muted_notice(mute, now);
self.guests[i].send(&C::SystemChat(play::SystemChat {
content: red(&text),
overlay: false,
@@ -116,7 +128,7 @@ impl Hub {
// An anvil renames at every key typed: refused quietly, on the action bar, with no hit.
if place == Place::Anvil {
self.guests[i].send(&C::SystemChat(play::SystemChat {
content: red(&notice(place, true, Action::Refused, now)),
content: red(&notice(place, Action::Refused, now)),
overlay: true,
}));
return false;
@@ -125,7 +137,7 @@ impl Hub {
let (count, action) = guard.strike(uuid, &name, now);
if !matches!(action, Action::Banned { .. }) {
self.guests[i].send(&C::SystemChat(play::SystemChat {
content: red(&notice(place, true, action, now)),
content: red(&notice(place, action, now)),
overlay: false,
}));
}
@@ -140,6 +152,38 @@ impl Hub {
false
}
/// An operator's `/mute` or `/tempmute`; the player is told if they are here.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
let Some(guard) = self.chat_filter.as_deref_mut() else {
return;
};
let now = now_millis();
guard.mute(uuid, name, until, by, reason);
let Some(text) = guard.muted(uuid, now).map(|m| muted_notice(m, now)) else {
return;
};
for g in self.guests.iter().filter(|g| g.uuid == uuid) {
g.send(&C::SystemChat(play::SystemChat {
content: red(&text),
overlay: false,
}));
}
}
/// The mutes in force, by name.
pub fn mutes(&self) -> Vec<Mute> {
self.chat_filter.as_deref().map_or_else(Vec::new, |g| {
g.mutes(now_millis()).into_iter().cloned().collect()
})
}
/// `/unmute <player>`: the name as it was muted, if they were.
pub fn unmute(&mut self, name: &str) -> Option<String> {
self.chat_filter.as_deref_mut()?.unmute(name)
@@ -162,13 +206,12 @@ mod tests {
fn the_sender_is_told_why() {
let now = 1_000_000;
assert_eq!(
notice(Place::Chat, true, Action::Refused, now),
notice(Place::Chat, Action::Refused, now),
"That message wasn't sent: slurs aren't allowed here."
);
assert_eq!(
notice(
Place::Command,
true,
Action::Muted {
until: now + 600_000
},
@@ -176,19 +219,27 @@ mod tests {
),
"That message wasn't sent: slurs aren't allowed here. You're muted for 10 minutes."
);
let mute = |until, by: Option<&str>, reason: Option<&str>| Mute {
uuid: String::new(),
name: "Ann".into(),
until,
by: by.map(Into::into),
reason: reason.map(Into::into),
};
assert_eq!(
notice(
Place::Chat,
false,
Action::Muted {
until: now + 61_000
},
now
),
muted_notice(&mute(Some(now + 61_000), None, None), now),
"You're muted for 2 more minutes: slurs aren't allowed here."
);
assert_eq!(
notice(Place::Sign, true, Action::Refused, now),
muted_notice(&mute(None, Some("Op"), Some("spam")), now),
"You're muted until an operator unmutes you: spam."
);
assert_eq!(
muted_notice(&mute(Some(now + 7_200_000), Some("Op"), None), now),
"You're muted for 2 more hours."
);
assert_eq!(
notice(Place::Sign, Action::Refused, now),
"That sign wasn't changed: slurs aren't allowed here."
);
assert!(Place::Chat.is_chat() && Place::Command.is_chat() && !Place::Sign.is_chat());
+6 -4
View File
@@ -1471,7 +1471,9 @@ impl Menu {
Role::Plain if self.kind == Kind::Lectern && self.slots[s].container == MAIN => 1,
Role::Fuel if Self::is_bucket(stack) => return 1,
Role::Mod | Role::ModResult => self.mod_slot(s, stack).1,
_ => CONTAINER_MAX,
// `Container.getMaxStackSize` is vanilla's 99, which no data pack's
// item reaches; a native mod's item may, and its own size is the cap.
_ => u32::MAX,
};
own.min(Self::max_stack(env, stack))
}
@@ -1489,7 +1491,7 @@ impl Menu {
if (slot.container == MAIN || slot.role == Role::LoomResult)
&& let Some(st) = stack.as_mut()
{
st.count = st.count.min(CONTAINER_MAX.min(Self::max_stack(env, st)));
st.count = st.count.min(Self::max_stack(env, st));
// The beacon's container holds one item (getMaxStackSize 1).
if self.kind == Kind::Beacon {
st.count = st.count.min(1);
@@ -1873,7 +1875,7 @@ impl Menu {
inv[i].as_ref().is_some_and(|have| {
have.same_item_same_components(stack)
&& Self::stackable(env, have)
&& have.count < CONTAINER_MAX.min(Self::max_stack(env, have))
&& have.count < Self::max_stack(env, have)
})
})
}
@@ -1891,7 +1893,7 @@ impl Menu {
empty.count = 0;
empty
});
let room = CONTAINER_MAX.min(Self::max_stack(env, cell)) - cell.count;
let room = Self::max_stack(env, cell).saturating_sub(cell.count);
let add = stack.count.min(room);
cell.count += add;
tidy(&mut self.containers[PLAYER][slot]);
+1 -3
View File
@@ -580,9 +580,7 @@ impl Menu {
count: 0,
..stack.clone()
});
let room = super::CONTAINER_MAX
.min(Self::max_stack(env, cell))
.saturating_sub(cell.count);
let room = Self::max_stack(env, cell).saturating_sub(cell.count);
let add = stack.count.min(room);
cell.count += add;
stack.count -= add;
+27 -4
View File
@@ -1827,12 +1827,11 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
let d = m.distance_sqr(p);
let persistent = m.persistent || requires_custom_persistence(m);
// removeWhenFarAway: animals, villagers, golems, patrols and raiders,
// zombie villagers being cured and ridden mobs stay.
// zombie villagers being cured and ridden mobs stay, bar the two felines.
let creature = m.kind.category == "creature";
let feline_goes = feline_removed_when_far(m.species(), m.extra.tame || m.extra.trusting, m.age);
let removable = !(never_removed_when_far(m.species())
|| creature && m.kind.flags.animal && !m.extra.jockey && m.species() != Species::Ocelot
// Ocelot.removeWhenFarAway: `!isTrusting() && tickCount > 2400`.
|| m.species() == Species::Ocelot && (m.extra.trusting || m.age <= 2400)
|| creature && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|| super::raider::keeps_alive(m, d)
|| super::zombie_villager::keeps_alive(m)
|| super::riders::keeps_alive(m));
@@ -1858,6 +1857,13 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
}
true
}
/// `Cat.removeWhenFarAway` and `Ocelot.removeWhenFarAway`: an untamed cat or an
/// untrusting ocelot is removed once it is older than 2400 ticks. That is
/// `Entity.tickCount`, not `AgeableMob.age`, which only counts a baby up to
/// adulthood. `Animal.removeWhenFarAway` keeps every other animal.
fn feline_removed_when_far(s: Species, tamed: bool, ticks: u32) -> bool {
matches!(s, Species::Cat | Species::Ocelot) && !tamed && ticks > 2400
}
/// The types whose `removeWhenFarAway` is always false (`Villager`,
/// `WanderingTrader`, `AbstractGolem`: iron, snow and copper golems and
/// shulkers; `Allay`, `Warden`), and the wither, whose `checkDespawn` never
@@ -2787,6 +2793,23 @@ mod despawn_tests {
assert!(!never_removed_when_far(s), "{s:?}");
}
}
/// `Cat.removeWhenFarAway` (`!isTame() && tickCount > 2400`) and
/// `Ocelot.removeWhenFarAway` (`!isTrusting() && tickCount > 2400`): the two
/// animals vanilla lets go, on `Entity.tickCount` and never on
/// `AgeableMob.age`, which an adult keeps at 0.
#[test]
fn an_untamed_feline_goes_once_it_is_old() {
for s in [Species::Cat, Species::Ocelot] {
assert!(!feline_removed_when_far(s, false, 2400), "{s:?}");
assert!(feline_removed_when_far(s, false, 2401), "{s:?}");
assert!(!feline_removed_when_far(s, true, 12000), "{s:?}");
}
// Animal.removeWhenFarAway: every other animal stays at any age.
for s in [Species::Cow, Species::Sheep, Species::Wolf] {
assert!(!feline_removed_when_far(s, false, 12000), "{s:?}");
}
}
}
#[cfg(test)]
+12 -13
View File
@@ -204,30 +204,29 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
Who::Player => cx.w.player.as_ref().is_some_and(|p| p.has_effect(effect)),
Who::Mob(id) => cx.w.seen(id).is_some_and(|s| s.has_effect(effect)),
};
let (effect, ticks, amplifier) = if heals {
// The potion it throws: `PotionContents.createItemStack(Items.SPLASH_POTION,
// potion)` for the one `Witch.performRangedAttack` picks.
let potion = if heals {
m.ai.target = None;
if health <= 4.0 {
("minecraft:instant_health", 1, 0)
"minecraft:healing"
} else {
("minecraft:regeneration", 900, 0)
"minecraft:regeneration"
}
} else if dist >= 8.0 && !has("slowness") {
("minecraft:slowness", 1800, 0)
"minecraft:slowness"
} else if health >= 8.0 && !has("poison") {
("minecraft:poison", 900, 0)
"minecraft:poison"
} else if dist <= 3.0 && !has("weakness") && m.random.next_float() < 0.25 {
("minecraft:weakness", 1800, 0)
"minecraft:weakness"
} else {
("minecraft:instant_damage", 1, 0)
"minecraft:harming"
};
let start = Vec3::new(m.position.x, m.eye_y() - 0.1, m.position.z);
let mut p = Projectile::new(
ProjectileKind::Potion {
effect,
ticks,
amplifier,
color: 0,
},
ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new("minecraft:splash_potion", 1).with_potion(potion),
)),
start,
Some(Who::Mob(m.id)),
);
+7 -4
View File
@@ -16,10 +16,10 @@ pub use crate::first_person::Hand;
pub use crate::fog::{FogData, FogType};
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
pub use crate::game::{
NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeDataMapType,
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayerRender,
NativeShake,
ItemEnergyBuffer, NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks,
NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem,
NativeHit, NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent,
NativePlayerRender, NativeShake,
};
pub use crate::gfx::native_render::{
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
@@ -185,6 +185,9 @@ pub struct NativeScreen {
/// The mod draws the title and the "Inventory" label itself
/// (`native_screen_draw`); Horizon leaves them out.
pub own_labels: bool,
/// The mod draws its own background (`native_screen_draw`), so Horizon
/// leaves out the plain panel and the slot frames.
pub own_background: bool,
}
/// A native mod as Options > Mods lists it (`native_mods`).
+90 -5
View File
@@ -1199,11 +1199,12 @@ pub trait Hooks: Send + Sync {
/// with those of `horizon-client/src/game/native_hooks.rs`): `components` holds
/// `item`'s default components in `items.json`'s form (`"minecraft:max_stack_size": 16`,
/// `"minecraft:food": {"nutrition": 4, "saturation": 2.4}`); insert or replace one to
/// set it, remove it to take it away (a stack size outside 1 to 99 is brought back
/// into it). Asked for every item each time the game reads
/// the defaults, from any thread, several times as resources load and again when a
/// world's data packs load: answer the same each time. Client and server each ask
/// their own mods, as NeoForge does, so both sides need the mod.
/// set it, remove it to take it away (a stack size outside 1 to `i32::MAX` is
/// brought back into it, and one that is not a number leaves the data pack's
/// own). Asked for every item each time the game reads the defaults, from any
/// thread, several times as resources load and again when a world's data packs
/// load: answer the same each time. Client and server each ask their own mods,
/// as NeoForge does, so both sides need the mod.
/// 79.
///
/// Every mod gets `event.components` in order and may add, change or remove components.
@@ -2953,6 +2954,26 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Item `item` (`mod_id:name`) holds energy (`Capabilities.Energy.ITEM`):
/// set `buffer` when the item is yours, as `SimpleEnergyHandler` is built:
/// its capacity and the most one insert and one extract move. A capacity
/// under 1 leaves the buffer out, and the limits are clamped to
/// 0..=capacity; vanilla's items have none. Asked at each call of the item
/// energy helpers (`native_item_energy`, `native_item_energy_insert`,
/// `native_item_energy_extract`, in `native_item_energy.rs`). The amount
/// is kept in the stack's `minecraft:custom_data`, as the int
/// `horizon_energy`. API @API:item_energy_buffer@.
///
/// Return `Some` to set `buffer`; the first mod returning `Some` wins.
fn item_energy_buffer(
&self,
host: &dyn Host,
event: &mut ItemEnergyBufferEvent<'_>,
) -> Option<ItemEnergyBuffer> {
let _ = host;
let _ = event;
None
}
/// A dropped item entity was successfully added to the server level.
/// @API:native_item_spawn@.
fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) {
@@ -3934,6 +3955,24 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Enough players have slept through the night in level `dimension`
/// and the clock is about to move to the morning (NeoForge's
/// `SleepFinishedTimeEvent`, vanilla's `ServerLevel.tick` when it moves
/// the clock to `wake_up_from_sleep`): `new_time` comes in as the next
/// morning's day time and may be changed to wake everyone at another
/// time. A time before `current` (the day time now) is refused, as
/// `setTimeAddition` refuses one, and the morning stays. Unlike
/// NeoForge, which refuses a bad value as it is set, the check runs once
/// after every mod, so a later mod sees the value an earlier one left.
/// On the server, only when the `advance_time` rule is on;
/// `native_time_skip` then hears the jump to the time chosen here and
/// may still cancel it. The players wake and the weather resets as
/// usual either way.
/// API @API:sleep_finished_time@.
fn sleep_finished_time(&self, host: &mut dyn Host, event: &mut SleepFinishedTimeEvent<'_>) {
let _ = host;
let _ = event;
}
/// A sponge at `pos` absorbed water and became wet. Server-side.
/// @API:native_sponge_absorb@.
fn sponge_absorb(&self, level: &dyn LevelHost, event: &mut SpongeAbsorbEvent) {
@@ -9773,6 +9812,28 @@ pub fn dispatch_item_crafted(
mod_hook.item_crafted(host, &mut event);
}
}
/// What [`Hooks::item_energy_buffer`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemEnergyBufferEvent<'a> {
pub item: &'a str,
}
pub fn dispatch_item_energy_buffer(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
item: &str,
buffer: &mut Option<ItemEnergyBuffer>,
) {
if buffer.is_some() {
return;
}
let mut event = ItemEnergyBufferEvent { item };
for mod_hook in mods {
if let Some(replacement) = mod_hook.item_energy_buffer(host, &mut event) {
*buffer = Some(replacement);
break;
}
}
}
/// What [`Hooks::item_spawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemSpawnEvent<'a> {
@@ -11896,6 +11957,30 @@ pub fn dispatch_sculk_bloom(
mod_hook.sculk_bloom(host, &mut event);
}
}
/// What [`Hooks::sleep_finished_time`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct SleepFinishedTimeEvent<'a> {
pub dimension: &'a str,
pub current: i64,
pub new_time: i64,
}
pub fn dispatch_sleep_finished_time(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
dimension: &str,
current: i64,
new_time: &mut i64,
) {
let mut event = SleepFinishedTimeEvent {
dimension,
current,
new_time: *new_time,
};
for mod_hook in mods {
mod_hook.sleep_finished_time(host, &mut event);
}
*new_time = event.new_time;
}
/// What [`Hooks::sponge_absorb`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct SpongeAbsorbEvent {
+47 -1
View File
@@ -1375,7 +1375,9 @@ impl Screen {
}
}
/// `DirectJoinServerScreen.keyPressed`: Enter joins the typed address
/// while the Join Server button is active.
/// while the Join Server button is active. On the server list it is
/// `ServerSelectionList.OnlineServerEntry.keyPressed`: Enter joins the
/// selected server or LAN game, as a click on it does.
pub fn confirm(&self) -> Outcome {
match self {
Self::Direct { address } if servers::parse_address(address).is_some() => {
@@ -1384,6 +1386,15 @@ impl Screen {
lan: false,
}
}
Self::List {
servers,
selected,
lan,
..
} => match selected.and_then(|i| entry_address(servers, lan, i)) {
Some((address, lan)) => Outcome::Connect { address, lan },
None => Outcome::None,
},
_ => Outcome::None,
}
}
@@ -2361,6 +2372,41 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_joins_the_selected_server() {
let dir = std::env::temp_dir().join(format!("horizon-mp-enter-{}", std::process::id()));
let saves = dir.join("saves");
std::fs::create_dir_all(&saves).unwrap();
let file = servers_file(&saves);
let list = vec![
ServerEntry::new("a", "127.0.0.1:2"),
ServerEntry::new("b", "127.0.0.1:3"),
];
servers::save(&file, &list).unwrap();
let mut screen = Screen::list(&saves);
// Nothing selected: Enter joins nothing.
assert!(matches!(screen.confirm(), Outcome::None));
let size = (427.0, 240.0);
assert!(screen.arrow(false, false, size, &saves));
match screen.confirm() {
Outcome::Connect { address, lan } => {
assert_eq!(address, "127.0.0.1:2");
assert!(!lan);
}
_ => panic!("Enter did not join the selected server"),
}
// Down picks the next server, and Enter joins that one.
assert!(screen.arrow(false, false, size, &saves));
match screen.confirm() {
Outcome::Connect { address, lan } => {
assert_eq!(address, "127.0.0.1:3");
assert!(!lan);
}
_ => panic!("Enter did not join the second server"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn icon_arrows_join_and_move_servers() {
let dir = std::env::temp_dir().join(format!("horizon-mp-arrows-{}", std::process::id()));
+41 -29
View File
@@ -105,13 +105,11 @@ pub enum ProjectileKind {
WitherSkull {
dangerous: bool,
},
/// Splash potion thrown by witches: effect id, duration.
Potion {
effect: &'static str,
ticks: i32,
amplifier: i32,
color: u32,
},
/// `ThrownSplashPotion` or `ThrownLingeringPotion`: the potion item it
/// flies as (`ThrowableItemProjectile.getItem`). What it splashes is that
/// stack's `potion_contents`, so a witch's potion and a summoned one are
/// the same thing; its entity type is the stack's item.
Potion(Box<crate::items::ItemStack>),
/// a content mod's type.
Thrown(&'static ThrownType),
}
@@ -137,7 +135,7 @@ impl ProjectileKind {
| Self::Egg
| Self::EnderPearl
| Self::LlamaSpit
| Self::Potion { .. }
| Self::Potion(_)
| Self::Thrown(_) => 0.25,
Self::SmallFireball
| Self::WindCharge
@@ -162,11 +160,29 @@ impl ProjectileKind {
Self::BreezeWindCharge => "minecraft:breeze_wind_charge",
Self::ShulkerBullet { .. } => "minecraft:shulker_bullet",
Self::WitherSkull { .. } => "minecraft:wither_skull",
Self::Potion { .. } => "minecraft:splash_potion",
// `ThrownSplashPotion`/`ThrownLingeringPotion`: the item names it.
Self::Potion(stack) => potion_entity_type(&stack.item),
Self::Thrown(t) => t.id,
}
}
}
/// The entity type a thrown potion's item names: `ThrownSplashPotion` for a
/// splash potion, `ThrownLingeringPotion` for a lingering one.
fn potion_entity_type(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
"minecraft:lingering_potion"
} else {
"minecraft:splash_potion"
}
}
/// `ThrownItemRenderer`'s sprite for a thrown potion.
fn potion_sprite(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
"item/lingering_potion"
} else {
"item/splash_potion"
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Projectile {
/// The entity id a server tracks it by (0 until it is tracked).
@@ -438,7 +454,7 @@ impl Projectile {
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
ProjectileKind::LlamaSpit => 0.06,
ProjectileKind::Potion { .. } => 0.05,
ProjectileKind::Potion(_) => 0.05,
_ => 0.03,
}
}
@@ -839,9 +855,11 @@ impl Projectile {
}
}
_ => {
// ThrowableProjectile: gravity, then inertia.
// ThrowableProjectile: gravity, then inertia. Its drag is a
// float in vanilla (0.8F in water, `getAirDrag` 0.99F), so
// widens to the double that scales the motion.
self.velocity.y -= self.gravity();
let inertia = if water { 0.8 } else { 0.99 };
let inertia = f64::from(if water { 0.8_f32 } else { 0.99_f32 });
self.velocity = Vec3::new(
self.velocity.x * inertia,
self.velocity.y * inertia,
@@ -945,31 +963,24 @@ impl Projectile {
damage as f32
}
/// the item a thrown potion or a mod's type is drawn as with its model
/// (`ThrownItemRenderer`): the splash potion `Witch.performRangedAttack`
/// makes for the effect, or the type's item.
/// (`ThrownItemRenderer`): the potion stack itself, or the type's item.
pub fn drawn_item(&self) -> Option<crate::items::ItemStack> {
let effect = match self.kind {
ProjectileKind::Potion { effect, .. } => effect,
ProjectileKind::Thrown(t) => return Some(crate::items::ItemStack::new(t.item, 1)),
_ => return None,
};
let potion = match effect {
"minecraft:instant_health" => "minecraft:healing",
"minecraft:instant_damage" => "minecraft:harming",
other => other,
};
Some(crate::items::ItemStack::new("minecraft:splash_potion", 1).with_potion(potion))
match &self.kind {
ProjectileKind::Potion(stack) => Some((**stack).clone()),
ProjectileKind::Thrown(t) => Some(crate::items::ItemStack::new(t.item, 1)),
_ => None,
}
}
/// The item sprite drawn for thrown projectiles (ThrownItemRenderer).
pub fn item_sprite(&self) -> Option<&'static str> {
Some(match self.kind {
Some(match &self.kind {
ProjectileKind::Snowball => "item/snowball",
ProjectileKind::Egg => "item/egg",
ProjectileKind::EnderPearl => "item/ender_pearl",
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
"item/fire_charge"
}
ProjectileKind::Potion { .. } => "item/splash_potion",
ProjectileKind::Potion(stack) => potion_sprite(&stack.item),
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
@@ -1016,8 +1027,9 @@ mod tests {
let w = Empty(vec![]);
p.tick(&w);
// ThrowableProjectile: gravity before inertia, then move.
// `getAirDrag` is 0.99F, widened to double: 0.9900000095367432.
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9);
assert!((p.position.x - 0.99).abs() < 1e-9);
assert!((p.position.x - f64::from(0.99_f32)).abs() < 1e-9);
}
/// A wall of blocks across x = 1.
struct Wall(Vec<(Who, Aabb)>);
@@ -1091,7 +1103,7 @@ mod tests {
let (keep, none) = through.tick_through(&world);
assert!(keep && none == Hit::None);
// Gravity and drag of the tick, then the whole of its motion.
assert!((through.position.x - 1.5 * 0.99).abs() < 1e-9);
assert!((through.position.x - 1.5 * f64::from(0.99_f32)).abs() < 1e-9);
}
#[test]
fn a_snowball_with_no_gravity_stays_where_it_was_summoned() {
+8
View File
@@ -370,6 +370,14 @@ mod tests {
goat.extra.age = -24000;
// Goat.getAgeScale: 0.55.
assert_eq!(mob_shadow_radius(&goat), 0.7 * 0.55);
let mut strider = mob("minecraft:strider", S::Strider);
strider.extra.age = -24000;
// StriderRenderer halves AgeableMobRenderer's 0.5 * 0.5 again.
assert_eq!(mob_shadow_radius(&strider), 0.125);
let mut turtle = mob("minecraft:turtle", S::Turtle);
turtle.extra.age = -24000;
// TurtleRenderer takes 0.83 of the 0.7 * 0.3 age-scaled radius.
assert!((mob_shadow_radius(&turtle) - 0.7 * 0.3 * 0.83).abs() < 1e-6);
}
#[test]
fn scaled_players_cast_a_scaled_shadow() {
@@ -3,7 +3,8 @@
use crate::save::{read_dat, write_dat};
use horizon_world::nbt::Tag;
use std::{
fs, io,
fs,
io::{self, Seek, Write},
path::{Path, PathBuf},
};
@@ -150,11 +151,24 @@ pub fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
result
}
fn write_tree(
zip: &mut zip::ZipWriter<fs::File>,
fn write_tree<W: Write + Seek>(
zip: &mut zip::ZipWriter<W>,
root: &Path,
dir: &Path,
folder: &str,
) -> crate::Result<()> {
write_tree_skipping(zip, root, dir, folder, &["session.lock"])
}
/// Every regular file under `root` (named `<prefix>/<relative>` in the zip),
/// except the names in `skip`. Symlinks are refused. The repro bundle zips a
/// whole recording with it.
pub fn write_tree_skipping<W: Write + Seek>(
zip: &mut zip::ZipWriter<W>,
root: &Path,
dir: &Path,
prefix: &str,
skip: &[&str],
) -> crate::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
@@ -163,11 +177,14 @@ fn write_tree(
if kind.is_symlink() {
return Err(format!("Cannot back up symbolic link: {}", path.display()).into());
}
let skipped = skip.iter().any(|name| entry.file_name() == *name);
if kind.is_dir() {
write_tree(zip, root, &path, folder)?;
} else if kind.is_file() && entry.file_name() != "session.lock" {
if !skipped {
write_tree_skipping(zip, root, &path, prefix, skip)?;
}
} else if kind.is_file() && !skipped {
let relative = path.strip_prefix(root)?;
let name = format!("{folder}/{}", relative.to_string_lossy().replace('\\', "/"));
let name = format!("{prefix}/{}", relative.to_string_lossy().replace('\\', "/"));
zip.start_file(
name,
zip::write::SimpleFileOptions::default()
@@ -217,6 +217,7 @@ fn relit_columns_keep_every_mesh_current() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(0),
blend_radius: 2,
smooth_lighting: true,
@@ -129,6 +129,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(0),
blend_radius: 2,
smooth_lighting: true,
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "horizon-mcp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The Model Context Protocol, one implementation for Horizon's three servers (horizon-ctl mcp, horizon-server mcp, horizon-launcher mcp)"
[dependencies]
serde_json = "1"
[lints]
workspace = true
+476
View File
@@ -0,0 +1,476 @@
//! The Model Context Protocol, as Horizon's three servers speak it (`horizon-ctl mcp`,
//! `horizon-server mcp`, `horizon-launcher mcp`: docs/agent-control.md, docs/server.md section 8):
//! JSON-RPC 2.0 over stdin and stdout, one message per line.
//!
//! Two revisions, newest first. `2026-07-28` is stateless: every request carries its version,
//! its capabilities and who it is in `_meta`, servers answer `server/discover`, every result
//! says `resultType`, and the list results carry `ttlMs` and `cacheScope`. `2025-06-18` opens
//! with `initialize` and is kept for the clients that still do that (the launcher's `game_*`
//! tools talk to whichever `horizon-ctl` version the player has installed, which may be older
//! than this crate). A request carrying `_meta` is served statelessly whatever the process
//! answered before it, as the revision requires.
//!
//! Each server supplies its identity, its instructions and the two tool methods; the protocol
//! methods (`server/discover`, `initialize`), the framing and the line loop live here, so the
//! three cannot drift apart.
use serde_json::{Map, Value, json};
use std::io::{BufRead, Write};
/// The newest revision: no handshake, every request carries `_meta`.
pub const MODERN: &str = "2026-07-28";
/// The handshake revision, still served for the clients that call `initialize`.
pub const LEGACY: &str = "2025-06-18";
/// Every revision served here, newest first: what `server/discover` advertises.
pub const VERSIONS: &[&str] = &[MODERN, LEGACY];
/// The revisions `initialize` may select: the stateless one has no handshake to open.
pub const LEGACY_VERSIONS: &[&str] = &[LEGACY];
/// `_meta` key: the protocol version the request uses (required on every modern request).
pub const VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
/// `_meta` key: the client's capabilities (required on every modern request).
pub const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
/// `_meta` key: who the server is, in every modern result.
pub const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
/// `UnsupportedProtocolVersion`: the request named a revision this server does not serve.
pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
/// `ttlMs` for `tools/list`: starting a game or a server adds tools to the launcher's list, and
/// no `notifications/tools/list_changed` follows it (that needs `subscriptions/listen`), so the
/// hint stays short rather than hiding the new tools until it expires.
pub const LIST_TTL_MS: i64 = 30_000;
/// `ttlMs` for `server/discover`: versions, capabilities and identity are fixed per process.
pub const DISCOVER_TTL_MS: i64 = 3_600_000;
/// Neither list holds anything specific to one caller.
pub const CACHE_SCOPE: &str = "public";
/// One server: who it says it is, and what an agent reads before its first call.
#[derive(Clone, Copy)]
pub struct Server<'a> {
pub name: &'a str,
pub version: &'a str,
pub instructions: &'a str,
}
/// A failed request: the JSON-RPC code, its message, and the `data` some errors carry.
struct Fault {
code: i64,
message: String,
data: Option<Value>,
}
impl Fault {
fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
fn with(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
impl From<(i64, String)> for Fault {
fn from((code, message): (i64, String)) -> Self {
Self::new(code, message)
}
}
/// `initialize` (the handshake revision): the client's version when it is one we serve,
/// otherwise ours. The client has no fall-forward, so never claim one we do not implement.
pub fn negotiate_legacy(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
LEGACY_VERSIONS
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(LEGACY_VERSIONS[0])
}
impl<'a> Server<'a> {
pub const fn new(name: &'a str, version: &'a str, instructions: &'a str) -> Self {
Server {
name,
version,
instructions,
}
}
/// Serve the process's stdin and stdout until the client closes them.
///
/// `app` answers the two tool methods (`tools/list` and `tools/call`); the protocol
/// methods and the framing are this module's.
pub fn serve<F>(&self, mut app: F) -> Result<(), String>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
if let Some(reply) = self.handle_line(&line, &mut app) {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// One line of stdin in, one line of stdout out; none for a notification.
pub fn handle_line<F>(&self, line: &str, app: &mut F) -> Option<Value>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
match serde_json::from_str::<Value>(line) {
Ok(message) => self.handle(&message, app),
Err(e) => Some(error(Value::Null, -32700, e.to_string(), None)),
}
}
fn handle<F>(&self, message: &Value, app: &mut F) -> Option<Value>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
// A request has an id, a notification has none and gets no reply.
let id = match message.get("id") {
None => return None,
Some(Value::Null) => {
return Some(error(
Value::Null,
-32600,
"A request's id is a string or an integer, not null".into(),
None,
));
}
Some(id) => id.clone(),
};
let method = message
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
Some(match self.dispatch(method, &params, app) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(Fault {
code,
message,
data,
}) => error(id, code, message, data),
})
}
fn dispatch<F>(&self, method: &str, params: &Value, app: &mut F) -> Result<Value, Fault>
where
F: FnMut(&str, &Value) -> Result<Value, (i64, String)>,
{
// `initialize` opens the handshake revision, whatever else the request carries.
if method == "initialize" {
return Ok(self.initialize(params));
}
// `server/discover` is always answered modern: it is how a client learns the versions
// this server serves, and the probe a client that speaks both revisions sends first.
if method == "server/discover" {
return Ok(self.discover());
}
// The revision comes from the request itself. Without `_meta` the client is on the
// handshake revision; with it, the version it names must be one we serve.
let version = match meta(params, VERSION_KEY) {
None => None,
Some(requested) => match VERSIONS.iter().copied().find(|v| *v == requested) {
Some(version) => Some(version),
None => {
return Err(Fault::new(
UNSUPPORTED_PROTOCOL_VERSION,
"Unsupported protocol version",
)
.with(json!({ "supported": VERSIONS, "requested": requested })));
}
},
};
// A modern request without the client's capabilities is malformed (-32602), and the
// server must not rely on capabilities the client did not declare.
if version == Some(MODERN) && meta_value(params, CLIENT_CAPABILITIES_KEY).is_none() {
return Err(Fault::new(
-32602,
format!("_meta.{CLIENT_CAPABILITIES_KEY} is required"),
));
}
let result = match method {
"tools/list" | "tools/call" => app(method, params).map_err(Fault::from)?,
// `ping` was removed in 2026-07-28: only the handshake revision still has it.
"ping" if version != Some(MODERN) => json!({}),
_ => return Err(Fault::new(-32601, format!("Method not found: {method}"))),
};
Ok(self.frame(version, method, result))
}
/// `initialize`: the handshake revision's answer, unchanged since 2025-06-18.
fn initialize(&self, params: &Value) -> Value {
json!({
"protocolVersion": negotiate_legacy(params),
"capabilities": self.capabilities(),
"serverInfo": self.info(),
"instructions": self.instructions,
})
}
/// `server/discover`: the versions, capabilities and identity of this server.
fn discover(&self) -> Value {
json!({
"resultType": "complete",
"supportedVersions": VERSIONS,
"capabilities": self.capabilities(),
"_meta": meta_object(self.info()),
"instructions": self.instructions,
"ttlMs": DISCOVER_TTL_MS,
"cacheScope": CACHE_SCOPE,
})
}
/// What every revision since 2026-07-28 puts around a result: its type, the cache hints on
/// the cacheable ones, and who answered. The handshake revision's results carry none of it.
fn frame(&self, version: Option<&'static str>, method: &str, mut result: Value) -> Value {
if version != Some(MODERN) {
return result;
}
let Some(object) = result.as_object_mut() else {
return result;
};
object.insert("resultType".into(), json!("complete"));
if method == "tools/list" {
object.insert("ttlMs".into(), json!(LIST_TTL_MS));
object.insert("cacheScope".into(), json!(CACHE_SCOPE));
}
let meta = object.entry("_meta").or_insert_with(|| json!({}));
if let Some(meta) = meta.as_object_mut() {
meta.insert(SERVER_INFO_KEY.into(), self.info());
}
result
}
fn info(&self) -> Value {
json!({ "name": self.name, "version": self.version })
}
/// Only tools: no resources, prompts, sampling or logging to declare.
fn capabilities(&self) -> Value {
json!({ "tools": {} })
}
}
/// A header of the request's `params`, whatever its type.
fn meta_value<'v>(params: &'v Value, key: &str) -> Option<&'v Value> {
params.get("_meta")?.get(key)
}
/// A header of the request's `params`, when it is a string.
fn meta<'v>(params: &'v Value, key: &str) -> Option<&'v str> {
meta_value(params, key)?.as_str()
}
/// A result's `_meta`: who answered.
fn meta_object(info: Value) -> Value {
let mut meta = Map::new();
meta.insert(SERVER_INFO_KEY.to_owned(), info);
Value::Object(meta)
}
fn error(id: Value, code: i64, message: String, data: Option<Value>) -> Value {
let mut error = json!({ "code": code, "message": message });
if let Some(data) = data {
error["data"] = data;
}
json!({ "jsonrpc": "2.0", "id": id, "error": error })
}
#[cfg(test)]
mod tests {
use super::*;
const SERVER: Server = Server::new("test-server", "1.2.3", "Do the thing.");
/// The answer to one request, with a tool list and a tool result the way a server answers them.
fn answer(request: Value) -> Value {
let mut app = |method: &str, _params: &Value| match method {
"tools/list" => Ok(json!({ "tools": [{ "name": "state" }] })),
"tools/call" => {
Ok(json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false }))
}
other => Err((-32601, format!("Method not found: {other}"))),
};
SERVER.handle_line(&request.to_string(), &mut app).unwrap()
}
/// A modern request's `params`: the version and the capabilities `_meta` must carry.
fn modern(mut params: Value, version: &str) -> Value {
let mut meta = Map::new();
meta.insert(VERSION_KEY.to_owned(), json!(version));
meta.insert(CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
params["_meta"] = Value::Object(meta);
params
}
fn request(id: i64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
#[test]
fn initialize_answers_a_version_we_serve() {
let reply = answer(request(
1,
"initialize",
json!({ "protocolVersion": "2025-06-18" }),
));
assert_eq!(reply["result"]["protocolVersion"], LEGACY);
assert_eq!(reply["result"]["serverInfo"]["name"], "test-server");
assert_eq!(reply["result"]["serverInfo"]["version"], "1.2.3");
assert_eq!(reply["result"]["instructions"], "Do the thing.");
assert_eq!(reply["result"]["capabilities"]["tools"], json!({}));
// The handshake revision's results carry no `resultType`.
assert!(reply["result"].get("resultType").is_none());
}
#[test]
fn initialize_never_claims_a_version_we_do_not_serve() {
for wanted in [
json!({ "protocolVersion": MODERN }),
json!({ "protocolVersion": "1999-01-01" }),
json!({ "protocolVersion": 7 }),
json!({}),
] {
let reply = answer(request(1, "initialize", wanted));
assert_eq!(reply["result"]["protocolVersion"], LEGACY);
}
}
#[test]
fn discover_lists_what_this_server_serves() {
// No `_meta`: the stdio probe a client that speaks both revisions sends first.
let reply = answer(request(1, "server/discover", json!({})));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["supportedVersions"], json!(VERSIONS));
assert_eq!(result["capabilities"]["tools"], json!({}));
assert_eq!(result["_meta"][SERVER_INFO_KEY]["name"], "test-server");
assert_eq!(result["instructions"], "Do the thing.");
assert_eq!(result["ttlMs"], json!(DISCOVER_TTL_MS));
assert_eq!(result["cacheScope"], CACHE_SCOPE);
}
#[test]
fn an_unsupported_version_is_an_error_that_lists_ours() {
let reply = answer(request(1, "tools/list", modern(json!({}), "1900-01-01")));
assert_eq!(
reply["error"]["code"],
json!(UNSUPPORTED_PROTOCOL_VERSION),
"{reply}"
);
assert_eq!(reply["error"]["data"]["requested"], "1900-01-01");
assert_eq!(reply["error"]["data"]["supported"], json!(VERSIONS));
}
#[test]
fn a_modern_request_without_capabilities_is_malformed() {
let mut params = json!({});
params["_meta"] = json!({ VERSION_KEY: MODERN });
let reply = answer(request(1, "tools/list", params));
assert_eq!(reply["error"]["code"], json!(-32602));
assert!(
reply["error"]["message"]
.as_str()
.unwrap()
.contains(CLIENT_CAPABILITIES_KEY)
);
}
#[test]
fn tools_list_is_cacheable_and_identified() {
let reply = answer(request(1, "tools/list", modern(json!({}), MODERN)));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["ttlMs"], json!(LIST_TTL_MS));
assert_eq!(result["cacheScope"], CACHE_SCOPE);
assert_eq!(result["_meta"][SERVER_INFO_KEY]["name"], "test-server");
assert_eq!(result["tools"][0]["name"], "state");
}
#[test]
fn tools_call_says_it_completed_but_is_not_cacheable() {
let reply = answer(request(
1,
"tools/call",
modern(json!({ "name": "state" }), MODERN),
));
let result = &reply["result"];
assert_eq!(result["resultType"], "complete");
assert_eq!(result["isError"], false);
assert_eq!(result["_meta"][SERVER_INFO_KEY]["version"], "1.2.3");
assert!(result.get("ttlMs").is_none(), "{result}");
}
#[test]
fn the_handshake_revisions_results_stay_as_they_were() {
// A legacy client sends no `_meta`, and its results carry no modern framing.
let list = answer(request(1, "tools/list", json!({})));
assert_eq!(list["result"], json!({ "tools": [{ "name": "state" }] }));
let call = answer(request(2, "tools/call", json!({ "name": "state" })));
assert_eq!(
call["result"],
json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false })
);
}
#[test]
fn ping_belongs_to_the_handshake_revision_only() {
assert_eq!(answer(request(1, "ping", json!({})))["result"], json!({}));
assert_eq!(
answer(request(1, "ping", modern(json!({}), MODERN)))["error"]["code"],
json!(-32601)
);
}
#[test]
fn unknown_methods_and_notifications() {
let reply = answer(request(1, "nope/nope", json!({})));
assert_eq!(reply["error"]["code"], json!(-32601));
// A notification (no id) gets no reply at all.
let mut app = |_: &str, _: &Value| Ok(json!({}));
let note = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
assert!(SERVER.handle_line(&note.to_string(), &mut app).is_none());
}
#[test]
fn a_request_whose_id_is_null_is_invalid() {
let mut app = |_: &str, _: &Value| Ok(json!({}));
let line = json!({ "jsonrpc": "2.0", "id": null, "method": "tools/list" });
let reply = SERVER.handle_line(&line.to_string(), &mut app).unwrap();
assert_eq!(reply["error"]["code"], json!(-32600));
}
#[test]
fn a_broken_line_is_a_parse_error() {
let mut app = |_: &str, _: &Value| Ok(json!({}));
let reply = SERVER.handle_line("{not json", &mut app).unwrap();
assert_eq!(reply["error"]["code"], json!(-32700));
assert_eq!(reply["id"], Value::Null);
}
#[test]
fn an_app_error_stays_a_json_rpc_error() {
let mut app = |_: &str, _: &Value| Err((-32602, "name is required".to_string()));
let reply = SERVER
.handle_line(&request(1, "tools/call", json!({})).to_string(), &mut app)
.unwrap();
assert_eq!(reply["error"]["code"], json!(-32602));
assert_eq!(reply["error"]["message"], "name is required");
}
}
+2
View File
@@ -8,6 +8,8 @@ description = "Host a Horizon server: server.toml, the CLI and the supervisor th
[dependencies]
horizon-server-files = { path = "../horizon-server-files" }
# `horizon-server mcp`: the Model Context Protocol, shared with horizon-ctl and the launcher.
horizon-mcp = { path = "../horizon-mcp" }
# server.toml's [anticheat], which the game reads: checked here before every start.
horizon-anticheat = { path = "../horizon-anticheat" }
# server.toml's [chat_filter], which the game reads too: checked here before every start.
+37 -74
View File
@@ -5,23 +5,15 @@
use crate::api::Client;
use serde_json::{Map, Value, json};
use std::{
io::{BufRead, Write},
path::Path,
};
use std::path::Path;
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Run and moderate a Horizon (Minecraft Java Edition 26.3) server. Start with status and players. Moderation tools (kick, ban, pardon, ban_ip, op, whitelist...) run vanilla's console commands and answer `changed` and the console's lines; events and logs read what happened since a number they return. Destructive tools that can't be undone need confirm: true.";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-server", crate::VERSION, INSTRUCTIONS)
}
/// One tool: its name, what it does, its arguments (name, JSON type, what it is, required), and whether it
@@ -397,15 +389,10 @@ fn call(dir: &Path, name: &str, args: &Value) -> (Value, bool) {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(dir: &Path, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-server", "version": crate::VERSION },
"instructions": "Run and moderate a Horizon (Minecraft Java Edition 26.3) server. Start with status and players. Moderation tools (kick, ban, pardon, ban_ip, op, whitelist...) run vanilla's console commands and answer `changed` and the console's lines; events and logs read what happened since a number they return. Destructive tools that can't be undone need confirm: true.",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -425,51 +412,13 @@ fn handle(dir: &Path, method: &str, params: &Value) -> Result<Value, (i64, Strin
}
pub fn serve(dir: &Path) -> Result<(), String> {
let mut stdout = std::io::stdout();
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(message) => {
let method = message
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = message.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
message.get("id").cloned().map(|id| match handle(dir, method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|()| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| handle(dir, method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn every_tool_has_a_schema_and_a_call() {
@@ -523,20 +472,34 @@ mod tests {
fn the_protocol_answers_without_a_server() {
let dir = std::env::temp_dir().join(format!("horizon-server-mcp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let init = handle(
&dir,
"initialize",
&json!({ "protocolVersion": "2025-06-18" }),
)
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(
handle(&dir, "tools/list", &json!({})).unwrap()["tools"]
.as_array()
.unwrap()
.len()
> 20
// A client that opens with the handshake revision, as the launcher's game_* tools do.
let mut app = |method: &str, params: &Value| handle(&dir, method, params);
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
let init = server()
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-server");
// A modern client gets the same tools with the framing that revision requires.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let list = server()
.handle_line(
&line("tools/list", json!({ "_meta": Value::Object(meta) })),
&mut app,
)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 20);
let reply = handle(
&dir,
"tools/call",
+1 -1
View File
@@ -241,7 +241,7 @@ pub const MATERIAL_INPUT: Struct = Struct {
emission: "f32" = "How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks.",
normal: "vec3<f32>" = "The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs.",
tangent: "vec4<f32>" = "The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes.",
colored_light: "vec3<f32>" = "The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.",
colored_light: "vec3<f32>" = "The frame's coloured lights at this point (`[[particle_lights]]`, held and dropped items in their `[[lights]]` colour while dynamic lights are on, and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.",
},
};
+10 -4
View File
@@ -199,6 +199,15 @@ impl Material {
/// Whether the sprite or block `name` (`namespace:path`) fits `pattern`: the namespace
/// defaults to `minecraft`, and each `*` matches any run of characters.
/// The colour of the first of `lights` whose patterns match the block `name`
/// (`Manifest::light_of`, for a copy of the entries).
pub fn light_color(lights: &[LightColor], name: &str) -> Option<[f32; 3]> {
lights
.iter()
.find(|l| l.blocks.iter().any(|pattern| sprite_matches(pattern, name)))
.map(|l| l.color)
}
fn sprite_matches(pattern: &str, name: &str) -> bool {
let pattern = match pattern.contains(':') {
true => std::borrow::Cow::Borrowed(pattern),
@@ -851,10 +860,7 @@ impl Manifest {
/// The `[[lights]]` colour of the block `name` (`namespace:path`): the first entry with a
/// matching pattern.
pub fn light_of(&self, name: &str) -> Option<[f32; 3]> {
self.lights
.iter()
.find(|l| l.blocks.iter().any(|pattern| sprite_matches(pattern, name)))
.map(|l| l.color)
light_color(&self.lights, name)
}
/// The pack targets its post passes write (`[[post]] output`), in the order first
+30
View File
@@ -99,6 +99,10 @@ impl Macros {
m.define("MC_SHADOW_QUALITY", "1.0");
m.define("MC_HAND_DEPTH", "0.125");
m.define("MAX_COLOR_BUFFERS", "16");
// The normal and specular maps are on (OptiFine's shaders.txt, H: their options, on by
// default; Iris 1.11.7 defines both too, with or without a resource pack's maps).
m.define("MC_NORMAL_MAP", "");
m.define("MC_SPECULAR_MAP", "");
// Packs take Iris's extensions (compute programs, images, custom textures) where this
// is defined.
m.define("IS_IRIS", "");
@@ -329,6 +333,32 @@ pub(crate) fn is_ident(c: char) -> bool {
mod tests {
use super::*;
/// Packs take their normal- and specular-map branches, as they do with the maps on: a
/// declaration made only there is seen.
#[test]
fn programs_take_the_normal_and_specular_map_branches() {
let fsh = "#version 330\nout vec4 c;\n\
#ifdef MC_NORMAL_MAP\nconst float bump = 1.0;\n#endif\n\
#ifdef MC_SPECULAR_MAP\nconst float shine = 2.0;\n#endif\n\
void main() { c = vec4(bump, shine, 0.0, 1.0); }\n";
let vsh = "#version 330\nvoid main() { gl_Position = vec4(0.0); }\n";
let pack = crate::Pack::from_zip_bytes(
"P.zip",
crate::pack::tests::zip(&[
("shaders/composite.vsh", vsh),
("shaders/composite.fsh", fsh),
]),
)
.unwrap();
let m = Macros::standard();
let program = crate::translate_composite(
&crate::preprocess(&pack, "composite.vsh", &m),
&crate::preprocess(&pack, "composite.fsh", &m),
crate::Target::Buffers,
);
assert!(program.is_ok(), "{:?}", program.err());
}
#[test]
fn iris_features_are_defined_or_noted() {
let properties = crate::Properties::parse(
+90 -4
View File
@@ -1025,16 +1025,19 @@ fn compile(stage: &Stage, text: &str, header_lines: usize) -> Result<naga::Modul
};
// naga's frontend refuses a few things GL takes: a scalar's swizzle (`f.x`, GLSL 4.20), a
// matrix column's component written to (`m[1].y =`) and `min`/`max`/`clamp` of a `uint` and
// integer literals. Each refusal is rewritten where naga points, and the source parsed again.
// integer literals, and a function prototype declared again. Each refusal is rewritten where
// naga points, and the source parsed again.
let mut text = std::borrow::Cow::Borrowed(text);
let mut tries = 0;
let mut module = loop {
tries += 1;
match Frontend::default().parse(&Options::from(stage.kind), &text) {
Ok(m) => break m,
Err(e) => match (e.errors.iter())
.find_map(|e| scalar_swizzle(&text, e).or_else(|| unsigned_literals(&text, e)))
{
Err(e) => match e.errors.iter().find_map(|e| {
scalar_swizzle(&text, e)
.or_else(|| unsigned_literals(&text, e))
.or_else(|| repeated_prototype(&text, e))
}) {
Some(fixed) if tries < 256 => text = fixed.into(),
_ => return parse(&text),
},
@@ -1168,6 +1171,33 @@ fn unsigned_literals(text: &str, e: &naga::front::glsl::Error) -> Option<String>
changed.then(|| format!("{}{out}{}", &text[..r.start], &text[r.end..]))
}
/// `text` without the function prototype naga's frontend refused as declared before (`e`): GLSL
/// lets a function be declared again (packs include the same helper's prototype from two
/// files). The prototype and its `;` turn to spaces, keeping lines where they are; one with
/// qualifiers before its type (naga's span starts at the type) is left alone.
fn repeated_prototype(text: &str, e: &naga::front::glsl::Error) -> Option<String> {
if e.kind.to_string() != "Prototype already defined" {
return None;
}
let r = e.meta.to_range()?;
let end = r.end + text.get(r.end..)?.find(';')? + 1;
if !text.get(r.end..end - 1)?.trim().is_empty() {
return None;
}
let before = text.get(..r.start)?.trim_end();
let line = &before[before.rfind('\n').map_or(0, |n| n + 1)..];
let boundary =
before.is_empty() || before.ends_with([';', '}']) || line.trim_start().starts_with('#');
if !boundary {
return None;
}
let blank: String = text[r.start..end]
.chars()
.map(|c| if c == '\n' { '\n' } else { ' ' })
.collect();
Some(format!("{}{blank}{}", &text[..r.start], &text[end..]))
}
/// Where the operand that ends right before byte `end` begins: a name, a call or an index (with
/// what it is of), or a bracketed expression.
fn expression_start(text: &str, end: usize) -> Option<usize> {
@@ -1811,6 +1841,20 @@ mod tests {
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// A non-void function whose only `return` has unreachable code after it and no trailing
/// return at the end of the block (which naga would otherwise end with `return;` without a value).
#[test]
fn early_return_without_trailing_return_translates() {
let fsh = "#version 120\n\
vec3 exposure(vec3 color) {\n return color * 3.5;\n int x = 1;\n}\n\
void main() { gl_FragColor = vec4(exposure(vec3(1.0)), 1.0); }\n";
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// GLSL's `dot` and `normalize` take a `float` too (genType); naga's take vectors only. A
/// pack's own `dot` is kept.
#[test]
@@ -1918,6 +1962,48 @@ mod tests {
assert_eq!(zero_returns("own"), 0, "own: its own return was replaced");
}
/// A function may be declared again (GLSL 4.60, 6.1), before or after its definition: Bloop
/// includes the same helper's prototype from two files.
#[test]
fn functions_declared_again_translate() {
let fsh = "#version 330\nuniform float k;\nout vec4 c;\n\
float f(float x);\nfloat f(float x);\nvec2 g(vec2 v, float s);\n\
float f(float x) { return x * 2.0; }\nfloat f(float x);\n\
vec2 g(vec2 v, float s) { return v * s; }\nvec2 g(vec2 v,\n float s);\n\
void main() { c = vec4(f(k), g(vec2(k), 3.0), 1.0); }\n";
let p = program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
for name in ["f", "g"] {
assert!(
(p.fragment.functions.iter()).any(|(_, f)| f.name.as_deref() == Some(name)),
"{name} missing"
);
}
}
/// A prototype declared again leaves the lines after it where they were: an error below it
/// still names its own line.
#[test]
fn errors_after_a_prototype_declared_again_keep_their_line() {
let fsh = "#version 330\nout vec4 c;\nfloat f(float x);\nfloat f(float x\n);\n\
float f(float x) { return x; }\nvoid main() { c = vec4(f(nowhere)); }\n";
let errors = program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.err()
.expect("`nowhere` is not declared");
assert!(
errors
.iter()
.all(|e| e.line == 7 && e.message.contains("nowhere")),
"{errors:?}"
);
}
/// `if (c) return; else ...` keeps its `else`: the statements the translation guards with
/// `if (true)` must not take an `else` that belongs to the pack's `if`.
#[test]
+117
View File
@@ -335,3 +335,120 @@ impl EntityFluidInteraction {
Ok(last.is_some())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A body the accumulator pushes: the player flag and the velocity it
/// reads are the only things `apply_to` asks for.
struct Body {
player: bool,
velocity: Vec3,
added: Vec3,
}
impl CurrentTarget for Body {
type Error = std::convert::Infallible;
fn is_player_class(&self) -> bool {
self.player
}
fn velocity(&mut self) -> Result<Vec3, Self::Error> {
Ok(self.velocity)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Self::Error> {
self.added = self.added.add_vector(delta);
Ok(())
}
}
/// `EntityFluidInteraction$CurrentAccumulator.applyTo`: a body that is not
/// a player takes the direction of the accumulated current, so two samples
/// of one block push as hard as one; a player takes their average, so the
/// push grows with the number of blocks and is halved by two of them.
#[test]
fn a_current_pushes_a_player_by_its_average_and_anything_else_by_its_direction() {
let mut acc = CurrentAccumulator::default();
acc.accumulate(Vec3::new(1.0, 0.0, 0.0));
let mut mob = Body {
player: false,
velocity: Vec3::ZERO,
added: Vec3::ZERO,
};
acc.apply_to(&mut mob, 0.014).unwrap();
assert!((mob.added.x - 0.014).abs() < 1e-12, "mob: {}", mob.added.x);
acc.accumulate(Vec3::new(1.0, 0.0, 0.0));
let mut mob = Body {
player: false,
velocity: Vec3::ZERO,
added: Vec3::ZERO,
};
acc.apply_to(&mut mob, 0.014).unwrap();
assert!(
(mob.added.x - 0.014).abs() < 1e-12,
"a second sample does not double a mob's push: {}",
mob.added.x
);
let mut player = Body {
player: true,
velocity: Vec3::ZERO,
added: Vec3::ZERO,
};
acc.apply_to(&mut player, 0.014).unwrap();
assert!(
(player.added.x - 0.014).abs() < 1e-12,
"the average of two (1,0,0) samples is (1,0,0): {}",
player.added.x
);
}
/// The same body with a single weak sample: the average is (0.5,0,0) for a
/// player, and a nearly still body gets the 0.0045 lift either way.
#[test]
fn a_weak_current_on_a_still_body_is_lifted_to_the_minimum() {
let mut acc = CurrentAccumulator::default();
// A tenth of a block a tick: 0.0014 once scaled, under the minimum.
acc.accumulate(Vec3::new(0.1, 0.0, 0.0));
let mut player = Body {
player: true,
velocity: Vec3::ZERO,
added: Vec3::ZERO,
};
acc.apply_to(&mut player, 0.014).unwrap();
assert!(
(player.added.x - 0.0045).abs() < 1e-12,
"{}",
player.added.x
);
// A body that is already moving on x or z takes the small push as it is.
let mut moving = Body {
player: true,
velocity: Vec3::new(0.05, 0.0, 0.0),
added: Vec3::ZERO,
};
acc.apply_to(&mut moving, 0.014).unwrap();
assert!(
(moving.added.x - 0.0014).abs() < 1e-12,
"{}",
moving.added.x
);
}
/// `applyTo` returns before reading the velocity with no samples, and when
/// the accumulated current is under the 1e-5 of a squared length.
#[test]
fn no_current_and_a_current_under_the_threshold_push_nothing() {
let mut body = Body {
player: false,
velocity: Vec3::ZERO,
added: Vec3::ZERO,
};
CurrentAccumulator::default()
.apply_to(&mut body, 0.014)
.unwrap();
assert_eq!(body.added, Vec3::ZERO);
let mut acc = CurrentAccumulator::default();
acc.accumulate(Vec3::new(0.001, 0.0, 0.0));
acc.apply_to(&mut body, 0.014).unwrap();
assert_eq!(body.added, Vec3::ZERO);
}
}
+104 -16
View File
@@ -9,15 +9,30 @@
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fs, io, path::Path};
/// The largest stack size a native mod may set: `ItemStack.count` is a `u32`,
/// but the count is a VarInt on the wire and is saved as an `int`, so a
/// larger one would turn negative (and lose its item) on the way out.
pub const MAX_STACK_SIZE: i64 = i32::MAX as i64;
/// Vanilla's `ExtraCodecs.intRange(1, 99)` on a data pack's own value.
const VANILLA_MAX_STACK_SIZE: i64 = 99;
const STACK_SIZE: &str = "minecraft:max_stack_size";
/// `value` as a stack size in `1..=max`, or None when it is not a number.
fn stack_size(value: &Value, max: i64) -> Option<i64> {
value.as_f64().map(|n| (n as i64).clamp(1, max))
}
/// Hook point for native mods (docs/native-mods.md, listed in `native-hooks.json`
/// with those of `horizon-client/src/game/native_hooks.rs`): `components` holds
/// `item`'s default components in `items.json`'s form (`"minecraft:max_stack_size": 16`,
/// `"minecraft:food": {"nutrition": 4, "saturation": 2.4}`); insert or replace one to
/// set it, remove it to take it away (a stack size outside 1 to 99 is brought back
/// into it). Asked for every item each time the game reads
/// the defaults, from any thread, several times as resources load and again when a
/// world's data packs load: answer the same each time. Client and server each ask
/// their own mods, as NeoForge does, so both sides need the mod.
/// set it, remove it to take it away (a stack size outside 1 to `i32::MAX` is
/// brought back into it, and one that is not a number leaves the data pack's
/// own). Asked for every item each time the game reads the defaults, from any
/// thread, several times as resources load and again when a world's data packs
/// load: answer the same each time. Client and server each ask their own mods,
/// as NeoForge does, so both sides need the mod.
/// 79.
#[inline(never)]
pub fn native_modify_default_components(item: &str, components: &mut Map<String, Value>) {
@@ -25,21 +40,43 @@ pub fn native_modify_default_components(item: &str, components: &mut Map<String,
}
/// Every item's default components at `root`, by item id, with the mods'
/// changes applied and the stack size kept to vanilla's 1 to 99
/// (`ExtraCodecs.intRange`).
/// changes applied. A data pack's own stack size is kept to vanilla's 1 to 99
/// (`ExtraCodecs.intRange`); a mod's, which no codec decodes, to 1 to
/// [`MAX_STACK_SIZE`], the count the wire and the save carry.
pub fn read(root: &Path) -> io::Result<BTreeMap<String, Value>> {
let mut items: BTreeMap<String, Value> =
serde_json::from_slice(&fs::read(root.join("items.json"))?)?;
for (item, components) in &mut items {
if let Value::Object(components) = components {
native_modify_default_components(item, components);
// ExtraCodecs.intRange(1, 99): nothing decodes a mod's change, and a
// size of 0 would break stack arithmetic.
// A data pack's own value goes through vanilla's codec.
if let Some(size) = components
.get_mut("minecraft:max_stack_size")
.filter(|size| size.is_number())
.get(STACK_SIZE)
.and_then(|size| stack_size(size, VANILLA_MAX_STACK_SIZE))
{
*size = size.as_f64().map_or(64, |n| (n as i64).clamp(1, 99)).into();
components.insert(STACK_SIZE.into(), size.into());
}
let pack = components.get(STACK_SIZE).cloned();
native_modify_default_components(item, components);
// The hook bypasses the codec, and a size of 0 would break stack
// arithmetic; keep whatever it leaves a number every reader can
// use. A mod that takes the component away keeps it away, and a
// value that is not a number falls back to the data pack's (each
// table would otherwise read its own default from it).
match components.get(STACK_SIZE).cloned() {
None => {}
Some(size) => match stack_size(&size, MAX_STACK_SIZE) {
Some(size) => {
components.insert(STACK_SIZE.into(), size.into());
}
None => match pack {
Some(pack) => {
components.insert(STACK_SIZE.into(), pack);
}
None => {
components.remove(STACK_SIZE);
}
},
},
}
}
}
@@ -50,10 +87,11 @@ pub fn read(root: &Path) -> io::Result<BTreeMap<String, Value>> {
mod tests {
use std::fs;
/// A stack size outside vanilla's codec range is kept to 1..=99 for every
/// reader, so stack arithmetic never sees a 0.
/// A data pack's stack size is kept to vanilla's 1..=99, so stack
/// arithmetic never sees a 0, and a mod may raise it above 99 (up to the
/// count the wire and the save carry).
#[test]
fn stack_sizes_stay_in_vanilla_range() {
fn data_pack_sizes_stay_in_vanilla_range() {
let root = std::env::temp_dir().join(format!("horizon-stack-sizes-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let items = r#"{"a:none": {"minecraft:max_stack_size": 0},
@@ -75,4 +113,54 @@ mod tests {
]
);
}
/// A mod's own size is kept to 1..=i32::MAX, a value that is not a number
/// leaves the data pack's, and a removed component stays removed.
#[test]
fn mod_sizes_are_sanitised() {
let _ = crate::native_load_hooks::install(crate::native_load_hooks::NativeLoadHooks {
loot_table_load: |_, _| {},
modify_default_components: |item, components| match item {
"a:raised" => {
components.insert(super::STACK_SIZE.into(), 500.into());
}
"a:zero" => {
components.insert(super::STACK_SIZE.into(), 0.into());
}
"a:huge" => {
components.insert(super::STACK_SIZE.into(), u64::MAX.into());
}
"a:string" => {
components.insert(super::STACK_SIZE.into(), "500".into());
}
"a:removed" => {
components.remove(super::STACK_SIZE);
}
_ => {}
},
loot_function: |_, _, _, _| {},
loot_condition: |_, _, _, _| {},
});
let root = std::env::temp_dir().join(format!("horizon-mod-sizes-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let items = r#"{"a:raised": {"minecraft:max_stack_size": 64},
"a:zero": {"minecraft:max_stack_size": 16},
"a:huge": {"minecraft:max_stack_size": 64},
"a:string": {"minecraft:max_stack_size": 16},
"a:removed": {"minecraft:max_stack_size": 64}}"#;
fs::write(root.join("items.json"), items).unwrap();
let items = super::read(&root).unwrap();
fs::remove_dir_all(&root).unwrap();
let size = |id: &str| items[id].get("minecraft:max_stack_size").cloned();
assert_eq!(
["a:raised", "a:zero", "a:huge", "a:string", "a:removed"].map(size),
[
Some(500.into()),
Some(1.into()),
Some(super::MAX_STACK_SIZE.into()),
Some(16.into()),
None
]
);
}
}
+30 -18
View File
@@ -60,18 +60,22 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
};
self.pickup_block(pos, state, creative).then_some(item)
}
/// `BucketItem.emptyContents(user, level, pos, hitResult)`. `sound` is the
/// bucket's empty sound (mob buckets have their own); `sneaking` is
/// `user.isShiftKeyDown()` (false for a dispenser, whose user is null);
/// `BucketItem.emptyContents(user, level, pos, hitResult)`: where the
/// contents went, None when nothing was emptied. `beside` is
/// `hitResult.getBlockPos().relative(hitResult.getDirection())` (None for
/// a dispenser, which has no hit result). `sound` is the bucket's empty
/// sound (mob buckets have their own); `sneaking` is
/// `user.isShiftKeyDown()`, which only counts with a hit result;
/// `creative` is whether that user is a creative player.
pub(crate) fn bucket_empty(
&mut self,
pos: BlockPos,
beside: Option<BlockPos>,
content: BucketContent,
sound: &str,
sneaking: bool,
creative: bool,
) -> bool {
) -> Option<BlockPos> {
let b = self.b;
let fluid = match content {
BucketContent::Powder => {
@@ -82,14 +86,14 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.game_event(pos, "minecraft:fluid_place");
let sound: Arc<str> = sound.into();
self.sound(pos, &sound, 1.0, 1.0);
return true;
return Some(pos);
}
return false;
return None;
}
BucketContent::Nothing => {
let sound: Arc<str> = sound.into();
self.sound(pos, &sound, 1.0, 1.0);
return true;
return Some(pos);
}
BucketContent::MobWater => FluidKind::Water,
BucketContent::Fluid(fluid) => fluid,
@@ -105,10 +109,15 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
let place_liquid = may_replace || container;
// `canPlaceFluidInsideBlock`: air always takes the fluid; a block that
// merely holds it (a waterloggable block, a replaceable plant) only
// when the player is not sneaking (a dispenser has no user and so
// no hit result, which always allows it).
if !(b.is_air(state) || place_liquid && !sneaking) {
return false;
// when the player is not sneaking or there is no hit result (a
// dispenser, or the retry below).
if !(b.is_air(state) || place_liquid && (!sneaking || beside.is_none())) {
// A refused click (a double slab, or Shift on a waterloggable
// block) pours into the block beside the clicked face instead,
// without a hit result.
return beside.and_then(|beside| {
self.bucket_empty(beside, None, content, sound, sneaking, creative)
});
}
let water = matches!(fluid, FluidKind::Water | FluidKind::FlowingWater);
if water && self.level.dimension() == Dimension::Nether {
@@ -120,7 +129,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
volume: 0.5,
pitch,
});
return true;
return Some(pos);
}
if liquid_container && water {
self.place_liquid(
@@ -130,7 +139,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
creative,
);
self.empty_sound(pos, sound, mob);
return true;
return Some(pos);
}
if may_replace && !b.instance_of(state, Class::Liquid) {
self.destroy_block(pos, true, super::flags::UPDATE_LIMIT);
@@ -148,10 +157,10 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if !self.set_block(pos, legacy, super::flags::UPDATE_ALL_IMMEDIATE)
&& !self.fluid_at(pos).is_source()
{
return false;
return None;
}
self.empty_sound(pos, sound, mob);
true
Some(pos)
}
/// `playEmptySound`: `MobBucketItem` overrides it without the game event.
fn empty_sound(&mut self, pos: BlockPos, sound: &str, mob: bool) {
@@ -175,18 +184,21 @@ impl Behaviours {
) -> Option<&'static str> {
self.sim(level, ticks).bucket_pickup(pos, creative)
}
/// `DispensibleContainerItem.emptyContents(user, level, pos, hitResult)`.
/// `DispensibleContainerItem.emptyContents(user, level, pos, hitResult)`:
/// where the contents went. `beside` is the block beside the clicked
/// face, tried when `pos` refuses (None without a hit result).
pub fn bucket_empty<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
ticks: &mut Ticks,
pos: BlockPos,
beside: Option<BlockPos>,
content: BucketContent,
sound: &str,
sneaking: bool,
creative: bool,
) -> bool {
) -> Option<BlockPos> {
self.sim(level, ticks)
.bucket_empty(pos, content, sound, sneaking, creative)
.bucket_empty(pos, beside, content, sound, sneaking, creative)
}
}
+24 -20
View File
@@ -79,6 +79,28 @@ impl Behaviours {
self.implements(state, Interface::LiquidBlockContainer)
|| self.implements(state, Interface::SimpleWaterlogged)
}
/// `LiquidBlockContainer.canPlaceLiquid(user, level, pos, state, fluid)`
/// for a liquid container: `creative` is whether `user` is a creative
/// player (a bucket in creative mode); a dispenser or a flowing fluid
/// passes none (`false`).
pub fn can_place_liquid(&self, state: BlockStateId, fluid: FluidKind, creative: bool) -> bool {
let data = self.data(state);
if data.is(Class::Kelp)
|| data.is(Class::KelpPlant)
|| data.is(Class::Seagrass)
|| data.is(Class::TallSeagrass)
{
return false;
}
// `BarrierBlock.canPlaceLiquid`: only a creative player may waterlog.
if data.is(Class::Barrier) && !creative {
return false;
}
if data.is(Class::Slab) && self.table.text(state, self.props.kind) == "double" {
return false;
}
fluid == FluidKind::Water
}
}
impl<L: LevelAccess + ?Sized> Sim<'_, L> {
@@ -203,32 +225,14 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
pub(super) fn is_liquid_container(&self, state: BlockStateId) -> bool {
self.b.is_liquid_container(state)
}
/// `LiquidBlockContainer.canPlaceLiquid(user, level, pos, state, fluid)`:
/// `creative` is whether `user` is a creative player (a bucket in creative
/// mode); a dispenser or a flowing fluid passes none (`false`).
/// `LiquidBlockContainer.canPlaceLiquid` (`Behaviours::can_place_liquid`).
pub(crate) fn can_place_liquid(
&self,
state: BlockStateId,
fluid: FluidKind,
creative: bool,
) -> bool {
let b = self.b;
let data = b.data(state);
if data.is(Class::Kelp)
|| data.is(Class::KelpPlant)
|| data.is(Class::Seagrass)
|| data.is(Class::TallSeagrass)
{
return false;
}
// `BarrierBlock.canPlaceLiquid`: only a creative player may waterlog.
if data.is(Class::Barrier) && !creative {
return false;
}
if data.is(Class::Slab) && b.table.text(state, b.props.kind) == "double" {
return false;
}
fluid == FluidKind::Water
self.b.can_place_liquid(state, fluid, creative)
}
/// `LiquidBlockContainer.placeLiquid`.
pub(crate) fn place_liquid(
+77 -42
View File
@@ -880,15 +880,20 @@ fn bucket_source_removal_drains_spread_water() {
let mut w = world!();
w.floor(0, 10, "stone");
let at = BlockPos::new(0, 1, 0);
assert!(w.b().bucket_empty(
&mut w.level,
&mut w.ticks,
at,
BucketContent::Fluid(crate::fluid::FluidKind::Water),
"minecraft:item.bucket.empty",
false,
false
));
assert!(
w.b()
.bucket_empty(
&mut w.level,
&mut w.ticks,
at,
None,
BucketContent::Fluid(crate::fluid::FluidKind::Water),
"minecraft:item.bucket.empty",
false,
false
)
.is_some()
);
for _ in 0..60 {
w.tick();
}
@@ -2214,6 +2219,21 @@ fn placing_and_breaking_report_the_affected_state() {
fn dispenser_buckets_pick_up_and_empty() {
let mut w = world!();
let at = BlockPos::new(0, 10, 0);
// A dispenser has no hit result: nothing beside to try.
let empty = |w: &mut World, content, sound: &str, creative| {
w.f.behaviours
.bucket_empty(
&mut w.level,
&mut w.ticks,
at,
None,
content,
sound,
false,
creative,
)
.is_some()
};
let water = w.id("water", &[("level", "0")]);
w.put(at, water);
let picked = w.b().bucket_pickup(&mut w.level, &mut w.ticks, at, false);
@@ -2246,15 +2266,9 @@ fn dispenser_buckets_pick_up_and_empty() {
w.put(at, barrier);
let sound = "minecraft:item.bucket.empty";
let content = BucketContent::Fluid(crate::fluid::FluidKind::Water);
assert!(
!w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, false, false)
);
assert!(!empty(&mut w, content, sound, false));
assert_eq!(w.prop(at, "waterlogged"), "false");
assert!(
w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, false, true)
);
assert!(empty(&mut w, content, sound, true));
assert_eq!(w.prop(at, "waterlogged"), "true");
assert_eq!(
w.b().bucket_pickup(&mut w.level, &mut w.ticks, at, false),
@@ -2268,48 +2282,69 @@ fn dispenser_buckets_pick_up_and_empty() {
// Emptying water into air makes a source; stone refuses.
let air = w.b().air();
w.put(at, air);
assert!(
w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, false, false)
);
assert!(empty(&mut w, content, sound, false));
assert_eq!(w.name(at), "water[level=0]");
let stone = w.id("stone", &[]);
w.put(at, stone);
assert!(
!w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, false, false)
);
assert!(!empty(&mut w, content, sound, false));
// Water fills a waterloggable slab instead of replacing it.
let dry = w.id("oak_slab", &[]);
w.put(at, dry);
assert!(
w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, false, false)
);
assert!(empty(&mut w, content, sound, false));
assert_eq!(w.prop(at, "waterlogged"), "true");
// Sneak-clicking the slab refuses: only air takes a fluid under Shift.
let dry = w.id("oak_slab", &[]);
w.put(at, dry);
assert!(
!w.b()
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, true, false)
);
assert_eq!(w.prop(at, "waterlogged"), "false");
// Powder snow only goes into empty space.
let air = w.b().air();
w.put(at, air);
assert!(w.b().bucket_empty(
&mut w.level,
&mut w.ticks,
at,
assert!(empty(
&mut w,
BucketContent::Powder,
"minecraft:item.bucket.empty_powder_snow",
false,
false
));
assert_eq!(w.name(at), "powder_snow[]");
}
#[test]
fn clicked_bucket_pours_beside_a_refusing_block() {
let mut w = world!();
let at = BlockPos::new(0, 10, 0);
let above = BlockPos::new(0, 11, 0);
let sound = "minecraft:item.bucket.empty";
let content = BucketContent::Fluid(crate::fluid::FluidKind::Water);
let air = w.b().air();
let click = |w: &mut World, sneaking: bool| {
w.f.behaviours.bucket_empty(
&mut w.level,
&mut w.ticks,
at,
Some(above),
content,
sound,
sneaking,
false,
)
};
// Sneak-clicking a slab pours beside it, where Shift no longer counts.
let slab = w.id("oak_slab", &[]);
w.put(at, slab);
assert_eq!(click(&mut w, true), Some(above));
assert_eq!(w.prop(at, "waterlogged"), "false");
assert_eq!(w.name(above), "water[level=0]");
// A double slab holds no water: the click fills the block beside it.
w.put(above, air);
let double = w.id("oak_slab", &[("type", "double")]);
w.put(at, double);
assert_eq!(click(&mut w, false), Some(above));
assert_eq!(w.prop(at, "type"), "double");
assert_eq!(w.name(above), "water[level=0]");
// Both refusing empties nothing.
let stone = w.id("stone", &[]);
w.put(above, stone);
assert_eq!(click(&mut w, false), None);
assert_eq!(w.prop(at, "type"), "double");
assert_eq!(w.get(above), stone);
}
#[test]
fn creaking_heart_follows_its_logs_and_the_night() {
let mut w = world!();
+86 -2
View File
@@ -50,6 +50,28 @@ agent's client needs one entry and one approval. The version that plays is alway
launcher selected, so the game's tools always match the game. `horizon-ctl mcp` stays available
on its own, for a game started by hand or by a script.
## Protocol revisions
All three servers share one implementation, `crates/horizon-mcp`, so they advertise and answer
the same revisions. There are two of them.
- `2026-07-28` is stateless: there is no handshake, and every request carries its version and
its capabilities in `_meta` (`io.modelcontextprotocol/protocolVersion`,
`io.modelcontextprotocol/clientCapabilities`). Every result says `resultType: "complete"`,
`tools/list` carries `ttlMs` and `cacheScope`, and each result names the server in
`_meta["io.modelcontextprotocol/serverInfo"]`. `server/discover` reports the versions,
capabilities and identity. `ping` no longer exists in this revision.
- `2025-06-18` is the handshake revision, kept because the launcher's `game_*` tools talk to
whichever `horizon-ctl` the player has installed: an older one does not know the stateless
revision. A request with no `_meta` is answered in that revision's shapes, and `initialize`
still negotiates it.
A server that serves both eras decides from the request itself, not from what came before it.
A version the server does not serve is refused with `UnsupportedProtocolVersionError` (`-32022`)
listing the ones it does, and a modern request missing its capabilities is malformed (`-32602`).
A client that speaks both probes with `server/discover` first on stdio, then falls back to
`initialize` when the answer is not a modern one.
## Inventory, before and after
Before is `main` at 2026-10-23, before #1342, #1343, #1344, #1345 and #1353. After is with
@@ -272,6 +294,64 @@ turn the switch off.
dimensions and worlds (the newest 256). Take `last` before an action, and wait with `--since`,
so the action's event can't slip in before the wait.
### Input provenance
Every input event carries who caused it: `user` (this computer's keyboard, mouse or pad),
`agent` (the control socket: `horizon-ctl`, an MCP client) or `game` (input the game made up
itself, such as a pad menu's click). This is how an agent tells its own actions from a person
who took over mid-test.
- `state` and `status` have an `input` section: `last_origin`, per-origin `counts`,
`user_since_agent` and `last_user_age_s`.
- `input-log [--since SEQ] [--limit N] [--origin ORIGIN]` lists the newest 512 input events:
`seq`, `tick`, `origin`, and the key/button/motion/pad event or control-socket line.
- Polls (`state`, `events`, `wait-for`, `input-log`, `ui`...) and session commands
(`record-*`, `repro-export`, `quit`...) are not input: they do not fill the ring or count as an
agent's keystroke. They still count as an agent watching, so a person's next key is noticed.
- When a person's input arrives while an agent was driving, the game logs one line and pushes a
`user-input` event: `wait-for event user-input --timeout 600` makes a script notice at once
instead of blaming the person's keystroke on itself. The agent counts as driving while a
client is connected, a deferred action runs, or a `wait-for` waits, not only for five seconds
after its last line.
- Things that are not input (frames, resizes) are not events. Typed text is never kept: a
`chat`, `command`, `type`, `signedit`, `connect`, `book` or `publish` line is recorded as
`<name> <redacted>`. OS-level tools that inject keys (xdotool, computer-use) look like a
person (`user`); only the agent's own socket is `agent`.
### Repro recordings
A recording is one folder, a bundle is that folder plus the logs in one zip:
- `record-start [NAME] [--no-world]` saves the world, freezes the server, copies the world into
`recordings/<stamp>-NAME/world/` and starts writing: `inputs.jsonl` (the provenance events
above, with ticks), `state.jsonl` (the full `state_value()` every second, entities every ten),
`events.jsonl` and `session.json`. A `--no-world` recording keeps only the input and state,
for a server session. The deprecated in-frame mode (`--no-server-thread`) is refused.
- A recording cannot cross worlds: quit-to-title stops it. It also stops itself at 1,000,000
input events or two hours, and is finished and bundled when the game exits. The newest ten
recordings and ten bundles are kept; older ones are pruned.
- `record-stop [--export]` closes it; `repro-export [NAME] [PATH]` writes
`repro/<stamp>-NAME.zip` with the recording, `logs/latest.log`, a crash report from this
recording's session, a reduced `status.json` and a README. The export scrubs the user folder,
IPs, UUIDs, the world and server names and the other players' names; `status.json` loses the
server, name tags, sidebar and clipboard (an invite code lives there). Nothing is uploaded
automatically: the player attaches the zip to the bug report.
- A player without `horizon-ctl` uses the debug keys: **F3+R** starts a recording and F3+R again
stops it and writes the bundle (the chat shows the path); **F3+E** writes the bundle of the
newest recording. The keys are off until **Recording shortcuts** is turned on (AI Agent page);
the page also has the two buttons and a live recording/replay line.
- `record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]` is the best effort
replay: it copies the recording's world into `saves/`, opens it, restores the recorded player
position, rotation and velocity, and feeds the recorded keys, mouse, wheel, look, pad events
and input-socket lines back at their original ticks. **Only input and movement commands run**
(`key`, `mouse`, `click*`, `look`, `turn`, `goto`, `pad`, `wait`...), never world, network or
account ones (`world-delete`, `connect`, `chat`, `command`...). It ends at the recording's
`end_tick`, releases every key and button it held, and removes its `saves/` copy (keep it with
`--keep-world`). The timeout counts from the command, not from when the world finished
loading. It is not frame-perfect and only makes sense on the same build and machine;
`state.jsonl` is the oracle for where the re-run should have ended. A server recording replays
only `--in-place`, into the same world.
## Tests
- **Unit tests:**
@@ -279,13 +359,17 @@ turn the switch off.
network annotations;
- the launcher: generated tools, `confirm`, the reference, the agent configs;
- `horizon_core::no_ai`;
- `options_ui`: the AI Agent page, and that No AI hides it.
- `options_ui`: the AI Agent page, and that No AI hides it;
- `input_trace`: origins, the ring, the intrusion rule, `input-log` and the recording writer;
- `repro`: the format's round trip, the folder slug and the world copy.
- **`tools/agent_control_e2e.py`** drives a real game, offscreen on macOS and on a virtual screen
on Linux, through the CLI and MCP:
- `survival`, `ui`, `options`, `events`, `consume`, `pick`, `suggestions`;
- `manage`: worlds, servers, effects, books, LAN, the options pages and `confirm`;
- `no-ai`: every surface refuses;
- `setting`: Agent Control on opens the endpoint, the AI Agent page, Copy, and off closes it.
- `setting`: Agent Control on opens the endpoint, the AI Agent page, Copy, and off closes it;
- `repro`: origins in `state`/`input-log`, record-start/stop, the bundle's entries, and a
replay ending near the recorded state.
- **`tools/launcher_mcp_e2e.py`** drives `horizon-launcher mcp`: the generated tools, `play` and
the `game_*` tools, `confirm`, `agent-config` and `--test`, and No AI.
+1 -1
View File
@@ -91,7 +91,7 @@ One point of a block's surface, for `fn material(input: MaterialInput) -> Surfac
| `emission` | `f32` | How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks. |
| `normal` | `vec3<f32>` | The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs. |
| `tangent` | `vec4<f32>` | The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes. |
| `colored_light` | `vec3<f32>` | The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches. |
| `colored_light` | `vec3<f32>` | The frame's coloured lights at this point (`[[particle_lights]]`, held and dropped items in their `[[lights]]` colour while dynamic lights are on, and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches. |
### `Surface`
+2
View File
@@ -154,6 +154,8 @@ stages.
## Changelog
- 2026-11-02: API 0.1 (additions): with dynamic lights on, held and dropped items light
`MaterialInput.colored_light` in their block's `[[lights]]` colour.
- 2026-11-02: API 0.1 (additions): `[[particle_lights]]` and `MaterialInput.colored_light`.
- 2026-11-01: API 0.1 (additions): `[[materials]]` and `MaterialInput.material`, an id by sprite,
and `[shadow] solid_materials`.
+15 -2
View File
@@ -18,7 +18,20 @@ tunes it. LAN worlds and single player don't filter.
muted doesn't count towards the window, so the hit after a mute still counts as the next. Anvil names
never count (the client renames at every key typed): they are only refused.
- Mutes are kept in `chat-filter-mutes.json` beside `server.properties`, and survive a restart.
Operators of level 3 lift one with `/unmute <player>`; the console takes `unmute <player>`.
## Muting by hand
Operators of level 3 (`/ban`'s level) mute players themselves; the console takes the same words without the
slash. These are Horizon extras, and exist only on servers that filter chat.
- `/mute <player> [reason]`: muted until `/unmute`.
- `/tempmute <player> <duration> [reason]`: muted for a duration such as `30s`, `10m`, `2h`, `1d` or `1w`.
- `/mutelist`: every mute in force, the filter's and the operators', with who muted, until when and why.
- `/unmute <player>`: lifts either kind.
A player muted by hand is told at once, then gets the same refusal as the filter's mutes in chat and message
commands, with the operator's reason. The player may be offline if the server has seen them. A filter mute
never shortens a longer mute an operator gave.
## Settings
@@ -51,4 +64,4 @@ in an entry must be doubled in the text, so Niger and Nigeria pass, and so do "s
Keep the list to slurs: insults that aren't slurs are the moderators' to handle.
Code: `crates/horizon-chat-filter` (settings, matcher, escalation, mutes), `lan/hub/chat_filter.rs` (the
check before the server relays a text), `game/chat_filter.rs` (log, events, bans, `/unmute`).
check before the server relays a text), `game/chat_filter.rs` (log, events, bans, the mute commands).
+14 -6
View File
@@ -189,12 +189,20 @@ P1 is alone, turning it off restores the normal controller reader. Leaving it on
alone keeps watching for joins without a compositor.
`HORIZON_SPLIT=N` remains a testing shortcut to enable it and add N-1 players.
The Setup map previews the monitors, windows and player views. Click a player and choose a
window or monitor, then **Apply** to move the actual views. **Revert** and **Done** discard
unapplied moves. An emptied window closes only on Apply. A player joining or leaving resets
the draft so it cannot apply a move to the wrong seat. P1's pause-menu shortcut opens Setup;
another player's shortcut opens their profiles. Dragging, controller pick-up and joining P1
on an external server follow separately.
The Setup map previews the monitors, windows and player views. Click a player (its name turns
yellow) and choose a window or monitor, or drag its tile onto a window, a place among another
window's players, or a monitor; then **Apply** to move the actual views. **Revert** and
**Done** discard unapplied moves. An emptied window closes only on Apply. A player joining or
leaving resets the draft so it cannot apply a move to the wrong seat. P1's pause-menu shortcut
opens Setup; another player's shortcut opens their profiles.
While a window has two or three players, a square at the bottom picks how they share it
(two: Auto, side by side, top and bottom; three: one wide on top, one tall on the left, side
by side); Apply saves it (`splitscreenTwo`, `splitscreenThree`). **Names** (top left,
`splitscreenNames`, on by default) keeps each player's name in the corner of their view; off,
it shows only for a moment when something changes. **Identify** makes every view say which
window it is in. Controller pick-up in Setup and joining P1 on an external server follow
separately.
## Shared rules
+7
View File
@@ -28,6 +28,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| Command | MCP tool | What it does | Arguments |
| --- | --- | --- | --- |
| `events [--since SEQ]` | `events` | What happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds | `--since` (integer): only events after this seq |
| `input-log [--since SEQ] [--limit N] [--origin user\|agent\|game]` | `input_log` | Recent input and who caused it: a person, an agent or the game | `--since` (integer): only events after this seq<br>`--limit` (integer): newest N events<br>`--origin` (user, agent, game): user, agent or game |
| `state` | `state` | Player, world, screen and crosshair target in one reply | none |
| `looking-at` | `looking_at` | The block or entity under the crosshair | none |
| `entities [RADIUS] [TYPE]` | `entities` | Mobs, dropped items and experience orbs near the player, nearest first | `radius` (number): search radius in blocks (default 32)<br>`type` (string): entity type filter |
@@ -142,3 +143,9 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `stats [reset] [raw]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again<br>`raw` (boolean): every frame time too (frame_ms, cpu_ms, gpu_ms) |
| `gpu-passes [on\|off]` | CLI only | Time Horizon's own render passes on the GPU, shown in F3 and in stats and bench | `mode` (on, off): on or off; without it, only reports |
| `fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]` | CLI only | Fly the camera along a line, then reply with the frame summary | none |
| `record-start [NAME] [--no-world]` | `record_start` | Record input, state and the world for a bug repro | `name` (string): folder name after the timestamp (default session)<br>`--no-world` (boolean): do not copy the world (a server session) |
| `record-stop [--export]` | `record_stop` | Stop the recording (and write the repro zip) | `--export` (boolean): write the repro bundle too |
| `record-status` | `record_status` | The running recording and replay, if any | none |
| `record-list` | `record_list` | The recordings and repro bundles on disk | none |
| `record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]` | `record_replay` | Replay a recording, best effort | `name` (string): recording from record-list<br>`--stop` (boolean): stop the running replay<br>`--in-place` (boolean): feed the open world instead of opening the copy<br>`--keep-world` (boolean): leave the copied world in saves/<br>`--timeout` (integer): give up after this many seconds (default 300) |
| `repro-export [NAME] [PATH] [--no-world]` | `repro_export` | Zip a recording with the logs, crash report and status | `name` (string): recording from record-list (default the newest)<br>`path` (string): where to write the zip<br>`--no-world` (boolean): leave the world out |
+11
View File
@@ -44,6 +44,17 @@ Remove an installed version.
MCP: `remove`, needs `confirm: true`.
## `horizon-launcher import-build`
Install a game program you have, such as a horizon-client built on your fork's Actions, as its own version on an installed release or Nightly's game files, and select it. Nothing signed it: it runs with your rights, and updates never replace it.
horizon-launcher import-build <PATH>
- `PATH`: The program (horizon-client, horizon-client.exe on Windows), or a zip holding it
- `--trust-unsigned`: You picked this build yourself and trust it with full access to this computer
MCP: none: the player's switch.
## `horizon-launcher prepare`
Prepare a version's resources from your own Minecraft install, or from Mojang.
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more