Storage

Microsoft::Xna::Framework::Storage — StorageDevice, StorageContainer, StorageDeviceNotConnectedException

ⓘ

Implementation status: implemented, and represented in full. All three Storage types are implemented — real std::filesystem I/O, a hand-written glob matcher for the pattern-based directory queries, and <root>/<displayName>/Player{N} save namespacing. All 35 documented Storage members of the XNA 4.0 runtime are represented in CNA’s member census (representation, not behaviour; see XNA Compatibility). The async Begin*/End* pairs are implemented synchronously, which is the correct behaviour on desktop. New in this snapshot: container names and paths are contained (absolute paths, .. escapes and symlink escapes are rejected), and the storage root is resolved by a documented chain of environment variables rather than by SDL_GetPrefPath (on Android the first step reaches SDL indirectly, through Sharp Runtime's StoragePaths).

⚠

Storage remains lightly tested. This snapshot has one Storage test source with 14 GoogleTest-family definitions (alpha.1 had five): ten cover device and container path containment and DeleteContainer, and four cover the StorageDeviceNotConnectedException serialization round trip. That disproves the old “StorageContainer has no tests” claim, but there is still no test that writes bytes through a container and reads them back, so the read/write semantics themselves are verified only by implementation audit. Test the filesystem behaviour your game depends on.

Overview

The Microsoft::Xna::Framework::Storage namespace provides a platform-independent abstraction for save-game and user-data persistence. On the original Xbox 360, StorageDevice represented a physical memory unit or hard drive, and device selection was presented to the player as a system UI. On PC, the selector returned immediately with the local user profile directory.

CNA preserves the full XNA 4.0 API shape, including the Begin/End asynchronous pattern, and backs it with genuine std::filesystem operations. StorageContainer maps to a real directory on the local filesystem and StorageDevice represents that location.

The async wrappers complete synchronously. This is a deliberate choice rather than a shortcut: the pattern exists in XNA because Xbox 360 storage selection could block on player interaction with a system UI, which has no desktop analogue. Local filesystem operations return fast enough that dispatching them to a thread would add latency and failure modes without adding responsiveness. Source-level XNA compatibility is preserved either way — your callback still fires and End* still returns the result.

⚠

On the web, saves persist in the browser. Under Emscripten CNA’s storage module links Emscripten’s IndexedDB file system (IDBFS) and mounts it at /cna-storage for StorageDevice and at /save for isolated storage, with automatic persistence; the stored data is restored before main() runs, so a StorageContainer written in one visit is there after a page reload. Two conditions: a threaded WebAssembly build keeps Emscripten’s WasmFS by default and needs -DCNA_EMSCRIPTEN_USE_WASMFS=OFF for this persistence, and if the mount fails StorageDevice throws StorageDeviceNotConnectedException instead of silently writing to memory. Browser storage is still per origin and can be cleared by the user, so treat it like any other web storage. (Source-verified; CNA records a reload test for one sample, which was not re-run here.)

Namespace members at a glance

Type Description Status
StorageDevice Represents a storage location (local disk). Obtained via the async selector. Implemented
StorageContainer Opened from a StorageDevice; provides file and directory access within a named save scope. Implemented
StorageDeviceNotConnectedException Thrown when a previously valid StorageDevice is no longer available. Implemented

StorageDevice

StorageDevice is the entry point into the storage system. On PC (and in CNA on all platforms) it represents the local filesystem. To obtain a device, use the static Begin/End async selector pattern. On a PC target the selector returns immediately with a device backed by the current user's profile directory.

Device selection (Begin/End pattern)

MethodDescription
StorageDevice::BeginShowSelector(callback, state) Starts the (synchronous on CNA) device-selection operation and returns a std::unique_ptr<System::IAsyncResult>. callback is a std::function<void(System::IAsyncResult*)> (may be empty) that has already run by the time the call returns; state is a void* passed through. Four overloads exist: (callback, state), (PlayerIndex, callback, state), (sizeInBytes, directoryCount, callback, state) and the combination; the size and count arguments are accepted and ignored.
StorageDevice::EndShowSelector(result) Takes the IAsyncResult* (result.get()) and returns a std::unique_ptr<StorageDevice>. It never returns nullptr: a device is always available on CNA, and a result that did not come from BeginShowSelector throws std::invalid_argument. Choosing the overload with a PlayerIndex scopes the device’s containers to that player (see the layout below).

Opening a container

MethodDescription
device.BeginOpenContainer(name, callback, state) Begins opening a StorageContainer with the given display name; returns a std::unique_ptr<System::IAsyncResult> and, like the selector, completes before it returns. The container maps to a subdirectory within the device root. An empty name, an absolute name or one that escapes the root throws std::invalid_argument.
device.EndOpenContainer(result) Takes result.get() and returns a std::unique_ptr<StorageContainer>, creating the container’s directory if it does not exist. The caller should Dispose() the container when done (the destructor also disposes it).

Other StorageDevice members

MemberDescription
getIsConnectedProperty()True while the storage root, or its nearest existing ancestor, is reachable; never throws.
getFreeSpaceProperty(), getTotalSpaceProperty()std::filesystem::space of the root (available and capacity, as long long). Failure is reported as StorageDeviceNotConnectedException.
DeviceChangedThe static XNA event. It exists for source compatibility; nothing in CNA raises it, because the local device is always present.
DeleteContainer(titleName)Recursively removes <root>/<titleName> (every player folder, whichever player the device was selected for). Empty, absolute and escaping names throw std::invalid_argument. XNA 4.0's own DeleteContainer is narrower: it deletes only the device's own Player{N} (or AllPlayers) folder of that title, so CNA's delete also removes the other players' folders (XNA side read from the decompiled reference, CNA side from StorageDevice.cpp; neither executed).
StorageDevice::SetAppNameEXT(name) CNAEXTNames the application folder under the root (see below). Call it before the first container is opened.
StorageDevice::GetStorageRootEXT() CNAEXTReturns the resolved root as a UTF-8, forward-slash string (creating the directory), so a game can show or log where saves go.

On-disk layout

Saves are namespaced on disk as <root>/<displayName>/Player{N}, matching XNA's own scheme:

  • <root> — the storage root the StorageDevice represents (next section).
  • <displayName> — the container name you pass to BeginOpenContainer.
  • Player{N} — the per-player subdirectory, derived from the PlayerIndex the device was selected for and numbered from one: PlayerIndex::One is Player1.
  • AllPlayers — used instead when the device was selected without a player (the (callback, state) overload).

The consequence is the one XNA intended: two players on the same machine writing to the same container name do not collide, and a container opened without a player index is shared across the title.

Where the root is: the persistence-path chain

The root is not SDL_GetPrefPath, and it does not depend on the selected windowing platform: the same policy applies under SDL3, HEADLESS and TERMINAL. It is resolved once, on first use, from the first of these that applies:

OrderConditionRoot
1Android buildthe current package’s app-private files directory, with <app> beneath it (Sharp Runtime’s isolated-storage policy, which at sharp-runtime 41b918c9 asks SDL for SDL_GetAndroidInternalStoragePath())
2XDG_DATA_HOME is set and non-empty$XDG_DATA_HOME/<app>
3LOCALAPPDATA is set%LOCALAPPDATA%\<app> (local, not roaming %APPDATA%)
4HOME is setmacOS: ~/Library/Application Support/<app>; elsewhere ~/.local/share/<app>
5none of the above./<app> in the current working directory

<app> is the literal string game unless the program calls StorageDevice::SetAppNameEXT("MyGame"). Nothing derives it from the window title or the executable name, so two CNA games that never call SetAppNameEXT share one folder. SetAppNameEXT discards the cached root (so call it before the first container is opened, or an earlier save stays under the old name) and also re-points Sharp Runtime’s isolated-storage root at <root>/.cna_isolated_storage. UTF-8 roots and names are handled on Windows by the root resolution and the container paths, but getIsConnectedProperty(), getFreeSpaceProperty() and getTotalSpaceProperty() still build narrow paths from the UTF-8 root, so with a non-ASCII root they may not resolve it correctly there. If the root cannot be created, the first access throws StorageDeviceNotConnectedException; that failure is not repeated, because the root is then latched empty and later calls silently resolve against the working directory (both points read from the source, not executed).

Path containment

A StorageContainer is an authority boundary. A container name, and every file or directory path passed to a container, must be relative and must stay inside the container. An empty name or path, an absolute path, a .. that climbs past the container root, or a path that resolves through a symlink to somewhere outside it throws std::invalid_argument. (DeleteContainer had its own containment check in alpha.1; the file, directory and container-name checks are new.) Code that passed "../other" or an absolute path to a container must be changed.

StorageContainer

StorageContainer provides file and directory access scoped to a named save context within a device. It implements the XNA 4.0 API. All paths passed to its methods are relative to the container root directory and are contained in it. The container also exposes getDisplayNameProperty(), getIsDisposedProperty(), getStorageDeviceProperty(), the Disposing event, and an idempotent Dispose().

File operations

MethodReturnsDescription
CreateFile(path) std::unique_ptr<System::IO::Stream> Creates (or replaces) the file and returns a stream for writing; equivalent to FileMode::Create.
OpenFile(path, mode) std::unique_ptr<System::IO::Stream> Opens or creates a file at path using the given System::IO::FileMode (Create, Open, OpenOrCreate, Append, Truncate). Overloads add FileAccess and FileShare (the share argument is accepted and ignored). The stream is a Sharp Runtime Stream: Read(buffer, offset, count), Write(buffer, offset, count), getLengthProperty(), Flush(), Close().
FileExists(path) bool Returns true if a regular file exists at path within the container.
DeleteFile(path) void Deletes the file at path. A path that does not exist is a silent no-op, not an error; other I/O failures throw.
GetFileNames() std::vector<std::string> Returns the names of all regular files in the container root (non-recursive). The order is the filesystem’s; sort if you need one.
GetFileNames(pattern) std::vector<std::string> Returns file names matching a wildcard pattern (e.g. "*.sav"; * and ? are supported).

Directory operations

MethodReturnsDescription
CreateDirectory(path) void Creates a subdirectory within the container (creates intermediate directories as needed).
DeleteDirectory(path) void Removes the directory. The call is not recursive: an empty directory is removed, a non-empty one fails with a filesystem error, and a missing one is a no-op.
DirectoryExists(path) bool Returns true if the directory exists within the container.
GetDirectoryNames() std::vector<std::string> Returns the names of all subdirectories in the container root (non-recursive; the order is the filesystem’s).
GetDirectoryNames(pattern) std::vector<std::string> Returns directory names matching a wildcard pattern (an empty pattern throws std::invalid_argument).

Pattern matching

The pattern-taking overloads are backed by a hand-written glob matcher rather than a delegation to the platform shell or a regex engine. That keeps wildcard semantics identical across every platform CNA builds for, instead of inheriting whatever the host's directory-enumeration API happens to do.

StorageDeviceNotConnectedException

StorageDeviceNotConnectedException is thrown when a StorageDevice that was previously valid is no longer accessible — on Xbox 360, for example, if a memory unit was ejected. In CNA, because storage always maps to the local filesystem, it is raised only when the storage root cannot be created on first use (“Unable to create the storage directory.”) or when getFreeSpaceProperty() / getTotalSpaceProperty() cannot query the root, with the underlying filesystem error attached as the inner exception.

It derives from ExternalException, carries a human-readable message, and has a protected serialization constructor and a GetObjectData round trip (covered by four of the module’s tests). Catch it wherever you first touch the device if your application needs to handle an unusable save location gracefully; note that ordinary I/O errors and bad paths surface as other exceptions (std::invalid_argument for a contained-path violation, stream or filesystem errors for I/O).

try {
    auto opening   = device->BeginOpenContainer("SaveData", nullptr, nullptr);
    auto container = device->EndOpenContainer(opening.get());
    // ... use container ...
    container->Dispose();
} catch (const StorageDeviceNotConnectedException& ex) {
    // Storage unavailable — the root could not be created or queried
    ShowErrorDialog(ex.what());
} catch (const std::invalid_argument& ex) {
    // The container name was empty, absolute, or escaped the storage root
    ShowErrorDialog(ex.what());
}

IAsyncResult pattern

XNA 4.0 used the standard .NET IAsyncResult Begin/End async pattern so that storage operations could show system UI (device selector, container namer) without blocking the game loop. The pattern works as follows:

  1. Call the Begin* method, supplying an optional callback and state object. It returns a std::unique_ptr<System::IAsyncResult>.
  2. In the callback (or right after the call), pass result.get() to the matching End* method to retrieve the result.

In CNA, all storage operations complete synchronously: the Begin* call finishes its work, invokes your callback, and only then returns the IAsyncResult. The pattern is preserved so that XNA save-game code ports with little change, and synchronous completion is the correct semantics for a desktop filesystem — there is no system UI to wait on and no removable media to negotiate. End* throws std::invalid_argument if handed a result that another Begin* produced.

Code examples

These use the real signatures at this snapshot: results are owned by std::unique_ptr, End* takes result.get(), and stream lengths and reads use Sharp Runtime’s getLengthProperty() and Read/Write(buffer, offset, count) with SharpRuntime::bytecs buffers. For a complete, runnable save-slot program see Tutorial 141: Save Data Portably.

Saving a file

using namespace Microsoft::Xna::Framework::Storage;
using System::IO::FileMode;

// Obtain a StorageDevice (the Begin/End pair completes synchronously)
auto selection = StorageDevice::BeginShowSelector(nullptr, nullptr);
std::unique_ptr<StorageDevice> device = StorageDevice::EndShowSelector(selection.get());

// Open a container named after your save slot
auto opening   = device->BeginOpenContainer("Slot1", nullptr, nullptr);
auto container = device->EndOpenContainer(opening.get());

// Write a save file
if (!container->DirectoryExists("profile"))
    container->CreateDirectory("profile");

auto stream = container->OpenFile("profile/save.dat", FileMode::Create);
stream->Write(saveData.data(), 0, static_cast<SharpRuntime::intcs>(saveData.size()));
stream->Close();

container->Dispose();

Loading a file

auto opening   = device->BeginOpenContainer("Slot1", nullptr, nullptr);
auto container = device->EndOpenContainer(opening.get());

auto stream = container->OpenFile("profile/save.dat", FileMode::Open);

std::vector<SharpRuntime::bytecs> buffer(static_cast<std::size_t>(stream->getLengthProperty()));
stream->Read(buffer.data(), 0, static_cast<SharpRuntime::intcs>(buffer.size()));
stream->Close();

container->Dispose();

// Deserialise from buffer ...
LoadGameState(buffer);

Checking file existence before loading

auto opening   = device->BeginOpenContainer("Slot1", nullptr, nullptr);
auto container = device->EndOpenContainer(opening.get());

if (container->FileExists("profile/save.dat")) {
    auto stream = container->OpenFile("profile/save.dat", FileMode::Open);
    // ... read and deserialise ...
    stream->Close();
} else {
    // No save file found — start a new game
    StartNewGame();
}

container->Dispose();
ⓘ

Always call container->Dispose() when you are finished with a StorageContainer (or let the std::unique_ptr go out of scope: the destructor disposes it). On CNA this raises the Disposing event; on Xbox 360 it was required to flush and unlock the device. Keeping containers open longer than necessary is discouraged.

StorageContainer details that affect save code

Three behaviours are easy to miss when XNA save code is ported; each was checked by reading StorageContainer.cpp at this snapshot (not executed). The internals are on the storage internals page.

Reading a save back completely

Stream::Read returns how many bytes it actually copied, which may be fewer than requested. Loop until the expected length is reached or a read returns 0, and treat a short result as a truncated save rather than parsing it:

std::unique_ptr<System::IO::Stream> in =
    container->OpenFile("savegame.dat", System::IO::FileMode::Open);
std::vector<SharpRuntime::bytecs> bytes(static_cast<std::size_t>(in->getLengthProperty()));

SharpRuntime::intcs offset = 0;
const auto total = static_cast<SharpRuntime::intcs>(bytes.size());
while (offset < total) {
    const SharpRuntime::intcs n = in->Read(bytes.data(), offset, total - offset);
    if (n == 0) break;          // end of file before the expected length
    offset += n;
}
if (offset != total) { /* reject a truncated save */ }

Syntax-checked with g++ -fsyntax-only against the snapshot's headers and a sibling sharp-runtime checkout; not run.

Wildcard patterns, exactly

GetFileNames(pattern) and GetDirectoryNames(pattern) match each entry name at the container's top level (not recursively) against the whole pattern with CNA's own matcher: * matches any run of bytes, including none; ? matches exactly one byte, so a non-ASCII character, which is several UTF-8 bytes, needs several; every other byte, . included, matches only itself, case-sensitively on every host. There are no character classes and no escapes, and an empty pattern throws std::invalid_argument. "slot?.sav" therefore finds slot1.sav but not Slot1.SAV or slot10.sav. The storage tests exercise path containment, not the matcher's edge cases, so test the patterns your game depends on.

Disposal and concurrent writers

Dispose() sets IsDisposed and raises Disposing once, but no file or directory method checks the flag: calls after disposal still reach the filesystem. Treat a disposed container as unusable anyway: no code should depend on calls after disposal working. The FileShare argument of the four-argument OpenFile is accepted and not applied: the stream is opened with path, mode and access only, and there is no cross-process lock, so FileShare::None, Read and ReadWrite behave the same. Do not rely on it to keep two writers apart; serialise saves in the game.