Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 b7a6df3a8b Block states: facts.json reads properties straight into shared strings
The registry's states were read into a BTreeMap of owned strings, then
shared: two allocations and a hash per string, 50 ms of the start. The
facts.json decoder now shares each name and value as it reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:38 +01:00
Ludovic MarieandClaude Opus 5.5 5cc12e9973 Block states: a palette entry's properties collect in one pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:38 +01:00
Ludovic Marie d1a54c150a bench: heap counting only with SP2_COUNT 2026-11-01 10:58:32 +01:00
Ludovic Marie 4f22b0bc66 bench: heap per core table (SP2_SERIAL) 2026-11-01 10:58:32 +01:00
Ludovic Marie 28c6e890ac bench: stage timestamps for the dedicated start 2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 8babf37afd bench: per-loader peak and kept bytes (SP2_SERIAL)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 e1e5fac7cb bench: release after start (SP2_RELEASE)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 027abe75ed bench: live heap bytes per load stage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
11 changed files with 300 additions and 91 deletions

No files matched your search

Generated
+1
View File
@@ -1658,6 +1658,7 @@ version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"log",
"mimalloc",
"windows-sys 0.61.2",
]
+86 -55
View File
@@ -11247,6 +11247,7 @@ impl Game<'_> {
/// integrated-server selection. Server startup must use this entry point.
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
self.join_world_rest()?;
log::info!("{}", horizon_threads::mem_line("world_rest_joined"));
self.perf.world_opening();
let prepared = request.prepare(
&self.res,
@@ -11255,6 +11256,7 @@ impl Game<'_> {
&self.settings,
&horizon_world::datafix::progress::UpgradeProgress::new(),
)?;
log::info!("{}", horizon_threads::mem_line("world_prepared"));
self.finish_open_world(prepared)
}
@@ -17997,7 +17999,8 @@ fn load_core(
parallel: bool,
progress: &Progress,
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
let assets = match Assets::load(root) {
let parallel = parallel && std::env::var_os("SP2_SERIAL").is_none();
let assets = match horizon_threads::mem_step("core_assets", || Assets::load(root)) {
Ok(assets) => assets,
Err(e) => return Ok(Err(e.to_string())),
};
@@ -18007,20 +18010,23 @@ fn load_core(
std::thread::scope(|scope| {
let assets = &assets;
let render = Step::spawn(scope, parallel, move || {
// A server reads no block sprites' pixels, which only the
// renderer draws (32 MB).
let render = if headless {
assets.reload_for_server(root)
} else {
assets.reload(root)
}
.map_err(|e| e.to_string())?;
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
root,
render.model_library.clone(),
)
.map_err(|e| e.to_string());
Ok((render, item_renderer))
horizon_threads::mem_step("core_render", || {
// A server reads no block sprites' pixels, which only the
// renderer draws (32 MB).
let render = if headless {
assets.reload_for_server(root)
} else {
assets.reload(root)
}
.map_err(|e| e.to_string())?;
let item_renderer =
horizon_client::item_render::ItemRenderer::load_with_library(
root,
render.model_library.clone(),
)
.map_err(|e| e.to_string());
Ok((render, item_renderer))
})
});
// Nor the face visibility the mesher and the block entity
// renderer cull with (19 MB).
@@ -18031,13 +18037,16 @@ fn load_core(
models::load_visibility(root, registry).map_err(|e| e.to_string())
});
let biomes = Step::spawn(scope, parallel, || {
Biomes::load(root).map_err(|e| e.to_string())
horizon_threads::mem_step("core_biomes", || Biomes::load(root))
.map_err(|e| e.to_string())
});
let environment = Step::spawn(scope, parallel, || {
Environment::load(root).map_err(|e| e.to_string())
horizon_threads::mem_step("core_environment", || Environment::load(root))
.map_err(|e| e.to_string())
});
let collision = Step::spawn(scope, parallel, || {
collisions::load(root, registry).map_err(|e| e.to_string())
horizon_threads::mem_step("core_collision", || collisions::load(root, registry))
.map_err(|e| e.to_string())
});
let gameplay = gameplay_tables(root, assets, parallel, progress);
(
@@ -18075,13 +18084,18 @@ fn gameplay_tables(
parallel: bool,
progress: &Progress,
) -> std::result::Result<GameplayTables, String> {
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json")).map_err(|e| e.to_string())?,
))
.map_err(|e| e.to_string())?;
let (facts, gameplay_projection) =
let behaviour_json: serde_json::Value =
horizon_threads::mem_step("core_behaviour_json", || {
serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))
.map_err(|e| e.to_string())?,
))
.map_err(|e| e.to_string())
})?;
let (facts, gameplay_projection) = horizon_threads::mem_step("core_facts", || {
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json)
.map_err(|e| e.to_string())?;
})
.map_err(|e| e.to_string())?;
horizon_client::block_contact::set_mod_blocks(
facts
.states
@@ -18091,10 +18105,15 @@ fn gameplay_tables(
progress.set(2);
let (light, motion) = std::thread::scope(|scope| {
let light = Step::spawn(scope, parallel, || {
LightTables::load(root, assets.registry, &facts).map_err(|e| e.to_string())
horizon_threads::mem_step("core_light", || {
LightTables::load(root, assets.registry, &facts)
})
.map_err(|e| e.to_string())
});
let motion =
MotionResources::load_with_facts(root, assets, &facts).map_err(|e| e.to_string());
let motion = horizon_threads::mem_step("core_motion", || {
MotionResources::load_with_facts(root, assets, &facts)
})
.map_err(|e| e.to_string());
(light.join(), motion)
});
Ok(GameplayTables {
@@ -18182,6 +18201,7 @@ fn load_resources(
root = vanilla_root.to_path_buf();
};
log::info!("load core tables: {:.2}s", started.elapsed().as_secs_f32());
log::info!("{}", horizon_threads::mem_line("core_tables"));
let CoreTables {
assets,
render,
@@ -18216,6 +18236,7 @@ fn load_resources(
Arc::new(light),
);
log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32());
log::info!("{}", horizon_threads::mem_line("pack_tables"));
progress.set(4);
if !menu {
horizon_client::ui::load_metadata(&root);
@@ -18346,11 +18367,17 @@ fn load_world_rest(
// Independent tables load together, within the worker budget the settings
// allow. Only the recipes and the stations read another table: the items.
let load_items = || -> Result<_> {
let items = Items::load(root, registry, facts, overlay)?;
let items = horizon_threads::mem_step("items_only", || {
Items::load(root, registry, facts, overlay)
})?;
log::info!("load items: {:.2}s", started.elapsed().as_secs_f32());
log::info!("{}", horizon_threads::mem_line("items"));
progress.set(7);
let recipes = Recipes::load(root, &items, overlay)?;
let stations = horizon_client::stations::StationData::load(root, &items, overlay);
let recipes =
horizon_threads::mem_step("recipes", || Recipes::load(root, &items, overlay))?;
let stations = horizon_threads::mem_step("stations", || {
horizon_client::stations::StationData::load(root, &items, overlay)
});
Ok((items, recipes, stations))
};
let load_behaviours =
@@ -18397,33 +18424,37 @@ fn load_world_rest(
) -> std::result::Result<T, String> {
result.map_err(|e| e.to_string())
}
let (items, behaviours, block_entity_render, animate, mobs, transformers) = if threads > 1 {
std::thread::scope(|scope| {
let items = scope.spawn(|| text(load_items()));
let behaviours = scope.spawn(|| text(load_behaviours()));
let block_entity_render = scope.spawn(|| text(load_block_entity_render()));
let animate = scope.spawn(|| text(load_animate()));
let mobs = scope.spawn(|| text(load_mobs()));
let transformers = text(load_transformers());
let serial = std::env::var_os("SP2_SERIAL").is_some();
let (items, behaviours, block_entity_render, animate, mobs, transformers) =
if threads > 1 && !serial {
std::thread::scope(|scope| {
let items = scope.spawn(|| text(load_items()));
let behaviours = scope.spawn(|| text(load_behaviours()));
let block_entity_render = scope.spawn(|| text(load_block_entity_render()));
let animate = scope.spawn(|| text(load_animate()));
let mobs = scope.spawn(|| text(load_mobs()));
let transformers = text(load_transformers());
(
items.join().unwrap(),
behaviours.join().unwrap(),
block_entity_render.join().unwrap(),
animate.join().unwrap(),
mobs.join().unwrap(),
transformers,
)
})
} else {
(
items.join().unwrap(),
behaviours.join().unwrap(),
block_entity_render.join().unwrap(),
animate.join().unwrap(),
mobs.join().unwrap(),
transformers,
horizon_threads::mem_step("rest_items", || text(load_items())),
horizon_threads::mem_step("rest_behaviours", || text(load_behaviours())),
horizon_threads::mem_step("rest_block_entity_render", || {
text(load_block_entity_render())
}),
horizon_threads::mem_step("rest_animate", || text(load_animate())),
horizon_threads::mem_step("rest_mobs", || text(load_mobs())),
horizon_threads::mem_step("rest_transformers", || text(load_transformers())),
)
})
} else {
(
text(load_items()),
text(load_behaviours()),
text(load_block_entity_render()),
text(load_animate()),
text(load_mobs()),
text(load_transformers()),
)
};
};
let (items, recipes, stations) = items?;
let (animate, fog) = animate?;
let (mob_types, mob_data) = mobs?;
@@ -768,6 +768,7 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
self.server_lists = Some(lists.clone());
log("INFO", &format!("Preparing level \"{level_name}\""));
log::info!("{}", horizon_threads::mem_line("preparing"));
let folder = world_dir.to_string_lossy().into_owned();
// MinecraftServer.loadLevel's start: nothing is loaded yet.
self.native_server_about_to_start(true);
@@ -781,6 +782,7 @@ impl Game<'_> {
game_mode == 1,
None,
)?)?;
log::info!("{}", horizon_threads::mem_line("load_world"));
if new_world {
self.level.hardcore = props.hardcore;
}
@@ -814,6 +816,7 @@ impl Game<'_> {
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
log::info!("{}", horizon_threads::mem_line("settle_spawn"));
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
let user = NameAndId {
@@ -922,6 +925,7 @@ impl Game<'_> {
log("WARN", "Perhaps a server is already running on that port?");
return Ok(());
}
log::info!("{}", horizon_threads::mem_line("lan_host"));
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
@@ -959,6 +963,21 @@ impl Game<'_> {
started.elapsed().as_secs_f64()
),
);
log::info!("{}", horizon_threads::mem_line("done"));
std::thread::spawn(|| {
for t in [10, 30] {
std::thread::sleep(std::time::Duration::from_secs(if t == 10 {
10
} else {
20
}));
log::info!("{}", horizon_threads::mem_line(&format!("idle{t}s")));
if t == 10 && std::env::var_os("SP2_RELEASE").is_some() {
horizon_threads::release_free_memory();
log::info!("{}", horizon_threads::mem_line("released"));
}
}
});
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
+6 -1
View File
@@ -5,7 +5,7 @@ mod game;
use horizon_client::{crash, logging};
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
static ALLOCATOR: horizon_threads::Counting = horizon_threads::Counting;
/// The mod API level this game serves, kept in the program for the launcher
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
@@ -13,6 +13,11 @@ static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
fn main() {
horizon_threads::T0.get_or_init(std::time::Instant::now);
horizon_threads::COUNT.store(
std::env::var_os("SP2_COUNT").is_some(),
std::sync::atomic::Ordering::Relaxed,
);
horizon_threads::configure_allocator();
std::hint::black_box(&MOD_API_LEVEL);
horizon_client::telemetry::mark_start();
+1
View File
@@ -684,6 +684,7 @@ pub fn shared_data_with(
let started = std::time::Instant::now();
let data = GeneratorData::load_with(dir, registry, overrides)?;
log::info!("Horizon: worldgen data loaded in {:?}", started.elapsed());
log::info!("{}", horizon_threads::mem_line("worldgen_data"));
for warning in data.warnings() {
log::warn!("Horizon: data pack worldgen: {warning}");
}
+1
View File
@@ -12,6 +12,7 @@ libmimalloc-sys = { version = "0.1.49", default-features = false, features = ["e
[target.'cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))'.dependencies]
libc = "0.2"
log = "0.4"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_WindowsAndMessaging"] }
+64
View File
@@ -492,3 +492,67 @@ mod junction_tests {
assert!(super::mount_point_buffer(&[0; 100]).is_ok());
}
}
/// Bench only: mimalloc with a count of the bytes held live.
pub struct Counting;
pub static LIVE: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(0);
pub static PEAK: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(0);
pub static COUNT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn grew(n: isize) {
if !COUNT.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
let now = LIVE.fetch_add(n, std::sync::atomic::Ordering::Relaxed) + n;
PEAK.fetch_max(now, std::sync::atomic::Ordering::Relaxed);
}
/// Bench only: run `f`, log its peak and kept live bytes.
pub fn mem_step<T>(name: &str, f: impl FnOnce() -> T) -> T {
use std::sync::atomic::Ordering::Relaxed;
let start = LIVE.load(Relaxed);
PEAK.store(start, Relaxed);
let began = std::time::Instant::now();
let out = f();
let took = began.elapsed().as_secs_f64() * 1000.0;
let (end, peak) = (LIVE.load(Relaxed), PEAK.load(Relaxed));
log::info!(
"memstep {name} {took:.0} ms peak +{:.1} MB kept +{:.1} MB",
(peak - start) as f64 / 1e6,
(end - start) as f64 / 1e6
);
out
}
unsafe impl std::alloc::GlobalAlloc for Counting {
unsafe fn alloc(&self, l: std::alloc::Layout) -> *mut u8 {
grew(l.size() as isize);
unsafe { mimalloc::MiMalloc.alloc(l) }
}
unsafe fn alloc_zeroed(&self, l: std::alloc::Layout) -> *mut u8 {
grew(l.size() as isize);
unsafe { mimalloc::MiMalloc.alloc_zeroed(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: std::alloc::Layout) {
if COUNT.load(std::sync::atomic::Ordering::Relaxed) {
LIVE.fetch_sub(l.size() as isize, std::sync::atomic::Ordering::Relaxed);
}
unsafe { mimalloc::MiMalloc.dealloc(p, l) }
}
unsafe fn realloc(&self, p: *mut u8, l: std::alloc::Layout, n: usize) -> *mut u8 {
grew(n as isize - l.size() as isize);
unsafe { mimalloc::MiMalloc.realloc(p, l, n) }
}
}
/// Bench only: `memdbg STAGE live MB rss MB`.
pub static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
pub fn mem_line(stage: &str) -> String {
let t = T0
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs_f64()
* 1000.0;
let rss = std::fs::read_to_string("/proc/self/statm")
.ok()
.and_then(|s| s.split_whitespace().nth(1)?.parse::<f64>().ok())
.map_or(0.0, |p| p * 4096.0 / 1e6);
let live = LIVE.load(std::sync::atomic::Ordering::Relaxed) as f64 / 1e6;
format!("memdbg {stage} t {t:.0} ms live {live:.1} MB rss {rss:.1} MB")
}
@@ -6,7 +6,7 @@
use std::{
borrow::Borrow,
cmp::Ordering,
collections::{BTreeMap, HashMap},
collections::BTreeMap,
fmt,
hash::{Hash, Hasher},
ops::Deref,
@@ -48,9 +48,9 @@ impl Text {
/// Strings shared by the states of one registry ([`BlockProperties::share`]).
#[derive(Default)]
pub struct SharedStrings(HashMap<String, Arc<String>>);
pub struct SharedStrings(horizon_core::fast_hash::FxHashMap<String, Arc<String>>);
impl SharedStrings {
fn get(&mut self, text: &str) -> Arc<String> {
pub fn get(&mut self, text: &str) -> Arc<String> {
if let Some(shared) = self.0.get(text) {
return shared.clone();
}
@@ -131,13 +131,26 @@ impl BlockProperties {
self.0.iter().map(|(_, v)| &**v)
}
/// These properties over the strings `strings` holds, which other states
/// share (a registry's states, built once).
/// share (a registry's states, built once). Strings already shared stay.
pub fn share(&mut self, strings: &mut SharedStrings) {
for (k, v) in self.0.iter_mut() {
*k = Text::Shared(strings.get(k));
*v = Text::Shared(strings.get(v));
for text in self.0.iter_mut().flat_map(|(k, v)| [k, v]) {
if let Text::Owned(owned) = text {
*text = Text::Shared(strings.get(owned));
}
}
}
/// Shared names and values, collected as a `BTreeMap` collects them.
pub fn from_shared(mut pairs: Vec<(Arc<String>, Arc<String>)>) -> Self {
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut kept: Vec<(Text, Text)> = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
match kept.last_mut() {
Some(last) if *last.0 == *k => last.1 = Text::Shared(v),
_ => kept.push((Text::Shared(k), Text::Shared(v))),
}
}
Self(kept.into_boxed_slice())
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (Text, Text)>);
+4 -4
View File
@@ -191,7 +191,7 @@ impl BlockState {
let Some(Tag::String(name)) = tag.get("id") else {
return Err(invalid("block state is missing id"));
};
let mut properties = BlockProperties::new();
let mut properties = Vec::new();
if let Some(tag) = tag.get("properties") {
let Tag::Compound(map) = tag else {
return Err(invalid("block properties are not a compound"));
@@ -200,15 +200,15 @@ impl BlockState {
let Tag::String(v) = v else {
return Err(invalid("block property is not a string"));
};
properties.insert(
properties.push((
k.to_string().map_err(|e| invalid(e.to_string()))?,
v.to_string().map_err(|e| invalid(e.to_string()))?,
);
));
}
}
Ok(Self {
name: name.to_string().map_err(|e| invalid(e.to_string()))?,
properties,
properties: properties.into_iter().collect(),
})
}
}
+3 -2
View File
@@ -75,8 +75,9 @@ impl BlockRegistry {
pub fn from_facts_json_with_flags(
reader: impl io::Read,
) -> io::Result<(Self, Vec<(bool, bool)>)> {
let facts: facts::Facts = serde_json::from_reader(io::BufReader::new(reader))
.map_err(|e| invalid(e.to_string()))?;
let facts: facts::Facts =
facts::with_shared_strings(|| serde_json::from_reader(io::BufReader::new(reader)))
.map_err(|e| invalid(e.to_string()))?;
let flags = facts.states.iter().map(|r| (r.air, r.fluid)).collect();
let states = facts.states.into_iter().map(|r| r.state).collect();
Ok((Self::from_states(states, facts.defaults)?, flags))
+95 -22
View File
@@ -1,11 +1,14 @@
//! The part of `facts.json` the block registry reads, deserialized as it
//! streams: each row's state (its fifth column) and the defaults.
use crate::chunk::BlockState;
use crate::{
block_state_properties::SharedStrings,
chunk::{BlockProperties, BlockState},
};
use serde::{
Deserialize, Deserializer,
de::{self, IgnoredAny, SeqAccess, Visitor},
de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor},
};
use std::{collections::BTreeMap, fmt};
use std::{cell::RefCell, collections::BTreeMap, fmt, sync::Arc};
#[derive(Deserialize)]
pub(super) struct Facts {
@@ -48,31 +51,101 @@ impl<'de> Deserialize<'de> for Row {
}
}
thread_local! {
/// The strings the states being read share (`with_shared_strings`).
static STRINGS: RefCell<Option<SharedStrings>> = const { RefCell::new(None) };
}
/// Runs `read` with the states it reads sharing their property strings.
pub(super) fn with_shared_strings<T>(read: impl FnOnce() -> T) -> T {
STRINGS.with(|s| *s.borrow_mut() = Some(SharedStrings::default()));
let out = read();
STRINGS.with(|s| s.borrow_mut().take());
out
}
/// A property name or value, shared with the other states' equal ones.
struct Shared(Arc<String>);
impl<'de> Deserialize<'de> for Shared {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SharedVisitor;
impl Visitor<'_> for SharedVisitor {
type Value = Shared;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a string")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<Shared, E> {
Ok(Shared(STRINGS.with(|s| match s.borrow_mut().as_mut() {
Some(strings) => strings.get(text),
None => Arc::new(text.to_owned()),
})))
}
}
deserializer.deserialize_str(SharedVisitor)
}
}
/// A state's `properties` object, read straight into shared strings.
struct Properties(BlockProperties);
impl<'de> Deserialize<'de> for Properties {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct PropertiesVisitor;
impl<'de> Visitor<'de> for PropertiesVisitor {
type Value = Properties;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("block state properties")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Properties, A::Error> {
let mut pairs = Vec::new();
while let Some((Shared(k), Shared(v))) = map.next_entry()? {
pairs.push((k, v));
}
Ok(Properties(BlockProperties::from_shared(pairs)))
}
}
deserializer.deserialize_map(PropertiesVisitor)
}
}
/// A state as BlockState::from_nbt reads it: a name, or `{"id", "properties"}`.
struct State(BlockState);
impl<'de> Deserialize<'de> for State {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Shape {
Name(String),
Full {
id: String,
#[serde(default)]
properties: BTreeMap<String, String>,
},
struct StateVisitor;
impl<'de> Visitor<'de> for StateVisitor {
type Value = State;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a block state name or {\"id\", \"properties\"}")
}
fn visit_str<E: de::Error>(self, name: &str) -> Result<State, E> {
Ok(State(BlockState {
name: name.to_owned(),
properties: BlockProperties::new(),
}))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<State, A::Error> {
let (mut id, mut properties) = (None, None);
while let Some(key) = map.next_key::<std::borrow::Cow<'de, str>>()? {
match &*key {
"id" if id.is_some() => return Err(de::Error::duplicate_field("id")),
"id" => id = Some(map.next_value::<String>()?),
"properties" if properties.is_some() => {
return Err(de::Error::duplicate_field("properties"));
}
"properties" => properties = Some(map.next_value::<Properties>()?.0),
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
Ok(State(BlockState {
name: id.ok_or_else(|| de::Error::missing_field("id"))?,
properties: properties.unwrap_or_default(),
}))
}
}
Ok(State(match Shape::deserialize(deserializer)? {
Shape::Name(name) => BlockState {
name,
properties: Default::default(),
},
Shape::Full { id, properties } => BlockState {
name: id,
properties: properties.into(),
},
}))
deserializer.deserialize_any(StateVisitor)
}
}