diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index 04580f760a..4175f86b02 100644 --- a/crates/horizon-client-render/src/brewing.rs +++ b/crates/horizon-client-render/src/brewing.rs @@ -9,7 +9,7 @@ use crate::{ }; use horizon_world::loot::PackOverlay; use serde_json::Value; -use std::{collections::HashSet, fs, path::Path}; +use std::{collections::HashSet, path::Path}; pub use horizon_client_core::potions::{ BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects, @@ -158,6 +158,28 @@ pub struct Brewing { /// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`). pub inputs: HashSet, } +/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole +/// recipe folder's read when another table holds it (the start loads them +/// together), else read alone. A file that doesn't parse is left out. +fn brewing_sources(root: &Path) -> Vec<(String, Value)> { + let recipe = root.join("data/minecraft/recipe"); + let (folder, prefix) = match crate::json_folder::held(&recipe) { + Some(folder) => (folder, "brewing/"), + None => match crate::json_folder::read(&recipe.join("brewing")) { + Ok(folder) => (folder, ""), + Err(_) => return Vec::new(), + }, + }; + folder + .files + .iter() + .filter_map(|f| { + let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?; + let v = serde_json::from_slice::(f.bytes().ok()?).ok()?; + Some((format!("minecraft:brewing/{name}"), v)) + }) + .collect() +} impl Brewing { pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self { // NeoForge's load conditions: a file whose don't hold is silently @@ -172,23 +194,7 @@ impl Brewing { let conditions_hold = |v: &Value| { horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id)) }; - let mut sources: Vec<(String, Value)> = - fs::read_dir(root.join("data/minecraft/recipe/brewing")) - .into_iter() - .flatten() - .flatten() - .filter_map(|e| { - let bytes = fs::read(e.path()).ok()?; - let v = serde_json::from_slice::(&bytes).ok()?; - Some(( - format!( - "minecraft:brewing/{}", - e.file_name().to_string_lossy().strip_suffix(".json")? - ), - v, - )) - }) - .collect(); + let mut sources: Vec<(String, Value)> = brewing_sources(root); // The world's packs' brewing recipes over the game's own: the top // pack's file for each id replaces it (`SimpleJsonResourceReloadListener`). for (id, files) in overlay.kind("recipe") { diff --git a/crates/horizon-client-render/src/json_folder.rs b/crates/horizon-client-render/src/json_folder.rs new file mode 100644 index 0000000000..ea6ec46553 --- /dev/null +++ b/crates/horizon-client-render/src/json_folder.rs @@ -0,0 +1,154 @@ +//! A data folder's files read from the disk once while a load holds them. +//! Several tables are built from the same folder (the recipes: the crafting +//! tables, the stations, the recipe book, brewing), and each used to list and +//! read every file again: on a hard disk or under a virus scanner, each open +//! costs. The first reader lists and reads the folder; later readers share +//! those bytes for as long as one of them holds the [`Folder`], then it is +//! gone (nothing is kept past the load). +use std::{ + fs, io, + path::{Path, PathBuf}, + sync::{Arc, Mutex, Weak}, +}; + +/// One file: its path inside the folder without `.json` (`a/b`, as an id has +/// it), the file, and its bytes or the error reading it gave. +pub struct File { + pub name: String, + pub path: PathBuf, + bytes: Result, (io::ErrorKind, String)>, +} +impl File { + pub fn bytes(&self) -> io::Result<&[u8]> { + self.bytes + .as_deref() + .map_err(|(kind, message)| io::Error::new(*kind, message.clone())) + } +} + +/// Every `.json` file under a folder, its subfolders' too, in name order. +pub struct Folder { + pub files: Vec, +} + +static OPEN: Mutex)>> = Mutex::new(Vec::new()); + +fn find(open: &[(PathBuf, Weak)], dir: &Path) -> Option> { + open.iter() + .find(|(d, _)| d == dir) + .and_then(|(_, f)| f.upgrade()) +} + +/// `dir`'s files if another reader holds them, without reading anything. +pub fn held(dir: &Path) -> Option> { + find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir) +} + +/// The `.json` files under `dir`: shared with any other reader holding them, +/// else listed and read now. An error when `dir` itself can't be listed. +pub fn read(dir: &Path) -> io::Result> { + if let Some(folder) = held(dir) { + return Ok(folder); + } + let files = list(dir)? + .into_iter() + .map(|(name, path)| { + let bytes = fs::read(&path) + .map(Vec::into_boxed_slice) + .map_err(|e| (e.kind(), e.to_string())); + File { name, path, bytes } + }) + .collect(); + let folder = Arc::new(Folder { files }); + let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner()); + // Another reader may have read it meanwhile: share one copy. + if let Some(theirs) = find(&open, dir) { + return Ok(theirs); + } + open.retain(|(_, f)| f.strong_count() > 0); + open.push((dir.to_owned(), Arc::downgrade(&folder))); + Ok(folder) +} + +/// The `.json` files under `dir` and its subfolders, in name order: each +/// one's path inside the folder without the extension (`a/b`) and its file. +/// An error when `dir` itself can't be listed; a subfolder or an entry that +/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps +/// what it found. +pub fn list(dir: &Path) -> io::Result> { + let mut files = Vec::new(); + let mut stack = vec![fs::read_dir(dir)?]; + while let Some(entries) = stack.pop() { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display()); + continue; + } + }; + let path = entry.path(); + if path.is_dir() { + match fs::read_dir(&path) { + Ok(list) => stack.push(list), + Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()), + } + } else if path.extension().is_some_and(|e| e == "json") { + files.push(path); + } + } + } + let mut named: Vec<(String, PathBuf)> = files + .into_iter() + .map(|path| { + let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension(""); + let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect(); + (name.join("/"), path) + }) + .collect(); + // Vanilla lists resources by id, in a map keyed by it + // (`FallbackResourceManager`): `crushing` before `crushing/iron`, where + // a path's components put the subfolder's file first. + named.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(named) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("brewing")).unwrap(); + fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap(); + fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap(); + fs::write(dir.join("notes.txt"), b"not json").unwrap(); + dir + } + + #[test] + fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() { + let dir = folder("share"); + let first = read(&dir).unwrap(); + let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["brewing/awkward", "stick"]); + assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}"); + // Held: a change on the disk isn't seen, the copy read is shared. + fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap(); + let second = read(&dir).unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + drop((first, second)); + // Let go: the next reader reads the folder again. + assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}"); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_folder_that_cannot_be_listed_is_an_error() { + let dir = + std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id())); + assert!(read(&dir).is_err()); + } +} diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index d0586102f9..fbd86e37cc 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -49,6 +49,7 @@ pub mod hud_overlays; pub mod inventory_effects; pub mod java_text; pub mod jigsaw_block; +pub mod json_folder; pub mod level_loading; pub mod light; pub mod lod; diff --git a/crates/horizon-client/src/client_lib.rs b/crates/horizon-client/src/client_lib.rs index 6e09d386bf..a2d568f1d6 100644 --- a/crates/horizon-client/src/client_lib.rs +++ b/crates/horizon-client/src/client_lib.rs @@ -13,14 +13,14 @@ pub use horizon_client_render::{ chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, - inventory_effects, java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, - mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, native_emissive, - options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles, - poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, - sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine, - sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites, - streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, - title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip, + inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging, + menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, + native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, + particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, + screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins, + sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles, + sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, + testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip, }; pub mod attack_strength; pub mod attribute_updates; diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index cbf2247dc2..4e392ad6fa 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -14,8 +14,7 @@ use horizon_world::nbt::Tag; use serde_json::Value; use std::{ collections::{HashMap, HashSet}, - fs, - path::{Path, PathBuf}, + path::Path, sync::Arc, }; @@ -1208,48 +1207,6 @@ pub fn recipe_result(v: &Value) -> Option { let template = Template::parse(v)?; template.item.is_some().then(|| template.create()) } -/// The `.json` files under a `recipe` folder, its subfolders' too, in id -/// order: each one's path inside the folder without the extension (`a/b`, as a -/// recipe id has it) and its file. An error when `dir` itself can't be read; a -/// subfolder or an entry that can't be listed is warned and skipped, as -/// vanilla's `PathPackResources` keeps what it found. -pub(crate) fn recipe_files(dir: &Path) -> Result> { - let mut files = Vec::new(); - let mut stack = vec![fs::read_dir(dir)?]; - while let Some(entries) = stack.pop() { - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(e) => { - log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display()); - continue; - } - }; - let path = entry.path(); - if path.is_dir() { - match fs::read_dir(&path) { - Ok(list) => stack.push(list), - Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()), - } - } else if path.extension().is_some_and(|e| e == "json") { - files.push(path); - } - } - } - let mut named: Vec<(String, PathBuf)> = files - .into_iter() - .map(|path| { - let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension(""); - let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect(); - (name.join("/"), path) - }) - .collect(); - // Vanilla lists resources by id, in a map keyed by it - // (`FallbackResourceManager`): `crushing` before `crushing/iron`, where - // a path's components put the subfolder's file first. - named.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(named) -} /// The type of a recipe the game doesn't craft itself: one outside `minecraft`. /// A recipe's whole id from its name in the tables: vanilla's are kept bare /// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`). @@ -1372,14 +1329,14 @@ impl Recipes { } mods }; - for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? { - sources.push((name, fs::read(&p)?)); + for file in &crate::json_folder::read(&root.join("data/minecraft/recipe"))?.files { + sources.push((file.name.clone(), file.bytes()?.to_vec())); } for namespace in crate::mod_content::namespaces(root) { let dir = root.join(format!("data/{namespace}/recipe")); if dir.is_dir() { - for (name, p) in recipe_files(&dir)? { - sources.push((format!("{namespace}:{name}"), fs::read(&p)?)); + for file in &crate::json_folder::read(&dir)?.files { + sources.push((format!("{namespace}:{}", file.name), file.bytes()?.to_vec())); } } } @@ -1887,6 +1844,7 @@ impl Recipes { mod tests { use super::*; use crate::banner::BannerLayer; + use std::fs; #[test] fn result_count_is_bounded_like_vanilla() { diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9a020c6688..529ec151bc 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -18996,6 +18996,9 @@ impl Game<'_> { return None; } }; + // The recipe tables, the stations and the recipe book read one folder: + // hold its files so it is read once. + let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe")); let recipes = match Recipes::load(root, &items, &overlay) { Ok(recipes) => recipes, Err(e) => { @@ -19627,6 +19630,9 @@ fn load_world_rest( let items = startup::time("rest.items", || Items::load(root, registry, facts, overlay))?; log::info!("load items: {:.2}s", started.elapsed().as_secs_f32()); progress.set(7); + // The recipe tables, the stations and the recipe book read one folder: + // hold its files so it is read once. + let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe")); let recipes = startup::time("rest.recipes", || Recipes::load(root, &items, overlay))?; let stations = startup::time("rest.stations", || { horizon_client::stations::StationData::load(root, &items, overlay) diff --git a/crates/horizon-client/src/recipe_book.rs b/crates/horizon-client/src/recipe_book.rs index 027907d001..9fee1ad201 100644 --- a/crates/horizon-client/src/recipe_book.rs +++ b/crates/horizon-client/src/recipe_book.rs @@ -717,14 +717,13 @@ impl RecipeIndex { // The packs' files replace first and the gate answers the merged // file, as the crafting loader's does: a false-conditioned pack // file leaves the lower one gone too. - let mut sources: Vec<(String, serde_json::Value)> = crate::crafting::recipe_files(&dir) - .unwrap_or_default() - .into_iter() - .filter_map(|(name, path)| { - let v = std::fs::read(&path) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok())?; - Some((format!("minecraft:{name}"), v)) + let folder = crate::json_folder::read(&dir).ok(); + let mut sources: Vec<(String, serde_json::Value)> = folder + .iter() + .flat_map(|folder| &folder.files) + .filter_map(|file| { + let v = serde_json::from_slice::(file.bytes().ok()?).ok()?; + Some((format!("minecraft:{}", file.name), v)) }) .collect(); for (id, files) in overlay.kind("recipe") { diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index 7966ab559a..2b425aabcc 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -503,18 +503,16 @@ impl StationRecipes { }; for namespace in namespaces { let dir = root.join(format!("data/{namespace}/recipe")); - let more = crate::crafting::recipe_files(&dir).unwrap_or_default(); - files.extend( - more.into_iter() - .map(|(name, p)| (namespace.clone(), name, p)), - ); + if let Ok(folder) = crate::json_folder::read(&dir) { + files.push((namespace, folder)); + } } let mut sources: Vec<(String, Value)> = files .iter() - .filter_map(|(namespace, name, p)| { - let bytes = fs::read(p).ok()?; - let v = serde_json::from_slice::(&bytes).ok()?; - Some((format!("{namespace}:{name}"), v)) + .flat_map(|(namespace, folder)| folder.files.iter().map(move |f| (namespace, f))) + .filter_map(|(namespace, file)| { + let v = serde_json::from_slice::(file.bytes().ok()?).ok()?; + Some((format!("{namespace}:{}", file.name), v)) }) .collect(); // The world's packs' recipes over the game's own: the top pack's