From bf1fe4344d6f13f0629e3ecdd09df5d9f101da46 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:47:53 +0100 Subject: [PATCH 1/2] Recipes: the start reads the recipe folder once, not once per table The crafting tables, the stations, the recipe book and brewing each listed and read every file under data/minecraft/recipe again: a start to the title opened each recipe file 7-8 times (14,581 opens for 2,043 files). json_folder::read lists and reads a folder once and shares the bytes with any reader while one holds them; the rest loader and /reload hold the recipe folder across the tables, so it is read once, and nothing is kept after. Each table still parses its own copy, with the same order, conditions and errors as before. Brewing alone (a server join) still reads only recipe/brewing. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/brewing.rs | 40 +++-- .../horizon-client-render/src/json_folder.rs | 155 ++++++++++++++++++ crates/horizon-client-render/src/lib.rs | 1 + crates/horizon-client/src/client_lib.rs | 16 +- crates/horizon-client/src/crafting.rs | 54 +----- crates/horizon-client/src/game.rs | 6 + crates/horizon-client/src/recipe_book.rs | 15 +- crates/horizon-client/src/stations.rs | 16 +- 8 files changed, 213 insertions(+), 90 deletions(-) create mode 100644 crates/horizon-client-render/src/json_folder.rs diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index 04580f760a..6895dccb7b 100644 --- a/crates/horizon-client-render/src/brewing.rs +++ b/crates/horizon-client-render/src/brewing.rs @@ -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..304aee83c3 --- /dev/null +++ b/crates/horizon-client-render/src/json_folder.rs @@ -0,0 +1,155 @@ +//! 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::{ + collections::HashMap, + 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 3bdc783883..516b8c33e9 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 c0546f2d60..61c05fb3a5 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_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_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 209d1b9b85..c377f2fa03 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -18991,6 +18991,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) => { @@ -19622,6 +19625,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 From 0bb31c7d423c54cbbaa1aeeb70ee7956cadb639f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:55:22 +0100 Subject: [PATCH 2/2] Recipes: drop the imports the shared folder read left unused Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/brewing.rs | 2 +- crates/horizon-client-render/src/json_folder.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index 6895dccb7b..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, diff --git a/crates/horizon-client-render/src/json_folder.rs b/crates/horizon-client-render/src/json_folder.rs index 304aee83c3..ea6ec46553 100644 --- a/crates/horizon-client-render/src/json_folder.rs +++ b/crates/horizon-client-render/src/json_folder.rs @@ -6,7 +6,6 @@ //! those bytes for as long as one of them holds the [`Folder`], then it is //! gone (nothing is kept past the load). use std::{ - collections::HashMap, fs, io, path::{Path, PathBuf}, sync::{Arc, Mutex, Weak},