Skip to main content

yog_formats/
yres.rs

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