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::time::SystemTime;
9use yog_sync::{Arc, Mutex, OnceLock};
10
11use yog_data::Res;
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))
54        .ok()
55        .map(|m| m.id)
56}
57
58type Cache = Mutex<HashMap<PathBuf, (SystemTime, Arc<Vec<Picture>>)>>;
59static PICTURES: OnceLock<Cache> = OnceLock::new();
60
61/// The pictures of the `.yres` file at `file` (a set's neutral one).
62fn pictures_of(file: &Path, set: &str) -> Arc<Vec<Picture>> {
63    let modified = std::fs::metadata(file)
64        .and_then(|m| m.modified())
65        .unwrap_or(SystemTime::UNIX_EPOCH);
66    let cache = PICTURES.get_or_init(Default::default);
67    if let Some((at, pictures)) = cache.lock().get(file).cloned()
68        && at == modified
69    {
70        return pictures;
71    }
72    let entries = crate::files::read_source(file)
73        .ok()
74        .and_then(|t| yog_formats::yres::parse(&t).ok())
75        .unwrap_or_default();
76    let pictures: Arc<Vec<Picture>> = Arc::new(
77        entries
78            .into_iter()
79            .filter_map(|e| match (e.kind, e.value) {
80                (Kind::Image, Value::Bytes(b)) => Some(Picture {
81                    set: set.to_string(),
82                    name: e.name,
83                    file: file.to_path_buf(),
84                    bytes: Arc::new(b),
85                }),
86                _ => None,
87            })
88            .collect(),
89    );
90    let mut c = cache.lock();
91    c.insert(file.to_path_buf(), (modified, pictures.clone()));
92    pictures
93}
94
95/// Every picture of the mod at `root`.
96pub fn pictures(root: &Path) -> Vec<Picture> {
97    let mut out = Vec::new();
98    for file in crate::files::with_ext(&root.join(yog_ids::project::RESOURCES), Format::Res.ext()) {
99        // A culture variant (`Images.ru_ru.yres`) has no pictures of its own.
100        let Some((set, None)) = file
101            .file_name()
102            .and_then(|n| n.to_str())
103            .and_then(split_file_name)
104        else {
105            continue;
106        };
107        out.extend(pictures_of(&file, set).iter().cloned());
108    }
109    out
110}
111
112/// Picture `@<set>.<name>` of the mod at `root`.
113pub fn picture(root: &Path, set: &str, name: &str) -> Option<Picture> {
114    let file = root
115        .join(yog_ids::project::RESOURCES)
116        .join(Format::Res.file(set));
117    pictures_of(&file, set)
118        .iter()
119        .find(|p| p.name == name)
120        .cloned()
121}
122
123/// Writes `bytes` as picture `@<set>.<name>` of the mod at `root` (the
124/// set's file made when there's none); the file written.
125pub fn save_picture(root: &Path, set: &str, name: &str, bytes: Vec<u8>) -> Result<PathBuf, String> {
126    let file = root
127        .join(yog_ids::project::RESOURCES)
128        .join(Format::Res.file(set));
129    let mut entries = match crate::files::read_source(&file) {
130        Ok(text) => {
131            yog_formats::yres::parse(&text).map_err(|e| format!("{}: {e}", file.display()))?
132        }
133        Err(_) => Vec::new(),
134    };
135    match entries.iter_mut().find(|e| e.name == name) {
136        Some(e) => e.value = Value::Bytes(bytes),
137        None => entries.push(Entry::picture(name, bytes)),
138    }
139    if let Some(dir) = file.parent() {
140        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
141    }
142    crate::files::write_source(&file, &yog_formats::yres::write(&entries))?;
143    Ok(file)
144}
145
146/// Where texture `id` of the mod at `root` is described, whether it is yet or not.
147fn texture_file(root: &Path, id: &str) -> Option<PathBuf> {
148    let (ns, path) = yog_ids::split(id);
149    (mod_id(root)? == ns).then(|| {
150        root.join(yog_ids::project::RESOURCES)
151            .join(Format::Texture.file(path))
152    })
153}
154
155/// Texture `id` of the mod at `root`, when it has a `.ytexture`.
156pub fn texture(root: &Path, id: &str) -> Option<Texture> {
157    let file = texture_file(root, id)?;
158    let texture = yog_data::from_text::<TextureFile>(&crate::files::read_source(&file).ok()?)
159        .ok()?
160        .value;
161    Some(Texture {
162        id: yog_ids::qualify(id),
163        file,
164        texture,
165    })
166}
167
168/// Every texture of the mod at `root`.
169pub fn textures(root: &Path) -> Vec<Texture> {
170    let Some(ns) = mod_id(root) else {
171        return Vec::new();
172    };
173    let resources = root.join(yog_ids::project::RESOURCES);
174    crate::files::with_ext(&resources, Format::Texture.ext())
175        .into_iter()
176        .filter_map(|f| {
177            let rel = f
178                .strip_prefix(&resources)
179                .ok()?
180                .to_string_lossy()
181                .replace('\\', yog_ids::sep::PATH);
182            texture(root, &yog_formats::texture::id_of(&ns, &rel)?)
183        })
184        .collect()
185}
186
187/// Whether texture `id` is the mod's own (it can be painted in place, or added).
188pub fn is_own_texture(root: &Path, id: &str) -> bool {
189    texture_file(root, id).is_some()
190}
191
192/// Writes `png` as texture `id`'s picture: into the `.yres` picture its
193/// `.ytexture` names, or — a new texture — into the set its folder's
194/// pictures go in, with a `.ytexture` naming it. The `.yres` written.
195pub fn save_texture_picture(root: &Path, id: &str, png: Vec<u8>) -> Result<PathBuf, String> {
196    let file = texture_file(root, id).ok_or_else(|| format!("{id} isn't a texture of this mod"))?;
197    if let Some(own) = texture(root, id) {
198        return save_picture(root, &own.texture.image.set, &own.texture.image.key, png);
199    }
200    let path = yog_ids::split(id).1;
201    let image = Res {
202        set: yog_formats::texture::picture_set(path),
203        key: path.replace(yog_ids::sep::PATH, "."),
204    };
205    let written = save_picture(root, &image.set, &image.key, png)?;
206    let texture = TextureFile {
207        image,
208        atlas: yog_registry::Atlas::of_path(path).map(|a| a.id.clone()),
209        animation: None,
210        blur: false,
211        clamp: false,
212    };
213    write_texture(&file, &texture)?;
214    Ok(written)
215}
216
217/// Sets texture `id`'s animation (`None`: still); the `.ytexture` written.
218pub fn save_texture_animation(
219    root: &Path,
220    id: &str,
221    animation: Option<Animation>,
222) -> Result<PathBuf, String> {
223    let mut own = texture(root, id)
224        .ok_or_else(|| format!("{id} has no .{} in this mod", Format::Texture.ext()))?;
225    own.texture.animation = animation;
226    write_texture(&own.file, &own.texture)?;
227    Ok(own.file)
228}
229
230fn write_texture(file: &Path, texture: &TextureFile) -> Result<(), String> {
231    if let Some(dir) = file.parent() {
232        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
233    }
234    std::fs::write(file, yog_data::to_bytes(texture))
235        .map_err(|e| format!("{}: {e}", file.display()))
236}
237
238/// The file the build makes at `assets/<ns>/<path>` from the mod at `root`
239/// — a texture's PNG or `.png.mcmeta` (a `.ytexture`'s picture and
240/// animation, else a `.yres` picture of that path) — `None` when the build
241/// makes none there.
242pub fn built_asset(root: &Path, ns: &str, path: &str) -> Option<Vec<u8>> {
243    if mod_id(root)? != ns {
244        return None;
245    }
246    let file = path.strip_prefix(yog_ids::res::TEXTURES)?;
247    if let Some(stem) = file
248        .strip_suffix(yog_ids::res::MCMETA)
249        .and_then(|f| f.strip_suffix(yog_ids::res::PNG))
250    {
251        let meta = texture(root, &yog_ids::join(ns, stem))?
252            .texture
253            .game_mcmeta()?;
254        return serde_json::to_vec(&meta).ok();
255    }
256    let stem = file.strip_suffix(yog_ids::res::PNG)?;
257    let picture = match texture(root, &yog_ids::join(ns, stem)) {
258        Some(own) => own.picture(root)?,
259        None => pictures(root).into_iter().find(|p| p.path() == stem)?,
260    };
261    // The game takes PNG; a picture stored as anything else is converted, as the build does.
262    let image = yog_media::image::decode(&picture.bytes).ok()?;
263    yog_media::image::encode_png(&image).ok()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn png() -> Vec<u8> {
271        yog_media::image::encode_png(&yog_media::image::Image {
272            width: 1,
273            height: 1,
274            rgba: vec![9, 9, 9, 255],
275        })
276        .unwrap()
277    }
278
279    #[test]
280    fn a_painted_texture_becomes_a_picture_and_a_ytexture() {
281        let dir = std::env::temp_dir().join(format!("yog-media-{}", std::process::id()));
282        let _ = std::fs::remove_dir_all(&dir);
283        std::fs::create_dir_all(&dir).unwrap();
284        std::fs::write(dir.join(yog_ids::file::MANIFEST), "[mod]\nid = \"mymod\"\n").unwrap();
285        save_texture_picture(&dir, "mymod:block/ruby", png()).unwrap();
286        let own = texture(&dir, "mymod:block/ruby").unwrap();
287        assert_eq!(own.texture.atlas.as_deref(), Some("minecraft:blocks"));
288        assert_eq!(
289            own.picture(&dir).unwrap().bytes.as_slice(),
290            png().as_slice()
291        );
292        assert_eq!(
293            built_asset(&dir, "mymod", "textures/block/ruby.png"),
294            Some(png())
295        );
296        save_texture_animation(
297            &dir,
298            "mymod:block/ruby",
299            Some(Animation {
300                frame_time: Some(4),
301                ..Animation::default()
302            }),
303        )
304        .unwrap();
305        assert!(built_asset(&dir, "mymod", "textures/block/ruby.png.mcmeta").is_some());
306        assert_eq!(textures(&dir).len(), 1);
307        assert!(!is_own_texture(&dir, "minecraft:block/stone"));
308        assert!(pictures(&dir).iter().any(|p| p.path() == "block/ruby"));
309        let _ = std::fs::remove_dir_all(&dir);
310    }
311}