mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7163
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
f60c7e53d4
61 files changed
+2002
-182
No files matched your search
Generated
+1
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ¯os);
|
||||
let post = load_post(&pack, "", &properties, ¯os, &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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
@@ -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@`."
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
|
@@ -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}]}]'),
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user