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