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