Prepare: accept Mojang's republished 26.3 asset indexes (sounds, panorama)

Mojang republished the 26.3 version JSON again (sha1 61fff131), with a new
asset index 34 (0c8f43b2) that changes only 142 translation files. A launcher
keeps one indexes/34.json, so every install made since then, official
launcher or Prism, failed the "pinned asset index" check: no sounds and no
title panorama.

- research-lock.json: accept the new version JSON, and list the asset
  indexes of every accepted version JSON (accepted_asset_index_sha1).
- horizon-prepare: an index the lock does not know is still used, with a
  line in the log, since every object copied from it is checked against its
  own SHA-1; the sounds now require minecraft/sounds.json in the index.
- tools/lab.py: the same for the dev tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-21 17:32:00 +02:00
1 parent d128289ca6
commit 3ac4d26cd0
4 files changed
+108 -22

No files matched your search

+15 -5
View File
@@ -79,12 +79,22 @@ impl Lock {
/// Mojang republishes a version JSON for asset-index changes; every known one is accepted.
fn manifest_accepted(&self, checksum: &str) -> bool {
let game = self.json.get("minecraft");
self.pin("manifest_sha1").is_ok_and(|pin| pin == checksum)
|| game
.and_then(|g| g.get("accepted_manifest_sha1"))
.and_then(Value::as_array)
.is_some_and(|list| list.iter().any(|v| v.as_str() == Some(checksum)))
|| self.listed("accepted_manifest_sha1", checksum)
}
/// The asset indexes of those republished version JSONs: a launcher keeps one
/// `indexes/<id>.json`, the last it downloaded, whichever version JSON it reads.
pub(crate) fn asset_index_accepted(&self, checksum: &str) -> bool {
self.listed("accepted_asset_index_sha1", checksum)
}
fn listed(&self, key: &str, checksum: &str) -> bool {
self.json
.get("minecraft")
.and_then(|g| g.get(key))
.and_then(Value::as_array)
.is_some_and(|list| list.iter().any(|v| v.as_str() == Some(checksum)))
}
}
+77 -12
View File
@@ -9,9 +9,17 @@ use crate::{digest, read_json, Log};
use std::fs;
use std::path::{Path, PathBuf};
/// The launcher's asset directory and the pinned version's verified asset index
/// (tools/lab.py `launcher_assets`).
fn launcher_assets(manifest: &Value, minecraft: &Path) -> Result<(PathBuf, PathBuf), String> {
/// The launcher's asset directory and the pinned version's asset index (tools/lab.py
/// `launcher_assets`). Mojang republishes a version's index under the same id for translation
/// fixes, and a launcher keeps only the last one it downloaded (#1076): an index the lock does
/// not know is still used, with a line in the log, since every object copied from it is
/// checked against its own SHA-1.
fn launcher_assets(
lock: &Lock,
manifest: &Value,
minecraft: &Path,
log: Log,
) -> Result<(PathBuf, PathBuf), String> {
let assets = minecraft.join("assets");
let info = manifest
.get("assetIndex")
@@ -34,8 +42,13 @@ fn launcher_assets(manifest: &Value, minecraft: &Path) -> Result<(PathBuf, PathB
artifacts::launcher_hint(version)
));
}
if digest(&index, "sha1")? != sha1 {
return Err(format!("{} is not the pinned asset index", index.display()));
let checksum = digest(&index, "sha1")?;
if checksum != sha1 && !lock.asset_index_accepted(&checksum) {
log(&format!(
"{} is an asset index Horizon does not know yet (sha1 {checksum}); Mojang may have \
republished it. Using it: every file is still checked against its own SHA-1.",
index.display()
));
}
Ok((assets, index))
}
@@ -76,7 +89,14 @@ fn is_sound(name: &str) -> bool {
/// `.local/horizon-client/<version>`; returns that folder. Files already there with the right
/// SHA-1 are kept.
pub fn sounds(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
copy_assets(root, minecraft, log, is_sound, "Sound", &[])
copy_assets(
root,
minecraft,
log,
is_sound,
"Sound",
&["minecraft/sounds.json"],
)
}
/// Replaces the client JAR's 1x1 title placeholders with the full panorama from the
@@ -119,7 +139,7 @@ fn copy_assets(
let lock = Lock::read(root)?;
let minecraft = artifacts::minecraft_dir(minecraft);
let manifest = artifacts::import_manifest(&lock, &minecraft)?;
let (store, index) = launcher_assets(&manifest, &minecraft)?;
let (store, index) = launcher_assets(&lock, &manifest, &minecraft, log)?;
let id = manifest
.get("id")
.and_then(Value::as_str)
@@ -307,6 +327,51 @@ mod tests {
f.run().unwrap();
}
/// #1076: the launcher holds an index Mojang republished under the same id, so its SHA-1
/// is not the one in the version JSON. A known one is used quietly, an unknown one with a
/// line in the log; the sounds come through either way.
#[test]
fn republished_asset_indexes_still_give_sounds() {
let f = Fixture::new("republished");
let index = f.minecraft.join("assets/indexes/7.json");
let republished = format!("{}\n", fs::read_to_string(&index).unwrap());
fs::write(&index, &republished).unwrap();
let checksum = sha1_hex(republished.as_bytes());
let lock = f.root.join("research-lock.json");
let pinned = fs::read_to_string(&lock).unwrap();
let sound = f
.root
.join(".local/horizon-client/9.9/assets/minecraft/sounds/dig/stone1.ogg");
for known in [true, false] {
let accepted = if known { checksum.as_str() } else { "0" };
fs::write(
&lock,
pinned.replace(
"\"manifest_sha1\"",
&format!("\"accepted_asset_index_sha1\": [\"{accepted}\"], \"manifest_sha1\""),
),
)
.unwrap();
let _ = fs::remove_file(&sound);
let lines = std::cell::RefCell::new(Vec::new());
sounds(&f.root, Some(&f.minecraft), &|l| {
lines.borrow_mut().push(l.to_string())
})
.unwrap();
assert_eq!(fs::read(&sound).unwrap(), b"OggS stone");
let lines = lines.into_inner();
assert_eq!(lines.last().unwrap(), "Sound assets ready: 3 files, 0.0 MB");
assert_eq!(lines.len(), if known { 1 } else { 2 }, "{lines:?}");
if !known {
assert!(
lines[0].contains(&format!("does not know yet (sha1 {checksum})")),
"{}",
lines[0]
);
}
}
}
#[test]
fn every_object_is_checked() {
let f = Fixture::new("corrupt");
@@ -412,14 +477,14 @@ mod tests {
}
#[test]
fn the_index_must_be_the_pinned_one() {
fn the_index_must_hold_the_sounds() {
let f = Fixture::new("index");
let index = f.minecraft.join("assets/indexes/7.json");
fs::write(&index, "{\"objects\": {}}").unwrap();
assert!(f
.run()
.unwrap_err()
.ends_with("is not the pinned asset index"));
assert_eq!(
f.run().unwrap_err(),
"minecraft/sounds.json is missing from the asset index"
);
fs::remove_file(&index).unwrap();
let error = f.run().unwrap_err();
assert!(
+9 -2
View File
@@ -4,9 +4,16 @@
"manifest_sha1": "11f1aefe473eefba0af62e3286c33b692807b2eb",
"accepted_manifest_sha1": [
"4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3",
"702fe59163c6ee6578607daa85811d9bc9c7cc40"
"702fe59163c6ee6578607daa85811d9bc9c7cc40",
"61fff1316bd50312624a858bbb5eb18fa45208ed"
],
"accepted_manifest_note": "Earlier 26.3 version JSONs Mojang republished: only assetIndex differs (non-English translations), jars and libraries are identical. A launcher may still hold any of them.",
"accepted_manifest_note": "Other 26.3 version JSONs Mojang republished: only assetIndex differs (non-English translations), jars and libraries are identical. A launcher may still hold any of them.",
"accepted_asset_index_sha1": [
"abfaa525f923f807df8b4e4d29c1b5e3a104adbe",
"1e4e4a68dece1c0f51b8f93b45da8fe8da51bcd3",
"0c8f43b28f837869c1e54a6aeb22c396a1548946"
],
"accepted_asset_index_note": "The asset indexes (id 34) of those republished version JSONs: a launcher keeps one 34.json, the last it downloaded, whichever version JSON it reads.",
"client_sha1": "e877b6a07acd633fb3bb475002175cec036e7b87",
"server_sha1": "33680f5f2ac32864d6d7cf5e56a705fdb3e05f4c",
"java_major": 25
+7 -3
View File
@@ -237,14 +237,18 @@ def import_library(artifact, destination_root, minecraft=None):
def launcher_assets(manifest, minecraft=None):
"""The launcher's asset directory and the pinned version's verified asset index."""
"""The launcher's asset directory and the pinned version's asset index."""
assets = minecraft_dir(minecraft) / "assets"
info = manifest["assetIndex"]
index = assets / "indexes" / f"{info['id']}.json"
if not index.is_file():
raise FileNotFoundError(f"Missing {index}. {LAUNCHER_HINT.format(version=manifest['id'])}")
if digest(index, "sha1") != info["sha1"]:
raise ValueError(f"{index} is not the pinned asset index")
checksum = digest(index, "sha1")
known = json.loads(LOCK.read_text(encoding="utf-8"))["minecraft"].get("accepted_asset_index_sha1", [])
if checksum != info["sha1"] and checksum not in known:
# Mojang republishes an index under the same id (#1076); each object is still verified.
print(f"{index} is an asset index Horizon does not know yet (sha1 {checksum}); "
"using it, every file is still checked against its own SHA-1", file=sys.stderr)
return assets, index