Reduce resource preparation filesystem work

Share the asset index and verification state, stage checked imports while hashing, preserve indexed replacements, and clone independent resource copies where supported.

Co-Authored-By: Codex (GPT-6.1) <noreply@openai.com>
This commit is contained in:
rdbtCVS
2026-10-25 22:58:00 +00:00
parent d58d26c793
commit 4be53ee28a
9 changed files with 637 additions and 142 deletions
Generated
+2
View File
@@ -1408,6 +1408,8 @@ dependencies = [
name = "horizon-prepare"
version = "0.1.0"
dependencies = [
"crc32fast",
"libc",
"regex",
"sha1",
"sha2",
+4
View File
@@ -6,11 +6,15 @@ license = "MIT"
description = "Prepares Horizon's resources from the player's own Minecraft: imports and checks the pinned jars, runs the vanilla exporters, writes the resource folder"
[dependencies]
crc32fast = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }
sha1 = "0.10"
sha2 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
libc = "0.2"
[build-dependencies]
sha2 = "0.10"
+35 -43
View File
@@ -143,31 +143,17 @@ pub(crate) fn import_copy(
algorithm: &str,
hint: &str,
) -> Result<PathBuf, String> {
fs::create_dir_all(destination.parent().unwrap()).map_err(|e| e.to_string())?;
if destination.exists() && digest(destination, algorithm)? == checksum {
return Ok(destination.to_path_buf());
}
if !source.is_file() {
return Err(format!("Missing {}. {hint}", source.display())
.trim()
.to_string());
}
let actual = digest(source, algorithm)?;
if actual != checksum {
return Err(format!(
"{} is not the pinned file: expected {algorithm} {checksum}, got {actual}",
source.display()
));
}
let temporary = destination.with_file_name(format!(
".{}.partial-{}",
destination.file_name().unwrap().to_string_lossy(),
std::process::id()
));
let copied = fs::copy(source, &temporary).and_then(|_| fs::rename(&temporary, destination));
let _ = fs::remove_file(&temporary);
copied.map_err(|e| format!("{}: {e}", destination.display()))?;
Ok(destination.to_path_buf())
crate::files::Import::stage(source, destination, checksum, algorithm, false)
.map_err(|error| {
if !source.is_file() {
format!("Missing {}. {hint}", source.display())
.trim()
.to_string()
} else {
error
}
})?
.commit()
}
/// The pinned version JSON from the launcher's versions/ directory. The launcher's copy wins
@@ -653,9 +639,17 @@ pub struct Artifacts {
/// Reuses checked imports from this root or another installed version beside it.
pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
Ok(reuse_verified(root, log)?.is_some())
}
/// Keep this preparation's verified artifacts and client libraries for the exporters.
pub(crate) fn reuse_verified(
root: &Path,
log: Log,
) -> Result<Option<(Artifacts, Vec<PathBuf>)>, String> {
let root = fs::canonicalize(root).map_err(|e| format!("{}: {e}", root.display()))?;
let lock = Lock::read(&root)?;
let checked = |directory: PathBuf| -> Result<Vec<PathBuf>, String> {
let checked = |directory: PathBuf| -> Result<(Artifacts, Vec<PathBuf>), String> {
let candidate = Lock {
json: lock.json.clone(),
version: lock.version.clone(),
@@ -664,17 +658,9 @@ pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
};
let found = verify(&candidate)?;
let client = client_libraries(&candidate, &found.manifest)?;
let mut files = vec![
candidate.directory.join("manifest.json"),
candidate.directory.join("jars/client.jar"),
candidate.directory.join("jars/server.jar"),
found.jars["server"].clone(),
];
files.extend(found.libraries);
files.extend(client);
Ok(files)
Ok((found, client))
};
if checked(lock.directory.clone()).is_ok() {
if let Ok((found, client_libraries)) = checked(lock.directory.clone()) {
if [
"prepared.json",
"reports/client-inventory.json",
@@ -683,8 +669,6 @@ pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
.iter()
.any(|path| !lock.directory.join(path).is_file())
{
let found = verify(&lock)?;
let client_libraries = client_libraries(&lock, &found.manifest)?;
write_import_metadata(
&root,
&lock,
@@ -696,10 +680,10 @@ pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
)?;
}
log(&format!("Using imported Minecraft {}", lock.version));
return Ok(true);
return Ok(Some((found, client_libraries)));
}
let Some(versions) = root.parent() else {
return Ok(false);
return Ok(None);
};
// Sibling discovery is intended for the launcher's versions/ folder.
let mut siblings: Vec<_> = fs::read_dir(versions)
@@ -711,9 +695,17 @@ pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
siblings.sort();
for sibling in siblings {
let source = sibling.join(".local/minecraft").join(&lock.version);
let Ok(files) = checked(source.clone()) else {
let Ok((found, client)) = checked(source.clone()) else {
continue;
};
let mut files = vec![
source.join("manifest.json"),
source.join("jars/client.jar"),
source.join("jars/server.jar"),
found.jars["server"].clone(),
];
files.extend(found.libraries);
files.extend(client);
for file in files {
let relative = file.strip_prefix(&source).map_err(|e| e.to_string())?;
import_copy(
@@ -740,9 +732,9 @@ pub fn reuse_imported(root: &Path, log: Log) -> Result<bool, String> {
lock.version,
sibling.display()
));
return Ok(true);
return Ok(Some((found, client_libraries)));
}
Ok(false)
Ok(None)
}
pub fn verify(lock: &Lock) -> Result<Artifacts, String> {
+180
View File
@@ -0,0 +1,180 @@
//! Independent copies, using filesystem cloning when available. Never hard links: exporters
//! rewrite prepared resources in place. Checked imports publish only after verifying the copy.
use crate::{digest, hex};
use sha2::Digest;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
fn clone_file(from: &Path, to: &Path) -> io::Result<()> {
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
let source = fs::File::open(from)?;
let target = fs::File::create(to)?;
// SAFETY: FICLONE takes a source fd, both descriptors stay open for the call.
if unsafe { libc::ioctl(target.as_raw_fd(), libc::FICLONE, source.as_raw_fd()) } == 0 {
return Ok(());
}
Err(io::Error::last_os_error())
}
#[cfg(target_os = "macos")]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let path = |p: &Path| {
CString::new(p.as_os_str().as_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
};
let (source, target) = (path(from)?, path(to)?);
// SAFETY: both paths are valid NUL-terminated strings for the duration of the call.
if unsafe { libc::clonefile(source.as_ptr(), target.as_ptr(), 0) } == 0 {
return Ok(());
}
Err(io::Error::last_os_error())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (from, to);
Err(io::ErrorKind::Unsupported.into())
}
}
/// Copies an independent file, attempting a reflink first. The destination may be replaced.
pub fn clone_or_copy(from: &Path, to: &Path) -> io::Result<()> {
clone_file(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
}
fn copy_hash<D: Digest>(from: &Path, to: &Path) -> io::Result<String> {
let mut source = fs::File::open(from)?;
let mut target = fs::File::create(to)?;
let mut hash = D::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let n = source.read(&mut buffer)?;
if n == 0 {
return Ok(hex(&hash.finalize()));
}
target.write_all(&buffer[..n])?;
hash.update(&buffer[..n]);
}
}
/// A verified replacement staged beside its destination. Dropping an unfinished set cleans
/// every temporary file, so a corrupt late panorama face cannot replace earlier placeholders.
pub(crate) struct Import {
temporary: Option<PathBuf>,
destination: PathBuf,
}
impl Import {
pub(crate) fn stage(
source: &Path,
destination: &Path,
checksum: &str,
algorithm: &str,
verified: bool,
) -> Result<Self, String> {
let mut import = Self {
temporary: None,
destination: destination.into(),
};
if verified || (destination.is_file() && digest(destination, algorithm)? == checksum) {
return Ok(import);
}
fs::create_dir_all(destination.parent().unwrap()).map_err(|e| e.to_string())?;
static COPIES: AtomicUsize = AtomicUsize::new(0);
let temporary = destination.with_file_name(format!(
".{}.partial-{}-{}",
destination.file_name().unwrap().to_string_lossy(),
std::process::id(),
COPIES.fetch_add(1, Ordering::Relaxed)
));
import.temporary = Some(temporary.clone());
let actual = if clone_file(source, &temporary).is_ok() {
digest(&temporary, algorithm)?
} else {
match algorithm {
"sha1" => copy_hash::<sha1::Sha1>(source, &temporary),
_ => copy_hash::<sha2::Sha256>(source, &temporary),
}
.map_err(|e| format!("{}: {e}", source.display()))?
};
if actual != checksum {
return Err(format!(
"{} is not the pinned file: expected {algorithm} {checksum}, got {actual}",
source.display()
));
}
Ok(import)
}
pub(crate) fn commit(mut self) -> Result<PathBuf, String> {
if let Some(temporary) = &self.temporary {
fs::rename(temporary, &self.destination)
.map_err(|e| format!("{}: {e}", self.destination.display()))?;
}
self.temporary = None;
Ok(self.destination.clone())
}
}
impl Drop for Import {
fn drop(&mut self) {
if let Some(temporary) = &self.temporary {
let _ = fs::remove_file(temporary);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copies_are_independent_and_checked_before_publication() {
let dir = std::env::temp_dir().join(format!("horizon-file-copy-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let source = dir.join("source");
let target = dir.join("target");
let data = vec![42; 200_000]; // More than one streaming buffer.
fs::write(&source, &data).unwrap();
for algorithm in ["sha1", "sha256"] {
let checksum = digest(&source, algorithm).unwrap();
Import::stage(&source, &target, &checksum, algorithm, false)
.unwrap()
.commit()
.unwrap();
assert_eq!(digest(&target, algorithm).unwrap(), checksum);
fs::write(&target, b"old destination").unwrap();
assert_eq!(fs::read(&source).unwrap(), data);
assert!(Import::stage(&source, &target, "bad hash", algorithm, false).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old destination");
// Explicitly exercise ordinary streaming copies even on a reflink filesystem.
let streamed = if algorithm == "sha1" {
copy_hash::<sha1::Sha1>(&source, &target).unwrap()
} else {
copy_hash::<sha2::Sha256>(&source, &target).unwrap()
};
assert_eq!(streamed, checksum);
assert_eq!(fs::read(&target).unwrap(), data);
}
clone_or_copy(&source, &target).unwrap();
fs::write(&target, b"rewrite").unwrap();
assert_eq!(fs::read(&source).unwrap(), data);
let staged = Import::stage(
&source,
&target,
&digest(&source, "sha1").unwrap(),
"sha1",
false,
)
.unwrap();
assert_eq!(fs::read_dir(&dir).unwrap().count(), 3);
drop(staged);
assert_eq!(fs::read_dir(&dir).unwrap().count(), 2);
fs::remove_dir_all(dir).unwrap();
}
}
+75 -30
View File
@@ -11,12 +11,14 @@
//! run, keyed by the sha256 of their inputs and outputs recorded in the manifest, as before.
pub mod artifacts;
mod files;
mod inventory;
pub mod java;
pub mod pyjson;
mod sounds;
mod survival;
pub use files::clone_or_copy;
pub use sounds::{fonts, languages, packs, panorama, sounds, BUILT_IN_PACKS};
use pyjson::Value;
@@ -410,16 +412,28 @@ pub fn prepare(options: &Options, log: Log) -> Result<PathBuf, String> {
&artifacts::minecraft_dir(options.minecraft_dir.as_deref()).join("runtime"),
));
let jdk = java::find(lock.java_major, &homes)?;
if options.import && !artifacts::reuse_imported(root, log)? {
artifacts::import(
root,
&lock,
options.minecraft_dir.as_deref(),
options.server_jar.as_deref(),
log,
)?;
}
let found = artifacts::verify(&lock)?;
let reused = if options.import {
artifacts::reuse_verified(root, log)?
} else {
None
};
let (found, client_libraries) = match reused {
Some(checked) => checked,
None => {
if options.import {
artifacts::import(
root,
&lock,
options.minecraft_dir.as_deref(),
options.server_jar.as_deref(),
log,
)?;
}
let found = artifacts::verify(&lock)?;
let libraries = artifacts::client_libraries(&lock, &found.manifest)?;
(found, libraries)
}
};
let local = root.join(".local");
let resources = local.join("horizon-client").join(&lock.version);
mkdir(&resources)?;
@@ -445,7 +459,6 @@ pub fn prepare(options: &Options, log: Log) -> Result<PathBuf, String> {
} else {
Value::Object(Vec::new())
};
let client_libraries = artifacts::client_libraries(&lock, &found.manifest)?;
let run = Run {
root: root.clone(),
local,
@@ -679,25 +692,9 @@ fn steps(
// and every data-pack resource (worldgen, recipes, loot tables, tags), and
// the vanilla pack's icon (`pack.png`) for the pack screen.
(run.log)("Copying the client's assets and data");
let mut count = 0usize;
let mut client = open_zip(&run.client)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
for name in zip_names(&client) {
let wanted = name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png";
if !wanted || name.ends_with('/') {
continue;
}
let relative = member_path(&name, "Invalid resource path")?;
let data = zip_bytes(&mut client, &name)?;
write_if_changed(&root.join(relative), &data)?;
count += 1;
}
// The JAR holds only gray 1x1 panorama placeholders and an empty Unifont
// include, and none of the built-in packs. Import the indexed ones afterwards, including on reruns that just
// extracted the JAR again. They are optional when preparing from
// already-imported JARs.
sounds::optional_assets(repo, minecraft, run.log);
let mut indexed = sounds::Assets::load(repo, minecraft, run.log);
let (version, count) = extract_client_resources(&run.client, root, indexed.as_mut().ok())?;
sounds::optional_imports(indexed.as_mut().map_err(|e| e.as_str()), run.log);
// One file each, exported again when the exporter or the jar it reads changed.
let single = |what: &str,
@@ -931,6 +928,54 @@ fn steps(
)
}
/// Check existing JAR assets by their ZIP size/CRC without inflating every member again.
/// Indexed panorama/font/language replacements have stronger SHA-1 checks and take precedence.
fn extract_client_resources(
jar: &Path,
root: &Path,
mut indexed: Option<&mut sounds::Assets>,
) -> Result<(Value, usize), String> {
let mut client = open_zip(jar)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
let mut count = 0;
for name in zip_names(&client) {
if !(name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png")
|| name.ends_with('/')
{
continue;
}
let relative = member_path(&name, "Invalid resource path")?;
count += 1;
if let Some(assets) = &mut indexed {
if assets.imported(&name)? {
continue;
}
}
let target = root.join(relative);
let member = client.by_name(&name).map_err(|e| e.to_string())?;
let (size, crc) = (member.size(), member.crc32());
drop(member);
if target.is_file() && fs::metadata(&target).map_err(|e| e.to_string())?.len() == size {
let mut file = fs::File::open(&target).map_err(|e| e.to_string())?;
let mut hash = crc32fast::Hasher::new();
let mut buffer = [0; 16 * 1024];
loop {
let n = file.read(&mut buffer).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
hash.update(&buffer[..n]);
}
if hash.finalize() == crc {
continue;
}
}
mkdir(target.parent().unwrap())?;
write(&target, &zip_bytes(&mut client, &name)?)?;
}
Ok((version, count))
}
fn datagen_reports_missing(root: &Path) -> bool {
["items.json", "registries.json", "commands.json"]
.iter()
+294 -68
View File
@@ -5,7 +5,8 @@
use crate::artifacts::{self, Lock};
use crate::pyjson::Value;
use crate::{digest, read_json, Log};
use crate::{digest, Log};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
@@ -18,8 +19,9 @@ fn launcher_assets(
lock: &Lock,
manifest: &Value,
minecraft: &Path,
cached_index: &Path,
log: Log,
) -> Result<(PathBuf, PathBuf), String> {
) -> Result<(PathBuf, PathBuf, Vec<u8>), String> {
let assets = minecraft.join("assets");
let info = manifest
.get("assetIndex")
@@ -33,7 +35,10 @@ fn launcher_assets(
if artifacts::safe_relative(id).is_none_or(|p| p.components().count() != 1) {
return Err(format!("Unsafe asset index id {id:?}"));
}
let index = assets.join("indexes").join(format!("{id}.json"));
let mut index = assets.join("indexes").join(format!("{id}.json"));
if !index.is_file() && cached_index.is_file() {
index = cached_index.to_path_buf();
}
if !index.is_file() {
let version = manifest.get("id").and_then(Value::as_str).unwrap_or("");
return Err(format!(
@@ -42,7 +47,8 @@ fn launcher_assets(
artifacts::launcher_hint(version)
));
}
let checksum = digest(&index, "sha1")?;
let bytes = fs::read(&index).map_err(|e| format!("{}: {e}", index.display()))?;
let checksum = crate::hex(&<sha1::Sha1 as sha1::Digest>::digest(&bytes));
if checksum != sha1 && !lock.asset_index_accepted(&checksum) {
log(&format!(
"{} is an asset index Horizon does not know yet (sha1 {checksum}); Mojang may have \
@@ -50,7 +56,7 @@ fn launcher_assets(
index.display()
));
}
Ok((assets, index))
Ok((assets, index, bytes))
}
/// Where the launcher's asset store keeps the object with this SHA-1.
@@ -61,24 +67,6 @@ fn object_path(assets: &Path, checksum: &str) -> Result<PathBuf, String> {
Ok(assets.join("objects").join(&checksum[..2]).join(checksum))
}
/// One verified object from the launcher's asset store (tools/lab.py `launcher_object`).
fn launcher_object(assets: &Path, checksum: &str) -> Result<PathBuf, String> {
let path = object_path(assets, checksum)?;
if !path.is_file() {
return Err(format!(
"Missing asset object {}. Play the pinned version once in your launcher.",
path.display()
));
}
if digest(&path, "sha1")? != checksum {
return Err(format!(
"Corrupt asset object {}; let the launcher repair it",
path.display()
));
}
Ok(path)
}
/// Whether the game plays this asset: sounds.json and everything under sounds/.
fn is_sound(name: &str) -> bool {
name == "minecraft/sounds.json" || name.starts_with("minecraft/sounds/")
@@ -127,26 +115,56 @@ fn is_language(name: &str) -> bool {
}
/// Full preparation can run from cached JARs without an official launcher install.
pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
if let Err(error) = panorama(root, minecraft, log) {
log(&format!(
"Title panorama not imported: {error}; run horizon-prepare panorama later"
));
}
if let Err(error) = fonts(root, minecraft, log) {
log(&format!(
"Unifont glyphs not imported: {error}; run horizon-prepare fonts later"
));
}
if let Err(error) = packs(root, minecraft, log) {
log(&format!(
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
));
}
if let Err(error) = languages(root, minecraft, log) {
log(&format!(
"Languages not imported: {error}; run horizon-prepare languages later"
));
#[cfg(test)]
fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
optional_imports(
Assets::load(root, minecraft, log)
.as_mut()
.map_err(|e| e.as_str()),
log,
);
}
pub(crate) fn optional_imports(mut assets: Result<&mut Assets, &str>, log: Log) {
for (wanted, label, required, failure, command) in [
(
is_panorama as fn(&str) -> bool,
"Panorama",
PANORAMA.as_slice(),
"Title panorama",
"panorama",
),
(
is_font,
"Font",
UNIFONT.as_slice(),
"Unifont glyphs",
"fonts",
),
(
is_built_in_pack,
"Built-in pack",
BUILT_IN_PACKS.as_slice(),
"Programmer Art and High Contrast",
"packs",
),
(
is_language,
"Language",
LANGUAGES.as_slice(),
"Languages",
"languages",
),
] {
let result = match &mut assets {
Ok(assets) => assets.copy(wanted, label, required, log).map(|_| ()),
Err(error) => Err(error.to_string()),
};
if let Err(error) = result {
log(&format!(
"{failure} not imported: {error}; run horizon-prepare {command} later"
));
}
}
}
@@ -207,29 +225,98 @@ fn copy_assets(
label: &str,
required: &[&str],
) -> Result<PathBuf, String> {
let lock = Lock::read(root)?;
let minecraft = artifacts::minecraft_dir(minecraft);
let manifest = artifacts::import_manifest(&lock, &minecraft)?;
let (store, index) = launcher_assets(&lock, &manifest, &minecraft, log)?;
let id = manifest
.get("id")
.and_then(Value::as_str)
.ok_or("The version JSON has no id")?;
let target = root.join(".local/horizon-client").join(id);
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
fs::copy(&index, target.join("asset-index.json"))
.map_err(|e| format!("{}: {e}", target.join("asset-index.json").display()))?;
let objects = read_json(&index)?;
let objects = objects
.get("objects")
.and_then(Value::as_object)
.ok_or_else(|| format!("{} has no objects", index.display()))?;
Assets::load(root, minecraft, log)?.copy(wanted_asset, label, required, log)
}
/// A preparation's index and verified destinations. Never persisted: a later invocation
/// checks the bytes again, including when repairing a corrupt prepared file.
pub(crate) struct Assets {
store: PathBuf,
target: PathBuf,
objects: Vec<(String, Value)>,
verified: HashSet<PathBuf>,
}
impl Assets {
pub(crate) fn load(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<Self, String> {
let lock = Lock::read(root)?;
let minecraft = artifacts::minecraft_dir(minecraft);
let manifest = artifacts::import_manifest(&lock, &minecraft)?;
let target = root.join(".local/horizon-client").join(&lock.version);
let (store, index, bytes) = launcher_assets(
&lock,
&manifest,
&minecraft,
&target.join("asset-index.json"),
log,
)?;
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
let objects = crate::pyjson::loads(text)?;
let objects = objects
.get("objects")
.and_then(Value::as_object)
.ok_or_else(|| format!("{} has no objects", index.display()))?
.to_vec();
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
crate::write_if_changed(&target.join("asset-index.json"), &bytes)?;
Ok(Self {
store,
target,
objects,
verified: HashSet::new(),
})
}
/// Preserve indexed replacements instead of overwriting them with JAR placeholders.
pub(crate) fn imported(&mut self, name: &str) -> Result<bool, String> {
let Some(name) = name.strip_prefix("assets/") else {
return Ok(false);
};
if !(is_panorama(name) || is_font(name) || is_language(name)) {
return Ok(false);
}
let Some((_, info)) = self.objects.iter().rev().find(|(n, _)| n == name) else {
return Ok(false);
};
let Some(hash) = info.get("hash").and_then(Value::as_str) else {
return Ok(false);
};
let destination = self.target.join("assets").join(name);
if destination.is_file() && digest(&destination, "sha1")? == hash {
self.verified.insert(destination);
return Ok(true);
}
Ok(false)
}
fn copy(
&mut self,
wanted_asset: fn(&str) -> bool,
label: &str,
required: &[&str],
log: Log,
) -> Result<PathBuf, String> {
copy_indexed(self, wanted_asset, label, required, log)
}
}
fn copy_indexed(
assets: &mut Assets,
wanted_asset: fn(&str) -> bool,
label: &str,
required: &[&str],
log: Log,
) -> Result<PathBuf, String> {
let objects = &assets.objects;
// A repeated name keeps its first place and its last value, as a Python dict does.
let mut wanted: Vec<(&str, &Value)> = Vec::new();
let mut positions = HashMap::new();
for (name, info) in objects.iter().filter(|(name, _)| wanted_asset(name)) {
match wanted.iter_mut().find(|(n, _)| n == name) {
Some(entry) => entry.1 = info,
None => wanted.push((name, info)),
if let Some(&position) = positions.get(name.as_str()) {
wanted[position] = (name.as_str(), info);
} else {
positions.insert(name.as_str(), wanted.len());
wanted.push((name, info));
}
}
for name in required {
@@ -251,21 +338,45 @@ fn copy_assets(
.ok_or_else(|| format!("{name} has no size in the asset index"))?;
let relative = artifacts::safe_relative(name)
.ok_or_else(|| format!("Unsafe asset name {name:?} in the asset index"))?;
let source = launcher_object(&store, hash)?;
copies.push((source, target.join("assets").join(relative), hash));
let source = object_path(&assets.store, hash)?;
let destination = assets.target.join("assets").join(relative);
let import = crate::files::Import::stage(
&source,
&destination,
hash,
"sha1",
assets.verified.contains(&destination),
)
.map_err(|error| {
if !source.is_file() {
format!(
"Missing asset object {}. Play the pinned version once in your launcher.",
source.display()
)
} else if error.contains(" is not the pinned file:") {
format!(
"Corrupt asset object {}; let the launcher repair it: {error}",
source.display()
)
} else {
error
}
})?;
copies.push((import, destination));
total += size;
}
// Check the entire set before replacing any placeholders: a missing late face
// must not leave a mixture of real textures and gray faces in either entry point.
for (source, destination, hash) in copies {
artifacts::import_copy(&source, &destination, hash, "sha1", "")?;
for (import, destination) in copies {
import.commit()?;
assets.verified.insert(destination);
}
log(&format!(
"{label} assets ready: {} files, {:.1} MB",
wanted.len(),
total as f64 / 1e6
));
Ok(target)
Ok(assets.target.clone())
}
#[cfg(test)]
@@ -680,6 +791,7 @@ mod tests {
"minecraft/sounds.json is missing from the asset index"
);
fs::remove_file(&index).unwrap();
fs::remove_file(f.root.join(".local/horizon-client/9.9/asset-index.json")).unwrap();
let error = f.run().unwrap_err();
assert!(
error.starts_with("Missing ") && error.contains("play 9.9 once"),
@@ -687,6 +799,120 @@ mod tests {
);
}
#[test]
fn late_corruption_discards_all_staged_replacements() {
let f = Fixture::new("late-corruption");
let target = f.root.join(".local/horizon-client/9.9/assets");
for name in PANORAMA {
let path = target.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, b"placeholder").unwrap();
}
fs::write(f.object(FIRST_FACE + 6), b"bad overlay").unwrap();
assert!(panorama(&f.root, Some(&f.minecraft), &|_| {}).is_err());
for name in PANORAMA {
assert_eq!(fs::read(target.join(name)).unwrap(), b"placeholder");
}
assert_eq!(
fs::read_dir(target.join("minecraft/textures/gui/title/background"))
.unwrap()
.count(),
7
);
}
#[test]
fn one_index_snapshot_serves_every_import_and_reruns_repair_corruption() {
let f = Fixture::with_extras("snapshot", &PANORAMA, &[("pack.mcmeta", b"{}")]);
let mut assets = Assets::load(&f.root, Some(&f.minecraft), &|_| {}).unwrap();
fs::remove_file(f.minecraft.join("assets/indexes/7.json")).unwrap();
let lines = std::cell::RefCell::new(Vec::new());
optional_imports(Ok(&mut assets), &|line| {
lines.borrow_mut().push(line.to_string())
});
assert_eq!(lines.borrow().len(), 4);
assert!(lines
.borrow()
.iter()
.all(|line| line.contains("assets ready")));
assets
.copy(is_sound, "Sound", &["minecraft/sounds.json"], &|_| {})
.unwrap();
let sound = assets
.target
.join("assets/minecraft/sounds/ambient/cave1.ogg");
let time = fs::metadata(&sound).unwrap().modified().unwrap();
fs::remove_file(f.object(1)).unwrap();
f.run().unwrap(); // A verified destination works even without the source object/index.
assert_eq!(fs::metadata(&sound).unwrap().modified().unwrap(), time);
fs::write(&sound, b"corrupt").unwrap();
assert!(f.run().is_err()); // Verification never carries over to another invocation.
fs::write(f.object(1), &f.objects[1].1).unwrap();
f.run().unwrap();
assert_eq!(fs::read(sound).unwrap(), f.objects[1].1);
}
#[test]
fn jar_reruns_preserve_indexed_assets_and_repair_missing_or_corrupt_members() {
use std::io::Write;
let f = Fixture::with_extras(
"extract",
&PANORAMA,
&[
("pack.mcmeta", b"{}"),
("minecraft/lang/en_us.json", b"{\"indexed\":true}"),
],
);
optional_assets(&f.root, Some(&f.minecraft), &|_| {});
let target = f.root.join(".local/horizon-client/9.9");
let jar = f.root.join("client.jar");
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
for (name, data) in [
("version.json", "{\"world_version\": 1}"),
(
"assets/minecraft/textures/gui/title/background/panorama_0.png",
"gray",
),
("assets/minecraft/font/include/unifont.json", "{}"),
("assets/minecraft/lang/en_us.json", "{}"),
("data/minecraft/test.json", "data"),
("pack.png", "icon"),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap();
let face = target.join("assets").join(PANORAMA[0]);
let time = fs::metadata(&face).unwrap().modified().unwrap();
// Preparing from cached JARs must also preserve full imports without the launcher.
fs::remove_dir_all(f.minecraft.join("assets")).unwrap();
for _ in 0..2 {
let mut assets = Assets::load(&f.root, Some(&f.minecraft), &|_| {}).unwrap();
let (_, count) =
crate::extract_client_resources(&jar, &target, Some(&mut assets)).unwrap();
assert_eq!(count, 5);
optional_imports(Ok(&mut assets), &|_| {});
assert_eq!(fs::read(&face).unwrap(), PANORAMA[0].as_bytes());
assert_eq!(fs::metadata(&face).unwrap().modified().unwrap(), time);
assert_eq!(
fs::read(target.join("assets").join(UNIFONT[0])).unwrap(),
f.objects[4].1
);
assert_eq!(
fs::read(target.join("assets/minecraft/lang/en_us.json")).unwrap(),
b"{\"indexed\":true}"
);
assert_eq!(
fs::read(target.join("data/minecraft/test.json")).unwrap(),
b"data"
);
assert_eq!(fs::read(target.join("pack.png")).unwrap(), b"icon");
fs::write(target.join("data/minecraft/test.json"), b"oops").unwrap();
fs::remove_file(target.join("pack.png")).unwrap();
}
}
#[test]
fn object_paths_come_from_real_hashes() {
let store = Path::new("assets");
+44
View File
@@ -0,0 +1,44 @@
# Resource preparation and filesystem work
The reported preparation timings put sounds at 20–29 seconds with little CPU use, versus
0.9–1.4 seconds for fresh client asset extraction. Prefer fewer file opens, reads and writes
over parallelizing decompression.
Preparation now shares one parsed asset index between panorama, font, pack and language imports.
Its destination verification results live only for that preparation. A subsequent invocation
checks bytes again, so it can repair corrupt resources. Existing valid destination files do not
require the original asset objects to remain installed. A saved asset index also lets preparation
preserve full imports when only cached Minecraft JARs remain.
New imports are staged beside their destination. On Linux, FICLONE is attempted first; other
filesystems fall back to copying while hashing. Cloned copies are hashed before publication.
macOS attempts clonefile with the same checked streaming fallback. A whole asset group is staged and
verified before any replacement is published; corrupt late panorama faces leave placeholders
alone. Temporary files are cleaned up on errors. This is not a transaction for rename failures
or process termination during publication: a later run repairs incomplete groups.
Unchanged JAR resources are checked by the ZIP member's size and CRC, avoiding repeated
decompression. Valid indexed replacements take precedence over JAR placeholders. Reusing checked
Minecraft imports also passes the verified artifact/library results directly to the exporters.
## An immutable store across releases
The official launcher's SHA-1 object store already provides an immutable source for indexed
assets. Independent filesystem clones let installed Horizon releases share physical blocks
where supported, without sharing writable inodes. Ordinary copies remain the fallback.
A separate Horizon store could additionally share complete preparations on filesystems without
cloning. Its key would need the pinned client/server hashes, the preparer's output version (or
the legacy source/exporter fingerprint), and the actual asset-index digest: Mojang republishes
translations without changing the Minecraft release. Two preparations with different optional
asset availability must not claim the same complete snapshot.
Before adopting that store, separate immutable runtime resources from mutable exporter scratch,
classes and logs. Publish snapshots through a temporary directory and atomic rename, give each
release an explicit reference, and define locking, corruption repair, migration and garbage
collection. Exporters must never receive a shared writable directory or hard-linked files.
The existing launcher compatibility check should choose reuse; the store should only supply its
bytes. Keep this separate from the open work on output-version compatibility (#1946/#1947).
For now, retain independent release directories and clone their resource files. This reduces
copying where the filesystem supports it without introducing a second ownership/cleanup scheme.
+2
View File
@@ -2726,6 +2726,8 @@ dependencies = [
name = "horizon-prepare"
version = "0.1.0"
dependencies = [
"crc32fast",
"libc",
"regex",
"sha1",
"sha2 0.10.9",
+1 -1
View File
@@ -719,7 +719,7 @@ fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
if entry.file_type()?.is_dir() {
copy_dir(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), target)?;
horizon_prepare::clone_or_copy(&entry.path(), &target)?;
}
}
Ok(())