Skip to main content

minecraft_java_rs_core/game/
assets.rs

1use crate::error::LaunchError;
2use crate::launcher::options::LaunchOptions;
3use crate::models::minecraft::{AssetIndexData, AssetItem, MinecraftVersionJson};
4use crate::net::http::fetch_text;
5
6const RESOURCES_BASE: &str = "https://resources.download.minecraft.net";
7
8// ── Public API ────────────────────────────────────────────────────────────────
9
10/// Build the full list of asset items that the launcher must have on disk.
11///
12/// Returns two kinds of [`AssetItem`]:
13/// - `CFile` — the asset-index JSON itself (written verbatim to
14///   `<path>/assets/indexes/<id>.json`).
15/// - `Asset` — each hashed object in the index (downloaded from Mojang CDN).
16///
17/// All paths are **absolute** (prefixed with `options.path`).
18/// If `version_json` has no `asset_index`, an empty `Vec` is returned.
19pub async fn get_assets(
20    options: &LaunchOptions,
21    version_json: &MinecraftVersionJson,
22    client: &reqwest::Client,
23) -> Result<Vec<AssetItem>, LaunchError> {
24    let ai = match &version_json.asset_index {
25        Some(ai) => ai,
26        None => return Ok(vec![]),
27    };
28
29    let raw = fetch_text(client, &ai.url)
30        .await
31        .map_err(LaunchError::InvalidData)?;
32    let data: AssetIndexData = serde_json::from_str(&raw)
33        .map_err(|e| LaunchError::InvalidData(format!("GET {}: failed to parse asset index: {e}", &ai.url)))?;
34
35    let base = &options.path;
36    let mut items: Vec<AssetItem> = Vec::with_capacity(data.objects.len() + 1);
37
38    // The index JSON is stored as a CFile so the downloader writes it verbatim.
39    items.push(AssetItem::CFile {
40        path: base
41            .join("assets")
42            .join("indexes")
43            .join(format!("{}.json", ai.id))
44            .to_string_lossy()
45            .into_owned(),
46        content: raw,
47    });
48
49    for obj in data.objects.values() {
50        let sub = &obj.hash[..2];
51        items.push(AssetItem::Asset {
52            path: base
53                .join("assets")
54                .join("objects")
55                .join(sub)
56                .join(&obj.hash)
57                .to_string_lossy()
58                .into_owned(),
59            sha1: obj.hash.clone(),
60            size: obj.size,
61            url: format!("{RESOURCES_BASE}/{sub}/{}", obj.hash),
62        });
63    }
64
65    Ok(items)
66}
67
68/// Copy legacy assets from the object store into a flat `resources/` tree.
69///
70/// Only meaningful for old Minecraft versions (assets `"legacy"` /
71/// `"pre-1.6"`).  The caller is responsible for deciding when to invoke this
72/// based on [`crate::utils::version_check::is_old`].
73///
74/// If the local index file does not yet exist (assets not downloaded),
75/// this is a no-op.
76pub async fn copy_assets(
77    options: &LaunchOptions,
78    version_json: &MinecraftVersionJson,
79) -> Result<(), LaunchError> {
80    let assets_id = match &version_json.assets {
81        Some(a) => a.clone(),
82        None => return Ok(()),
83    };
84
85    let index_path = options
86        .path
87        .join("assets")
88        .join("indexes")
89        .join(format!("{assets_id}.json"));
90
91    if !index_path.exists() {
92        return Ok(());
93    }
94
95    let raw = tokio::fs::read_to_string(&index_path).await?;
96    let data: AssetIndexData = serde_json::from_str(&raw)?;
97
98    let legacy_dir = match &options.instance {
99        Some(inst) => options
100            .path
101            .join("instances")
102            .join(inst)
103            .join("resources"),
104        None => options.path.join("resources"),
105    };
106
107    for (file_path, obj) in &data.objects {
108        let sub = &obj.hash[..2];
109        let source = options
110            .path
111            .join("assets")
112            .join("objects")
113            .join(sub)
114            .join(&obj.hash);
115
116        if !source.exists() {
117            continue;
118        }
119
120        let target = legacy_dir.join(file_path);
121
122        if let Some(parent) = target.parent() {
123            tokio::fs::create_dir_all(parent).await?;
124        }
125
126        if !target.exists() {
127            tokio::fs::copy(&source, &target).await?;
128        }
129    }
130
131    Ok(())
132}
133
134// ── Tests ─────────────────────────────────────────────────────────────────────
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::path::PathBuf;
140    use tempfile::TempDir;
141
142    fn opts(path: PathBuf) -> LaunchOptions {
143        use crate::models::minecraft::Authenticator;
144        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
145        LaunchOptions {
146            path,
147            version: "1.20.4".into(),
148            authenticator: Authenticator {
149                access_token: "tok".into(),
150                name: "Player".into(),
151                uuid: "uuid".into(),
152                xbox_account: None,
153                user_properties: None,
154                client_id: None,
155                client_token: None,
156            },
157            timeout_secs: 10,
158            download_concurrency: 5,
159            verify_concurrency: 4,
160            memory: MemoryConfig::default(),
161            java: JavaOptions::default(),
162            loader: LoaderConfig::default(),
163            screen: ScreenConfig::default(),
164            verify: false,
165            game_args: vec![],
166            jvm_args: vec![],
167            instance: None,
168            url: None,
169            mcp: None,
170            intel_enabled_mac: false,
171            bypass_offline: false,
172            skip_bundle_check: false,
173        }
174    }
175
176    fn version_json_no_assets() -> MinecraftVersionJson {
177        MinecraftVersionJson {
178            id: "1.20.4".into(),
179            version_type: "release".into(),
180            assets: None,
181            asset_index: None,
182            downloads: None,
183            libraries: vec![],
184            arguments: None,
185            minecraft_arguments: None,
186            java_version: None,
187            main_class: None,
188            has_natives: false,
189        }
190    }
191
192    #[tokio::test]
193    async fn get_assets_returns_empty_without_asset_index() {
194        let dir = TempDir::new().unwrap();
195        let client = reqwest::Client::new();
196        let result = get_assets(&opts(dir.path().to_path_buf()), &version_json_no_assets(), &client)
197            .await
198            .unwrap();
199        assert!(result.is_empty());
200    }
201
202    #[tokio::test]
203    async fn copy_assets_noop_when_no_assets_field() {
204        let dir = TempDir::new().unwrap();
205        let vj = version_json_no_assets();
206        copy_assets(&opts(dir.path().to_path_buf()), &vj).await.unwrap();
207    }
208
209    #[tokio::test]
210    async fn copy_assets_noop_when_index_missing() {
211        let dir = TempDir::new().unwrap();
212        let mut vj = version_json_no_assets();
213        vj.assets = Some("legacy".into());
214        // index file doesn't exist → should return Ok(()) without creating anything
215        copy_assets(&opts(dir.path().to_path_buf()), &vj).await.unwrap();
216        assert!(!dir.path().join("resources").exists());
217    }
218
219    #[tokio::test]
220    async fn copy_assets_copies_objects_to_resources() {
221        let dir = TempDir::new().unwrap();
222        let base = dir.path();
223
224        // Write a fake asset object
225        let hash = "aabbccddee112233445566778899001122334455";
226        let sub = &hash[..2];
227        let obj_dir = base.join("assets").join("objects").join(sub);
228        tokio::fs::create_dir_all(&obj_dir).await.unwrap();
229        tokio::fs::write(obj_dir.join(hash), b"fake asset content").await.unwrap();
230
231        // Write the asset index pointing to it
232        let index_json = format!(
233            r#"{{"objects": {{"sounds/ambient/cave.ogg": {{"hash": "{hash}", "size": 18}}}}}}"#
234        );
235        let idx_dir = base.join("assets").join("indexes");
236        tokio::fs::create_dir_all(&idx_dir).await.unwrap();
237        tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
238
239        let mut vj = version_json_no_assets();
240        vj.assets = Some("legacy".into());
241
242        copy_assets(&opts(base.to_path_buf()), &vj).await.unwrap();
243
244        let copied = base.join("resources").join("sounds").join("ambient").join("cave.ogg");
245        assert!(copied.exists(), "asset should have been copied to resources/");
246        assert_eq!(std::fs::read(&copied).unwrap(), b"fake asset content");
247    }
248
249    #[tokio::test]
250    async fn copy_assets_skips_existing_target() {
251        let dir = TempDir::new().unwrap();
252        let base = dir.path();
253
254        let hash = "aabbccddee112233445566778899001122334455";
255        let sub = &hash[..2];
256        let obj_dir = base.join("assets").join("objects").join(sub);
257        tokio::fs::create_dir_all(&obj_dir).await.unwrap();
258        tokio::fs::write(obj_dir.join(hash), b"new content").await.unwrap();
259
260        let index_json = format!(
261            r#"{{"objects": {{"file.txt": {{"hash": "{hash}", "size": 11}}}}}}"#
262        );
263        let idx_dir = base.join("assets").join("indexes");
264        tokio::fs::create_dir_all(&idx_dir).await.unwrap();
265        tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
266
267        // Pre-create target with different content
268        let resources_dir = base.join("resources");
269        tokio::fs::create_dir_all(&resources_dir).await.unwrap();
270        tokio::fs::write(resources_dir.join("file.txt"), b"original").await.unwrap();
271
272        let mut vj = version_json_no_assets();
273        vj.assets = Some("legacy".into());
274
275        copy_assets(&opts(base.to_path_buf()), &vj).await.unwrap();
276
277        // Existing file must NOT be overwritten
278        let content = std::fs::read(resources_dir.join("file.txt")).unwrap();
279        assert_eq!(content, b"original");
280    }
281
282    #[tokio::test]
283    async fn copy_assets_uses_instance_resources_dir() {
284        let dir = TempDir::new().unwrap();
285        let base = dir.path();
286
287        let hash = "aabbccddee112233445566778899001122334455";
288        let sub = &hash[..2];
289        let obj_dir = base.join("assets").join("objects").join(sub);
290        tokio::fs::create_dir_all(&obj_dir).await.unwrap();
291        tokio::fs::write(obj_dir.join(hash), b"sound").await.unwrap();
292
293        let index_json = format!(
294            r#"{{"objects": {{"a.ogg": {{"hash": "{hash}", "size": 5}}}}}}"#
295        );
296        let idx_dir = base.join("assets").join("indexes");
297        tokio::fs::create_dir_all(&idx_dir).await.unwrap();
298        tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
299
300        let mut options = opts(base.to_path_buf());
301        options.instance = Some("myworld".into());
302
303        let mut vj = version_json_no_assets();
304        vj.assets = Some("legacy".into());
305
306        copy_assets(&options, &vj).await.unwrap();
307
308        let target = base.join("instances").join("myworld").join("resources").join("a.ogg");
309        assert!(target.exists());
310    }
311}