Просмотр исходного кода

Seed created characters from the package new-character table and a starter table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
stan 4 дней назад
Родитель
Сommit
ea48909591

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -769,6 +769,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" />
@@ -1162,6 +1164,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" />
@@ -2094,6 +2097,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" />
@@ -2327,6 +2332,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 - 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

@@ -260,6 +260,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

@@ -39,7 +39,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
@@ -219,6 +220,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

@@ -322,6 +322,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. */

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

@@ -29,6 +29,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 {
@@ -555,6 +556,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

@@ -21,6 +21,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"
@@ -381,6 +382,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

+ 74 - 0
Sunrise/src/state/investment/investment_account_store.cpp

@@ -157,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);

+ 26 - 16
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,9 +129,10 @@ 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 3 adds the per-character appearance header. Version 2 added account
+        // 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;
@@ -145,19 +158,16 @@ bool open(std::string_view path,
     }
     if (ready && version == 2) {
         Transaction transaction;
-        // A character predating this column has no captured header; the zero blob is never
-        // read, since appearance_header_valid stays 0 and both encoders fall back to the fixed
-        // default header for it.
-        ready = transaction.ready()
-                // SQLite's ADD COLUMN refuses a non-constant default (zeroblob(36) included),
-                // so the fill value has to be a literal blob, not a function call.
-                && 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("PRAGMA user_version=3") && transaction.commit();
+        // 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{};

+ 10 - 13
Sunrise/src/state/runtime/runtime.h

@@ -474,15 +474,13 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept;
 [[nodiscard]] bool set_selected_character(std::uint64_t characterSoid) noexcept;
 
 /**
- * Creates a new character in an unused slot and persists 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 Authored appearance header captured from the creation request, in
- *                        record byte order. Only meaningful beside the `race` it arrived with.
- * @param characterSoid Receives the SOID of the newly created character.
- * @return True when the character was created, the account still validates, and the write
- *         committed.
+ * 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,
@@ -491,10 +489,9 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept;
                                     std::uint64_t& characterSoid) noexcept;
 
 /**
- * Deletes a character by SOID and persists the account.
- * @param characterSoid SOID of the character to delete.
- * @return True when the character was found, removed, the account still validates, and the
- *         write committed.
+ * 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;
 

+ 183 - 232
Sunrise/src/state/runtime/state_account_roster_runtime.cpp

@@ -1,11 +1,16 @@
-/** Character creation and deletion logic. */
+/** 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"
@@ -15,133 +20,157 @@ namespace {
 
 namespace authored_inventory = account::inventory;
 
-/** One authored equipment slot this build seeds every newly created character with. */
-struct StarterItem {
-    authored_inventory::EquipmentSlot slot;
-    std::uint32_t definitionHash;
-    std::int32_t level;
-};
+/** 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;
+}
 
 /**
- * The 10 equipment slots the real client draws identically regardless of class: weapons, and the
- * cosmetic/utility slots (ghost, vehicle, ship, clan banner, emote, finisher, artifact). Hashes
- * and levels are copied verbatim from `resources/database/investment_defaults.sql`'s seed rows
- * for character slot 0, and cross-checked against the T4tsuk3i fork's per-class
- * `default_settings.json` templates -- identical across all three classes there too.
+ * 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.
  */
-constexpr std::array<StarterItem, 10> kSharedStarterItems{{
-    {authored_inventory::EquipmentSlot::kinetic, 3843477312U, 106},
-    {authored_inventory::EquipmentSlot::energy, 1096206669U, 106},
-    {authored_inventory::EquipmentSlot::heavy, 1864563948U, 106},
-    {authored_inventory::EquipmentSlot::ghost, 4135938409U, 0},
-    {authored_inventory::EquipmentSlot::vehicle, 3317837688U, 0},
-    {authored_inventory::EquipmentSlot::ship, 292872938U, 0},
-    {authored_inventory::EquipmentSlot::clanBanner, 1460578929U, 0},
-    {authored_inventory::EquipmentSlot::emote, 2038017661U, 0},
-    {authored_inventory::EquipmentSlot::finisher, 152583919U, 0},
-    {authored_inventory::EquipmentSlot::artifact, 1631206822U, 0},
-}};
+[[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;
+}
 
 /**
- * The 7 equipment slots the real client renders per class: armor silhouette, the class item, the
- * subclass (and the abilities/animations it drives), and the class-flavored emblem variant.
- * Equipping another class's items here is exactly the "Hunter wearing Titan gear and subclass"
- * mixup this build must not produce. Indexed by `CharacterClass`'s own wire value (titan=0,
- * hunter=1, warlock=2). Hashes copied from the T4tsuk3i fork's `default_settings.json`, which
- * carries a real, in-game-verified template per class; cross-checked against this build's own
- * default character (class hunter) for the slots the two sources share, which matched exactly.
+ * 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.
  */
-constexpr std::array<std::array<StarterItem, 7>, 3> kPerClassStarterItems{{
-    // titan
-    {{
-        {authored_inventory::EquipmentSlot::helmet, 0xECB479E4U, 106},
-        {authored_inventory::EquipmentSlot::gauntlets, 0xE98EBABDU, 106},
-        {authored_inventory::EquipmentSlot::chest, 0x542FC543U, 106},
-        {authored_inventory::EquipmentSlot::legs, 0xD2F64E86U, 106},
-        {authored_inventory::EquipmentSlot::classItem, 0x295F70BAU, 106},
-        {authored_inventory::EquipmentSlot::subclass, 0xB920CE9AU, 0},
-        {authored_inventory::EquipmentSlot::emblem, 0x71B4CC1BU, 0},
-    }},
-    // hunter
-    {{
-        {authored_inventory::EquipmentSlot::helmet, 0xF2994B80U, 106},
-        {authored_inventory::EquipmentSlot::gauntlets, 0x61868551U, 106},
-        {authored_inventory::EquipmentSlot::chest, 0x6F0DBB07U, 106},
-        {authored_inventory::EquipmentSlot::legs, 0x0B8E36D0U, 106},
-        {authored_inventory::EquipmentSlot::classItem, 0xB578E716U, 106},
-        {authored_inventory::EquipmentSlot::subclass, 0xD8B8D1FCU, 0},
-        {authored_inventory::EquipmentSlot::emblem, 0x71B4CC1AU, 0},
-    }},
-    // warlock
-    {{
-        {authored_inventory::EquipmentSlot::helmet, 0xEA042965U, 106},
-        {authored_inventory::EquipmentSlot::gauntlets, 0x188C5834U, 106},
-        {authored_inventory::EquipmentSlot::chest, 0xF8689C4CU, 106},
-        {authored_inventory::EquipmentSlot::legs, 0x083E04B6U, 106},
-        {authored_inventory::EquipmentSlot::classItem, 0x99446581U, 106},
-        {authored_inventory::EquipmentSlot::subclass, 0xCF88FEA5U, 0},
-        {authored_inventory::EquipmentSlot::emblem, 0x71B4CC19U, 0},
-    }},
-}};
+[[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);
+}
 
 /**
- * Equips the class-appropriate starter loadout, each item given a freshly allocated instance SOID
- * instead of a fixed one: the same hashes are granted to every created character of a class, so a
- * literal SOID would collide the moment more than one has ever carried this loadout.
- * @param account In-out account; scanned for collision-free SOIDs as each item is assigned one.
- *                Must already count `character` towards `characterCount`, so the scan sees this
- *                character's own items as they are assigned, not just prior characters.
- * @param character In-out character, already appended to account.characters and counted. Its
- *                  `characterClass` selects which armor/class-item/subclass row is granted.
- * @return False when a fresh SOID could not be allocated; the character is left without a
- *         loadout, exactly as it started, rather than half-equipped.
+ * 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 seed_starter_loadout(AccountState& account, CharacterState& character) noexcept {
-    const auto& perClass =
-        kPerClassStarterItems[static_cast<std::size_t>(character.characterClass)];
-    std::int32_t maxMutationSerial = -1;
-    std::int32_t serial = 0;
-    const auto place = [&](const StarterItem& starter) noexcept {
-        std::uint64_t freshSoid = 0;
-        if (!runtime::detail::next_item_instance_soid(account, freshSoid)) {
-            return false;
-        }
-        authored_inventory::Item item{};
-        item.instanceSoid = freshSoid;
-        item.definitionHash = starter.definitionHash;
-        item.level = starter.level;
-        item.quantity = 1;
-        item.mutationSerial = serial;
-        item.sockets.policy = authored_inventory::SocketPolicy::nativeDefaults;
-        character.equipment.slots[static_cast<std::size_t>(starter.slot)] = item;
-        maxMutationSerial = serial;
-        ++serial;
+[[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;
-    };
-    for (const StarterItem& starter : kSharedStarterItems) {
-        if (!place(starter)) {
-            character.equipment = {};
-            core::log::write(core::log::Channel::state,
-                             core::log::Level::warn,
-                             "ev=create_character stage=loadout result=fail reason=soid_exhausted");
+    }
+    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 (const StarterItem& starter : perClass) {
-        if (!place(starter)) {
-            character.equipment = {};
-            core::log::write(core::log::Channel::state,
-                             core::log::Level::warn,
-                             "ev=create_character stage=loadout result=fail reason=soid_exhausted");
+    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;
         }
     }
-    character.nextInventorySerial = static_cast<std::uint32_t>(maxMutationSerial + 1);
     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,
@@ -151,163 +180,85 @@ bool create_character(std::uint8_t characterClass,
     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)) {
-        core::log::writef(core::log::Channel::state,
-                          core::log::Level::warn,
-                          "ev=create_character stage=range result=fail class=%u gender=%u race=%u",
-                          static_cast<unsigned>(characterClass),
-                          static_cast<unsigned>(gender),
-                          static_cast<unsigned>(race));
+        report_refusal("create_character", "range");
         return false;
     }
-
-    investment::store::g_mutex.lock();
+    const std::lock_guard lock(investment::store::g_mutex);
     AccountState candidate = investment::store::account();
-    core::log::writef(core::log::Channel::state,
-                      core::log::Level::info,
-                      "ev=create_character stage=request class=%u gender=%u race=%u chars=%zu "
-                      "cap=%zu primary=0x%016llX",
-                      static_cast<unsigned>(characterClass),
-                      static_cast<unsigned>(gender),
-                      static_cast<unsigned>(race),
-                      candidate.characterCount,
-                      candidate.characters.size(),
-                      static_cast<unsigned long long>(candidate.primarySoid));
-    if (candidate.characterCount >= candidate.characters.size() || candidate.primarySoid == 0) {
-        investment::store::g_mutex.unlock();
-        core::log::write(core::log::Channel::state,
-                         core::log::Level::warn,
-                         "ev=create_character stage=capacity_or_primary result=fail");
-        return false;
-    }
-
-    const std::size_t index = candidate.characterCount;
-    // The slot index cannot name the SOID: deletion keeps every survivor's SOID, so after
-    // deleting a middle character the next free index already belongs to a living one. The
-    // lowest unused offset is taken instead, which reuses a freed SOID without ever colliding.
-    for (std::uint64_t offset = 1U; offset <= candidate.characters.size(); ++offset) {
-        const std::uint64_t soidCandidate = candidate.primarySoid + offset;
-        bool taken = false;
-        for (std::size_t existing = 0; existing < candidate.characterCount; ++existing) {
-            if (candidate.characters[existing].soid == soidCandidate) {
-                taken = true;
-                break;
-            }
-        }
-        if (!taken) {
-            characterSoid = soidCandidate;
-            break;
-        }
-    }
-    if (characterSoid == 0) {
-        investment::store::g_mutex.unlock();
-        core::log::write(
-            core::log::Channel::state, core::log::Level::warn, "ev=create_character stage=soid result=fail");
+    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;
     }
-
-    CharacterState& character = candidate.characters[index];
+    const std::int32_t level = starter_level(candidate);
+    CharacterState& character = candidate.characters[candidate.characterCount];
     character = {};
-    character.soid = characterSoid;
+    character.soid = soid;
     character.race = static_cast<CharacterRace>(race);
     character.gender = static_cast<CharacterGender>(gender);
     character.characterClass = static_cast<CharacterClass>(characterClass);
-    // Captured from the creation request, so the character keeps the face the player built
-    // instead of the single fixed header every character used to share.
     character.appearanceHeader = appearanceHeader;
     character.appearanceHeaderValid = true;
-
-    // The new character joins the roster unselected. Marking it selected here would be a pick
-    // the player never made, and a pick is what leaves the character-select step for orbit, so
-    // creation would skip the roster it just changed. Nothing is picked until the player picks.
-    ++candidate.characterCount;
-
-    // Counted above so the fresh-SOID scan inside sees this character's own items as they are
-    // assigned, not just the characters that existed before it. Without a loadout the character
-    // has no equipped gear, and the Family-4 encoder refuses to publish a character with nothing
-    // equipped (an empty gear set has no light average to divide), so it would exist in the
-    // database but never actually reach the client.
-    if (!seed_starter_loadout(candidate, character)) {
-        investment::store::g_mutex.unlock();
-        characterSoid = 0;
+    if (!investment::store::read_starter_character(character)) {
+        report_refusal("create_character", "starter_character");
         return false;
     }
-
-    if (!account::valid(candidate) || !investment::store::write_account(candidate)) {
-        investment::store::g_mutex.unlock();
-        core::log::writef(core::log::Channel::state,
-                          core::log::Level::warn,
-                          "ev=create_character stage=commit result=fail primary=0x%016llX chars=%zu",
-                          static_cast<unsigned long long>(candidate.primarySoid),
-                          candidate.characterCount);
-        characterSoid = 0;
+    // 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;
     }
-    for (std::size_t i = 0; i < candidate.characters.size(); ++i) {
-        investment::store::g_session.selected[i] = candidate.characters[i].selected;
+    if (!investment::store::write_account(candidate)) {
+        report_refusal("create_character", "commit");
+        return false;
     }
-    investment::store::g_mutex.unlock();
+    // 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 stage=commit result=ok soid=0x%016llX chars=%zu",
-                      static_cast<unsigned long long>(characterSoid),
+                      "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 {
-    if (characterSoid == 0) {
-        return false;
-    }
-
-    investment::store::g_mutex.lock();
+    const std::lock_guard lock(investment::store::g_mutex);
     AccountState candidate = investment::store::account();
-    if (candidate.characterCount == 0 || candidate.primarySoid == 0) {
-        investment::store::g_mutex.unlock();
-        return false;
-    }
-
-    std::size_t found = candidate.characterCount;
-    for (std::size_t i = 0; i < candidate.characterCount; ++i) {
-        if (candidate.characters[i].soid == characterSoid) {
-            found = i;
-            break;
-        }
-    }
-    if (found == candidate.characterCount) {
-        investment::store::g_mutex.unlock();
+    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;
     }
-
-    // Survivors keep the SOID they were created with. Rebasing them onto their new slot index
-    // silently renames living characters, which strands every object the client already holds
-    // under the old SOID -- its roster entry, its Family-4 body and its banner all keyed by it.
-    for (std::size_t i = found; i + 1 < candidate.characterCount; ++i) {
-        candidate.characters[i] = candidate.characters[i + 1U];
-    }
+    const auto slot = static_cast<std::size_t>(found - begin);
+    std::move(found + 1, end, found);
     --candidate.characterCount;
     candidate.characters[candidate.characterCount] = {};
-
-    // Removing a character never creates a selection. The deleted character's own flag left with
-    // its slot, and handing the selection to a survivor would be a pick the player never made --
-    // which is what leaves the character-select step for orbit, skipping the shrunken roster the
-    // delete just produced. The selection moves only when the player picks (opcodes 504 and 505).
-
-    if (!account::valid(candidate) || !investment::store::write_account(candidate)) {
-        investment::store::g_mutex.unlock();
-        core::log::writef(core::log::Channel::state,
-                          core::log::Level::warn,
-                          "ev=delete_character stage=commit result=fail soid=0x%016llX",
-                          static_cast<unsigned long long>(characterSoid));
+    // 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;
     }
-    for (std::size_t i = 0; i < candidate.characters.size(); ++i) {
-        investment::store::g_session.selected[i] =
-            i < candidate.characterCount && candidate.characters[i].selected;
-    }
-    investment::store::g_mutex.unlock();
     core::log::writef(core::log::Channel::state,
                       core::log::Level::info,
-                      "ev=delete_character stage=commit result=ok soid=0x%016llX chars=%zu",
+                      "ev=delete_character result=ok soid=0x%016llX chars=%zu",
                       static_cast<unsigned long long>(characterSoid),
                       candidate.characterCount);
     return true;