From 1f1d5a5aa413e9d4c0f0171b934cd2d96b7f64ee Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:53:46 +0200 Subject: [PATCH] Carry: leave out the files the content loader would refuse Vanilla skips a resource with an invalid path, a data file that does not parse, one at a time; Horizon's content loader refuses the whole mod set. carry now keeps them in outside/ with the reason (report `unreadable`): upper-case paths, JSON that does not parse, shared tags with "replace": true. A byte-order mark is dropped. ATM10's 42 carried mods: 19 such files in 7 mods (Mekanism Tools' ry_UA lang, CTOV's empty tag, Towns and Towers' replacing tags, broken template pools, recipes and a lang file), and the 42 load as carried. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-port-pack/Cargo.toml | 5 +- crates/horizon-port-pack/src/carry.rs | 69 ++++++++++++++++++++++++++- docs/modpacks.md | 8 +++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/horizon-port-pack/Cargo.toml b/crates/horizon-port-pack/Cargo.toml index 3b903ad03..775a853a8 100644 --- a/crates/horizon-port-pack/Cargo.toml +++ b/crates/horizon-port-pack/Cargo.toml @@ -7,6 +7,8 @@ rust-version = "1.86" description = "Port a Java modpack to Horizon: import, inventory, classification and data carry-over (docs/modpacks.md)" [dependencies] +# Content path rules only (no runtime): what the game's content loader accepts. +horizon-mods = { path = "../horizon-mods", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.10" @@ -14,8 +16,5 @@ toml = "1.1.6" ureq = { version = "2", default-features = false, features = ["tls"] } zip = { version = "2", default-features = false, features = ["deflate"] } -[dev-dependencies] -horizon-mods = { path = "../horizon-mods", default-features = false } - [lints] workspace = true diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index a637d6246..dfe4d3dbc 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -19,6 +19,10 @@ pub struct Report { /// Files outside what a content mod may hold (other namespaces, vanilla /// data other than tags), kept in `outside/` for review. pub outside: Vec, + /// Files of the mod's own content the game would refuse, kept in + /// `outside/` too, with why: a path vanilla does not read (upper case), + /// JSON that does not parse, a vanilla tag the mod replaces. + pub unreadable: BTreeMap, pub recipes_upgraded: usize, pub loot_tables_upgraded: usize, /// Worldgen and structure files (`data//worldgen/`, `structure/`). @@ -291,6 +295,32 @@ fn place(name: &str, id: &str) -> Option { Some(ns == id || shared_tags) } +/// Why the game would refuse a file of the mod's own content, if it would. +/// Vanilla skips such files one by one (an invalid resource path, a data +/// file that does not parse); Horizon's content loader refuses the whole +/// set, so they stay out of the carry. +fn unreadable(name: &str, bytes: &mut Vec) -> Option { + if let Err(e) = horizon_mods::content::check_path(name) { + return Some(e); + } + if !(name.ends_with(".json") || name.ends_with(".mcmeta")) { + return None; + } + // A byte-order mark: Horizon's JSON readers take the file without it. + if bytes.starts_with(b"\xEF\xBB\xBF") { + bytes.drain(..3); + } + let value: Value = match serde_json::from_slice(bytes) { + Ok(v) => v, + Err(e) => return Some(format!("does not parse as JSON: {e}")), + }; + let shared_tag = name.starts_with("data/minecraft/tags/") || name.starts_with("data/c/tags/"); + if shared_tag && value.get("replace").and_then(Value::as_bool) == Some(true) { + return Some("replaces a shared tag; a content mod may only add to it".into()); + } + None +} + /// Carries `jar` over into `out/carry/`. With the oracle's `facts`, blocks /// and items are the ones the mod registers, with the templates and numbers /// the facts give; `allowed` says which vanilla blocks the game has. @@ -334,6 +364,15 @@ pub fn carry( entry .read_to_end(&mut bytes) .map_err(|e| format!("{name}: {e}"))?; + let unreadable = if keep { + unreadable(&name, &mut bytes) + } else { + None + }; + let keep = keep && unreadable.is_none(); + if let Some(why) = unreadable { + report.unreadable.insert(name.clone(), why); + } let dest = if keep { report.copied += 1; inside.join(&name) @@ -716,6 +755,17 @@ mod tests { r#"{"values": ["gem:gem_block"]}"#, ), ("data/c/tags/item/gems.json", r#"{"values": ["gem:gem"]}"#), + // What the game would refuse: vanilla skips these files alone. + ("assets/gem/lang/ry_UA.json", r#"{"item.gem.gem": "Gem"}"#), + ("data/gem/tags/item/empty.json", ""), + ( + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", + r#"{"replace": true, "values": []}"#, + ), + ( + "assets/gem/lang/de_de.json", + "\u{feff}{\"item.gem.gem\": \"Juwel\"}", + ), ]; for (name, text) in files { zip.start_file(*name, zip::write::SimpleFileOptions::default()) @@ -732,8 +782,23 @@ mod tests { assert_eq!(info.census.classes, 1); let out = dir.join("work/mods/gem"); let report = carry(&jar, &info, &out, None, &|_| true).unwrap(); - assert_eq!(report.copied, 12); - assert!(report.outside.is_empty()); + assert_eq!(report.copied, 13); + let unreadable: Vec<&str> = report.unreadable.keys().map(String::as_str).collect(); + assert_eq!( + unreadable, + [ + "assets/gem/lang/ry_UA.json", + "data/gem/tags/item/empty.json", + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", + ] + ); + assert_eq!(report.outside.len(), 3); + assert!(!out.join("carry/data/gem/tags/item/empty.json").exists()); + // A byte-order mark is dropped, the file kept. + assert_eq!( + fs::read(out.join("carry/assets/gem/lang/de_de.json")).unwrap()[0], + b'{' + ); assert_eq!((report.recipes_upgraded, report.recipes_kept.len()), (1, 1)); assert_eq!(report.item_definitions_added, 2); assert_eq!(report.blocks["gem_door"], "minecraft:oak_door"); diff --git a/docs/modpacks.md b/docs/modpacks.md index fb9bca5f4..a70bf03b7 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -302,8 +302,12 @@ only): content 54, library 33, client-ui 26, performance 25, other 14, unknown 2 `carry` copies a jar's `assets//`, `data//`, `data/minecraft/tags/` and `data/c/tags/` into `mods//carry/`, a folder content mod (API 0.2) the game loads with `--dev-mod`, and -keeps what a content mod may not hold (other namespaces, vanilla data) in `outside/`. No class -file is copied. It upgrades from 1.21.1 to 26.3: vanilla-type recipes (ingredients), loot tables +keeps what a content mod may not hold (other namespaces, vanilla data) in `outside/`. Files of +the mod's own content that the game's content loader would refuse whole also go to `outside/`, +listed with the reason in the report's `unreadable`: paths vanilla does not read (upper case), +JSON that does not parse, and shared tags with `"replace": true`. Vanilla skips them one by one. +A byte-order mark is dropped. For ATM10's 42 mods that is 19 files in 7 mods, and the 42 load as +carried. No class file is copied. It upgrades from 1.21.1 to 26.3: vanilla-type recipes (ingredients), loot tables (`conditions` lists become one `condition`, `functions` become `modifier`, kinds move to `type`, tool matches on silk touch or shears become `minecraft:tool/can_silk_touch` and `minecraft:tool/can_shear`), and the `items/` definitions 1.21.4 introduced.