Skip to main content

yog_cli/
media.rs

1//! A mod's media as the tools see them: its `.yres` pictures (bytes and
2//! all), its `.ytexture`s, and the files the build makes of them — so the
3//! IDE previews and edits what the build will make, converting nothing
4//! itself. Reading is cached until a file changes.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex, OnceLock};
9use std::time::SystemTime;
10
11use yog_data::{Res, Schema};
12use yog_formats::texture::{Animation, TextureFile};
13use yog_formats::yres::{Entry, Kind, Value, split_file_name};
14use yog_ids::Format;
15
16/// One picture of a `.yres` set.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Picture {
19    /// Its set (`Images` for `Images.yres`) and name: `@Images.gui.logo`.
20    pub set: String,
21    pub name: String,
22    /// The `.yres` file it's in.
23    pub file: PathBuf,
24    /// The picture, as stored.
25    pub bytes: Arc<Vec<u8>>,
26}
27
28impl Picture {
29    /// Its path under the mod's textures, as the build writes it: `gui.logo` → `gui/logo`.
30    pub fn path(&self) -> String {
31        self.name.replace('.', yog_ids::sep::PATH)
32    }
33}
34
35/// One of the mod's textures.
36#[derive(Debug, Clone)]
37pub struct Texture {
38    pub id: String,
39    /// Its `.ytexture`.
40    pub file: PathBuf,
41    pub texture: TextureFile,
42}
43
44impl Texture {
45    /// Its picture, when its `.yres` has it.
46    pub fn picture(&self, root: &Path) -> Option<Picture> {
47        picture(root, &self.texture.image.set, &self.texture.image.key)
48    }
49}
50
51/// The id of the mod at `root` (its manifest's).
52pub fn mod_id(root: &Path) -> Option<String> {
53    crate::manifest::YogToml::read(&root.join(yog_ids::file::MANIFEST)).ok().map(|m| m.id)
54}
55
56type Cache = Mutex<HashMap<PathBuf, (SystemTime, Arc<Vec<Picture>>)>>;
57static PICTURES: OnceLock<Cache> = OnceLock::new();
58
59/// The pictures of the `.yres` file at `file` (a set's neutral one).
60fn pictures_of(file: &Path, set: &str) -> Arc<Vec<Picture>> {
61    let modified = std::fs::metadata(file).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
62    let cache = PICTURES.get_or_init(Default::default);
63    if let Some((at, pictures)) = cache.lock().ok().and_then(|c| c.get(file).cloned())
64        && at == modified
65    {
66        return pictures;
67    }
68    let entries = std::fs::read_to_string(file).ok().and_then(|t| yog_formats::yres::parse(&t).ok()).unwrap_or_default();
69    let pictures: Arc<Vec<Picture>> = Arc::new(
70        entries
71            .into_iter()
72            .filter_map(|e| match (e.kind, e.value) {
73                (Kind::Image, Value::Bytes(b)) => Some(Picture { set: set.to_string(), name: e.name, file: file.to_path_buf(), bytes: Arc::new(b) }),
74                _ => None,
75            })
76            .collect(),
77    );
78    if let Ok(mut c) = cache.lock() {
79        c.insert(file.to_path_buf(), (modified, pictures.clone()));
80    }
81    pictures
82}
83
84/// Every picture of the mod at `root`.
85pub fn pictures(root: &Path) -> Vec<Picture> {
86    let mut out = Vec::new();
87    for file in crate::yui::find_files(&root.join(yog_ids::project::RESOURCES), Format::Res.ext()) {
88        // A culture variant (`Images.ru_ru.yres`) has no pictures of its own.
89        let Some((set, None)) = file.file_name().and_then(|n| n.to_str()).and_then(split_file_name) else { continue };
90        out.extend(pictures_of(&file, set).iter().cloned());
91    }
92    out
93}
94
95/// Picture `@<set>.<name>` of the mod at `root`.
96pub fn picture(root: &Path, set: &str, name: &str) -> Option<Picture> {
97    let file = root.join(yog_ids::project::RESOURCES).join(Format::Res.file(set));
98    pictures_of(&file, set).iter().find(|p| p.name == name).cloned()
99}
100
101/// Writes `bytes` as picture `@<set>.<name>` of the mod at `root` (the
102/// set's file made when there's none); the file written.
103pub fn save_picture(root: &Path, set: &str, name: &str, bytes: Vec<u8>) -> Result<PathBuf, String> {
104    let file = root.join(yog_ids::project::RESOURCES).join(Format::Res.file(set));
105    let mut entries = match std::fs::read_to_string(&file) {
106        Ok(text) => yog_formats::yres::parse(&text).map_err(|e| format!("{}: {e}", file.display()))?,
107        Err(_) => Vec::new(),
108    };
109    match entries.iter_mut().find(|e| e.name == name) {
110        Some(e) => e.value = Value::Bytes(bytes),
111        None => entries.push(Entry::picture(name, bytes)),
112    }
113    if let Some(dir) = file.parent() {
114        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
115    }
116    std::fs::write(&file, yog_formats::yres::write(&entries)).map_err(|e| format!("{}: {e}", file.display()))?;
117    Ok(file)
118}
119
120/// Where texture `id` of the mod at `root` is described, whether it is yet or not.
121fn texture_file(root: &Path, id: &str) -> Option<PathBuf> {
122    let (ns, path) = yog_ids::split(id);
123    (mod_id(root)? == ns).then(|| root.join(yog_ids::project::RESOURCES).join(Format::Texture.file(path)))
124}
125
126/// Texture `id` of the mod at `root`, when it has a `.ytexture`.
127pub fn texture(root: &Path, id: &str) -> Option<Texture> {
128    let file = texture_file(root, id)?;
129    let texture = yog_data::from_text::<TextureFile>(&std::fs::read_to_string(&file).ok()?).ok()?.value;
130    Some(Texture { id: yog_ids::qualify(id), file, texture })
131}
132
133/// Every texture of the mod at `root`.
134pub fn textures(root: &Path) -> Vec<Texture> {
135    let Some(ns) = mod_id(root) else { return Vec::new() };
136    let resources = root.join(yog_ids::project::RESOURCES);
137    crate::yui::find_files(&resources, Format::Texture.ext())
138        .into_iter()
139        .filter_map(|f| {
140            let rel = f.strip_prefix(&resources).ok()?.to_string_lossy().replace('\\', yog_ids::sep::PATH);
141            texture(root, &yog_formats::texture::id_of(&ns, &rel)?)
142        })
143        .collect()
144}
145
146/// Whether texture `id` is the mod's own (it can be painted in place, or added).
147pub fn is_own_texture(root: &Path, id: &str) -> bool {
148    texture_file(root, id).is_some()
149}
150
151/// Writes `png` as texture `id`'s picture: into the `.yres` picture its
152/// `.ytexture` names, or — a new texture — into the set its folder's
153/// pictures go in, with a `.ytexture` naming it. The `.yres` written.
154pub fn save_texture_picture(root: &Path, id: &str, png: Vec<u8>) -> Result<PathBuf, String> {
155    let file = texture_file(root, id).ok_or_else(|| format!("{id} isn't a texture of this mod"))?;
156    if let Some(own) = texture(root, id) {
157        return save_picture(root, &own.texture.image.set, &own.texture.image.key, png);
158    }
159    let path = yog_ids::split(id).1;
160    let image = Res { set: yog_formats::texture::picture_set(path), key: path.replace(yog_ids::sep::PATH, ".") };
161    let written = save_picture(root, &image.set, &image.key, png)?;
162    let texture = TextureFile { image, atlas: yog_registry::Atlas::of_path(path).map(|a| a.id.clone()), animation: None, blur: false, clamp: false };
163    write_texture(&file, &texture)?;
164    Ok(written)
165}
166
167/// Sets texture `id`'s animation (`None`: still); the `.ytexture` written.
168pub fn save_texture_animation(root: &Path, id: &str, animation: Option<Animation>) -> Result<PathBuf, String> {
169    let mut own = texture(root, id).ok_or_else(|| format!("{id} has no .{} in this mod", Format::Texture.ext()))?;
170    own.texture.animation = animation;
171    write_texture(&own.file, &own.texture)?;
172    Ok(own.file)
173}
174
175fn write_texture(file: &Path, texture: &TextureFile) -> Result<(), String> {
176    if let Some(dir) = file.parent() {
177        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
178    }
179    std::fs::write(file, yog_data::text::to_string_pretty(&texture.write())).map_err(|e| format!("{}: {e}", file.display()))
180}
181
182/// The file the build makes at `assets/<ns>/<path>` from the mod at `root`
183/// — a texture's PNG or `.png.mcmeta` (a `.ytexture`'s picture and
184/// animation, else a `.yres` picture of that path) — `None` when the build
185/// makes none there.
186pub fn built_asset(root: &Path, ns: &str, path: &str) -> Option<Vec<u8>> {
187    if mod_id(root)? != ns {
188        return None;
189    }
190    let file = path.strip_prefix(yog_ids::res::TEXTURES)?;
191    if let Some(stem) = file.strip_suffix(yog_ids::res::MCMETA).and_then(|f| f.strip_suffix(yog_ids::res::PNG)) {
192        let meta = texture(root, &yog_ids::join(ns, stem))?.texture.game_mcmeta()?;
193        return serde_json::to_vec(&meta).ok();
194    }
195    let stem = file.strip_suffix(yog_ids::res::PNG)?;
196    let picture = match texture(root, &yog_ids::join(ns, stem)) {
197        Some(own) => own.picture(root)?,
198        None => pictures(root).into_iter().find(|p| p.path() == stem)?,
199    };
200    // The game takes PNG; a picture stored as anything else is converted, as the build does.
201    let image = yog_media::image::decode(&picture.bytes).ok()?;
202    yog_media::image::encode_png(&image).ok()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn png() -> Vec<u8> {
210        yog_media::image::encode_png(&yog_media::image::Image { width: 1, height: 1, rgba: vec![9, 9, 9, 255] }).unwrap()
211    }
212
213    #[test]
214    fn a_painted_texture_becomes_a_picture_and_a_ytexture() {
215        let dir = std::env::temp_dir().join(format!("yog-media-{}", std::process::id()));
216        let _ = std::fs::remove_dir_all(&dir);
217        std::fs::create_dir_all(&dir).unwrap();
218        std::fs::write(dir.join(yog_ids::file::MANIFEST), "[mod]\nid = \"mymod\"\n").unwrap();
219        save_texture_picture(&dir, "mymod:block/ruby", png()).unwrap();
220        let own = texture(&dir, "mymod:block/ruby").unwrap();
221        assert_eq!(own.texture.atlas.as_deref(), Some("minecraft:blocks"));
222        assert_eq!(own.picture(&dir).unwrap().bytes.as_slice(), png().as_slice());
223        assert_eq!(built_asset(&dir, "mymod", "textures/block/ruby.png"), Some(png()));
224        save_texture_animation(&dir, "mymod:block/ruby", Some(Animation { frame_time: Some(4), ..Animation::default() })).unwrap();
225        assert!(built_asset(&dir, "mymod", "textures/block/ruby.png.mcmeta").is_some());
226        assert_eq!(textures(&dir).len(), 1);
227        assert!(!is_own_texture(&dir, "minecraft:block/stone"));
228        assert!(pictures(&dir).iter().any(|p| p.path() == "block/ruby"));
229        let _ = std::fs::remove_dir_all(&dir);
230    }
231}