Merge main into #7163

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:20:57 +01:00
commit f60c7e53d4
61 files changed
+2002 -182

No files matched your search

Generated
+1
View File
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
+59 -38
View File
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
let mut dirs = vec![textures.clone()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
// Linked folders too: mods' content links into the packs' tree.
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
continue;
}
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
+12
View File
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
}
descriptor.backends = backends.with_env();
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
// 1 s); what it compiled once is kept in the game folder and read back
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
#[cfg(windows)]
if dx12 {
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
}
if !cfg!(debug_assertions) && !dx12 {
descriptor
.flags
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
}
descriptor
}
/// Where DX12 keeps the shaders it compiled, inside a game folder.
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
home.join("cache").join("dx12-shaders.bin")
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
@@ -273,16 +273,20 @@ const ZEROS: u64 = 4 << 20;
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
minimums: BTreeMap<u32, u64>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
minimums: BTreeMap<u32, u64>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
@@ -305,6 +309,7 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -330,7 +335,8 @@ impl Buffers {
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
@@ -368,6 +374,13 @@ impl Buffers {
}
}
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
@@ -458,3 +471,27 @@ impl Dump {
self.pending = Some((buffer, texel, dims, row));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -1686,6 +1686,8 @@ impl PackPost {
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
@@ -1769,7 +1771,7 @@ impl PackPost {
if gpu.storage_stages().is_empty() {
objects.clear();
}
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
let buffers = pack_images::Buffers::new(device, objects, minimums, gpu.storage_stages());
let storable = (0..BUFFERS)
.filter(|&i| gpu.storable(adapter, formats[i], features))
.fold(0u16, |m, i| m | 1 << i);
+10
View File
@@ -193,6 +193,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
.then(|| (field(16), field(20)))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
// A prepared root's folder: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
out.extend(
files
.iter()
.filter(|file| file.ends_with(".png"))
.map(|file| dir.join(file)),
);
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
+51 -30
View File
@@ -5,7 +5,12 @@ use crate::{
hud::{HudAtlas, HudBatch, Rect, rgba},
text::Text,
};
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::RwLock,
time::Instant,
};
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
/// its texture's resolution, its borders (left, top, right, bottom) in the
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
pub fn read_metadata(root: &Path) -> UiMetadata {
let mut map = HashMap::new();
let base = root.join("assets/minecraft/textures");
let mut stack = vec![base.join("gui/sprites")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
let sprites = base.join("gui/sprites");
// A prepared root's: from its data file, without listing the disk.
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
Some(files) => files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| sprites.join(file))
.collect(),
None => {
let mut files = Vec::new();
let mut stack = vec![sprites];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
files.push(p);
}
}
}
files
}
};
for p in files {
let name = p.to_string_lossy().into_owned();
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let name = p.to_string_lossy().into_owned();
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
continue;
};
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, nine_slice);
}
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, nine_slice);
}
UiMetadata(map)
}
+28 -4
View File
@@ -8095,6 +8095,10 @@ impl Game<'_> {
self.settings.file.bool("hideSplashTexts"),
);
}
// ScreenEvent.Render.Post over the title's own screens: a mod draws
// over them here (a pack browser over Create New World, say), and the
// toasts still come after, as over any screen.
self.fire_title_screen_render_post(&mut batch);
self.draw_toasts(&mut batch, false);
self.draw_tutorial_toasts(&mut batch, false);
self.draw_pack_toasts(&mut batch, false);
@@ -8440,6 +8444,21 @@ impl Game<'_> {
let view = self.screen_view(batch, screen);
self.native_screen_render_post(batch, &view);
}
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
/// once a frame, after it has drawn and before the toasts. Not when a
/// screen of the game's own is over the title (Options draws and fires
/// its own), nor while a dialog, a prompt or a connect or load hides it.
/// Client only, read-only: what a mod draws is its own.
fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
return;
}
let Some(view) = self.title_screen_view(batch) else {
return;
};
self.native_screen_render_post(batch, &view);
}
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
/// as the screen's constructor and `containerTick` do.
@@ -14826,14 +14845,14 @@ impl Game<'_> {
// it ends without failing, for every save: the command, the autosave, the pause.
let dedicated = self.options.dedicated.is_some();
if dedicated {
dedicated::events::record_server("saving");
dedicated::events::record_server("saving", serde_json::json!({}));
}
self.save_level(wait)?;
self.save_parked_levels(wait)?;
self.save_game_metadata()?;
self.native_world_saved(wait);
if dedicated {
dedicated::events::record_server("saved");
dedicated::events::record_server("saved", serde_json::json!({}));
}
Ok(())
}
@@ -16807,14 +16826,19 @@ impl Game<'_> {
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
// Options keeps its text on the key: its own key path routes a key's
// text between its boxes and its key-bind capture, and no key that
// carries text closes that screen or moves its focus.
// carries text closes that screen or moves its focus. The title's
// screens keep theirs for the same reason: their key path types into
// the world name, the seed and a server's address.
let mut typed: Option<String> = None;
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &mut event
&& key_event.state == ElementState::Pressed
{
if self.screen_hooks_open() && self.screen != Screen::Options {
if self.screen_hooks_open()
&& self.screen != Screen::Options
&& !control::title_owns_text(&self.screen, self.title.is_some())
{
typed = key_event.text.take().map(|text| text.to_string());
}
let canceled = match key_event.physical_key {
+12
View File
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
/// The `namespace:` ids of the `extension` files under `root`, sorted.
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
let suffix = format!(".{extension}");
// A prepared root's folder: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(root) {
let mut out: Vec<String> = files
.iter()
.filter_map(|file| file.strip_suffix(&suffix))
.filter(|path| !path.is_empty() && !path.ends_with('/'))
.map(|path| format!("{namespace}:{path}"))
.collect();
out.sort();
return out;
}
let mut out = Vec::new();
let mut stack = vec![root.to_owned()];
while let Some(dir) = stack.pop() {
+22
View File
@@ -233,6 +233,14 @@ impl Game<'_> {
self.sign_input(None, Some(text));
return Ok(());
}
// The title's screens: the world name, the seed, a server's address.
// A screen opened over the title (Options, say) keeps its own text.
if title_owns_text(&self.screen, self.title.is_some())
&& let Some(title) = &mut self.title
{
title.type_text(text);
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
})
}
/// The title's own screens (not one opened over it, as Options is) take the
/// text: the world name, the seed, a server's address.
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
title_up && *screen == Screen::Playing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_title_takes_text_only_on_its_own_screens() {
assert!(title_owns_text(&Screen::Playing, true));
assert!(!title_owns_text(&Screen::Playing, false));
assert!(!title_owns_text(&Screen::Options, true));
assert!(!title_owns_text(&Screen::Chat, true));
}
#[test]
fn hello_names_the_protocol_the_release_and_the_command_groups() {
let reply = hello();
+8 -2
View File
@@ -263,6 +263,10 @@ pub(super) fn named_mob_pickable(
alive && Some(id) != riding && Some(id) != camera
}
/// What an entity drew on the GPU path: the skin frame's mark before its draws, and their
/// camera-relative bounds (`cull_skinned`).
pub(super) type Skinned = Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>;
impl Game<'_> {
pub(super) fn player_view(&self) -> Option<PlayerView> {
let p = self.player.as_ref()?;
@@ -6312,7 +6316,7 @@ impl Game<'_> {
&self,
out: &mut Vec<Vertex>,
entity_start: usize,
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
skinned: Skinned,
) {
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
@@ -6564,7 +6568,9 @@ impl Game<'_> {
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
self.mob_block_geometry(mob, camera, alpha, packed, out);
// Held items join the GPU path's draws unless an outline reads their vertices.
let skin = (skinning && outline.is_none()).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
+46 -2
View File
@@ -15,8 +15,8 @@ use horizon_client::{
properties::{GAME_MODES, ServerProperties, SystemEntropy},
user_cache::UserCache,
user_lists::{
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
format_date,
BanEntry, BanList, Change, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry,
WhiteList, format_date,
},
},
text::Text,
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
Ok(true)
}
/// The event a change of a list is (`operators_added`, `bans_removed`...), and the entry it is about.
fn listed<E>(list: &str, change: Change<E>) -> (String, E) {
match change {
Change::Added(e) => (format!("{list}_added"), e),
Change::Removed(e) => (format!("{list}_removed"), e),
}
}
/// What a ban says besides who it is for: the reason and the source, and when it ends (milliseconds, or none).
fn ban_fields<T>(ban: &BanEntry<T>) -> serde_json::Value {
serde_json::json!({ "reason": ban.reason, "source": ban.source, "expires": ban.expires })
}
/// The operator, whitelist and ban lists (DedicatedPlayerList), shared with
/// the connection threads that check logins.
pub(super) struct Lists {
@@ -431,6 +444,30 @@ impl Lists {
enforce_whitelist: false,
op_level: 4,
};
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
l.ops.watch(|change| {
let (kind, e) = listed("operators", change);
events::record(
&kind,
&e.user.name,
e.user.id,
serde_json::json!({ "level": e.level, "bypassesPlayerLimit": e.bypasses_player_limit }),
);
});
l.white.watch(|change| {
let (kind, e) = listed("allowlist", change);
events::record(&kind, &e.user.name, e.user.id, serde_json::json!({}));
});
l.bans.watch(|change| {
let (kind, e) = listed("bans", change);
events::record(&kind, &e.user.name, e.user.id, ban_fields(&e));
});
l.ip_bans.watch(|change| {
let (kind, e) = listed("ip_bans", change);
let mut fields = ban_fields(&e);
fields["ip"] = e.user.clone().into();
events::record_server(&kind, fields);
});
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
if let Err(e) = r {
@@ -1794,6 +1831,13 @@ impl Game<'_> {
}
}
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
/// server.properties.
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
lists.lock().unwrap().enforce_whitelist = on;
self.store_property("enforce-whitelist", on.to_string());
}
/// DedicatedServerSettings.update: change one key and store the file.
pub(super) fn store_property(&mut self, key: &str, value: String) {
let path = PathBuf::from("server.properties");
@@ -26,9 +26,10 @@ pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Va
push(kind, fields);
}
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save).
pub(in crate::game) fn record_server(kind: &str) {
push(kind, json!({}));
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save; an IP
/// ban), and what else it says.
pub(in crate::game) fn record_server(kind: &str, fields: Value) {
push(kind, fields);
}
fn push(kind: &str, mut fields: Value) {
@@ -76,7 +77,7 @@ mod tests {
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
// An event of the server itself (a world save) carries no player.
record_server("saving");
record_server("saving", json!({}));
let event = &since(first + 2)["events"][0];
assert_eq!(event["type"], "saving");
assert_eq!(event["seq"].as_u64(), Some(first + 3));
@@ -120,6 +120,46 @@ impl Game<'_> {
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"serversettings/use_allowlist" => {
return json!({
"value": lists.lock().unwrap().use_whitelist
});
}
"serversettings/use_allowlist/set" => {
let Some(use_allowlist) = body["value"].as_bool() else {
return json!({ "error": "using allowlist must be a boolean" });
};
let current = lists.lock().unwrap().use_whitelist;
rpc(format!(
"Update using allowlist from {} to {}",
current, use_allowlist
));
self.set_using_whitelist(&lists, use_allowlist);
self.kick_unlisted_players(&lists);
return json!({
"value": lists.lock().unwrap().use_whitelist
});
}
"serversettings/enforce_allowlist" => {
return json!({
"value": lists.lock().unwrap().enforce_whitelist
});
}
"serversettings/enforce_allowlist/set" => {
let Some(enforce) = body["value"].as_bool() else {
return json!({ "error": "enforce allowlist must be a boolean" });
};
let current = lists.lock().unwrap().enforce_whitelist;
rpc(format!(
"Update enforce allowlist from {} to {}",
current, enforce
));
self.set_enforce_whitelist(&lists, enforce);
self.kick_unlisted_players(&lists);
return json!({
"value": lists.lock().unwrap().enforce_whitelist
});
}
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
@@ -492,6 +492,32 @@ impl Game<'_> {
}
Some((mark, skin.bounds))
}
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
/// nothing drawn, when the state cannot take it.
pub(super) fn skin_item_into(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
skinned: &mut super::creatures::Skinned,
) -> bool {
let lights = diffuse_lights(self.dim == Dim::Nether);
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
return false;
};
*skinned = Some(match skinned.take() {
Some((first, before)) => (
first,
match (before, bounds) {
(Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))),
(one, other) => one.or(other),
},
),
None => (mark, bounds),
});
true
}
/// `ItemEntityRenderer.submit` for every dropped item.
pub(super) fn dropped_item_geometry(
&self,
+10 -1
View File
@@ -187,10 +187,11 @@ impl Game<'_> {
camera: glam::DVec3,
alpha: f64,
light: u32,
skin: Option<&mut super::creatures::Skinned>,
out: &mut Vec<Vertex>,
) {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.held_item_geometry(mob, camera, alpha, light, true, skin, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
self.head_item_geometry(mob, camera, alpha, light, true, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
@@ -376,6 +377,7 @@ impl Game<'_> {
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
/// `skin`: the mob's GPU path draws, which a lit item joins when it can.
pub(super) fn held_item_geometry(
&self,
mob: &Mob,
@@ -383,6 +385,7 @@ impl Game<'_> {
alpha: f64,
light: u32,
lit: bool,
mut skin: Option<&mut super::creatures::Skinned>,
out: &mut Vec<Vertex>,
) {
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
@@ -483,6 +486,12 @@ impl Game<'_> {
horizon_client::cit::Hand::Main
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
if lit
&& let Some(skinned) = skin.as_deref_mut()
&& self.skin_item_into(&state, t, light, skinned)
{
continue;
}
if lit {
self.submit_item_world(&state, t, light, out);
} else {
@@ -483,4 +483,7 @@ impl Host for super::Game<'_> {
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
fn instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
}
+38 -2
View File
@@ -80,6 +80,9 @@ pub(super) fn content_root(
paths.extend(dev.iter().cloned());
let mut known = None;
let mut mods: Vec<ContentMod> = Vec::new();
// The first load's list stays (`set_local`): a reload does not hash the files again.
let announce = !horizon_protocol::mod_list::is_set();
let mut announced = Vec::new();
for path in &paths {
let Ok(package) = Package::read(path) else {
continue;
@@ -121,8 +124,10 @@ pub(super) fn content_root(
id: m.id.clone(),
content,
};
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
match mod_content::accept_mod(&mut mods, candidate, known) {
Ok(_) if announce => announced.push(announced_entry(m, path)),
Ok(_) => {}
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
}
}
let (blocks, items) = native_declared();
@@ -146,6 +151,9 @@ pub(super) fn content_root(
}
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
Ok(out) => {
if announce {
horizon_protocol::mod_list::set_local(announced);
}
if out != root {
log::info!(
"Horizon: content from {} mod(s) in {}",
@@ -168,6 +176,34 @@ pub(super) fn content_root(
}
}
/// A loaded mod as a server tells joining clients (`horizon_protocol::mod_list`): its manifest's
/// id, version and side, and the SHA-256 of its `.hmod` (none for a folder).
fn announced_entry(
m: &horizon_mods::Manifest,
path: &std::path::Path,
) -> horizon_protocol::mod_list::Entry {
use horizon_mods::Side;
use horizon_protocol::mod_list;
use sha2::{Digest, Sha256};
let sha256 = if path.is_file() {
std::fs::read(path)
.ok()
.map(|bytes| Sha256::digest(bytes).into())
} else {
None
};
mod_list::Entry {
id: m.id.clone(),
version: m.version.clone(),
sha256,
side: match m.side {
Side::Both => mod_list::Side::Both,
Side::Server => mod_list::Side::Server,
Side::Client => mod_list::Side::Client,
},
}
}
/// The mods' mob effects join vanilla's (`brewing::effect`), once the root
/// holding their network ids is built. The startup root's effects, none
/// included, stay until restart: a pack reload after a Mods switch builds a
+58 -24
View File
@@ -2419,13 +2419,15 @@ impl Game<'_> {
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
///
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// It does not fire for the screens drawn over gameplay after it (a
/// server dialog, the server pack, link and code-of-conduct prompts, a
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
/// server pack prompt hides an open screen too, and that screen does not
/// fire under it), and draw over what it drew, while vanilla's
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
/// their `screen.id` from the title state machine and the whole window
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
/// API 78.
pub(super) fn native_screen_render_post(
&self,
@@ -2445,10 +2447,11 @@ impl Game<'_> {
/// no screen button, no drag, no hotbar key, no sort or header button.
///
/// It fires before anything the screen does with the press, on the window
/// only, and only while a screen of the game's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`). It does not fire on the title screen itself, the connect and
/// loading screens, nor for a server
/// only, and while a screen of the game's or the title's is open (Options
/// and the pack list opened from the title screen count, with no player
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
/// `screen.id` from the title state machine). It does not fire on the
/// connect and loading screens, nor for a server
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
/// sign editor drawn over gameplay, nor while a world loads or saves: the
/// click there stays the game's own. A gamepad click or a click the
@@ -2535,9 +2538,10 @@ impl Game<'_> {
///
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// while a screen of the game's or the title's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`; the title's own since API @API:native_instance_dir@). It
/// does not fire on the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
@@ -2573,10 +2577,10 @@ impl Game<'_> {
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// keys and the narrator — and while a screen of the game's or the title's
/// is open (Options and the pack list opened from the title screen count,
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
@@ -2675,6 +2679,23 @@ impl Game<'_> {
partial_tick: self.clock.fraction() as f32,
}
}
/// The title's own screen as a native mod sees it (`NativeScreenView`):
/// its id from the title state machine (`title`, `world_select`,
/// `world_create`, `multiplayer`, `add_server`…), no menu and no menu
/// title, the whole window as its rectangle and no slots (no menu is
/// open). None while no title screen is up. API @API:native_instance_dir@.
pub(super) fn title_screen_view(&self, batch: &HudBatch) -> Option<NativeScreenView<'_>> {
let title = self.title.as_ref()?;
Some(NativeScreenView {
id: title.mode.id(),
menu: None,
title: None,
rect: [0.0, 0.0, batch.width, batch.height],
slots: 0,
mouse: self.native_cursor(),
partial_tick: self.clock.fraction() as f32,
})
}
/// The open screen's container panel in GUI pixels (`screen`'s, the one
/// this frame draws), or the whole window for a screen with none
/// (`NativeScreenView.rect`).
@@ -2721,15 +2742,16 @@ impl Game<'_> {
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
/// and no connect or world load is in progress. The one gate they all ask
/// before they fire.
/// renderer, either the title's own (`title`, `world_select`,
/// `world_create`, `multiplayer`…) or a screen over the world
/// (`Screen::Playing` behind it, so not a book, a sign editor, a server
/// dialog or a prompt drawn over gameplay), and no connect or world load
/// is in progress. The one gate they all ask before they fire.
pub(super) fn screen_hooks_open(&self) -> bool {
self.renderer.is_some()
&& self.screen != Screen::Playing
&& !self.screen_over_playing()
&& !self.remote_loading()
&& (self.screen != Screen::Playing || self.title.is_some())
}
/// The open screen as an input hook sees it (`NativeScreenView`), or None
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
@@ -2743,7 +2765,10 @@ impl Game<'_> {
}
let renderer = self.renderer.as_ref()?;
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
if self.screen != Screen::Playing {
return Some(self.screen_view(&batch, self.screen));
}
self.title_screen_view(&batch)
}
/// Whether the key press `key` over the open screen went no further
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
@@ -7368,6 +7393,15 @@ impl Game<'_> {
.chat
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
pub(super) fn native_instance_dir(&self) -> PathBuf {
Self::game_dir()
}
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
/// where the WebAssembly mod of the same id kept its storage, so a
/// player's settings carry over. Not created here.
@@ -691,7 +691,7 @@ impl Game<'_> {
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
self.held_item_geometry(m, camera, alpha, packed, true, None, out);
// CustomHeadLayer: a pumpkin, skull or other head item.
self.head_item_geometry(m, camera, alpha, packed, true, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
@@ -802,7 +802,7 @@ impl Game<'_> {
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
let wings = wings_start..verts.len();
self.shoulder_parrots_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, None, &mut verts);
self.head_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
// PictureInPictureRenderer: the box centre, (size, size, -size);
+18 -5
View File
@@ -1260,10 +1260,10 @@ mod tests {
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
}
/// Every vanilla item on the ground, turned, shaded and unshaded: the GPU path's
/// vertices (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each state it takes. Needs the prepared
/// resources.
/// Every vanilla item on the ground, held in either hand, framed and worn on the
/// head, turned, shaded and unshaded: the GPU path's vertices (its jobs expanded as
/// `entity_skin.wgsl` does) are the CPU path's, to the bit and in the same order, for
/// each state it takes. Needs the prepared resources.
#[test]
fn items_posed_on_the_gpu_draw_what_the_cpu_draws() {
use crate::gfx::entity_skin::{SkinFrame, expand};
@@ -1281,11 +1281,24 @@ mod tests {
let view = StackView::new(id, 1);
r.resolve(&view, DisplayContext::Ground, None, None, 7, &NoEnv)
};
// Each display context's transforms (`ItemTransform`s) pose the same quads.
let contexts = [
DisplayContext::Ground,
DisplayContext::ThirdPersonRightHand,
DisplayContext::ThirdPersonLeftHand,
DisplayContext::Fixed,
DisplayContext::Head,
];
let states: Vec<_> = r
.models
.definitions
.keys()
.map(|id| (id, resolve(id)))
.flat_map(|id| {
contexts.map(|context| {
let view = StackView::new(id, 1);
(id, r.resolve(&view, context, None, None, 7, &NoEnv))
})
})
.collect();
// Each sprite its own rect, so a run drawn with another's shows.
let mut sprites: HashMap<String, Rect> = HashMap::new();
+40
View File
@@ -1003,6 +1003,46 @@ fn login_and_configure(
}),
)?;
}
// The mods that must be the same on both sides, named when they are not
// (horizon_protocol::mod_list); the registry hash below only sees ids.
let server_mods = horizon_protocol::mod_list::local();
if brand == "horizon" && !server_mods.is_empty() {
send(
c,
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
channel: horizon_protocol::mod_list::CHANNEL.into(),
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
&server_mods,
)),
}),
)?;
send(c, cfg::Clientbound::Ping(cfg::Ping { id: 1 }))?;
// A client that does not answer has no mods to offer.
let mut client_mods = Vec::new();
loop {
let (id, payload) = c.recv().map_err(e)?;
match decode_config(id, &payload).map_err(e)? {
cfg::Serverbound::CustomPayload(p)
if p.channel == horizon_protocol::mod_list::CHANNEL =>
{
client_mods = horizon_protocol::mod_list::decode(&p.data).unwrap_or_default();
}
cfg::Serverbound::Pong(p) if p.id == 1 => break,
cfg::Serverbound::ClientInformation(i) => info = i,
_ => {}
}
}
let difference = horizon_protocol::mod_list::compare(&server_mods, &client_mods);
if !difference.is_empty() {
let text = difference.reason();
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(
text.as_str(),
));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
return Ok(None);
}
}
if brand == "horizon"
&& let Some(hash) = sync.content_identity
{
+4
View File
@@ -276,6 +276,9 @@ pub struct Guest {
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
/// last sent about itself.
pub sent_shared: i8,
/// The shoulder parrots (Player.DATA_SHOULDER_PARROT_LEFT and _RIGHT) the
/// player's client was last sent about itself.
pub sent_shoulders: [Option<i32>; 2],
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
pub last_death: Option<(String, [i32; 3])>,
/// The last action sequence to acknowledge at the next tick (-1: none).
@@ -450,6 +453,7 @@ impl Guest {
sent_xp: None,
sent_air: (300, 0.0),
sent_shared: 0,
sent_shoulders: [None; 2],
last_death: None,
ack_up_to: std::cell::Cell::new(-1),
sent_selected: 0,
+37
View File
@@ -356,6 +356,19 @@ fn shoulder_meta(state: &mut EntityState, shoulders: [Option<i32>; 2]) {
}
}
/// The shoulder parrot slots that differ from what the player's client was
/// last sent, as entity data: 0 for none, else the variant's id plus one.
fn shoulder_changes(now: [Option<i32>; 2], sent: [Option<i32>; 2]) -> Vec<(u8, i32, Value)> {
(19..)
.zip(now.into_iter().zip(sent))
.filter(|(_, (now, sent))| now != sent)
.map(|(index, (now, _))| {
let id = now.map_or(0, |v| v + 1);
(index, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id))
})
.collect()
}
impl Hub {
/// Player.getDisplayName: the name formatted for the player's team
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
@@ -2688,6 +2701,17 @@ impl Hub {
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
}));
}
// ServerEntity.sendDirtyEntityData: the parrots on the player's
// shoulders go to the player too, whose own model draws them.
let g = &mut self.guests[i];
if g.shoulders != g.sent_shoulders {
let values = shoulder_changes(g.shoulders, g.sent_shoulders);
g.sent_shoulders = g.shoulders;
g.send(&C::SetEntityData(play::SetEntityData {
id: g.entity_id,
values,
}));
}
// Sent when the health or the food level changes, or the saturation
// reaches or leaves zero; a dead player's health of 0 too.
let g = &mut self.guests[i];
@@ -3829,6 +3853,7 @@ impl Hub {
g.sent_xp = None;
g.sent_air = (300, 0.0);
g.sent_shared = 0;
g.sent_shoulders = [None; 2];
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
x: cx,
@@ -3999,6 +4024,18 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
assert!(shoulder_changes([None; 2], [None; 2]).is_empty());
let landed = shoulder_changes([Some(1), None], [None; 2]);
assert_eq!(landed.len(), 1);
assert_eq!((landed[0].0, &landed[0].2), (19, &Value::Id(2)));
let left = shoulder_changes([None; 2], [Some(1), Some(4)]);
assert_eq!(left.len(), 2);
assert_eq!((left[1].0, &left[1].2), (20, &Value::Id(0)));
}
/// The counts of what is stuck in a player are sent when set, and a
/// count back at zero is sent as the default.
#[test]
+4 -1
View File
@@ -110,7 +110,10 @@ pub struct NativeScreenView<'a> {
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`.
/// `slots`. The title's own screens report theirs (API @API:native_instance_dir@): `title`,
/// `world_select`, `world_create`, `world_create_flat`, `world_edit`,
/// `multiplayer`, `add_server`, `direct_connect`…, with no menu, no title
/// and no slots.
pub id: &'static str,
/// The type id of the menu the server opened for this screen
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
+22 -18
View File
@@ -1187,13 +1187,15 @@ pub trait Hooks: Send + Sync {
/// it is. Once a frame on the window, never while playing with no screen;
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// It does not fire for the screens drawn over gameplay after it (a
/// server dialog, the server pack, link and code-of-conduct prompts, a
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
/// server pack prompt hides an open screen too, and that screen does not
/// fire under it), and draw over what it drew, while vanilla's
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
/// their `screen.id` from the title state machine and the whole window
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
/// API 78.
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
let _ = host;
@@ -1225,10 +1227,11 @@ pub trait Hooks: Send + Sync {
/// alt 4, super 8). Set `canceled` and the press goes no further: no slot,
/// no screen button, no drag, no hotbar key, no sort or header button.
/// It fires before anything the screen does with the press, on the window
/// only, and only while a screen of the game's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`). It does not fire on the title screen itself, the connect and
/// loading screens, nor for a server
/// only, and while a screen of the game's or the title's is open (Options
/// and the pack list opened from the title screen count, with no player
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
/// `screen.id` from the title state machine). It does not fire on the
/// connect and loading screens, nor for a server
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
/// sign editor drawn over gameplay, nor while a world loads or saves: the
/// click there stays the game's own. A gamepad click or a click the
@@ -1418,9 +1421,10 @@ pub trait Hooks: Send + Sync {
/// changes either.
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// while a screen of the game's or the title's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`; the title's own since API @API:native_instance_dir@). It
/// does not fire on the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
@@ -1488,10 +1492,10 @@ pub trait Hooks: Send + Sync {
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// keys and the narrator — and while a screen of the game's or the title's
/// is open (Options and the pack list opened from the title screen count,
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
+9
View File
@@ -1180,4 +1180,13 @@ pub trait Host {
let _ = size;
unimplemented!("Host::item_stack")
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
fn instance_dir(&self) -> PathBuf {
unimplemented!("Host::instance_dir")
}
}
+18
View File
@@ -138,6 +138,24 @@ pub enum Outcome {
Back,
Connect { address: String, lan: bool },
}
impl Screen {
/// The id native mods see for this screen (`NativeScreenView.id`, the
/// screen hooks): one per variant, a hook's contract, so it stays the
/// same from release to release. A new variant must add one.
pub fn id(&self) -> &'static str {
match self {
Self::List { .. } => "multiplayer",
Self::Edit { index: None, .. } => "add_server",
Self::Edit { index: Some(_), .. } => "edit_server",
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Disconnected { .. } => "disconnected",
}
}
}
/// A button: x, y, width, label, enabled, id.
pub type ButtonSpec = (f32, f32, f32, String, bool, u8);
+11
View File
@@ -910,6 +910,17 @@ fn configure(
data: hash.to_vec(),
}))?;
}
// The server names its mods; the answer is ours, and the server judges.
cfg::Clientbound::CustomPayload(p)
if p.channel == horizon_protocol::mod_list::CHANNEL =>
{
reply(cfg::Serverbound::CustomPayload(cfg::CustomPayload {
channel: horizon_protocol::mod_list::CHANNEL.into(),
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
&horizon_protocol::mod_list::local(),
)),
}))?;
}
cfg::Clientbound::CustomPayload(p) => receive_brand(&p, send),
cfg::Clientbound::Ping(p) => reply(cfg::Serverbound::Pong(p))?,
// Know no packs: the server then sends every registry entry in full.
+2 -2
View File
@@ -776,7 +776,7 @@ fn players(id: &str, ctx: &Context, env: &mut dyn Env) -> CmdResult<i32> {
}
}
/// `OperationArgument`: the new (target, source) values.
/// `OperationArgument.getSimpleOperation`: the new (target, source) values.
pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
let div0 = || err("arguments.operation.div0", &[]);
Ok(match op {
@@ -789,7 +789,7 @@ pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
return Err(div0());
}
(
a.wrapping_div(b) - i32::from((a % b != 0) && ((a < 0) != (b < 0))),
a.wrapping_div(b) - i32::from((a.wrapping_rem(b) != 0) && ((a < 0) != (b < 0))),
b,
)
}
@@ -189,6 +189,27 @@ fn operations_use_floor_semantics() {
assert_eq!(apply_operation("*=", i32::MAX, 2).unwrap().0, -2);
}
#[test]
fn division_wraps_minimum_integer_by_negative_one() {
let mut m = mock();
run(&mut m, "scoreboard objectives add poc dummy").unwrap();
run(&mut m, "scoreboard players set target poc -2147483648").unwrap();
run(&mut m, "scoreboard players set divisor poc -1").unwrap();
run(
&mut m,
"scoreboard players operation target poc /= divisor poc",
)
.unwrap();
assert_eq!(
run(&mut m, "scoreboard players get target poc").unwrap(),
i32::MIN
);
assert_eq!(
run(&mut m, "scoreboard players get divisor poc").unwrap(),
-1
);
}
#[test]
fn triggers() {
let mut m = mock();
+29
View File
@@ -699,6 +699,35 @@ fn rolls_user_splash(text: &str, seed: u64, date: (u32, u32)) -> bool {
.is_ok_and(|size| size > 0 && LegacyRandom::new(seed as i64).next_int_bound(size) == Ok(42))
}
impl Mode {
/// The id native mods see for this screen (`NativeScreenView.id`, the
/// screen hooks): one per variant, a hook's contract, so it stays the
/// same from release to release. A new variant must add one.
pub fn id(&self) -> &'static str {
match self {
Self::Main => "title",
Self::Select { .. } => "world_select",
Self::Create { .. } => "world_create",
Self::CreateFlat { .. } => "world_create_flat",
Self::CreateBuffet { .. } => "world_create_buffet",
Self::Edit { .. } => "world_edit",
Self::Backup { .. } => "world_backup",
Self::BackupBeforeUpgrade { .. } => "world_backup_confirm",
Self::OptimizeConfirm { .. } => "world_optimize_confirm",
Self::Optimize { .. } => "world_optimize",
Self::LowDiskSpace { .. } => "low_disk_space",
Self::Delete { .. } => "world_delete",
Self::Recover { .. } => "world_recover",
Self::Recovered { .. } => "world_recovered",
Self::Error { .. } => "error",
Self::Multiplayer(screen) => screen.id(),
Self::MultiplayerWarning { .. } => "multiplayer_warning",
Self::Onboarding(_) => "onboarding",
Self::BanNotice(_) => "ban_notice",
}
}
}
impl Title {
/// Names the player for the `<NAME> IS YOU` splash; `name` is only asked for when that splash was rolled.
pub fn with_user(mut self, name: impl FnOnce() -> String) -> Self {
+73
View File
@@ -2278,3 +2278,76 @@ fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() {
);
}
}
/// The ids native mods see for the title's screens (`Mode::id`, the screen
/// hooks' `NativeScreenView.id`): one per screen, stable across releases.
#[test]
fn the_title_screens_have_their_own_ids() {
let mut f = Fixture::new();
assert_eq!(f.title.mode.id(), "title");
// An empty list opens Create New World (an empty list is why vanilla
// starts one), a world in it opens the list.
f.title.enter_singleplayer();
assert!(matches!(f.title.mode, Mode::Create { .. }));
assert_eq!(f.title.mode.id(), "world_create");
f.title.escape();
assert_eq!(f.title.mode.id(), "title");
f.world("World", "World");
f.select();
assert_eq!(f.title.mode.id(), "world_select");
f.click("Edit");
assert_eq!(f.title.mode.id(), "world_edit");
}
/// The multiplayer screens' ids, the ones the title's `Multiplayer` mode
/// passes through.
#[test]
fn the_multiplayer_screens_have_their_own_ids() {
use crate::multiplayer::Screen as M;
assert_eq!(
M::Direct {
address: String::new()
}
.id(),
"direct_connect"
);
assert_eq!(
M::Edit {
index: None,
name: String::new(),
address: String::new(),
focus: 0,
packs: None,
}
.id(),
"add_server"
);
assert_eq!(
M::Edit {
index: Some(0),
name: String::new(),
address: String::new(),
focus: 0,
packs: None,
}
.id(),
"edit_server"
);
assert_eq!(
M::Friend {
code: String::new(),
relay: String::new(),
focus: 0,
}
.id(),
"friends_join"
);
assert_eq!(
M::Disconnected {
reason: String::new(),
retry: None,
}
.id(),
"disconnected"
);
}
+63 -4
View File
@@ -32,10 +32,12 @@ use std::{
pub const FILE_NAME: &str = "horizon-data.bin";
/// The file it checks its preparation against.
const SOURCE: &str = "horizon-resources.json";
/// The folders it holds, with their subfolders.
const FOLDERS: [&str; 2] = ["data", "assets"];
/// The folders it holds, with their subfolders: the resources, and the
/// structure templates world generation loads one by one.
const FOLDERS: [&str; 3] = ["data", "assets", "worldgen/structure"];
const MAGIC: [u8; 8] = *b"HZDATA\0\0";
const VERSION: u32 = 1;
/// 2: `worldgen/structure` joined the folders (a version 1 file lacks it).
const VERSION: u32 = 2;
const HEADER: usize = 40;
const ENTRY: usize = 24;
@@ -224,6 +226,21 @@ pub fn read(path: &Path) -> io::Result<Vec<u8>> {
}
}
/// The files under `dir` (a folder of a prepared root) and its subfolders, as
/// `/`-separated paths from `dir` in name order, from the root's data file
/// without listing the disk: empty when it holds none there. `None` when no
/// data file holds `dir` (walk the folder).
pub fn files_under(dir: &Path) -> Option<Vec<String>> {
let (files, rel) = find(dir)?;
let names = files.list(&rel).unwrap_or_default();
Some(
names
.into_iter()
.map(|name| name[rel.len() + 1..].to_owned())
.collect(),
)
}
/// At most the first `max` bytes of `path`, from where [`read`] takes them:
/// a header, without reading the whole file.
pub fn read_head(path: &Path, max: u64) -> io::Result<Vec<u8>> {
@@ -392,6 +409,11 @@ mod tests {
("data/minecraft/tags/item/logs.json", b"[1,2,3]"),
("assets/minecraft/textures/block/stone.png", b"\x89PNG..."),
("assets/minecraft/sounds/ambient/cave1.ogg", b"OggS"),
(
"worldgen/structure/minecraft/fossil/spine_1.nbt",
b"\x1f\x8b",
),
("worldgen/biomes.json", b"{}"),
("facts.json", b"{}"),
] {
let path = root.join(name);
@@ -425,6 +447,14 @@ mod tests {
.is_none()
);
assert!(files.read("facts.json").is_none());
assert!(files.read("worldgen/biomes.json").is_none());
assert_eq!(
files
.read("worldgen/structure/minecraft/fossil/spine_1.nbt")
.unwrap()
.unwrap(),
b"\x1f\x8b"
);
assert!(files.read("data/minecraft/recipe/missing.json").is_none());
assert_eq!(
files.list("data/minecraft/recipe").unwrap(),
@@ -483,6 +513,31 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
/// A folder's files come from the file, without the disk: one added since
/// isn't listed (as `read` doesn't find it), and a folder it holds nothing
/// in is empty. Outside its folders, the disk is walked.
#[test]
fn files_under_lists_a_folder_from_the_file() {
let root = root("under");
write(&root).unwrap();
fs::write(root.join("assets/minecraft/textures/block/added.png"), b"x").unwrap();
assert_eq!(
files_under(&root.join("assets/minecraft/textures")).unwrap(),
["block/stone.png"]
);
assert_eq!(
files_under(&root.join("worldgen/structure")).unwrap(),
["minecraft/fossil/spine_1.nbt"]
);
assert!(
files_under(&root.join("assets/minecraft/textures/entity"))
.unwrap()
.is_empty()
);
assert_eq!(files_under(&root.join("worldgen")), None);
let _ = fs::remove_dir_all(&root);
}
/// `find` gives the file and the path from its root for a folder under a
/// root that has one, and nothing elsewhere.
#[test]
@@ -491,7 +546,11 @@ mod tests {
write(&root).unwrap();
let (_, rel) = find(&root.join("data/minecraft/recipe")).unwrap();
assert_eq!(rel, "data/minecraft/recipe");
assert!(find(&root.join("worldgen/structure")).is_none());
let (_, rel) = find(&root.join("worldgen/structure/minecraft")).unwrap();
assert_eq!(rel, "worldgen/structure/minecraft");
// The top-level tables beside the structures stay on the disk.
assert!(find(&root.join("worldgen")).is_none());
assert!(find(&root.join("worldgen/biomes.json")).is_none());
let _ = fs::remove_dir_all(&root);
}
+1
View File
@@ -7,6 +7,7 @@ rust-version.workspace = true
description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules, and the Fusion mod's connected-texture format"
[dependencies]
horizon-core = { path = "../horizon-core" }
log = "0.4"
regex = { version = "1", default-features = false, features = ["std", "unicode-perl", "unicode-case"] }
+12
View File
@@ -117,6 +117,18 @@ fn walk(
seen: &mut HashSet<PathBuf>,
out: &mut Vec<String>,
) {
// A prepared root's folder: from its data file, without listing the disk.
if depth == 0
&& let Some(files) = horizon_core::prepared_files::files_under(dir)
{
out.extend(
files
.into_iter()
.filter(|file| endings.iter().any(|end| file.ends_with(end)))
.map(|file| format!("{rel}/{file}")),
);
return;
}
// Links are followed (overlay folders are links into the packs and vanilla), so a link back
// to a parent would loop: each real folder is read once, and never deeper than MAX_DEPTH.
if depth > MAX_DEPTH || !fs::canonicalize(dir).is_ok_and(|real| seen.insert(real)) {
+1
View File
@@ -13,6 +13,7 @@ pub mod frame;
pub mod hash_ops;
pub mod ids;
pub mod item;
pub mod mod_list;
pub mod mod_net;
pub mod packets;
pub mod quiet;
+348
View File
@@ -0,0 +1,348 @@
//! The mod list: which content mods a Horizon server and a Horizon client
//! have turned on, compared when a client joins (docs/mods.md, "Playing on a
//! server with mods").
//!
//! In the configuration phase the server sends its list on [`CHANNEL`] and
//! the client answers with its own on the same channel. The server then
//! refuses a client whose mods differ, naming each one, which the registry
//! hash of `horizon:block_registry` cannot do (it only sees ids): a mod
//! whose loot, recipes or textures were edited keeps its ids, not its file's
//! SHA-256. A vanilla client never hears the channel.
use crate::{
Error, Result,
buf::{Reader, Writer},
};
use std::sync::OnceLock;
/// The custom payload channel, both ways.
pub const CHANNEL: &str = "horizon:mod_list";
/// Mods in a list at most; a list with more is refused whole.
pub const MAX_ENTRIES: usize = 128;
/// Characters in a mod's id or version at most.
pub const MAX_TEXT: usize = 64;
/// Mods named in a refusal's reason per kind of difference; the rest is counted.
const SHOWN: usize = 6;
/// Where a mod runs (a manifest's `side`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
/// Both halves must have it: it changes what the world holds.
Both,
/// Only the server needs it.
Server,
/// Only the player's game: a HUD, keys, a minimap. Any server accepts it.
Client,
}
impl Side {
fn code(self) -> u8 {
match self {
Side::Both => 0,
Side::Server => 1,
Side::Client => 2,
}
}
fn from_code(code: u8) -> Result<Self> {
match code {
0 => Ok(Side::Both),
1 => Ok(Side::Server),
2 => Ok(Side::Client),
_ => Err(Error::new(format!("mod list: side {code}"))),
}
}
}
/// A mod that is on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: String,
pub version: String,
/// SHA-256 of the mod's file; none for a mod run from a folder.
pub sha256: Option<[u8; 32]>,
pub side: Side,
}
/// The mods this game has on, set once when they load (`game/mods.rs`), like
/// the registries they fill, and read when a connection is set up, whatever
/// side of it this game is.
static LOCAL: OnceLock<Vec<Entry>> = OnceLock::new();
/// Sets the mods this game has on; false when they were set already (a pack
/// reload builds another root, the registries keep the first).
pub fn set_local(mut entries: Vec<Entry>) -> bool {
entries.sort_by(|a, b| a.id.cmp(&b.id));
entries.truncate(MAX_ENTRIES);
LOCAL.set(entries).is_ok()
}
/// Whether `set_local` has run: a pack reload then skips reading and hashing
/// the mods' files again, since the first list stays.
pub fn is_set() -> bool {
LOCAL.get().is_some()
}
/// The mods this game has on, by id.
pub fn local() -> Vec<Entry> {
LOCAL.get().cloned().unwrap_or_default()
}
/// The entries the other side compares (`side = "both"`): what a join sends. Client-only
/// and server-only mods stay private, since any server can ask for the list.
pub fn shared(entries: &[Entry]) -> Vec<Entry> {
entries
.iter()
.filter(|e| e.side == Side::Both)
.cloned()
.collect()
}
/// A list as a payload.
pub fn encode(entries: &[Entry]) -> Vec<u8> {
let cut = |s: &str| -> String { s.chars().take(MAX_TEXT).collect() };
let entries = &entries[..entries.len().min(MAX_ENTRIES)];
let mut w = Writer::new();
w.varint(entries.len() as i32);
for e in entries {
w.string(&cut(&e.id));
w.string(&cut(&e.version));
w.u8(e.side.code());
match &e.sha256 {
Some(hash) => {
w.bool(true);
w.bytes(hash);
}
None => w.bool(false),
}
}
w.data
}
/// A payload's list; an error for one that breaks a cap.
pub fn decode(data: &[u8]) -> Result<Vec<Entry>> {
let mut r = Reader::new(data);
let count = r.varint()?;
if !(0..=MAX_ENTRIES as i32).contains(&count) {
return Err(Error::new(format!("mod list: {count} mods")));
}
let entries = (0..count)
.map(|_| {
let id = r.string(MAX_TEXT)?;
let version = r.string(MAX_TEXT)?;
let side = Side::from_code(r.u8()?)?;
let sha256 = match r.bool()? {
true => Some(r.bytes(32)?.try_into().expect("32 bytes")),
false => None,
};
Ok(Entry {
id,
version,
sha256,
side,
})
})
.collect::<Result<Vec<_>>>()?;
if r.remaining() > 0 {
return Err(Error::new("mod list: trailing bytes"));
}
Ok(entries)
}
/// How a client's mods differ from a server's.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Difference {
/// The server has it on and the client does not: `id version`.
pub missing: Vec<String>,
/// The client has it on, the server does not, and it changes the world.
pub extra: Vec<String>,
/// Both have it, not the same: `id (server version, yours version)`, or
/// `id version (your file was changed)` for the same version.
pub changed: Vec<String>,
}
impl Difference {
pub fn is_empty(&self) -> bool {
self.missing.is_empty() && self.extra.is_empty() && self.changed.is_empty()
}
/// What the refused player reads.
pub fn reason(&self) -> String {
let mut parts = Vec::new();
for (label, names) in [
("Missing", &self.missing),
("Not on this server", &self.extra),
("Different version or file", &self.changed),
] {
if names.is_empty() {
continue;
}
let mut text = names[..names.len().min(SHOWN)].join(", ");
if names.len() > SHOWN {
text.push_str(&format!(" and {} more", names.len() - SHOWN));
}
parts.push(format!("{label}: {text}"));
}
format!("Your mods don't match this server's. {}", parts.join(". "))
}
}
/// What a client must change to join a server. Only `Both` mods count, on
/// either side: a `Server` mod is the server's business, a `Client` one the
/// player's.
pub fn compare(server: &[Entry], client: &[Entry]) -> Difference {
let mut difference = Difference::default();
for s in server.iter().filter(|s| s.side == Side::Both) {
match client.iter().find(|c| c.id == s.id) {
None => difference.missing.push(format!("{} {}", s.id, s.version)),
Some(c) => {
let other_file = matches!((s.sha256, c.sha256), (Some(a), Some(b)) if a != b);
if s.version != c.version {
difference.changed.push(format!(
"{} (server {}, yours {})",
s.id, s.version, c.version
));
} else if other_file {
difference
.changed
.push(format!("{} {} (your file was changed)", s.id, s.version));
}
}
}
}
for c in client.iter().filter(|c| c.side == Side::Both) {
if !server.iter().any(|s| s.id == c.id) {
difference.extra.push(format!("{} {}", c.id, c.version));
}
}
difference
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, version: &str, hash: u8, side: Side) -> Entry {
Entry {
id: id.into(),
version: version.into(),
sha256: Some([hash; 32]),
side,
}
}
#[test]
fn only_shared_mods_are_sent() {
let list = [
entry("ruby", "1.0", 1, Side::Both),
entry("minimap", "2.0", 2, Side::Client),
entry("admin", "3.0", 3, Side::Server),
];
let sent = shared(&list);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].id, "ruby");
assert!(compare(&list, &sent).is_empty());
}
#[test]
fn a_list_round_trips() {
let list = vec![
entry("ruby", "1.2.0", 7, Side::Both),
Entry {
sha256: None,
..entry("dev", "0.1", 0, Side::Client)
},
];
assert_eq!(decode(&encode(&list)).unwrap(), list);
assert_eq!(decode(&encode(&[])).unwrap(), []);
}
#[test]
fn lists_over_the_caps_are_refused() {
let mut w = Writer::new();
w.varint(MAX_ENTRIES as i32 + 1);
assert!(decode(&w.data).is_err());
let mut w = Writer::new();
w.varint(1);
w.string(&"x".repeat(MAX_TEXT + 1));
assert!(decode(&w.data).is_err());
let mut w = Writer::new();
w.varint(1);
w.string("a");
w.string("1");
w.u8(9);
w.bool(false);
assert!(decode(&w.data).is_err());
let mut trailing = encode(&[]);
trailing.push(0);
assert!(decode(&trailing).is_err());
assert!(decode(&[0xFF]).is_err());
}
#[test]
fn the_same_mods_match_whatever_the_order() {
let a = entry("a", "1", 1, Side::Both);
let b = entry("b", "2", 2, Side::Both);
assert!(compare(&[a.clone(), b.clone()], &[b, a]).is_empty());
}
#[test]
fn each_kind_of_difference_is_named() {
let server = [
entry("ruby", "1.0", 1, Side::Both),
entry("pipes", "2.0", 2, Side::Both),
entry("glass", "1.0", 3, Side::Both),
];
let client = [
entry("ruby", "1.0", 1, Side::Both),
entry("pipes", "2.0", 99, Side::Both),
entry("glass", "0.9", 3, Side::Both),
entry("xray_blocks", "1.0", 4, Side::Both),
];
let d = compare(&server, &client);
assert_eq!(d.missing, Vec::<String>::new());
assert_eq!(d.extra, ["xray_blocks 1.0"]);
assert_eq!(
d.changed,
[
"pipes 2.0 (your file was changed)",
"glass (server 1.0, yours 0.9)"
]
);
let d = compare(&server, &client[..1]);
assert_eq!(d.missing, ["pipes 2.0", "glass 1.0"]);
let reason = d.reason();
assert!(reason.contains("Missing: pipes 2.0, glass 1.0"), "{reason}");
}
#[test]
fn client_only_and_server_only_mods_never_block_a_join() {
let server = [
entry("anti_grief", "1", 1, Side::Server),
entry("ruby", "1", 2, Side::Both),
];
let client = [
entry("minimap", "3", 3, Side::Client),
entry("ruby", "1", 2, Side::Both),
];
assert!(compare(&server, &client).is_empty());
assert!(compare(&[], &[entry("minimap", "3", 3, Side::Client)]).is_empty());
}
#[test]
fn a_mod_run_from_a_folder_matches_by_version() {
let mut folder = entry("ruby", "1", 0, Side::Both);
folder.sha256 = None;
assert!(compare(&[entry("ruby", "1", 5, Side::Both)], &[folder.clone()]).is_empty());
assert!(!compare(&[entry("ruby", "2", 5, Side::Both)], &[folder]).is_empty());
}
#[test]
fn a_long_refusal_counts_the_rest() {
let server: Vec<_> = (0..9)
.map(|i| entry(&format!("m{i}"), "1", i, Side::Both))
.collect();
let reason = compare(&server, &[]).reason();
assert!(reason.contains("m5 1 and 3 more"), "{reason}");
assert!(!reason.contains("m6"), "{reason}");
}
}
+75 -3
View File
@@ -312,16 +312,39 @@ fn json_bool(v: &Json) -> bool {
}
}
/// What happened to a list, for the one that watches it: what vanilla's `StoredUserList` tells its
/// `NotificationService` (`playerOped`/`playerDeoped`, `playerAddedToAllowlist`/`playerRemovedFromAllowlist`,
/// `playerBanned`/`playerUnbanned`, `ipBanned`/`ipUnbanned`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Change<E> {
/// An entry was added (or replaced by a different one).
Added(E),
/// An entry was removed; a `clear` says it once for each entry, in the map's order, before it empties.
Removed(E),
}
/// StoredUserList.
pub struct UserList<E: Entry> {
pub file: PathBuf,
map: JavaHashMap<JKey, E>,
watcher: Option<Box<dyn Fn(Change<E>) + Send>>,
}
impl<E: Entry> UserList<E> {
pub fn new(file: impl Into<PathBuf>) -> Self {
Self {
file: file.into(),
map: JavaHashMap::default(),
watcher: None,
}
}
/// Has `watcher` told of every change from now on (`add`, `remove` and `clear`, not a `load`, and not the
/// expired entries `get` drops, as vanilla's don't).
pub fn watch(&mut self, watcher: impl Fn(Change<E>) + Send + 'static) {
self.watcher = Some(Box::new(watcher));
}
fn told(&self, change: Change<E>) {
if let Some(watcher) = &self.watcher {
watcher(change);
}
}
/// The entries in the map's iteration order.
@@ -354,23 +377,29 @@ impl<E: Entry> UserList<E> {
if self.map.get(&key).is_some_and(|old| entry.same(old)) {
return false;
}
self.map.put(key, entry);
self.map.put(key, entry.clone());
if let Err(e) = self.save() {
log::warn!("Could not save the list after adding a user. {e}");
}
self.told(Change::Added(entry));
true
}
pub fn remove(&mut self, user: &E::User) -> bool {
if self.map.remove(&JKey(E::key(user))).is_none() {
let Some(entry) = self.map.remove(&JKey(E::key(user))) else {
return false;
}
};
if let Err(e) = self.save() {
log::warn!("Could not save the list after removing a user. {e}");
}
self.told(Change::Removed(entry));
true
}
/// StoredUserList.clear: no entry left; saves.
pub fn clear(&mut self) {
let entries: Vec<E> = self.map.entries().map(|(_, e)| e.clone()).collect();
for entry in entries {
self.told(Change::Removed(entry));
}
self.map.clear();
if let Err(e) = self.save() {
log::warn!("Could not save the list after removing a user. {e}");
@@ -448,6 +477,49 @@ mod tests {
assert_eq!(parse_date("forever"), None);
}
#[test]
fn a_watcher_hears_what_vanillas_notification_service_does() {
use std::sync::{Arc, Mutex};
let dir = std::env::temp_dir().join(format!("horizon-lists-watch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let heard = Arc::new(Mutex::new(Vec::new()));
let mut ops = OpList::new(dir.join("ops.json"));
let sink = heard.clone();
ops.watch(move |change| sink.lock().unwrap().push(change));
let op = |name: &str, level| OpEntry {
user: NameAndId::offline(name),
level,
bypasses_player_limit: false,
};
assert!(ops.add(op("Kim", 4)));
assert!(ops.add(op("Lee", 2)));
// A removal that finds nothing says nothing; one that finds an entry says which.
assert!(!ops.remove(&NameAndId::offline("Nobody")));
assert!(ops.remove(&NameAndId::offline("Kim")));
// A clear says it for each entry that is left, and the list is empty after.
ops.clear();
assert!(ops.is_empty());
assert_eq!(
*heard.lock().unwrap(),
[
Change::Added(op("Kim", 4)),
Change::Added(op("Lee", 2)),
Change::Removed(op("Kim", 4)),
Change::Removed(op("Lee", 2)),
]
);
// A ban equal to the stored one is not added again, so nothing is said.
let mut bans = BanList::new(dir.join("banned-players.json"));
let said = Arc::new(Mutex::new(0));
let counter = said.clone();
bans.watch(move |_| *counter.lock().unwrap() += 1);
let ban = BanEntry::new(NameAndId::offline("Kim"), None, None, None);
assert!(bans.add(ban.clone()));
assert!(!bans.add(ban));
assert_eq!(*said.lock().unwrap(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_cleared_list_saves_empty() {
let dir = std::env::temp_dir().join(format!("horizon-lists-{}", std::process::id()));
+28 -3
View File
@@ -73,8 +73,9 @@ pub fn started(shared: &Shared) {
}
/// The notification a typed event of the game is, if vanilla sends one for it: a player who joined or left
/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), and a world save that began or ended
/// (`serverSaveStarted`, `serverSaveCompleted`).
/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), a world save that began or ended
/// (`serverSaveStarted`, `serverSaveCompleted`), and a change of the operators, the allowlist, the bans or the
/// IP bans.
pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
let player = || Some(json!({ "id": event["uuid"], "name": event["name"] }));
let (method, params) = match kind {
@@ -82,7 +83,11 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
"leave" => ("notification/players/left", player()),
"saving" => ("notification/server/saving", None),
"saved" => ("notification/server/saved", None),
_ => return,
// A change of a list, whoever made it: the console, a command in the game, the protocol's own methods.
_ => match crate::management_methods::list_notification(kind, event) {
Some((method, params)) => (method, Some(params)),
None => return,
},
};
notify(shared, method, params);
}
@@ -891,6 +896,18 @@ mod tests {
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":{player}}}"#
)
);
// A change of a list reaches the client with its one parameter (the player, for an allowlist).
game_event(
&shared,
"allowlist_removed",
&json!({ "name": "Kim0", "uuid": id }),
);
assert_eq!(
next_text(&mut client),
format!(
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/allowlist/removed","params":{player}}}"#
)
);
}
#[test]
@@ -909,6 +926,14 @@ mod tests {
"notification/server/saved",
"notification/players/joined",
"notification/players/left",
"notification/operators/added",
"notification/operators/removed",
"notification/allowlist/added",
"notification/allowlist/removed",
"notification/bans/added",
"notification/bans/removed",
"notification/ip_bans/added",
"notification/ip_bans/removed",
] {
assert!(
names.contains(&format!("minecraft:{sent}").as_str()),
@@ -77,6 +77,28 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"serversettings/use_allowlist" => game.allowlist_setting(client, "use_allowlist", None),
"serversettings/use_allowlist/set" => game.allowlist_setting(
client,
"use_allowlist",
Some(
argument
.as_bool()
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"serversettings/enforce_allowlist" => {
game.allowlist_setting(client, "enforce_allowlist", None)
}
"serversettings/enforce_allowlist/set" => game.allowlist_setting(
client,
"enforce_allowlist",
Some(
argument
.as_bool()
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"allowlist" => Ok(lists.allowlist()),
"bans" => Ok(lists.bans()),
"ip_bans" => Ok(lists.ip_bans()),
@@ -308,6 +330,42 @@ struct Game<'a> {
}
impl Game<'_> {
/// `MinecraftServerSettingsServiceImpl.isUsingWhitelist`, `setUsingWhitelist`,
/// `isEnforceWhitelist` and `setEnforceWhitelist`.
fn allowlist_setting(
&self,
client: u32,
setting: &str,
value: Option<bool>,
) -> Result<Value, Failure> {
let mut request = json!({
"method": format!("serversettings/{setting}")
});
if let Some(value) = value {
request["method"] = json!(format!("serversettings/{setting}/set"));
request["value"] = json!(value);
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
if let Some(value) = value {
let property = match setting {
"use_allowlist" => "whitelist",
"enforce_allowlist" => "enforce_whitelist",
_ => unreachable!("allowlist setting is validated by its callers"),
};
crate::config::update(
&self.shared.dir,
"properties",
property,
toml::Value::Boolean(value),
)
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
@@ -600,6 +658,41 @@ impl Lists<'_> {
}
}
/// The notification a change of one of the lists is, from the game's typed event (`operators_added`,
/// `bans_removed`...): `JsonRpcNotificationService`'s `playerOped`/`playerDeoped` (an `OperatorDto`),
/// `playerAddedToAllowlist`/`playerRemovedFromAllowlist` and `playerUnbanned` (a `PlayerDto`), `playerBanned`
/// (a `UserBanDto`), `ipBanned` (an `IpBanDto`) and `ipUnbanned` (the address).
pub fn list_notification(kind: &str, event: &Value) -> Option<(&'static str, Value)> {
let player = || json!({ "id": event["uuid"], "name": event["name"] });
let operator = || {
json!({
"player": player(),
"permissionLevel": event["level"],
"bypassesPlayerLimit": event["bypassesPlayerLimit"],
})
};
let reason = event["reason"].as_str().map(str::to_owned);
let source = event["source"].as_str().unwrap_or_default();
let expires = event["expires"].as_i64();
Some(match kind {
"operators_added" => ("notification/operators/added", operator()),
"operators_removed" => ("notification/operators/removed", operator()),
"allowlist_added" => ("notification/allowlist/added", player()),
"allowlist_removed" => ("notification/allowlist/removed", player()),
"bans_added" => (
"notification/bans/added",
ban(("player", player()), &reason, source, expires),
),
"bans_removed" => ("notification/bans/removed", player()),
"ip_bans_added" => (
"notification/ip_bans/added",
ban(("ip", event["ip"].clone()), &reason, source, expires),
),
"ip_bans_removed" => ("notification/ip_bans/removed", event["ip"].clone()),
_ => return None,
})
}
/// A `UserBanDto` or `IpBanDto`: the reason when there is one, the end as an ISO-8601 instant.
fn ban(who: (&str, Value), reason: &Option<String>, source: &str, expires: Option<i64>) -> Value {
let mut b = json!({ "source": source });
@@ -627,6 +720,73 @@ mod tests {
Shared::with_install(&dir, None)
}
#[test]
fn a_change_of_a_list_is_the_notification_vanilla_sends() {
let id = "93e029a5-04a0-349d-90b2-f7e145b2644f";
let event = |fields: Value| {
let mut event = json!({ "name": "Kim0", "uuid": id });
event
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
event
};
let player = json!({ "id": id, "name": "Kim0" });
let note = |kind, fields| list_notification(kind, &event(fields)).unwrap();
assert_eq!(
note(
"operators_added",
json!({ "level": 3, "bypassesPlayerLimit": true })
),
(
"notification/operators/added",
json!({ "player": player, "permissionLevel": 3, "bypassesPlayerLimit": true })
)
);
assert_eq!(
note(
"operators_removed",
json!({ "level": 4, "bypassesPlayerLimit": false })
)
.0,
"notification/operators/removed"
);
assert_eq!(
note("allowlist_added", json!({})),
("notification/allowlist/added", player.clone())
);
assert_eq!(
note("bans_removed", json!({})),
("notification/bans/removed", player.clone())
);
// A ban says why and until when, as the lists do; no reason or end leaves them out.
assert_eq!(
note(
"bans_added",
json!({ "reason": "griefing", "source": "Kim", "expires": 1_793_491_200_000_i64 })
),
(
"notification/bans/added",
json!({ "player": player, "source": "Kim", "reason": "griefing", "expires": "2026-11-01T00:00:00Z" })
)
);
assert_eq!(
note(
"ip_bans_added",
json!({ "ip": "10.0.0.1", "source": "Rcon", "reason": null, "expires": null })
),
(
"notification/ip_bans/added",
json!({ "ip": "10.0.0.1", "source": "Rcon" })
)
);
assert_eq!(
note("ip_bans_removed", json!({ "ip": "10.0.0.1" })),
("notification/ip_bans/removed", json!("10.0.0.1"))
);
assert!(list_notification("kick", &event(json!({}))).is_none());
}
#[test]
fn the_lists_read_as_vanillas_dtos() {
let shared = shared();
+9 -4
View File
@@ -3,11 +3,15 @@
use std::collections::BTreeMap;
/// The Iris feature flags (`iris.features.*`) Horizon has.
const FEATURES: [&str; 4] = [
/// The Iris feature flags (`iris.features.*`) Horizon has. `SEPARATE_HARDWARE_SAMPLERS`
/// (ShaderDoc, "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth
/// samplers when hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks
/// a comparison sampler by the declared type (`sampler2DShadow`), so they always are.
const FEATURES: [&str; 5] = [
"COMPUTE_SHADERS",
"CUSTOM_IMAGES",
"ENTITY_TRANSLUCENT",
"SEPARATE_HARDWARE_SAMPLERS",
"SSBO",
];
@@ -413,13 +417,14 @@ mod tests {
#[test]
fn iris_features_are_defined_or_noted() {
let properties = crate::Properties::parse(
"iris.features.optional = COMPUTE_SHADERS NOT_A_FEATURE\n\
iris.features.required = ENTITY_TRANSLUCENT NOT_A_FEATURE\n",
"iris.features.optional = COMPUTE_SHADERS SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n\
iris.features.required = ENTITY_TRANSLUCENT SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n",
&Macros::standard(),
);
let mut m = Macros::standard();
let notes = m.define_features(&properties);
assert!(m.is_defined("IRIS_FEATURE_COMPUTE_SHADERS"));
assert!(m.is_defined("IRIS_FEATURE_SEPARATE_HARDWARE_SAMPLERS"));
assert!(!m.is_defined("IRIS_FEATURE_NOT_A_FEATURE"));
assert_eq!(
notes,
+86
View File
@@ -439,6 +439,57 @@ pub struct Post {
pub notes: Vec<String>,
}
impl Post {
/// Every program that loaded: the passes', what draws the game's geometry, the shadow
/// pass's and its composites.
fn every_program(&self) -> impl Iterator<Item = &PostProgram> {
let draws = [
&self.terrain,
&self.water,
&self.sky,
&self.sky_textured,
&self.entities,
&self.block,
&self.entities_translucent,
&self.particles,
&self.damaged_block,
&self.clouds,
&self.weather,
&self.hand,
&self.shadow,
&self.shadow_water,
&self.shadow_entities,
&self.shadow_block,
];
self.programs
.iter()
.chain(draws.into_iter().flatten())
.chain(&self.shadow_comp)
}
/// The fewest bytes each storage buffer (`bufferObject.<n>`, set 4) needs for every stage
/// that binds it: its block's size as the stage lays it out (std430). OpenGL binds a smaller buffer and leaves what lies
/// past its end undefined; wgpu refuses to draw or dispatch with one.
pub fn buffer_minimums(&self) -> std::collections::BTreeMap<u32, u64> {
let mut sizes = std::collections::BTreeMap::new();
for p in self.every_program() {
let stages = p.program.iter().flat_map(|s| [&s.vertex, &s.fragment]);
for module in stages.chain(p.compute.iter().map(|(_, c)| &c.module)) {
for (_, var) in module.global_variables.iter() {
if let (naga::AddressSpace::Storage { .. }, Some(b)) = (var.space, &var.binding)
&& b.group == 4
{
let size = u64::from(module.types[var.ty].inner.size(module.to_ctx()));
let least = sizes.entry(b.binding).or_insert(0);
*least = size.max(*least);
}
}
}
}
sizes
}
}
/// The folder a dimension reads its programs from: `world<id>` when the pack has it, else the
/// base folder `""` (1.2). `id` is the legacy number: 0 the Overworld, -1 the Nether, 1 the End.
pub fn dimension_folder(pack: &Pack, id: i32) -> String {
@@ -1341,6 +1392,41 @@ mod tests {
);
}
/// A storage buffer declared smaller than the block a stage binds it as: the block's size
/// as the stage lays it out is what the buffer needs (std430 rounds a block to its
/// largest member's alignment), the most of every stage and program that binds it.
#[test]
fn storage_buffers_need_their_blocks_size() {
let compute = "#version 430\nlayout(local_size_x = 1) in;\n\
layout(std430, binding = 0) buffer Sums { vec4 a; vec4 b; float c; } sums;\n\
layout(std430, binding = 1) buffer Cells { uint count; uvec4 cells[3]; } grid;\n\
void main() {\n sums.c = sums.a.x + float(grid.count + grid.cells[0].x);\n}\n";
let fsh = "#version 430\nlayout(std430, binding = 0) buffer Sums { vec4 a; } sums;\n\
uniform sampler2D colortex0;\nvarying vec2 uv;\n\
void main() {\n/* DRAWBUFFERS:3 */\n\
gl_FragData[0] = texture2D(colortex0, uv) + sums.a;\n}\n";
let pack = Pack::from_zip_bytes(
"t",
zip(&[
("shaders/composite.vsh", VSH),
("shaders/composite.fsh", fsh),
("shaders/composite.csh", compute),
]),
)
.unwrap();
let macros = Macros::standard();
let properties = Properties::parse("bufferObject.0 = 40\nbufferObject.1 = 4\n", &macros);
let post = load_post(&pack, "", &properties, &macros, &GameInputs::default());
assert!(post.notes.is_empty(), "{:?}", post.notes);
assert_eq!(post.programs[0].compute.len(), 1);
// `Sums`: 36 bytes rounded to vec4's 16; `Cells`: a uint padded to the array's 16,
// then three elements.
assert_eq!(
post.buffer_minimums(),
std::collections::BTreeMap::from([(0, 48), (1, 64)])
);
}
#[test]
fn buffer_sizes_are_bounded() {
let relative = BufferSize::parse("0.5 0.25").unwrap();
+14 -12
View File
@@ -368,18 +368,20 @@ impl StructureResources {
}
let (namespace, path) = id.split_once(':').unwrap();
let file = self.dir.join(namespace).join(format!("{path}.nbt"));
let loaded = std::fs::read(&file).ok().and_then(|bytes| {
let mut raw = Vec::new();
flate2::read::GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.ok()?;
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
Some(StructureTemplate::from_nbt(
&tag,
self.registry,
&self.transforms,
))
});
let loaded = horizon_core::prepared_files::read(&file)
.ok()
.and_then(|bytes| {
let mut raw = Vec::new();
flate2::read::GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.ok()?;
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
Some(StructureTemplate::from_nbt(
&tag,
self.registry,
&self.transforms,
))
});
// StructureTemplateManager.getOrCreate: missing templates are empty.
let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty);
loaded.location = id.as_str().into();
+3 -4
View File
@@ -79,10 +79,9 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Backups**: restore a single dimension.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than
`server/started`, `server/stopping`, `server/saving`, `server/saved`, `players/joined` and `players/left`,
and the status heartbeat. The methods answer an internal error saying
so; a kick's translatable message is sent as its key.
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications of the game rules, the
server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
an internal error saying so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
## Changelog
+9
View File
@@ -77,6 +77,15 @@ effects, entity types and menu types (each below) in `creative_tab/`, `mob_effec
them. LAN and dedicated server players play with them when they run the same mods: a client whose mods add different
content is refused when it joins, with the reason.
A server that has content mods on also checks them one by one (`horizon_protocol::mod_list`, channel
`horizon:mod_list`): in the configuration phase it sends the id, version and SHA-256 of each `.hmod` with
`side = "both"`, the client answers with its own, and a client that differs is refused with the mods named: missing,
not on this server, or another version or file. Only `side = "both"` mods are compared and sent: a `client` mod (a
HUD, a minimap) never blocks a join and is never told to a server, and a `server` mod stays private to it. A manifest
with no `side` line is `client`, so a content mod meant for both halves must say `side = "both"`, or this check
ignores it. A mod run from a folder (`--dev-mod`) has no file hash and matches by version. Native mods are not in the
list yet: their manifest has no side.
A block names its template and what differs from it:
```json
+5 -1
View File
@@ -512,7 +512,11 @@ runtime or new dependency. Server-sent events can replace the polling later if a
(when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and
`server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when
empty; a save the game makes while it stops is after the last one the supervisor hears of), and
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second);
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second), and
`operators/*`, `allowlist/*`, `bans/*` and `ip_bans/*` `added`/`removed`: each of the game's lists tells a
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)
+1
View File
@@ -348,6 +348,7 @@ class StressSceneTests(unittest.TestCase):
summons = lambda name, kind: sum(c.startswith(f"summon {kind} ") for c in stress_scenes.setup(name))
self.assertEqual(sum(summons("stress_mobs", k) for k in stress_scenes.MOBS), 1000)
self.assertEqual(summons("stress_items", "item"), 2000)
self.assertEqual(sum(summons("stress_held", k) for k in stress_scenes.HOLDERS), 1000)
self.assertEqual(summons("stress_frames", "item_frame"), 512)
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
+22 -7
View File
@@ -424,14 +424,29 @@ impl super::Device {
key.shader_model.to_str()
);
let compiled_shader = self.compiler_container.compile(
self,
&key.source,
source_name,
// Horizon: a shader compiled in an earlier run comes from the disk.
let disk_key = super::disk_cache::key(
self.compiler_container.cache_name(),
key.shader_model.to_str(),
naga::back::hlsl::shader_stage_to_hlsl_str(naga_stage),
&key.entry_point,
stage_bit,
&full_stage,
)?;
&key.source,
);
let compiled_shader = match super::disk_cache::get(disk_key) {
Some(bytes) => super::CompiledShader::Precompiled(bytes),
None => {
let compiled = self.compiler_container.compile(
self,
&key.source,
source_name,
&key.entry_point,
stage_bit,
&full_stage,
)?;
super::disk_cache::put(disk_key, compiled.bytes());
compiled
}
};
{
let mut shader_cache = self.shader_cache.lock();
+275
View File
@@ -0,0 +1,275 @@
//! Horizon: compiled shaders kept on disk between runs.
//!
//! FXC takes about 2.6 s to compile Horizon's ~65 pipelines on every start
//! (DXC about 1 s), while the in-memory [`super::ShaderCache`] only helps
//! within one device. The application names one file with [`set_file`]. A
//! shader compiled once is appended there, and the next run reads its bytecode
//! back as [`super::CompiledShader::Precompiled`] instead of compiling it.
//!
//! The key is a 128-bit FNV-1a hash of the compiler, the shader model, the
//! stage, the entry point and the HLSL source naga wrote. Any change to
//! naga's output, the shader, or the compiler choice is a different key, so
//! the file never needs explicit invalidation. Each record carries a checksum
//! of its bytes; reading stops at the first record that is short or does not
//! match (a run killed mid-write, two games writing at once), and the next
//! append starts there. A file past [`MAX_BYTES`] is started over.
//!
//! File: `MAGIC`, then records of `key: u128 LE`, `len: u32 LE`,
//! `check: u64 LE` (FNV-1a 64 of the bytes), `bytes`.
use alloc::vec::Vec;
use std::{
fs,
io::{Seek as _, SeekFrom, Write as _},
path::PathBuf,
sync::{Mutex, OnceLock},
};
use hashbrown::HashMap;
const MAGIC: &[u8; 8] = b"HZDXSC1\n";
const HEADER: usize = 16 + 4 + 8;
/// Past this the file is started over: Horizon's shaders take about 1 MB.
pub const MAX_BYTES: u64 = 64 << 20;
static FILE: OnceLock<PathBuf> = OnceLock::new();
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
/// Keep compiled shaders in `path` for the rest of the run. Only the first
/// call counts; without one nothing is kept on disk.
pub fn set_file(path: PathBuf) {
let _ = FILE.set(path);
}
pub(super) struct Cache {
path: PathBuf,
entries: HashMap<u128, Vec<u8>>,
/// Where the next record goes: the end of the last good record.
end: u64,
}
fn cache() -> Option<&'static Mutex<Cache>> {
let path = FILE.get()?;
Some(CACHE.get_or_init(|| Mutex::new(Cache::load(path.clone()))))
}
/// The bytecode compiled for `key` in an earlier run.
pub(super) fn get(key: u128) -> Option<Vec<u8>> {
let cache = cache()?.lock().ok()?;
cache.entries.get(&key).cloned()
}
/// Keep `bytes` for `key` for the next runs.
pub(super) fn put(key: u128, bytes: &[u8]) {
let Some(cache) = cache() else { return };
let Ok(mut cache) = cache.lock() else { return };
if let Err(error) = cache.append(key, bytes) {
log::warn!("DX12 shader cache {}: {error}", cache.path.display());
}
}
/// The key of one compile.
pub(super) fn key(
compiler: &str,
shader_model: &str,
stage: &str,
entry: &str,
source: &str,
) -> u128 {
let mut hash = Fnv128::new();
for part in [compiler, shader_model, stage, entry, source] {
hash.write(&(part.len() as u64).to_le_bytes());
hash.write(part.as_bytes());
}
hash.0
}
impl Cache {
fn load(path: PathBuf) -> Self {
let bytes = match fs::metadata(&path) {
Ok(meta) if meta.len() <= MAX_BYTES => fs::read(&path).unwrap_or_default(),
_ => Vec::new(),
};
let (entries, end) = parse(&bytes);
Self { path, entries, end }
}
fn append(&mut self, key: u128, bytes: &[u8]) -> std::io::Result<()> {
if self.entries.contains_key(&key) {
return Ok(());
}
let len = u32::try_from(bytes.len()).map_err(std::io::Error::other)?;
if self.end + (HEADER + bytes.len()) as u64 > MAX_BYTES {
self.end = 0;
self.entries.clear();
}
if let Some(dir) = self.path.parent() {
fs::create_dir_all(dir)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&self.path)?;
let mut record = Vec::with_capacity(MAGIC.len() + HEADER + bytes.len());
if self.end == 0 {
record.extend_from_slice(MAGIC);
}
record.extend_from_slice(&key.to_le_bytes());
record.extend_from_slice(&len.to_le_bytes());
record.extend_from_slice(&fnv64(bytes).to_le_bytes());
record.extend_from_slice(bytes);
// Drop whatever follows the last good record, then write after it.
file.set_len(self.end)?;
file.seek(SeekFrom::Start(self.end))?;
file.write_all(&record)?;
self.end += record.len() as u64;
self.entries.insert(key, bytes.to_vec());
Ok(())
}
}
/// The records of a cache file and where its good part ends.
fn parse(bytes: &[u8]) -> (HashMap<u128, Vec<u8>>, u64) {
let mut entries = HashMap::new();
if bytes.get(..MAGIC.len()) != Some(MAGIC.as_slice()) {
return (entries, 0);
}
let mut at = MAGIC.len();
while let Some(header) = bytes.get(at..at + HEADER) {
let key = u128::from_le_bytes(header[..16].try_into().unwrap());
let len = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize;
let check = u64::from_le_bytes(header[20..28].try_into().unwrap());
let Some(body) = bytes.get(at + HEADER..at + HEADER + len) else {
break;
};
if fnv64(body) != check {
break;
}
entries.insert(key, body.to_vec());
at += HEADER + len;
}
(entries, at as u64)
}
struct Fnv128(u128);
impl Fnv128 {
fn new() -> Self {
Self(0x6c62272e07bb014262b821756295c58d)
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 ^= u128::from(b);
self.0 = self.0.wrapping_mul(0x0000000001000000000000000000013b);
}
}
}
fn fnv64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("hz-dx12-cache-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir.join("cache/dx12-shaders.bin")
}
#[test]
fn a_shader_kept_in_one_run_is_read_back_in_the_next() {
let path = scratch("next");
let vs = key(
"fxc",
"5_1",
"vs",
"main",
"float4 main() : SV_Position { return 0; }",
);
let fs_ = key(
"fxc",
"5_1",
"ps",
"main",
"float4 main() : SV_Target { return 1; }",
);
let mut run = Cache::load(path.clone());
run.append(vs, b"DXBC vertex").unwrap();
run.append(fs_, b"DXBC fragment").unwrap();
let next = Cache::load(path.clone());
assert_eq!(
next.entries.get(&vs).map(Vec::as_slice),
Some(&b"DXBC vertex"[..])
);
assert_eq!(
next.entries.get(&fs_).map(Vec::as_slice),
Some(&b"DXBC fragment"[..])
);
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
#[test]
fn each_part_of_the_compile_changes_the_key() {
let base = key("fxc", "5_1", "vs", "main", "src");
assert_ne!(base, key("dxc", "5_1", "vs", "main", "src"));
assert_ne!(base, key("fxc", "6_0", "vs", "main", "src"));
assert_ne!(base, key("fxc", "5_1", "ps", "main", "src"));
assert_ne!(base, key("fxc", "5_1", "vs", "main2", "src"));
assert_ne!(base, key("fxc", "5_1", "vs", "main", "src "));
// Lengths are hashed, so moving a byte between parts changes the key.
assert_ne!(key("ab", "c", "", "", ""), key("a", "bc", "", "", ""));
}
#[test]
fn a_torn_record_is_dropped_and_the_next_one_written_over_it() {
let path = scratch("torn");
let a = key("fxc", "5_1", "vs", "a", "a");
let b = key("fxc", "5_1", "vs", "b", "b");
let c = key("fxc", "5_1", "vs", "c", "c");
let mut run = Cache::load(path.clone());
run.append(a, b"first").unwrap();
run.append(b, b"second").unwrap();
// The run is killed while it writes `b`: its last bytes never land.
let full = fs::read(&path).unwrap();
fs::write(&path, &full[..full.len() - 3]).unwrap();
let mut next = Cache::load(path.clone());
assert_eq!(next.entries.len(), 1);
assert!(next.entries.contains_key(&a));
next.append(c, b"third").unwrap();
let last = Cache::load(path.clone());
assert_eq!(last.entries.len(), 2);
assert_eq!(last.entries.get(&c).map(Vec::as_slice), Some(&b"third"[..]));
// A flipped byte fails the checksum the same way.
let mut bytes = fs::read(&path).unwrap();
let at = bytes.len() - 1;
bytes[at] ^= 1;
fs::write(&path, bytes).unwrap();
assert_eq!(Cache::load(path.clone()).entries.len(), 1);
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
#[test]
fn a_file_that_is_not_a_cache_is_started_over() {
let path = scratch("foreign");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"not a shader cache at all").unwrap();
let mut run = Cache::load(path.clone());
assert!(run.entries.is_empty());
let k = key("dxc", "6_0", "cs", "main", "x");
run.append(k, b"DXIL").unwrap();
let next = Cache::load(path.clone());
assert_eq!(next.entries.get(&k).map(Vec::as_slice), Some(&b"DXIL"[..]));
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
}
+11
View File
@@ -83,6 +83,7 @@ mod conv;
mod dcomp;
mod descriptor;
mod device;
pub mod disk_cache;
mod device_creation;
mod instance;
mod pipeline_desc;
@@ -1353,6 +1354,16 @@ pub(super) enum CompiledShader {
}
impl CompiledShader {
/// The bytecode, as [`disk_cache`] keeps it.
fn bytes(&self) -> &[u8] {
let code = self.create_native_shader();
if code.BytecodeLength == 0 {
return &[];
}
// SAFETY: the blob or vector `code` points into lives as long as `self`.
unsafe { core::slice::from_raw_parts(code.pShaderBytecode.cast(), code.BytecodeLength) }
}
fn create_native_shader(&self) -> Direct3D12::D3D12_SHADER_BYTECODE {
match self {
CompiledShader::Dxc(shader) => Direct3D12::D3D12_SHADER_BYTECODE {
+9
View File
@@ -104,6 +104,15 @@ impl CompilerContainer {
}
}
/// Which compiler made a shader, for [`super::disk_cache`]'s keys.
pub(super) fn cache_name(&self) -> &'static str {
match self {
CompilerContainer::Fxc(_) => "fxc",
CompilerContainer::DynamicDxc(_) => "dxc",
CompilerContainer::StaticDxc(_) => "static-dxc",
}
}
pub(super) fn compile(
&self,
device: &super::Device,
@@ -0,0 +1,11 @@
{
"hooks": [],
"helpers": [
{
"name": "instance_dir",
"target": "horizon_client::game::native_hooks::Game<'_>::native_instance_dir"
}
],
"rust_summary": "the title's own screens to the screen hooks, and the instance folder helper (`native_instance_dir`).",
"doc_summary": "the title's own screens to the screen hooks. `NativeScreenView.id` gains one id per title state (`title`, `world_select`, `world_create`, `world_create_flat`, `world_create_buffet`, `world_edit`, `world_backup`, `world_backup_confirm`, `world_optimize_confirm`, `world_optimize`, `low_disk_space`, `world_delete`, `world_recover`, `world_recovered`, `error`, `multiplayer`, `add_server`, `edit_server`, `direct_connect`, `server_delete`, `friends_join`, `disconnected`, `multiplayer_warning`, `onboarding`, `ban_notice`), with no `menu`, no `title` and no `slots`, and the whole window as `rect`. `native_screen_render_post` fires after the title draws its screen (before its toasts), and the `_pre` input hooks — mouse buttons, drag, wheel, key press and release, character typed — fire on it and can cancel it as on any screen, so a mod draws its own button or browser over Create New World and takes the clicks. A dialog, a prompt or a server's pack prompt over the title keeps them away, and typing still goes to the title's own boxes (the key's text stays on the key there, as on Options, so `native_screen_char_typed_pre` does not fire from the keyboard on the title's own screens; the control socket's `type` now types into them too). `native_instance_dir()` is the instance folder, where `options.txt`, `saves/`, `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are; a world's `datapacks/` is in that world's folder. Bundles that use them need `\"hooks\": @API:native_instance_dir@`."
}
+2 -2
View File
@@ -74,7 +74,7 @@ ChunkMap.sendToTrackingPlayersFiltered port crates/horizon-client/src/lan/contai
ChunkMap.sendToTrackingPlayersAndSelf port crates/horizon-client/src/lan/containers.rs::Hub::critical_hit the one caller, the crit animation, goes to the attacker and the clients tracking them
ChunkMap.isTrackedByAnyPlayer skip:debug only the debug synchronizers ask
ChunkMap.forEachEntityTrackedBy skip:debug only the debug synchronizers ask
ChunkMap.resendBiomesForChunks missing minor: /fillbiome changes the host's biomes but sends guests no ChunksBiomes packet, so LAN guests keep the old biome tints until their chunks are sent again
ChunkMap.resendBiomesForChunks inline crates/horizon-client/src/game/commands/terrain_cmds.rs::Game::cmd_fillbiome /fillbiome sends the changed columns' biomes to the guests that have them (Hub::resend_biomes, a ChunksBiomes packet), in the order the columns were gathered
ChunkMap.waitForLightBeforeSending inline crates/horizon-client/src/lan/hub.rs::Hub::next_batch columns are sent only once lit
ChunkMap.forEachReadyToSendChunk skip:debug only the debug synchronizers ask
ChunkMap.TrackedEntity.equals skip:java identity by entity id; Horizon keys tracked entities by id
@@ -83,7 +83,7 @@ ChunkMap.TrackedEntity.sendToTrackingPlayersAndSelf port crates/horizon-client/s
ChunkMap.TrackedEntity.sendToTrackingPlayersFiltered port crates/horizon-client/src/lan/containers.rs::Hub::broadcast_near gap: minor: only an except-one-guest filter
ChunkMap.TrackedEntity.broadcastRemoved inline crates/horizon-client/src/lan/entity.rs::Entities::tick removed entities leave each client in one RemoveEntities
ChunkMap.TrackedEntity.removePlayer inline crates/horizon-client/src/lan/entity.rs::Entities::tick the client's tracker loses entities out of range or level
ChunkMap.TrackedEntity.updatePlayers port crates/horizon-client/src/lan/entity.rs::Entities::tick gap: player: broadcastToPlayer is not checked, so a spectating player is tracked and shown to non-spectators as a normal player model
ChunkMap.TrackedEntity.updatePlayers port crates/horizon-client/src/lan/entity.rs::Entities::tick broadcastToPlayer is checked: a spectator is hidden from everyone but other spectators, and sees players only while its camera is itself
ChunkResult.error skip:java result type
ChunkTaskDispatcher.hasWork port crates/horizon-client-render/src/streaming.rs::Streaming::busy
ChunkTaskDispatcher.onLevelChange port crates/horizon-client-render/src/streaming.rs::Streaming::requeue
1 BlockDestructionProgress.equals skip:java identity by breaker id; Horizon keys cracks by breaker id in a map
74 ChunkMap.sendToTrackingPlayersAndSelf port crates/horizon-client/src/lan/containers.rs::Hub::critical_hit the one caller, the crit animation, goes to the attacker and the clients tracking them
75 ChunkMap.isTrackedByAnyPlayer skip:debug only the debug synchronizers ask
76 ChunkMap.forEachEntityTrackedBy skip:debug only the debug synchronizers ask
77 ChunkMap.resendBiomesForChunks missing inline crates/horizon-client/src/game/commands/terrain_cmds.rs::Game::cmd_fillbiome minor: /fillbiome changes the host's biomes but sends guests no ChunksBiomes packet, so LAN guests keep the old biome tints until their chunks are sent again /fillbiome sends the changed columns' biomes to the guests that have them (Hub::resend_biomes, a ChunksBiomes packet), in the order the columns were gathered
78 ChunkMap.waitForLightBeforeSending inline crates/horizon-client/src/lan/hub.rs::Hub::next_batch columns are sent only once lit
79 ChunkMap.forEachReadyToSendChunk skip:debug only the debug synchronizers ask
80 ChunkMap.TrackedEntity.equals skip:java identity by entity id; Horizon keys tracked entities by id
83 ChunkMap.TrackedEntity.sendToTrackingPlayersFiltered port crates/horizon-client/src/lan/containers.rs::Hub::broadcast_near gap: minor: only an except-one-guest filter
84 ChunkMap.TrackedEntity.broadcastRemoved inline crates/horizon-client/src/lan/entity.rs::Entities::tick removed entities leave each client in one RemoveEntities
85 ChunkMap.TrackedEntity.removePlayer inline crates/horizon-client/src/lan/entity.rs::Entities::tick the client's tracker loses entities out of range or level
86 ChunkMap.TrackedEntity.updatePlayers port crates/horizon-client/src/lan/entity.rs::Entities::tick gap: player: broadcastToPlayer is not checked, so a spectating player is tracked and shown to non-spectators as a normal player model broadcastToPlayer is checked: a spectator is hidden from everyone but other spectators, and sees players only while its camera is itself
87 ChunkResult.error skip:java result type
88 ChunkTaskDispatcher.hasWork port crates/horizon-client-render/src/streaming.rs::Streaming::busy
89 ChunkTaskDispatcher.onLevelChange port crates/horizon-client-render/src/streaming.rs::Streaming::requeue
+3 -1
View File
@@ -1,5 +1,5 @@
DedicatedServer.readCodeOfConducts port crates/horizon-client/src/lan/code_of_conduct.rs::read with enable-code-of-conduct the folder's files are read at startup (game/dedicated.rs::run_dedicated_inner) and handed to a joining player, whose client shows the prompt (game/code_of_conduct.rs)
DedicatedServer.setEnforceWhitelist skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
DedicatedServer.setEnforceWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::set_enforce_whitelist management stores enforce-whitelist and kicks unlisted players when enforcement is enabled
DedicatedServer.setUsingWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::console_whitelist console whitelist on|off; store_property writes white-list back
DedicatedServer.saveAllChunks port crates/horizon-client/src/game.rs::Game::save_game gap: minor: no save notifications to the JSON-RPC management API
DedicatedServer.sendLowDiskSpaceWarning port crates/horizon-client/src/lan/hub.rs::Hub::send_low_disk_space_warning chunk_io reports the low disk space and the operators are told (game/dedicated.rs); a client shows the toast (game/remote.rs)
@@ -25,6 +25,8 @@ DedicatedServer.setEntityBroadcastRangePercentage skip:server JSON-RPC manageme
DedicatedServer.createTextFilterForPlayer skip:service external chat filter service; Horizon has no text filter
DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/management.rs::Game::management stores force-gamemode, updates the host and applies the default mode to online players
MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml
MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml
MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml
MinecraftServerSettingsServiceImpl.setAutoSave port crates/horizon-server/src/management_methods.rs::Game::autosave forwards the live save flag to the game; unlike a server property, it resets when the server restarts
DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties
DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
1 DedicatedServer.readCodeOfConducts port crates/horizon-client/src/lan/code_of_conduct.rs::read with enable-code-of-conduct the folder's files are read at startup (game/dedicated.rs::run_dedicated_inner) and handed to a joining player, whose client shows the prompt (game/code_of_conduct.rs)
2 DedicatedServer.setEnforceWhitelist skip:server port crates/horizon-client/src/game/dedicated.rs::Game::set_enforce_whitelist JSON-RPC management API only (ServerSettingsService); Horizon has no such API management stores enforce-whitelist and kicks unlisted players when enforcement is enabled
3 DedicatedServer.setUsingWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::console_whitelist console whitelist on|off; store_property writes white-list back
4 DedicatedServer.saveAllChunks port crates/horizon-client/src/game.rs::Game::save_game gap: minor: no save notifications to the JSON-RPC management API
5 DedicatedServer.sendLowDiskSpaceWarning port crates/horizon-client/src/lan/hub.rs::Hub::send_low_disk_space_warning chunk_io reports the low disk space and the operators are told (game/dedicated.rs); a client shows the toast (game/remote.rs)
25 DedicatedServer.createTextFilterForPlayer skip:service external chat filter service; Horizon has no text filter
26 DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/management.rs::Game::management stores force-gamemode, updates the host and applies the default mode to online players
27 MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml
28 MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml
29 MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml
30 MinecraftServerSettingsServiceImpl.setAutoSave port crates/horizon-server/src/management_methods.rs::Game::autosave forwards the live save flag to the game; unlike a server property, it resets when the server restarts
31 DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties
32 DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
+6
View File
@@ -130,4 +130,10 @@ ENTITIES = [
('ghast', ''), ('piglin', ''), ('hoglin', ''), ('strider', 'Saddle:1b'), ('warden', ''),
('trader_llama', ''), ('mooshroom', 'Type:"brown"'), ('panda', 'MainGene:"lazy"'),
('copper_golem', ''), ('creaking', ''),
# Riders (an entity saves inside its vehicle). The riders
# have their own UUIDs, so a dropped or reordered one shows in the vehicle's Passengers diff.
('pig', 'Passengers:[{id:"minecraft:zombie",UUID:[I;28673,0,0,1],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
('oak_boat', 'Passengers:[{id:"minecraft:pig",UUID:[I;28673,0,0,2],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b},{id:"minecraft:chicken",UUID:[I;28673,0,0,3],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
('minecart', 'Passengers:[{id:"minecraft:cow",UUID:[I;28673,0,0,4],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
('horse', 'Tame:1b,Passengers:[{id:"minecraft:skeleton",UUID:[I;28673,0,0,5],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Passengers:[{id:"minecraft:bat",UUID:[I;28673,0,0,6],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]}]'),
]
+19
View File
@@ -32,6 +32,8 @@ SETTINGS = [
"time set 6000",
]
MOBS = ["husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider"]
HOLDERS = ["husk", "wither_skeleton", "vindicator", "piglin"]
HELD = ["iron_sword", "stone_axe", "torch", "apple", "bow", "carrot", "diamond_pickaxe", "stone"]
ITEMS = ["stone", "oak_log", "diamond", "apple", "iron_sword", "bread", "redstone", "torch"]
FRAMED = ["diamond_sword", "compass", "clock", "stone", "oak_planks", "apple", "shield", "golden_apple"]
CONTAINERS = ["chest", "trapped_chest", "ender_chest", "white_shulker_box"]
@@ -73,6 +75,22 @@ def mobs(ox, oz):
return out
def held(ox, oz):
"""1000 mobs holding an item in each hand: the mobs scene's grid, of kinds that neither burn nor convert."""
out, i = [], 0
for layer in range(5):
for row in range(10):
for col in range(20):
kind = HOLDERS[i % len(HOLDERS)]
main, off = HELD[i % len(HELD)], HELD[(i + 3) % len(HELD)]
x, y, z = ox + 0.5 - 14.25 + 1.5 * col, FLOOR_Y + 1 + 2.5 * layer, oz + 8 + 2 * row
out.append(f"summon {kind} {num(x)} {num(y)} {num(z)} "
"{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Silent:1b,IsImmuneToZombification:1b,"
f'equipment:{{mainhand:{{id:"minecraft:{main}"}},offhand:{{id:"minecraft:{off}"}}}}}}')
i += 1
return out
def items(ox, oz):
"""2000 dropped items of 8 kinds: 25 x 16 on the floor and on four invisible barrier layers above it, 2 blocks
apart, a block apart in a layer (vanilla merges equal stacks within 0.625). They fall the last 0.2 block onto
@@ -171,6 +189,7 @@ STRESS = [
("stress_signs", signs),
("stress_banners", banners),
("stress_particles", particles),
("stress_held", held),
]
NAMES = [name for name, _ in STRESS]
+1 -1
View File
@@ -23,7 +23,7 @@ def main():
classes, dependencies, oracle = oh.compile_oracle(
build, 'VanillaLanAuthOracle', jars, client_libraries(), lock['minecraft']['java_major'])
oh.java_run(build, classes, dependencies, 'VanillaLanAuthOracle', [], 'vanilla')
oh.run_logged(['cargo', 'test', '-p', 'horizon-client', '--lib',
oh.run_logged(['cargo', 'test', '-p', 'horizon-client-lib', '--lib',
'net::tests::lan_login_continues_after_authentication_failure', '--', '--nocapture'],
build, 'rust', timeout=180)
pattern = r'lan_auth=(LAN|OTHER):(true|false):(true|false)'
+56 -1
View File
@@ -17,7 +17,8 @@ sent the same things over TLS, and every answer is compared exactly:
`server/system_message` with no one online, the four lists changed (`add`, `remove`, `set`,
`clear`; with no profile service, players named resolve to offline players on both), the live server
message, difficulty, default and forced game mode read and changed (including saved properties), autosave
read and changed (including its reset after restart), and
read and changed (including its reset after restart), allowlist use and enforcement read and changed
(including saved properties and restart), and
`server/stop`;
- the TLS version each server agrees on.
@@ -158,6 +159,24 @@ MESSAGES = [
'{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/autosave/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":85,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":86,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":87,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":88,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":89,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":90,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":91,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":92,"method":"minecraft:serversettings/enforce_allowlist"}',
]
@@ -383,12 +402,22 @@ def main():
sides[side]['saved_force_game_mode'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('force-gamemode=')), None)
sides[side]['saved_use_allowlist'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('white-list=')), None)
sides[side]['saved_enforce_allowlist'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('enforce-whitelist=')), None)
if side == 'horizon':
world = (folder / 'server.toml').read_text().partition('[world]')[2].split('\n[', 1)[0]
sides[side]['saved_config_difficulty'] = 'difficulty = "peaceful"' in world
sides[side]['saved_config_game_mode'] = 'game_mode = "spectator"' in world
sides[side]['saved_config_force_game_mode'] = 'force-gamemode = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_use_allowlist'] = 'whitelist = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_enforce_allowlist'] = 'enforce_whitelist = true' in (
folder / 'server.toml').read_text()
finally:
server.stop()
restarted = Server(command, folder, build / f'{side}-restart.log')
@@ -408,6 +437,18 @@ def main():
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_autosave'] = reply
ws.send('{"jsonrpc":"2.0","id":93,"method":"minecraft:serversettings/use_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_use_allowlist'] = reply
ws.send('{"jsonrpc":"2.0","id":94,"method":"minecraft:serversettings/enforce_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_enforce_allowlist'] = reply
finally:
ws.sock.close()
finally:
@@ -432,12 +473,26 @@ def main():
failures.append(f"saved forced game mode: vanilla {vanilla['saved_force_game_mode']!r}, Horizon {horizon['saved_force_game_mode']!r}")
if not horizon['saved_config_force_game_mode']:
failures.append('Horizon did not save forced game mode in server.toml')
if vanilla['saved_use_allowlist'] != horizon['saved_use_allowlist'] or vanilla['saved_use_allowlist'] != 'true':
failures.append(f"saved allowlist use: vanilla {vanilla['saved_use_allowlist']!r}, Horizon {horizon['saved_use_allowlist']!r}")
if vanilla['saved_enforce_allowlist'] != horizon['saved_enforce_allowlist'] or vanilla['saved_enforce_allowlist'] != 'true':
failures.append(f"saved allowlist enforcement: vanilla {vanilla['saved_enforce_allowlist']!r}, Horizon {horizon['saved_enforce_allowlist']!r}")
if not horizon['saved_config_use_allowlist']:
failures.append('Horizon did not save allowlist use in server.toml')
if not horizon['saved_config_enforce_allowlist']:
failures.append('Horizon did not save allowlist enforcement in server.toml')
if vanilla['restarted_force_game_mode'] != horizon['restarted_force_game_mode'] or \
vanilla['restarted_force_game_mode'] != {'jsonrpc': '2.0', 'id': 64, 'result': True}:
failures.append(f"restarted forced game mode: vanilla {vanilla['restarted_force_game_mode']!r}, Horizon {horizon['restarted_force_game_mode']!r}")
if vanilla['restarted_autosave'] != horizon['restarted_autosave'] or \
vanilla['restarted_autosave'] != {'jsonrpc': '2.0', 'id': 74, 'result': True}:
failures.append(f"restarted autosave: vanilla {vanilla['restarted_autosave']!r}, Horizon {horizon['restarted_autosave']!r}")
if vanilla['restarted_use_allowlist'] != horizon['restarted_use_allowlist'] or \
vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 93, 'result': True}:
failures.append(f"restarted allowlist use: vanilla {vanilla['restarted_use_allowlist']!r}, Horizon {horizon['restarted_use_allowlist']!r}")
if vanilla['restarted_enforce_allowlist'] != horizon['restarted_enforce_allowlist'] or \
vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 94, 'result': True}:
failures.append(f"restarted allowlist enforcement: vanilla {vanilla['restarted_enforce_allowlist']!r}, Horizon {horizon['restarted_enforce_allowlist']!r}")
(build / 'report.json').write_text(json.dumps(sides, indent=1))
if failures:
sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures))
+1 -1
View File
@@ -85,7 +85,7 @@ IGNORED = {
r'^chunk\.block_entities\..*\.(ticks_since_song_started|Age|BrewTime|warning_level|ticks_in_hive|last_vibration_frequency|listener\..*|RecipesUsed\..*|'
r'server_data\.state_updating_resumes_at)$':
'block entity timers advance',
r'^entity\.(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|'
r'^entity\.(Passengers\[\]\.)*(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|'
r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time)$': 'entity simulation advances',
r'^level\.dat\.Data\.(Time|LastPlayed)$': 'clock and wall time of the save',
r'^level\.dat\.Data\.(ServerBrands.*|singleplayer_uuid)$': 'a client world names its brand and its player',