1use serde::{Deserialize, Serialize, de};
7use std::fmt;
8
9#[derive(Debug, Clone, Deserialize, Serialize)]
11#[serde(rename_all = "PascalCase")]
12#[non_exhaustive]
13pub struct SaveRoot {
14 pub version: u32,
15 pub platform: String,
16 pub active_context: String,
17 pub common_state_data: CommonStateData,
18 pub base_context: GameContext,
19 pub expedition_context: GameContext,
20 pub discovery_manager_data: DiscoveryManagerData,
21}
22
23#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25#[serde(rename_all = "PascalCase")]
26#[non_exhaustive]
27pub struct CommonStateData {
28 #[serde(default)]
29 pub save_name: String,
30 #[serde(default)]
31 pub total_play_time: u64,
32}
33
34#[derive(Debug, Clone, Default, Deserialize, Serialize)]
36#[serde(rename_all = "PascalCase")]
37#[non_exhaustive]
38pub struct GameContext {
39 #[serde(default)]
40 pub game_mode: u32,
41 #[serde(default)]
42 pub player_state_data: PlayerStateData,
43}
44
45#[derive(Debug, Clone, Default, Deserialize, Serialize)]
47#[serde(rename_all = "PascalCase")]
48#[non_exhaustive]
49pub struct PlayerStateData {
50 #[serde(default)]
51 pub universe_address: UniverseAddress,
52
53 #[serde(default)]
54 pub previous_universe_address: UniverseAddress,
55
56 #[serde(default)]
57 pub save_summary: String,
58
59 #[serde(default)]
61 pub units: i64,
62
63 #[serde(default)]
64 pub nanites: i64,
65
66 #[serde(default)]
68 pub specials: i64,
69
70 #[serde(default)]
71 pub persistent_player_bases: Vec<PersistentPlayerBase>,
72
73 #[serde(default)]
74 pub health: u32,
75
76 #[serde(default)]
77 pub time_alive: u64,
78}
79
80#[derive(Debug, Clone, Default, Deserialize, Serialize)]
82#[serde(rename_all = "PascalCase")]
83#[non_exhaustive]
84pub struct UniverseAddress {
85 #[serde(default)]
86 pub reality_index: u8,
87 #[serde(default)]
88 pub galactic_address: GalacticAddressObject,
89}
90
91#[derive(Debug, Clone, Default, Deserialize, Serialize)]
93#[serde(rename_all = "PascalCase")]
94#[non_exhaustive]
95pub struct GalacticAddressObject {
96 #[serde(default)]
97 pub voxel_x: i16,
98 #[serde(default)]
99 pub voxel_y: i8,
100 #[serde(default)]
101 pub voxel_z: i16,
102 #[serde(default)]
103 pub solar_system_index: u16,
104 #[serde(default)]
105 pub planet_index: u8,
106}
107
108impl GalacticAddressObject {
109 pub fn to_galactic_address(&self, reality_index: u8) -> nms_core::GalacticAddress {
111 nms_core::GalacticAddress::new(
112 self.voxel_x,
113 self.voxel_y,
114 self.voxel_z,
115 self.solar_system_index,
116 self.planet_index,
117 reality_index,
118 )
119 }
120}
121
122#[derive(Debug, Clone, Copy, Default, Serialize)]
126pub struct PackedGalacticAddress(pub u64);
127
128impl<'de> Deserialize<'de> for PackedGalacticAddress {
129 fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
130 struct Visitor;
131 impl<'de> de::Visitor<'de> for Visitor {
132 type Value = PackedGalacticAddress;
133
134 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
135 write!(f, "a hex string like \"0x...\" or an integer")
136 }
137
138 fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
139 Ok(PackedGalacticAddress(v))
140 }
141
142 fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
143 Ok(PackedGalacticAddress(v as u64))
144 }
145
146 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
147 let hex = v
148 .strip_prefix("0x")
149 .or_else(|| v.strip_prefix("0X"))
150 .unwrap_or(v);
151 u64::from_str_radix(hex, 16)
152 .map(PackedGalacticAddress)
153 .map_err(|_| de::Error::custom(format!("invalid hex galactic address: {v}")))
154 }
155 }
156 deserializer.deserialize_any(Visitor)
157 }
158}
159
160impl PackedGalacticAddress {
161 pub fn to_galactic_address(&self, reality_index: u8) -> nms_core::GalacticAddress {
163 nms_core::GalacticAddress::from_packed(self.0, reality_index)
164 }
165}
166
167#[derive(Debug, Clone, Default, Deserialize, Serialize)]
169#[non_exhaustive]
170pub struct DiscoveryManagerData {
171 #[serde(rename = "DiscoveryData-v1", default)]
172 pub discovery_data_v1: DiscoveryDataV1,
173}
174
175#[derive(Debug, Clone, Default, Deserialize, Serialize)]
177#[serde(rename_all = "PascalCase")]
178#[non_exhaustive]
179pub struct DiscoveryDataV1 {
180 #[serde(default)]
181 pub reserve_store: u32,
182 #[serde(default)]
183 pub reserve_managed: u32,
184 #[serde(default)]
185 pub store: DiscoveryStore,
186}
187
188#[derive(Debug, Clone, Default, Deserialize, Serialize)]
190#[serde(rename_all = "PascalCase")]
191#[non_exhaustive]
192pub struct DiscoveryStore {
193 #[serde(default)]
194 pub record: Vec<RawDiscoveryRecord>,
195}
196
197#[derive(Debug, Clone, Deserialize, Serialize)]
201#[non_exhaustive]
202pub struct RawDiscoveryRecord {
203 #[serde(rename = "DD")]
205 pub dd: DiscoveryData,
206
207 #[serde(rename = "DM", default)]
209 pub dm: serde_json::Value,
210
211 #[serde(rename = "OWS")]
213 pub ows: OwnershipData,
214
215 #[serde(rename = "FL", default)]
217 pub fl: DiscoveryFlags,
218
219 #[serde(rename = "RID", default)]
221 pub rid: Option<String>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
226#[non_exhaustive]
227pub struct DiscoveryData {
228 #[serde(rename = "UA")]
230 pub ua: PackedGalacticAddress,
231
232 #[serde(rename = "DT")]
234 pub dt: String,
235
236 #[serde(rename = "VP", default)]
238 pub vp: Vec<serde_json::Value>,
239}
240
241#[derive(Debug, Clone, Default, Deserialize, Serialize)]
243#[non_exhaustive]
244pub struct OwnershipData {
245 #[serde(rename = "LID", default)]
247 pub lid: String,
248
249 #[serde(rename = "UID", default)]
251 pub uid: String,
252
253 #[serde(rename = "USN", default)]
255 pub usn: String,
256
257 #[serde(rename = "PTK", default)]
259 pub ptk: String,
260
261 #[serde(rename = "TS", default)]
263 pub ts: u64,
264}
265
266#[derive(Debug, Clone, Default, Deserialize, Serialize)]
268#[non_exhaustive]
269pub struct DiscoveryFlags {
270 #[serde(rename = "C", default)]
272 pub created: Option<u8>,
273
274 #[serde(rename = "U", default)]
276 pub uploaded: Option<u8>,
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
281#[serde(rename_all = "PascalCase")]
282#[non_exhaustive]
283pub struct PersistentPlayerBase {
284 #[serde(default)]
285 pub base_version: u32,
286
287 pub galactic_address: PackedGalacticAddress,
288
289 #[serde(default)]
290 pub position: [f32; 3],
291
292 #[serde(default)]
293 pub forward: [f32; 3],
294
295 #[serde(default)]
296 pub last_update_timestamp: u64,
297
298 #[serde(default)]
300 pub objects: Vec<serde_json::Value>,
301
302 #[serde(default, rename = "RID")]
303 pub rid: String,
304
305 #[serde(default)]
306 pub owner: OwnershipData,
307
308 #[serde(default)]
309 pub name: String,
310
311 #[serde(default)]
312 pub base_type: BaseTypeWrapper,
313
314 #[serde(default)]
315 pub last_edited_by_id: String,
316
317 #[serde(default)]
318 pub last_edited_by_username: String,
319
320 #[serde(default)]
321 pub game_mode: Option<GameModeWrapper>,
322}
323
324#[derive(Debug, Clone, Default, Deserialize, Serialize)]
326#[non_exhaustive]
327pub struct BaseTypeWrapper {
328 #[serde(rename = "PersistentBaseTypes", default)]
329 pub persistent_base_types: String,
330}
331
332#[derive(Debug, Clone, Default, Deserialize, Serialize)]
334#[non_exhaustive]
335pub struct GameModeWrapper {
336 #[serde(rename = "PresetGameMode", default)]
337 pub preset_game_mode: String,
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn parse_packed_galactic_address_hex_string() {
346 let json = r#""0x40050003AB8C07""#;
347 let addr: PackedGalacticAddress = serde_json::from_str(json).unwrap();
348 assert_eq!(addr.0, 0x40050003AB8C07);
349 }
350
351 #[test]
352 fn parse_packed_galactic_address_integer() {
353 let json = "4716909145249443";
354 let addr: PackedGalacticAddress = serde_json::from_str(json).unwrap();
355 assert_eq!(addr.0, 4716909145249443);
356 }
357
358 #[test]
359 fn parse_packed_galactic_address_zero() {
360 let json = "0";
361 let addr: PackedGalacticAddress = serde_json::from_str(json).unwrap();
362 assert_eq!(addr.0, 0);
363 }
364
365 #[test]
366 fn parse_galactic_address_object() {
367 let json = r#"{"VoxelX": 1699, "VoxelY": -2, "VoxelZ": 165, "SolarSystemIndex": 369, "PlanetIndex": 0}"#;
368 let addr: GalacticAddressObject = serde_json::from_str(json).unwrap();
369 assert_eq!(addr.voxel_x, 1699);
370 assert_eq!(addr.voxel_y, -2);
371 assert_eq!(addr.voxel_z, 165);
372 assert_eq!(addr.solar_system_index, 369);
373 assert_eq!(addr.planet_index, 0);
374 }
375
376 #[test]
377 fn parse_universe_address() {
378 let json = r#"{
379 "RealityIndex": 0,
380 "GalacticAddress": {"VoxelX": 1699, "VoxelY": -2, "VoxelZ": 165, "SolarSystemIndex": 369, "PlanetIndex": 0}
381 }"#;
382 let ua: UniverseAddress = serde_json::from_str(json).unwrap();
383 assert_eq!(ua.reality_index, 0);
384 assert_eq!(ua.galactic_address.voxel_x, 1699);
385 }
386
387 #[test]
388 fn parse_discovery_record() {
389 let json = r#"{
390 "DD": {"UA": "0x513300F79B1D82", "DT": "Flora", "VP": ["0xD6911E7B1D31085E", "0x6454A508A8EBE022"]},
391 "DM": {},
392 "OWS": {"LID": "", "UID": "76561197977678185", "USN": "Allasar", "PTK": "ST", "TS": 1757022865},
393 "FL": {"C": 1, "U": 1},
394 "RID": "RAyjId1/Ea20q4fOptVHGQ3K99CKxs8609foiDDzCDc="
395 }"#;
396 let rec: RawDiscoveryRecord = serde_json::from_str(json).unwrap();
397 assert_eq!(rec.dd.dt, "Flora");
398 assert_eq!(rec.dd.ua.0, 0x513300F79B1D82);
399 assert_eq!(rec.ows.usn, "Allasar");
400 assert_eq!(rec.ows.ptk, "ST");
401 assert_eq!(rec.ows.ts, 1757022865);
402 assert_eq!(rec.fl.created, Some(1));
403 assert_eq!(rec.fl.uploaded, Some(1));
404 }
405
406 #[test]
407 fn parse_discovery_record_integer_ua() {
408 let json = r#"{
409 "DD": {"UA": 498082938293634, "DT": "SolarSystem", "VP": ["0xD9F543C64FB79748"]},
410 "DM": {},
411 "OWS": {"LID": "", "UID": "76561197962153408", "USN": "Cereal 4th", "PTK": "ST", "TS": 1756915149},
412 "FL": {"C": 1, "U": 1}
413 }"#;
414 let rec: RawDiscoveryRecord = serde_json::from_str(json).unwrap();
415 assert_eq!(rec.dd.dt, "SolarSystem");
416 assert_eq!(rec.dd.ua.0, 498082938293634);
417 assert!(rec.rid.is_none());
418 }
419
420 #[test]
421 fn parse_discovery_record_sector() {
422 let json = r#"{
423 "DD": {"UA": "0x61C100039060B9", "DT": "Sector", "VP": ["0x8665527833B28EE7", 512]},
424 "DM": {},
425 "OWS": {"LID": "76561198024880757", "UID": "76561198024880757", "USN": "Ascalon", "PTK": "ST", "TS": 1771036917},
426 "FL": {"U": 1}
427 }"#;
428 let rec: RawDiscoveryRecord = serde_json::from_str(json).unwrap();
429 assert_eq!(rec.dd.dt, "Sector");
430 assert_eq!(rec.fl.created, None);
431 assert_eq!(rec.fl.uploaded, Some(1));
432 }
433
434 #[test]
435 fn parse_persistent_player_base() {
436 let json = r#"{
437 "BaseVersion": 8,
438 "OriginalBaseVersion": 8,
439 "GalacticAddress": "0x40050003AB8C07",
440 "Position": [17267.421875, 3043.806640625, 63082.875],
441 "Forward": [0.913, -0.333, -0.233],
442 "UserData": 0,
443 "LastUpdateTimestamp": 1738887563,
444 "Objects": [],
445 "RID": "",
446 "Owner": {"LID": "76561198025707979", "UID": "76561198025707979", "USN": "", "PTK": "ST", "TS": 1700427307},
447 "Name": "Gugestor Colony",
448 "BaseType": {"PersistentBaseTypes": "HomePlanetBase"},
449 "LastEditedById": "",
450 "LastEditedByUsername": "",
451 "ScreenshotAt": [-0.601, 0.052, 0.797],
452 "ScreenshotPos": [-16.56, 14.89, 95.18],
453 "GameMode": {"PresetGameMode": "Normal"},
454 "Difficulty": {}
455 }"#;
456 let base: PersistentPlayerBase = serde_json::from_str(json).unwrap();
457 assert_eq!(base.name, "Gugestor Colony");
458 assert_eq!(base.base_type.persistent_base_types, "HomePlanetBase");
459 assert_eq!(base.galactic_address.0, 0x40050003AB8C07);
460 assert_eq!(base.owner.uid, "76561198025707979");
461 }
462
463 #[test]
464 fn parse_common_state_data() {
465 let json = r#"{"SaveName": "main - Steam", "TotalPlayTime": 2464349, "UsesThirdPersonCharacterCam": true}"#;
466 let csd: CommonStateData = serde_json::from_str(json).unwrap();
467 assert_eq!(csd.save_name, "main - Steam");
468 assert_eq!(csd.total_play_time, 2464349);
469 }
470
471 #[test]
472 fn parse_player_state_data_minimal() {
473 let json = r#"{
474 "UniverseAddress": {
475 "RealityIndex": 0,
476 "GalacticAddress": {"VoxelX": 1699, "VoxelY": -2, "VoxelZ": 165, "SolarSystemIndex": 369, "PlanetIndex": 0}
477 },
478 "PreviousUniverseAddress": {
479 "RealityIndex": 0,
480 "GalacticAddress": {"VoxelX": 1699, "VoxelY": -2, "VoxelZ": 165, "SolarSystemIndex": 505, "PlanetIndex": 0}
481 },
482 "SaveSummary": "In the Rabirad-Motom system",
483 "Units": -919837762,
484 "Nanites": 272127,
485 "Specials": 2230,
486 "Health": 180,
487 "TimeAlive": 1435361,
488 "PersistentPlayerBases": []
489 }"#;
490 let ps: PlayerStateData = serde_json::from_str(json).unwrap();
491 assert_eq!(ps.units, -919837762);
492 assert_eq!(ps.nanites, 272127);
493 assert_eq!(ps.specials, 2230);
494 assert_eq!(ps.universe_address.galactic_address.voxel_x, 1699);
495 assert_eq!(ps.universe_address.galactic_address.solar_system_index, 369);
496 }
497
498 #[test]
499 fn parse_minimal_save_root() {
500 let json = r#"{
501 "Version": 4720,
502 "Platform": "Mac|Final",
503 "ActiveContext": "Main",
504 "CommonStateData": {"SaveName": "test", "TotalPlayTime": 100},
505 "BaseContext": {
506 "GameMode": 1,
507 "PlayerStateData": {
508 "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}},
509 "Units": 1000000,
510 "Nanites": 5000,
511 "Specials": 200,
512 "PersistentPlayerBases": []
513 }
514 },
515 "ExpeditionContext": {
516 "GameMode": 6,
517 "PlayerStateData": {
518 "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}},
519 "Units": 0,
520 "Nanites": 0,
521 "Specials": 0,
522 "PersistentPlayerBases": []
523 }
524 },
525 "DiscoveryManagerData": {
526 "DiscoveryData-v1": {
527 "ReserveStore": 100,
528 "ReserveManaged": 100,
529 "Store": {"Record": []}
530 }
531 }
532 }"#;
533 let save: SaveRoot = serde_json::from_str(json).unwrap();
534 assert_eq!(save.version, 4720);
535 assert_eq!(save.platform, "Mac|Final");
536 assert_eq!(save.active_context, "Main");
537 assert_eq!(save.common_state_data.save_name, "test");
538 assert_eq!(save.common_state_data.total_play_time, 100);
539 assert_eq!(save.base_context.player_state_data.units, 1000000);
540 assert_eq!(
541 save.discovery_manager_data
542 .discovery_data_v1
543 .store
544 .record
545 .len(),
546 0
547 );
548 }
549}