diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj
index 11a8e4d63..0742e4da7 100644
--- a/Sunrise/Sunrise.vcxproj
+++ b/Sunrise/Sunrise.vcxproj
@@ -147,7 +147,10 @@
+
+
+
@@ -1765,6 +1768,8 @@
+
+
diff --git a/Sunrise/resources/database/investment_defaults.sql b/Sunrise/resources/database/investment_defaults.sql
index b2da7586d..a4821f36b 100644
--- a/Sunrise/resources/database/investment_defaults.sql
+++ b/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);
diff --git a/Sunrise/resources/database/investment_schema.sql b/Sunrise/resources/database/investment_schema.sql
index 662d673fd..3f451ce72 100644
--- a/Sunrise/resources/database/investment_schema.sql
+++ b/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 (
diff --git a/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp b/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
index 69d371ba5..f189674b2 100644
--- a/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
+++ b/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
@@ -42,9 +42,9 @@ constexpr std::size_t kCardFlagOffset = 8;
static_cast(character.gender),
static_cast(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);
diff --git a/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp b/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
index 96d376dcf..b60c5c8f5 100644
--- a/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
+++ b/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
@@ -271,9 +271,10 @@ bool encode(const state::CharacterState& state,
object.identity.race = static_cast(state.race);
object.identity.gender = static_cast(state.gender);
object.identity.characterClass = static_cast(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;
diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp
index 8f38fa276..36ea68e55 100644
--- a/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp
+++ b/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp
@@ -12,8 +12,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 output,
std::size_t& written) noexcept {
@@ -26,9 +27,6 @@ bool encode_response(const Message& message,
message.transactionId);
encoding::bits::Writer writer(output.subspan(kEnvelopeHeaderSize));
- // 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)
diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_codec.h b/Sunrise/src/middleware/web_service/messages/opcode501_codec.h
index c56858e38..ad1177abd 100644
--- a/Sunrise/src/middleware/web_service/messages/opcode501_codec.h
+++ b/Sunrise/src/middleware/web_service/messages/opcode501_codec.h
@@ -13,13 +13,19 @@ inline constexpr std::uint16_t kOpcode = 501;
/**
* Encodes the create-character response: the status pair then the character object id.
+ * The status is the caller's, because this opcode has no single right answer. A refusal has to
+ * say so in the code -- the SOID field alone cannot, since zero is what a refusal would send and
+ * the Client reads the code, not the id -- and the value feeds the Family-4 version wait, which
+ * needs the revision a staged publication will carry, or kNoFamily4Publication when none will.
* @param message Parsed request whose envelope fields are echoed.
- * @param characterSoid Character object id; must be published in family 3.
+ * @param status Logical status pair: refusal code, and the revision the wait should expect.
+ * @param characterSoid Character object id; must be published in family 3. 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 output,
std::size_t& written) noexcept;
diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp
new file mode 100644
index 000000000..f4ddc48cd
--- /dev/null
+++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp
@@ -0,0 +1,151 @@
+#include "opcode501_request_codec.h"
+
+#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;
+
+constexpr std::uint64_t kByteBias = 0x80U;
+constexpr std::uint64_t kWordBias = 0x8000U;
+
+/** Writes the record's little-endian byte order, which is not the wire's bit order. */
+void put8(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept {
+ header[offset] = static_cast(value & 0xFFU);
+}
+
+void put16(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept {
+ header[offset] = static_cast(value & 0xFFU);
+ header[offset + 1U] = static_cast((value >> 8U) & 0xFFU);
+}
+
+void put32(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept {
+ put16(header, offset, value & 0xFFFFU);
+ put16(header, offset + 2U, (value >> 16U) & 0xFFFFU);
+}
+
+/**
+ * Reads one 1-bit presence flag followed by an 8-bit biased value.
+ * @return True when the field is present and readable. An absent field fails closed: nothing on
+ * the wire carries the value that was skipped, so there is nothing to fall back to.
+ */
+[[nodiscard]] bool read_optional_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+ std::uint64_t present = 0;
+ if (!reader.read(1, present) || present == 0) {
+ return false;
+ }
+ std::uint64_t raw = 0;
+ if (!reader.read(8, raw)) {
+ return false;
+ }
+ value = static_cast(raw - kByteBias);
+ return true;
+}
+
+/** Reads an 8-bit +0x80-biased header field straight into the record block. */
+[[nodiscard]] bool
+copy_byte(encoding::bits::Reader& reader, state::AppearanceHeader& header, std::size_t offset) noexcept {
+ std::uint64_t raw = 0;
+ if (!reader.read(8, raw)) {
+ return false;
+ }
+ put8(header, offset, raw - kByteBias);
+ return true;
+}
+
+/** Reads a 16-bit +0x8000-biased header field straight into the record block. */
+[[nodiscard]] bool
+copy_word(encoding::bits::Reader& reader, state::AppearanceHeader& header, std::size_t offset) noexcept {
+ std::uint64_t raw = 0;
+ if (!reader.read(16, raw)) {
+ return false;
+ }
+ put16(header, offset, raw - kWordBias);
+ return true;
+}
+
+/**
+ * Byte offsets of every appearance-header field inside the 36-byte record block. The gaps at 5,
+ * 26 and 33 are the struct's own alignment padding and stay zero.
+ */
+constexpr std::size_t kOffsetF0 = 0;
+constexpr std::size_t kOffsetF2 = 2;
+constexpr std::size_t kOffsetF3 = 3;
+constexpr std::size_t kOffsetF4 = 4;
+constexpr std::size_t kOffsetF6 = 6;
+constexpr std::size_t kOffsetF8 = 8;
+constexpr std::size_t kOffsetF10 = 10;
+constexpr std::size_t kOffsetF12 = 12;
+constexpr std::size_t kOffsetF18 = 18;
+constexpr std::size_t kOffsetF24 = 24;
+constexpr std::size_t kOffsetF28 = 28;
+constexpr std::size_t kOffsetF32 = 32;
+
+/** Both triples of 16-bit customization indices hold 3 entries. */
+constexpr std::size_t kIndexTripleCount = 3;
+/** Each entry in those triples is 2 bytes wide in the record block. */
+constexpr std::size_t kIndexTripleStride = 2;
+
+/** The record requires 1 in the trailing field; the request always carries 0 there. */
+constexpr std::uint64_t kRecordTrailingField = 1;
+
+/** 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;
+
+} // namespace
+
+bool parse_request(const Message& message, Request& request) noexcept {
+ request = {};
+ if (message.opcode != kOpcode || message.payload.size() * 8U < kIdentityAndHeaderBits) {
+ return false;
+ }
+ encoding::bits::Reader reader(message.payload);
+ if (!read_optional_byte(reader, request.race) || !read_optional_byte(reader, request.gender)
+ || !read_optional_byte(reader, request.characterClass)) {
+ return false;
+ }
+ if (request.race > kMaxRace || request.gender > kMaxGender
+ || request.characterClass > kMaxClass) {
+ return false;
+ }
+
+ auto& header = request.appearanceHeader;
+ if (!copy_word(reader, header, kOffsetF0) || !copy_byte(reader, header, kOffsetF2)
+ || !copy_byte(reader, header, kOffsetF3) || !copy_byte(reader, header, kOffsetF4)
+ || !copy_word(reader, header, kOffsetF6) || !copy_word(reader, header, kOffsetF8)
+ || !copy_word(reader, header, kOffsetF10)) {
+ return false;
+ }
+ for (std::size_t index = 0; index < kIndexTripleCount; ++index) {
+ if (!copy_word(reader, header, kOffsetF12 + index * kIndexTripleStride)) {
+ return false;
+ }
+ }
+ for (std::size_t index = 0; index < kIndexTripleCount; ++index) {
+ if (!copy_word(reader, header, kOffsetF18 + index * kIndexTripleStride)) {
+ return false;
+ }
+ }
+ if (!copy_word(reader, header, kOffsetF24)) {
+ return false;
+ }
+ std::uint64_t emptyHashSentinel = 0;
+ if (!reader.read(32, emptyHashSentinel)) {
+ return false;
+ }
+ put32(header, kOffsetF28, emptyHashSentinel);
+
+ // Read but not copied: the request's own value is 0 and the record rejects it.
+ std::uint64_t requestTrailingField = 0;
+ if (!reader.read(2, requestTrailingField)) {
+ return false;
+ }
+ put8(header, kOffsetF32, kRecordTrailingField);
+ return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode501
diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h
new file mode 100644
index 000000000..53bfa3731
--- /dev/null
+++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h
@@ -0,0 +1,46 @@
+#pragma once
+
+#include
+
+#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{};
+ /**
+ * The appearance header in record byte order, ready to memcpy into either character
+ * encoder's block. Its values index per-race customization tables, so it is only
+ * meaningful beside the `race` it arrived with.
+ */
+ state::AppearanceHeader appearanceHeader{};
+};
+
+/**
+ * Parses the create-character request body.
+ *
+ * The payload is a big-endian bitstream, not a byte-aligned struct: every field is written at
+ * whatever bit offset the previous one ended on. Wire order is depth-first over
+ * `{ int8 triple[3]; CharHeader header; char name[64]; }`, where `triple` is race, gender and
+ * class in that order. Each `triple` entry is a 1-bit presence flag followed by 8 value bits
+ * biased by +0x80; the header's fields are 8, 16 or 32 bits biased by +0x80, +0x8000 or not at
+ * all; and the 64-byte name begins at bit 261. This parser consumes the first 261 bits and
+ * ignores the name, which every observed capture leaves as the placeholder "Player".
+ *
+ * The header is emitted in the record's own little-endian struct layout rather than as parsed
+ * fields, since both character encoders want it as an opaque block. Its last field is the one
+ * value that is not copied through: the request carries 0 where the record requires 1.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the parsed creation fields.
+ * @return True when the payload holds all 261 bits, every presence flag is set, and race,
+ * gender and class are all in range.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode501
diff --git a/Sunrise/src/middleware/web_service/messages/opcode502.cpp b/Sunrise/src/middleware/web_service/messages/opcode502.cpp
new file mode 100644
index 000000000..e7b211074
--- /dev/null
+++ b/Sunrise/src/middleware/web_service/messages/opcode502.cpp
@@ -0,0 +1,22 @@
+#include "opcode502.h"
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+namespace {
+
+constexpr std::uint8_t kCharacterSoidWidth = 64;
+constexpr std::size_t kMinimumPayloadSize = 8;
+
+} // namespace
+
+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
diff --git a/Sunrise/src/middleware/web_service/messages/opcode502.h b/Sunrise/src/middleware/web_service/messages/opcode502.h
new file mode 100644
index 000000000..3db44ea3d
--- /dev/null
+++ b/Sunrise/src/middleware/web_service/messages/opcode502.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include
+
+#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
diff --git a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
index 80baf576e..4cddf9f87 100644
--- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
+++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
@@ -12,6 +12,8 @@
#include "../../../../middleware/bap/family_unsubscription.h"
#include "../../../../middleware/bap/user_message/user_message_response.h"
#include "../../../../middleware/encoding/byte_order.h"
+#include "../../../../middleware/web_service/messages/opcode501_request_codec.h"
+#include "../../../../middleware/web_service/messages/opcode502.h"
#include "../../../../middleware/web_service/messages/opcode505/opcode505_codec.h"
#include "../../../../state/activity/membership/activity_membership_query.h"
#include "../../../../state/runtime/runtime.h"
@@ -204,6 +206,108 @@ bool process(const ServiceRoute& route,
outcome.hasChangeCharacter = true;
return true;
}
+ // Character deletion. A refusal still answers with the status pair: the roster screen
+ // waits on the echoed transaction id, so a missing reply hangs it for the rest of the run.
+ if (middleware::web_service::parse_request(requestBody, message)
+ && message.opcode == middleware::web_service::messages::opcode502::kOpcode) {
+ const auto refuse = [&]() noexcept {
+ middleware::web_service::StatusResponse status{};
+ status.code = middleware::web_service::kRefusedStatusCode;
+ // A refused delete stages nothing, so the version wait must be told not to wait.
+ status.value = middleware::web_service::kNoFamily4Publication;
+ return middleware::web_service::encode_response(
+ message,
+ middleware::web_service::ResponseShape::statusPair,
+ status,
+ output,
+ written);
+ };
+ middleware::web_service::messages::opcode502::Request deletion{};
+ if (!middleware::web_service::messages::opcode502::parse_request(message, deletion)
+ || deletion.characterSoid == 0
+ || !state::delete_character(deletion.characterSoid)) {
+ core::log::write(core::log::Channel::server,
+ core::log::Level::warn,
+ "ev=ws502 stage=delete result=fail");
+ return refuse();
+ }
+ // The account is already changed and written (delete_character persists through the
+ // investment store internally), but the caller can still drop the frame afterwards --
+ // a stale entity lease, a refused commit, or a response that does not fit -- and the
+ // outcome flag at the end of this file only lands on the handled path. Without this
+ // the character is gone from disk while the client keeps a roster that names it.
+ sunrise::server::bap::arm_account_resync_everywhere();
+ // The roster shrank, and the client is still on the character-select step. Nothing
+ // here may move the selection: a selected character is a pick, and a pick is what
+ // leaves the screen for orbit, so the delete would skip the roster it just changed.
+ // The account object the armed resync re-encodes already carries the new roster count,
+ // so the reply only has to name the revision that resync will publish -- the same
+ // "name the following resync" contract the claim path below uses -- and the step waits
+ // on it instead of walking on. Left at its default the field reads as revision zero,
+ // which the wait blocks on, and the roster screen never finishes the delete it asked
+ // for.
+ std::int32_t deletionVersion = middleware::web_service::kNoFamily4Publication;
+ if (queuezState.family4Active
+ && queuezState.family4Version != (std::numeric_limits::max)()) {
+ deletionVersion = queuezState.family4Version + 1;
+ }
+ core::log::write(
+ core::log::Channel::server, core::log::Level::info, "ev=ws502 stage=delete result=ok");
+ middleware::web_service::StatusResponse deletionStatus{};
+ deletionStatus.value = deletionVersion;
+ return middleware::web_service::encode_response(
+ message,
+ middleware::web_service::ResponseShape::statusPair,
+ deletionStatus,
+ output,
+ written);
+ }
+ // Character creation. Both outcomes answer in opcode 501's own response shape, which
+ // carries the new SOID: the generic status pair omits that field and the Client fatally
+ // disconnects trying to decode it.
+ if (middleware::web_service::parse_request(requestBody, message)
+ && message.opcode == middleware::web_service::messages::opcode501::kOpcode) {
+ middleware::web_service::StatusResponse creationRefusal{};
+ creationRefusal.code = middleware::web_service::kRefusedStatusCode;
+ creationRefusal.value = middleware::web_service::kNoFamily4Publication;
+ middleware::web_service::messages::opcode501::Request creation{};
+ std::uint64_t characterSoid = 0;
+ if (!middleware::web_service::messages::opcode501::parse_request(message, creation)
+ || !state::create_character(creation.characterClass,
+ creation.gender,
+ creation.race,
+ creation.appearanceHeader,
+ characterSoid)) {
+ core::log::write(core::log::Channel::server,
+ core::log::Level::warn,
+ "ev=ws501 stage=create result=fail");
+ return middleware::web_service::messages::opcode501::encode_response(
+ message, creationRefusal, 0, output, written);
+ }
+ // As in the delete branch: the account is already changed and written, but the caller
+ // can still drop the frame, and the outcome flag only lands on the handled path.
+ sunrise::server::bap::arm_account_resync_everywhere();
+ // The new character joins the roster unselected (see create_character), and the client
+ // is still on the character-select step. Selecting it here would be a pick the player
+ // never made, and a pick is what leaves the step for orbit, so creation would skip the
+ // roster it just changed. The armed resync re-encodes the account object with the new
+ // roster count, so the reply only names the revision that resync will publish and the
+ // step waits on it. A change-character patch cannot do this: it refuses unless family
+ // three is in its normal phase, and the creation flow leaves it outside that phase, so
+ // the client would wait forever on a publication that was never staged.
+ std::int32_t creationVersion = middleware::web_service::kNoFamily4Publication;
+ if (queuezState.family4Active
+ && queuezState.family4Version != (std::numeric_limits::max)()) {
+ creationVersion = queuezState.family4Version + 1;
+ }
+ core::log::write(core::log::Channel::server,
+ core::log::Level::info,
+ "ev=ws501 stage=create result=ok");
+ middleware::web_service::StatusResponse creationStatus{};
+ creationStatus.value = creationVersion;
+ return middleware::web_service::messages::opcode501::encode_response(
+ message, creationStatus, characterSoid, output, written);
+ }
state::investment::store::Transaction investmentTransaction;
if (!investmentTransaction.ready()) {
return false;
diff --git a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
index cab7c9fe2..f667f73f4 100644
--- a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
+++ b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
@@ -386,7 +386,11 @@ 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;
+ // The appearance record belongs to the character the player picked. Before a pick there is
+ // none, and the account change that armed this resync already went out in the Family-4 frame
+ // ahead of it. Refusing would report a failure that frame covered and leave the retry lane
+ // re-running on a screen whose whole purpose is to wait for that pick.
+ return true;
}
if (before.family0Character != selected) {
return append_banner_move_notification(
@@ -428,6 +432,14 @@ 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) {
+ // The character record and account roster this lane carries describe the picked character.
+ // Before a pick there is none to describe, and the account change that armed this resync
+ // already went out in the Family-4 frame ahead of it. Refusing would report a failure that
+ // frame covered and leave the retry lane re-running on a screen that waits for the pick
+ // which would end it.
+ return true;
+ }
std::size_t characterIndex = account.characterCount;
for (std::size_t index = 0; index < account.characterCount; ++index) {
if (account.characters[index].soid == selected) {
@@ -437,7 +449,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)
diff --git a/Sunrise/src/server/web_service/web_service_runtime.cpp b/Sunrise/src/server/web_service/web_service_runtime.cpp
index 104aea162..05fa0f487 100644
--- a/Sunrise/src/server/web_service/web_service_runtime.cpp
+++ b/Sunrise/src/server/web_service/web_service_runtime.cpp
@@ -354,8 +354,11 @@ bool consume(std::span request,
// 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());
+ // The reply names a character the roster already holds, so no revision publishes it.
+ middleware::web_service::StatusResponse status{};
+ status.value = middleware::web_service::kNoFamily4Publication;
return middleware::web_service::messages::opcode501::encode_response(
- message, characterSoid, response, written)
+ message, status, characterSoid, response, written)
|| encode_echo(message, response, written);
}
diff --git a/Sunrise/src/state/account/account_state.h b/Sunrise/src/state/account/account_state.h
index b76d74f94..c60063547 100644
--- a/Sunrise/src/state/account/account_state.h
+++ b/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;
/** 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. */
@@ -165,6 +169,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. */
diff --git a/Sunrise/src/state/investment/investment_account_store.cpp b/Sunrise/src/state/investment/investment_account_store.cpp
index 1f672baea..5922e0421 100644
--- a/Sunrise/src/state/investment/investment_account_store.cpp
+++ b/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;
}
}
diff --git a/Sunrise/src/state/investment/investment_database.cpp b/Sunrise/src/state/investment/investment_database.cpp
index bce004936..0c0848fcf 100644
--- a/Sunrise/src/state/investment/investment_database.cpp
+++ b/Sunrise/src/state/investment/investment_database.cpp
@@ -119,13 +119,15 @@ bool open(std::string_view path,
&& execute(preferenceSchema.c_str()) && execute(preferenceDefaults.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 per-character appearance header. 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 +139,25 @@ 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;
+ // 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();
}
if (!ready) {
shutdown();
diff --git a/Sunrise/src/state/investment/store_internal.h b/Sunrise/src/state/investment/store_internal.h
index 49178575a..680313ce7 100644
--- a/Sunrise/src/state/investment/store_internal.h
+++ b/Sunrise/src/state/investment/store_internal.h
@@ -1,7 +1,9 @@
#pragma once
+#include
#include
#include
+#include
#include
#include
#include
@@ -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 inline constexpr bool kIsByteArray = false;
+template
+inline constexpr bool kIsByteArray> = true;
+
extern sqlite3* g_database;
extern std::recursive_mutex g_mutex;
extern std::uint64_t g_failureSerial;
@@ -61,7 +68,14 @@ class Statement {
if (statement_ == nullptr) {
return false;
}
- if constexpr (std::is_enum_v) {
+ if constexpr (kIsByteArray) {
+ if (sqlite3_column_type(statement_, index) != SQLITE_BLOB
+ || static_cast(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) {
std::underlying_type_t raw{};
if (!column(index, raw)) {
return false;
@@ -113,8 +127,12 @@ class Statement {
private:
/** SQL integers preserve all 64 identity bits; text bindings copy their input. */
- template [[nodiscard]] bool bind(int index, T value) noexcept {
- if constexpr (std::is_convertible_v) {
+ template [[nodiscard]] bool bind(int index, const T& value) noexcept {
+ if constexpr (kIsByteArray) {
+ return sqlite3_bind_blob64(
+ statement_, index, value.data(), value.size(), copy_text())
+ == SQLITE_OK;
+ } else if constexpr (std::is_convertible_v) {
const std::string_view textValue(value);
return sqlite3_bind_text64(statement_,
index,
diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h
index 76525e31d..729aeb017 100644
--- a/Sunrise/src/state/runtime/runtime.h
+++ b/Sunrise/src/state/runtime/runtime.h
@@ -475,6 +475,31 @@ 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.
+ */
+[[nodiscard]] bool create_character(std::uint8_t characterClass,
+ std::uint8_t gender,
+ std::uint8_t race,
+ const AppearanceHeader& appearanceHeader,
+ 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.
+ */
+[[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.
diff --git a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp
new file mode 100644
index 000000000..1247c82c0
--- /dev/null
+++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp
@@ -0,0 +1,316 @@
+/** Character creation and deletion logic. */
+
+#include
+#include
+
+#include "../../core/logging/log.h"
+#include "../account/account_state.h"
+#include "../account/inventory/inventory_state.h"
+#include "../investment/store_internal.h"
+#include "runtime.h"
+#include "state_account_transaction_helpers.h"
+
+namespace sunrise::state {
+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;
+};
+
+/**
+ * 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.
+ */
+constexpr std::array 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},
+}};
+
+/**
+ * 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.
+ */
+constexpr std::array, 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},
+ }},
+}};
+
+/**
+ * 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.
+ */
+[[nodiscard]] bool seed_starter_loadout(AccountState& account, CharacterState& character) noexcept {
+ const auto& perClass =
+ kPerClassStarterItems[static_cast(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(starter.slot)] = item;
+ maxMutationSerial = serial;
+ ++serial;
+ 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");
+ 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");
+ return false;
+ }
+ }
+ character.nextInventorySerial = static_cast(maxMutationSerial + 1);
+ return true;
+}
+
+} // namespace
+
+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(CharacterClass::warlock)
+ || gender > static_cast(CharacterGender::female)
+ || race > static_cast(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(characterClass),
+ static_cast(gender),
+ static_cast(race));
+ return false;
+ }
+
+ investment::store::g_mutex.lock();
+ 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(characterClass),
+ static_cast(gender),
+ static_cast(race),
+ candidate.characterCount,
+ candidate.characters.size(),
+ static_cast(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");
+ return false;
+ }
+
+ CharacterState& character = candidate.characters[index];
+ character = {};
+ character.soid = characterSoid;
+ character.race = static_cast(race);
+ character.gender = static_cast(gender);
+ character.characterClass = static_cast(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;
+ 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(candidate.primarySoid),
+ candidate.characterCount);
+ characterSoid = 0;
+ return false;
+ }
+ for (std::size_t i = 0; i < candidate.characters.size(); ++i) {
+ investment::store::g_session.selected[i] = candidate.characters[i].selected;
+ }
+ investment::store::g_mutex.unlock();
+ 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(characterSoid),
+ candidate.characterCount);
+ return true;
+}
+
+bool delete_character(std::uint64_t characterSoid) noexcept {
+ if (characterSoid == 0) {
+ return false;
+ }
+
+ investment::store::g_mutex.lock();
+ 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();
+ 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];
+ }
+ --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(characterSoid));
+ 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",
+ static_cast(characterSoid),
+ candidate.characterCount);
+ return true;
+}
+
+} // namespace sunrise::state