1use 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
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 { jar, version: version.to_string(), zip: Mutex::new(zip), objects: None })
59 }
60
61 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 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 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 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 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
147fn version_key(v: &str) -> Vec<u32> {
149 v.split(['.', '-'])
150 .map(|p| p.parse().unwrap_or(0))
151 .collect()
152}
153
154fn 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 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
211fn 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
260pub 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
288pub 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#[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 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 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 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}