Skip to main content

yog_cli/design/
assets.rs

1//! Minecraft's own resources, read from the user's local game files — the
2//! client jar a Gradle dev setup (Loom, ForgeGradle, NeoGradle) or the
3//! official launcher already downloaded, plus its asset index/objects —
4//! so the designer draws with the game's font and textures. Nothing is
5//! bundled or redistributed: when no client jar is found the designer says
6//! so and draws with what it has (flat rects, a fallback glyph box).
7//!
8//! Resources resolve like a resource-pack stack: the mod's own images
9//! (`.yres`) first, then the client jar, then the asset objects (where the
10//! unifont lives).
11
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::Read;
15use std::path::{Path, PathBuf};
16use yog_sync::{Arc, Mutex, OnceLock};
17
18pub use yog_graphics::Image;
19
20/// Minecraft's missing texture: 16×16, magenta/black quarters.
21pub fn missing_image() -> Image {
22    let mut image = Image::new(16, 16);
23    for y in 0..16 {
24        for x in 0..16 {
25            image.set_pixel(
26                x,
27                y,
28                if (x < 8) == (y < 8) {
29                    yog_graphics::Color::from_rgba8([0xF8, 0, 0xF8, 0xFF])
30                } else {
31                    yog_graphics::Color::from_rgba8([0, 0, 0, 0xFF])
32                },
33            );
34        }
35    }
36    image
37}
38
39/// A picture in any format the build reads (`yog_media`), as RGBA.
40pub fn decode_png(bytes: &[u8]) -> Option<Image> {
41    yog_media::image::decode(bytes).ok()
42}
43
44/// Where the vanilla resources come from.
45pub struct Vanilla {
46    pub jar: PathBuf,
47    pub version: String,
48    zip: Mutex<zip::ZipArchive<File>>,
49    /// Asset objects dir + `key → hash` from the version's index.
50    objects: Option<(PathBuf, HashMap<String, String>)>,
51}
52
53impl Vanilla {
54    /// A client jar at `jar` (tests stand one in for the game's).
55    #[cfg(test)]
56    pub fn from_jar(jar: PathBuf, version: &str) -> Option<Vanilla> {
57        let zip = zip::ZipArchive::new(File::open(&jar).ok()?).ok()?;
58        Some(Vanilla {
59            jar,
60            version: version.to_string(),
61            zip: Mutex::new(zip),
62            objects: None,
63        })
64    }
65
66    /// `key` from the jar and from the asset objects — both, for files the
67    /// download overrides (the jar's `font/include/unifont.json` is an
68    /// empty stub; the real one is an asset object).
69    pub fn read_all(&self, key: &str) -> Vec<Vec<u8>> {
70        let mut out = Vec::new();
71        let mut zip = self.zip.lock();
72        if let Ok(mut entry) = zip.by_name(&format!("assets/{key}")) {
73            let mut bytes = Vec::new();
74            if entry.read_to_end(&mut bytes).is_ok() {
75                out.push(bytes);
76            }
77        }
78        if let Some((dir, index)) = &self.objects
79            && let Some(bytes) = index
80                .get(key)
81                .and_then(|hash| std::fs::read(dir.join(&hash[..2]).join(hash)).ok())
82        {
83            out.push(bytes);
84        }
85        out
86    }
87
88    /// `data/<key>` from the jar (tags, recipes, …), if there.
89    pub fn read_data(&self, key: &str) -> Option<Vec<u8>> {
90        let mut zip = self.zip.lock();
91        let mut entry = zip.by_name(&format!("data/{key}")).ok()?;
92        let mut bytes = Vec::new();
93        entry.read_to_end(&mut bytes).ok()?;
94        Some(bytes)
95    }
96
97    /// The jar's file names under `prefix` (`data/minecraft/loot_tables/`), the prefix cut.
98    pub fn names_under(&self, prefix: &str) -> Vec<String> {
99        let zip = self.zip.lock();
100        zip.file_names()
101            .filter_map(|n| Some(n.ok()?.strip_prefix(prefix)?.to_string()))
102            .filter(|n| !n.is_empty() && !n.ends_with('/'))
103            .collect()
104    }
105
106    /// The game's item and block texture names (`apple`, `stone`, ...),
107    /// sorted — what `item:` / `block:` sources can show in the designer.
108    pub fn texture_names(&self) -> (Vec<String>, Vec<String>) {
109        let (mut items, mut blocks) = (Vec::new(), Vec::new());
110        let zip = self.zip.lock();
111        for name in zip.file_names() {
112            let Ok(name) = name else { continue };
113            let Some(stem) = name
114                .strip_prefix("assets/minecraft/textures/")
115                .and_then(|n| n.strip_suffix(yog_ids::res::PNG))
116            else {
117                continue;
118            };
119            if let Some(item) = stem.strip_prefix("item/") {
120                items.push(item.to_string());
121            } else if let Some(block) = stem.strip_prefix("block/") {
122                blocks.push(block.to_string());
123            }
124        }
125        items.sort();
126        blocks.sort();
127        (items, blocks)
128    }
129
130    fn read(&self, key: &str) -> Option<Vec<u8>> {
131        // `key` is `namespace/path` — `assets/<key>` in the jar.
132        let mut zip = self.zip.lock();
133        if let Ok(mut entry) = zip.by_name(&format!("assets/{key}")) {
134            let mut out = Vec::new();
135            if entry.read_to_end(&mut out).is_ok() {
136                return Some(out);
137            }
138        }
139        let (dir, index) = self.objects.as_ref()?;
140        let hash = index.get(key)?;
141        std::fs::read(dir.join(&hash[..2]).join(hash)).ok()
142    }
143}
144
145fn home() -> Option<PathBuf> {
146    dirs::home_dir()
147}
148
149/// `1.21.1` > `1.20.1` > `1.9`.
150fn version_key(v: &str) -> Vec<u32> {
151    v.split(['.', '-'])
152        .map(|p| p.parse().unwrap_or(0))
153        .collect()
154}
155
156/// Every client jar on this machine, as `(version, path)`.
157fn client_jars(extra_roots: &[PathBuf]) -> Vec<(String, PathBuf)> {
158    let mut out = Vec::new();
159    if let Ok(p) = std::env::var(yog_ids::env::MC_CLIENT_JAR) {
160        let p = PathBuf::from(p);
161        if p.is_file() {
162            out.push(("999".to_string(), p));
163        }
164    }
165    let mut version_dirs: Vec<(PathBuf, &str)> = Vec::new();
166    if let Some(h) = home() {
167        let caches = h.join(".gradle/caches");
168        version_dirs.push((caches.join("fabric-loom"), "minecraft-client.jar"));
169        version_dirs.push((
170            caches.join("forge_gradle/minecraft_repo/versions"),
171            "client.jar",
172        ));
173        version_dirs.push((caches.join("minecraft/versions"), "client.jar"));
174        version_dirs.push((h.join(".minecraft/versions"), ""));
175        version_dirs.push((h.join("Library/Application Support/minecraft/versions"), ""));
176    }
177    if let Some(d) = dirs::data_dir() {
178        version_dirs.push((d.join(".minecraft/versions"), ""));
179    }
180    for root in extra_roots {
181        // A Yog workspace: the loader's NeoGradle caches beside the mod.
182        for loader in [
183            yog_ids::loader::NEOFORGE,
184            yog_ids::loader::FABRIC,
185            yog_ids::loader::FORGE,
186        ] {
187            for base in [root.join("Yog-Mod-Loader"), root.join("../Yog-Mod-Loader")] {
188                version_dirs.push((
189                    base.join(loader).join(".gradle/caches/minecraft/versions"),
190                    "client.jar",
191                ));
192            }
193        }
194    }
195    for (dir, file) in version_dirs {
196        let Ok(entries) = std::fs::read_dir(&dir) else {
197            continue;
198        };
199        for e in entries.flatten() {
200            let version = e.file_name().to_string_lossy().into_owned();
201            if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) {
202                continue;
203            }
204            let jar = if file.is_empty() {
205                e.path().join(format!("{version}.jar"))
206            } else {
207                e.path().join(file)
208            };
209            if jar.is_file() {
210                out.push((version, jar));
211            }
212        }
213    }
214    out
215}
216
217/// The newest client jar on this machine (`YOG_MC_CLIENT_JAR` first).
218pub(crate) fn newest_client_jar(extra_roots: &[PathBuf]) -> Option<PathBuf> {
219    client_jars(extra_roots)
220        .into_iter()
221        .max_by_key(|(v, _)| version_key(v))
222        .map(|(_, p)| p)
223}
224
225/// The asset objects dir and the index for `version` (any index that knows
226/// the unifont when there's no exact match).
227fn asset_objects(version: &str) -> Option<(PathBuf, HashMap<String, String>)> {
228    let h = home()?;
229    let mut dirs = vec![
230        h.join(".gradle/caches/fabric-loom/assets"),
231        h.join(".gradle/caches/forge_gradle/assets"),
232        h.join(".gradle/caches/minecraft/assets"),
233        h.join(".minecraft/assets"),
234    ];
235    if let Some(d) = dirs::data_dir() {
236        dirs.push(d.join(".minecraft/assets"));
237    }
238    let mut best: Option<(bool, PathBuf, HashMap<String, String>)> = None;
239    for dir in dirs {
240        let Ok(entries) = std::fs::read_dir(dir.join("indexes")) else {
241            continue;
242        };
243        for e in entries.flatten() {
244            let name = e.file_name().to_string_lossy().into_owned();
245            let exact =
246                name.starts_with(&format!("{version}-")) || name == format!("{version}.json");
247            if best.as_ref().is_some_and(|b| b.0 || !exact) {
248                continue;
249            }
250            let Ok(text) = std::fs::read_to_string(e.path()) else {
251                continue;
252            };
253            let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
254                continue;
255            };
256            let Some(objects) = json.get("objects").and_then(|o| o.as_object()) else {
257                continue;
258            };
259            if !objects.contains_key("minecraft/font/include/unifont.json") {
260                continue;
261            }
262            let index = objects
263                .iter()
264                .filter_map(|(k, v)| Some((k.clone(), v.get("hash")?.as_str()?.to_string())))
265                .collect();
266            best = Some((exact, dir.join("objects"), index));
267        }
268    }
269    best.map(|(_, dir, index)| (dir, index))
270}
271
272static VANILLA: OnceLock<Mutex<Option<Arc<Vanilla>>>> = OnceLock::new();
273
274/// The newest client jar found (cached after the first successful lookup).
275pub fn vanilla(extra_roots: &[PathBuf]) -> Option<Arc<Vanilla>> {
276    let cell = VANILLA.get_or_init(|| Mutex::new(None));
277    let mut slot = cell.lock();
278    if let Some(v) = slot.as_ref() {
279        return Some(v.clone());
280    }
281    let mut jars = client_jars(extra_roots);
282    jars.sort_by_key(|(v, _)| std::cmp::Reverse(version_key(v)));
283    for (version, jar) in jars {
284        let Ok(file) = File::open(&jar) else { continue };
285        let Ok(zip) = zip::ZipArchive::new(file) else {
286            continue;
287        };
288        let v = Arc::new(Vanilla {
289            objects: asset_objects(&version),
290            jar,
291            version,
292            zip: Mutex::new(zip),
293        });
294        *slot = Some(v.clone());
295        return Some(v);
296    }
297    None
298}
299
300static ALL_VANILLA: OnceLock<Vec<Arc<Vanilla>>> = OnceLock::new();
301
302/// Every Minecraft version found, newest first — for what one version has
303/// and another hasn't (a tag removed in 1.21).
304pub fn vanilla_versions(extra_roots: &[PathBuf]) -> &'static [Arc<Vanilla>] {
305    ALL_VANILLA.get_or_init(|| {
306        let mut jars = client_jars(extra_roots);
307        jars.sort_by_key(|(v, _)| std::cmp::Reverse(version_key(v)));
308        jars.dedup_by(|a, b| a.0 == b.0);
309        jars.into_iter()
310            .filter_map(|(version, jar)| {
311                let zip = zip::ZipArchive::new(File::open(&jar).ok()?).ok()?;
312                Some(Arc::new(Vanilla {
313                    objects: asset_objects(&version),
314                    jar,
315                    version,
316                    zip: Mutex::new(zip),
317                }))
318            })
319            .collect()
320    })
321}
322
323/// Resolves `namespace:path` resources for one mod: its `.yres` images, then the game's.
324#[derive(Clone)]
325pub struct Resources {
326    pub mod_root: Option<PathBuf>,
327    pub vanilla: Option<Arc<Vanilla>>,
328}
329
330impl Resources {
331    pub fn for_mod(mod_root: Option<&Path>) -> Resources {
332        let roots: Vec<PathBuf> = mod_root.into_iter().map(Path::to_path_buf).collect();
333        let parents: Vec<PathBuf> = roots
334            .iter()
335            .filter_map(|r| r.parent().map(Path::to_path_buf))
336            .collect();
337        Resources {
338            mod_root: mod_root.map(Path::to_path_buf),
339            vanilla: vanilla(&[roots, parents].concat()),
340        }
341    }
342
343    /// The bytes of resource `<ns>:<path>` (`textures/gui/x.png`): the mod's
344    /// own are its `.yres` images (a mod has no `assets/`), the rest the game's.
345    pub fn read(&self, ns: &str, path: &str) -> Option<Vec<u8>> {
346        if let Some(bytes) = self
347            .mod_root
348            .as_deref()
349            .and_then(|root| crate::media::built_asset(root, ns, path))
350        {
351            return Some(bytes);
352        }
353        // Yog's own assets, as every host ships them.
354        if ns == yog_ids::ns::YOG
355            && let Some(bytes) = yog_gfx::image::own_asset(path)
356        {
357            return Some(bytes.to_vec());
358        }
359        self.vanilla.as_ref()?.read(&format!("{ns}/{path}"))
360    }
361
362    /// A texture by full resource id (`minecraft:textures/item/apple.png`).
363    pub fn texture(&self, id: &str) -> Option<Arc<Image>> {
364        type Cache = Mutex<HashMap<(Option<PathBuf>, String), Option<Arc<Image>>>>;
365        static CACHE: OnceLock<Cache> = OnceLock::new();
366        let cache = CACHE.get_or_init(Default::default);
367        let key = (self.mod_root.clone(), id.to_string());
368        if let Some(hit) = cache.lock().get(&key) {
369            return hit.clone();
370        }
371        let (ns, path) = yog_ids::split(id);
372        let image = self
373            .read(ns, path)
374            .and_then(|b| decode_png(&b))
375            .map(Arc::new);
376        let mut c = cache.lock();
377        c.insert(key, image.clone());
378        image
379    }
380}