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 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-17 10:53:46 +02:00
parent ee104603dd
commit 1f1d5a5aa4
3 changed files with 75 additions and 7 deletions
+2 -3
View File
@@ -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
+67 -2
View File
@@ -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<String>,
/// 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<String, String>,
pub recipes_upgraded: usize,
pub loot_tables_upgraded: usize,
/// Worldgen and structure files (`data/<id>/worldgen/`, `structure/`).
@@ -291,6 +295,32 @@ fn place(name: &str, id: &str) -> Option<bool> {
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<u8>) -> Option<String> {
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");
+6 -2
View File
@@ -302,8 +302,12 @@ only): content 54, library 33, client-ui 26, performance 25, other 14, unknown 2
`carry` copies a jar's `assets/<id>/`, `data/<id>/`, `data/minecraft/tags/` and `data/c/tags/`
into `mods/<id>/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.