Accounts, characters and saves
How Sunrise stores the player's account and sends it to the game over the BAP link.
Sunrise keeps one account in a local SQLite database. Every answer about the player is read from that database, turned into the game’s own data layout and sent over the BAP link. Every change the player makes is written to the database before the game is told it succeeded.
The BAP link itself is in From launch to orbit. The layout of the data inside the game is in Items, characters and inventory.
The local database
The database is one file, Sunrise\data\investment.sqlite3, in the folder that holds the Sunrise DLL. The code is in src/state/investment/.
- On first start Sunrise creates the file. It writes the schema and the default rows. Both are built into the DLL as resources (
resources/database/). - The defaults hold one account with three level-50 characters: a Hunter, a Titan and a Warlock.
- On a later start Sunrise checks the schema version. Version 1 is upgraded to version 2 in place. Any other version, or a file from another program, is refused.
- Every write runs inside a SQLite savepoint. A failed write rolls back and leaves no partial rows.
- Writes are synchronous (
PRAGMA synchronous=FULL). No unsaved copy is held in memory, except in a cheat session.
The file is plain SQLite. Back it up before you edit it by hand.
Tables
table
what it holds
account
the account SOID and whether profile setup is done
characters
one row per slot 0 to 2: SOID, race, gender, class, level, title, last orbit destination
items
character items; location 0 is equipped (17 slots), location 1 is inventory (135 rows)
sockets
the plug in each socket lane of an item
profile_items
account-wide currencies and materials, up to 701 rows
character_stacks
per-character materials that have no instance
unlocks
flag, objective and progression banks, per account or per character
family5
account-wide unlock and value overrides
entitlements
owned content, sent in the SignOn reply
dismantle_rewards
what dismantling gear pays out
pending_rewards
rewards earned but not yet granted
bootstrap
one-time setup steps already done
account_*
game settings: controls, audio, display, interface, social, key bindings
The account_* tables come from a second schema file, account_settings_schema.sql.
Cheat sessions
When a loaded mod sets "cheat": true, or client.cheats unlocks the cheat tools in settings.json, the session never writes to the file.
- After the mods load and the save opens, Sunrise copies the database into memory with the SQLite backup API. This runs before account repairs or progression seeding read mod content.
- It closes the file and sends every read and write to the copy.
- The copy is gone when the game exits.
So every table above is back to its old state after a restart, game settings and key bindings included. Account repairs and progression seeding also stay in the copy. Creating a new database or upgrading its schema happens when the file opens, before the copy.
If the copy fails, Sunrise does not start. The code is use_session_copy in src/state/investment/investment_database.cpp. How a mod sets the flag is in Mods. The settings key is in Settings, logs and in-game tools.
The account model
In memory the account is one AccountState value (src/state/account/account_state.h). It is read from the database each time it is needed. There is no separate cached copy.
/* Simplified from src/state/account/account_state.h. */
struct AccountState {
uint64_t primarySoid; /* the account's object id */
ProfileItem profileItems[]; /* currencies and materials */
CharacterState characters[3]; /* slots 0 to 2 */
bool profileSetupCompleted;
AccountSettings settings; /* controls, audio, display, ... */
};
struct CharacterState {
uint64_t soid; /* primarySoid + 1 + slot */
bool selected; /* session only */
uint8_t race, gender, characterClass, level;
uint16_t currentActivityIndex; /* session only */
uint16_t equippedTitleRecordIndex;
uint64_t signInSeconds; /* session only */
Equipment equipment; /* equipped items by slot */
CharacterItems inventory; /* unequipped items */
CharacterStacks stacks; /* non-instanced materials */
};
Every object the game tracks has a 64-bit id, a SOID. The account has one, each character has one, and each item instance has one. In the defaults, and after Sunrise adopts a new account SOID, the character SOIDs are the account SOID plus 1, 2 and 3.
Which account SOID is used
The game names the account in its first request, ws opcode 503.
- If that request carries a nonzero account id, Sunrise adopts it. It stores it as the new account SOID and moves the character SOIDs with it.
- If the id is zero, Sunrise answers with the SOID already in the database.
This is in src/server/web_service/web_service_runtime.cpp and set_primary_soid in src/state/runtime/state_account_runtime.cpp.
How the data reaches the client
There are three paths. All of them run over the encrypted BAP link.
path
carries
when
ws opcode 503 reply
the account SOID, the server clock, the family 5 object
once, during investment_signin
service 123 push
queuez family snapshots and updates
after a subscribe, and after every change
ws reply status
the family 4 version a change will publish
in the reply to each action
ws opcode 503: the account bootstrap
The game sends 503 once, on the main link, during boot step 23. The reply is built in src/middleware/web_service/messages/opcode503_codec.cpp. In order, it holds:
- the status pair: code 0, and value -1, which means “no family 4 version to wait for”
- the account SOID, 64 bits
- two 32-bit fields that nothing reads. Sunrise sends 0.
- the family 5 object: a fixed sentinel SOID, the server clock, and the override lists from the
family5table
The clock always moves forward, even for two requests in the same second. The client ignores a family 5 update whose clock is not newer.
Replicated families: queuez
The queuez service is a replicated object store. The client holds a copy of each family it subscribed to. The server keeps it current with pushes on service 123. There is no “get everything” request.
A family is identified by its type and a root SOID. Sunrise builds these types:
type
what Sunrise sends
builder
0
the banner pair for one character
push/snapshot/banner_snapshot.cpp
2
the social roster member record
push/snapshot/social_roster_snapshot.cpp
3
the character roster; its count picks selection or creation
push/snapshot/roster_snapshot.cpp
4
the account object, the selected character object, one object per item
push/snapshot/family4_snapshot_preparer.cpp
5
the global override object
push/queuez/queuez_family5_push.cpp
Paths are under src/server/bap/encrypted/. Any other family gets an empty full snapshot. An empty snapshot still moves the client’s record to “synced”.
The client subscribes in two ways:
- Service 12. The body is 9 bytes: a
u8family type, then theu64root SOID. Sunrise answers with an empty service 13, then pushes the first snapshot on service 123. - ws opcode 206. The body is bit-packed: 4 bits of family type plus 1, then 64 bits of root SOID. The client uses it for family 3. Sunrise puts the first snapshot in the first trailer blob of the 206 reply. If family 4 is not yet live on that link, Sunrise then pushes the family 4 snapshot and the banner pair on service 123. It sends family 4 once more 400 ms later.
Both paths end in append_queuez_notification in push/queuez/queuez_subscription.cpp.
The service 123 body
The body is big-endian and has no padding. It is encoded by src/middleware/queuez/queuez_update.cpp.
#pragma pack(push, 1)
struct QueuezUpdate { /* the service 123 body */
uint32_t family_count; /* +0x00, never 0 */
/* family_count QueuezFamily records follow */
};
struct QueuezFamily { /* 21 bytes */
uint32_t family_type; /* +0x00 */
uint64_t family_root_soid; /* +0x04 */
int32_t version; /* +0x0C, -1 is refused */
uint8_t flags; /* +0x10, bit 0 = full snapshot */
uint32_t object_count; /* +0x11, 0 is legal */
/* object_count QueuezObject records follow */
};
struct QueuezObject { /* 20 bytes, then the payload */
uint32_t object_id; /* +0x00, a definition id, not a slot */
uint64_t object_version; /* +0x04, the object's SOID for family 4 */
uint32_t payload_len; /* +0x0C, 0 = delete this object */
uint32_t encoding; /* +0x10, see below */
/* payload_len bytes */
};
#pragma pack(pop)
encoding
meaning
Sunrise uses it for
0
none; goes with a delete
–
1
tag reflection, a bit-packed schema walk
family 5, and some small updates
2
binary diff
not used
3
raw bytes, starting with the object version
not used
4
Oodle-compressed image
the family 4 objects
Rules:
- The first update to a family must set flag bit 0, full snapshot. The client refuses it otherwise.
- A full snapshot replaces the family. Objects it does not name are deleted.
- After the first snapshot, each change goes out at the next version. Sunrise tracks the version it last sent on each connection.
- Sunrise caps one notification at 64 KiB and one object payload at 96,280 bytes.
Family 4: the account itself
Family 4 is what boot step 23 waits for. Its root SOID is the account SOID from the 503 reply.
object
size before compression
holds
account
96,280 bytes
roster, profile inventory, progressions, flags, settings, key bindings
selected character
46,928 bytes
the character’s equipment, inventory and state; only after a pick
item instance
416 bytes each
one per owned item, including profile items the game can act on
Each object is a byte-exact image of the game’s own structure. Sunrise fills it from AccountState (src/middleware/datagen/family4/), then compresses it with Oodle. The layouts are described in Items, characters and inventory.
Inventory actions: the ws RPC
Every player action is a web service call on BAP service 10, answered on service 11. The same envelope runs on 110 and 112. The codec is src/middleware/web_service/web_service_envelope.cpp.
struct WsEnvelope { /* the service 10 or 11 body */
uint16_t opcode; /* +0x00, big-endian */
uint32_t transaction_id; /* +0x02, big-endian; the reply echoes it */
/* +0x06: the payload, bit-packed MSB first, laid out by the opcode's definition */
/* then two trailer blobs, each: 1 presence bit [, 16-bit length, bytes] */
};
The reply echoes the opcode and the transaction id. Most replies then start with a status:
/* The status fields, in order, all bit-packed. */
code : 5 bits /* stored as logical code + 1; 0 = success, 1 = refused */
value : 32 bits /* stored as logical value - INT32_MIN; a family 4 version, or -1 */
flag : 1 bit /* only for opcodes 104 and 901 */
shape
bits
example opcodes
status only
5
206
status pair
37
402, 403, 404, 504, 701, 903, 1801, 2400
status pair and flag
38
104, 901
no status
0
opcodes Sunrise does not know
Sunrise sets both trailer presence bits to 0, except in the 206 reply.
The version in the reply
A reply that changes the account names the family 4 version that will carry the change. The client waits for that version before it treats the action as done.
/* How Sunrise answers one account action, e.g. opcode 403 equip. Simplified. */
begin_database_transaction();
plan = prepare(request); /* check the action against the account */
if (!plan.ok) {
rollback();
send(reply(code = 1)); /* refused; nothing changes */
return;
}
next = session.family4Version + 1;
reply = reply(code = 0, value = next);
push = family4_update(plan, next); /* on service 123 */
if (fits(reply, push) && commit_database_transaction()) {
send(reply, push); /* one socket write */
session.family4Version = next;
} else {
rollback(); /* nothing is sent */
}
- The value -1 means there is nothing to wait for. Sunrise sends it in 503 and in any reply that changes nothing.
- A refused action gets code 1 in its own shape. An opcode whose own codec fails gets a bare echo of the header, so the client’s task still completes.
Opcodes Sunrise acts on
The dispatch is in src/server/web_service/web_service_runtime.cpp and src/server/bap/encrypted/body/bap_service_body.cpp.
opcode
action
205
send the family 5 object again
206
subscribe to a family
402
dismantle an item
403, 404
equip, unequip
406
change item state flags
501
return the selected character’s SOID
503
account bootstrap
504
pick a character
505
change character
601
complete a loot pickup
701
save game settings
702
the character write-back the client sends on its own
801
pick a subclass
901
buy from a vendor; the seasonal artifact vendor has its own path
903, 1901
insert a socket plug
904
pick up a quest
1801
claim a record
1820
acquire one item instance
1821
equip a title
2400
claim a season pass reward
Any other opcode gets a correlated reply and no action. The reply uses the status shape Sunrise has on record for that opcode, or no status at all.
What persists
survives a restart
lost at exit
the account SOID and profile setup flag
which character is selected
characters, their items, sockets and stacks
the activity each character is in
currencies and materials
the sign-in time
unlocks, objectives and progressions
the SignOn token and every link key
family 5 overrides
each connection’s queuez versions
game settings and key bindings
pending rewards
The session-only fields live beside the database, not in it (Session in src/state/investment/store_internal.h). After a restart no character is selected until the player picks one.
In a cheat session nothing in the left column changes on disk.
Open questions
- The second family 4 push after ws opcode 206 runs on a fixed 400 ms delay. The client report that should trigger it is unknown.
Related pages
- How Sunrise is built shows where the state and server layers sit.
- The Activity Host covers what happens after orbit.
- Settings, logs and in-game tools covers log files and levels.
