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