Skip to main content

unity_assetdb/
meta.rs

1//! Parser for Unity `.meta` sidecar files.
2//!
3//! Pulls top-level GUID, sprite-sheet sub-asset names, and the
4//! TextureImporter mode fields the bake needs to recognize Single-mode
5//! Sprite textures.
6
7use anyhow::{Context, Result};
8
9/// `TextureImporter.textureType` enum — Unity only auto-generates a
10/// Sprite sub-object when the importer is in Sprite mode.
11pub const TEXTURE_TYPE_SPRITE: u32 = 8;
12
13/// `TextureImporter.spriteMode` — Single produces one implicit Sprite
14/// named after the texture file; Multiple uses the `sprites:` list.
15pub const SPRITE_MODE_SINGLE: u32 = 1;
16
17/// Parsed contents of a `.meta` file.
18#[derive(Debug, Clone, Default)]
19pub struct MetaInfo {
20    /// 32-hex GUID parsed as u128.
21    pub guid: u128,
22    /// Texture sprite-sheet sub-assets, if the importer is TextureImporter
23    /// with sprite mode = Multiple. `(file_id, name)` pairs.
24    pub sprite_sheet: Vec<(i64, String)>,
25    /// `TextureImporter.textureType`. None for non-texture importers.
26    pub texture_type: Option<u32>,
27    /// `TextureImporter.spriteMode`. None for non-texture importers.
28    pub sprite_mode: Option<u32>,
29}
30
31/// Parse a `.meta` file's text contents.
32///
33/// Format reference: <https://docs.unity3d.com/Manual/SpecialFolders.html>
34/// Robust enough for the YAML subset Unity emits — line-oriented, `key: value`,
35/// without resorting to a full YAML parser.
36pub fn parse(text: &str) -> Result<MetaInfo> {
37    let guid = parse_guid(text).context("missing or malformed `guid:` in .meta")?;
38    let sprite_sheet = parse_sprite_sheet(text);
39    let texture_type = parse_u32_field(text, "textureType:");
40    let sprite_mode = parse_u32_field(text, "spriteMode:");
41    Ok(MetaInfo {
42        guid,
43        sprite_sheet,
44        texture_type,
45        sprite_mode,
46    })
47}
48
49/// First-match scan for a u32 scalar at any indent. `key` includes the
50/// trailing `:` (e.g. `"textureType:"`) — caller's responsibility to
51/// pick a key unique to TextureImporter so the whole-file scan can't
52/// false-match a longer key with the same prefix.
53fn parse_u32_field(text: &str, key: &str) -> Option<u32> {
54    for line in text.lines() {
55        let line = line.trim_start();
56        if let Some(rest) = line.strip_prefix(key) {
57            return rest.trim().parse().ok();
58        }
59    }
60    None
61}
62
63fn parse_guid(text: &str) -> Option<u128> {
64    for line in text.lines() {
65        let line = line.trim_start();
66        if let Some(rest) = line.strip_prefix("guid:") {
67            let hex = rest.trim();
68            if hex.len() == 32 {
69                return u128::from_str_radix(hex, 16).ok();
70            }
71        }
72    }
73    None
74}
75
76/// Walks the `spriteSheet:` block under `TextureImporter:` and the
77/// follow-on `sprites:` list, capturing each sprite's `name` + `internalID`.
78/// Stays line-oriented; Unity emits a stable indented form.
79///
80/// List-item detection: any non-empty line whose first non-space char is
81/// `-` opens a new entry, regardless of which key follows the dash. We
82/// flush the previous entry on each new dash and at end-of-block.
83fn parse_sprite_sheet(text: &str) -> Vec<(i64, String)> {
84    let mut out: Vec<(i64, String)> = Vec::new();
85    let mut in_sprites = false;
86    let mut cur_name: Option<String> = None;
87    let mut cur_id: Option<i64> = None;
88
89    for line in text.lines() {
90        let trimmed = line.trim();
91
92        if trimmed == "sprites:" {
93            in_sprites = true;
94            continue;
95        }
96        if !in_sprites {
97            continue;
98        }
99
100        // Block ends when indent drops to root (sibling key with no leading
101        // whitespace). Empty lines are tolerated.
102        if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
103            break;
104        }
105
106        // New list item. Strip the dash; the rest of the line is the
107        // first key:value pair of the entry (often `serializedVersion: 2`
108        // or `name: foo`).
109        if let Some(after_dash) = trimmed.strip_prefix("- ") {
110            flush(&mut out, &mut cur_name, &mut cur_id);
111            absorb_kv(after_dash, &mut cur_name, &mut cur_id);
112            continue;
113        }
114
115        absorb_kv(trimmed, &mut cur_name, &mut cur_id);
116    }
117    flush(&mut out, &mut cur_name, &mut cur_id);
118    out
119}
120
121fn absorb_kv(line: &str, cur_name: &mut Option<String>, cur_id: &mut Option<i64>) {
122    if let Some(rest) = line.strip_prefix("name:") {
123        let s = rest.trim();
124        if cur_name.is_none() && !s.is_empty() {
125            *cur_name = Some(s.to_string());
126        }
127    } else if let Some(rest) = line.strip_prefix("internalID:") {
128        *cur_id = rest.trim().parse().ok();
129    }
130}
131
132fn flush(out: &mut Vec<(i64, String)>, name: &mut Option<String>, id: &mut Option<i64>) {
133    if let (Some(n), Some(i)) = (name.take(), id.take())
134        && !n.is_empty()
135    {
136        out.push((i, n));
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parses_simple_guid() {
146        let text = "fileFormatVersion: 2\nguid: 7d602c2080b53413fa393df6b2c0af43\n";
147        let info = parse(text).unwrap();
148        assert_eq!(info.guid, 0x7d602c2080b53413fa393df6b2c0af43_u128);
149        assert!(info.sprite_sheet.is_empty());
150    }
151
152    #[test]
153    fn rejects_short_guid() {
154        let text = "guid: deadbeef\n";
155        assert!(parse(text).is_err());
156    }
157
158    #[test]
159    fn parses_sprite_sheet() {
160        let text = "fileFormatVersion: 2
161guid: 7d602c2080b53413fa393df6b2c0af43
162TextureImporter:
163  spriteSheet:
164    sprites:
165    - serializedVersion: 2
166      name: spr_a
167      internalID: 11111
168      rect:
169        serializedVersion: 2
170    - serializedVersion: 2
171      name: spr_b
172      internalID: 22222
173  spritePackingTag:
174";
175        let info = parse(text).unwrap();
176        assert_eq!(
177            info.sprite_sheet,
178            vec![(11111, "spr_a".to_string()), (22222, "spr_b".to_string()),]
179        );
180    }
181
182    #[test]
183    fn parses_texture_type_and_sprite_mode() {
184        let text = "fileFormatVersion: 2
185guid: 7d602c2080b53413fa393df6b2c0af43
186TextureImporter:
187  textureType: 8
188  spriteMode: 1
189  spriteSheet:
190    sprites: []
191";
192        let info = parse(text).unwrap();
193        assert_eq!(info.texture_type, Some(8));
194        assert_eq!(info.sprite_mode, Some(1));
195    }
196
197    #[test]
198    fn missing_texture_fields_are_none() {
199        let text = "fileFormatVersion: 2\nguid: 7d602c2080b53413fa393df6b2c0af43\n";
200        let info = parse(text).unwrap();
201        assert_eq!(info.texture_type, None);
202        assert_eq!(info.sprite_mode, None);
203    }
204}