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
7/// Errors from `.meta` parsing.
8#[derive(Debug, thiserror::Error)]
9pub enum MetaParseError {
10    #[error("missing or malformed `guid:` in .meta")]
11    MissingGuid,
12}
13
14/// `TextureImporter.textureType` enum — Unity only auto-generates a
15/// Sprite sub-object when the importer is in Sprite mode.
16pub const TEXTURE_TYPE_SPRITE: u32 = 8;
17
18/// `TextureImporter.spriteMode` — Single produces one implicit Sprite
19/// named after the texture file; Multiple uses the `sprites:` list.
20pub const SPRITE_MODE_SINGLE: u32 = 1;
21
22/// Parsed contents of a `.meta` file.
23#[derive(Debug, Clone, Default)]
24pub struct MetaInfo {
25    /// 32-hex GUID parsed as u128.
26    pub guid: u128,
27    /// Texture sprite-sheet sub-assets, if the importer is TextureImporter
28    /// with sprite mode = Multiple. `(file_id, name)` pairs.
29    pub sprite_sheet: Vec<(i64, String)>,
30    /// `TextureImporter.textureType`. None for non-texture importers.
31    pub texture_type: Option<u32>,
32    /// `TextureImporter.spriteMode`. None for non-texture importers.
33    pub sprite_mode: Option<u32>,
34}
35
36/// Parse a `.meta` file's text contents.
37///
38/// Format reference: <https://docs.unity3d.com/Manual/SpecialFolders.html>
39/// Robust enough for the YAML subset Unity emits — line-oriented, `key: value`,
40/// without resorting to a full YAML parser.
41///
42/// Single-pass: walks every line once, picking off the guid, the
43/// TextureImporter mode fields, and the spriteSheet `sprites:` list in
44/// the same loop. Replaces the older four-scan-per-file shape — a
45/// measurable cold-path win since `str::lines` is memchr-bound and
46/// every redundant pass is wasted SIMD time.
47pub fn parse(text: &str) -> Result<MetaInfo, MetaParseError> {
48    let mut info = MetaInfo::default();
49    let mut have_guid = false;
50
51    // SpriteSheet `sprites:` list cursor. `in_sprites` is true between the
52    // `sprites:` key line and the indent drop that ends the block. Per-item
53    // `cur_name` / `cur_id` accumulate the current entry; flushed on each
54    // new `-` and at block exit.
55    let mut in_sprites = false;
56    let mut cur_name: Option<String> = None;
57    let mut cur_id: Option<i64> = None;
58
59    for line in text.lines() {
60        let trimmed_left = line.trim_start();
61
62        // Top-level key scans — INDEPENDENT of `in_sprites` state. Each
63        // matches only when the line (after left-trim) starts with the
64        // key, so a `- name: textureType: …` sprite entry can't false-
65        // match (it starts with `- `, not `textureType:`).
66        if !have_guid
67            && let Some(rest) = trimmed_left.strip_prefix("guid:")
68        {
69            let hex = rest.trim();
70            if hex.len() == 32
71                && let Ok(g) = u128::from_str_radix(hex, 16)
72            {
73                info.guid = g;
74                have_guid = true;
75            }
76        } else if info.texture_type.is_none()
77            && let Some(rest) = trimmed_left.strip_prefix("textureType:")
78        {
79            info.texture_type = rest.trim().parse().ok();
80        } else if info.sprite_mode.is_none()
81            && let Some(rest) = trimmed_left.strip_prefix("spriteMode:")
82        {
83            info.sprite_mode = rest.trim().parse().ok();
84        }
85
86        // SpriteSheet sub-list — separate state machine. Top-level keys
87        // above (textureType/spriteMode) sit at SHALLOWER indent than
88        // sprite-entry contents in Unity's output, but we don't rely on
89        // that here: the entry parser only matches `- ` list openers and
90        // `name:` / `internalID:` lines, neither of which collides with
91        // the top-level scans above.
92        if in_sprites {
93            let trimmed = line.trim();
94            // Block ends when indent drops to root (sibling key with no
95            // leading whitespace). Empty lines are tolerated.
96            if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
97                in_sprites = false;
98                flush_sprite(&mut info.sprite_sheet, &mut cur_name, &mut cur_id);
99            } else if let Some(after_dash) = trimmed.strip_prefix("- ") {
100                flush_sprite(&mut info.sprite_sheet, &mut cur_name, &mut cur_id);
101                absorb_kv(after_dash, &mut cur_name, &mut cur_id);
102            } else {
103                absorb_kv(trimmed, &mut cur_name, &mut cur_id);
104            }
105        } else if trimmed_left == "sprites:" {
106            in_sprites = true;
107        }
108    }
109
110    // Flush a trailing sprite entry if the file ended inside the block.
111    flush_sprite(&mut info.sprite_sheet, &mut cur_name, &mut cur_id);
112
113    if !have_guid {
114        return Err(MetaParseError::MissingGuid);
115    }
116    Ok(info)
117}
118
119fn absorb_kv(line: &str, cur_name: &mut Option<String>, cur_id: &mut Option<i64>) {
120    if let Some(rest) = line.strip_prefix("name:") {
121        let s = rest.trim();
122        if cur_name.is_none() && !s.is_empty() {
123            *cur_name = Some(s.to_string());
124        }
125    } else if let Some(rest) = line.strip_prefix("internalID:") {
126        *cur_id = rest.trim().parse().ok();
127    }
128}
129
130fn flush_sprite(out: &mut Vec<(i64, String)>, name: &mut Option<String>, id: &mut Option<i64>) {
131    if let (Some(n), Some(i)) = (name.take(), id.take())
132        && !n.is_empty()
133    {
134        out.push((i, n));
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn parses_simple_guid() {
144        let text = "fileFormatVersion: 2\nguid: 7d602c2080b53413fa393df6b2c0af43\n";
145        let info = parse(text).unwrap();
146        assert_eq!(info.guid, 0x7d602c2080b53413fa393df6b2c0af43_u128);
147        assert!(info.sprite_sheet.is_empty());
148    }
149
150    #[test]
151    fn rejects_short_guid() {
152        let text = "guid: deadbeef\n";
153        assert!(parse(text).is_err());
154    }
155
156    #[test]
157    fn parses_sprite_sheet() {
158        let text = "fileFormatVersion: 2
159guid: 7d602c2080b53413fa393df6b2c0af43
160TextureImporter:
161  spriteSheet:
162    sprites:
163    - serializedVersion: 2
164      name: spr_a
165      internalID: 11111
166      rect:
167        serializedVersion: 2
168    - serializedVersion: 2
169      name: spr_b
170      internalID: 22222
171  spritePackingTag:
172";
173        let info = parse(text).unwrap();
174        assert_eq!(
175            info.sprite_sheet,
176            vec![(11111, "spr_a".to_string()), (22222, "spr_b".to_string()),]
177        );
178    }
179
180    #[test]
181    fn parses_texture_type_and_sprite_mode() {
182        let text = "fileFormatVersion: 2
183guid: 7d602c2080b53413fa393df6b2c0af43
184TextureImporter:
185  textureType: 8
186  spriteMode: 1
187  spriteSheet:
188    sprites: []
189";
190        let info = parse(text).unwrap();
191        assert_eq!(info.texture_type, Some(8));
192        assert_eq!(info.sprite_mode, Some(1));
193    }
194
195    #[test]
196    fn missing_texture_fields_are_none() {
197        let text = "fileFormatVersion: 2\nguid: 7d602c2080b53413fa393df6b2c0af43\n";
198        let info = parse(text).unwrap();
199        assert_eq!(info.texture_type, None);
200        assert_eq!(info.sprite_mode, None);
201    }
202
203    /// Single-pass parser correctness: a `name:` key sitting INSIDE the
204    /// `sprites:` block must NOT leak out and pollute any top-level key
205    /// match (e.g. an erroneous `guid:` parse off a sub-entry's `name:`).
206    /// Regression for cross-block bleed-through — the kind of bug
207    /// single-pass parsers fail at when they share state inappropriately.
208    #[test]
209    fn sprites_block_inner_keys_dont_leak_to_top_level() {
210        let text = "fileFormatVersion: 2
211guid: aabbccddaabbccddaabbccddaabbccdd
212TextureImporter:
213  spriteSheet:
214    sprites:
215    - name: textureType: 99
216      internalID: 12345
217  textureType: 8
218  spriteMode: 2
219";
220        let info = parse(text).unwrap();
221        // sub-entry's `name:` is a sprite name, NOT the importer's
222        // textureType (which is 8 below the block, not 99 inside).
223        assert_eq!(info.texture_type, Some(8));
224        assert_eq!(info.sprite_mode, Some(2));
225        assert_eq!(info.sprite_sheet.len(), 1);
226        assert_eq!(info.sprite_sheet[0].0, 12345);
227    }
228
229    /// Key order independence: pre-2022 .meta files sometimes emit
230    /// `textureType` BEFORE `spriteSheet:` rather than after. The
231    /// single-pass scanner must pick both up regardless of order.
232    #[test]
233    fn key_order_independence() {
234        let text = "fileFormatVersion: 2
235guid: aabbccddaabbccddaabbccddaabbccdd
236TextureImporter:
237  textureType: 8
238  spriteMode: 1
239  spriteSheet:
240    sprites: []
241  externalObjects: {}
242";
243        let info = parse(text).unwrap();
244        assert_eq!(info.texture_type, Some(8));
245        assert_eq!(info.sprite_mode, Some(1));
246        assert!(info.sprite_sheet.is_empty());
247    }
248
249    /// CRLF line endings (Unity-on-Windows project, occasional cross-OS
250    /// fixture). `str::lines` already handles both \n and \r\n; pin that
251    /// our key strip-prefix matches don't accidentally include a trailing
252    /// \r in the value parse.
253    #[test]
254    fn crlf_line_endings() {
255        let text = "fileFormatVersion: 2\r\nguid: 7d602c2080b53413fa393df6b2c0af43\r\nTextureImporter:\r\n  textureType: 8\r\n  spriteMode: 1\r\n";
256        let info = parse(text).unwrap();
257        assert_eq!(info.guid, 0x7d602c2080b53413fa393df6b2c0af43_u128);
258        assert_eq!(info.texture_type, Some(8));
259        assert_eq!(info.sprite_mode, Some(1));
260    }
261
262    /// Empty file → clean error (not a panic). `process_one` catches
263    /// the parse error and surfaces it as a worker error; this test
264    /// pins the boundary so the `?` propagation stays valid.
265    #[test]
266    fn empty_file_errors_cleanly() {
267        let err = parse("").unwrap_err();
268        assert!(matches!(err, MetaParseError::MissingGuid));
269    }
270}