unity_asset/environment/imp/
stream.rs

1use super::*;
2
3impl Environment {
4    fn normalize_stream_path(stream_path: &str) -> String {
5        let mut p = stream_path.trim().to_string();
6        if let Some(rest) = p.strip_prefix("archive:/") {
7            p = rest.to_string();
8        }
9        p = p.replace('\\', "/");
10        while p.starts_with("./") {
11            p = p.trim_start_matches("./").to_string();
12        }
13        p
14    }
15
16    fn cab_prefix_from_normalized(normalized: &str) -> Option<String> {
17        let needle = "CAB-";
18        let start = normalized.find(needle)?;
19        let mut hex = String::with_capacity(32);
20        for ch in normalized[start + needle.len()..].chars() {
21            if ch.is_ascii_hexdigit() && hex.len() < 32 {
22                hex.push(ch);
23            } else {
24                break;
25            }
26        }
27        if hex.len() == 32 {
28            Some(format!("CAB-{}", hex))
29        } else {
30            None
31        }
32    }
33
34    fn find_bundle_resource_node<'a>(
35        bundle: &'a AssetBundle,
36        stream_path: &str,
37    ) -> Option<&'a unity_asset_binary::bundle::types::DirectoryNode> {
38        let normalized = Self::normalize_stream_path(stream_path);
39        if normalized.is_empty() {
40            return None;
41        }
42
43        let file_name = Path::new(&normalized)
44            .file_name()
45            .and_then(|n| n.to_str())
46            .map(|s| s.to_string());
47
48        let mut nodes: Vec<&unity_asset_binary::bundle::types::DirectoryNode> =
49            bundle.nodes.iter().filter(|n| n.is_file()).collect();
50        nodes.sort_by(|a, b| a.name.cmp(&b.name));
51
52        for node in &nodes {
53            let node_norm = node.name.replace('\\', "/");
54            if node_norm == normalized
55                || node_norm.ends_with(&normalized)
56                || normalized.ends_with(&node_norm)
57            {
58                return Some(*node);
59            }
60
61            if let Some(file_name) = &file_name
62                && Path::new(&node_norm).file_name().and_then(|n| n.to_str())
63                    == Some(file_name.as_str())
64            {
65                return Some(*node);
66            }
67        }
68
69        // Unity sometimes appends an index suffix to the CAB resource node name
70        // (e.g. `CAB-<hash>1.resource`) while the `StreamedResource.m_Source` path
71        // points to `CAB-<hash>.resource`. Best-effort: match by CAB prefix.
72        let cab_prefix = normalized
73            .split('/')
74            .find(|s| s.starts_with("CAB-"))
75            .and_then(|s| {
76                let hash: String = s
77                    .trim_start_matches("CAB-")
78                    .chars()
79                    .take_while(|c| c.is_ascii_hexdigit())
80                    .collect();
81                if hash.is_empty() {
82                    None
83                } else {
84                    Some(format!("CAB-{}", hash))
85                }
86            });
87
88        if let Some(cab_prefix) = cab_prefix {
89            for node in &nodes {
90                let node_norm = node.name.replace('\\', "/");
91                let is_resource = node_norm.ends_with(".resS") || node_norm.ends_with(".resource");
92                let base = Path::new(&node_norm)
93                    .file_name()
94                    .and_then(|n| n.to_str())
95                    .unwrap_or(&node_norm);
96                if is_resource
97                    && (node_norm.starts_with(&cab_prefix) || base.starts_with(&cab_prefix))
98                {
99                    return Some(*node);
100                }
101            }
102        }
103
104        None
105    }
106
107    fn stream_fs_candidates(source_path: &Path, stream_path: &str) -> Vec<PathBuf> {
108        let base_dir = source_path.parent().unwrap_or_else(|| Path::new("."));
109        let normalized = Self::normalize_stream_path(stream_path);
110        let cab_prefix = Self::cab_prefix_from_normalized(&normalized);
111
112        let mut dirs = vec![base_dir.to_path_buf(), base_dir.join("StreamingAssets")];
113        if let Some(cab) = &cab_prefix {
114            dirs.push(base_dir.join(cab));
115            dirs.push(base_dir.join("StreamingAssets").join(cab));
116        }
117        dirs.sort();
118        dirs.dedup();
119
120        let mut candidates: Vec<PathBuf> = Vec::new();
121
122        // If the path already exists as-is (e.g. absolute path), try it first.
123        candidates.push(PathBuf::from(stream_path));
124
125        if !normalized.is_empty() {
126            candidates.push(base_dir.join(&normalized));
127            if let Some(file_name) = Path::new(&normalized).file_name() {
128                candidates.push(base_dir.join(file_name));
129                candidates.push(base_dir.join("StreamingAssets").join(file_name));
130            }
131        }
132
133        // Unity often stores resources as `CAB-<hash><n>.resource` / `.resS` on disk,
134        // while the stream path references `CAB-<hash>.resource` (no suffix).
135        if let Some(cab) = &cab_prefix {
136            for ext in ["resource", "resS"] {
137                for dir in &dirs {
138                    candidates.push(dir.join(format!("{cab}.{ext}")));
139                }
140                for suffix in 1..=9 {
141                    for dir in &dirs {
142                        candidates.push(dir.join(format!("{cab}{suffix}.{ext}")));
143                    }
144                }
145            }
146
147            // Targeted directory scans (non-recursive) to catch suffixes beyond 9.
148            for dir in &dirs {
149                if let Ok(entries) = std::fs::read_dir(dir) {
150                    for entry in entries.flatten() {
151                        let path = entry.path();
152                        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
153                            continue;
154                        };
155                        if !(name.ends_with(".resS") || name.ends_with(".resource")) {
156                            continue;
157                        }
158                        if name.starts_with(cab) {
159                            candidates.push(path);
160                        }
161                    }
162                }
163            }
164        }
165
166        candidates.sort();
167        candidates.dedup();
168        candidates
169    }
170
171    /// Read streamed resource bytes from a loaded bundle.
172    ///
173    /// This is primarily used for `AudioClip` / `Texture2D` stream data (`m_StreamData`) when the
174    /// referenced resource file is contained inside the same bundle (e.g. `.resS` / `.resource`).
175    pub fn read_bundle_stream_data<P: AsRef<Path>>(
176        &self,
177        bundle_path: P,
178        stream_path: &str,
179        offset: u64,
180        size: u32,
181    ) -> Result<Vec<u8>> {
182        let bundle_source = BinarySource::path(bundle_path.as_ref());
183        self.read_bundle_stream_data_source(&bundle_source, stream_path, offset, size)
184    }
185
186    pub fn read_bundle_stream_data_source(
187        &self,
188        bundle_source: &BinarySource,
189        stream_path: &str,
190        offset: u64,
191        size: u32,
192    ) -> Result<Vec<u8>> {
193        let bundle = self.bundles.get(bundle_source).ok_or_else(|| {
194            UnityAssetError::format(format!(
195                "AssetBundle source not loaded: {}",
196                bundle_source.describe()
197            ))
198        })?;
199
200        let node = Self::find_bundle_resource_node(bundle, stream_path).ok_or_else(|| {
201            UnityAssetError::format(format!(
202                "Resource node not found in bundle {}: {}",
203                bundle_source.describe(),
204                stream_path
205            ))
206        })?;
207
208        let node_start: usize = node.offset.try_into().map_err(|_| {
209            UnityAssetError::format(format!("Resource node offset overflow: {}", node.offset))
210        })?;
211        let node_size: usize = node.size.try_into().map_err(|_| {
212            UnityAssetError::format(format!("Resource node size overflow: {}", node.size))
213        })?;
214        let data = bundle.data();
215        if node_start.saturating_add(node_size) > data.len() {
216            return Err(UnityAssetError::format(format!(
217                "Resource node out of bounds: name={}, offset={}, size={}, bundle_len={}",
218                node.name,
219                node.offset,
220                node.size,
221                data.len()
222            )));
223        }
224
225        let offset_usize: usize = offset
226            .try_into()
227            .map_err(|_| UnityAssetError::format(format!("Stream offset overflow: {}", offset)))?;
228        let size_usize: usize = size
229            .try_into()
230            .map_err(|_| UnityAssetError::format(format!("Stream size overflow: {}", size)))?;
231
232        if offset_usize.saturating_add(size_usize) > node_size {
233            return Err(UnityAssetError::format(format!(
234                "Stream range out of bounds: name={}, stream_offset={}, stream_size={}, node_size={}",
235                node.name, offset, size, node.size
236            )));
237        }
238
239        let start = node_start.saturating_add(offset_usize);
240        let end = start.saturating_add(size_usize);
241        Ok(data[start..end].to_vec())
242    }
243
244    fn find_webfile_resource_entry(web: &WebFile, stream_path: &str) -> Option<String> {
245        let normalized = Self::normalize_stream_path(stream_path);
246        if normalized.is_empty() {
247            return None;
248        }
249
250        let file_name = Path::new(&normalized)
251            .file_name()
252            .and_then(|n| n.to_str())
253            .map(|s| s.to_string());
254
255        let mut names: Vec<&String> = web.files.iter().map(|f| &f.name).collect();
256        names.sort();
257
258        for name in &names {
259            let name_norm = name.replace('\\', "/");
260            if name_norm == normalized
261                || name_norm.ends_with(&normalized)
262                || normalized.ends_with(&name_norm)
263            {
264                return Some((*name).clone());
265            }
266
267            if let Some(file_name) = &file_name
268                && Path::new(&name_norm).file_name().and_then(|n| n.to_str())
269                    == Some(file_name.as_str())
270            {
271                return Some((*name).clone());
272            }
273        }
274
275        let cab_prefix = Self::cab_prefix_from_normalized(&normalized);
276        if let Some(cab) = cab_prefix {
277            for name in &names {
278                let name_norm = name.replace('\\', "/");
279                let base = Path::new(&name_norm)
280                    .file_name()
281                    .and_then(|n| n.to_str())
282                    .unwrap_or(&name_norm);
283                if (name_norm.ends_with(".resS") || name_norm.ends_with(".resource"))
284                    && (name_norm.starts_with(&cab) || base.starts_with(&cab))
285                {
286                    return Some((*name).clone());
287                }
288            }
289        }
290
291        None
292    }
293
294    fn read_webfile_stream_data(
295        &self,
296        web_path: &PathBuf,
297        stream_path: &str,
298        offset: u64,
299        size: u32,
300    ) -> Result<Vec<u8>> {
301        let web = self.webfiles.get(web_path).ok_or_else(|| {
302            UnityAssetError::format(format!("WebFile source not loaded: {:?}", web_path))
303        })?;
304
305        let entry_name = Self::find_webfile_resource_entry(web, stream_path).ok_or_else(|| {
306            UnityAssetError::format(format!(
307                "Resource entry not found in WebFile {:?}: {}",
308                web_path, stream_path
309            ))
310        })?;
311
312        let bytes = web.extract_file(&entry_name).map_err(|e| {
313            UnityAssetError::format(format!(
314                "Failed to extract WebFile entry {:?} from {:?}: {}",
315                entry_name, web_path, e
316            ))
317        })?;
318
319        let offset_usize: usize = offset
320            .try_into()
321            .map_err(|_| UnityAssetError::format(format!("Stream offset overflow: {}", offset)))?;
322        let size_usize: usize = size
323            .try_into()
324            .map_err(|_| UnityAssetError::format(format!("Stream size overflow: {}", size)))?;
325
326        if offset_usize.saturating_add(size_usize) > bytes.len() {
327            return Err(UnityAssetError::format(format!(
328                "Stream range out of bounds in WebFile entry {}: offset={}, size={}, entry_len={}",
329                entry_name,
330                offset,
331                size,
332                bytes.len()
333            )));
334        }
335
336        let start = offset_usize;
337        let end = start.saturating_add(size_usize);
338        Ok(bytes[start..end].to_vec())
339    }
340
341    /// Read streamed resource bytes (best-effort) using the current environment context.
342    ///
343    /// Resolution strategy:
344    /// - If `source_kind` is `AssetBundle`, try to read from resource nodes inside the same bundle.
345    /// - Fall back to reading from the filesystem (same directory / `StreamingAssets/`), which
346    ///   matches UnityPy's `ResourceReader`-like behavior.
347    pub fn read_stream_data<P: AsRef<Path>>(
348        &self,
349        source_path: P,
350        source_kind: BinarySourceKind,
351        stream_path: &str,
352        offset: u64,
353        size: u32,
354    ) -> Result<Vec<u8>> {
355        let source = BinarySource::path(source_path.as_ref());
356        self.read_stream_data_source(&source, source_kind, stream_path, offset, size)
357    }
358
359    pub fn read_stream_data_source(
360        &self,
361        source: &BinarySource,
362        source_kind: BinarySourceKind,
363        stream_path: &str,
364        offset: u64,
365        size: u32,
366    ) -> Result<Vec<u8>> {
367        if size == 0 {
368            return Ok(Vec::new());
369        }
370
371        match source_kind {
372            BinarySourceKind::AssetBundle => self
373                .read_bundle_stream_data_source(source, stream_path, offset, size)
374                .or_else(|_| match source {
375                    BinarySource::Path(p) => {
376                        self.read_stream_data_from_fs(p, stream_path, offset, size)
377                    }
378                    BinarySource::WebEntry { web_path, .. } => {
379                        self.read_webfile_stream_data(web_path, stream_path, offset, size)
380                    }
381                }),
382            BinarySourceKind::SerializedFile => match source {
383                BinarySource::Path(p) => {
384                    self.read_stream_data_from_fs(p, stream_path, offset, size)
385                }
386                BinarySource::WebEntry { web_path, .. } => {
387                    self.read_webfile_stream_data(web_path, stream_path, offset, size)
388                }
389            },
390        }
391    }
392
393    /// Read streamed resource bytes from the filesystem (best-effort).
394    ///
395    /// This is useful when `StreamedResource.m_Source` points to an external `.resS`/`.resource`
396    /// file that is not embedded in the current bundle.
397    pub fn read_stream_data_from_fs<P: AsRef<Path>>(
398        &self,
399        source_path: P,
400        stream_path: &str,
401        offset: u64,
402        size: u32,
403    ) -> Result<Vec<u8>> {
404        use std::fs::File;
405        use std::io::{Read, Seek, SeekFrom};
406
407        let source_path = source_path.as_ref();
408        let candidates = Self::stream_fs_candidates(source_path, stream_path);
409        for candidate in candidates {
410            if !candidate.exists() {
411                continue;
412            }
413            let mut file = File::open(&candidate).map_err(|e| {
414                UnityAssetError::with_source(
415                    format!("Failed to open stream resource {:?}", candidate),
416                    e,
417                )
418            })?;
419            file.seek(SeekFrom::Start(offset)).map_err(|e| {
420                UnityAssetError::with_source(
421                    format!(
422                        "Failed to seek stream resource {:?} to {}",
423                        candidate, offset
424                    ),
425                    e,
426                )
427            })?;
428
429            let mut buffer = vec![0u8; size as usize];
430            file.read_exact(&mut buffer).map_err(|e| {
431                UnityAssetError::with_source(
432                    format!(
433                        "Failed to read stream resource {:?} (offset={}, size={})",
434                        candidate, offset, size
435                    ),
436                    e,
437                )
438            })?;
439            return Ok(buffer);
440        }
441
442        Err(UnityAssetError::format(format!(
443            "Stream resource file not found for source {:?}: {}",
444            source_path, stream_path
445        )))
446    }
447}