Skip to main content

yog_formats/definitions/
yres.rs

1//! `.yres` — a mod's texts, images, sounds, videos, files, colors, numbers, flags.
2
3use yog_data::Schema;
4use yog_ids::res_kind;
5
6/// An entry's type.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Schema)]
8pub enum Kind {
9    String,
10    Image,
11    File,
12    Sound,
13    Color,
14    Number,
15    Bool,
16    Video,
17}
18
19impl Kind {
20    pub fn name(self) -> &'static str {
21        match self {
22            Kind::String => res_kind::STRING,
23            Kind::Image => res_kind::IMAGE,
24            Kind::File => res_kind::FILE,
25            Kind::Sound => res_kind::SOUND,
26            Kind::Color => res_kind::COLOR,
27            Kind::Number => res_kind::NUMBER,
28            Kind::Bool => res_kind::BOOL,
29            Kind::Video => res_kind::VIDEO,
30        }
31    }
32
33    /// Whether its value is bytes (`@bytes:…`): a picture, a file, a sound, a video.
34    pub fn holds_bytes(self) -> bool {
35        matches!(self, Kind::Image | Kind::File | Kind::Sound | Kind::Video)
36    }
37}
38
39/// An entry's object form: `{ "type": ..., "value": ..., "comment": ..., ... }`.
40#[derive(Schema)]
41pub struct EntryObject {
42    /// What the entry is; by default what its value is (string, number, bool).
43    #[data(rename = "type")]
44    pub kind: Option<Kind>,
45    /// The text, path, color, number or flag.
46    pub value: yog_data::Value,
47    /// A note for whoever edits the file — never shipped.
48    pub comment: Option<String>,
49    /// Texts: the translation key instead of the default one.
50    pub key: Option<String>,
51}
52
53/// `resources/.../Strings.ru_ru.yres` → `("Strings", Some("ru_ru"))`.
54pub fn split_file_name(file_name: &str) -> Option<(&str, Option<&str>)> {
55    let stem = file_name
56        .strip_suffix(yog_ids::Format::Res.ext())?
57        .strip_suffix('.')?;
58    Some(match stem.split_once('.') {
59        Some((set, locale)) => (set, Some(locale)),
60        None => (stem, None),
61    })
62}
63
64/// Where an image entry's picture goes, under `assets/<mod id>/textures/`:
65/// its file's path under `resources/` (`rel`) without `.png`. `None` for a
66/// file that isn't a `.png`. (Textures the game's atlases are made of are
67/// `.ytexture` files, not `.yres` images.)
68#[must_use]
69pub fn image_texture(rel: &str) -> Option<String> {
70    rel.strip_suffix(yog_ids::res::PNG).map(str::to_string)
71}
72
73// ── Entries ───────────────────────────────────────────────────────────────────
74
75use std::collections::HashMap;
76
77use yog_data::{Reader, Seg, Shape, ValueRef};
78
79/// The type of an entry that doesn't say, by its value — bytes by what they start with.
80pub fn kind_of(value: &Value) -> Kind {
81    match value {
82        Value::Str(_) => Kind::String,
83        Value::Num(_) => Kind::Number,
84        Value::Bool(_) => Kind::Bool,
85        Value::Bytes(b) if b.starts_with(OGG_MAGIC) => Kind::Sound,
86        Value::Bytes(b) if PICTURE_MAGIC.iter().any(|m| b.starts_with(m)) => Kind::Image,
87        Value::Bytes(_) => Kind::File,
88    }
89}
90
91/// What an Ogg stream starts with — the game's sound format.
92pub const OGG_MAGIC: &[u8] = b"OggS";
93/// What the pictures an entry can hold start with: PNG, JPEG, GIF, WebP (RIFF), BMP.
94const PICTURE_MAGIC: &[&[u8]] = &[b"\x89PNG", b"\xFF\xD8\xFF", b"GIF8", b"RIFF", b"BM"];
95
96/// A scalar as written: a string, a number (its literal), a bool, or bytes
97/// (a picture, a sound, a file — `@bytes:<base64>`).
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Value {
100    Str(String),
101    Num(String),
102    Bool(bool),
103    Bytes(Vec<u8>),
104}
105
106/// One entry of a `.yres` file.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Entry {
109    pub name: String,
110    pub kind: Kind,
111    pub value: Value,
112    pub comment: Option<String>,
113    pub key: Option<String>,
114    /// 1-based line of the name.
115    pub line: usize,
116}
117
118impl Entry {
119    /// What the entry holds when it's text (empty otherwise).
120    pub fn text(&self) -> &str {
121        match &self.value {
122            Value::Str(s) => s,
123            _ => "",
124        }
125    }
126
127    /// An image entry named `name` holding the picture `bytes`.
128    pub fn picture(name: &str, bytes: Vec<u8>) -> Entry {
129        Entry {
130            name: name.to_string(),
131            kind: Kind::Image,
132            value: Value::Bytes(bytes),
133            comment: None,
134            key: None,
135            line: 0,
136        }
137    }
138}
139
140/// What bytes are written as in text: `@bytes:<base64>`.
141const BYTES_PREFIX: &str = yog_ids::concatcp!(
142    yog_ids::sep::TYPED,
143    yog_ids::typed::BYTES,
144    yog_ids::sep::NAMESPACE
145);
146
147/// A whole `.yres` file: named entries in file order.
148pub struct YresFile(Vec<Entry>);
149
150/// `v` as an entry's scalar value.
151fn scalar(v: &ValueRef, cx: &mut Reader) -> Option<Value> {
152    Some(match v {
153        ValueRef::Bytes(b) => Value::Bytes(b.to_vec()),
154        ValueRef::Str(s) if s.starts_with(BYTES_PREFIX) => match yog_data::typed::classify(s) {
155            Ok(yog_data::Value::Bytes(b)) => Value::Bytes(b),
156            Ok(other) => {
157                cx.problem(format!("`{s}`: bytes, not {other:?}"));
158                return None;
159            }
160            Err(e) => {
161                cx.problem(e);
162                return None;
163            }
164        },
165        ValueRef::Str(_) => Value::Str(String::read(v, cx)?),
166        ValueRef::Int(n) => Value::Num(n.to_string()),
167        ValueRef::Float(f) => Value::Num(format!("{f:?}")),
168        ValueRef::Bool(b) => Value::Bool(*b),
169        other => {
170            cx.expected("a string, a number or true/false", other);
171            return None;
172        }
173    })
174}
175
176fn read_entry(name: &str, v: &ValueRef, cx: &mut Reader) -> Option<Entry> {
177    let mut entry = Entry {
178        name: name.to_string(),
179        kind: Kind::String,
180        value: Value::Str(String::new()),
181        comment: None,
182        key: None,
183        line: 0,
184    };
185    if !matches!(v, ValueRef::Map(_)) {
186        entry.value = scalar(v, cx)?;
187        entry.kind = kind_of(&entry.value);
188        return Some(entry);
189    }
190    let o = EntryObject::read(v, cx);
191    // The value is checked even when another field is wrong: every problem at once.
192    let value = v
193        .get("value")
194        .and_then(|val| cx.at(Seg::Key("value".into()), |cx| scalar(val, cx)));
195    let (o, value) = (o?, value?);
196    let kind = o.kind.unwrap_or(kind_of(&value));
197    let fits = match (kind, &value) {
198        (k, Value::Bytes(_)) => k.holds_bytes(),
199        (Kind::Number, Value::Num(_))
200        | (Kind::Bool, Value::Bool(_))
201        | (Kind::String | Kind::Color, Value::Str(_)) => true,
202        _ => false,
203    };
204    if !fits {
205        let needs = match kind {
206            Kind::Number => "number",
207            Kind::Bool => "true/false",
208            k if k.holds_bytes() => "bytes (`@bytes:…`)",
209            _ => "string",
210        };
211        cx.at(Seg::Key("value".into()), |cx| {
212            cx.problem(format!("a {} needs a {needs} value", kind.name()))
213        });
214        return None;
215    }
216    let misplaced = [("key", o.key.is_some(), Kind::String)];
217    for (field, present, only) in misplaced {
218        if present && kind != only {
219            cx.at(Seg::Key(field.into()), |cx| {
220                cx.problem(format!("only {} entries have a `{field}`", only.name()))
221            });
222            return None;
223        }
224    }
225    entry.kind = kind;
226    entry.value = value;
227    (entry.comment, entry.key) = (o.comment, o.key);
228    Some(entry)
229}
230
231impl Schema for YresFile {
232    fn shape() -> Shape {
233        Shape::Map(EntryObject::shape)
234    }
235
236    fn read(v: &ValueRef<'_>, cx: &mut Reader) -> Option<Self> {
237        let ValueRef::Map(map) = v else {
238            cx.problem("expected `{` — a .yres file is one map of named resources");
239            return None;
240        };
241        let mut entries = Vec::with_capacity(map.len());
242        let mut ok = true;
243        // `$…` keys are the file's metadata (`$schema`); every other key names a resource.
244        for (name, v) in map.iter().filter(|(k, _)| !k.starts_with('$')) {
245            match cx.at(Seg::Key(name.to_string()), |cx| read_entry(name, v, cx)) {
246                Some(e) => entries.push(e),
247                None => ok = false,
248            }
249        }
250        ok.then_some(YresFile(entries))
251    }
252
253    fn write(&self) -> yog_data::Value {
254        let mut map = yog_data::Map::new();
255        for e in &self.0 {
256            let value = match &e.value {
257                Value::Str(s) => yog_data::Value::Str(s.clone()),
258                Value::Num(n) => n
259                    .parse::<i64>()
260                    .map(yog_data::Value::Int)
261                    .unwrap_or_else(|_| yog_data::Value::Float(n.parse().unwrap_or_default())),
262                Value::Bool(b) => yog_data::Value::Bool(*b),
263                Value::Bytes(b) => yog_data::Value::Bytes(b.clone()),
264            };
265            let plain = e.kind == kind_of(&e.value) && e.comment.is_none() && e.key.is_none();
266            if plain {
267                map.insert(e.name.clone(), value);
268                continue;
269            }
270            let o = EntryObject {
271                kind: (e.kind != kind_of(&e.value)).then_some(e.kind),
272                value,
273                comment: e.comment.clone(),
274                key: e.key.clone(),
275            };
276            map.insert(e.name.clone(), o.write());
277        }
278        yog_data::Value::Map(map)
279    }
280}
281
282/// Parses `.yres` text: its entries, or every problem, each with its code
283/// and place; names aren't validated here (see [`valid_name`]).
284pub fn parse(text: &str) -> Result<Vec<Entry>, yog_data::Errors> {
285    let doc = yog_data::text::parse(text).map_err(yog_data::Errors::from)?;
286    let mut entries = yog_data::schema::from_doc::<YresFile>(text, &doc)?.value.0;
287    // Each name's line: the spans are in the same order as the names.
288    let mut lines = HashMap::new();
289    if let yog_data::Value::Map(map) = &doc.value {
290        let mut line = 1;
291        let mut at = 0;
292        for ((name, _), spans) in map.iter().zip(&doc.spans.children) {
293            let start = spans.key.unwrap_or(spans.span).start as usize;
294            line += text.as_bytes()[at..start]
295                .iter()
296                .filter(|&&b| b == b'\n')
297                .count();
298            at = start;
299            lines.insert(name, line);
300        }
301    }
302    for e in &mut entries {
303        e.line = lines.get(e.name.as_str()).copied().unwrap_or(0);
304    }
305    Ok(entries)
306}
307
308/// `.yres` text for `entries`, as the IDE writes it.
309pub fn write(entries: &[Entry]) -> String {
310    yog_data::text::to_string_pretty(&YresFile(entries.to_vec()).write())
311}