1use 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
20pub 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
39pub fn decode_png(bytes: &[u8]) -> Option<Image> {
41 yog_media::image::decode(bytes).ok()
42}
43
44pub struct Vanilla {
46 pub jar: PathBuf,
47 pub version: String,
48 zip: Mutex<zip::ZipArchive<File>>,
49 objects: Option<(PathBuf, HashMap<String, String>)>,
51}
52
53impl Vanilla {
54 #[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 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 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 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 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 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
149fn version_key(v: &str) -> Vec<u32> {
151 v.split(['.', '-'])
152 .map(|p| p.parse().unwrap_or(0))
153 .collect()
154}
155
156fn 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 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
217pub(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
225fn 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
274pub 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
302pub 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#[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 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 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 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}