Skip to main content

nms_save/
model.rs

1//! Typed Rust structs for deobfuscated NMS save file JSON.
2//!
3//! Only the fields NMS Copilot uses are deserialized — unknown fields
4//! are silently ignored (no `deny_unknown_fields`).
5
6use serde::{Deserialize, Serialize, de};
7use std::fmt;
8
9/// Top-level save file structure.
10#[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/// Shared state across game contexts (name, play time).
24#[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/// A game context (Base or Expedition), containing mode and player state.
35#[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/// Subset of PlayerStateData fields needed by NMS Copilot.
46#[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    /// Units can be negative in actual saves.
60    #[serde(default)]
61    pub units: i64,
62
63    #[serde(default)]
64    pub nanites: i64,
65
66    /// Quicksilver is stored as "Specials" in the JSON.
67    #[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/// Universe address wrapping a galactic address with reality (galaxy) index.
81#[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/// Galactic address in expanded object form (used in PlayerStateData).
92#[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    /// Convert to the core `GalacticAddress` type.
110    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/// Galactic address in packed form — hex string `"0x..."` or bare integer.
123///
124/// Used for bases and discoveries where the address is a single value.
125#[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    /// Convert to the core `GalacticAddress` type.
162    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/// Top-level discovery manager data.
168#[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/// Discovery data version 1 container.
176#[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/// Store containing all discovery records.
189#[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/// A raw discovery record from the save file.
198///
199/// Field names (DD, DM, OWS, FL, RID) are the actual JSON keys — not obfuscated.
200#[derive(Debug, Clone, Deserialize, Serialize)]
201#[non_exhaustive]
202pub struct RawDiscoveryRecord {
203    /// Discovery data.
204    #[serde(rename = "DD")]
205    pub dd: DiscoveryData,
206
207    /// Discovery metadata (usually empty object).
208    #[serde(rename = "DM", default)]
209    pub dm: serde_json::Value,
210
211    /// Ownership data.
212    #[serde(rename = "OWS")]
213    pub ows: OwnershipData,
214
215    /// Flags (C=created, U=uploaded).
216    #[serde(rename = "FL", default)]
217    pub fl: DiscoveryFlags,
218
219    /// Record ID (base64 hash).
220    #[serde(rename = "RID", default)]
221    pub rid: Option<String>,
222}
223
224/// Discovery data sub-object.
225#[derive(Debug, Clone, Deserialize, Serialize)]
226#[non_exhaustive]
227pub struct DiscoveryData {
228    /// Universe address (packed galactic address).
229    #[serde(rename = "UA")]
230    pub ua: PackedGalacticAddress,
231
232    /// Discovery type: "Flora", "Planet", "Sector", "SolarSystem", "Mineral", "Animal".
233    #[serde(rename = "DT")]
234    pub dt: String,
235
236    /// Variable-purpose data array — opaque for now.
237    #[serde(rename = "VP", default)]
238    pub vp: Vec<serde_json::Value>,
239}
240
241/// Ownership data for discoveries and bases.
242#[derive(Debug, Clone, Default, Deserialize, Serialize)]
243#[non_exhaustive]
244pub struct OwnershipData {
245    /// Local ID (may be empty).
246    #[serde(rename = "LID", default)]
247    pub lid: String,
248
249    /// User ID (Steam ID, PSN ID, etc.).
250    #[serde(rename = "UID", default)]
251    pub uid: String,
252
253    /// Username.
254    #[serde(rename = "USN", default)]
255    pub usn: String,
256
257    /// Platform token: "ST" = Steam, "PS" = PlayStation, etc.
258    #[serde(rename = "PTK", default)]
259    pub ptk: String,
260
261    /// Timestamp (Unix epoch seconds).
262    #[serde(rename = "TS", default)]
263    pub ts: u64,
264}
265
266/// Discovery flags sub-object.
267#[derive(Debug, Clone, Default, Deserialize, Serialize)]
268#[non_exhaustive]
269pub struct DiscoveryFlags {
270    /// Created flag.
271    #[serde(rename = "C", default)]
272    pub created: Option<u8>,
273
274    /// Uploaded flag.
275    #[serde(rename = "U", default)]
276    pub uploaded: Option<u8>,
277}
278
279/// A player-owned base from PersistentPlayerBases.
280#[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    /// Base objects — stored as opaque JSON.
299    #[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/// Wrapper for `{"PersistentBaseTypes": "HomePlanetBase"}`.
325#[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/// Wrapper for `{"PresetGameMode": "Normal"}`.
333#[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}