1use yog_ids::Format;
7use crate::design::assets::{Resources};
8use crate::design::project::mod_root;
9use base64::Engine as _;
10use serde::Serialize;
11use serde_json::{Map, Value};
12use std::path::{Path, PathBuf};
13
14fn texture_id(texture: &str) -> String {
18 yog_ids::qualify(texture.strip_prefix(yog_ids::reference::TEXTURE).unwrap_or(texture))
19}
20
21fn texture_bytes(file: &Path, texture: &str) -> Result<Vec<u8>, String> {
24 match texture_file(file, texture, "png") {
25 Ok(bytes) => Ok(bytes),
26 Err(original) => {
27 let (ns, path) = yog_ids::split(texture);
29 let Some(legacy) = path
30 .strip_prefix(yog_ids::res::GUI_SPRITES)
31 .and_then(yog_gfx::image::sprite_by_atlas)
32 .and_then(|s| s.legacy.as_ref())
33 .filter(|_| ns == yog_ids::ns::MINECRAFT)
34 else {
35 return Err(original);
36 };
37 let sheet = texture_file(file, &yog_gfx::image::short_source(&legacy.texture), "png")?;
38 let r = legacy.region;
39 crop_png(&sheet, r.x as u32, r.y as u32, r.w as u32, r.h as u32).map_err(|e| format!("{original}; the 1.20.1 sprite: {e}"))
40 }
41 }
42}
43
44fn crop_png(bytes: &[u8], x: u32, y: u32, width: u32, height: u32) -> Result<Vec<u8>, String> {
45 let image = crate::design::assets::decode_png(bytes).ok_or("invalid PNG")?;
46 if x + width > image.width || y + height > image.height {
47 return Err(format!("crop {x},{y} {width}x{height} exceeds {}x{}", image.width, image.height));
48 }
49 let mut rgba = Vec::with_capacity((width * height * 4) as usize);
50 for row in y..y + height {
51 let start = ((row * image.width + x) * 4) as usize;
52 rgba.extend_from_slice(&image.rgba[start..start + (width * 4) as usize]);
53 }
54 let mut output = Vec::new();
55 let mut encoder = png::Encoder::new(&mut output, width, height);
56 encoder.set_color(png::ColorType::Rgba);
57 encoder.set_depth(png::BitDepth::Eight);
58 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
59 writer.write_image_data(&rgba).map_err(|e| e.to_string())?;
60 drop(writer);
61 Ok(output)
62}
63
64fn texture_file(file: &Path, texture: &str, ext: &str) -> Result<Vec<u8>, String> {
67 let id = texture_id(texture);
68 let (ns, path) = yog_ids::split(&id);
69 Resources::for_mod(mod_root(file).as_deref())
70 .read(ns, &format!("{}{path}.{ext}", yog_ids::res::TEXTURES))
71 .ok_or_else(|| format!("texture {id} not found (the mod's .{}, or the Minecraft client jar)", yog_ids::Format::Texture.ext()))
72}
73
74fn content_file(root: &Path, id: &str, formats: &[Format]) -> Option<(PathBuf, Value)> {
77 let ns = crate::design::project::mod_id(root)?;
78 let (want_ns, want) = yog_ids::split(id);
79 if want_ns != ns {
80 return None;
81 }
82 for format in formats {
83 for file in crate::design::files::of(&root.join("content"), *format) {
84 let Some(doc) = std::fs::read_to_string(&file).ok().and_then(|t| crate::design::data::read_object(&t)).map(Value::Object) else {
85 continue;
86 };
87 let fid = doc.get(yog_ids::content_field::ID).and_then(Value::as_str).map(str::to_string)
88 .or_else(|| file.file_stem().map(|s| s.to_string_lossy().into_owned()))?;
89 if fid.split_once(':').map_or(fid.as_str(), |(_, p)| p) == want {
90 return Some((file, doc));
91 }
92 }
93 }
94 None
95}
96
97fn model_textures(root: &Path, doc: &Value, field: &str) -> serde_json::Map<String, Value> {
100 let Some(model) = doc.get(field) else { return serde_json::Map::new() };
101 let read = yog_data::read_value::<yog_formats::content::Model>(&yog_data::json::from_json(model));
102 let (Ok(mut model), Some(ns)) = (read, crate::design::project::mod_id(root)) else {
103 return model.get(yog_ids::model::TEXTURES).and_then(Value::as_object).cloned().unwrap_or_default();
105 };
106 if let Some(material) = model.material.clone()
107 && let Ok(m) = crate::content::material::load(root, &ns, &material)
108 {
109 crate::content::material::apply(&mut model, &m);
110 }
111 model.textures.0.iter().map(|(slot, t)| (slot.clone(), yog_data::json::to_json(&yog_data::Schema::write(t)))).collect()
112}
113
114pub fn content_block_faces(root: &Path, id: &str) -> Option<[Option<Vec<u8>>; 6]> {
118 let (file, doc) = content_file(root, id, &[Format::Block])?;
119 let textures = model_textures(root, &doc, yog_ids::content_field::MODEL);
120 let pick = |keys: &[&str]| {
121 keys.iter()
122 .find_map(|k| textures.get(*k).and_then(Value::as_str))
123 .or_else(|| textures.values().find_map(Value::as_str))
124 .and_then(|t| texture_bytes(&file, t).ok())
125 };
126 Some(super::blocks3d::FACE_TEXTURES.map(pick))
127}
128
129pub fn content_block_model(root: &Path, id: &str) -> Option<(Vec<Value>, std::collections::HashMap<String, Vec<u8>>)> {
133 let (file, doc) = content_file(root, id, &[Format::Block])?;
134 let elements = match super::data::at(&doc, &[yog_ids::content_field::MODEL, yog_ids::model::ELEMENTS]).and_then(Value::as_array) {
135 Some(elements) => elements.clone(),
136 None if doc.get(yog_ids::content_field::KIND).and_then(Value::as_str) == Some("stairs") => vec![
137 stair_element([0, 0, 0], [16, 8, 16]),
138 stair_element([0, 8, 8], [16, 16, 16]),
139 ],
140 None => return None,
141 };
142 let textures = model_textures(root, &doc, yog_ids::content_field::MODEL)
143 .iter()
144 .filter_map(|(k, v)| Some((k.clone(), texture_bytes(&file, v.as_str()?).ok()?)))
145 .collect();
146 Some((elements, textures))
147}
148
149fn stair_element(from: [u8; 3], to: [u8; 3]) -> Value {
150 let face = |texture: &str| serde_json::json!({ yog_ids::model::TEXTURE: format!("{}{texture}", yog_formats::content::SLOT_MARK) });
151 serde_json::json!({
152 "from": from,
153 "to": to,
154 "faces": {
155 "up": face("top"),
156 "down": face("bottom"),
157 "north": face("side"),
158 "south": face("side"),
159 "west": face("side"),
160 "east": face("side")
161 }
162 })
163}
164
165pub fn content_item_appearance(root: &Path, id: &str) -> Option<(Value, Option<u32>)> {
167 let ns = crate::design::project::mod_id(root)?;
168 let (want_ns, want) = yog_ids::split(id);
169 let want = if want_ns == yog_ids::ns::YOG {
170 want.strip_prefix(yog_ids::split(yog_ids::yog::POTION_ALIAS).1)?
171 } else if want_ns == ns {
172 want
173 } else {
174 return None;
175 };
176 for file in crate::design::files::of(&root.join("content/items"), Format::Item) {
177 let Ok(text) = std::fs::read_to_string(&file) else {
178 continue;
179 };
180 let Some(doc) = crate::design::data::read_object(&text) else {
181 continue;
182 };
183 let Some(fid) = doc
184 .get(yog_ids::content_field::ID)
185 .and_then(Value::as_str)
186 .or_else(|| file.file_stem().and_then(|s| s.to_str()))
187 else {
188 continue;
189 };
190 if fid.split_once(':').map_or(fid, |(_, p)| p) != want {
191 continue;
192 }
193 let definition = Value::Object(doc);
194 let color = potion_effect_color(root, &definition);
195 return Some((definition, color));
196 }
197 None
198}
199
200fn potion_effect_color(root: &Path, item: &Value) -> Option<u32> {
201 let effects = super::data::at(item, &[yog_ids::content_field::ITEM, yog_ids::content_field::POTION, yog_ids::content_field::EFFECTS])?.as_array()?;
202 let mut channels = [0u64; 3];
203 let mut count = 0u64;
204 for effect in effects {
205 let Some(id) = effect.get(yog_ids::content_field::EFFECT).and_then(Value::as_str) else {
206 continue;
207 };
208 let Some(color) = content_effect_color(root, id) else {
209 continue;
210 };
211 channels[0] += u64::from((color >> 16) & 0xFF);
212 channels[1] += u64::from((color >> 8) & 0xFF);
213 channels[2] += u64::from(color & 0xFF);
214 count += 1;
215 }
216 (count > 0).then(|| {
217 (((channels[0] / count) as u32) << 16)
218 | (((channels[1] / count) as u32) << 8)
219 | (channels[2] / count) as u32
220 })
221}
222
223pub fn item_potion_color(file: &Path) -> Option<u32> {
225 let root = mod_root(file)?;
226 let text = std::fs::read_to_string(file).ok()?;
227 let item = Value::Object(crate::design::data::read_object(&text)?);
228 potion_effect_color(&root, &item)
229}
230
231fn content_effect_color(root: &Path, reference: &str) -> Option<u32> {
232 let mod_ns = crate::design::project::mod_id(root)?;
233 let id = reference.strip_prefix("@effect:").unwrap_or(reference);
234 let (ns, want) = yog_ids::split(id);
235 if ns != mod_ns {
236 return None;
237 }
238 for file in crate::design::files::of(&root.join("content/effects"), Format::Def) {
239 let Ok(text) = std::fs::read_to_string(&file) else {
240 continue;
241 };
242 let Some(doc) = crate::design::data::read_object(&text) else {
243 continue;
244 };
245 let Some(id) = doc
246 .get(yog_ids::content_field::ID)
247 .and_then(Value::as_str)
248 .or_else(|| file.file_stem().and_then(|s| s.to_str()))
249 else {
250 continue;
251 };
252 if id.split_once(':').map_or(id, |(_, p)| p) == want {
253 return doc
254 .get(yog_ids::content_field::COLOR)
255 .and_then(Value::as_u64)
256 .and_then(|v| u32::try_from(v).ok());
257 }
258 }
259 None
260}
261
262pub fn content_icon(root: &Path, id: &str) -> Option<Vec<u8>> {
265 let ns = crate::design::project::mod_id(root)?;
266 let (want_ns, want) = yog_ids::split(id);
267 if want_ns != ns {
268 return None;
269 }
270 let files = [crate::design::files::of(&root.join("content"), Format::Item), crate::design::files::of(&root.join("content"), Format::Block)].concat();
271 for file in files {
272 let Ok(text) = std::fs::read_to_string(&file) else { continue };
273 let Some(doc) = crate::design::data::read_object(&text).map(Value::Object) else { continue };
274 let fid = doc.get(yog_ids::content_field::ID).and_then(Value::as_str).map(str::to_string)
275 .or_else(|| file.file_stem().map(|s| s.to_string_lossy().into_owned()))?;
276 let fid = fid.split_once(':').map_or(fid.as_str(), |(_, p)| p).to_string();
277 if fid != want {
278 continue;
279 }
280 let textures = model_textures(root, &doc, yog_ids::content_field::MODEL);
281 let tex = textures
282 .get(yog_ids::model::LAYER0)
283 .or_else(|| ["all", "side", "top", "texture"].iter().find_map(|k| textures.get(*k)))
284 .or_else(|| textures.values().next())?
285 .as_str()?;
286 return texture_bytes(&file, tex).ok();
287 }
288 None
289}
290
291#[derive(Serialize)]
292pub struct Texture {
293 pub url: String,
295 pub animation: Option<Animation>,
297 pub writable: bool,
300}
301
302#[derive(Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
306#[serde(rename_all = "camelCase")]
307pub struct Animation {
308 pub frame_width: u32,
309 pub frame_height: u32,
310 pub frames: Vec<AnimationFrame>,
311 pub interpolate: bool,
313}
314
315#[derive(Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
316pub struct AnimationFrame {
317 pub index: u32,
318 pub time: u32,
320}
321
322impl Animation {
323 pub fn read(meta: &str, size: (u32, u32)) -> Option<Animation> {
325 let doc: Value = serde_json::from_str(meta).ok()?;
326 Self::of(&yog_formats::texture::Animation::from_mcmeta(&doc)?, size)
327 }
328
329 pub fn of(a: &yog_formats::texture::Animation, (w, h): (u32, u32)) -> Option<Animation> {
332 let side = w.min(h);
333 let fw = a.width.unwrap_or(side).max(1);
334 let fh = a.height.unwrap_or(side).max(1);
335 let count = (w / fw) * (h / fh);
336 if count == 0 {
337 return None;
338 }
339 let frame_time = a.frame_time().max(1);
340 let frames = if a.frames.is_empty() {
341 (0..count).map(|index| AnimationFrame { index, time: frame_time }).collect()
342 } else {
343 a.frames
344 .iter()
345 .filter(|f| f.index < count)
346 .map(|f| AnimationFrame { index: f.index, time: f.time.map_or(frame_time, |t| t.max(1)) })
347 .collect()
348 };
349 Some(Animation { frame_width: fw, frame_height: fh, frames, interpolate: a.interpolate })
350 }
351
352 pub fn to_format(&self, (w, h): (u32, u32)) -> yog_formats::texture::Animation {
354 let side = w.min(h);
355 yog_formats::texture::Animation {
356 frame_time: None,
357 interpolate: self.interpolate,
358 frames: self.frames.iter().map(|f| yog_formats::texture::Frame { index: f.index, time: Some(f.time) }).collect(),
359 width: (self.frame_width != side).then_some(self.frame_width),
360 height: (self.frame_height != side).then_some(self.frame_height),
361 }
362 }
363}
364
365pub fn write_animation(file: &Path, texture: &str, animation: Option<&Animation>) -> Result<PathBuf, String> {
368 let root = mod_root(file).ok_or("not in a mod")?;
369 let id = texture_id(texture);
370 let animation = match animation {
371 Some(a) => {
372 let png = texture_bytes(file, texture)?;
373 let size = image_size(&png).ok_or_else(|| format!("{id} isn't a picture"))?;
374 Some(a.to_format(size))
375 }
376 None => None,
377 };
378 crate::media::save_texture_animation(&root, &id, animation)
379}
380
381pub fn body_texture(file: &Path, texture: &str) -> Result<Texture, String> {
383 self::texture(file, texture)
384}
385
386pub fn texture(file: &Path, texture: &str) -> Result<Texture, String> {
387 let bytes = texture_bytes(file, texture)?;
388 let animation = image_size(&bytes).and_then(|size| {
389 let meta = texture_file(file, texture, "png.mcmeta").ok()?;
390 Animation::read(&String::from_utf8_lossy(&meta), size)
391 });
392 Ok(Texture {
393 url: format!(
394 "data:image/png;base64,{}",
395 base64::engine::general_purpose::STANDARD.encode(&bytes)
396 ),
397 animation,
398 writable: own_texture(file, texture).is_some(),
399 })
400}
401
402pub fn own_texture(file: &Path, texture: &str) -> Option<(PathBuf, String)> {
406 if texture.starts_with(yog_formats::content::SLOT_MARK) {
407 return None;
408 }
409 let root = mod_root(file)?;
410 let id = texture_id(texture);
411 crate::media::is_own_texture(&root, &id).then_some((root, id))
412}
413
414pub fn write_texture(file: &Path, texture: &str, png: Vec<u8>) -> Result<PathBuf, String> {
416 if image_size(&png).is_none() {
417 return Err("not a PNG image".into());
418 }
419 let (root, id) = own_texture(file, texture).ok_or_else(|| format!("{texture} isn't a texture of this mod"))?;
420 crate::media::save_texture_picture(&root, &id, png)
421}
422
423fn image_size(png: &[u8]) -> Option<(u32, u32)> {
425 if png.len() < 24 || &png[1..4] != b"PNG" {
426 return None;
427 }
428 let w = u32::from_be_bytes(png[16..20].try_into().ok()?);
429 let h = u32::from_be_bytes(png[20..24].try_into().ok()?);
430 Some((w, h))
431}
432
433#[derive(Serialize)]
434pub struct TextureChoice {
435 pub value: String,
437 pub group: &'static str,
439}
440
441pub fn strings(file: &Path) -> Vec<String> {
443 let mut out = Vec::new();
444 let Some(root) = mod_root(file) else {
445 return out;
446 };
447 for p in crate::design::files::of(&root.join("resources"), Format::Res) {
448 let Some(set) = p.file_stem().and_then(|s| s.to_str()) else {
449 continue;
450 };
451 if set.contains('.') {
452 continue; }
454 let Ok(text) = std::fs::read_to_string(&p) else {
455 continue;
456 };
457 let Some(doc) = crate::design::data::read_object(&text) else {
458 continue;
459 };
460 for (name, e) in &doc {
461 let ty = e.get("type").and_then(Value::as_str);
462 if e.is_string() || ty.is_none_or(|t| t == "string") {
463 out.push(format!("@{set}.{name}"));
464 }
465 }
466 }
467 out.sort();
468 out
469}
470
471fn definitions(file: &Path, kind: &str, built_ins: impl IntoIterator<Item = String>) -> Vec<String> {
474 let mut out: Vec<String> = built_ins.into_iter().map(|id| id.trim_start_matches(&format!("{}{}", yog_ids::ns::MINECRAFT, yog_ids::sep::NAMESPACE)).to_string()).collect();
475 let Some(root) = mod_root(file) else {
476 return out;
477 };
478 let ns = crate::design::project::mod_id(&root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
479 let mut own = Vec::new();
480 for p in crate::design::files::of(&root.join(yog_ids::project::CONTENT), Format::Def) {
481 let Some(doc) = std::fs::read_to_string(&p).ok().and_then(|t| crate::design::data::read_object(&t)) else {
482 continue;
483 };
484 if doc.get(yog_ids::content_field::KIND).and_then(Value::as_str) != Some(kind) {
485 continue;
486 }
487 if let Some(id) = doc.get(yog_ids::content_field::ID).and_then(Value::as_str) {
488 own.push(format!("{}{kind}{}{}", yog_ids::sep::TYPED, yog_ids::sep::NAMESPACE, yog_ids::qualify_in(&ns, id)));
489 }
490 }
491 own.sort();
492 out.extend(own);
493 out
494}
495
496pub fn sound_groups(file: &Path) -> Vec<String> {
498 definitions(file, "sound_group", yog_formats::def::SoundGroup::built_ins().iter().map(|g| g.id.clone()))
499}
500
501pub fn tool_materials(file: &Path) -> Vec<String> {
503 definitions(file, "tool_material", yog_formats::def::ToolMaterial::built_ins().iter().map(|m| m.id.clone()))
504}
505
506pub fn armor_materials(file: &Path) -> Vec<String> {
508 definitions(file, "armor_material", yog_formats::def::ArmorMaterial::built_ins().iter().map(|m| m.id.clone()))
509}
510
511pub fn tags(file: &Path, registry: &str) -> Vec<String> {
515 let mut out = Vec::new();
516 if let Some(root) = mod_root(file) {
517 let ns = crate::design::project::mod_id(&root).unwrap_or_default();
518 let listed = std::fs::read(root.join(".yog-build/generated/tags.json"))
519 .ok()
520 .and_then(|b| serde_json::from_slice::<std::collections::BTreeMap<String, std::collections::BTreeMap<String, Value>>>(&b).ok())
521 .and_then(|mut by| by.remove(registry))
522 .unwrap_or_default();
523 out.extend(listed.into_keys().filter_map(|t| t.strip_prefix(&format!("{ns}:")).map(str::to_string)));
524 }
525 out.extend(yog_formats::tags::yog_tags(registry).into_iter().map(|(p, _)| format!("yog:{p}")));
526 out
527}
528
529pub fn textures(file: &Path) -> Vec<TextureChoice> {
531 let mut out = Vec::new();
532 let root = mod_root(file);
533 if let Some(root) = &root {
534 out.extend(crate::media::textures(root).into_iter().map(|t| TextureChoice {
535 value: format!("{}{}", yog_ids::reference::TEXTURE, t.id),
536 group: "Mod textures",
537 }));
538 }
539 if let Some(v) = Resources::for_mod(root.as_deref()).vanilla {
540 let (items, blocks) = v.texture_names();
541 out.extend(blocks.into_iter().map(|b| TextureChoice {
542 value: format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::join(yog_ids::ns::MINECRAFT, &format!("{}{b}", yog_ids::res::BLOCK_DIR))),
543 group: "Minecraft blocks",
544 }));
545 out.extend(items.into_iter().map(|i| TextureChoice {
546 value: format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::join(yog_ids::ns::MINECRAFT, &format!("{}{i}", yog_ids::res::ITEM_DIR))),
547 group: "Minecraft items",
548 }));
549 }
550 out
551}
552
553pub fn parent_model(file: &Path, id: &str) -> Result<Value, String> {
556 let resources = Resources::for_mod(mod_root(file).as_deref());
557 let mut textures = Map::new();
558 let mut display = Map::new();
559 let mut elements: Option<Value> = None;
560 let mut current = id.to_string();
561 let mut root_parent: Option<String> = None;
562 for _ in 0..16 {
563 let (ns, path) = yog_ids::split(¤t);
564 let bytes = resources
565 .read(ns, &yog_ids::model_file(path))
566 .ok_or_else(|| format!("model {ns}:{path} not found"))?;
567 let m: Map<String, Value> =
568 serde_json::from_slice(&bytes).map_err(|e| format!("{ns}:{path}: {e}"))?;
569 if let Some(Value::Object(t)) = m.get(yog_ids::model::TEXTURES) {
570 for (k, v) in t {
571 textures.entry(k.clone()).or_insert_with(|| v.clone());
572 }
573 }
574 if let Some(Value::Object(d)) = m.get(yog_ids::model::DISPLAY) {
575 for (k, v) in d {
576 display.entry(k.clone()).or_insert_with(|| v.clone());
577 }
578 }
579 if elements.is_none() {
580 elements = m.get(yog_ids::model::ELEMENTS).cloned();
581 }
582 match m.get(yog_ids::model::PARENT).and_then(Value::as_str) {
583 None => break,
584 Some(p) if p.contains(yog_ids::res::BUILTIN) => {
585 root_parent = Some(p.to_string());
586 break;
587 }
588 Some(p) => {
589 let full = yog_ids::qualify(p);
590 if crate::content::TEMPLATES.iter().any(|t| t.sprite && t.parent == full) {
592 root_parent = Some(full.clone());
593 }
594 current = full;
595 }
596 }
597 }
598 let mut out = Map::new();
599 out.insert(yog_ids::model::TEXTURES.into(), Value::Object(textures));
600 out.insert(yog_ids::model::DISPLAY.into(), Value::Object(display));
601 if let Some(e) = elements {
602 out.insert(yog_ids::model::ELEMENTS.into(), e);
603 }
604 if let Some(p) = root_parent {
605 out.insert(yog_ids::model::PARENT.into(), Value::String(p));
606 }
607 Ok(Value::Object(out))
608}
609
610pub fn import(
614 file: &Path,
615 kind: &str,
616 source: &str,
617 model: Option<&str>,
618 force: bool,
619) -> Result<String, String> {
620 let root = mod_root(file).ok_or("not in a mod (no yog.toml)")?;
621 let (what, item) = match kind {
623 "resourcepack" => ("resourcepack", false),
624 "bbmodel" => ("bbmodel", false),
625 "bbmodel-item" => ("bbmodel", true),
626 _ => return Err(format!("unknown import kind {kind}")),
627 };
628 let mut cmd = crate::design::this_cli();
629 cmd.current_dir(&root).args(["import", what, source]);
630 if let Some(m) = model.filter(|m| !m.is_empty()) {
631 cmd.args([if what == "bbmodel" { "--id" } else { "--model" }, m]);
632 } else if what == "resourcepack" {
633 cmd.arg("--all");
634 }
635 if item {
636 cmd.arg("--item");
637 }
638 if force {
639 cmd.arg("--force");
640 }
641 let out = cmd
642 .output()
643 .map_err(|e| format!("failed to run yog: {e}"))?;
644 let text = String::from_utf8_lossy(&out.stderr).replace("yog: error: ", "");
645 if out.status.success() {
646 Ok(text.trim().to_string())
647 } else {
648 Err(text.trim().to_string())
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 #[test]
655 fn animation_reads_the_games_mcmeta() {
656 let meta = r#"{"animation":{"frametime":3,"frames":[0,{"index":2,"time":10},7]}}"#;
657 let a = Animation::read(meta, (16, 64)).unwrap();
658 assert_eq!((a.frame_width, a.frame_height), (16, 16));
659 assert_eq!(a.frames, vec![AnimationFrame { index: 0, time: 3 }, AnimationFrame { index: 2, time: 10 }]);
661 assert!(!a.interpolate);
662 let all = Animation::read(r#"{"animation":{}}"#, (16, 48)).unwrap();
663 assert_eq!(all.frames.iter().map(|f| f.index).collect::<Vec<_>>(), vec![0, 1, 2]);
664 assert!(Animation::read("{}", (16, 16)).is_none());
665 }
666
667 #[test]
668 fn animation_round_trips() {
669 let a = Animation { frame_width: 16, frame_height: 16, frames: vec![AnimationFrame { index: 1, time: 4 }, AnimationFrame { index: 0, time: 1 }], interpolate: true };
670 assert_eq!(Animation::of(&a.to_format((16, 32)), (16, 32)), Some(a));
671 }
672
673 use super::*;
674
675 #[test]
676 fn png_size() {
677 let mut png = vec![
678 0x89, b'P', b'N', b'G', 13, 10, 26, 10, 0, 0, 0, 13, b'I', b'H', b'D', b'R',
679 ];
680 png.extend(16u32.to_be_bytes());
681 png.extend(64u32.to_be_bytes());
682 assert_eq!(image_size(&png), Some((16, 64)));
683 }
684
685 #[test]
686 fn sound_groups_are_the_games_then_the_mods() {
687 let dir = std::env::temp_dir().join(format!("idle-sounds-{}", std::process::id()));
688 let _ = std::fs::remove_dir_all(&dir);
689 std::fs::create_dir_all(dir.join("content")).unwrap();
690 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
691 std::fs::write(dir.join("content/crystal.ydef"), r#"{ kind: "sound_group", id: "crystal" }"#).unwrap();
692 std::fs::write(dir.join("content/mythril.ydef"), r#"{ kind: "level", id: "mythril" }"#).unwrap();
693 let all = sound_groups(&dir.join("content/ore.yblock"));
694 assert!(all.contains(&"stone".to_string()) && all.contains(&"amethyst".to_string()));
695 assert_eq!(all.last().map(String::as_str), Some("@sound_group:mymod:crystal"));
696 assert!(!all.iter().any(|s| s.contains("mythril")));
697 let _ = std::fs::remove_dir_all(&dir);
698 }
699
700 #[test]
701 fn resolves_yres_images_and_resource_paths() {
702 let dir = std::env::temp_dir().join(format!("idle-content-{}", std::process::id()));
703 let _ = std::fs::remove_dir_all(&dir);
704 std::fs::create_dir_all(dir.join("content")).unwrap();
705 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
706 let png = yog_media::image::encode_png(&yog_media::image::Image { width: 1, height: 1, rgba: vec![1, 2, 3, 255] }).unwrap();
707 let file = dir.join("content/ruby.yblock");
708 assert_eq!(own_texture(&file, "@texture:mymod:block/ruby").map(|(_, id)| id).as_deref(), Some("mymod:block/ruby"));
710 write_texture(&file, "@texture:mymod:block/ruby", png.clone()).unwrap();
711 assert_eq!(texture_bytes(&file, "@texture:mymod:block/ruby").unwrap(), png);
712 assert!(textures(&file).iter().any(|c| c.value == "@texture:mymod:block/ruby"));
713 assert_eq!(own_texture(&file, "@texture:minecraft:item/apple"), None);
714 assert_eq!(own_texture(&file, "#layer0"), None);
715 let _ = std::fs::remove_dir_all(dir);
716 }
717
718 #[test]
719 fn item_appearance_reads_its_model_and_effect_colors() {
720 let dir = std::env::temp_dir().join(format!("idle-item-appearance-{}", std::process::id()));
721 let _ = std::fs::remove_dir_all(&dir);
722 std::fs::create_dir_all(dir.join("content/items")).unwrap();
723 std::fs::create_dir_all(dir.join("content/effects")).unwrap();
724 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
725 std::fs::write(
726 dir.join("content/items/potion.yitem"),
727 r#"{ model: { parent: "minecraft:item/potion" }, item: { potion: { effects: [{ effect: "@effect:mymod:glow" }] } } }"#,
728 )
729 .unwrap();
730 std::fs::write(
731 dir.join("content/effects/glow.ydef"),
732 r#"{ kind: "effect", id: "glow", color: 1193046 }"#,
733 )
734 .unwrap();
735
736 let (definition, color) = content_item_appearance(&dir, "mymod:potion").unwrap();
737 assert_eq!(
738 definition.pointer("/model/parent").and_then(Value::as_str),
739 Some("minecraft:item/potion")
740 );
741 assert_eq!(color, Some(0x123456));
742 assert_eq!(item_potion_color(&dir.join("content/items/potion.yitem")), Some(0x123456));
743 let _ = std::fs::remove_dir_all(dir);
744 }
745
746 #[test]
747 fn stairs_without_custom_elements_get_a_step_shape() {
748 let dir = std::env::temp_dir().join(format!("idle-stairs-{}", std::process::id()));
749 let _ = std::fs::remove_dir_all(&dir);
750 std::fs::create_dir_all(dir.join("content/blocks")).unwrap();
751 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
752 std::fs::write(
753 dir.join("content/blocks/stairs.yblock"),
754 r#"{ kind: "stairs", model: { textures: { top: "block/top.png", bottom: "block/bottom.png", side: "block/side.png" } } }"#,
755 )
756 .unwrap();
757
758 let (elements, _) = content_block_model(&dir, "mymod:stairs").unwrap();
759 assert_eq!(elements.len(), 2);
760 assert_eq!(elements[0]["from"], serde_json::json!([0, 0, 0]));
761 assert_eq!(elements[0]["to"], serde_json::json!([16, 8, 16]));
762 assert_eq!(elements[1]["from"], serde_json::json!([0, 8, 8]));
763 assert_eq!(elements[1]["to"], serde_json::json!([16, 16, 16]));
764 let _ = std::fs::remove_dir_all(&dir);
765 }
766}