Anvil Rework (#367)

## Description

Solidify the design of `valence_anvil` so that most of the boilerplate
in the anvil example is eliminated. `AnvilLevel` is now a component of
`Instance` and automatically loads and unloads chunks as clients move
around. Events are used to communicate when chunks are loaded and
unloaded.

Also changes the system message API and introduces the `SendMessage`
trait.

Checks off a box in #288

### Known Issues
- Still no support for saving or entities.
- The handling of chunk `min_y` is wrong. I plan to fix this in an
upcoming redesign of instances and chunks.
- Uses one OS thread per anvil level. This could be improved with a
dedicated shared thread pool to parallelize the loading process.
However, it seems decently fast as it is.
- Old benchmark is commented out.
- Could use some tests.
This commit is contained in:
Ryan Johnson 2023-06-15 14:11:37 -07:00 committed by GitHub
parent 61f2279831
commit 2ed5a8840d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 564 additions and 333 deletions

View file

@ -40,6 +40,7 @@ glam = "0.23.0"
heck = "0.4.0" heck = "0.4.0"
hmac = "0.12.1" hmac = "0.12.1"
indexmap = "1.9.3" indexmap = "1.9.3"
lru = "0.10.0"
noise = "0.8.2" noise = "0.8.2"
num = "0.4.0" num = "0.4.0"
num-bigint = "0.4.3" num-bigint = "0.4.3"

View file

@ -10,7 +10,7 @@ Ignoring transitive dependencies and `valence_core`, the dependency graph can be
```mermaid ```mermaid
graph TD graph TD
network --> client network --> client
client --> instance client --> instance
biome --> registry biome --> registry
dimension --> registry dimension --> registry
@ -19,7 +19,7 @@ graph TD
instance --> entity instance --> entity
player_list --> client player_list --> client
inventory --> client inventory --> client
anvil --> instance anvil --> client
entity --> block entity --> block
advancement --> client advancement --> client
world_border --> client world_border --> client

View file

@ -1,3 +1,4 @@
/*
use std::fs::create_dir_all; use std::fs::create_dir_all;
use std::hint::black_box; use std::hint::black_box;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -133,3 +134,4 @@ fn get_world_asset(
Ok(final_path) Ok(final_path)
} }
*/

View file

@ -10,7 +10,7 @@ mod var_long;
criterion_group! { criterion_group! {
benches, benches,
anvil::load, // anvil::load,
block::block, block::block,
decode_array::decode_array, decode_array::decode_array,
idle::idle_update, idle::idle_update,

View file

@ -1,80 +1,44 @@
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::thread;
use clap::Parser; use clap::Parser;
use flume::{Receiver, Sender};
use tracing::warn;
use valence::anvil::{AnvilChunk, AnvilWorld};
use valence::prelude::*; use valence::prelude::*;
use valence_anvil::{AnvilLevel, ChunkLoadEvent, ChunkLoadStatus};
use valence_client::message::SendMessage;
const SPAWN_POS: DVec3 = DVec3::new(0.0, 256.0, 0.0); const SPAWN_POS: DVec3 = DVec3::new(0.0, 256.0, 0.0);
const SECTION_COUNT: usize = 24;
#[derive(Parser)] #[derive(Parser, Resource)]
#[clap(author, version, about)] #[clap(author, version, about)]
struct Cli { struct Cli {
/// The path to a Minecraft world save containing a `region` subdirectory. /// The path to a Minecraft world save containing a `region` subdirectory.
path: PathBuf, path: PathBuf,
} }
#[derive(Resource)]
struct GameState {
/// Chunks that need to be generated. Chunks without a priority have already
/// been sent to the anvil thread.
pending: HashMap<ChunkPos, Option<Priority>>,
sender: Sender<ChunkPos>,
receiver: Receiver<(ChunkPos, Chunk)>,
}
/// The order in which chunks should be processed by anvil worker. Smaller
/// values are sent first.
type Priority = u64;
pub fn main() { pub fn main() {
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
let cli = Cli::parse(); let cli = Cli::parse();
let dir = cli.path;
if !dir.exists() { if !cli.path.exists() {
eprintln!("Directory `{}` does not exist. Exiting.", dir.display()); eprintln!(
return; "Directory `{}` does not exist. Exiting.",
} else if !dir.is_dir() { cli.path.display()
eprintln!("`{}` is not a directory. Exiting.", dir.display()); );
return; return;
} }
let anvil = AnvilWorld::new(dir); if !cli.path.is_dir() {
eprintln!("`{}` is not a directory. Exiting.", cli.path.display());
let (finished_sender, finished_receiver) = flume::unbounded(); return;
let (pending_sender, pending_receiver) = flume::unbounded(); }
// Process anvil chunks in a different thread to avoid blocking the main tick
// loop.
thread::spawn(move || anvil_worker(pending_receiver, finished_sender, anvil));
let game_state = GameState {
pending: HashMap::new(),
sender: pending_sender,
receiver: finished_receiver,
};
App::new() App::new()
.add_plugins(DefaultPlugins) .add_plugins(DefaultPlugins)
.insert_resource(game_state) .insert_resource(cli)
.add_startup_system(setup) .add_startup_system(setup)
.add_systems(
(
init_clients,
remove_unviewed_chunks,
update_client_views,
send_recv_chunks,
)
.chain(),
)
.add_system(despawn_disconnected_clients) .add_system(despawn_disconnected_clients)
.add_systems((init_clients, handle_chunk_loads).chain())
.add_system(display_loaded_chunk_count)
.run(); .run();
} }
@ -83,9 +47,24 @@ fn setup(
dimensions: Res<DimensionTypeRegistry>, dimensions: Res<DimensionTypeRegistry>,
biomes: Res<BiomeRegistry>, biomes: Res<BiomeRegistry>,
server: Res<Server>, server: Res<Server>,
cli: Res<Cli>,
) { ) {
let instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server); let instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
commands.spawn(instance); let mut level = AnvilLevel::new(&cli.path, &biomes);
// Force a 16x16 area of chunks around the origin to be loaded at all times,
// similar to spawn chunks in vanilla. This isn't necessary, but it is done to
// demonstrate that it is possible.
for z in -8..8 {
for x in -8..8 {
let pos = ChunkPos::new(x, z);
level.ignored_chunks.insert(pos);
level.force_chunk_load(pos);
}
}
commands.spawn((instance, level));
} }
fn init_clients( fn init_clients(
@ -100,103 +79,44 @@ fn init_clients(
} }
} }
fn remove_unviewed_chunks(mut instances: Query<&mut Instance>) { fn handle_chunk_loads(
instances mut events: EventReader<ChunkLoadEvent>,
.single_mut() mut instances: Query<&mut Instance, With<AnvilLevel>>,
.retain_chunks(|_, chunk| chunk.is_viewed_mut());
}
fn update_client_views(
mut instances: Query<&mut Instance>,
mut clients: Query<(&mut Client, View, OldView)>,
mut state: ResMut<GameState>,
) { ) {
let instance = instances.single_mut(); let mut inst = instances.single_mut();
for (client, view, old_view) in &mut clients { for event in events.iter() {
let view = view.get(); match &event.status {
let queue_pos = |pos| { ChunkLoadStatus::Success { .. } => {
if instance.chunk(pos).is_none() { // The chunk was inserted into the world. Nothing for us to do.
match state.pending.entry(pos) {
Entry::Occupied(mut oe) => {
if let Some(priority) = oe.get_mut() {
let dist = view.pos.distance_squared(pos);
*priority = (*priority).min(dist);
}
}
Entry::Vacant(ve) => {
let dist = view.pos.distance_squared(pos);
ve.insert(Some(dist));
}
}
} }
}; ChunkLoadStatus::Empty => {
// There's no chunk here so let's insert an empty chunk. If we were doing
// Queue all the new chunks in the view to be sent to the anvil worker. // terrain generation we would prepare that here.
if client.is_added() { inst.insert_chunk(event.pos, Chunk::default());
view.iter().for_each(queue_pos); }
} else { ChunkLoadStatus::Failed(e) => {
let old_view = old_view.get(); // Something went wrong.
if old_view != view { eprintln!(
view.diff(old_view).for_each(queue_pos); "failed to load chunk at ({}, {}): {e:#}",
event.pos.x, event.pos.z
);
inst.insert_chunk(event.pos, Chunk::default());
} }
} }
} }
} }
fn send_recv_chunks(mut instances: Query<&mut Instance>, state: ResMut<GameState>) { // Display the number of loaded chunks in the action bar of all clients.
let mut instance = instances.single_mut(); fn display_loaded_chunk_count(mut instances: Query<&mut Instance>, mut last_count: Local<usize>) {
let state = state.into_inner(); let mut inst = instances.single_mut();
// Insert the chunks that are finished loading into the instance. let cnt = inst.chunks().count();
for (pos, chunk) in state.receiver.drain() {
instance.insert_chunk(pos, chunk);
assert!(state.pending.remove(&pos).is_some());
}
// Collect all the new chunks that need to be loaded this tick. if *last_count != cnt {
let mut to_send = vec![]; *last_count = cnt;
inst.send_action_bar_message(
for (pos, priority) in &mut state.pending { "Chunk Count: ".into_text() + (cnt as i32).color(Color::LIGHT_PURPLE),
if let Some(pri) = priority.take() { );
to_send.push((pri, pos));
}
}
// Sort chunks by ascending priority.
to_send.sort_unstable_by_key(|(pri, _)| *pri);
// Send the sorted chunks to be loaded.
for (_, pos) in to_send {
let _ = state.sender.try_send(*pos);
} }
} }
fn anvil_worker(
receiver: Receiver<ChunkPos>,
sender: Sender<(ChunkPos, Chunk)>,
mut world: AnvilWorld,
) {
while let Ok(pos) = receiver.recv() {
match get_chunk(pos, &mut world) {
Ok(chunk) => {
if let Some(chunk) = chunk {
let _ = sender.try_send((pos, chunk));
}
}
Err(e) => warn!("Failed to get chunk at ({}, {}): {e:#}.", pos.x, pos.z),
}
}
}
fn get_chunk(pos: ChunkPos, world: &mut AnvilWorld) -> anyhow::Result<Option<Chunk>> {
let Some(AnvilChunk { data, .. }) = world.read_chunk(pos.x, pos.z)? else {
return Ok(None)
};
let mut chunk = Chunk::new(SECTION_COUNT);
valence_anvil::to_valence(&data, &mut chunk, 4, |_| BiomeId::default())?;
Ok(Some(chunk))
}

View file

@ -2,8 +2,8 @@
use valence::nbt::{compound, List}; use valence::nbt::{compound, List};
use valence::prelude::*; use valence::prelude::*;
use valence_client::chat::ChatMessageEvent;
use valence_client::interact_block::InteractBlockEvent; use valence_client::interact_block::InteractBlockEvent;
use valence_client::message::ChatMessageEvent;
const FLOOR_Y: i32 = 64; const FLOOR_Y: i32 = 64;
const SIGN_POS: [i32; 3] = [3, FLOOR_Y + 1, 2]; const SIGN_POS: [i32; 3] = [3, FLOOR_Y + 1, 2];

View file

@ -3,6 +3,7 @@
use valence::inventory::HeldItem; use valence::inventory::HeldItem;
use valence::prelude::*; use valence::prelude::*;
use valence_client::interact_block::InteractBlockEvent; use valence_client::interact_block::InteractBlockEvent;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -55,7 +56,7 @@ fn init_clients(
loc.0 = instances.single(); loc.0 = instances.single();
pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
client.send_message("Welcome to Valence! Build something cool.".italic()); client.send_chat_message("Welcome to Valence! Build something cool.".italic());
} }
} }

View file

@ -3,6 +3,7 @@
use std::mem; use std::mem;
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
const BOARD_MIN_X: i32 = -30; const BOARD_MIN_X: i32 = -30;
const BOARD_MAX_X: i32 = 30; const BOARD_MAX_X: i32 = 30;
@ -74,8 +75,8 @@ fn init_clients(
instances: Query<Entity, With<Instance>>, instances: Query<Entity, With<Instance>>,
) { ) {
for (mut client, mut loc, mut pos) in &mut clients { for (mut client, mut loc, mut pos) in &mut clients {
client.send_message("Welcome to Conway's game of life in Minecraft!".italic()); client.send_chat_message("Welcome to Conway's game of life in Minecraft!".italic());
client.send_message( client.send_chat_message(
"Sneak to toggle running the simulation and the left mouse button to bring blocks to \ "Sneak to toggle running the simulation and the left mouse button to bring blocks to \
life." life."
.italic(), .italic(),

View file

@ -1,6 +1,7 @@
#![allow(clippy::type_complexity)] #![allow(clippy::type_complexity)]
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::status::RequestRespawnEvent; use valence_client::status::RequestRespawnEvent;
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -58,7 +59,7 @@ fn init_clients(
pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
has_respawn_screen.0 = true; has_respawn_screen.0 = true;
client.send_message( client.send_chat_message(
"Welcome to Valence! Sneak to die in the game (but not in real life).".italic(), "Welcome to Valence! Sneak to die in the game (but not in real life).".italic(),
); );
} }

View file

@ -4,6 +4,7 @@ use bevy_app::App;
use bevy_ecs::prelude::Entity; use bevy_ecs::prelude::Entity;
use rand::Rng; use rand::Rng;
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_entity::entity::NameVisible; use valence_entity::entity::NameVisible;
use valence_entity::hoglin::HoglinEntityBundle; use valence_entity::hoglin::HoglinEntityBundle;
use valence_entity::pig::PigEntityBundle; use valence_entity::pig::PigEntityBundle;
@ -53,7 +54,7 @@ fn init_clients(
loc.0 = instances.single(); loc.0 = instances.single();
pos.set([0.5, 65.0, 0.5]); pos.set([0.5, 65.0, 0.5]);
*game_mode = GameMode::Creative; *game_mode = GameMode::Creative;
client.send_message("To spawn an entity, press shift. F3 + B to activate hitboxes"); client.send_chat_message("To spawn an entity, press shift. F3 + B to activate hitboxes");
} }
} }

View file

@ -7,6 +7,7 @@ use rand::seq::SliceRandom;
use rand::Rng; use rand::Rng;
use valence::prelude::*; use valence::prelude::*;
use valence::protocol::packet::sound::{Sound, SoundCategory}; use valence::protocol::packet::sound::{Sound, SoundCategory};
use valence_client::message::SendMessage;
const START_POS: BlockPos = BlockPos::new(0, 100, 0); const START_POS: BlockPos = BlockPos::new(0, 100, 0);
const VIEW_DIST: u8 = 10; const VIEW_DIST: u8 = 10;
@ -66,7 +67,7 @@ fn init_clients(
is_flat.0 = true; is_flat.0 = true;
*game_mode = GameMode::Adventure; *game_mode = GameMode::Adventure;
client.send_message("Welcome to epic infinite parkour game!".italic()); client.send_chat_message("Welcome to epic infinite parkour game!".italic());
let state = GameState { let state = GameState {
blocks: VecDeque::new(), blocks: VecDeque::new(),
@ -96,7 +97,7 @@ fn reset_clients(
if out_of_bounds || state.is_added() { if out_of_bounds || state.is_added() {
if out_of_bounds && !state.is_added() { if out_of_bounds && !state.is_added() {
client.send_message( client.send_chat_message(
"Your score was ".italic() "Your score was ".italic()
+ state + state
.score .score

View file

@ -3,6 +3,7 @@
use rand::Rng; use rand::Rng;
use valence::player_list::{DisplayName, PlayerListEntryBundle}; use valence::player_list::{DisplayName, PlayerListEntryBundle};
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::Ping; use valence_client::Ping;
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -62,7 +63,7 @@ fn init_clients(
loc.0 = instances.single(); loc.0 = instances.single();
*game_mode = GameMode::Creative; *game_mode = GameMode::Creative;
client.send_message( client.send_chat_message(
"Please open your player list (tab key)." "Please open your player list (tab key)."
.italic() .italic()
.color(Color::WHITE), .color(Color::WHITE),

View file

@ -3,6 +3,7 @@
use valence::entity::player::PlayerEntityBundle; use valence::entity::player::PlayerEntityBundle;
use valence::entity::sheep::SheepEntityBundle; use valence::entity::sheep::SheepEntityBundle;
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
use valence_client::resource_pack::{ResourcePackStatus, ResourcePackStatusEvent}; use valence_client::resource_pack::{ResourcePackStatus, ResourcePackStatusEvent};
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -57,7 +58,7 @@ fn init_clients(
for (entity, uuid, mut client, mut game_mode) in &mut clients { for (entity, uuid, mut client, mut game_mode) in &mut clients {
*game_mode = GameMode::Creative; *game_mode = GameMode::Creative;
client.send_message("Hit the sheep to prompt for the resource pack.".italic()); client.send_chat_message("Hit the sheep to prompt for the resource pack.".italic());
commands.entity(entity).insert(PlayerEntityBundle { commands.entity(entity).insert(PlayerEntityBundle {
location: Location(instances.single()), location: Location(instances.single()),
@ -91,17 +92,18 @@ fn on_resource_pack_status(
if let Ok(mut client) = clients.get_mut(event.client) { if let Ok(mut client) = clients.get_mut(event.client) {
match event.status { match event.status {
ResourcePackStatus::Accepted => { ResourcePackStatus::Accepted => {
client.send_message("Resource pack accepted.".color(Color::GREEN)); client.send_chat_message("Resource pack accepted.".color(Color::GREEN));
} }
ResourcePackStatus::Declined => { ResourcePackStatus::Declined => {
client.send_message("Resource pack declined.".color(Color::RED)); client.send_chat_message("Resource pack declined.".color(Color::RED));
} }
ResourcePackStatus::FailedDownload => { ResourcePackStatus::FailedDownload => {
client.send_message("Resource pack failed to download.".color(Color::RED)); client.send_chat_message("Resource pack failed to download.".color(Color::RED));
} }
ResourcePackStatus::SuccessfullyLoaded => { ResourcePackStatus::SuccessfullyLoaded => {
client client.send_chat_message(
.send_message("Resource pack successfully downloaded.".color(Color::BLUE)); "Resource pack successfully downloaded.".color(Color::BLUE),
);
} }
} }
}; };

View file

@ -1,6 +1,7 @@
#![allow(clippy::type_complexity)] #![allow(clippy::type_complexity)]
use valence::prelude::*; use valence::prelude::*;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -47,16 +48,17 @@ fn init_clients(
loc.0 = instances.single(); loc.0 = instances.single();
*game_mode = GameMode::Creative; *game_mode = GameMode::Creative;
client.send_message("Welcome to the text example.".bold()); client.send_chat_message("Welcome to the text example.".bold());
client client.send_chat_message(
.send_message("The following examples show ways to use the different text components."); "The following examples show ways to use the different text components.",
);
// Text examples // Text examples
client.send_message("\nText"); client.send_chat_message("\nText");
client.send_message(" - ".into_text() + Text::text("Plain text")); client.send_chat_message(" - ".into_text() + Text::text("Plain text"));
client.send_message(" - ".into_text() + Text::text("Styled text").italic()); client.send_chat_message(" - ".into_text() + Text::text("Styled text").italic());
client.send_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD)); client.send_chat_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD));
client.send_message( client.send_chat_message(
" - ".into_text() " - ".into_text()
+ Text::text("Colored and styled text") + Text::text("Colored and styled text")
.color(Color::GOLD) .color(Color::GOLD)
@ -65,59 +67,61 @@ fn init_clients(
); );
// Translated text examples // Translated text examples
client.send_message("\nTranslated Text"); client.send_chat_message("\nTranslated Text");
client.send_message( client.send_chat_message(
" - 'chat.type.advancement.task': ".into_text() " - 'chat.type.advancement.task': ".into_text()
+ Text::translate(translation_key::CHAT_TYPE_ADVANCEMENT_TASK, []), + Text::translate(translation_key::CHAT_TYPE_ADVANCEMENT_TASK, []),
); );
client.send_message( client.send_chat_message(
" - 'chat.type.advancement.task' with slots: ".into_text() " - 'chat.type.advancement.task' with slots: ".into_text()
+ Text::translate( + Text::translate(
translation_key::CHAT_TYPE_ADVANCEMENT_TASK, translation_key::CHAT_TYPE_ADVANCEMENT_TASK,
["arg1".into(), "arg2".into()], ["arg1".into(), "arg2".into()],
), ),
); );
client.send_message( client.send_chat_message(
" - 'custom.translation_key': ".into_text() " - 'custom.translation_key': ".into_text()
+ Text::translate("custom.translation_key", []), + Text::translate("custom.translation_key", []),
); );
// Scoreboard value example // Scoreboard value example
client.send_message("\nScoreboard Values"); client.send_chat_message("\nScoreboard Values");
client.send_message(" - Score: ".into_text() + Text::score("*", "objective", None)); client.send_chat_message(" - Score: ".into_text() + Text::score("*", "objective", None));
client.send_message( client.send_chat_message(
" - Score with custom value: ".into_text() " - Score with custom value: ".into_text()
+ Text::score("*", "objective", Some("value".into())), + Text::score("*", "objective", Some("value".into())),
); );
// Entity names example // Entity names example
client.send_message("\nEntity Names (Selector)"); client.send_chat_message("\nEntity Names (Selector)");
client.send_message(" - Nearest player: ".into_text() + Text::selector("@p", None)); client.send_chat_message(" - Nearest player: ".into_text() + Text::selector("@p", None));
client.send_message(" - Random player: ".into_text() + Text::selector("@r", None)); client.send_chat_message(" - Random player: ".into_text() + Text::selector("@r", None));
client.send_message(" - All players: ".into_text() + Text::selector("@a", None)); client.send_chat_message(" - All players: ".into_text() + Text::selector("@a", None));
client.send_message(" - All entities: ".into_text() + Text::selector("@e", None)); client.send_chat_message(" - All entities: ".into_text() + Text::selector("@e", None));
client.send_message( client.send_chat_message(
" - All entities with custom separator: ".into_text() " - All entities with custom separator: ".into_text()
+ Text::selector("@e", Some(", ".into_text().color(Color::GOLD))), + Text::selector("@e", Some(", ".into_text().color(Color::GOLD))),
); );
// Keybind example // Keybind example
client.send_message("\nKeybind"); client.send_chat_message("\nKeybind");
client.send_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory")); client
.send_chat_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory"));
// NBT examples // NBT examples
client.send_message("\nNBT"); client.send_chat_message("\nNBT");
client.send_message( client.send_chat_message(
" - Block NBT: ".into_text() + Text::block_nbt("{}", "0 1 0", None, None), " - Block NBT: ".into_text() + Text::block_nbt("{}", "0 1 0", None, None),
); );
client client.send_chat_message(
.send_message(" - Entity NBT: ".into_text() + Text::entity_nbt("{}", "@a", None, None)); " - Entity NBT: ".into_text() + Text::entity_nbt("{}", "@a", None, None),
client.send_message( );
client.send_chat_message(
" - Storage NBT: ".into_text() " - Storage NBT: ".into_text()
+ Text::storage_nbt(ident!("storage.key"), "@a", None, None), + Text::storage_nbt(ident!("storage.key"), "@a", None, None),
); );
client.send_message( client.send_chat_message(
"\n\n".into_text().bold().color(Color::GOLD) "\n\n".into_text().bold().color(Color::GOLD)
+ "Scroll up to see the full example!".into_text().not_bold(), + "Scroll up to see the full example!".into_text().not_bold(),
); );

View file

@ -1,11 +1,12 @@
use std::time::Duration; use std::time::Duration;
use bevy_app::App; use bevy_app::App;
use valence::client::chat::ChatMessageEvent;
use valence::client::despawn_disconnected_clients; use valence::client::despawn_disconnected_clients;
use valence::client::message::ChatMessageEvent;
use valence::inventory::HeldItem; use valence::inventory::HeldItem;
use valence::prelude::*; use valence::prelude::*;
use valence::world_border::*; use valence::world_border::*;
use valence_client::message::SendMessage;
const SPAWN_Y: i32 = 64; const SPAWN_Y: i32 = 64;
@ -66,7 +67,7 @@ fn init_clients(
pos.set([0.5, SPAWN_Y as f64 + 1.0, 0.5]); pos.set([0.5, SPAWN_Y as f64 + 1.0, 0.5]);
let pickaxe = Some(ItemStack::new(ItemKind::WoodenPickaxe, 1, None)); let pickaxe = Some(ItemStack::new(ItemKind::WoodenPickaxe, 1, None));
inv.set_slot(main_slot.slot(), pickaxe); inv.set_slot(main_slot.slot(), pickaxe);
client.send_message("Break block to increase border size!"); client.send_chat_message("Break block to increase border size!");
} }
} }

View file

@ -154,7 +154,7 @@ impl PluginGroup for DefaultPlugins {
#[cfg(feature = "anvil")] #[cfg(feature = "anvil")]
{ {
// No plugin... yet. group = group.add(valence_anvil::AnvilPlugin);
} }
#[cfg(feature = "advancement")] #[cfg(feature = "advancement")]

View file

@ -1,8 +1,7 @@
[package] [package]
name = "valence_anvil" name = "valence_anvil"
description = "A library for Minecraft's Anvil world format." description = "Anvil world format support for Valence"
documentation.workspace = true documentation.workspace = true
repository = "https://github.com/valence-rs/valence/tree/main/crates/valence_anvil"
readme = "README.md" readme = "README.md"
license.workspace = true license.workspace = true
keywords = ["anvil", "minecraft", "deserialization"] keywords = ["anvil", "minecraft", "deserialization"]
@ -10,12 +9,20 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true
bevy_app.workspace = true
bevy_ecs.workspace = true
byteorder.workspace = true byteorder.workspace = true
flate2.workspace = true flate2.workspace = true
flume.workspace = true
lru.workspace = true
num-integer.workspace = true num-integer.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true
valence_biome.workspace = true valence_biome.workspace = true
valence_block.workspace = true valence_block.workspace = true
valence_client.workspace = true
valence_core.workspace = true valence_core.workspace = true
valence_entity.workspace = true
valence_instance.workspace = true valence_instance.workspace = true
valence_nbt.workspace = true valence_nbt.workspace = true

View file

@ -17,99 +17,149 @@
clippy::dbg_macro clippy::dbg_macro
)] )]
use std::collections::btree_map::Entry; use std::collections::hash_map::Entry;
use std::collections::BTreeMap; use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs::File; use std::fs::File;
use std::io;
use std::io::{ErrorKind, Read, Seek, SeekFrom}; use std::io::{ErrorKind, Read, Seek, SeekFrom};
use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
use std::thread;
use anyhow::{bail, ensure};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use byteorder::{BigEndian, ReadBytesExt}; use byteorder::{BigEndian, ReadBytesExt};
use flate2::bufread::{GzDecoder, ZlibDecoder}; use flate2::bufread::{GzDecoder, ZlibDecoder};
use thiserror::Error; use flume::{Receiver, Sender};
pub use to_valence::*; use lru::LruCache;
use tracing::warn;
use valence_biome::{BiomeId, BiomeRegistry};
use valence_client::{Client, OldView, UpdateClientsSet, View};
use valence_core::chunk_pos::ChunkPos;
use valence_core::ident::Ident;
use valence_entity::{Location, OldLocation};
use valence_instance::{Chunk, Instance};
use valence_nbt::Compound; use valence_nbt::Compound;
mod to_valence; mod parse_chunk;
#[derive(Debug)] #[derive(Component, Debug)]
pub struct AnvilWorld { pub struct AnvilLevel {
/// Path to the "region" subdirectory in the world root. /// Chunk worker state to be moved to another thread.
region_root: PathBuf, worker_state: Option<ChunkWorkerState>,
// TODO: LRU cache for region file handles. /// The set of chunk positions that should not be loaded or unloaded by
/// Maps region (x, z) positions to region files. /// the anvil system.
regions: BTreeMap<(i32, i32), Region>, ///
/// This set is empty by default, but you can modify it at any time.
pub ignored_chunks: HashSet<ChunkPos>,
/// Chunks that need to be loaded. Chunks with `None` priority have already
/// been sent to the anvil thread.
pending: HashMap<ChunkPos, Option<Priority>>,
/// Sender for the chunk worker thread.
sender: Sender<ChunkPos>,
/// Receiver for the chunk worker thread.
receiver: Receiver<(ChunkPos, WorkerResult)>,
} }
#[derive(Clone, PartialEq, Debug)] type WorkerResult = anyhow::Result<Option<(Chunk, AnvilChunk)>>;
pub struct AnvilChunk {
/// This chunk's NBT data.
pub data: Compound,
/// The time this chunk was last modified measured in seconds since the
/// epoch.
pub timestamp: u32,
}
#[derive(Debug, Error)] impl AnvilLevel {
#[non_exhaustive] pub fn new(world_root: impl Into<PathBuf>, biomes: &BiomeRegistry) -> Self {
pub enum ReadChunkError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Nbt(#[from] valence_nbt::binary::Error),
#[error("invalid chunk sector offset")]
BadSectorOffset,
#[error("invalid chunk size")]
BadChunkSize,
#[error("unknown compression scheme number of {0}")]
UnknownCompressionScheme(u8),
#[error("not all chunk NBT data was read")]
IncompleteNbtRead,
}
#[derive(Debug)]
struct Region {
file: File,
/// The first 8 KiB in the file.
header: [u8; SECTOR_SIZE * 2],
}
const SECTOR_SIZE: usize = 4096;
impl AnvilWorld {
pub fn new(world_root: impl Into<PathBuf>) -> Self {
let mut region_root = world_root.into(); let mut region_root = world_root.into();
region_root.push("region"); region_root.push("region");
let (pending_sender, pending_receiver) = flume::unbounded();
let (finished_sender, finished_receiver) = flume::bounded(4096);
Self { Self {
region_root, worker_state: Some(ChunkWorkerState {
regions: BTreeMap::new(), regions: LruCache::new(LRU_CACHE_SIZE),
region_root,
sender: finished_sender,
receiver: pending_receiver,
decompress_buf: vec![],
biome_to_id: biomes
.iter()
.map(|(id, name, _)| (name.to_string_ident(), id))
.collect(),
section_count: 0, // Assigned later.
}),
ignored_chunks: HashSet::new(),
pending: HashMap::new(),
sender: pending_sender,
receiver: finished_receiver,
} }
} }
/// Reads a chunk from the file system with the given chunk coordinates. If /// Forces a chunk to be loaded at a specific position in this world. This
/// no chunk exists at the position, then `None` is returned. /// will bypass [`AnvilLevel::ignored_chunks`].
pub fn read_chunk( /// Note that the chunk will be unloaded next tick unless it has been added
&mut self, /// to [`AnvilLevel::ignored_chunks`] or it is in view of a client.
chunk_x: i32, ///
chunk_z: i32, /// This has no effect if a chunk at the position is already present.
) -> Result<Option<AnvilChunk>, ReadChunkError> { pub fn force_chunk_load(&mut self, pos: ChunkPos) {
let region_x = chunk_x.div_euclid(32); match self.pending.entry(pos) {
let region_z = chunk_z.div_euclid(32); Entry::Occupied(oe) => {
// If the chunk is already scheduled to load but hasn't been sent to the chunk
let region = match self.regions.entry((region_x, region_z)) { // worker yet, then give it the highest priority.
if let Some(priority) = oe.into_mut() {
*priority = 0;
}
}
Entry::Vacant(ve) => { Entry::Vacant(ve) => {
// Load the region file if it exists. Otherwise, the chunk is considered absent. ve.insert(Some(0));
}
}
}
}
// TODO: Add tombstone for missing region file in `regions`. const LRU_CACHE_SIZE: NonZeroUsize = match NonZeroUsize::new(256) {
Some(n) => n,
None => unreachable!(),
};
/// The order in which chunks should be processed by the anvil worker. Smaller
/// values are sent first.
type Priority = u64;
#[derive(Debug)]
struct ChunkWorkerState {
/// Region files. An LRU cache is used to limit the number of open file
/// handles.
regions: LruCache<RegionPos, RegionEntry>,
/// Path to the "region" subdirectory in the world root.
region_root: PathBuf,
/// Sender of finished chunks.
sender: Sender<(ChunkPos, WorkerResult)>,
/// Receiver of pending chunks.
receiver: Receiver<ChunkPos>,
/// Scratch buffer for decompression.
decompress_buf: Vec<u8>,
/// Mapping of biome names to their biome ID.
biome_to_id: BTreeMap<Ident<String>, BiomeId>,
/// Number of chunk sections in the instance.
section_count: usize,
}
impl ChunkWorkerState {
fn get_chunk(&mut self, pos: ChunkPos) -> anyhow::Result<Option<AnvilChunk>> {
let region_x = pos.x.div_euclid(32);
let region_z = pos.z.div_euclid(32);
let region = match self.regions.get_mut(&(region_x, region_z)) {
Some(RegionEntry::Occupied(region)) => region,
Some(RegionEntry::Vacant) => return Ok(None),
None => {
let path = self let path = self
.region_root .region_root
.join(format!("r.{region_x}.{region_z}.mca")); .join(format!("r.{region_x}.{region_z}.mca"));
let mut file = match File::options().read(true).write(true).open(path) { let mut file = match File::options().read(true).write(true).open(path) {
Ok(file) => file, Ok(file) => file,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), Err(e) if e.kind() == ErrorKind::NotFound => {
self.regions.put((region_x, region_z), RegionEntry::Vacant);
return Ok(None);
}
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
@ -117,12 +167,19 @@ impl AnvilWorld {
file.read_exact(&mut header)?; file.read_exact(&mut header)?;
ve.insert(Region { file, header }) // TODO: this is ugly.
let res = self.regions.get_or_insert_mut((region_x, region_z), || {
RegionEntry::Occupied(Region { file, header })
});
match res {
RegionEntry::Occupied(r) => r,
RegionEntry::Vacant => unreachable!(),
}
} }
Entry::Occupied(oe) => oe.into_mut(),
}; };
let chunk_idx = (chunk_x.rem_euclid(32) + chunk_z.rem_euclid(32) * 32) as usize; let chunk_idx = (pos.x.rem_euclid(32) + pos.z.rem_euclid(32) * 32) as usize;
let location_bytes = (&region.header[chunk_idx * 4..]).read_u32::<BigEndian>()?; let location_bytes = (&region.header[chunk_idx * 4..]).read_u32::<BigEndian>()?;
let timestamp = (&region.header[chunk_idx * 4 + SECTOR_SIZE..]).read_u32::<BigEndian>()?; let timestamp = (&region.header[chunk_idx * 4 + SECTOR_SIZE..]).read_u32::<BigEndian>()?;
@ -135,11 +192,9 @@ impl AnvilWorld {
let sector_offset = (location_bytes >> 8) as u64; let sector_offset = (location_bytes >> 8) as u64;
let sector_count = (location_bytes & 0xff) as usize; let sector_count = (location_bytes & 0xff) as usize;
if sector_offset < 2 { // If the sector offset was <2, then the chunk data would be inside the region
// If the sector offset was <2, then the chunk data would be inside the region // header. That doesn't make any sense.
// header. That doesn't make any sense. ensure!(sector_offset >= 2, "invalid chunk sector offset");
return Err(ReadChunkError::BadSectorOffset);
}
// Seek to the beginning of the chunk's data. // Seek to the beginning of the chunk's data.
region region
@ -148,44 +203,267 @@ impl AnvilWorld {
let exact_chunk_size = region.file.read_u32::<BigEndian>()? as usize; let exact_chunk_size = region.file.read_u32::<BigEndian>()? as usize;
if exact_chunk_size > sector_count * SECTOR_SIZE { // size of this chunk in sectors must always be >= the exact size.
// Sector size of this chunk must always be >= the exact size. ensure!(
return Err(ReadChunkError::BadChunkSize); sector_count * SECTOR_SIZE >= exact_chunk_size,
} "invalid chunk size"
);
let mut data_buf = vec![0; exact_chunk_size].into_boxed_slice(); let mut data_buf = vec![0; exact_chunk_size].into_boxed_slice();
region.file.read_exact(&mut data_buf)?; region.file.read_exact(&mut data_buf)?;
let mut r = data_buf.as_ref(); let mut r = data_buf.as_ref();
let mut decompress_buf = vec![]; self.decompress_buf.clear();
// What compression does the chunk use? // What compression does the chunk use?
let mut nbt_slice = match r.read_u8()? { let mut nbt_slice = match r.read_u8()? {
// GZip // GZip
1 => { 1 => {
let mut z = GzDecoder::new(r); let mut z = GzDecoder::new(r);
z.read_to_end(&mut decompress_buf)?; z.read_to_end(&mut self.decompress_buf)?;
decompress_buf.as_slice() self.decompress_buf.as_slice()
} }
// Zlib // Zlib
2 => { 2 => {
let mut z = ZlibDecoder::new(r); let mut z = ZlibDecoder::new(r);
z.read_to_end(&mut decompress_buf)?; z.read_to_end(&mut self.decompress_buf)?;
decompress_buf.as_slice() self.decompress_buf.as_slice()
} }
// Uncompressed // Uncompressed
3 => r, 3 => r,
// Unknown // Unknown
b => return Err(ReadChunkError::UnknownCompressionScheme(b)), b => bail!("unknown compression scheme number of {b}"),
}; };
let (data, _) = Compound::from_binary(&mut nbt_slice)?; let (data, _) = Compound::from_binary(&mut nbt_slice)?;
if !nbt_slice.is_empty() { ensure!(nbt_slice.is_empty(), "not all chunk NBT data was read");
return Err(ReadChunkError::IncompleteNbtRead);
}
Ok(Some(AnvilChunk { data, timestamp })) Ok(Some(AnvilChunk { data, timestamp }))
} }
} }
struct AnvilChunk {
data: Compound,
timestamp: u32,
}
/// X and Z positions of a region.
type RegionPos = (i32, i32);
#[allow(clippy::large_enum_variant)] // We're not moving this around.
#[derive(Debug)]
enum RegionEntry {
/// There is a region file loaded here.
Occupied(Region),
/// There is no region file at this position. Don't try to read it from the
/// filesystem again.
Vacant,
}
#[derive(Debug)]
struct Region {
file: File,
/// The first 8 KiB in the file.
header: [u8; SECTOR_SIZE * 2],
}
const SECTOR_SIZE: usize = 4096;
pub struct AnvilPlugin;
impl Plugin for AnvilPlugin {
fn build(&self, app: &mut App) {
app.add_event::<ChunkLoadEvent>()
.add_event::<ChunkUnloadEvent>()
.add_system(remove_unviewed_chunks.in_base_set(CoreSet::PreUpdate))
.add_systems(
(init_anvil, update_client_views, send_recv_chunks)
.chain()
.in_base_set(CoreSet::PostUpdate)
.before(UpdateClientsSet),
);
}
}
fn init_anvil(mut query: Query<(&mut AnvilLevel, &Instance), Added<AnvilLevel>>) {
for (mut level, inst) in &mut query {
if let Some(mut state) = level.worker_state.take() {
state.section_count = inst.section_count();
thread::spawn(move || anvil_worker(state));
}
}
}
/// Removes all chunks no longer viewed by clients.
///
/// This needs to run in `PreUpdate` where the chunk viewer counts have been
/// updated from the previous tick.
fn remove_unviewed_chunks(
mut instances: Query<(Entity, &mut Instance, &AnvilLevel)>,
mut unload_events: EventWriter<ChunkUnloadEvent>,
) {
for (entity, mut inst, anvil) in &mut instances {
inst.retain_chunks(|pos, chunk| {
if chunk.is_viewed_mut() || anvil.ignored_chunks.contains(&pos) {
true
} else {
unload_events.send(ChunkUnloadEvent {
instance: entity,
pos,
});
false
}
});
}
}
fn update_client_views(
clients: Query<(&Location, Ref<OldLocation>, View, OldView), With<Client>>,
mut instances: Query<(&Instance, &mut AnvilLevel)>,
) {
for (loc, old_loc, view, old_view) in &clients {
let view = view.get();
let old_view = old_view.get();
if loc != &*old_loc || view != old_view || old_loc.is_added() {
let Ok((inst, mut anvil)) = instances.get_mut(loc.0) else {
continue
};
let queue_pos = |pos| {
if !anvil.ignored_chunks.contains(&pos) && inst.chunk(pos).is_none() {
// Chunks closer to clients are prioritized.
match anvil.pending.entry(pos) {
Entry::Occupied(mut oe) => {
if let Some(priority) = oe.get_mut() {
let dist = view.pos.distance_squared(pos);
*priority = (*priority).min(dist);
}
}
Entry::Vacant(ve) => {
let dist = view.pos.distance_squared(pos);
ve.insert(Some(dist));
}
}
}
};
// Queue all the new chunks in the view to be sent to the anvil worker.
if old_loc.is_added() {
view.iter().for_each(queue_pos);
} else {
view.diff(old_view).for_each(queue_pos);
}
}
}
}
fn send_recv_chunks(
mut instances: Query<(Entity, &mut Instance, &mut AnvilLevel)>,
mut to_send: Local<Vec<(Priority, ChunkPos)>>,
mut load_events: EventWriter<ChunkLoadEvent>,
) {
for (entity, mut inst, anvil) in &mut instances {
let anvil = anvil.into_inner();
// Insert the chunks that are finished loading into the instance and send load
// events.
for (pos, res) in anvil.receiver.drain() {
anvil.pending.remove(&pos);
let status = match res {
Ok(Some((chunk, AnvilChunk { data, timestamp }))) => {
inst.insert_chunk(pos, chunk);
ChunkLoadStatus::Success { data, timestamp }
}
Ok(None) => ChunkLoadStatus::Empty,
Err(e) => ChunkLoadStatus::Failed(e),
};
load_events.send(ChunkLoadEvent {
instance: entity,
pos,
status,
});
}
// Collect all the new chunks that need to be loaded this tick.
for (pos, priority) in &mut anvil.pending {
if let Some(pri) = priority.take() {
to_send.push((pri, *pos));
}
}
// Sort chunks by ascending priority.
to_send.sort_unstable_by_key(|(pri, _)| *pri);
// Send the sorted chunks to be loaded.
for (_, pos) in to_send.drain(..) {
let _ = anvil.sender.try_send(pos);
}
}
}
fn anvil_worker(mut state: ChunkWorkerState) {
while let Ok(pos) = state.receiver.recv() {
let res = get_chunk(pos, &mut state);
let _ = state.sender.send((pos, res));
}
fn get_chunk(pos: ChunkPos, state: &mut ChunkWorkerState) -> WorkerResult {
let Some(anvil_chunk) = state.get_chunk(pos)? else {
return Ok(None);
};
let mut chunk = Chunk::new(state.section_count);
// TODO: account for min_y correctly.
parse_chunk::parse_chunk(&anvil_chunk.data, &mut chunk, 4, |biome| {
state
.biome_to_id
.get(biome.as_str())
.copied()
.unwrap_or_default()
})?;
Ok(Some((chunk, anvil_chunk)))
}
}
/// An event sent by `valence_anvil` after an attempt to load a chunk is made.
#[derive(Debug)]
pub struct ChunkLoadEvent {
/// The [`Instance`] where the chunk is located.
pub instance: Entity,
/// The position of the chunk in the instance.
pub pos: ChunkPos,
pub status: ChunkLoadStatus,
}
#[derive(Debug)]
pub enum ChunkLoadStatus {
/// A new chunk was successfully loaded and inserted into the instance.
Success {
/// The raw chunk data of the new chunk.
data: Compound,
/// The time this chunk was last modified, measured in seconds since the
/// epoch.
timestamp: u32,
},
/// The Anvil level does not have a chunk at the position. No chunk was
/// loaded.
Empty,
/// An attempt was made to load the chunk, but something went wrong.
Failed(anyhow::Error),
}
/// An event sent by `valence_anvil` when a chunk is unloaded from an instance.
#[derive(Debug)]
pub struct ChunkUnloadEvent {
/// The [`Instance`] where the chunk was unloaded.
pub instance: Entity,
/// The position of the chunk that was unloaded.
pub pos: ChunkPos,
}

View file

@ -10,7 +10,7 @@ use valence_nbt::{Compound, List, Value};
#[derive(Clone, Debug, Error)] #[derive(Clone, Debug, Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum ToValenceError { pub(crate) enum ParseChunkError {
#[error("missing chunk sections")] #[error("missing chunk sections")]
MissingSections, MissingSections,
#[error("missing chunk section Y")] #[error("missing chunk section Y")]
@ -45,8 +45,6 @@ pub enum ToValenceError {
BadBiomePaletteLen, BadBiomePaletteLen,
#[error("biome name is not a valid resource identifier")] #[error("biome name is not a valid resource identifier")]
BadBiomeName, BadBiomeName,
#[error("missing biome name")]
MissingBiomeName,
#[error("missing packed biome data in section")] #[error("missing packed biome data in section")]
MissingBiomeData, MissingBiomeData,
#[error("unexpected number of longs in biome data")] #[error("unexpected number of longs in biome data")]
@ -69,27 +67,24 @@ pub enum ToValenceError {
/// ///
/// # Arguments /// # Arguments
/// ///
/// - `nbt`: The Anvil chunk to read from. This is usually the value returned by /// - `nbt`: The raw Anvil chunk NBT to read from.
/// [`AnvilWorld::read_chunk`].
/// - `chunk`: The Valence chunk to write to. /// - `chunk`: The Valence chunk to write to.
/// - `sect_offset`: A constant to add to all sector Y positions in `nbt`. After /// - `sect_offset`: A constant to add to all sector Y positions in `nbt`. After
/// applying the offset, only the sectors in the range /// applying the offset, only the sectors in the range
/// `0..chunk.sector_count()` are written. /// `0..chunk.section_count()` are written.
/// - `map_biome`: A function to map biome resource identifiers in the NBT data /// - `map_biome`: A function to map biome resource identifiers in the NBT data
/// to Valence [`BiomeId`]s. /// to Valence [`BiomeId`]s.
/// pub(crate) fn parse_chunk<F, const LOADED: bool>(
/// [`AnvilWorld::read_chunk`]: crate::AnvilWorld::read_chunk
pub fn to_valence<F, const LOADED: bool>(
nbt: &Compound, nbt: &Compound,
chunk: &mut Chunk<LOADED>, chunk: &mut Chunk<LOADED>,
sect_offset: i32, sect_offset: i32,
mut map_biome: F, mut map_biome: F,
) -> Result<(), ToValenceError> ) -> Result<(), ParseChunkError>
where where
F: FnMut(Ident<&str>) -> BiomeId, F: FnMut(Ident<&str>) -> BiomeId,
{ {
let Some(Value::List(List::Compound(sections))) = nbt.get("sections") else { let Some(Value::List(List::Compound(sections))) = nbt.get("sections") else {
return Err(ToValenceError::MissingSections) return Err(ParseChunkError::MissingSections)
}; };
let mut converted_block_palette = vec![]; let mut converted_block_palette = vec![];
@ -97,7 +92,7 @@ where
for section in sections { for section in sections {
let Some(Value::Byte(sect_y)) = section.get("Y") else { let Some(Value::Byte(sect_y)) = section.get("Y") else {
return Err(ToValenceError::MissingSectionY) return Err(ParseChunkError::MissingSectionY)
}; };
let adjusted_sect_y = *sect_y as i32 + sect_offset; let adjusted_sect_y = *sect_y as i32 + sect_offset;
@ -108,26 +103,26 @@ where
} }
let Some(Value::Compound(block_states)) = section.get("block_states") else { let Some(Value::Compound(block_states)) = section.get("block_states") else {
return Err(ToValenceError::MissingBlockStates) return Err(ParseChunkError::MissingBlockStates)
}; };
let Some(Value::List(List::Compound(palette))) = block_states.get("palette") else { let Some(Value::List(List::Compound(palette))) = block_states.get("palette") else {
return Err(ToValenceError::MissingBlockPalette) return Err(ParseChunkError::MissingBlockPalette)
}; };
if !(1..BLOCKS_PER_SECTION).contains(&palette.len()) { if !(1..BLOCKS_PER_SECTION).contains(&palette.len()) {
return Err(ToValenceError::BadBlockPaletteLen); return Err(ParseChunkError::BadBlockPaletteLen);
} }
converted_block_palette.clear(); converted_block_palette.clear();
for block in palette { for block in palette {
let Some(Value::String(name)) = block.get("Name") else { let Some(Value::String(name)) = block.get("Name") else {
return Err(ToValenceError::MissingBlockName) return Err(ParseChunkError::MissingBlockName)
}; };
let Some(block_kind) = BlockKind::from_str(ident_path(name)) else { let Some(block_kind) = BlockKind::from_str(ident_path(name)) else {
return Err(ToValenceError::UnknownBlockName(name.into())) return Err(ParseChunkError::UnknownBlockName(name.into()))
}; };
let mut state = block_kind.to_state(); let mut state = block_kind.to_state();
@ -135,15 +130,15 @@ where
if let Some(Value::Compound(properties)) = block.get("Properties") { if let Some(Value::Compound(properties)) = block.get("Properties") {
for (key, value) in properties { for (key, value) in properties {
let Value::String(value) = value else { let Value::String(value) = value else {
return Err(ToValenceError::BadPropValueType) return Err(ParseChunkError::BadPropValueType)
}; };
let Some(prop_name) = PropName::from_str(key) else { let Some(prop_name) = PropName::from_str(key) else {
return Err(ToValenceError::UnknownPropName(key.into())) return Err(ParseChunkError::UnknownPropName(key.into()))
}; };
let Some(prop_value) = PropValue::from_str(value) else { let Some(prop_value) = PropValue::from_str(value) else {
return Err(ToValenceError::UnknownPropValue(value.into())) return Err(ParseChunkError::UnknownPropValue(value.into()))
}; };
state = state.set(prop_name, prop_value); state = state.set(prop_name, prop_value);
@ -159,7 +154,7 @@ where
debug_assert!(converted_block_palette.len() > 1); debug_assert!(converted_block_palette.len() > 1);
let Some(Value::LongArray(data)) = block_states.get("data") else { let Some(Value::LongArray(data)) = block_states.get("data") else {
return Err(ToValenceError::MissingBlockStateData) return Err(ParseChunkError::MissingBlockStateData)
}; };
let bits_per_idx = bit_width(converted_block_palette.len() - 1).max(4); let bits_per_idx = bit_width(converted_block_palette.len() - 1).max(4);
@ -168,7 +163,7 @@ where
let mask = 2_u64.pow(bits_per_idx as u32) - 1; let mask = 2_u64.pow(bits_per_idx as u32) - 1;
if long_count != data.len() { if long_count != data.len() {
return Err(ToValenceError::BadBlockLongCount); return Err(ParseChunkError::BadBlockLongCount);
}; };
let mut i = 0; let mut i = 0;
@ -183,7 +178,7 @@ where
let idx = (u64 >> (bits_per_idx * j)) & mask; let idx = (u64 >> (bits_per_idx * j)) & mask;
let Some(block) = converted_block_palette.get(idx as usize).cloned() else { let Some(block) = converted_block_palette.get(idx as usize).cloned() else {
return Err(ToValenceError::BadBlockPaletteIndex) return Err(ParseChunkError::BadBlockPaletteIndex)
}; };
let x = i % 16; let x = i % 16;
@ -198,22 +193,22 @@ where
} }
let Some(Value::Compound(biomes)) = section.get("biomes") else { let Some(Value::Compound(biomes)) = section.get("biomes") else {
return Err(ToValenceError::MissingBiomes) return Err(ParseChunkError::MissingBiomes)
}; };
let Some(Value::List(List::String(palette))) = biomes.get("palette") else { let Some(Value::List(List::String(palette))) = biomes.get("palette") else {
return Err(ToValenceError::MissingBiomePalette) return Err(ParseChunkError::MissingBiomePalette)
}; };
if !(1..BIOMES_PER_SECTION).contains(&palette.len()) { if !(1..BIOMES_PER_SECTION).contains(&palette.len()) {
return Err(ToValenceError::BadBiomePaletteLen); return Err(ParseChunkError::BadBiomePaletteLen);
} }
converted_biome_palette.clear(); converted_biome_palette.clear();
for biome_name in palette { for biome_name in palette {
let Ok(ident) = Ident::<Cow<str>>::new(biome_name) else { let Ok(ident) = Ident::<Cow<str>>::new(biome_name) else {
return Err(ToValenceError::BadBiomeName) return Err(ParseChunkError::BadBiomeName)
}; };
converted_biome_palette.push(map_biome(ident.as_str_ident())); converted_biome_palette.push(map_biome(ident.as_str_ident()));
@ -225,7 +220,7 @@ where
debug_assert!(converted_biome_palette.len() > 1); debug_assert!(converted_biome_palette.len() > 1);
let Some(Value::LongArray(data)) = biomes.get("data") else { let Some(Value::LongArray(data)) = biomes.get("data") else {
return Err(ToValenceError::MissingBiomeData) return Err(ParseChunkError::MissingBiomeData)
}; };
let bits_per_idx = bit_width(converted_biome_palette.len() - 1); let bits_per_idx = bit_width(converted_biome_palette.len() - 1);
@ -234,7 +229,7 @@ where
let mask = 2_u64.pow(bits_per_idx as u32) - 1; let mask = 2_u64.pow(bits_per_idx as u32) - 1;
if long_count != data.len() { if long_count != data.len() {
return Err(ToValenceError::BadBiomeLongCount); return Err(ParseChunkError::BadBiomeLongCount);
}; };
let mut i = 0; let mut i = 0;
@ -249,7 +244,7 @@ where
let idx = (u64 >> (bits_per_idx * j)) & mask; let idx = (u64 >> (bits_per_idx * j)) & mask;
let Some(biome) = converted_biome_palette.get(idx as usize).cloned() else { let Some(biome) = converted_biome_palette.get(idx as usize).cloned() else {
return Err(ToValenceError::BadBiomePaletteIndex) return Err(ParseChunkError::BadBiomePaletteIndex)
}; };
let x = i % 4; let x = i % 4;
@ -265,41 +260,41 @@ where
} }
let Some(Value::List(block_entities)) = nbt.get("block_entities") else { let Some(Value::List(block_entities)) = nbt.get("block_entities") else {
return Err(ToValenceError::MissingBlockEntity); return Err(ParseChunkError::MissingBlockEntity);
}; };
if let List::Compound(block_entities) = block_entities { if let List::Compound(block_entities) = block_entities {
for comp in block_entities { for comp in block_entities {
let Some(Value::String(ident)) = comp.get("id") else { let Some(Value::String(ident)) = comp.get("id") else {
return Err(ToValenceError::MissingBlockEntityIdent); return Err(ParseChunkError::MissingBlockEntityIdent);
}; };
let Ok(ident) = Ident::new(&ident[..]) else { let Ok(ident) = Ident::new(&ident[..]) else {
return Err(ToValenceError::UnknownBlockEntityIdent(ident.clone())); return Err(ParseChunkError::UnknownBlockEntityIdent(ident.clone()));
}; };
let Some(kind) = BlockEntityKind::from_ident(ident.as_str_ident()) else { let Some(kind) = BlockEntityKind::from_ident(ident.as_str_ident()) else {
return Err(ToValenceError::UnknownBlockEntityIdent(ident.as_str().to_string())); return Err(ParseChunkError::UnknownBlockEntityIdent(ident.as_str().to_string()));
}; };
let block_entity = BlockEntity { let block_entity = BlockEntity {
kind, kind,
nbt: comp.clone(), nbt: comp.clone(),
}; };
let Some(Value::Int(x)) = comp.get("x") else { let Some(Value::Int(x)) = comp.get("x") else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
let Ok(x) = usize::try_from(x.mod_floor(&16)) else { let Ok(x) = usize::try_from(x.mod_floor(&16)) else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
let Some(Value::Int(y)) = comp.get("y") else { let Some(Value::Int(y)) = comp.get("y") else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
let Ok(y) = usize::try_from(y + sect_offset * 16) else { let Ok(y) = usize::try_from(y + sect_offset * 16) else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
let Some(Value::Int(z)) = comp.get("z") else { let Some(Value::Int(z)) = comp.get("z") else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
let Ok(z) = usize::try_from(z.mod_floor(&16)) else { let Ok(z) = usize::try_from(z.mod_floor(&16)) else {
return Err(ToValenceError::InvalidBlockEntityPosition); return Err(ParseChunkError::InvalidBlockEntityPosition);
}; };
chunk.set_block_entity(x, y, z, block_entity); chunk.set_block_entity(x, y, z, block_entity);

View file

@ -72,7 +72,6 @@ use valence_registry::tags::TagsRegistry;
use valence_registry::RegistrySet; use valence_registry::RegistrySet;
pub mod action; pub mod action;
pub mod chat;
pub mod command; pub mod command;
pub mod custom_payload; pub mod custom_payload;
pub mod event_loop; pub mod event_loop;
@ -81,6 +80,7 @@ pub mod interact_block;
pub mod interact_entity; pub mod interact_entity;
pub mod interact_item; pub mod interact_item;
pub mod keepalive; pub mod keepalive;
pub mod message;
pub mod movement; pub mod movement;
pub mod op_level; pub mod op_level;
pub mod packet; pub mod packet;
@ -107,8 +107,10 @@ pub struct FlushPacketsSet;
pub struct SpawnClientsSet; pub struct SpawnClientsSet;
/// The system set where various facets of the client are updated. Systems that
/// modify chunks should run _before_ this.
#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)] #[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
struct UpdateClientsSet; pub struct UpdateClientsSet;
impl Plugin for ClientPlugin { impl Plugin for ClientPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
@ -150,7 +152,7 @@ impl Plugin for ClientPlugin {
action::build(app); action::build(app);
teleport::build(app); teleport::build(app);
weather::build(app); weather::build(app);
chat::build(app); message::build(app);
custom_payload::build(app); custom_payload::build(app);
hand_swing::build(app); hand_swing::build(app);
interact_block::build(app); interact_block::build(app);

View file

@ -7,7 +7,6 @@ use valence_core::protocol::packet::chat::{ChatMessageC2s, GameMessageS2c};
use valence_core::text::Text; use valence_core::text::Text;
use crate::event_loop::{EventLoopSchedule, EventLoopSet, PacketEvent}; use crate::event_loop::{EventLoopSchedule, EventLoopSet, PacketEvent};
use crate::Client;
pub(super) fn build(app: &mut App) { pub(super) fn build(app: &mut App) {
app.add_event::<ChatMessageEvent>().add_system( app.add_event::<ChatMessageEvent>().add_system(
@ -17,22 +16,34 @@ pub(super) fn build(app: &mut App) {
); );
} }
#[derive(Clone, Debug)] pub trait SendMessage {
pub struct ChatMessageEvent { /// Sends a system message visible in the chat.
pub client: Entity, fn send_chat_message(&mut self, msg: impl Into<Text>);
pub message: Box<str>, /// Displays a message in the player's action bar (text above the hotbar).
pub timestamp: u64, fn send_action_bar_message(&mut self, msg: impl Into<Text>);
} }
impl Client { impl<T: WritePacket> SendMessage for T {
/// Sends a system message to the player which is visible in the chat. The fn send_chat_message(&mut self, msg: impl Into<Text>) {
/// message is only visible to this client.
pub fn send_message(&mut self, msg: impl Into<Text>) {
self.write_packet(&GameMessageS2c { self.write_packet(&GameMessageS2c {
chat: msg.into().into(), chat: msg.into().into(),
overlay: false, overlay: false,
}); });
} }
fn send_action_bar_message(&mut self, msg: impl Into<Text>) {
self.write_packet(&GameMessageS2c {
chat: msg.into().into(),
overlay: true,
});
}
}
#[derive(Clone, Debug)]
pub struct ChatMessageEvent {
pub client: Entity,
pub message: Box<str>,
pub timestamp: u64,
} }
pub fn handle_chat_message( pub fn handle_chat_message(

View file

@ -64,7 +64,8 @@ mod paletted_container;
pub struct InstancePlugin; pub struct InstancePlugin;
/// When Minecraft entity changes are written to the packet buffers of chunks. /// When Minecraft entity changes are written to the packet buffers of chunks.
/// Systems that read from the packet buffer of chunks should run _after_ this. /// Systems that modify entites should run _before_ this. Systems that read from
/// the packet buffer of chunks should run _after_ this.
#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)] #[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct WriteUpdatePacketsToInstancesSet; pub struct WriteUpdatePacketsToInstancesSet;

View file

@ -45,7 +45,8 @@
//! ## Access other world border properties. //! ## Access other world border properties.
//! Access to the rest of the world border properties is fairly straightforward //! Access to the rest of the world border properties is fairly straightforward
//! by querying their respective component. [`WorldBorderBundle`] contains //! by querying their respective component. [`WorldBorderBundle`] contains
//! references for all properties of the world border and their respective component //! references for all properties of the world border and their respective
//! component
#![allow(clippy::type_complexity)] #![allow(clippy::type_complexity)]
#![deny( #![deny(
rustdoc::broken_intra_doc_links, rustdoc::broken_intra_doc_links,