Tutorial 141: Save Data Portably with StorageDevice

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

What you’ll learn: How StorageDevice picks a per-platform save root, why to call SetAppNameEXT, how to save and load a slot through StorageContainer streams, which exceptions to expect, and why nothing persists in a browser build.

ℹ

Before you start — Tutorial 02 (toolchain and sibling checkouts; sharp-runtime on its next branch) and Tutorial 03 (a CMake project that pulls CNA in with add_subdirectory). Nothing here needs a window or a graphics device. Tutorial 19 shows saving inside a running game; this tutorial is the standalone, portable-by-construction version and uses only the real StorageDevice / StorageContainer API.

Every platform keeps user data in a different place: ~/.local/share on Linux, %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, an app-private directory on Android. XNA’s StorageDevice exists so your game never has to know. In this tutorial you write a small program that keeps a save slot — player name, level, score — between runs, on any desktop platform, without a single #ifdef. You will also see exactly where the file lands, how to keep two games from colliding, what the API refuses to do, and what does not persist (the web).

Where saves go

StorageDevice resolves one storage root per process, from the first of these that applies (independent of the windowing platform, and not SDL_GetPrefPath):

OrderConditionRoot
1Android buildthe package’s app-private files directory
2XDG_DATA_HOME set$XDG_DATA_HOME/<app>
3LOCALAPPDATA set (Windows)%LOCALAPPDATA%\<app>
4HOME setmacOS ~/Library/Application Support/<app>; otherwise ~/.local/share/<app>
5nothing set./<app> in the working directory

Below the root the layout is <displayName>/Player{N} or <displayName>/AllPlayers. <app> is the literal string game unless you call StorageDevice::SetAppNameEXT("Name"), so always call it — otherwise every CNA game on the machine shares one folder. The full reference is on the Storage page.

The project

my-cna-workspace/
  sharp-runtime/      # git clone -b next https://github.com/libcna/sharp-runtime.git
  cna/                # git clone -b next https://github.com/libcna/cna.git
  save-demo/
    CMakeLists.txt
    main.cpp
cmake_minimum_required(VERSION 3.20)
project(SaveDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(CNA_GRAPHICS_RENDERER "HEADLESS" CACHE STRING "CNA graphics renderer")
set(CNA_BUILD_TESTS OFF CACHE BOOL "")
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../cna ${CMAKE_BINARY_DIR}/cna)

add_executable(save_demo main.cpp)
# Storage is a self-contained module: it needs only Sharp Runtime. A real game links CNA,
# which already includes it.
target_link_libraries(save_demo PRIVATE CNA::Storage)
💡

The code in this tutorial was checked against the CNA headers at snapshot 009d40f5 (the whole program compiles as C++23 against them). The site did not build and run it, so the output shown is what the code paths in StorageDevice.cpp / StorageContainer.cpp produce, not a captured log.

Step 1: name the app, get a device and a container

The Begin/End pairs are XNA’s async pattern; on CNA they complete synchronously — your callback (here nullptr) has already run when Begin* returns. Both Begin* calls return a std::unique_ptr<System::IAsyncResult>, and End* takes .get() of it. There is no StorageDevice::GetDefault(), no synchronous OpenContainer() shortcut and no way to ask a container for its path — the container is the only door.

// main.cpp - Tutorial 141: save and load game data through StorageDevice / StorageContainer
#include <exception>
#include <iostream>
#include <memory>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageDeviceNotConnectedException.hpp"
#include "System/IO/FileMode.hpp"

using namespace Microsoft::Xna::Framework::Storage;

struct SaveGame
{
    std::string playerName;
    int level = 1;
    int score = 0;
};

// ---- 1. A device and a container ------------------------------------------------------------
// Begin/End pairs complete synchronously: the callback has already run when Begin* returns.
std::unique_ptr<StorageDevice> OpenDevice()
{
    auto selection = StorageDevice::BeginShowSelector(nullptr, nullptr);
    return StorageDevice::EndShowSelector(selection.get());
}

std::unique_ptr<StorageContainer> OpenSlot(StorageDevice& device, const std::string& slot)
{
    auto opening = device.BeginOpenContainer(slot, nullptr, nullptr);
    return device.EndOpenContainer(opening.get());
}

A container name such as "Slot1" becomes the directory <root>/Slot1/AllPlayers (the selector overload without a PlayerIndex). Select the device with BeginShowSelector(PlayerIndex::One, ...) instead and it becomes <root>/Slot1/Player1: two local players then never collide on one machine.

Step 2: serialise your data

Storage moves bytes, not objects; format is your choice. A one-line version header (cna-save 1) makes an old or corrupt file detectable instead of silently misread, and std::optional makes “no usable save” a normal outcome:

// ---- 2. Serialise to a small text format ----------------------------------------------------
std::string ToText(const SaveGame& save)
{
    std::ostringstream out;
    out << "cna-save 1\n"
        << "name=" << save.playerName << '\n'
        << "level=" << save.level << '\n'
        << "score=" << save.score << '\n';
    return out.str();
}

std::optional<SaveGame> FromText(const std::string& text)
{
    std::istringstream in(text);
    std::string header;
    std::getline(in, header);
    if (header != "cna-save 1") return std::nullopt;        // wrong or corrupt file

    SaveGame save;
    std::string line;
    while (std::getline(in, line))
    {
        const auto eq = line.find('=');
        if (eq == std::string::npos) continue;
        const std::string key = line.substr(0, eq);
        const std::string value = line.substr(eq + 1);
        try
        {
            if (key == "name") save.playerName = value;
            else if (key == "level") save.level = std::stoi(value);
            else if (key == "score") save.score = std::stoi(value);
        }
        catch (const std::exception&) { return std::nullopt; }   // stoi failed
    }
    return save;
}

Step 3: write and read through the container

CreateFile gives a writable stream that replaces any existing file (it is FileMode::Create); OpenFile(file, FileMode::Open) gives a readable one. Both return std::unique_ptr<System::IO::Stream>, Sharp Runtime’s Stream, whose length is getLengthProperty() and whose Read/Write take a SharpRuntime::bytecs buffer, an offset and a count. FileExists turns “first run” into an ordinary branch:

// ---- 3. Write and read a file through the container -----------------------------------------
void WriteSave(StorageContainer& container, const std::string& file, const SaveGame& save)
{
    const std::string text = ToText(save);
    const std::vector<SharpRuntime::bytecs> bytes(text.begin(), text.end());
    auto stream = container.CreateFile(file);               // FileMode::Create: replaces any old file
    stream->Write(bytes.data(), 0, static_cast<SharpRuntime::intcs>(bytes.size()));
    stream->Flush();
    stream->Close();
}

std::optional<SaveGame> ReadSave(StorageContainer& container, const std::string& file)
{
    if (!container.FileExists(file)) return std::nullopt;   // first run: nothing saved yet
    auto stream = container.OpenFile(file, System::IO::FileMode::Open);
    std::vector<SharpRuntime::bytecs> bytes(static_cast<std::size_t>(stream->getLengthProperty()));
    const auto got = stream->Read(bytes.data(), 0, static_cast<SharpRuntime::intcs>(bytes.size()));
    stream->Close();
    return FromText(std::string(bytes.begin(), bytes.begin() + got));
}
⚠

The write is not atomic. CreateFile truncates the old file first; a crash in the middle of Write can leave a short file. That is why the loader checks the header and returns std::nullopt for anything it cannot parse. If losing a save is unacceptable, write to a second file name and keep the previous slot until the new one has been read back successfully.

Step 4: slots, listing and deleting

The whole program ties the steps together. Naming the app comes first; GetFileNames("*.sav") lists the container’s regular files by wildcard (* and ?; the order is the filesystem’s, so sort if you need one); DeleteContainer("Slot1") removes a slot recursively:

int main(int argc, char** argv)
{
    // The app folder is the literal "game" unless you name it - do this before the first use.
    StorageDevice::SetAppNameEXT("SaveDemo");
    std::cout << "storage root: " << StorageDevice::GetStorageRootEXT() << '\n';

    try
    {
        auto device = OpenDevice();
        if (argc > 1 && std::string(argv[1]) == "--reset")
        {
            device->DeleteContainer("Slot1");                // removes <root>/Slot1 recursively
            std::cout << "slot deleted\n";
            return 0;
        }
        auto slot = OpenSlot(*device, "Slot1");

        if (auto existing = ReadSave(*slot, "profile.sav"))
        {
            std::cout << "loaded " << existing->playerName << ", level " << existing->level
                      << ", score " << existing->score << '\n';
            existing->level += 1;
            existing->score += 100;
            WriteSave(*slot, "profile.sav", *existing);
            std::cout << "saved level " << existing->level << '\n';
        }
        else
        {
            std::cout << "no save found - creating one\n";
            WriteSave(*slot, "profile.sav", SaveGame{"Ada", 1, 0});
        }

        for (const std::string& name : slot->GetFileNames("*.sav"))
            std::cout << "  file: " << name << '\n';
        slot->Dispose();
    }
    catch (const StorageDeviceNotConnectedException& e)
    {
        std::cerr << "storage unavailable: " << e.what() << '\n';   // root could not be created
        return 1;
    }
    catch (const std::invalid_argument& e)
    {
        std::cerr << "rejected name or path: " << e.what() << '\n'; // absolute, '..' or empty
        return 1;
    }
    catch (const std::exception& e)
    {
        std::cerr << "save failed: " << e.what() << '\n';           // any I/O error from the stream
        return 1;
    }
    return 0;
}

Step 5: what can fail

ExceptionWhenWhat to do
StorageDeviceNotConnectedExceptionThe storage root cannot be created on first use, or free/total space cannot be read.Tell the player saving is unavailable; keep playing.
std::invalid_argumentA container name or path is empty, absolute, climbs out with .., or resolves through a symlink outside the container. Also thrown by End* for a result from the wrong Begin*.Fix the name. Never build a container name or path from untrusted text without validating it — CNA refuses, but relying on the refusal is the last line of defence.
Other std::exceptionStream or filesystem errors (disk full, permissions).Report the failure; do not delete the previous save.

DeleteFile on a file that does not exist is a silent no-op, and DeleteDirectory removes only empty directories.

Build and run

cd save-demo
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target save_demo -j$(nproc)

# Keep the demo's files inside the project by pointing the root at a local folder (Linux/macOS):
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo --reset

Expected output (the root line shows your absolute path):

# run 1
storage root: /home/you/save-demo/build/xdg/SaveDemo
no save found - creating one
  file: profile.sav
# run 2
storage root: /home/you/save-demo/build/xdg/SaveDemo
loaded Ada, level 1, score 0
saved level 2
  file: profile.sav
# --reset
storage root: /home/you/save-demo/build/xdg/SaveDemo
slot deleted

Open build/xdg/SaveDemo/Slot1/AllPlayers/profile.sav to see the text. Run it once without XDG_DATA_HOME and the same file appears under ~/.local/share/SaveDemo on Linux; on Windows the root is %LOCALAPPDATA%\SaveDemo and on macOS ~/Library/Application Support/SaveDemo. Delete the slot with --reset when you are done.

The web: no persistence

⚠

Under Emscripten this program “works” and saves nothing. CNA mounts no persistent file system on the web — a search of the whole snapshot finds no IDBFS, FS.syncfs, FS.mount, IndexedDB, localStorage or OPFS use — so StorageDevice writes into Emscripten’s default in-memory file system. Every CreateFile succeeds and the data is gone on reload. The same is true of the local GamerServices achievement and leaderboard files, which live under the same root.

The honest options for a browser build are (a) accept it — a game that keeps state for one page view does not need persistence; (b) save through your own browser storage: after WriteSave, hand the text to JavaScript (localStorage or IndexedDB) yourself, and on start-up read it back and feed it to FromText before falling back to the container; or (c) mount a persistent file system in your Emscripten link flags and page script. CNA does not do (b) or (c) for you, and this site has not verified an end-to-end recipe for either, so none is given here. The web gotchas tutorial lists the other browser boundaries.

Pitfalls

SymptomCauseFix
Saves from different games mix in one folderNeither called SetAppNameEXT, so both use game.Call SetAppNameEXT("YourGame") at start-up, before opening any container.
An old save “vanished” after you added SetAppNameEXTThe root moved from .../game to .../YourGame.Copy the old folder once, or keep the old name.
std::invalid_argument opening a containerEmpty name, an absolute path, or a .. in it.Use a simple relative name such as Slot1.
Compile error: no member GetDefault, OpenContainer or getPathPropertyOlder examples used APIs that do not exist.Use the Begin/End pairs shown above; files go through the container’s streams.
Save works on desktop, disappears in the browserNo persistent file system on the web.See the web section.
File order differs between machinesGetFileNames returns the filesystem’s order.Sort the names.

Next steps