Merge upstream main into the Setup PR

Re-export list conflict in client_lib.rs: main's list (json_folder) plus split_map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-01 22:13:41 -05:00
commit afe39ff137
8 files changed
+213 -91

No files matched your search

+24 -18
View File
@@ -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<String>,
}
/// 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::<Value>(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::<Value>(&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") {
@@ -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<Box<[u8]>, (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<File>,
}
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
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<Arc<Folder>> {
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<Arc<Folder>> {
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<Vec<(String, PathBuf)>> {
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());
}
}
+1
View File
@@ -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;
+8 -8
View File
@@ -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;
+6 -48
View File
@@ -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<ItemStack> {
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<Vec<(String, PathBuf)>> {
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() {
+6
View File
@@ -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)
+7 -8
View File
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(file.bytes().ok()?).ok()?;
Some((format!("minecraft:{}", file.name), v))
})
.collect();
for (id, files) in overlay.kind("recipe") {
+7 -9
View File
@@ -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::<Value>(&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::<Value>(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