minecraft_java_rs_core/game/
assets.rs1use 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
8pub 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 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
69pub 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#[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 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 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 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 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 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}