Parcourir la source

Merge pull request #129 from T4tsuk3i/feature/character-lifecycle

Feature/character lifecycle
stan il y a 4 jours
Parent
commit
f8857e398a
39 fichiers modifiés avec 1251 ajouts et 46 suppressions
  1. 11 0
      Sunrise/Sunrise.vcxproj
  2. 23 0
      Sunrise/resources/database/character_starter.sql
  3. 3 3
      Sunrise/resources/database/investment_defaults.sql
  4. 4 2
      Sunrise/resources/database/investment_schema.sql
  5. 3 0
      Sunrise/resources/resource.h
  6. 2 0
      Sunrise/resources/sunrise.rc
  7. 13 0
      Sunrise/src/client/content/items/packages/internal.h
  8. 8 1
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  9. 135 0
      Sunrise/src/client/content/items/packages/package_starter_set_build.cpp
  10. 2 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  11. 3 3
      Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
  12. 4 3
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  13. 2 4
      Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp
  14. 4 1
      Sunrise/src/middleware/web_service/messages/opcode501_codec.h
  15. 156 0
      Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp
  16. 29 0
      Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h
  17. 30 0
      Sunrise/src/middleware/web_service/messages/opcode502.cpp
  18. 25 0
      Sunrise/src/middleware/web_service/messages/opcode502.h
  19. 19 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  20. 7 2
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  21. 6 4
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  22. 52 0
      Sunrise/src/server/web_service/web_service_actions.cpp
  23. 4 0
      Sunrise/src/server/web_service/web_service_actions.h
  24. 50 8
      Sunrise/src/server/web_service/web_service_runtime.cpp
  25. 22 1
      Sunrise/src/server/web_service/web_service_runtime.h
  26. 9 0
      Sunrise/src/state/account/account_state.h
  27. 21 0
      Sunrise/src/state/build_data/runtime.h
  28. 2 0
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  29. 22 0
      Sunrise/src/state/build_data/starter_sets/definition.h
  30. 21 0
      Sunrise/src/state/build_data/starter_sets/starter_set_build_data_runtime.cpp
  31. 67 0
      Sunrise/src/state/build_data/starter_sets/starter_set_catalog.cpp
  32. 31 0
      Sunrise/src/state/build_data/starter_sets/starter_set_catalog.h
  33. 81 3
      Sunrise/src/state/investment/investment_account_store.cpp
  34. 37 6
      Sunrise/src/state/investment/investment_database.cpp
  35. 3 1
      Sunrise/src/state/investment/investment_store_boot.cpp
  36. 30 1
      Sunrise/src/state/investment/store.h
  37. 21 3
      Sunrise/src/state/investment/store_internal.h
  38. 22 0
      Sunrise/src/state/runtime/runtime.h
  39. 267 0
      Sunrise/src/state/runtime/state_account_roster_runtime.cpp

+ 11 - 0
Sunrise/Sunrise.vcxproj

@@ -202,7 +202,10 @@
     <ClCompile Include="src\middleware\web_service\messages\opcode702_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode406_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1820_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode501_request_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode502.cpp" />
     <ClCompile Include="src\state\runtime\state_account_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_roster_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_acquisition_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_reward_grant_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_dismantle_runtime.cpp" />
@@ -771,6 +774,8 @@
     <ClCompile Include="src\state\build_data\season_pass\season_pass_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\bounties\bounty_catalog.cpp" />
     <ClCompile Include="src\state\build_data\bounties\bounty_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\starter_sets\starter_set_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\starter_sets\starter_set_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\scriptables\scriptable_catalog.cpp" />
     <ClCompile Include="src\state\build_data\runtime\domain_markers.cpp" />
     <ClCompile Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
@@ -1164,6 +1169,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_starter_set_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
@@ -1903,6 +1909,8 @@
     <ClInclude Include="src\middleware\web_service\messages\opcode503.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode206.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode501_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode501_request_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode502.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode505\opcode505_codec.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode601\opcode601_codec.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />
@@ -2100,6 +2108,8 @@
     <ClInclude Include="src\state\build_data\season_pass\season_pass_catalog.h" />
     <ClInclude Include="src\state\build_data\bounties\definition.h" />
     <ClInclude Include="src\state\build_data\bounties\bounty_catalog.h" />
+    <ClInclude Include="src\state\build_data\starter_sets\definition.h" />
+    <ClInclude Include="src\state\build_data\starter_sets\starter_set_catalog.h" />
     <ClInclude Include="src\state\build_data\vendors\repeatable_triggers.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
@@ -2337,6 +2347,7 @@
     <ClInclude Include="src\state\account\inventory\seen_state.h" />
     <None Include="resources\database\account_settings_schema.sql" />
     <None Include="resources\database\account_settings_defaults.sql" />
+    <None Include="resources\database\character_starter.sql" />
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="src\state\activity_sdk\actor_sequences.h" />

+ 23 - 0
Sunrise/resources/database/character_starter.sql

@@ -0,0 +1,23 @@
+-- Sunrise's own starting state for a created character. Each value is the one the default save
+-- gives its characters. The items go ahead of the packages' new-character table, which names no
+-- subclass. Classes: 0 Titan, 1 Hunter, 2 Warlock.
+CREATE TABLE starter_character (
+    id INTEGER PRIMARY KEY CHECK (id = 1),
+    level INTEGER NOT NULL CHECK (level BETWEEN 0 AND 255),
+    preview_available INTEGER NOT NULL CHECK (preview_available IN (0, 1)),
+    appearance_value REAL NOT NULL,
+    last_orbited_destination INTEGER NOT NULL,
+    content_bypass INTEGER NOT NULL CHECK (content_bypass IN (0, 1))
+) STRICT;
+
+CREATE TABLE starter_items (
+    class INTEGER NOT NULL CHECK (class BETWEEN 0 AND 2),
+    position INTEGER NOT NULL CHECK (position >= 0),
+    definition_hash INTEGER NOT NULL CHECK (definition_hash BETWEEN 1 AND 4294967295),
+    PRIMARY KEY (class, position)
+) STRICT;
+
+INSERT INTO starter_character VALUES (1, 50, 1, 1.0, 308080871, 1);
+INSERT INTO starter_items VALUES (0, 0, 3105935002);
+INSERT INTO starter_items VALUES (1, 0, 3635991036);
+INSERT INTO starter_items VALUES (2, 0, 3481861797);

+ 3 - 3
Sunrise/resources/database/investment_defaults.sql

@@ -1,5 +1,5 @@
 INSERT INTO account VALUES (1, -7013740688821976832, 1);
-INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (0, 0, 0, 4611686018427387905, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427387905, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427387905, 1, 3433396027);
@@ -145,7 +145,7 @@ INSERT INTO items VALUES (0, 1, 36, 4611686018427387920, 4012203003, 0, 1, 53, 0
 INSERT INTO items VALUES (0, 1, 37, 4611686018427387957, 95537029, 0, 1, 54, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (0, 1, 38, 4611686018427387958, 2748215252, 0, 1, 55, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (0, 1, 39, 4611686018427387959, 3769820799, 0, 1, 56, 0, 0, 0, 4, 7, 10, 11, 2, 0);
-INSERT INTO characters VALUES (1, -7013740688821976830, 0, 0, 0, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (1, -7013740688821976830, 0, 0, 0, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (1, 0, 0, 4611686018427388005, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427388005, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427388005, 1, 3433396027);
@@ -291,7 +291,7 @@ INSERT INTO items VALUES (1, 1, 36, 4611686018427388020, 2854370169, 0, 1, 53, 0
 INSERT INTO items VALUES (1, 1, 37, 4611686018427388057, 530754878, 0, 1, 54, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (1, 1, 38, 4611686018427388058, 815877596, 0, 1, 55, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (1, 1, 39, 4611686018427388059, 1188569234, 0, 1, 56, 0, 0, 0, 4, 7, 10, 11, 2, 0);
-INSERT INTO characters VALUES (2, -7013740688821976829, 0, 0, 2, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (2, -7013740688821976829, 0, 0, 2, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (2, 0, 0, 4611686018427388105, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427388105, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427388105, 1, 3433396027);

+ 4 - 2
Sunrise/resources/database/investment_schema.sql

@@ -1,5 +1,5 @@
 PRAGMA application_id = 1397902921;
-PRAGMA user_version = 2;
+PRAGMA user_version = 3;
 
 CREATE TABLE account (
     id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -20,7 +20,9 @@ CREATE TABLE characters (
     content_bypass INTEGER NOT NULL CHECK (content_bypass IN (0, 1)),
     equipped_title INTEGER NOT NULL CHECK (equipped_title BETWEEN 0 AND 65535),
     acquired_subclass_mask INTEGER NOT NULL,
-    next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295)
+    next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295),
+    appearance_header BLOB NOT NULL CHECK (length(appearance_header) = 36),
+    appearance_header_valid INTEGER NOT NULL CHECK (appearance_header_valid IN (0, 1))
 ) STRICT;
 
 CREATE TABLE items (

+ 3 - 0
Sunrise/resources/resource.h

@@ -47,3 +47,6 @@
 /** Account preferences have their own schema and seed data in the save. */
 #define IDR_ACCOUNT_SETTINGS_SCHEMA 109
 #define IDR_ACCOUNT_SETTINGS_DEFAULTS 110
+
+/** A created character's starting state has its own tables and rows in the save. */
+#define IDR_CHARACTER_STARTER 111

+ 2 - 0
Sunrise/resources/sunrise.rc

@@ -61,3 +61,5 @@ IDR_SQLITE_NOTICE RCDATA "../vendor/sqlite/NOTICE.txt"
 
 IDR_ACCOUNT_SETTINGS_SCHEMA RCDATA "database/account_settings_schema.sql"
 IDR_ACCOUNT_SETTINGS_DEFAULTS RCDATA "database/account_settings_defaults.sql"
+
+IDR_CHARACTER_STARTER RCDATA "database/character_starter.sql"

+ 13 - 0
Sunrise/src/client/content/items/packages/internal.h

@@ -263,6 +263,19 @@ read_investment_constants(const reader::Source& source,
                           std::vector<std::byte>& blob,
                           state::build_data::constants::InvestmentConstants& output) noexcept;
 
+/**
+ * Reads and publishes the new-character table: per class, the items a new character gets.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the table blob.
+ * @return False only when the tag does not read, so a later slice retries it.
+ */
+[[nodiscard]] bool build_starter_sets(const reader::Source& source,
+                                      reader::Scratch& scratch,
+                                      std::span<const std::byte> root,
+                                      std::vector<std::byte>& blob) noexcept;
+
 /**
  * Builds the ability buckets one subclass publishes under one ability selection.
  * @param source Package source.

+ 8 - 1
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -40,7 +40,8 @@ namespace {
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
            && state::build_data::sobject_definitions_ready()
-           && state::build_data::investment_constants_ready() && exotic_catalysts_settled();
+           && state::build_data::investment_constants_ready()
+           && state::build_data::starter_sets_ready() && exotic_catalysts_settled();
 }
 
 } // namespace
@@ -228,6 +229,12 @@ bool build() noexcept {
                     (void)state::build_data::publish_investment_constants(extracted);
                 }
             }
+            if (!state::build_data::starter_sets_ready()) {
+                (void)build_starter_sets(source,
+                                         storage.scratch,
+                                         std::span<const std::byte>{storage.root},
+                                         storage.child);
+            }
             reason = "slot";
             if (!tables::slot_tag(
                     std::span<const std::byte>{storage.root}, tables::kItemTableSlot, tableTag)

+ 135 - 0
Sunrise/src/client/content/items/packages/package_starter_set_build.cpp

@@ -0,0 +1,135 @@
+#include <array>
+#include <cstddef>
+#include <cstring>
+
+#include "../../../../core/logging/log.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace domain = state::build_data::starter_sets;
+
+/** Package class of the new-character table blob. */
+constexpr std::uint32_t kStarterSetTableClass = 0x808075BAU;
+/** Element class of the table's per-class rows. */
+constexpr std::uint32_t kStarterSetRowClass = 0x808074E7U;
+/** Element class of an array of 16-bit item-table indices. */
+constexpr std::uint32_t kItemIndexArrayClass = 0x808087DBU;
+
+/** An array field is a 64-bit count and a 64-bit self-relative offset. */
+constexpr std::size_t kArrayFieldSize = 16;
+/** The installed class rows are 40 bytes apart. */
+constexpr std::size_t kStarterSetRowSize = 40;
+
+/** One class row: the class byte, then two arrays of item-table indices. */
+struct StarterSetRow {
+    std::uint8_t characterClass;
+    std::array<std::byte, 7> opaque01;
+    std::array<std::byte, kArrayFieldSize> firstItems;
+    std::array<std::byte, kArrayFieldSize> secondItems;
+};
+static_assert(sizeof(StarterSetRow) == kStarterSetRowSize);
+
+/**
+ * Appends one item-index array of a class row to that row's definition.
+ * @param blob Whole table blob.
+ * @param descriptorOffset Offset of the array's count and self-relative offset.
+ * @param output Row whose item list grows.
+ * @return False when the array does not read or overflows the row's storage.
+ */
+[[nodiscard]] bool append_items(std::span<const std::byte> blob,
+                                std::size_t descriptorOffset,
+                                domain::Definition& output) noexcept {
+    tables::Array items{};
+    if (!tables::find_optional_array_at(blob, descriptorOffset, items)) {
+        return false;
+    }
+    if (items.count == 0) {
+        return true;
+    }
+    const std::size_t size = static_cast<std::size_t>(items.count) * sizeof(std::uint16_t);
+    if (items.elementClass != kItemIndexArrayClass
+        || items.count > domain::kItemCapacity - output.itemCount || items.dataOffset > blob.size()
+        || size > blob.size() - items.dataOffset) {
+        return false;
+    }
+    for (std::size_t index = 0; index < items.count; ++index) {
+        std::uint16_t itemIndex = 0;
+        std::memcpy(&itemIndex,
+                    blob.data() + items.dataOffset + index * sizeof(std::uint16_t),
+                    sizeof itemIndex);
+        output.itemIndices[output.itemCount++] = itemIndex;
+    }
+    return true;
+}
+
+/**
+ * Reads every class row of the new-character table.
+ * @param blob Whole table blob.
+ * @param output Receives one definition per row.
+ * @param count Receives the row count.
+ * @return False when the rows do not match the installed layout.
+ */
+[[nodiscard]] bool read_rows(std::span<const std::byte> blob,
+                             std::array<domain::Definition, domain::kClassCount>& output,
+                             std::size_t& count) noexcept {
+    count = 0;
+    tables::Array rows{};
+    if (!tables::find_array(blob, kStarterSetRowClass, rows) || rows.count > output.size()
+        || rows.dataOffset > blob.size()
+        || static_cast<std::size_t>(rows.count) * sizeof(StarterSetRow)
+               > blob.size() - rows.dataOffset) {
+        return false;
+    }
+    for (std::size_t row = 0; row < rows.count; ++row) {
+        const std::size_t base = rows.dataOffset + row * sizeof(StarterSetRow);
+        domain::Definition& definition = output[row];
+        definition = {};
+        definition.characterClass =
+            std::to_integer<std::uint8_t>(blob[base + offsetof(StarterSetRow, characterClass)]);
+        if (!append_items(blob, base + offsetof(StarterSetRow, firstItems), definition)
+            || !append_items(blob, base + offsetof(StarterSetRow, secondItems), definition)) {
+            return false;
+        }
+    }
+    count = static_cast<std::size_t>(rows.count);
+    return true;
+}
+
+} // namespace
+
+/**
+ * Reads and publishes the new-character table: per class, the items a new character gets.
+ * A table that reads but does not match the layout publishes empty, so the pass settles.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the table blob.
+ * @return False only when the tag does not read, so a later slice retries it.
+ */
+bool build_starter_sets(const reader::Source& source,
+                        reader::Scratch& scratch,
+                        std::span<const std::byte> root,
+                        std::vector<std::byte>& blob) noexcept {
+    std::uint32_t tag = 0;
+    std::uint32_t tableClass = 0;
+    if (!tables::slot_tag(root, tables::kStarterSetTableSlot, tag) || tag == 0
+        || !reader::read_tag(source, scratch, tag, blob, tableClass)) {
+        return false;
+    }
+    std::array<domain::Definition, domain::kClassCount> rows{};
+    std::size_t count = 0;
+    if (tableClass != kStarterSetTableClass
+        || !read_rows(std::span<const std::byte>{blob}, rows, count)) {
+        count = 0;
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=starter_sets stage=read result=fail tag=0x%08X class=0x%08X",
+                          tag,
+                          tableClass);
+    }
+    return state::build_data::publish_starter_sets(std::span(rows).first(count));
+}
+
+} // namespace sunrise::client::content::items::packages

+ 2 - 0
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -335,6 +335,8 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 
 /** The investment root holds the item index table at this slot. */
 inline constexpr std::size_t kItemTableSlot = 48;
+/** The investment root holds the new-character table, one row of items per class, here. */
+inline constexpr std::size_t kStarterSetTableSlot = 15;
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 inline constexpr std::size_t kPlugSetTableSlot = 51;
 /** The investment root holds the dense ordinary socket-type table at this slot. */

+ 3 - 3
Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp

@@ -42,9 +42,9 @@ constexpr std::size_t kCardFlagOffset = 8;
         static_cast<std::int8_t>(character.gender),
         static_cast<std::int8_t>(character.characterClass),
     };
-    std::memcpy(identity.headerBlock.data(),
-                layout::kHeaderBlockBytes.data(),
-                layout::kHeaderBlockBytes.size());
+    const auto& headerSource =
+        character.appearanceHeaderValid ? character.appearanceHeader : layout::kHeaderBlockBytes;
+    std::memcpy(identity.headerBlock.data(), headerSource.data(), headerSource.size());
 
     output = {};
     appearance::apply_sentinels(output);

+ 4 - 3
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -270,9 +270,10 @@ bool encode(const state::CharacterState& state,
     object.identity.race = static_cast<std::int8_t>(state.race);
     object.identity.gender = static_cast<std::int8_t>(state.gender);
     object.identity.characterClass = static_cast<std::int8_t>(state.characterClass);
-    std::memcpy(object.customisationHeader.data(),
-                character_record::layout::kHeaderBlockBytes.data(),
-                character_record::layout::kHeaderBlockBytes.size());
+    const auto& headerSource = state.appearanceHeaderValid
+                                   ? state.appearanceHeader
+                                   : character_record::layout::kHeaderBlockBytes;
+    std::memcpy(object.customisationHeader.data(), headerSource.data(), headerSource.size());
     object.lastOrbitedDestination = state.lastOrbitedDestination;
     // No previous activity is tracked, and a zero here would name the orbit activity.
     object.previousActivityIndex = kAbsentActivityIndex;

+ 2 - 4
Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp

@@ -11,8 +11,9 @@ constexpr std::uint8_t kCharacterSoidWidth = 64;
 
 } // namespace
 
-/** Writes the echoed header, the shared status pair, the character id, and the trailer. */
+/** Writes the echoed header, the caller's status pair, the character id, and the trailer. */
 bool encode_response(const Message& message,
+                     const StatusResponse& status,
                      std::uint64_t characterSoid,
                      std::span<std::byte> output,
                      std::size_t& written) noexcept {
@@ -21,9 +22,6 @@ bool encode_response(const Message& message,
         return false;
     }
     encoding::bits::Writer writer = begin_response(message, output);
-    // The reply names a character the roster already holds, so no revision publishes it.
-    StatusResponse status{};
-    status.value = kNoFamily4Publication;
     // The id carries no presence bit or bias.
     bool encoded = status::write_fields(writer, ResponseShape::statusPair, status)
                    && writer.write(characterSoid, kCharacterSoidWidth)

+ 4 - 1
Sunrise/src/middleware/web_service/messages/opcode501_codec.h

@@ -13,13 +13,16 @@ inline constexpr std::uint16_t kOpcode = 501;
 
 /**
  * Encodes the create-character response: the status pair then the character object id.
+ * The client reads a refusal from the status code, not from the id.
  * @param message Parsed request whose envelope fields are echoed.
- * @param characterSoid Character object id; must be published in family 3.
+ * @param status Refusal code, and the Family-4 revision that carries the new character.
+ * @param characterSoid The new character's id; zero on a refusal.
  * @param output Caller-owned svc-11 response-body storage.
  * @param written Receives encoded response-body bytes.
  * @return True when the fixed response fits the output buffer.
  */
 [[nodiscard]] bool encode_response(const Message& message,
+                                   const StatusResponse& status,
                                    std::uint64_t characterSoid,
                                    std::span<std::byte> output,
                                    std::size_t& written) noexcept;

+ 156 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp

@@ -0,0 +1,156 @@
+#include "opcode501_request_codec.h"
+
+#include <array>
+#include <cstring>
+#include <type_traits>
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+namespace {
+
+/** The identity triple and appearance header end here; the 64-byte name follows. */
+constexpr std::size_t kIdentityAndHeaderBits = 261;
+/** Each identity byte is preceded by a 1-bit presence flag. */
+constexpr std::uint8_t kPresenceBits = 1;
+/** Header fields travel as 8, 16 or 32 bits. */
+constexpr std::uint8_t kByteBits = 8;
+constexpr std::uint8_t kWordBits = 16;
+constexpr std::uint8_t kHashBits = 32;
+/** The request's last header field is 2 bits wide. */
+constexpr std::uint8_t kTrailingBits = 2;
+/** 8-bit fields travel biased by +0x80, 16-bit fields by +0x8000; 32-bit fields unbiased. */
+constexpr std::uint64_t kByteBias = 0x80U;
+constexpr std::uint64_t kWordBias = 0x8000U;
+/** Each of the header's two index runs holds 3 entries. */
+constexpr std::size_t kIndexRunCount = 3;
+/** The record requires 1 in the last field; the request always carries 0 there. */
+constexpr std::uint8_t kRecordTrailingValue = 1;
+/** Padding after the last field fills the record to its 4-byte alignment. */
+constexpr std::size_t kTailPadding = 3;
+
+/** The appearance header in record byte order. Field meanings are not decoded. */
+struct HeaderLayout {
+    std::uint16_t word00;
+    std::uint8_t byte02;
+    std::uint8_t byte03;
+    std::uint8_t byte04;
+    std::uint8_t padding05;
+    std::uint16_t word06;
+    std::uint16_t word08;
+    std::uint16_t word10;
+    std::array<std::uint16_t, kIndexRunCount> indices12;
+    std::array<std::uint16_t, kIndexRunCount> indices18;
+    std::uint16_t word24;
+    std::uint16_t padding26;
+    std::uint32_t hash28;
+    std::uint8_t trailing32;
+    std::array<std::uint8_t, kTailPadding> padding33;
+};
+static_assert(sizeof(HeaderLayout) == state::kAppearanceHeaderSize);
+static_assert(std::is_trivially_copyable_v<HeaderLayout>);
+
+/** Highest valid race, gender and class wire values, matching the state enums. */
+constexpr std::uint8_t kMaxRace = 2;
+constexpr std::uint8_t kMaxGender = 1;
+constexpr std::uint8_t kMaxClass = 2;
+
+/**
+ * Reads one presence flag and the 8-bit biased value behind it.
+ * @param reader Request bit reader.
+ * @param value Receives the unbiased value.
+ * @return False when the field is absent or short. Nothing else carries an absent value.
+ */
+[[nodiscard]] bool read_present_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+    std::uint64_t present = 0;
+    std::uint64_t raw = 0;
+    if (!reader.read(kPresenceBits, present) || present == 0 || !reader.read(kByteBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint8_t>(raw - kByteBias);
+    return true;
+}
+
+/** Reads one 8-bit +0x80-biased header field. @return False when the payload is short. */
+[[nodiscard]] bool read_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+    std::uint64_t raw = 0;
+    if (!reader.read(kByteBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint8_t>(raw - kByteBias);
+    return true;
+}
+
+/** Reads one 16-bit +0x8000-biased header field. @return False when the payload is short. */
+[[nodiscard]] bool read_word(encoding::bits::Reader& reader, std::uint16_t& value) noexcept {
+    std::uint64_t raw = 0;
+    if (!reader.read(kWordBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint16_t>(raw - kWordBias);
+    return true;
+}
+
+/**
+ * Reads the appearance header fields in wire order.
+ * @param reader Request bit reader, just past the identity triple.
+ * @param header Receives the fields; padding stays zero.
+ * @return False when the payload is short.
+ */
+[[nodiscard]] bool read_header(encoding::bits::Reader& reader, HeaderLayout& header) noexcept {
+    if (!read_word(reader, header.word00) || !read_byte(reader, header.byte02)
+        || !read_byte(reader, header.byte03) || !read_byte(reader, header.byte04)
+        || !read_word(reader, header.word06) || !read_word(reader, header.word08)
+        || !read_word(reader, header.word10)) {
+        return false;
+    }
+    for (std::uint16_t& index : header.indices12) {
+        if (!read_word(reader, index)) {
+            return false;
+        }
+    }
+    for (std::uint16_t& index : header.indices18) {
+        if (!read_word(reader, index)) {
+            return false;
+        }
+    }
+    std::uint64_t hash = 0;
+    std::uint64_t trailing = 0;
+    if (!read_word(reader, header.word24) || !reader.read(kHashBits, hash)
+        || !reader.read(kTrailingBits, trailing)) {
+        return false;
+    }
+    header.hash28 = static_cast<std::uint32_t>(hash);
+    // The request's own trailing value is read past but not kept.
+    header.trailing32 = kRecordTrailingValue;
+    return true;
+}
+
+} // namespace
+
+/**
+ * Parses the create-character request body: the identity triple, then the appearance header.
+ * The trailing 64-byte name is not read.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the creation fields, the header in record byte order.
+ * @return False when the payload is short, a presence flag is clear, or a value is out of range.
+ */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() * kByteBits < kIdentityAndHeaderBits) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    HeaderLayout header{};
+    if (!read_present_byte(reader, request.race) || !read_present_byte(reader, request.gender)
+        || !read_present_byte(reader, request.characterClass) || request.race > kMaxRace
+        || request.gender > kMaxGender || request.characterClass > kMaxClass
+        || !read_header(reader, header)) {
+        request = {};
+        return false;
+    }
+    std::memcpy(request.appearanceHeader.data(), &header, sizeof header);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 29 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../../state/account/account_state.h"
+#include "../web_service_envelope.h"
+#include "opcode501_codec.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+
+/** The create-character request: the identity triple plus the authored appearance header. */
+struct Request {
+    std::uint8_t characterClass{};
+    std::uint8_t gender{};
+    std::uint8_t race{};
+    /** Record byte order. Its values index per-race tables, so it only fits its own `race`. */
+    state::AppearanceHeader appearanceHeader{};
+};
+
+/**
+ * Parses the create-character request body, a big-endian bitstream with no byte alignment.
+ * Race, gender and class each carry a presence bit and a +0x80 bias; the header follows.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the creation fields, the header in record byte order.
+ * @return False when the payload is short, a presence flag is clear, or a value is out of range.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 30 - 0
Sunrise/src/middleware/web_service/messages/opcode502.cpp

@@ -0,0 +1,30 @@
+#include "opcode502.h"
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+namespace {
+
+/** The request is one unbiased 64-bit character id with no presence bit. */
+constexpr std::uint8_t kCharacterSoidWidth = 64;
+/** Those 64 bits need 8 payload bytes. */
+constexpr std::size_t kMinimumPayloadSize = 8;
+
+} // namespace
+
+/**
+ * Parses the delete-character request: one 64-bit character id.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the targeted character id.
+ * @return False when the opcode differs or the id is short.
+ */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() < kMinimumPayloadSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    return reader.read(kCharacterSoidWidth, request.characterSoid);
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 25 - 0
Sunrise/src/middleware/web_service/messages/opcode502.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+
+/** Web Service opcode for the delete-character request. */
+inline constexpr std::uint16_t kOpcode = 502;
+
+/** The targeted character. */
+struct Request {
+    std::uint64_t characterSoid{};
+};
+
+/**
+ * Parses the bare 64-bit targeted-character id.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the targeted character id.
+ * @return True when the whole id is present.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 19 - 0
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -18,6 +18,7 @@
 #include "../../../gameplay/group/group_host_sessions.h"
 #include "../../../web_service/opcode_routes.h"
 #include "../../../web_service/web_service_runtime.h"
+#include "../../runtime.h"
 #include "../activity_host_manager/activity_host_manager_route.h"
 #include "../activity_message/activity_message_route.h"
 #include "../internal.h"
@@ -234,6 +235,24 @@ bool process(const ServiceRoute& route,
             }
             sunrise::server::bap::arm_account_resync_everywhere();
         }
+        if (webOutcome.hasRosterChange) {
+            // Name the revision the account resync below publishes; it carries the new roster.
+            if (queuezState.family4Active
+                && queuezState.family4Version != (std::numeric_limits<std::int32_t>::max)()
+                && !sunrise::server::web_service::encode_roster_reply(
+                    message, queuezState.family4Version + 1, webOutcome, output, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=roster stage=response result=fail");
+                // Returning before the commit drops the change with this transaction.
+                return false;
+            }
+            sunrise::server::bap::arm_account_resync_everywhere();
+            if (webOutcome.createdCharacterSoid != 0) {
+                // One more refresh slice builds the ability rows of the new character's subclass.
+                sunrise::server::bap::request_investment_slice();
+            }
+        }
         outcome.hasSubscription = webOutcome.hasSubscription;
         outcome.hasRecordClaim = webOutcome.hasRecordClaim;
         outcome.hasArtifactReset = webOutcome.hasArtifactReset;

+ 7 - 2
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -246,7 +246,8 @@ bool append_account_resync_appearance_notification(
     const state::AccountState account = state::account_snapshot();
     const std::uint64_t selected = state::account::selected_character_soid(account);
     if (selected == 0) {
-        return false;
+        // Before a pick no appearance record is owed, so nothing is appended.
+        return true;
     }
     if (before.family0Character != selected) {
         return append_banner_move_notification(
@@ -288,6 +289,10 @@ bool append_account_resync_roster_notification(Scratch& scratch,
     }
     const state::AccountState account = state::account_snapshot();
     const std::uint64_t selected = state::account::selected_character_soid(account);
+    if (selected == 0) {
+        // Before a pick no character record is owed, so nothing is appended.
+        return true;
+    }
     std::size_t characterIndex = account.characterCount;
     for (std::size_t index = 0; index < account.characterCount; ++index) {
         if (account.characters[index].soid == selected) {
@@ -297,7 +302,7 @@ bool append_account_resync_roster_notification(Scratch& scratch,
     }
     queuez::RosterAppearanceRefresh refresh{};
     snapshot::Prepared prepared{};
-    if (selected == 0 || characterIndex >= account.characterCount
+    if (characterIndex >= account.characterCount
         || !queuez::stage_roster_appearance_refresh(before, selected, includeRoster, refresh)
         || !snapshot::prepare_roster_appearance_refresh(
             scratch, refresh, account.characters[characterIndex], characterIndex, prepared)

+ 6 - 4
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -534,7 +534,6 @@ selected_character(const state::AccountState& account) noexcept {
     auto nextSendNonce = session.sendNonce;
     std::size_t framedSize = 0;
     queuez::SessionState current = session.queuez;
-    bool wrote = false;
     if (current.family0Active) {
         queuez::SessionState appearanceAfter{};
         if (!push::append_account_resync_appearance_notification(scratch,
@@ -547,7 +546,6 @@ selected_character(const state::AccountState& account) noexcept {
             return false;
         }
         current = appearanceAfter;
-        wrote = true;
     }
     if (current.family3Active) {
         queuez::SessionState rosterAfter{};
@@ -563,9 +561,13 @@ selected_character(const state::AccountState& account) noexcept {
             return false;
         }
         current = rosterAfter;
-        wrote = true;
     }
-    if (!wrote || framedSize == 0 || framedSize > response.size() || !queuez::valid(current)) {
+    if (framedSize == 0 && queuez::valid(current)) {
+        // Both lanes appended nothing because nothing is picked, so no refresh is owed.
+        session.characterRefreshScope = CharacterRefreshScope::none;
+        return false;
+    }
+    if (framedSize == 0 || framedSize > response.size() || !queuez::valid(current)) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=ability_refresh result=fail");

+ 52 - 0
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -9,6 +9,8 @@
 #include "../../middleware/web_service/messages/opcode402.h"
 #include "../../middleware/web_service/messages/opcode403.h"
 #include "../../middleware/web_service/messages/opcode406.h"
+#include "../../middleware/web_service/messages/opcode501_request_codec.h"
+#include "../../middleware/web_service/messages/opcode502.h"
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode701/opcode701_codec.h"
 #include "../../middleware/web_service/messages/opcode801.h"
@@ -116,6 +118,56 @@ void select_character(const middleware::web_service::Message& message, Outcome&
     outcome.selectedCharacterSoid = picked.characterSoid;
 }
 
+/**
+ * Saves the character an opcode-501 request describes, with its starter set and subclasses.
+ * Both writes share one save transaction, so a refusal leaves no trace.
+ * @param message Parsed create-character request.
+ * @param outcome Gets the roster change and the new id once both writes commit.
+ */
+void create_character(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace opcode501 = middleware::web_service::messages::opcode501;
+    opcode501::Request request{};
+    if (!opcode501::parse_request(message, request)) {
+        core::log::write(
+            core::log::Channel::server, core::log::Level::warn, "ev=ws501 stage=parse result=fail");
+        return;
+    }
+    state::investment::store::Transaction transaction;
+    std::uint64_t characterSoid = 0;
+    if (!transaction.ready()
+        || !state::create_character(request.characterClass,
+                                    request.gender,
+                                    request.race,
+                                    request.appearanceHeader,
+                                    characterSoid)
+        || !state::ensure_character_subclasses() || !transaction.commit()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=ws501 stage=create result=fail");
+        return;
+    }
+    outcome.hasRosterChange = true;
+    outcome.createdCharacterSoid = characterSoid;
+}
+
+/**
+ * Removes the character an opcode-502 request names.
+ * @param message Parsed delete-character request.
+ * @param outcome Gets the roster change once the removal commits.
+ */
+void delete_character(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace opcode502 = middleware::web_service::messages::opcode502;
+    opcode502::Request request{};
+    if (!opcode502::parse_request(message, request)
+        || !state::delete_character(request.characterSoid)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=ws502 stage=delete result=fail");
+        return;
+    }
+    outcome.hasRosterChange = true;
+}
+
 /** Reads the shared opcode-403/404 SOID descriptor through its codec. */
 [[nodiscard]] bool parse_equipment_instance(const middleware::web_service::Message& message,
                                             std::uint64_t& instanceSoid) noexcept {

+ 4 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -8,6 +8,10 @@
 namespace sunrise::server::web_service {
 
 void select_character(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+/** Saves the character an opcode-501 request describes, with its starter set. */
+void create_character(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+/** Removes the character an opcode-502 request names. */
+void delete_character(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void mutate_equipment(const middleware::web_service::Message& message,
                       bool unequip,
                       Outcome& outcome) noexcept;

+ 50 - 8
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -14,6 +14,7 @@
 #include "../../middleware/web_service/messages/opcode206.h"
 #include "../../middleware/web_service/messages/opcode2400.h"
 #include "../../middleware/web_service/messages/opcode501_codec.h"
+#include "../../middleware/web_service/messages/opcode502.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode601/opcode601_codec.h"
@@ -279,6 +280,36 @@ bool encode_resident_dependent_refusal(std::span<const std::byte> request,
            || encode_echo(message, response, written);
 }
 
+/**
+ * Encodes the reply to an opcode-501 create or 502 delete, a refusal when nothing changed.
+ * @param message Parsed request; its opcode picks the reply shape.
+ * @param family4Version Revision carrying the change, or kNoFamily4Publication.
+ * @param outcome The roster change and created id, empty for a refusal.
+ * @param response Svc-11 response-body storage owned by the caller.
+ * @param written Gets the encoded response-body size.
+ * @return False when the reply does not fit.
+ */
+bool encode_roster_reply(const middleware::web_service::Message& message,
+                         std::int32_t family4Version,
+                         const Outcome& outcome,
+                         std::span<std::byte> response,
+                         std::size_t& written) noexcept {
+    namespace ws = middleware::web_service;
+    ws::StatusResponse status{};
+    status.value = family4Version;
+    if (!outcome.hasRosterChange) {
+        // A refusal stages nothing, so the version wait must not wait.
+        status.code = kRefusedStatus;
+        status.value = ws::kNoFamily4Publication;
+    }
+    if (message.opcode == ws::messages::opcode501::kOpcode) {
+        // The 501 shape always carries the id field, zero on a refusal.
+        return ws::messages::opcode501::encode_response(
+            message, status, outcome.createdCharacterSoid, response, written);
+    }
+    return ws::encode_response(message, ws::ResponseShape::statusPair, status, response, written);
+}
+
 /**
  * Parses one request, prepares any action it names, and encodes the reply that reports it.
  * @param request Whole decrypted svc-10 body.
@@ -286,7 +317,8 @@ bool encode_resident_dependent_refusal(std::span<const std::byte> request,
  * @param written Gets the encoded response-body size, or zero when the header does not parse.
  * @param outcome Gets the prepared action for the caller to publish, and is left empty when
  * the action was refused or the reply could not be encoded.
- * @return False only when the envelope header does not parse.
+ * @return False when the envelope header does not parse, or a saved roster change has no
+ *         reply; the caller's save transaction must then drop the change.
  */
 bool consume(std::span<const std::byte> request,
              std::span<std::byte> response,
@@ -336,13 +368,23 @@ bool consume(std::span<const std::byte> request,
         return true;
     }
 
-    if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) {
-        // Returns a SOID family three already publishes. The request body is not parsed.
-        const std::uint64_t characterSoid =
-            state::account::selected_character_soid(state::account_snapshot());
-        return middleware::web_service::messages::opcode501::encode_response(
-                   message, characterSoid, response, written)
-               || encode_echo(message, response, written);
+    if (message.opcode == middleware::web_service::messages::opcode501::kOpcode
+        || message.opcode == middleware::web_service::messages::opcode502::kOpcode) {
+        if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) {
+            create_character(message, outcome);
+        } else {
+            delete_character(message, outcome);
+        }
+        // The caller re-encodes a change with the revision that publishes it.
+        if (encode_roster_reply(message,
+                                middleware::web_service::kNoFamily4Publication,
+                                outcome,
+                                response,
+                                written)) {
+            return true;
+        }
+        // A saved change must not outlive a missing reply; false makes the caller drop it.
+        return !outcome.hasRosterChange && encode_echo(message, response, written);
     }
 
     // The artifact vendor is answered here. Every other vendor purchase falls through to the

+ 22 - 1
Sunrise/src/server/web_service/web_service_runtime.h

@@ -10,6 +10,7 @@
 #include <variant>
 
 #include "../../middleware/web_service/messages/opcode206.h"
+#include "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/inventory/seen_state.h"
 #include "../../state/runtime/runtime.h"
 
@@ -26,6 +27,10 @@ struct Outcome {
     /** An opcode-504 pick was recorded and its Family-4 update still has to follow. */
     bool hasSelectedCharacter{};
     std::uint64_t selectedCharacterSoid{};
+    /** An opcode-501 create or 502 delete saved a roster change; the account resync follows. */
+    bool hasRosterChange{};
+    /** The character an opcode-501 create added, which its reply names. */
+    std::uint64_t createdCharacterSoid{};
     /** Reset is precommitted because it changes persistence and account currency together. */
     bool hasArtifactReset{};
     state::ArtifactResetResult artifactReset{};
@@ -109,7 +114,8 @@ inline void clear_mutation(Outcome& outcome) noexcept {
  * @param written Gets the encoded response-body size, or zero when the header does not parse.
  * @param outcome Gets the prepared action for the caller to publish, and is left empty when
  * the action was refused or the reply could not be encoded.
- * @return False only when the envelope header does not parse.
+ * @return False when the envelope header does not parse, or a saved roster change has no
+ *         reply; the caller's save transaction must then drop the change.
  */
 [[nodiscard]] bool
 consume(std::span<const std::byte> request,
@@ -118,6 +124,21 @@ consume(std::span<const std::byte> request,
         Outcome& outcome,
         std::span<const state::account::inventory::PresentedItemRow> presentation = {}) noexcept;
 
+/**
+ * Encodes the reply to an opcode-501 create or 502 delete, a refusal when nothing changed.
+ * @param message Parsed request; its opcode picks the reply shape.
+ * @param family4Version Revision carrying the change, or kNoFamily4Publication.
+ * @param outcome The roster change and created id, empty for a refusal.
+ * @param response Svc-11 response-body storage owned by the caller.
+ * @param written Gets the encoded response-body size.
+ * @return False when the reply does not fit.
+ */
+[[nodiscard]] bool encode_roster_reply(const middleware::web_service::Message& message,
+                                       std::int32_t family4Version,
+                                       const Outcome& outcome,
+                                       std::span<std::byte> response,
+                                       std::size_t& written) noexcept;
+
 /** Encodes the normal refusal shape for a request that may publish resident references. */
 [[nodiscard]] bool encode_resident_dependent_refusal(std::span<const std::byte> request,
                                                      std::span<std::byte> response,

+ 9 - 0
Sunrise/src/state/account/account_state.h

@@ -11,6 +11,10 @@ namespace sunrise::state {
 
 /** One account can own at most the 3 playable character slots. */
 inline constexpr std::size_t kCharacterCapacity = 3;
+/** The character record's own appearance-header block size, record byte order. */
+inline constexpr std::size_t kAppearanceHeaderSize = 36;
+/** Opaque authored appearance header, ready to memcpy into either character encoder's block. */
+using AppearanceHeader = std::array<std::uint8_t, kAppearanceHeaderSize>;
 /** A server-authored dismantle policy: a few rows per rarity and gear class. */
 inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
 /** Native sentinel used when a character has no title equipped. */
@@ -169,6 +173,11 @@ struct CharacterState {
     account::inventory::CharacterStacks stacks;
     /** Next row generation; equip transactions consume two values for the two moved items. */
     std::uint32_t nextInventorySerial{};
+    /** Authored appearance header captured at creation. Meaningless without its own `race`. */
+    AppearanceHeader appearanceHeader{};
+    /** False for a character predating appearance capture; both encoders fall back to the
+     * fixed default header when this is unset. */
+    bool appearanceHeaderValid{};
 };
 
 /** Call-local account snapshot shared by backend object families. */

+ 21 - 0
Sunrise/src/state/build_data/runtime.h

@@ -30,6 +30,7 @@
 #include "socket_entry_buckets/definition.h"
 #include "socket_entry_lists/definition.h"
 #include "spawn_sets/definition.h"
+#include "starter_sets/definition.h"
 #include "vendors/definition.h"
 
 namespace sunrise::state::build_data::items::catalysts {
@@ -581,6 +582,26 @@ publish_investment_constants(const constants::InvestmentConstants& value) noexce
  */
 [[nodiscard]] bool find_investment_constants(constants::InvestmentConstants& value) noexcept;
 
+/** @return True once the new-character table, empty or not, is in State. */
+[[nodiscard]] bool starter_sets_ready() noexcept;
+
+/**
+ * Publishes the new-character table. It is read every boot and never joins the disk cache.
+ * @param definitions One row per class; empty when the installed table did not read.
+ * @return True when every row names a distinct playable class and fits its storage.
+ */
+[[nodiscard]] bool
+publish_starter_sets(std::span<const starter_sets::Definition> definitions) noexcept;
+
+/**
+ * Finds the items the packages give a new character of one class.
+ * @param characterClass Class byte: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param definition Receives the class row.
+ * @return True when the published table holds that class.
+ */
+[[nodiscard]] bool find_starter_set(std::uint8_t characterClass,
+                                    starter_sets::Definition& definition) noexcept;
+
 /** @return True when the whole inventory-bucket descriptor table is in State. */
 [[nodiscard]] bool inventory_bucket_descriptors_ready() noexcept;
 

+ 2 - 0
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -22,6 +22,7 @@
 #include "../socket_entry_buckets/socket_entry_bucket_catalog.h"
 #include "../socket_entry_lists/socket_entry_list_catalog.h"
 #include "../spawn_sets/spawn_set_catalog.h"
+#include "../starter_sets/starter_set_catalog.h"
 #include "../vendors/vendor_catalog.h"
 #include "domain_markers.h"
 #include "persistence/publication_transaction.h"
@@ -383,6 +384,7 @@ void clear_catalogs() noexcept {
     rollback_name_catalog_publication();
     vendors::clear();
     constants::clear();
+    starter_sets::clear();
 }
 
 } // namespace runtime

+ 22 - 0
Sunrise/src/state/build_data/starter_sets/definition.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::build_data::starter_sets {
+
+/** The installed table holds one row for each of the 3 playable classes. */
+inline constexpr std::size_t kClassCount = 3;
+/** Items one class row lists over its two lists. The installed rows list 15. */
+inline constexpr std::size_t kItemCapacity = 24;
+
+/** The items the packages give a new character of one class, in authored order. */
+struct Definition {
+    /** Class byte the row is keyed by: 0 Titan, 1 Hunter, 2 Warlock. */
+    std::uint8_t characterClass{};
+    std::uint8_t itemCount{};
+    std::array<std::uint16_t, kItemCapacity> itemIndices{};
+};
+
+} // namespace sunrise::state::build_data::starter_sets

+ 21 - 0
Sunrise/src/state/build_data/starter_sets/starter_set_build_data_runtime.cpp

@@ -0,0 +1,21 @@
+#include "../runtime.h"
+#include "starter_set_catalog.h"
+
+namespace sunrise::state::build_data {
+
+/** @return True once the new-character table, empty or not, is in State. */
+bool starter_sets_ready() noexcept {
+    return starter_sets::ready();
+}
+
+/** Publishes the new-character table outside the disk cache, so a warm boot reads it again. */
+bool publish_starter_sets(std::span<const starter_sets::Definition> definitions) noexcept {
+    return starter_sets::replace(definitions);
+}
+
+/** Finds the items the packages give a new character of one class. */
+bool find_starter_set(std::uint8_t characterClass, starter_sets::Definition& definition) noexcept {
+    return starter_sets::find(characterClass, definition);
+}
+
+} // namespace sunrise::state::build_data

+ 67 - 0
Sunrise/src/state/build_data/starter_sets/starter_set_catalog.cpp

@@ -0,0 +1,67 @@
+#include "starter_set_catalog.h"
+
+#include <mutex>
+#include <shared_mutex>
+
+#include "core/threading/srw_lock.h"
+
+namespace sunrise::state::build_data::starter_sets {
+namespace {
+
+core::threading::SrwLock g_lock;
+/** Indexed by class byte. */
+std::array<Definition, kClassCount> g_rows{};
+std::array<bool, kClassCount> g_present{};
+bool g_published{};
+
+} // namespace
+
+/** Clears the published rows under the catalog lock. */
+void clear() noexcept {
+    const std::lock_guard guard(g_lock);
+    g_rows = {};
+    g_present = {};
+    g_published = false;
+}
+
+/**
+ * Publishes the whole table in one step. An empty table is published too.
+ * @param definitions One row per class, each class at most once.
+ * @return False when a row names no playable class, repeats one, or overflows its items.
+ */
+bool replace(std::span<const Definition> definitions) noexcept {
+    std::array<Definition, kClassCount> rows{};
+    std::array<bool, kClassCount> present{};
+    for (const Definition& definition : definitions) {
+        if (definition.characterClass >= kClassCount || present[definition.characterClass]
+            || definition.itemCount > kItemCapacity) {
+            return false;
+        }
+        rows[definition.characterClass] = definition;
+        present[definition.characterClass] = true;
+    }
+    const std::lock_guard guard(g_lock);
+    g_rows = rows;
+    g_present = present;
+    g_published = true;
+    return true;
+}
+
+/** @return True once a table, empty or not, is published. */
+bool ready() noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_published;
+}
+
+/** Finds the row of one class under the catalog lock. */
+bool find(std::uint8_t characterClass, Definition& definition) noexcept {
+    definition = {};
+    const std::shared_lock guard(g_lock);
+    if (characterClass >= kClassCount || !g_present[characterClass]) {
+        return false;
+    }
+    definition = g_rows[characterClass];
+    return true;
+}
+
+} // namespace sunrise::state::build_data::starter_sets

+ 31 - 0
Sunrise/src/state/build_data/starter_sets/starter_set_catalog.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::starter_sets {
+
+/** Clears the published rows and marks the table unpublished. */
+void clear() noexcept;
+
+/**
+ * Publishes the whole table in one step. An empty table is published too.
+ * @param definitions One row per class, each class at most once.
+ * @return False when a row names no playable class, repeats one, or overflows its items.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/** @return True once a table, empty or not, is published. */
+[[nodiscard]] bool ready() noexcept;
+
+/**
+ * Finds the row of one class.
+ * @param characterClass Class byte: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param definition Receives the row.
+ * @return True when the published table holds that class.
+ */
+[[nodiscard]] bool find(std::uint8_t characterClass, Definition& definition) noexcept;
+
+} // namespace sunrise::state::build_data::starter_sets

+ 81 - 3
Sunrise/src/state/investment/investment_account_store.cpp

@@ -22,7 +22,9 @@ bool read_characters(AccountState& output) noexcept {
                           character.contentBypass,
                           character.equippedTitleRecordIndex,
                           character.acquiredSubclassAbilityMask,
-                          character.nextInventorySerial)
+                          character.nextInventorySerial,
+                          character.appearanceHeader,
+                          character.appearanceHeaderValid)
             || slot != output.characterCount || slot >= output.characters.size()) {
             return false;
         }
@@ -60,7 +62,7 @@ bool read_rewards(AccountState& output) noexcept {
 
 /** Characters are saved before their items to satisfy foreign keys. */
 bool write_characters(const AccountState& value) noexcept {
-    Statement rows("INSERT INTO characters VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
+    Statement rows("INSERT INTO characters VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
     for (std::size_t slot = 0; slot < value.characterCount; ++slot) {
         const auto& character = value.characters[slot];
         if (!rows.write(slot,
@@ -75,7 +77,9 @@ bool write_characters(const AccountState& value) noexcept {
                         character.contentBypass,
                         character.equippedTitleRecordIndex,
                         character.acquiredSubclassAbilityMask,
-                        character.nextInventorySerial)) {
+                        character.nextInventorySerial,
+                        character.appearanceHeader,
+                        character.appearanceHeaderValid)) {
             return false;
         }
     }
@@ -153,6 +157,80 @@ bool write_account(const AccountState& value) noexcept {
     return true;
 }
 
+/**
+ * Fills a created character's level, card, destination and content fields from the save.
+ * @param character Character whose other fields stay unchanged.
+ * @return False when the one starter row does not read; the character is then unchanged.
+ */
+bool read_starter_character(CharacterState& character) noexcept {
+    const std::lock_guard lock(g_mutex);
+    Statement row("SELECT level,preview_available,appearance_value,last_orbited_destination,"
+                  "content_bypass FROM starter_character WHERE id=1");
+    CharacterState staged = character;
+    if (row.step() != SQLITE_ROW
+        || !row.columns(staged.level,
+                        staged.previewAvailable,
+                        staged.appearanceValue,
+                        staged.lastOrbitedDestination,
+                        staged.contentBypass)) {
+        return false;
+    }
+    character = staged;
+    return true;
+}
+
+/**
+ * Reads Sunrise's own starter rows for one class, in position order.
+ * @param characterClass Class byte: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param output Receives the definition hashes.
+ * @param count Receives the row count, zero on failure.
+ * @return False when the rows do not read or do not fit output.
+ */
+bool read_starter_items(std::uint8_t characterClass,
+                        std::span<std::uint32_t> output,
+                        std::size_t& count) noexcept {
+    const std::lock_guard lock(g_mutex);
+    count = 0;
+    Statement rows("SELECT definition_hash FROM starter_items WHERE class=? ORDER BY position");
+    if (!rows.parameters(characterClass)) {
+        return false;
+    }
+    int result = rows.step();
+    while (result == SQLITE_ROW) {
+        if (count >= output.size() || !rows.column(0, output[count])) {
+            count = 0;
+            return false;
+        }
+        ++count;
+        result = rows.step();
+    }
+    return result == SQLITE_DONE;
+}
+
+/**
+ * Drops one character's slot-keyed unlock and reward rows and moves later slots down one.
+ * @param slot Character slot the removed character held.
+ * @return True when every row moved in one transaction.
+ */
+bool remove_character_rows(std::size_t slot) noexcept {
+    Transaction transaction;
+    Statement unlocks("DELETE FROM unlocks WHERE character_slot=?");
+    Statement rewards("DELETE FROM pending_rewards WHERE character_slot=?");
+    if (!transaction.ready() || slot >= kCharacterCapacity || !unlocks.write(slot)
+        || !rewards.write(slot)) {
+        return false;
+    }
+    Statement moveUnlocks("UPDATE unlocks SET character_slot=? WHERE character_slot=?");
+    Statement moveRewards("UPDATE pending_rewards SET character_slot=? WHERE character_slot=?");
+    // Ascending, so every target slot is already empty and no key collides.
+    for (std::size_t from = slot + 1; from < kCharacterCapacity; ++from) {
+        if (!moveUnlocks.write(from - 1, from) || !moveRewards.write(from - 1, from)) {
+            return false;
+        }
+    }
+    return transaction.commit();
+}
+
 /** A sign-in timestamp belongs only to the current connection lifetime. */
 void set_sign_in_time(std::uint64_t seconds) noexcept {
     const std::lock_guard lock(g_mutex);

+ 37 - 6
Sunrise/src/state/investment/investment_database.cpp

@@ -79,12 +79,23 @@ bool Statement::text(int column, std::string_view& value) const noexcept {
     return true;
 }
 
-/** New databases receive schema and defaults in one durable transaction. */
+/**
+ * Opens the save, creating it from the bundled SQL or upgrading an older schema in place.
+ * A new database gets the schema and every default in one durable transaction.
+ * @param path Save file name, or `:memory:`.
+ * @param schema Investment schema SQL.
+ * @param defaults Investment default rows SQL.
+ * @param settingsSchema Account preference schema SQL.
+ * @param settingsDefaults Account preference default rows SQL.
+ * @param characterStarter Created-character starting tables and rows SQL.
+ * @return False when the save does not open, is not ours, or does not upgrade.
+ */
 bool open(std::string_view path,
           std::string_view schema,
           std::string_view defaults,
           std::string_view settingsSchema,
-          std::string_view settingsDefaults) noexcept {
+          std::string_view settingsDefaults,
+          std::string_view characterStarter) noexcept {
     const std::lock_guard lock(g_mutex);
     if (g_database != nullptr) {
         return false;
@@ -109,6 +120,7 @@ bool open(std::string_view path,
     }
     const std::string preferenceSchema(settingsSchema);
     const std::string preferenceDefaults(settingsDefaults);
+    const std::string starterText(characterStarter);
     if (ready && version == 0) {
         Transaction transaction;
         const std::string schemaText(schema);
@@ -117,15 +129,18 @@ bool open(std::string_view path,
                 && execute(schemaText.c_str()) && execute(defaultText.c_str())
                 && !settingsSchema.empty() && !settingsDefaults.empty()
                 && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str())
+                && !characterStarter.empty() && execute(starterText.c_str())
                 && transaction.commit();
     } else if (ready) {
-        // Version 2 adds account preferences and per-item seen state.
-        constexpr int kSchemaVersion = 2;
+        // Version 3 adds the appearance header and the starter tables. Version 2 added account
+        // preferences and per-item seen state.
+        constexpr int kSchemaVersion = 3;
         constexpr int kApplicationId = 1397902921;
         int application = 0;
         Statement query("PRAGMA application_id");
-        ready = (version == 1 || version == kSchemaVersion) && query.step() == SQLITE_ROW
-                && query.column(0, application) && application == kApplicationId;
+        ready = (version == 1 || version == 2 || version == kSchemaVersion)
+                && query.step() == SQLITE_ROW && query.column(0, application)
+                && application == kApplicationId;
     }
     if (ready && version == 1) {
         Transaction transaction;
@@ -137,6 +152,22 @@ bool open(std::string_view path,
                 "IN(0,1));")
             && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str())
             && execute("PRAGMA user_version=2") && transaction.commit();
+        if (ready) {
+            version = 2;
+        }
+    }
+    if (ready && version == 2) {
+        Transaction transaction;
+        // Older characters keep the fixed default header: their zero blob is marked invalid.
+        // ADD COLUMN refuses a function default such as zeroblob(), so the blob is a literal.
+        ready = transaction.ready() && !characterStarter.empty()
+                && execute("ALTER TABLE characters ADD COLUMN appearance_header BLOB NOT NULL "
+                           "DEFAULT (X'000000000000000000000000000000000000000000000000000000000000"
+                           "000000000000') CHECK (length(appearance_header) = 36);"
+                           "ALTER TABLE characters ADD COLUMN appearance_header_valid INTEGER "
+                           "NOT NULL DEFAULT 0 CHECK (appearance_header_valid IN (0, 1));")
+                && execute(starterText.c_str()) && execute("PRAGMA user_version=3")
+                && transaction.commit();
     }
     if (!ready) {
         shutdown();

+ 3 - 1
Sunrise/src/state/investment/investment_store_boot.cpp

@@ -68,11 +68,13 @@ bool initialize(void* module) noexcept {
     std::string_view defaults;
     std::string_view settingsSchema;
     std::string_view settingsDefaults;
+    std::string_view characterStarter;
     if (!resource(module, IDR_INVESTMENT_SCHEMA, schema)
         || !resource(module, IDR_INVESTMENT_DEFAULTS, defaults)
         || !resource(module, IDR_ACCOUNT_SETTINGS_SCHEMA, settingsSchema)
         || !resource(module, IDR_ACCOUNT_SETTINGS_DEFAULTS, settingsDefaults)
-        || !open(filename, schema, defaults, settingsSchema, settingsDefaults)) {
+        || !resource(module, IDR_CHARACTER_STARTER, characterStarter)
+        || !open(filename, schema, defaults, settingsSchema, settingsDefaults, characterStarter)) {
         return false;
     }
     return true;

+ 30 - 1
Sunrise/src/state/investment/store.h

@@ -1,5 +1,8 @@
 #pragma once
 
+#include <cstddef>
+#include <cstdint>
+#include <span>
 #include <string>
 #include <string_view>
 
@@ -28,7 +31,8 @@ enum class Bank : int {
                         std::string_view schema,
                         std::string_view defaults,
                         std::string_view settingsSchema,
-                        std::string_view settingsDefaults) noexcept;
+                        std::string_view settingsDefaults,
+                        std::string_view characterStarter) noexcept;
 void shutdown() noexcept;
 /**
  * Moves the session to an in-memory copy of the open save and closes the file, so no later
@@ -52,6 +56,31 @@ void set_sign_in_time(std::uint64_t seconds) noexcept;
 [[nodiscard]] bool bootstrap_completed(std::string_view name) noexcept;
 [[nodiscard]] bool complete_bootstrap(std::string_view name) noexcept;
 
+/**
+ * Fills a created character's level, card, destination and content fields from the save.
+ * @param character Character whose other fields stay unchanged.
+ * @return False when the one starter row does not read.
+ */
+[[nodiscard]] bool read_starter_character(CharacterState& character) noexcept;
+
+/**
+ * Reads Sunrise's own starter rows for one class, in position order.
+ * @param characterClass Class byte: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param output Receives the definition hashes.
+ * @param count Receives the row count.
+ * @return False when the rows do not read or do not fit output.
+ */
+[[nodiscard]] bool read_starter_items(std::uint8_t characterClass,
+                                      std::span<std::uint32_t> output,
+                                      std::size_t& count) noexcept;
+
+/**
+ * Drops one character's slot-keyed unlock and reward rows and moves later slots down one.
+ * @param slot Character slot the removed character held.
+ * @return True when every row moved in one transaction.
+ */
+[[nodiscard]] bool remove_character_rows(std::size_t slot) noexcept;
+
 /** An earned reward stays in the database until its inventory grant commits. */
 struct PendingReward {
     std::uint64_t id{};

+ 21 - 3
Sunrise/src/state/investment/store_internal.h

@@ -1,7 +1,9 @@
 #pragma once
 
+#include <array>
 #include <bit>
 #include <cmath>
+#include <cstring>
 #include <limits>
 #include <mutex>
 #include <sqlite3.h>
@@ -12,6 +14,11 @@
 
 namespace sunrise::state::investment::store {
 
+/** True for a fixed-size byte array, the only shape this store binds as a BLOB. */
+template <typename T> inline constexpr bool kIsByteArray = false;
+template <std::size_t N>
+inline constexpr bool kIsByteArray<std::array<std::uint8_t, N>> = true;
+
 extern sqlite3* g_database;
 extern std::recursive_mutex g_mutex;
 extern std::uint64_t g_failureSerial;
@@ -63,7 +70,14 @@ public:
         if (statement_ == nullptr) {
             return false;
         }
-        if constexpr (std::is_enum_v<T>) {
+        if constexpr (kIsByteArray<T>) {
+            if (sqlite3_column_type(statement_, index) != SQLITE_BLOB
+                || static_cast<std::size_t>(sqlite3_column_bytes(statement_, index))
+                       != value.size()) {
+                return false;
+            }
+            std::memcpy(value.data(), sqlite3_column_blob(statement_, index), value.size());
+        } else if constexpr (std::is_enum_v<T>) {
             std::underlying_type_t<T> raw{};
             if (!column(index, raw)) {
                 return false;
@@ -115,8 +129,12 @@ public:
 
 private:
     /** SQL integers preserve all 64 identity bits; text bindings copy their input. */
-    template <typename T> [[nodiscard]] bool bind(int index, T value) noexcept {
-        if constexpr (std::is_convertible_v<T, std::string_view>) {
+    template <typename T> [[nodiscard]] bool bind(int index, const T& value) noexcept {
+        if constexpr (kIsByteArray<T>) {
+            return sqlite3_bind_blob64(
+                       statement_, index, value.data(), value.size(), copy_text())
+                   == SQLITE_OK;
+        } else if constexpr (std::is_convertible_v<T, std::string_view>) {
             const std::string_view textValue(value);
             return sqlite3_bind_text64(statement_,
                                        index,

+ 22 - 0
Sunrise/src/state/runtime/runtime.h

@@ -482,6 +482,28 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept;
  */
 [[nodiscard]] bool set_selected_character(std::uint64_t characterSoid) noexcept;
 
+/**
+ * Adds an unselected character to a free slot with its starter set and saves the account.
+ * @param characterClass Character class: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param gender Character gender: 0 male, 1 female.
+ * @param race Character race: 0 human, 1 awoken, 2 exo.
+ * @param appearanceHeader Appearance header from the request, in record byte order.
+ * @param characterSoid Receives the new character's id, zero on failure.
+ * @return False when nothing was saved.
+ */
+[[nodiscard]] bool create_character(std::uint8_t characterClass,
+                                    std::uint8_t gender,
+                                    std::uint8_t race,
+                                    const AppearanceHeader& appearanceHeader,
+                                    std::uint64_t& characterSoid) noexcept;
+
+/**
+ * Removes one character with its items and slot-keyed rows and saves the account.
+ * @param characterSoid Id of the character to remove.
+ * @return False when nothing was saved.
+ */
+[[nodiscard]] bool delete_character(std::uint64_t characterSoid) noexcept;
+
 /**
  * Stores the selected character's equipped title row. The caller proves the record is a claimed
  * title; this only writes it.

+ 267 - 0
Sunrise/src/state/runtime/state_account_roster_runtime.cpp

@@ -0,0 +1,267 @@
+/** Character creation and deletion. */
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <mutex>
+#include <optional>
+
+#include "../../core/logging/log.h"
+#include "../account/account_state.h"
+#include "../account/inventory/inventory_state.h"
+#include "../build_data/runtime.h"
+#include "../investment/store_internal.h"
+#include "runtime.h"
+#include "state_account_transaction_helpers.h"
+
+namespace sunrise::state {
+namespace {
+
+namespace authored_inventory = account::inventory;
+
+/** Sunrise's own starter rows one class may list. The bundled table lists one. */
+constexpr std::size_t kAuthoredStarterCapacity = 16;
+/** A powered item needs a nonzero level. Level 1 already scores the Power floor. */
+constexpr std::int32_t kMinimumPoweredLevel = 1;
+
+/** @return True for a weapon or armour slot; these lead the slot order and alone carry Power. */
+[[nodiscard]] constexpr bool powered_slot(std::size_t slot) noexcept {
+    return slot <= static_cast<std::size_t>(authored_inventory::EquipmentSlot::classItem);
+}
+
+/**
+ * Picks the level a new character's weapons and armour get.
+ * It is the strongest level any character already holds, so a new character keeps pace.
+ * @param account Account before the new character joins.
+ * @return That level, and never less than the powered minimum.
+ */
+[[nodiscard]] std::int32_t starter_level(const AccountState& account) noexcept {
+    std::int32_t level = kMinimumPoweredLevel;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        level = (std::max)(level, runtime::detail::acquisition_level(account.characters[index]));
+    }
+    return level;
+}
+
+/**
+ * Picks the lowest character id after the account's own that no character holds.
+ * Survivors keep their ids across a delete, so a slot index can name a living character.
+ * @param account Account the new character joins.
+ * @return The free id, or zero when every id is taken.
+ */
+[[nodiscard]] std::uint64_t free_character_soid(const AccountState& account) noexcept {
+    const auto begin = account.characters.begin();
+    const auto end = begin + static_cast<std::ptrdiff_t>(account.characterCount);
+    for (std::uint64_t offset = 1; offset <= account.characters.size(); ++offset) {
+        const std::uint64_t candidate = account.primarySoid + offset;
+        const bool taken = std::any_of(begin, end, [candidate](const CharacterState& character) {
+            return character.soid == candidate;
+        });
+        if (!taken) {
+            return candidate;
+        }
+    }
+    return 0;
+}
+
+/**
+ * Resolves the semantic equipment slot one item takes.
+ * @param definitionHash Item definition hash.
+ * @param slot Receives the semantic slot.
+ * @return False when the item is not installed or takes no supported slot.
+ */
+[[nodiscard]] bool starter_slot(std::uint32_t definitionHash, std::size_t& slot) noexcept {
+    build_data::items::Definition definition{};
+    build_data::items::details::Definition detail{};
+    std::uint8_t nativeSlot = 0;
+    return build_data::find_item_definition_hash(definitionHash, definition)
+           && build_data::find_configured_item_detail(definition.definitionIndex, detail)
+           && authored_inventory::resolve_native_equipment_slot(
+               definitionHash, detail.equipmentSlot, nativeSlot)
+           && runtime::detail::semantic_equipment_slot(nativeSlot, slot);
+}
+
+/**
+ * Gives one starter item to a new character: equipped when its slot is free, else unequipped.
+ * @param account Account that already counts the character, so a new item id cannot collide.
+ * @param character The new character inside account.
+ * @param definitionHash Item to give.
+ * @param level Level a weapon or armour piece gets. Every other item gets none.
+ * @return False when the item takes no slot, no item id is free, or the inventory is full.
+ */
+[[nodiscard]] bool give_starter_item(AccountState& account,
+                                     CharacterState& character,
+                                     std::uint32_t definitionHash,
+                                     std::int32_t level) noexcept {
+    std::size_t slot = 0;
+    authored_inventory::Item item{};
+    if (!starter_slot(definitionHash, slot)
+        || !runtime::detail::next_item_instance_soid(account, item.instanceSoid)) {
+        return false;
+    }
+    item.definitionHash = definitionHash;
+    item.level = powered_slot(slot) ? level : 0;
+    item.quantity = 1;
+    item.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
+    std::optional<authored_inventory::Item>& equipped = character.equipment.slots[slot];
+    if (!equipped.has_value()) {
+        equipped = item;
+        return true;
+    }
+    if (character.inventory.count >= character.inventory.values.size()) {
+        return false;
+    }
+    character.inventory.values[character.inventory.count++] = item;
+    return true;
+}
+
+/**
+ * Gives a new character Sunrise's own starter rows, then the packages' new-character set.
+ * The first item for a slot is equipped. A later item for the same slot stays unequipped.
+ * @param account Account that already counts the character.
+ * @param character The new character inside account.
+ * @param level Level for weapons and armour.
+ * @return False when either source does not read for the class, or one item cannot be given.
+ */
+[[nodiscard]] bool
+seed_starter_set(AccountState& account, CharacterState& character, std::int32_t level) noexcept {
+    const auto characterClass = static_cast<std::uint8_t>(character.characterClass);
+    std::array<std::uint32_t, kAuthoredStarterCapacity> authored{};
+    std::size_t authoredCount = 0;
+    build_data::starter_sets::Definition packaged{};
+    if (!investment::store::read_starter_items(characterClass, authored, authoredCount)
+        || !build_data::find_starter_set(characterClass, packaged)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < authoredCount; ++index) {
+        if (!give_starter_item(account, character, authored[index], level)) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < packaged.itemCount; ++index) {
+        build_data::items::Definition definition{};
+        if (!build_data::find_item_definition_index(packaged.itemIndices[index], definition)
+            || !give_starter_item(account, character, definition.definitionHash, level)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Logs one refused roster change with the stage that refused it. */
+void report_refusal(const char* event, const char* stage) noexcept {
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::warn,
+                      "ev=%s stage=%s result=fail",
+                      event,
+                      stage);
+}
+
+} // namespace
+
+/**
+ * Adds a character to a free slot with its starter set and saves the account.
+ * The character joins unselected. Only an opcode-504 or 505 pick selects a character.
+ * @param characterClass Character class: 0 Titan, 1 Hunter, 2 Warlock.
+ * @param gender Character gender: 0 male, 1 female.
+ * @param race Character race: 0 human, 1 awoken, 2 exo.
+ * @param appearanceHeader Appearance header from the request, in record byte order.
+ * @param characterSoid Receives the new character's id, zero on failure.
+ * @return False when a value is out of range, the roster is full, the starter set does not
+ *         resolve, or the save refuses the account.
+ */
+bool create_character(std::uint8_t characterClass,
+                      std::uint8_t gender,
+                      std::uint8_t race,
+                      const AppearanceHeader& appearanceHeader,
+                      std::uint64_t& characterSoid) noexcept {
+    characterSoid = 0;
+    if (characterClass > static_cast<std::uint8_t>(CharacterClass::warlock)
+        || gender > static_cast<std::uint8_t>(CharacterGender::female)
+        || race > static_cast<std::uint8_t>(CharacterRace::exo)) {
+        report_refusal("create_character", "range");
+        return false;
+    }
+    const std::lock_guard lock(investment::store::g_mutex);
+    AccountState candidate = investment::store::account();
+    const std::uint64_t soid =
+        candidate.primarySoid == 0 || candidate.characterCount >= candidate.characters.size()
+            ? 0
+            : free_character_soid(candidate);
+    if (soid == 0) {
+        report_refusal("create_character", "slot");
+        return false;
+    }
+    const std::int32_t level = starter_level(candidate);
+    CharacterState& character = candidate.characters[candidate.characterCount];
+    character = {};
+    character.soid = soid;
+    character.race = static_cast<CharacterRace>(race);
+    character.gender = static_cast<CharacterGender>(gender);
+    character.characterClass = static_cast<CharacterClass>(characterClass);
+    character.appearanceHeader = appearanceHeader;
+    character.appearanceHeaderValid = true;
+    if (!investment::store::read_starter_character(character)) {
+        report_refusal("create_character", "starter_character");
+        return false;
+    }
+    // Counted before seeding, so each new item id sees the items given before it.
+    ++candidate.characterCount;
+    if (!seed_starter_set(candidate, character, level)) {
+        report_refusal("create_character", "starter_set");
+        return false;
+    }
+    if (!investment::store::write_account(candidate)) {
+        report_refusal("create_character", "commit");
+        return false;
+    }
+    // The new class's subclasses may have no ability row yet; the next refresh rebuilds them.
+    build_data::invalidate_ability_buckets();
+    characterSoid = soid;
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::info,
+                      "ev=create_character result=ok soid=0x%016llX chars=%zu",
+                      static_cast<unsigned long long>(soid),
+                      candidate.characterCount);
+    return true;
+}
+
+/**
+ * Removes one character, its items and its slot-keyed rows, and saves the account.
+ * Survivors keep their ids and move down one slot. The selection never moves.
+ * @param characterSoid Id of the character to remove.
+ * @return False when no character has that id or the save refuses the change.
+ */
+bool delete_character(std::uint64_t characterSoid) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
+    AccountState candidate = investment::store::account();
+    const auto begin = candidate.characters.begin();
+    const auto end = begin + static_cast<std::ptrdiff_t>(candidate.characterCount);
+    const auto found = std::find_if(begin, end, [characterSoid](const CharacterState& character) {
+        return character.soid == characterSoid;
+    });
+    if (characterSoid == 0 || found == end) {
+        report_refusal("delete_character", "find");
+        return false;
+    }
+    const auto slot = static_cast<std::size_t>(found - begin);
+    std::move(found + 1, end, found);
+    --candidate.characterCount;
+    candidate.characters[candidate.characterCount] = {};
+    // Both writes share one save transaction, so a refused one leaves neither.
+    investment::store::Transaction transaction;
+    if (!transaction.ready() || !investment::store::write_account(candidate)
+        || !investment::store::remove_character_rows(slot) || !transaction.commit()) {
+        report_refusal("delete_character", "commit");
+        return false;
+    }
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::info,
+                      "ev=delete_character result=ok soid=0x%016llX chars=%zu",
+                      static_cast<unsigned long long>(characterSoid),
+                      candidate.characterCount);
+    return true;
+}
+
+} // namespace sunrise::state