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)
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 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
68pub 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#[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 }
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(&opts(dir.path().to_path_buf()), &version_json_no_assets(), &client)
196 .await
197 .unwrap();
198 assert!(result.is_empty());
199 }
200
201 #[tokio::test]
202 async fn copy_assets_noop_when_no_assets_field() {
203 let dir = TempDir::new().unwrap();
204 let vj = version_json_no_assets();
205 copy_assets(&opts(dir.path().to_path_buf()), &vj).await.unwrap();
206 }
207
208 #[tokio::test]
209 async fn copy_assets_noop_when_index_missing() {
210 let dir = TempDir::new().unwrap();
211 let mut vj = version_json_no_assets();
212 vj.assets = Some("legacy".into());
213 copy_assets(&opts(dir.path().to_path_buf()), &vj).await.unwrap();
215 assert!(!dir.path().join("resources").exists());
216 }
217
218 #[tokio::test]
219 async fn copy_assets_copies_objects_to_resources() {
220 let dir = TempDir::new().unwrap();
221 let base = dir.path();
222
223 let hash = "aabbccddee112233445566778899001122334455";
225 let sub = &hash[..2];
226 let obj_dir = base.join("assets").join("objects").join(sub);
227 tokio::fs::create_dir_all(&obj_dir).await.unwrap();
228 tokio::fs::write(obj_dir.join(hash), b"fake asset content").await.unwrap();
229
230 let index_json = format!(
232 r#"{{"objects": {{"sounds/ambient/cave.ogg": {{"hash": "{hash}", "size": 18}}}}}}"#
233 );
234 let idx_dir = base.join("assets").join("indexes");
235 tokio::fs::create_dir_all(&idx_dir).await.unwrap();
236 tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
237
238 let mut vj = version_json_no_assets();
239 vj.assets = Some("legacy".into());
240
241 copy_assets(&opts(base.to_path_buf()), &vj).await.unwrap();
242
243 let copied = base.join("resources").join("sounds").join("ambient").join("cave.ogg");
244 assert!(copied.exists(), "asset should have been copied to resources/");
245 assert_eq!(std::fs::read(&copied).unwrap(), b"fake asset content");
246 }
247
248 #[tokio::test]
249 async fn copy_assets_skips_existing_target() {
250 let dir = TempDir::new().unwrap();
251 let base = dir.path();
252
253 let hash = "aabbccddee112233445566778899001122334455";
254 let sub = &hash[..2];
255 let obj_dir = base.join("assets").join("objects").join(sub);
256 tokio::fs::create_dir_all(&obj_dir).await.unwrap();
257 tokio::fs::write(obj_dir.join(hash), b"new content").await.unwrap();
258
259 let index_json = format!(
260 r#"{{"objects": {{"file.txt": {{"hash": "{hash}", "size": 11}}}}}}"#
261 );
262 let idx_dir = base.join("assets").join("indexes");
263 tokio::fs::create_dir_all(&idx_dir).await.unwrap();
264 tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
265
266 let resources_dir = base.join("resources");
268 tokio::fs::create_dir_all(&resources_dir).await.unwrap();
269 tokio::fs::write(resources_dir.join("file.txt"), b"original").await.unwrap();
270
271 let mut vj = version_json_no_assets();
272 vj.assets = Some("legacy".into());
273
274 copy_assets(&opts(base.to_path_buf()), &vj).await.unwrap();
275
276 let content = std::fs::read(resources_dir.join("file.txt")).unwrap();
278 assert_eq!(content, b"original");
279 }
280
281 #[tokio::test]
282 async fn copy_assets_uses_instance_resources_dir() {
283 let dir = TempDir::new().unwrap();
284 let base = dir.path();
285
286 let hash = "aabbccddee112233445566778899001122334455";
287 let sub = &hash[..2];
288 let obj_dir = base.join("assets").join("objects").join(sub);
289 tokio::fs::create_dir_all(&obj_dir).await.unwrap();
290 tokio::fs::write(obj_dir.join(hash), b"sound").await.unwrap();
291
292 let index_json = format!(
293 r#"{{"objects": {{"a.ogg": {{"hash": "{hash}", "size": 5}}}}}}"#
294 );
295 let idx_dir = base.join("assets").join("indexes");
296 tokio::fs::create_dir_all(&idx_dir).await.unwrap();
297 tokio::fs::write(idx_dir.join("legacy.json"), &index_json).await.unwrap();
298
299 let mut options = opts(base.to_path_buf());
300 options.instance = Some("myworld".into());
301
302 let mut vj = version_json_no_assets();
303 vj.assets = Some("legacy".into());
304
305 copy_assets(&options, &vj).await.unwrap();
306
307 let target = base.join("instances").join("myworld").join("resources").join("a.ogg");
308 assert!(target.exists());
309 }
310}