Xbox Connected Storage (wgs) format
This is the on-disk layout that Game Pass (Microsoft Store / Xbox app) PC titles use for their saves, as read and written by this library. Findings come from real stores and independent implementations; unverified points are called out at the end.
A store is a folder (usually %LOCALAPPDATA%\Packages\<PackageFamilyName>\SystemAppData\wgs\<XUID>_<SCID>) holding a containers.index that maps logical container names to GUID-named folders. Each folder holds a container.N manifest naming a GUID-named blob file. What is inside a blob is up to the game; this library treats it as opaque bytes and lets a game adapter describe it through IWgsBlobInspector.
containers.index is a sync protocol, not a file listing
The index is one half of a conversation with the Xbox cloud service. It records what the service should believe about each container. A local tool can inspect and update this metadata, while the service remains authoritative for cloud versions and ETags.
Layout (little-endian):
| Field | Type | Notes |
|---|---|---|
| version | u32 | 14 in every observed store |
| container count | u32 | rewritten from the live list on every save |
| reserved | u32 | 0. Really an empty length-prefixed display name |
| package family name | wstring | u32 char count + UTF-16. e.g. Publisher.Game_xxxxxxxxxxxxx!AppGameShipping |
| index FILETIME | i64 | the recency token sync compares. Full precision |
| sync flags | u32 | see below. Previously misread as a constant 3 |
| root GUID | wstring | |
| reserved | 8 bytes | 00 00 00 10 00 00 00 00 as the game writes it |
Then one entry per container:
| Field | Type | Notes |
|---|---|---|
| name | wstring | e.g. ForScience-WC |
| name2 | wstring | same as name in practice |
| ETag | wstring | a version token issued by the service, e.g. "0x8DEBCCC41BE9635" |
| container number | u8 | the N in container.N |
| state | u32 | see below |
| folder GUID | 16 bytes | mixed-endian, matches the GUID folder name |
| FILETIME | i64 | millisecond granular (every observed value divides by 10,000 ticks) |
| reserved | i64 | really Type + UnkInt3, both 0 |
| blob size | i64 | must equal the real blob byte count |
Container state
| Value | Meaning |
|---|---|
| 0 | undefined |
| 1 | Synced. Local and cloud agree; the resting state |
| 2 | Modified. Changed locally, still based on a known cloud version. Keeps its ETag |
| 3 | Deleted. A tombstone, so the deletion can reach the cloud |
| 4 | undefined |
| 5 | Created. Made locally, never uploaded, so no ETag |
Observation: public reverse-engineering documentation does not agree on the names for states 2, 4 and 5. The mapping above follows libNOM.io. LukeFZ/XblContainerReader uses different names for states 2 and 5. This is a documentation discrepancy; observed stores and their ETag pairing provide context:
- Containers written by the game use state 1 or 2 and carry an ETag.
- Other parsers associate the Created state with an empty ETag, consistent with the local-only interpretation of states 4 and 5 in the mapping above.
The write rule follows from that invariant. A container with an ETag becomes Modified; one without stays Created. Never break the pairing, and never mint an ETag: only the service issues them, and it uses the one you echo back to recognise which cloud version your copy was based on.
Sync flags
The header u32 after the index FILETIME is a flags field:
| Bit | Meaning |
|---|---|
| 1 | FullyUploaded |
| 2 | FullyDownloaded |
| 16 | HasUnresolvedConflicts |
A healthy fully-synced store reads 3. A live store found mid-problem read 18 (FullyDownloaded | HasUnresolvedConflicts). On write the library clears FullyUploaded, because the store holds something the cloud does not. It deliberately does not clear the conflict bit: only Xbox can decide a conflict is resolved, and clearing it locally would hide a real problem rather than fix one. WgsStore.HasUnresolvedConflicts and Diagnose() report it instead, and the default write gate refuses to write while it is set.
The index FILETIME is refreshed on every write and strictly advances (max(now, previous + 1)). Cloud sync compares it to decide which copy is newer, so a same-or-older stamp is enough to lose an edit to the cloud copy. Confirmed by diffing two game-written indexes: the game rewrites this value on every save.
container.N holds two blob ids, and they are not a duplicate
u32 constant (4)
u32 blob count (1 for every store verified so far; see Multi-blob containers below)
128-byte fixed UTF-16 name field ("Data", zero padded)
16 bytes blob id as the cloud last knew it
16 bytes blob id of the file on diskConfirmed across four independent implementations (Z1ni/XGP-save-extractor, LukeFZ, Fr33dan/GPSaveConverter, libNOM.io). In a settled container the two are identical, which is what this library writes. They differ while a sync is in flight, which gives the read rules:
- Both present and different: a sync is genuinely in flight and nothing on disk says which side wins.
TryReadBlobreturnsSyncInFlightrather than hand back the wrong save as though it were the right one. - Current missing, previous present: use the previous one. It is a recorded alternative, not a guess.
- Neither present: last resort, scan the folder for a GUID-named blob, and accept it only when its size matches the size the index records.
The game keeps exactly one container.N and one blob per folder, so the library prunes the superseded generation after committing the index. Keeping old generations is what made the folder-scan fallback ambiguous in the first place. Use CopyStoreTo to take a whole-folder backup before writing; that is the real rollback.
containers.index and the manifests are written through a temp file plus an atomic replace: a truncated index loses every container in the store at once, which no per-save backup can undo.
Multi-blob containers
The blob count in container.N is a real count. After the two leading u32s the manifest holds that many entries, each 160 bytes: the 128-byte UTF-16 name field, then the two 16-byte GUIDs (cloud id, then on-disk id). There is nothing after the last entry.
u32 constant (4)
u32 blob count (N)
N x { 128-byte UTF-16 name, 16-byte cloud id, 16-byte on-disk id }Source: Z1ni/XGP-save-extractor's main.py reads exactly this (a file_count, then per file a 128-byte UTF-16 name, a GUID and a copy of the GUID, "each entry occupies 160 bytes"), which is consistent with LukeFZ/XblContainerReader, Fr33dan/GPSaveConverter and libNOM.io.
Verified on a real store (2026-10). A store written by the game for package BethesdaSoftworks.ProjectTitan_3275kfvn8vcwc holds a container (GAME-AUTOSAVE1) with six blobs (game.details, game.details-BACKUP, game_duration.dat, game_duration.dat-BACKUP and two .checksum blobs). Its container.3 is exactly 968 bytes, the 8-byte header plus six 160-byte entries, with both GUIDs equal in every entry and nothing after the last. A sanitized copy is the fixture tests/fixtures/RealMultiBlob, and RealMultiBlobFixtureTests pins the layout.
How the library handles it:
TryReadBlobsreads every blob by name. Each blob follows the single-blob read rules (in-flight sync, previous-id fallback). The folder-scan last resort is used only for one-blob manifests, because with several blobs a size match cannot say which file is which.- A write replaces only the blobs it names, with fresh GUIDs. Blobs it does not name keep their files and both ids exactly, so their bytes are preserved. Bytes after the last entry (none in known stores) and the leading constant are written back as read.
- A manifest is malformed (reported by
Diagnose, never guessed at) when it is shorter than its count promises, declares zero or an absurd number (over 1024) of blobs, or repeats a blob name. - For a multi-blob container the index entry's size is the total of all blob sizes. The sources describe that field only for single-blob containers; the real store above confirms it (56,661 bytes for the six blobs). Reading never depends on it.
- Container names may hold
/(seen in a real Call of Duty HQ store,38985CA0.COREBase_5bkah9njm3e9g:7300/cerberus_savegame_progression_1,sp24/savegame_1.svg). It is part of the name, not a folder: the container still lives in one GUID folder. Unwrapping treats it as a path separator. - The write-side name limit is 63 characters (the 64-character field keeps a terminator); names that differ only by case are refused.
Deleting a container
WgsStore.DeleteContainer applies one rule, built from the state table above:
- state 5 (
Created) with an empty ETag means "made locally, never uploaded": the cloud has nothing to delete, so the entry is removed from the index and its files are cleared (after the index commits); - an entry with an ETag is known to the cloud: it becomes state 3 (
Deleted), "a tombstone kept so the deletion can reach the cloud", with the ETag and every other field untouched, and its files are left in place. The index timestamp advances like any write.
Sources: the state mapping in libNOM.io and the tombstone reading in the state table above. What Xbox does with a tombstone was not observed here (that needs a live sync); the rule only avoids inventing anything: no ETag is minted or dropped, and a never-uploaded entry leaves no tombstone because there is nothing to sync. A tombstone with an ETag is treated as healthy: it does not block writes to other containers, is not "repaired" back to life, and cannot be written to. A Deleted entry without an ETag is still damage.
Restoring a backup
WgsStore.TryRestore copies files in blob, manifest, then index order, so an interruption before the final atomic index replace leaves the previous index and data described. The restored index is not the backup's bytes: its FILETIME is set strictly above both the backup's and the current store's, FullyUploaded is cleared, and containers the backup recorded as Synced are recorded as Modified (keeping their ETag), because restored content is a local change against whatever the cloud holds now. That last step is an assumption drawn from "state is set to what actually happened"; it has not been verified against a live sync.
What a write does
WgsStore.TryWriteBlob and TryAddOrReplaceContainer follow this order:
- Ask the write gate (
IWgsWriteGate) whether writing is allowed. The default refuses a store with an unresolved conflict or a deleted/undefined container state; a platform or game adapter can add its own concerns (for example "the game is running"). - Refuse if the store changed on disk since it was opened (
DetectExternalChange). - Write a fresh GUID blob file.
- Write
container.<N+1>naming it (both ids identical). - Update the index entry: number, size, entry FILETIME, and state set to
ModifiedorCreatedper the ETag rule. The ETag itself is echoed untouched. - Rewrite the index header: container count, strictly advanced FILETIME,
FullyUploadedcleared. - Prune the superseded manifest and blob.
Order matters: blob, then the manifest naming it, then the index naming the manifest. A crash at any point leaves the previous generation still fully described, never a manifest pointing at a blob that does not exist. Index and manifests are written through a temp file plus an atomic replace. Several atomic replacements are still not a transaction across the whole store, which is why the change detection above exists.
Where this knowledge came from, and what is still unverified
Sources:
- Real Game Pass stores from Abiotic Factor analysed byte by byte (2026-06), including a live store whose saves kept vanishing and a series of backups showing the state field climbing over successive edits by an older tool.
- libNOM.io for the container state mapping and the prune-on-write behaviour.
- LukeFZ/XblContainerReader (LibXblContainer) for the index layout, the sync flags, and the fact that its
SetModified()never touches the ETag. Its state mapping is the one not to follow. palworld-xgp-import,palworld-save-pal, Z1ni/XGP-save-extractor and Fr33dan/GPSaveConverter as independent cross-checks on the state/ETag invariant and the two blob ids.- Microsoft's Connected Storage documentation for sync behaviour: conflict resolution is user-driven, and data written straight to disk uploads only the next time the title launches and acquires the Game Saves provider.
Still unverified from here:
- Whether Xbox accepts a rewritten container in-game. That needs a real sync cycle on a real machine with a signed-in account. Connected Storage sync cannot be invoked from outside the title (it needs the title's service configuration id and a signed-in Xbox Live account), so observing the before/after of a real sync is the only proof available.
WgsSnapshot.CaptureandWgsSnapshot.Compareexist for exactly that. - The meaning of the reserved header bytes. They are round-tripped verbatim.
- Whether any state value above 5 has a meaning at all. The library treats them as damage (
WgsContainer.HasInvalidState), on the evidence that only older third-party tools are known to have produced them.
PGS: the file-oriented layout
Newer GDK titles can keep saves in a second layout, beside wgs rather than replacing it:
<drive>:\XboxGames\GameSave\pgs\
u_<xuid>_<gameId>\ one user and title; xuid decimal, game id hex
current a reparse point (symlink or junction) to the active snapshot
<N>\ContainersRoot\<path>\<file> numeric snapshot folders; the save files, with their real names
<N>.json Gaming Services metadata, schema undocumented- There is no binary index. Containers are folders and blobs are files, as in Microsoft's XGameSaveFiles model, so reading is a directory walk.
- The game id is not derived from the package family; it is known per title (Forza Horizon 6 is
16D460, packageMicrosoft.ForteBaseGame_8wekyb3d8bbwe). currentmust resolve to a numeric folder directly inside the same user root; anything else is not trusted, and the reader then asks for a snapshot by number instead of guessing.- Links inside
ContainersRootare not followed, and paths may not leave it.
Still unverified: what the metadata JSON holds (it may list files, versions, hashes and sync state), what the snapshot number means and whether old snapshots are kept for rollback, and how the folders under ContainersRoot are named (the example User_<profileID> comes from the game). No real PGS store has been examined here. Writing is not supported until those are known.
Sources: brodrigz/XgpSaveTools (PgsGameSaveSource.cs, PgsGameSaveHandler.cs, games.json; MIT), which also keeps PGS read-only "until the source can perform a safe cloud-aware transaction", and Microsoft's XGameSaveFiles documentation.