From d4704269552a694396fe3efc487dc8d94970b16e Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Fri, 18 Sep 2026 16:09:17 +0530 Subject: [PATCH 1/7] feat: create and delete characters at runtime ## Summary 0.5.0's opcode-501 handler is a stub: it returns whatever character is already selected and never parses the request body, so the create-character flow does nothing. Opcode-502 (delete) doesn't exist at all. This makes both real: - state::create_character / state::delete_character (new, state_account_roster_runtime.cpp) do the actual roster mutation: pick an unused SOID offset from the account's own primary key (reusing a freed SOID after a delete without ever colliding with a living character), build the new character, validate the whole account, and persist it -- following the same lock/read/validate/write/unlock shape already used by set_selected_character and ensure_character_subclasses in this file. - opcode501_request_codec parses the create-character request's race/ gender/class identity triple from its bit-packed payload. - opcode502 (new) parses the bare 64-bit delete-character target id. - web_service_runtime.cpp wires both into the dispatch that used to just echo opcode501 back and never handle opcode502 at all. ## Scope note This build has no per-character authored appearance to capture and no settings-authored default-loadout template to seed from -- both fields dev tooling elsewhere relies on don't exist on this CharacterState. A created character starts with an empty loadout, the same fallback path this account model already uses when no template matches. Capturing the player's authored face and seeding a starting loadout are natural follow-ups, not included here. ## Effects - Creating a character now actually adds a row to the roster and persists it; deleting one now actually removes it, preserving surviving characters' SOIDs (an object the client already holds under an old SOID never gets silently renamed). - Verified: fresh clean build (CMake + clang-cl, Release x64) links with zero warnings from any of the new or modified files. --- Sunrise/Sunrise.vcxproj | 5 + .../messages/opcode501_request_codec.cpp | 59 ++++++ .../messages/opcode501_request_codec.h | 33 ++++ .../web_service/messages/opcode502.cpp | 22 +++ .../web_service/messages/opcode502.h | 25 +++ .../web_service/web_service_runtime.cpp | 26 ++- Sunrise/src/state/runtime/runtime.h | 22 +++ .../runtime/state_account_roster_runtime.cpp | 176 ++++++++++++++++++ 8 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp create mode 100644 Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h create mode 100644 Sunrise/src/middleware/web_service/messages/opcode502.cpp create mode 100644 Sunrise/src/middleware/web_service/messages/opcode502.h create mode 100644 Sunrise/src/state/runtime/state_account_roster_runtime.cpp diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 6cd0414ae..595ced397 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -147,7 +147,10 @@ + + + @@ -1763,6 +1766,8 @@ + + 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..082f08f14 --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp @@ -0,0 +1,59 @@ +#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. Only the + * triple is decoded; the rest is skipped as one span. */ +constexpr std::size_t kIdentityAndHeaderBits = 261; +/** Each triple entry is a 1-bit presence flag plus an 8-bit +0x80-biased value. */ +constexpr std::uint8_t kTripleEntryBits = 9; +constexpr std::uint64_t kByteBias = 0x80U; +/** Bits left to skip after the triple: the appearance header and the request's trailing field. */ +constexpr std::size_t kSkipBits = kIdentityAndHeaderBits - 3U * kTripleEntryBits; + +/** + * Reads one 1-bit presence flag followed by an 8-bit +0x80-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; +} + +/** 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; + } + return reader.skip(kSkipBits); +} + +} // 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..97edaa224 --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "../web_service_envelope.h" +#include "opcode501_codec.h" + +namespace sunrise::middleware::web_service::messages::opcode501 { + +/** The create-character request's identity triple: race, gender and class. */ +struct Request { + std::uint8_t characterClass{}; + std::uint8_t gender{}; + std::uint8_t race{}; +}; + +/** + * Parses the create-character request's identity triple. + * + * The payload is a big-endian bitstream, not a byte-aligned struct. 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. This build has no per-character authored appearance to capture, so the header + * and name that follow are consumed and discarded rather than decoded field by field. + * + * @param message Parsed Web Service envelope. + * @param request Receives the parsed identity triple. + * @return True when the payload holds the full identity-and-header span, 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/web_service/web_service_runtime.cpp b/Sunrise/src/server/web_service/web_service_runtime.cpp index 104aea162..c30a686ea 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.cpp +++ b/Sunrise/src/server/web_service/web_service_runtime.cpp @@ -14,6 +14,8 @@ #include "../../middleware/web_service/messages/opcode206.h" #include "../../middleware/web_service/messages/opcode2400.h" #include "../../middleware/web_service/messages/opcode501_codec.h" +#include "../../middleware/web_service/messages/opcode501_request_codec.h" +#include "../../middleware/web_service/messages/opcode502.h" #include "../../middleware/web_service/messages/opcode503.h" #include "../../middleware/web_service/messages/opcode504.h" #include "../../middleware/web_service/messages/opcode601/opcode601_codec.h" @@ -351,14 +353,32 @@ bool consume(std::span request, } if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) { - // Returns a SOID family three already publishes. The request body is not parsed. - const std::uint64_t characterSoid = - state::account::selected_character_soid(state::account_snapshot()); + middleware::web_service::messages::opcode501::Request created{}; + std::uint64_t characterSoid = 0; + if (!middleware::web_service::messages::opcode501::parse_request(message, created) + || !state::create_character( + created.characterClass, created.gender, created.race, characterSoid)) { + core::log::write( + core::log::Channel::server, core::log::Level::warn, "ev=ws501 stage=create result=fail"); + // The client still needs an answer even when creation fails; falling back to the + // selected character keeps the response shape valid instead of leaving it unanswered. + characterSoid = state::account::selected_character_soid(state::account_snapshot()); + } return middleware::web_service::messages::opcode501::encode_response( message, characterSoid, response, written) || encode_echo(message, response, written); } + if (message.opcode == middleware::web_service::messages::opcode502::kOpcode) { + middleware::web_service::messages::opcode502::Request targeted{}; + if (!middleware::web_service::messages::opcode502::parse_request(message, targeted) + || !state::delete_character(targeted.characterSoid)) { + core::log::write( + core::log::Channel::server, core::log::Level::warn, "ev=ws502 stage=delete result=fail"); + } + return encode_echo(message, response, written); + } + // The artifact vendor is answered here. Every other vendor purchase falls through to the // shared response-shape path, which runs the action and answers its status: an action that // prepared no mutation is answered with the refused code. diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 6561b2aef..e6bdba65f 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -473,6 +473,28 @@ 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 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, + 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..07d46b438 --- /dev/null +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -0,0 +1,176 @@ +/** Character creation and deletion logic. */ + +#include + +#include "../../core/logging/log.h" +#include "../account/account_state.h" +#include "../investment/store_internal.h" +#include "runtime.h" + +namespace sunrise::state { + +bool create_character(std::uint8_t characterClass, + std::uint8_t gender, + std::uint8_t race, + 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); + + // Select the new character and deselect all others. + for (std::size_t i = 0; i < candidate.characterCount; ++i) { + candidate.characters[i].selected = false; + } + character.selected = true; + ++candidate.characterCount; + + 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] = {}; + + // Select the first character if the deleted one was selected. + bool hasSelection = false; + for (std::size_t i = 0; i < candidate.characterCount; ++i) { + if (candidate.characters[i].selected) { + hasSelection = true; + break; + } + } + if (!hasSelection && candidate.characterCount > 0) { + candidate.characters[0].selected = true; + } + + 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 From 2a8327a1d33e6ad20800f9f19b6c02b69aac90ca Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Sat, 19 Sep 2026 01:33:31 +0530 Subject: [PATCH 2/7] fix: stage the Family-4 select-character publish for create/delete The real client blocks after opcode 501/502 waiting for a Family-4 publish at the version the response names. The stock opcode501 codec hardcoded that value to kNoFamily4Publication (correct for its original "echo the selected character" use, wrong once the request actually creates or deletes a character), and opcode502 answered with a bare echo with no version at all. Both left the client waiting on a publication that was never staged, which times out into a disconnect. Moved the actual opcode 501/502 handling into bap_service_body.cpp, alongside the existing opcode 505 change-character block it mirrors: stage a queuez::SelectCharacter transaction onto the new/surviving character, encode the real staged Family-4 version in the response, and arm a full account resync as a safety net if staging fails or the frame gets dropped. opcode501_codec::encode_response now takes the status pair as a parameter instead of hardcoding it, so the existing stock call site (character-select echo) keeps its original behavior explicitly instead of implicitly. --- .../web_service/messages/opcode501_codec.cpp | 6 +- .../web_service/messages/opcode501_codec.h | 8 +- .../bap/encrypted/body/bap_service_body.cpp | 107 ++++++++++++++++++ .../web_service/web_service_runtime.cpp | 31 ++--- 4 files changed, 123 insertions(+), 29 deletions(-) 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/server/bap/encrypted/body/bap_service_body.cpp b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp index 80baf576e..54944b7e8 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,111 @@ 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 delete_character has already moved the selection onto a + // survivor, so the client needs the Family-4 character object moved with it. Opcode + // 502 feeds the Client's Family-4 version wait: the reply has to name the revision the + // wait should expect, the staged one when a publication is coming and the no-wait + // constant when none is -- left at its default the field reads as revision zero, which + // the wait blocks on, and the roster screen never finishes the delete it just asked for. + std::int32_t deletionVersion = middleware::web_service::kNoFamily4Publication; + const std::uint64_t survivorSoid = + state::account::selected_character_soid(state::account_snapshot()); + auto* deletionSelect = emplace_transaction(outcome); + if (survivorSoid != 0 && deletionSelect != nullptr + && queuez::stage_select_character(queuezState, survivorSoid, *deletionSelect)) { + outcome.hasSelectCharacter = true; + deletionVersion = deletionSelect->after.family4Version; + } else { + clear_transaction(outcome); + sunrise::server::bap::arm_account_resync_everywhere(); + } + 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, 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(); + // create_character marks the new character selected, and the client is waiting for the + // Family-4 character object to move onto it. A change-character patch cannot do that + // here: 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. Selecting is both ungated and what the client actually asked for. + std::int32_t creationVersion = middleware::web_service::kNoFamily4Publication; + auto* creationSelect = emplace_transaction(outcome); + if (creationSelect != nullptr + && queuez::stage_select_character(queuezState, characterSoid, *creationSelect)) { + outcome.hasSelectCharacter = true; + creationVersion = creationSelect->after.family4Version; + } else { + clear_transaction(outcome); + sunrise::server::bap::arm_account_resync_everywhere(); + } + 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/web_service/web_service_runtime.cpp b/Sunrise/src/server/web_service/web_service_runtime.cpp index c30a686ea..05fa0f487 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.cpp +++ b/Sunrise/src/server/web_service/web_service_runtime.cpp @@ -14,8 +14,6 @@ #include "../../middleware/web_service/messages/opcode206.h" #include "../../middleware/web_service/messages/opcode2400.h" #include "../../middleware/web_service/messages/opcode501_codec.h" -#include "../../middleware/web_service/messages/opcode501_request_codec.h" -#include "../../middleware/web_service/messages/opcode502.h" #include "../../middleware/web_service/messages/opcode503.h" #include "../../middleware/web_service/messages/opcode504.h" #include "../../middleware/web_service/messages/opcode601/opcode601_codec.h" @@ -353,32 +351,17 @@ bool consume(std::span request, } if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) { - middleware::web_service::messages::opcode501::Request created{}; - std::uint64_t characterSoid = 0; - if (!middleware::web_service::messages::opcode501::parse_request(message, created) - || !state::create_character( - created.characterClass, created.gender, created.race, characterSoid)) { - core::log::write( - core::log::Channel::server, core::log::Level::warn, "ev=ws501 stage=create result=fail"); - // The client still needs an answer even when creation fails; falling back to the - // selected character keeps the response shape valid instead of leaving it unanswered. - characterSoid = state::account::selected_character_soid(state::account_snapshot()); - } + // 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); } - if (message.opcode == middleware::web_service::messages::opcode502::kOpcode) { - middleware::web_service::messages::opcode502::Request targeted{}; - if (!middleware::web_service::messages::opcode502::parse_request(message, targeted) - || !state::delete_character(targeted.characterSoid)) { - core::log::write( - core::log::Channel::server, core::log::Level::warn, "ev=ws502 stage=delete result=fail"); - } - return encode_echo(message, response, written); - } - // The artifact vendor is answered here. Every other vendor purchase falls through to the // shared response-shape path, which runs the action and answers its status: an action that // prepared no mutation is answered with the refused code. From 5650b21c029c579995d4e88b1066e942be389449 Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Sat, 19 Sep 2026 01:42:04 +0530 Subject: [PATCH 3/7] fix: seed a starter loadout so created characters can publish The Family-4 character encoder refuses to publish a character with nothing equipped: build_equipment_summary rejects a light-evaluation divisor of zero (nothing to average), which is exactly what an empty-equipment character produces. A created character therefore existed in the database but could never actually be sent to the client, leaving the server retrying the same failing encode forever and the character never appearing. Seeds all 17 native equipment slots with the same items this build's own single authored default character already carries (copied from investment_defaults.sql's seed rows), each given a freshly allocated instance SOID so multiple created characters never collide. Item definitions carry no class restriction in this codebase, so the same proven-valid set is safe regardless of the requested class. --- .../runtime/state_account_roster_runtime.cpp | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp index 07d46b438..ad3fa8f6e 100644 --- a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -1,13 +1,94 @@ /** 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 account's own single authored default character already carries a complete, proven-valid + * 17-slot loadout (every native equipment slot, including the 8 that feed the Family-4 light + * divisor). Item definitions here carry no class restriction, so the same set is safe to grant + * regardless of the requested class -- it is exactly what this build already ships as its one + * default character's own gear. Hashes and levels are copied verbatim from + * `resources/database/investment_defaults.sql`'s seed rows for character slot 0. + */ +constexpr std::array kStarterLoadout{{ + {authored_inventory::EquipmentSlot::kinetic, 3843477312U, 106}, + {authored_inventory::EquipmentSlot::energy, 1096206669U, 106}, + {authored_inventory::EquipmentSlot::heavy, 1864563948U, 106}, + {authored_inventory::EquipmentSlot::helmet, 4070132608U, 106}, + {authored_inventory::EquipmentSlot::gauntlets, 1636205905U, 106}, + {authored_inventory::EquipmentSlot::chest, 1863170823U, 106}, + {authored_inventory::EquipmentSlot::legs, 193869520U, 106}, + {authored_inventory::EquipmentSlot::classItem, 3044599574U, 106}, + {authored_inventory::EquipmentSlot::ghost, 4135938409U, 0}, + {authored_inventory::EquipmentSlot::vehicle, 3317837688U, 0}, + {authored_inventory::EquipmentSlot::ship, 292872938U, 0}, + {authored_inventory::EquipmentSlot::subclass, 3635991036U, 0}, + {authored_inventory::EquipmentSlot::clanBanner, 1460578929U, 0}, + {authored_inventory::EquipmentSlot::emblem, 1907674138U, 0}, + {authored_inventory::EquipmentSlot::emote, 2038017661U, 0}, + {authored_inventory::EquipmentSlot::finisher, 152583919U, 0}, + {authored_inventory::EquipmentSlot::artifact, 1631206822U, 0}, +}}; + +/** + * Equips the fixed starter loadout, each item given a freshly allocated instance SOID instead of + * a fixed one: the same 17 hashes are granted to every created character, so a literal SOID would + * collide the moment more than one character 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. + * @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 { + std::int32_t maxMutationSerial = -1; + std::int32_t serial = 0; + for (const StarterItem& starter : kStarterLoadout) { + std::uint64_t freshSoid = 0; + if (!runtime::detail::next_item_instance_soid(account, freshSoid)) { + 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; + } + 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; + } + character.nextInventorySerial = static_cast(maxMutationSerial + 1); + return true; +} + +} // namespace bool create_character(std::uint8_t characterClass, std::uint8_t gender, @@ -85,6 +166,17 @@ bool create_character(std::uint8_t characterClass, character.selected = true; ++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, From 29798a13c4af9e10111e00b6ad3783b9417d8ac0 Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Sat, 19 Sep 2026 01:51:32 +0530 Subject: [PATCH 4/7] fix: give each created character its own class's gear, not one shared set The starter loadout added in the previous commit granted every created character the same 17 items, sourced from this build's one single authored default character. That character is a fixed class, so every other class got its gear and subclass -- a Hunter equipped with a Titan subclass and Titan armor. Item definitions carry no class-restriction field in this codebase, so nothing caught this at the data layer, but the real client still renders subclass abilities/animations and armor per class. Split the loadout into 10 slots that are genuinely class-agnostic (weapons, ghost, ship, vehicle, clan banner, emote, finisher, artifact -- cross- checked identical across all three classes in the T4tsuk3i fork's own per-class templates) and 7 that are not (helmet, gauntlets, chest, legs, class item, subclass, and the class-flavored emblem variant), now indexed by the created character's own class. --- .../runtime/state_account_roster_runtime.cpp | 98 ++++++++++++++----- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp index ad3fa8f6e..3e8e09937 100644 --- a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -23,54 +23,87 @@ struct StarterItem { }; /** - * The account's own single authored default character already carries a complete, proven-valid - * 17-slot loadout (every native equipment slot, including the 8 that feed the Family-4 light - * divisor). Item definitions here carry no class restriction, so the same set is safe to grant - * regardless of the requested class -- it is exactly what this build already ships as its one - * default character's own gear. Hashes and levels are copied verbatim from - * `resources/database/investment_defaults.sql`'s seed rows for character slot 0. + * 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 kStarterLoadout{{ +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::helmet, 4070132608U, 106}, - {authored_inventory::EquipmentSlot::gauntlets, 1636205905U, 106}, - {authored_inventory::EquipmentSlot::chest, 1863170823U, 106}, - {authored_inventory::EquipmentSlot::legs, 193869520U, 106}, - {authored_inventory::EquipmentSlot::classItem, 3044599574U, 106}, {authored_inventory::EquipmentSlot::ghost, 4135938409U, 0}, {authored_inventory::EquipmentSlot::vehicle, 3317837688U, 0}, {authored_inventory::EquipmentSlot::ship, 292872938U, 0}, - {authored_inventory::EquipmentSlot::subclass, 3635991036U, 0}, {authored_inventory::EquipmentSlot::clanBanner, 1460578929U, 0}, - {authored_inventory::EquipmentSlot::emblem, 1907674138U, 0}, {authored_inventory::EquipmentSlot::emote, 2038017661U, 0}, {authored_inventory::EquipmentSlot::finisher, 152583919U, 0}, {authored_inventory::EquipmentSlot::artifact, 1631206822U, 0}, }}; /** - * Equips the fixed starter loadout, each item given a freshly allocated instance SOID instead of - * a fixed one: the same 17 hashes are granted to every created character, so a literal SOID would - * collide the moment more than one character has ever carried this loadout. + * 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. + * @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; - for (const StarterItem& starter : kStarterLoadout) { + const auto place = [&](const StarterItem& starter) noexcept { std::uint64_t freshSoid = 0; if (!runtime::detail::next_item_instance_soid(account, freshSoid)) { - 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; } authored_inventory::Item item{}; @@ -83,6 +116,25 @@ constexpr std::array kStarterLoadout{{ 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; From 2a452638b66c12272097983dacba9d4cfab48ec7 Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Sat, 19 Sep 2026 02:17:36 +0530 Subject: [PATCH 5/7] fix: hold the character-select screen instead of falling through to orbit Without this, the sign-in boot step's enter handler clears its own stay flag on the first frame, so the client always falls straight through the character-select screen into orbit -- with whatever character happens to be selected -- immediately after create or delete rather than showing the updated roster. This is the piece client-stability-fixes carries that character-lifecycle actually depends on for its own responses (which correctly say nothing needs to be waited on) to be honored: the client has to still be on the character-select step for "stay here" to mean anything. Ported standalone rather than pulling in the rest of the client- stability-fixes branch, which bundles five unrelated bootflow fixes this feature doesn't need. --- Sunrise/Sunrise.vcxproj | 1 + .../bootflow/bootflow_hook_lifecycle.cpp | 7 +- .../hooks/bootflow/character_select_hold.cpp | 108 ++++++++++++++++++ Sunrise/src/client/hooks/bootflow/internal.h | 10 ++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 Sunrise/src/client/hooks/bootflow/character_select_hold.cpp diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 595ced397..013aaea8e 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -267,6 +267,7 @@ + diff --git a/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp b/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp index ba7f3f914..5eb419b11 100644 --- a/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp +++ b/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp @@ -21,12 +21,15 @@ bool install() noexcept { const bool worldStep = install_world_step(); const bool sliceSet = spawn::install_targets(); const bool probe = install_lifetime_gate_probe(); - g_installed.store(worldStep || sliceSet || probe, std::memory_order_release); - return worldStep && sliceSet && probe; + const bool characterSelectHold = install_character_select_hold(); + g_installed.store(worldStep || sliceSet || probe || characterSelectHold, + std::memory_order_release); + return worldStep && sliceSet && probe && characterSelectHold; } /** Clears both accessors, in the reverse order of install. */ void uninstall() noexcept { + uninstall_character_select_hold(); uninstall_lifetime_gate_probe(); spawn::uninstall_targets(); uninstall_world_step(); diff --git a/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp b/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp new file mode 100644 index 000000000..80598229d --- /dev/null +++ b/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include + +#include "../../../core/logging/log.h" +#include "../../hooking/detour.h" +#include "internal.h" + +namespace sunrise::client::hooks::bootflow { +namespace { + +/** + * The character sign-in step's enter handler, anchored on its two setup stores. + * Those write the step's latch field and the paired setup words, so no sibling step matches. + */ +constexpr std::string_view kEnterSignatureText = + "40 53 48 83 EC ? 48 8B D9 C7 41 38 FF FF FF FF 66 C7 41 3C 00 00 33 D2"; +/** Compiled pattern bytes of the signature text above. */ +constexpr auto kEnterSignature = + signature(kEnterSignatureText); + +/** Fields of the boot step this hook touches, as byte offsets from the step's own base. */ +struct StepLayout { + /** + * The stay flag. The exit gate needs it non-zero to stay on the select screen. The enter + * handler clears it, so the step would skip selection on its first frame. + */ + static constexpr std::size_t stayFlag = 72; +}; + +/** Value the step's own listener writes to hold the screen. Matched here. */ +constexpr std::uint8_t kHold = 1; + +using EnterHandler = void(__fastcall*)(std::byte*); + +hooking::detour::Handle g_handle{}; +std::atomic g_original{nullptr}; +std::atomic_bool g_reported{false}; + +/** + * Holds the character-select screen after the step sets itself up. + * Runs once per boot on the boot-step thread, after the original writes its defaults, so the + * original cannot overwrite this write. + * @param step Borrowed boot-step base pointer. + */ +__declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept { + const EnterHandler original = g_original.load(std::memory_order_acquire); + if (original != nullptr) { + original(step); + } + if (step == nullptr) { + return; + } + std::memcpy(step + StepLayout::stayFlag, &kHold, sizeof kHold); + if (!g_reported.exchange(true, std::memory_order_relaxed)) { + core::log::write(core::log::Channel::client, + core::log::Level::info, + "ev=bootflow stage=character_select result=held"); + } +} + +} // namespace + +/** + * Attaches the character-select hold: without it, the sign-in boot step clears its own stay + * flag on entry and the screen falls straight through to orbit, whatever character was picked, + * created, or removed. The client's exit gate reads this flag every frame and only leaves the + * screen once something (the player picking a character) sets it again. + * @return True when the target is found and the detour attaches. + */ +bool install_character_select_hold() noexcept { + if (g_handle.attached) { + return true; + } + std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter"); + if (target == nullptr) { + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=bootflow stage=character_select result=fail reason=target"); + return false; + } + const hooking::detour::Spec spec{target, reinterpret_cast(&enter_handler)}; + if (!hooking::detour::install(spec, g_handle)) { + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=bootflow stage=character_select result=fail reason=attach"); + return false; + } + g_original.store(reinterpret_cast(g_handle.original), std::memory_order_release); + core::log::write(core::log::Channel::client, + core::log::Level::info, + "ev=bootflow stage=character_select result=ok"); + return true; +} + +/** Detaches the character-select hold. */ +void uninstall_character_select_hold() noexcept { + if (g_handle.attached) { + (void)hooking::detour::uninstall(g_handle); + } + g_original.store(nullptr, std::memory_order_release); + g_reported.store(false, std::memory_order_release); +} + +} // namespace sunrise::client::hooks::bootflow diff --git a/Sunrise/src/client/hooks/bootflow/internal.h b/Sunrise/src/client/hooks/bootflow/internal.h index 1ff57873d..05e75eb99 100644 --- a/Sunrise/src/client/hooks/bootflow/internal.h +++ b/Sunrise/src/client/hooks/bootflow/internal.h @@ -28,4 +28,14 @@ void uninstall_world_step() noexcept; /** Detaches the lifetime gate probe. */ void uninstall_lifetime_gate_probe() noexcept; +/** + * Attaches the character-select hold, so the sign-in boot step stays on the character-select + * screen instead of falling through to orbit on its own. + * @return True when the target is found and the detour attaches. + */ +[[nodiscard]] bool install_character_select_hold() noexcept; + +/** Detaches the character-select hold. */ +void uninstall_character_select_hold() noexcept; + } // namespace sunrise::client::hooks::bootflow From b4c3c73e78a7ccc3ccfad6f1e937339bc6655057 Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Sat, 19 Sep 2026 13:22:19 +0530 Subject: [PATCH 6/7] feat: capture and encode real per-character appearance Both character encoders (family-4 and the family-3/family-0 character record) wrote a fixed 36-byte header for every character, unconditionally -- authored client-side ids, not extracted data, identical for every character regardless of race or gender. Real per-character appearance (face, hair, skin, etc.) was never captured: the creation request's appearance header was parsed only far enough to read race/gender/class, then the remaining 234 bits were explicitly skipped. Adds the missing half: parses the full appearance header from the creation request (8/16/32-bit fields, three different biases, mapped into the record's own little-endian byte layout), stores it per character, and has both encoders use it when present, falling back to the fixed header only for a character that predates this (existing saves keep working via a schema migration). Persistence: appearance_header/appearance_header_valid added to the characters table (schema v2 -> v3, migrated in place for existing databases). The generic Statement bind/column helpers gained BLOB support for fixed-size byte arrays, since nothing previously stored in this database needed anything but integers, floats, or text. --- .../database/investment_defaults.sql | 2 +- .../resources/database/investment_schema.sql | 6 +- .../character_record_encoder.cpp | 6 +- .../family4/character/character_encoder.cpp | 7 +- .../messages/opcode501_request_codec.cpp | 108 ++++++++++++++++-- .../messages/opcode501_request_codec.h | 33 ++++-- .../bap/encrypted/body/bap_service_body.cpp | 7 +- Sunrise/src/state/account/account_state.h | 9 ++ .../investment/investment_account_store.cpp | 10 +- .../state/investment/investment_database.cpp | 29 ++++- Sunrise/src/state/investment/store_internal.h | 24 +++- Sunrise/src/state/runtime/runtime.h | 3 + .../runtime/state_account_roster_runtime.cpp | 5 + 13 files changed, 210 insertions(+), 39 deletions(-) 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_request_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp index 082f08f14..f4ddc48cd 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp @@ -5,17 +5,29 @@ namespace sunrise::middleware::web_service::messages::opcode501 { namespace { -/** The identity triple and appearance header end here; the 64-byte name follows. Only the - * triple is decoded; the rest is skipped as one span. */ +/** The identity triple and appearance header end here; the 64-byte name follows. */ constexpr std::size_t kIdentityAndHeaderBits = 261; -/** Each triple entry is a 1-bit presence flag plus an 8-bit +0x80-biased value. */ -constexpr std::uint8_t kTripleEntryBits = 9; + constexpr std::uint64_t kByteBias = 0x80U; -/** Bits left to skip after the triple: the appearance header and the request's trailing field. */ -constexpr std::size_t kSkipBits = kIdentityAndHeaderBits - 3U * kTripleEntryBits; +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 +0x80-biased value. + * 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. */ @@ -32,6 +44,53 @@ constexpr std::size_t kSkipBits = kIdentityAndHeaderBits - 3U * kTripleEntryBits 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; @@ -53,7 +112,40 @@ bool parse_request(const Message& message, Request& request) noexcept { || request.characterClass > kMaxClass) { return false; } - return reader.skip(kSkipBits); + + 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 index 97edaa224..53bfa3731 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h @@ -2,31 +2,44 @@ #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's identity triple: race, gender and class. */ +/** 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's identity triple. + * Parses the create-character request body. * - * The payload is a big-endian bitstream, not a byte-aligned struct. 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. This build has no per-character authored appearance to capture, so the header - * and name that follow are consumed and discarded rather than decoded field by field. + * 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 identity triple. - * @return True when the payload holds the full identity-and-header span, every presence flag is - * set, and race, gender and class are all in range. + * @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; 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 54944b7e8..c03387806 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -277,8 +277,11 @@ bool process(const ServiceRoute& route, 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, characterSoid)) { + || !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"); 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 ff78faf5a..729aeb017 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -480,6 +480,8 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept; * @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. @@ -487,6 +489,7 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept; [[nodiscard]] bool create_character(std::uint8_t characterClass, std::uint8_t gender, std::uint8_t race, + const AppearanceHeader& appearanceHeader, std::uint64_t& characterSoid) noexcept; /** diff --git a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp index 3e8e09937..86690492f 100644 --- a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -145,6 +145,7 @@ constexpr std::array, 3> kPerClassStarterItems{{ 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) @@ -210,6 +211,10 @@ bool create_character(std::uint8_t characterClass, 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; // Select the new character and deselect all others. for (std::size_t i = 0; i < candidate.characterCount; ++i) { From 34a9c6a8a258208bc1cbbff033fec337e68e5c8c Mon Sep 17 00:00:00 2001 From: T4tsuk3i Date: Mon, 12 Oct 2026 11:38:23 +0530 Subject: [PATCH 7/7] fix: hold the character-select step with the server, not a client hook Stan's review on the select-screen hold is that it re-adds a deleted client hook and should be a server implementation instead. The hook is deleted here; the hold is carried by what the server publishes. The client was walking off the screen because the server invented a pick the player never made: - create_character marked the new character selected and cleared every other flag, and delete_character handed the selection to a survivor whenever the deleted slot held it. Moving the account onto a character by itself is what the real pick (opcodes 504/505) does, so the step had nothing left to wait for. Both now change the roster without selecting anything; only the player picks. - The opcode 501/502 handlers staged a queuez::SelectCharacter Family-4 move to publish that invented pick. They now arm the account resync they already arm and name the revision it publishes (queuezState.family4Version + 1) -- the same "name the following resync" contract the claim path below them uses -- so the reply names a publication that actually arrives instead of one that moves the selection. Two account-resync companions treated "nothing selected" as a failure. That is now a real, expected state (it is what the select screen is), so the Family-0 appearance and Family-3 roster lanes return without publishing instead of failing, which also stops the ability-refresh retry lane from re-running against a screen that is waiting for its own pick. The Family-4 account frame still goes out, so the roster count moves immediately. Removes Sunrise/src/client/hooks/bootflow/character_select_hold.cpp, its lifecycle registration, its declarations, and its project entry. --- Sunrise/Sunrise.vcxproj | 1 - .../bootflow/bootflow_hook_lifecycle.cpp | 7 +- .../hooks/bootflow/character_select_hold.cpp | 108 ------------------ Sunrise/src/client/hooks/bootflow/internal.h | 10 -- .../bap/encrypted/body/bap_service_body.cpp | 52 ++++----- .../push/queuez/queuez_banner_push.cpp | 16 ++- .../runtime/state_account_roster_runtime.cpp | 23 ++-- 7 files changed, 46 insertions(+), 171 deletions(-) delete mode 100644 Sunrise/src/client/hooks/bootflow/character_select_hold.cpp diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 6ab2ecf52..0742e4da7 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -267,7 +267,6 @@ - diff --git a/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp b/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp index 5eb419b11..ba7f3f914 100644 --- a/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp +++ b/Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp @@ -21,15 +21,12 @@ bool install() noexcept { const bool worldStep = install_world_step(); const bool sliceSet = spawn::install_targets(); const bool probe = install_lifetime_gate_probe(); - const bool characterSelectHold = install_character_select_hold(); - g_installed.store(worldStep || sliceSet || probe || characterSelectHold, - std::memory_order_release); - return worldStep && sliceSet && probe && characterSelectHold; + g_installed.store(worldStep || sliceSet || probe, std::memory_order_release); + return worldStep && sliceSet && probe; } /** Clears both accessors, in the reverse order of install. */ void uninstall() noexcept { - uninstall_character_select_hold(); uninstall_lifetime_gate_probe(); spawn::uninstall_targets(); uninstall_world_step(); diff --git a/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp b/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp deleted file mode 100644 index 80598229d..000000000 --- a/Sunrise/src/client/hooks/bootflow/character_select_hold.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include "../../../core/logging/log.h" -#include "../../hooking/detour.h" -#include "internal.h" - -namespace sunrise::client::hooks::bootflow { -namespace { - -/** - * The character sign-in step's enter handler, anchored on its two setup stores. - * Those write the step's latch field and the paired setup words, so no sibling step matches. - */ -constexpr std::string_view kEnterSignatureText = - "40 53 48 83 EC ? 48 8B D9 C7 41 38 FF FF FF FF 66 C7 41 3C 00 00 33 D2"; -/** Compiled pattern bytes of the signature text above. */ -constexpr auto kEnterSignature = - signature(kEnterSignatureText); - -/** Fields of the boot step this hook touches, as byte offsets from the step's own base. */ -struct StepLayout { - /** - * The stay flag. The exit gate needs it non-zero to stay on the select screen. The enter - * handler clears it, so the step would skip selection on its first frame. - */ - static constexpr std::size_t stayFlag = 72; -}; - -/** Value the step's own listener writes to hold the screen. Matched here. */ -constexpr std::uint8_t kHold = 1; - -using EnterHandler = void(__fastcall*)(std::byte*); - -hooking::detour::Handle g_handle{}; -std::atomic g_original{nullptr}; -std::atomic_bool g_reported{false}; - -/** - * Holds the character-select screen after the step sets itself up. - * Runs once per boot on the boot-step thread, after the original writes its defaults, so the - * original cannot overwrite this write. - * @param step Borrowed boot-step base pointer. - */ -__declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept { - const EnterHandler original = g_original.load(std::memory_order_acquire); - if (original != nullptr) { - original(step); - } - if (step == nullptr) { - return; - } - std::memcpy(step + StepLayout::stayFlag, &kHold, sizeof kHold); - if (!g_reported.exchange(true, std::memory_order_relaxed)) { - core::log::write(core::log::Channel::client, - core::log::Level::info, - "ev=bootflow stage=character_select result=held"); - } -} - -} // namespace - -/** - * Attaches the character-select hold: without it, the sign-in boot step clears its own stay - * flag on entry and the screen falls straight through to orbit, whatever character was picked, - * created, or removed. The client's exit gate reads this flag every frame and only leaves the - * screen once something (the player picking a character) sets it again. - * @return True when the target is found and the detour attaches. - */ -bool install_character_select_hold() noexcept { - if (g_handle.attached) { - return true; - } - std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter"); - if (target == nullptr) { - core::log::write(core::log::Channel::client, - core::log::Level::warn, - "ev=bootflow stage=character_select result=fail reason=target"); - return false; - } - const hooking::detour::Spec spec{target, reinterpret_cast(&enter_handler)}; - if (!hooking::detour::install(spec, g_handle)) { - core::log::write(core::log::Channel::client, - core::log::Level::warn, - "ev=bootflow stage=character_select result=fail reason=attach"); - return false; - } - g_original.store(reinterpret_cast(g_handle.original), std::memory_order_release); - core::log::write(core::log::Channel::client, - core::log::Level::info, - "ev=bootflow stage=character_select result=ok"); - return true; -} - -/** Detaches the character-select hold. */ -void uninstall_character_select_hold() noexcept { - if (g_handle.attached) { - (void)hooking::detour::uninstall(g_handle); - } - g_original.store(nullptr, std::memory_order_release); - g_reported.store(false, std::memory_order_release); -} - -} // namespace sunrise::client::hooks::bootflow diff --git a/Sunrise/src/client/hooks/bootflow/internal.h b/Sunrise/src/client/hooks/bootflow/internal.h index 05e75eb99..1ff57873d 100644 --- a/Sunrise/src/client/hooks/bootflow/internal.h +++ b/Sunrise/src/client/hooks/bootflow/internal.h @@ -28,14 +28,4 @@ void uninstall_world_step() noexcept; /** Detaches the lifetime gate probe. */ void uninstall_lifetime_gate_probe() noexcept; -/** - * Attaches the character-select hold, so the sign-in boot step stays on the character-select - * screen instead of falling through to orbit on its own. - * @return True when the target is found and the detour attaches. - */ -[[nodiscard]] bool install_character_select_hold() noexcept; - -/** Detaches the character-select hold. */ -void uninstall_character_select_hold() noexcept; - } // namespace sunrise::client::hooks::bootflow 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 c03387806..4cddf9f87 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -237,23 +237,19 @@ bool process(const ServiceRoute& route, // 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 delete_character has already moved the selection onto a - // survivor, so the client needs the Family-4 character object moved with it. Opcode - // 502 feeds the Client's Family-4 version wait: the reply has to name the revision the - // wait should expect, the staged one when a publication is coming and the no-wait - // constant when none is -- left at its default the field reads as revision zero, which - // the wait blocks on, and the roster screen never finishes the delete it just asked for. + // 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; - const std::uint64_t survivorSoid = - state::account::selected_character_soid(state::account_snapshot()); - auto* deletionSelect = emplace_transaction(outcome); - if (survivorSoid != 0 && deletionSelect != nullptr - && queuez::stage_select_character(queuezState, survivorSoid, *deletionSelect)) { - outcome.hasSelectCharacter = true; - deletionVersion = deletionSelect->after.family4Version; - } else { - clear_transaction(outcome); - sunrise::server::bap::arm_account_resync_everywhere(); + 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"); @@ -291,20 +287,18 @@ bool process(const ServiceRoute& route, // 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(); - // create_character marks the new character selected, and the client is waiting for the - // Family-4 character object to move onto it. A change-character patch cannot do that - // here: 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. Selecting is both ungated and what the client actually asked for. + // 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; - auto* creationSelect = emplace_transaction(outcome); - if (creationSelect != nullptr - && queuez::stage_select_character(queuezState, characterSoid, *creationSelect)) { - outcome.hasSelectCharacter = true; - creationVersion = creationSelect->after.family4Version; - } else { - clear_transaction(outcome); - sunrise::server::bap::arm_account_resync_everywhere(); + if (queuezState.family4Active + && queuezState.family4Version != (std::numeric_limits::max)()) { + creationVersion = queuezState.family4Version + 1; } core::log::write(core::log::Channel::server, core::log::Level::info, 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/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp index 86690492f..1247c82c0 100644 --- a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -216,11 +216,9 @@ bool create_character(std::uint8_t characterClass, character.appearanceHeader = appearanceHeader; character.appearanceHeaderValid = true; - // Select the new character and deselect all others. - for (std::size_t i = 0; i < candidate.characterCount; ++i) { - candidate.characters[i].selected = false; - } - character.selected = 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 @@ -289,17 +287,10 @@ bool delete_character(std::uint64_t characterSoid) noexcept { --candidate.characterCount; candidate.characters[candidate.characterCount] = {}; - // Select the first character if the deleted one was selected. - bool hasSelection = false; - for (std::size_t i = 0; i < candidate.characterCount; ++i) { - if (candidate.characters[i].selected) { - hasSelection = true; - break; - } - } - if (!hasSelection && candidate.characterCount > 0) { - candidate.characters[0].selected = true; - } + // 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();