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