diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 11a8e4d63..0742e4da7 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -147,7 +147,10 @@ + + + @@ -1765,6 +1768,8 @@ + + diff --git a/Sunrise/resources/database/investment_defaults.sql b/Sunrise/resources/database/investment_defaults.sql index b2da7586d..a4821f36b 100644 --- a/Sunrise/resources/database/investment_defaults.sql +++ b/Sunrise/resources/database/investment_defaults.sql @@ -1,5 +1,5 @@ INSERT INTO account VALUES (1, -7013740688821976832, 1); -INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57); +INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0); INSERT INTO items VALUES (0, 0, 0, 4611686018427387905, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0); INSERT INTO sockets VALUES (4611686018427387905, 0, 2874284214); INSERT INTO sockets VALUES (4611686018427387905, 1, 3433396027); diff --git a/Sunrise/resources/database/investment_schema.sql b/Sunrise/resources/database/investment_schema.sql index 662d673fd..3f451ce72 100644 --- a/Sunrise/resources/database/investment_schema.sql +++ b/Sunrise/resources/database/investment_schema.sql @@ -1,5 +1,5 @@ PRAGMA application_id = 1397902921; -PRAGMA user_version = 2; +PRAGMA user_version = 3; CREATE TABLE account ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -20,7 +20,9 @@ CREATE TABLE characters ( content_bypass INTEGER NOT NULL CHECK (content_bypass IN (0, 1)), equipped_title INTEGER NOT NULL CHECK (equipped_title BETWEEN 0 AND 65535), acquired_subclass_mask INTEGER NOT NULL, - next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295) + next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295), + appearance_header BLOB NOT NULL CHECK (length(appearance_header) = 36), + appearance_header_valid INTEGER NOT NULL CHECK (appearance_header_valid IN (0, 1)) ) STRICT; CREATE TABLE items ( diff --git a/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp b/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp index 69d371ba5..f189674b2 100644 --- a/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp +++ b/Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp @@ -42,9 +42,9 @@ constexpr std::size_t kCardFlagOffset = 8; static_cast(character.gender), static_cast(character.characterClass), }; - std::memcpy(identity.headerBlock.data(), - layout::kHeaderBlockBytes.data(), - layout::kHeaderBlockBytes.size()); + const auto& headerSource = + character.appearanceHeaderValid ? character.appearanceHeader : layout::kHeaderBlockBytes; + std::memcpy(identity.headerBlock.data(), headerSource.data(), headerSource.size()); output = {}; appearance::apply_sentinels(output); diff --git a/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp b/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp index 96d376dcf..b60c5c8f5 100644 --- a/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp +++ b/Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp @@ -271,9 +271,10 @@ bool encode(const state::CharacterState& state, object.identity.race = static_cast(state.race); object.identity.gender = static_cast(state.gender); object.identity.characterClass = static_cast(state.characterClass); - std::memcpy(object.customisationHeader.data(), - character_record::layout::kHeaderBlockBytes.data(), - character_record::layout::kHeaderBlockBytes.size()); + const auto& headerSource = state.appearanceHeaderValid + ? state.appearanceHeader + : character_record::layout::kHeaderBlockBytes; + std::memcpy(object.customisationHeader.data(), headerSource.data(), headerSource.size()); object.lastOrbitedDestination = state.lastOrbitedDestination; // No previous activity is tracked, and a zero here would name the orbit activity. object.previousActivityIndex = kAbsentActivityIndex; diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp index 8f38fa276..36ea68e55 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp +++ b/Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp @@ -12,8 +12,9 @@ constexpr std::uint8_t kCharacterSoidWidth = 64; } // namespace -/** Writes the echoed header, the shared status pair, the character id, and the trailer. */ +/** Writes the echoed header, the caller's status pair, the character id, and the trailer. */ bool encode_response(const Message& message, + const StatusResponse& status, std::uint64_t characterSoid, std::span output, std::size_t& written) noexcept { @@ -26,9 +27,6 @@ bool encode_response(const Message& message, message.transactionId); encoding::bits::Writer writer(output.subspan(kEnvelopeHeaderSize)); - // The reply names a character the roster already holds, so no revision publishes it. - StatusResponse status{}; - status.value = kNoFamily4Publication; // The id carries no presence bit or bias. bool encoded = status::write_fields(writer, ResponseShape::statusPair, status) && writer.write(characterSoid, kCharacterSoidWidth) diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_codec.h b/Sunrise/src/middleware/web_service/messages/opcode501_codec.h index c56858e38..ad1177abd 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode501_codec.h +++ b/Sunrise/src/middleware/web_service/messages/opcode501_codec.h @@ -13,13 +13,19 @@ inline constexpr std::uint16_t kOpcode = 501; /** * Encodes the create-character response: the status pair then the character object id. + * The status is the caller's, because this opcode has no single right answer. A refusal has to + * say so in the code -- the SOID field alone cannot, since zero is what a refusal would send and + * the Client reads the code, not the id -- and the value feeds the Family-4 version wait, which + * needs the revision a staged publication will carry, or kNoFamily4Publication when none will. * @param message Parsed request whose envelope fields are echoed. - * @param characterSoid Character object id; must be published in family 3. + * @param status Logical status pair: refusal code, and the revision the wait should expect. + * @param characterSoid Character object id; must be published in family 3. Zero on a refusal. * @param output Caller-owned svc-11 response-body storage. * @param written Receives encoded response-body bytes. * @return True when the fixed response fits the output buffer. */ [[nodiscard]] bool encode_response(const Message& message, + const StatusResponse& status, std::uint64_t characterSoid, std::span output, std::size_t& written) noexcept; diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp new file mode 100644 index 000000000..f4ddc48cd --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp @@ -0,0 +1,151 @@ +#include "opcode501_request_codec.h" + +#include "../../encoding/bit_reader.h" + +namespace sunrise::middleware::web_service::messages::opcode501 { +namespace { + +/** The identity triple and appearance header end here; the 64-byte name follows. */ +constexpr std::size_t kIdentityAndHeaderBits = 261; + +constexpr std::uint64_t kByteBias = 0x80U; +constexpr std::uint64_t kWordBias = 0x8000U; + +/** Writes the record's little-endian byte order, which is not the wire's bit order. */ +void put8(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept { + header[offset] = static_cast(value & 0xFFU); +} + +void put16(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept { + header[offset] = static_cast(value & 0xFFU); + header[offset + 1U] = static_cast((value >> 8U) & 0xFFU); +} + +void put32(state::AppearanceHeader& header, std::size_t offset, std::uint64_t value) noexcept { + put16(header, offset, value & 0xFFFFU); + put16(header, offset + 2U, (value >> 16U) & 0xFFFFU); +} + +/** + * Reads one 1-bit presence flag followed by an 8-bit biased value. + * @return True when the field is present and readable. An absent field fails closed: nothing on + * the wire carries the value that was skipped, so there is nothing to fall back to. + */ +[[nodiscard]] bool read_optional_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept { + std::uint64_t present = 0; + if (!reader.read(1, present) || present == 0) { + return false; + } + std::uint64_t raw = 0; + if (!reader.read(8, raw)) { + return false; + } + value = static_cast(raw - kByteBias); + return true; +} + +/** Reads an 8-bit +0x80-biased header field straight into the record block. */ +[[nodiscard]] bool +copy_byte(encoding::bits::Reader& reader, state::AppearanceHeader& header, std::size_t offset) noexcept { + std::uint64_t raw = 0; + if (!reader.read(8, raw)) { + return false; + } + put8(header, offset, raw - kByteBias); + return true; +} + +/** Reads a 16-bit +0x8000-biased header field straight into the record block. */ +[[nodiscard]] bool +copy_word(encoding::bits::Reader& reader, state::AppearanceHeader& header, std::size_t offset) noexcept { + std::uint64_t raw = 0; + if (!reader.read(16, raw)) { + return false; + } + put16(header, offset, raw - kWordBias); + return true; +} + +/** + * Byte offsets of every appearance-header field inside the 36-byte record block. The gaps at 5, + * 26 and 33 are the struct's own alignment padding and stay zero. + */ +constexpr std::size_t kOffsetF0 = 0; +constexpr std::size_t kOffsetF2 = 2; +constexpr std::size_t kOffsetF3 = 3; +constexpr std::size_t kOffsetF4 = 4; +constexpr std::size_t kOffsetF6 = 6; +constexpr std::size_t kOffsetF8 = 8; +constexpr std::size_t kOffsetF10 = 10; +constexpr std::size_t kOffsetF12 = 12; +constexpr std::size_t kOffsetF18 = 18; +constexpr std::size_t kOffsetF24 = 24; +constexpr std::size_t kOffsetF28 = 28; +constexpr std::size_t kOffsetF32 = 32; + +/** Both triples of 16-bit customization indices hold 3 entries. */ +constexpr std::size_t kIndexTripleCount = 3; +/** Each entry in those triples is 2 bytes wide in the record block. */ +constexpr std::size_t kIndexTripleStride = 2; + +/** The record requires 1 in the trailing field; the request always carries 0 there. */ +constexpr std::uint64_t kRecordTrailingField = 1; + +/** Highest valid race, gender and class wire values, matching the state enums. */ +constexpr std::uint8_t kMaxRace = 2; +constexpr std::uint8_t kMaxGender = 1; +constexpr std::uint8_t kMaxClass = 2; + +} // namespace + +bool parse_request(const Message& message, Request& request) noexcept { + request = {}; + if (message.opcode != kOpcode || message.payload.size() * 8U < kIdentityAndHeaderBits) { + return false; + } + encoding::bits::Reader reader(message.payload); + if (!read_optional_byte(reader, request.race) || !read_optional_byte(reader, request.gender) + || !read_optional_byte(reader, request.characterClass)) { + return false; + } + if (request.race > kMaxRace || request.gender > kMaxGender + || request.characterClass > kMaxClass) { + return false; + } + + auto& header = request.appearanceHeader; + if (!copy_word(reader, header, kOffsetF0) || !copy_byte(reader, header, kOffsetF2) + || !copy_byte(reader, header, kOffsetF3) || !copy_byte(reader, header, kOffsetF4) + || !copy_word(reader, header, kOffsetF6) || !copy_word(reader, header, kOffsetF8) + || !copy_word(reader, header, kOffsetF10)) { + return false; + } + for (std::size_t index = 0; index < kIndexTripleCount; ++index) { + if (!copy_word(reader, header, kOffsetF12 + index * kIndexTripleStride)) { + return false; + } + } + for (std::size_t index = 0; index < kIndexTripleCount; ++index) { + if (!copy_word(reader, header, kOffsetF18 + index * kIndexTripleStride)) { + return false; + } + } + if (!copy_word(reader, header, kOffsetF24)) { + return false; + } + std::uint64_t emptyHashSentinel = 0; + if (!reader.read(32, emptyHashSentinel)) { + return false; + } + put32(header, kOffsetF28, emptyHashSentinel); + + // Read but not copied: the request's own value is 0 and the record rejects it. + std::uint64_t requestTrailingField = 0; + if (!reader.read(2, requestTrailingField)) { + return false; + } + put8(header, kOffsetF32, kRecordTrailingField); + return true; +} + +} // namespace sunrise::middleware::web_service::messages::opcode501 diff --git a/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h new file mode 100644 index 000000000..53bfa3731 --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include "../../../state/account/account_state.h" +#include "../web_service_envelope.h" +#include "opcode501_codec.h" + +namespace sunrise::middleware::web_service::messages::opcode501 { + +/** The create-character request: the identity triple plus the authored appearance header. */ +struct Request { + std::uint8_t characterClass{}; + std::uint8_t gender{}; + std::uint8_t race{}; + /** + * The appearance header in record byte order, ready to memcpy into either character + * encoder's block. Its values index per-race customization tables, so it is only + * meaningful beside the `race` it arrived with. + */ + state::AppearanceHeader appearanceHeader{}; +}; + +/** + * Parses the create-character request body. + * + * The payload is a big-endian bitstream, not a byte-aligned struct: every field is written at + * whatever bit offset the previous one ended on. Wire order is depth-first over + * `{ int8 triple[3]; CharHeader header; char name[64]; }`, where `triple` is race, gender and + * class in that order. Each `triple` entry is a 1-bit presence flag followed by 8 value bits + * biased by +0x80; the header's fields are 8, 16 or 32 bits biased by +0x80, +0x8000 or not at + * all; and the 64-byte name begins at bit 261. This parser consumes the first 261 bits and + * ignores the name, which every observed capture leaves as the placeholder "Player". + * + * The header is emitted in the record's own little-endian struct layout rather than as parsed + * fields, since both character encoders want it as an opaque block. Its last field is the one + * value that is not copied through: the request carries 0 where the record requires 1. + * + * @param message Parsed Web Service envelope. + * @param request Receives the parsed creation fields. + * @return True when the payload holds all 261 bits, every presence flag is set, and race, + * gender and class are all in range. + */ +[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept; + +} // namespace sunrise::middleware::web_service::messages::opcode501 diff --git a/Sunrise/src/middleware/web_service/messages/opcode502.cpp b/Sunrise/src/middleware/web_service/messages/opcode502.cpp new file mode 100644 index 000000000..e7b211074 --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode502.cpp @@ -0,0 +1,22 @@ +#include "opcode502.h" + +#include "../../encoding/bit_reader.h" + +namespace sunrise::middleware::web_service::messages::opcode502 { +namespace { + +constexpr std::uint8_t kCharacterSoidWidth = 64; +constexpr std::size_t kMinimumPayloadSize = 8; + +} // namespace + +bool parse_request(const Message& message, Request& request) noexcept { + request = {}; + if (message.opcode != kOpcode || message.payload.size() < kMinimumPayloadSize) { + return false; + } + encoding::bits::Reader reader(message.payload); + return reader.read(kCharacterSoidWidth, request.characterSoid); +} + +} // namespace sunrise::middleware::web_service::messages::opcode502 diff --git a/Sunrise/src/middleware/web_service/messages/opcode502.h b/Sunrise/src/middleware/web_service/messages/opcode502.h new file mode 100644 index 000000000..3db44ea3d --- /dev/null +++ b/Sunrise/src/middleware/web_service/messages/opcode502.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "../web_service_envelope.h" + +namespace sunrise::middleware::web_service::messages::opcode502 { + +/** Web Service opcode for the delete-character request. */ +inline constexpr std::uint16_t kOpcode = 502; + +/** The targeted character. */ +struct Request { + std::uint64_t characterSoid{}; +}; + +/** + * Parses the bare 64-bit targeted-character id. + * @param message Parsed Web Service envelope. + * @param request Receives the targeted character id. + * @return True when the whole id is present. + */ +[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept; + +} // namespace sunrise::middleware::web_service::messages::opcode502 diff --git a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp index 80baf576e..4cddf9f87 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -12,6 +12,8 @@ #include "../../../../middleware/bap/family_unsubscription.h" #include "../../../../middleware/bap/user_message/user_message_response.h" #include "../../../../middleware/encoding/byte_order.h" +#include "../../../../middleware/web_service/messages/opcode501_request_codec.h" +#include "../../../../middleware/web_service/messages/opcode502.h" #include "../../../../middleware/web_service/messages/opcode505/opcode505_codec.h" #include "../../../../state/activity/membership/activity_membership_query.h" #include "../../../../state/runtime/runtime.h" @@ -204,6 +206,108 @@ bool process(const ServiceRoute& route, outcome.hasChangeCharacter = true; return true; } + // Character deletion. A refusal still answers with the status pair: the roster screen + // waits on the echoed transaction id, so a missing reply hangs it for the rest of the run. + if (middleware::web_service::parse_request(requestBody, message) + && message.opcode == middleware::web_service::messages::opcode502::kOpcode) { + const auto refuse = [&]() noexcept { + middleware::web_service::StatusResponse status{}; + status.code = middleware::web_service::kRefusedStatusCode; + // A refused delete stages nothing, so the version wait must be told not to wait. + status.value = middleware::web_service::kNoFamily4Publication; + return middleware::web_service::encode_response( + message, + middleware::web_service::ResponseShape::statusPair, + status, + output, + written); + }; + middleware::web_service::messages::opcode502::Request deletion{}; + if (!middleware::web_service::messages::opcode502::parse_request(message, deletion) + || deletion.characterSoid == 0 + || !state::delete_character(deletion.characterSoid)) { + core::log::write(core::log::Channel::server, + core::log::Level::warn, + "ev=ws502 stage=delete result=fail"); + return refuse(); + } + // The account is already changed and written (delete_character persists through the + // investment store internally), but the caller can still drop the frame afterwards -- + // a stale entity lease, a refused commit, or a response that does not fit -- and the + // outcome flag at the end of this file only lands on the handled path. Without this + // the character is gone from disk while the client keeps a roster that names it. + sunrise::server::bap::arm_account_resync_everywhere(); + // The roster shrank, and the client is still on the character-select step. Nothing + // here may move the selection: a selected character is a pick, and a pick is what + // leaves the screen for orbit, so the delete would skip the roster it just changed. + // The account object the armed resync re-encodes already carries the new roster count, + // so the reply only has to name the revision that resync will publish -- the same + // "name the following resync" contract the claim path below uses -- and the step waits + // on it instead of walking on. Left at its default the field reads as revision zero, + // which the wait blocks on, and the roster screen never finishes the delete it asked + // for. + std::int32_t deletionVersion = middleware::web_service::kNoFamily4Publication; + if (queuezState.family4Active + && queuezState.family4Version != (std::numeric_limits::max)()) { + deletionVersion = queuezState.family4Version + 1; + } + core::log::write( + core::log::Channel::server, core::log::Level::info, "ev=ws502 stage=delete result=ok"); + middleware::web_service::StatusResponse deletionStatus{}; + deletionStatus.value = deletionVersion; + return middleware::web_service::encode_response( + message, + middleware::web_service::ResponseShape::statusPair, + deletionStatus, + output, + written); + } + // Character creation. Both outcomes answer in opcode 501's own response shape, which + // carries the new SOID: the generic status pair omits that field and the Client fatally + // disconnects trying to decode it. + if (middleware::web_service::parse_request(requestBody, message) + && message.opcode == middleware::web_service::messages::opcode501::kOpcode) { + middleware::web_service::StatusResponse creationRefusal{}; + creationRefusal.code = middleware::web_service::kRefusedStatusCode; + creationRefusal.value = middleware::web_service::kNoFamily4Publication; + middleware::web_service::messages::opcode501::Request creation{}; + std::uint64_t characterSoid = 0; + if (!middleware::web_service::messages::opcode501::parse_request(message, creation) + || !state::create_character(creation.characterClass, + creation.gender, + creation.race, + creation.appearanceHeader, + characterSoid)) { + core::log::write(core::log::Channel::server, + core::log::Level::warn, + "ev=ws501 stage=create result=fail"); + return middleware::web_service::messages::opcode501::encode_response( + message, creationRefusal, 0, output, written); + } + // As in the delete branch: the account is already changed and written, but the caller + // can still drop the frame, and the outcome flag only lands on the handled path. + sunrise::server::bap::arm_account_resync_everywhere(); + // The new character joins the roster unselected (see create_character), and the client + // is still on the character-select step. Selecting it here would be a pick the player + // never made, and a pick is what leaves the step for orbit, so creation would skip the + // roster it just changed. The armed resync re-encodes the account object with the new + // roster count, so the reply only names the revision that resync will publish and the + // step waits on it. A change-character patch cannot do this: it refuses unless family + // three is in its normal phase, and the creation flow leaves it outside that phase, so + // the client would wait forever on a publication that was never staged. + std::int32_t creationVersion = middleware::web_service::kNoFamily4Publication; + if (queuezState.family4Active + && queuezState.family4Version != (std::numeric_limits::max)()) { + creationVersion = queuezState.family4Version + 1; + } + core::log::write(core::log::Channel::server, + core::log::Level::info, + "ev=ws501 stage=create result=ok"); + middleware::web_service::StatusResponse creationStatus{}; + creationStatus.value = creationVersion; + return middleware::web_service::messages::opcode501::encode_response( + message, creationStatus, characterSoid, output, written); + } state::investment::store::Transaction investmentTransaction; if (!investmentTransaction.ready()) { return false; diff --git a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp index cab7c9fe2..f667f73f4 100644 --- a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp +++ b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp @@ -386,7 +386,11 @@ bool append_account_resync_appearance_notification( const state::AccountState account = state::account_snapshot(); const std::uint64_t selected = state::account::selected_character_soid(account); if (selected == 0) { - return false; + // The appearance record belongs to the character the player picked. Before a pick there is + // none, and the account change that armed this resync already went out in the Family-4 frame + // ahead of it. Refusing would report a failure that frame covered and leave the retry lane + // re-running on a screen whose whole purpose is to wait for that pick. + return true; } if (before.family0Character != selected) { return append_banner_move_notification( @@ -428,6 +432,14 @@ bool append_account_resync_roster_notification(Scratch& scratch, } const state::AccountState account = state::account_snapshot(); const std::uint64_t selected = state::account::selected_character_soid(account); + if (selected == 0) { + // The character record and account roster this lane carries describe the picked character. + // Before a pick there is none to describe, and the account change that armed this resync + // already went out in the Family-4 frame ahead of it. Refusing would report a failure that + // frame covered and leave the retry lane re-running on a screen that waits for the pick + // which would end it. + return true; + } std::size_t characterIndex = account.characterCount; for (std::size_t index = 0; index < account.characterCount; ++index) { if (account.characters[index].soid == selected) { @@ -437,7 +449,7 @@ bool append_account_resync_roster_notification(Scratch& scratch, } queuez::RosterAppearanceRefresh refresh{}; snapshot::Prepared prepared{}; - if (selected == 0 || characterIndex >= account.characterCount + if (characterIndex >= account.characterCount || !queuez::stage_roster_appearance_refresh(before, selected, includeRoster, refresh) || !snapshot::prepare_roster_appearance_refresh( scratch, refresh, account.characters[characterIndex], characterIndex, prepared) diff --git a/Sunrise/src/server/web_service/web_service_runtime.cpp b/Sunrise/src/server/web_service/web_service_runtime.cpp index 104aea162..05fa0f487 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.cpp +++ b/Sunrise/src/server/web_service/web_service_runtime.cpp @@ -354,8 +354,11 @@ bool consume(std::span request, // Returns a SOID family three already publishes. The request body is not parsed. const std::uint64_t characterSoid = state::account::selected_character_soid(state::account_snapshot()); + // The reply names a character the roster already holds, so no revision publishes it. + middleware::web_service::StatusResponse status{}; + status.value = middleware::web_service::kNoFamily4Publication; return middleware::web_service::messages::opcode501::encode_response( - message, characterSoid, response, written) + message, status, characterSoid, response, written) || encode_echo(message, response, written); } diff --git a/Sunrise/src/state/account/account_state.h b/Sunrise/src/state/account/account_state.h index b76d74f94..c60063547 100644 --- a/Sunrise/src/state/account/account_state.h +++ b/Sunrise/src/state/account/account_state.h @@ -11,6 +11,10 @@ namespace sunrise::state { /** One account can own at most the 3 playable character slots. */ inline constexpr std::size_t kCharacterCapacity = 3; +/** The character record's own appearance-header block size, record byte order. */ +inline constexpr std::size_t kAppearanceHeaderSize = 36; +/** Opaque authored appearance header, ready to memcpy into either character encoder's block. */ +using AppearanceHeader = std::array; /** A server-authored dismantle policy: a few rows per rarity and gear class. */ inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32; /** Native sentinel used when a character has no title equipped. */ @@ -165,6 +169,11 @@ struct CharacterState { account::inventory::CharacterStacks stacks; /** Next row generation; equip transactions consume two values for the two moved items. */ std::uint32_t nextInventorySerial{}; + /** Authored appearance header captured at creation. Meaningless without its own `race`. */ + AppearanceHeader appearanceHeader{}; + /** False for a character predating appearance capture; both encoders fall back to the + * fixed default header when this is unset. */ + bool appearanceHeaderValid{}; }; /** Call-local account snapshot shared by backend object families. */ diff --git a/Sunrise/src/state/investment/investment_account_store.cpp b/Sunrise/src/state/investment/investment_account_store.cpp index 1f672baea..5922e0421 100644 --- a/Sunrise/src/state/investment/investment_account_store.cpp +++ b/Sunrise/src/state/investment/investment_account_store.cpp @@ -22,7 +22,9 @@ bool read_characters(AccountState& output) noexcept { character.contentBypass, character.equippedTitleRecordIndex, character.acquiredSubclassAbilityMask, - character.nextInventorySerial) + character.nextInventorySerial, + character.appearanceHeader, + character.appearanceHeaderValid) || slot != output.characterCount || slot >= output.characters.size()) { return false; } @@ -60,7 +62,7 @@ bool read_rewards(AccountState& output) noexcept { /** Characters are saved before their items to satisfy foreign keys. */ bool write_characters(const AccountState& value) noexcept { - Statement rows("INSERT INTO characters VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"); + Statement rows("INSERT INTO characters VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"); for (std::size_t slot = 0; slot < value.characterCount; ++slot) { const auto& character = value.characters[slot]; if (!rows.write(slot, @@ -75,7 +77,9 @@ bool write_characters(const AccountState& value) noexcept { character.contentBypass, character.equippedTitleRecordIndex, character.acquiredSubclassAbilityMask, - character.nextInventorySerial)) { + character.nextInventorySerial, + character.appearanceHeader, + character.appearanceHeaderValid)) { return false; } } diff --git a/Sunrise/src/state/investment/investment_database.cpp b/Sunrise/src/state/investment/investment_database.cpp index bce004936..0c0848fcf 100644 --- a/Sunrise/src/state/investment/investment_database.cpp +++ b/Sunrise/src/state/investment/investment_database.cpp @@ -119,13 +119,15 @@ bool open(std::string_view path, && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && transaction.commit(); } else if (ready) { - // Version 2 adds account preferences and per-item seen state. - constexpr int kSchemaVersion = 2; + // Version 3 adds the per-character appearance header. Version 2 added account + // preferences and per-item seen state. + constexpr int kSchemaVersion = 3; constexpr int kApplicationId = 1397902921; int application = 0; Statement query("PRAGMA application_id"); - ready = (version == 1 || version == kSchemaVersion) && query.step() == SQLITE_ROW - && query.column(0, application) && application == kApplicationId; + ready = (version == 1 || version == 2 || version == kSchemaVersion) + && query.step() == SQLITE_ROW && query.column(0, application) + && application == kApplicationId; } if (ready && version == 1) { Transaction transaction; @@ -137,6 +139,25 @@ bool open(std::string_view path, "IN(0,1));") && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && execute("PRAGMA user_version=2") && transaction.commit(); + if (ready) { + version = 2; + } + } + if (ready && version == 2) { + Transaction transaction; + // A character predating this column has no captured header; the zero blob is never + // read, since appearance_header_valid stays 0 and both encoders fall back to the fixed + // default header for it. + ready = transaction.ready() + // SQLite's ADD COLUMN refuses a non-constant default (zeroblob(36) included), + // so the fill value has to be a literal blob, not a function call. + && execute( + "ALTER TABLE characters ADD COLUMN appearance_header BLOB NOT NULL " + "DEFAULT (X'000000000000000000000000000000000000000000000000000000000000" + "000000000000') CHECK (length(appearance_header) = 36);" + "ALTER TABLE characters ADD COLUMN appearance_header_valid INTEGER " + "NOT NULL DEFAULT 0 CHECK (appearance_header_valid IN (0, 1));") + && execute("PRAGMA user_version=3") && transaction.commit(); } if (!ready) { shutdown(); diff --git a/Sunrise/src/state/investment/store_internal.h b/Sunrise/src/state/investment/store_internal.h index 49178575a..680313ce7 100644 --- a/Sunrise/src/state/investment/store_internal.h +++ b/Sunrise/src/state/investment/store_internal.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #include #include @@ -12,6 +14,11 @@ namespace sunrise::state::investment::store { +/** True for a fixed-size byte array, the only shape this store binds as a BLOB. */ +template inline constexpr bool kIsByteArray = false; +template +inline constexpr bool kIsByteArray> = true; + extern sqlite3* g_database; extern std::recursive_mutex g_mutex; extern std::uint64_t g_failureSerial; @@ -61,7 +68,14 @@ class Statement { if (statement_ == nullptr) { return false; } - if constexpr (std::is_enum_v) { + if constexpr (kIsByteArray) { + if (sqlite3_column_type(statement_, index) != SQLITE_BLOB + || static_cast(sqlite3_column_bytes(statement_, index)) + != value.size()) { + return false; + } + std::memcpy(value.data(), sqlite3_column_blob(statement_, index), value.size()); + } else if constexpr (std::is_enum_v) { std::underlying_type_t raw{}; if (!column(index, raw)) { return false; @@ -113,8 +127,12 @@ class Statement { private: /** SQL integers preserve all 64 identity bits; text bindings copy their input. */ - template [[nodiscard]] bool bind(int index, T value) noexcept { - if constexpr (std::is_convertible_v) { + template [[nodiscard]] bool bind(int index, const T& value) noexcept { + if constexpr (kIsByteArray) { + return sqlite3_bind_blob64( + statement_, index, value.data(), value.size(), copy_text()) + == SQLITE_OK; + } else if constexpr (std::is_convertible_v) { const std::string_view textValue(value); return sqlite3_bind_text64(statement_, index, diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 76525e31d..729aeb017 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -475,6 +475,31 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept; */ [[nodiscard]] bool set_selected_character(std::uint64_t characterSoid) noexcept; +/** + * Creates a new character in an unused slot and persists the account. + * @param characterClass Character class (0=titan, 1=hunter, 2=warlock). + * @param gender Character gender (0=male, 1=female). + * @param race Character race (0=human, 1=awoken, 2=exo). + * @param appearanceHeader Authored appearance header captured from the creation request, in + * record byte order. Only meaningful beside the `race` it arrived with. + * @param characterSoid Receives the SOID of the newly created character. + * @return True when the character was created, the account still validates, and the write + * committed. + */ +[[nodiscard]] bool create_character(std::uint8_t characterClass, + std::uint8_t gender, + std::uint8_t race, + const AppearanceHeader& appearanceHeader, + std::uint64_t& characterSoid) noexcept; + +/** + * Deletes a character by SOID and persists the account. + * @param characterSoid SOID of the character to delete. + * @return True when the character was found, removed, the account still validates, and the + * write committed. + */ +[[nodiscard]] bool delete_character(std::uint64_t characterSoid) noexcept; + /** * Stores the selected character's equipped title row. The caller proves the record is a claimed * title; this only writes it. diff --git a/Sunrise/src/state/runtime/state_account_roster_runtime.cpp b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp new file mode 100644 index 000000000..1247c82c0 --- /dev/null +++ b/Sunrise/src/state/runtime/state_account_roster_runtime.cpp @@ -0,0 +1,316 @@ +/** Character creation and deletion logic. */ + +#include +#include + +#include "../../core/logging/log.h" +#include "../account/account_state.h" +#include "../account/inventory/inventory_state.h" +#include "../investment/store_internal.h" +#include "runtime.h" +#include "state_account_transaction_helpers.h" + +namespace sunrise::state { +namespace { + +namespace authored_inventory = account::inventory; + +/** One authored equipment slot this build seeds every newly created character with. */ +struct StarterItem { + authored_inventory::EquipmentSlot slot; + std::uint32_t definitionHash; + std::int32_t level; +}; + +/** + * The 10 equipment slots the real client draws identically regardless of class: weapons, and the + * cosmetic/utility slots (ghost, vehicle, ship, clan banner, emote, finisher, artifact). Hashes + * and levels are copied verbatim from `resources/database/investment_defaults.sql`'s seed rows + * for character slot 0, and cross-checked against the T4tsuk3i fork's per-class + * `default_settings.json` templates -- identical across all three classes there too. + */ +constexpr std::array kSharedStarterItems{{ + {authored_inventory::EquipmentSlot::kinetic, 3843477312U, 106}, + {authored_inventory::EquipmentSlot::energy, 1096206669U, 106}, + {authored_inventory::EquipmentSlot::heavy, 1864563948U, 106}, + {authored_inventory::EquipmentSlot::ghost, 4135938409U, 0}, + {authored_inventory::EquipmentSlot::vehicle, 3317837688U, 0}, + {authored_inventory::EquipmentSlot::ship, 292872938U, 0}, + {authored_inventory::EquipmentSlot::clanBanner, 1460578929U, 0}, + {authored_inventory::EquipmentSlot::emote, 2038017661U, 0}, + {authored_inventory::EquipmentSlot::finisher, 152583919U, 0}, + {authored_inventory::EquipmentSlot::artifact, 1631206822U, 0}, +}}; + +/** + * The 7 equipment slots the real client renders per class: armor silhouette, the class item, the + * subclass (and the abilities/animations it drives), and the class-flavored emblem variant. + * Equipping another class's items here is exactly the "Hunter wearing Titan gear and subclass" + * mixup this build must not produce. Indexed by `CharacterClass`'s own wire value (titan=0, + * hunter=1, warlock=2). Hashes copied from the T4tsuk3i fork's `default_settings.json`, which + * carries a real, in-game-verified template per class; cross-checked against this build's own + * default character (class hunter) for the slots the two sources share, which matched exactly. + */ +constexpr std::array, 3> kPerClassStarterItems{{ + // titan + {{ + {authored_inventory::EquipmentSlot::helmet, 0xECB479E4U, 106}, + {authored_inventory::EquipmentSlot::gauntlets, 0xE98EBABDU, 106}, + {authored_inventory::EquipmentSlot::chest, 0x542FC543U, 106}, + {authored_inventory::EquipmentSlot::legs, 0xD2F64E86U, 106}, + {authored_inventory::EquipmentSlot::classItem, 0x295F70BAU, 106}, + {authored_inventory::EquipmentSlot::subclass, 0xB920CE9AU, 0}, + {authored_inventory::EquipmentSlot::emblem, 0x71B4CC1BU, 0}, + }}, + // hunter + {{ + {authored_inventory::EquipmentSlot::helmet, 0xF2994B80U, 106}, + {authored_inventory::EquipmentSlot::gauntlets, 0x61868551U, 106}, + {authored_inventory::EquipmentSlot::chest, 0x6F0DBB07U, 106}, + {authored_inventory::EquipmentSlot::legs, 0x0B8E36D0U, 106}, + {authored_inventory::EquipmentSlot::classItem, 0xB578E716U, 106}, + {authored_inventory::EquipmentSlot::subclass, 0xD8B8D1FCU, 0}, + {authored_inventory::EquipmentSlot::emblem, 0x71B4CC1AU, 0}, + }}, + // warlock + {{ + {authored_inventory::EquipmentSlot::helmet, 0xEA042965U, 106}, + {authored_inventory::EquipmentSlot::gauntlets, 0x188C5834U, 106}, + {authored_inventory::EquipmentSlot::chest, 0xF8689C4CU, 106}, + {authored_inventory::EquipmentSlot::legs, 0x083E04B6U, 106}, + {authored_inventory::EquipmentSlot::classItem, 0x99446581U, 106}, + {authored_inventory::EquipmentSlot::subclass, 0xCF88FEA5U, 0}, + {authored_inventory::EquipmentSlot::emblem, 0x71B4CC19U, 0}, + }}, +}}; + +/** + * Equips the class-appropriate starter loadout, each item given a freshly allocated instance SOID + * instead of a fixed one: the same hashes are granted to every created character of a class, so a + * literal SOID would collide the moment more than one has ever carried this loadout. + * @param account In-out account; scanned for collision-free SOIDs as each item is assigned one. + * Must already count `character` towards `characterCount`, so the scan sees this + * character's own items as they are assigned, not just prior characters. + * @param character In-out character, already appended to account.characters and counted. Its + * `characterClass` selects which armor/class-item/subclass row is granted. + * @return False when a fresh SOID could not be allocated; the character is left without a + * loadout, exactly as it started, rather than half-equipped. + */ +[[nodiscard]] bool seed_starter_loadout(AccountState& account, CharacterState& character) noexcept { + const auto& perClass = + kPerClassStarterItems[static_cast(character.characterClass)]; + std::int32_t maxMutationSerial = -1; + std::int32_t serial = 0; + const auto place = [&](const StarterItem& starter) noexcept { + std::uint64_t freshSoid = 0; + if (!runtime::detail::next_item_instance_soid(account, freshSoid)) { + return false; + } + authored_inventory::Item item{}; + item.instanceSoid = freshSoid; + item.definitionHash = starter.definitionHash; + item.level = starter.level; + item.quantity = 1; + item.mutationSerial = serial; + item.sockets.policy = authored_inventory::SocketPolicy::nativeDefaults; + character.equipment.slots[static_cast(starter.slot)] = item; + maxMutationSerial = serial; + ++serial; + return true; + }; + for (const StarterItem& starter : kSharedStarterItems) { + if (!place(starter)) { + character.equipment = {}; + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=create_character stage=loadout result=fail reason=soid_exhausted"); + return false; + } + } + for (const StarterItem& starter : perClass) { + if (!place(starter)) { + character.equipment = {}; + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=create_character stage=loadout result=fail reason=soid_exhausted"); + return false; + } + } + character.nextInventorySerial = static_cast(maxMutationSerial + 1); + return true; +} + +} // namespace + +bool create_character(std::uint8_t characterClass, + std::uint8_t gender, + std::uint8_t race, + const AppearanceHeader& appearanceHeader, + std::uint64_t& characterSoid) noexcept { + characterSoid = 0; + if (characterClass > static_cast(CharacterClass::warlock) + || gender > static_cast(CharacterGender::female) + || race > static_cast(CharacterRace::exo)) { + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=create_character stage=range result=fail class=%u gender=%u race=%u", + static_cast(characterClass), + static_cast(gender), + static_cast(race)); + return false; + } + + investment::store::g_mutex.lock(); + AccountState candidate = investment::store::account(); + core::log::writef(core::log::Channel::state, + core::log::Level::info, + "ev=create_character stage=request class=%u gender=%u race=%u chars=%zu " + "cap=%zu primary=0x%016llX", + static_cast(characterClass), + static_cast(gender), + static_cast(race), + candidate.characterCount, + candidate.characters.size(), + static_cast(candidate.primarySoid)); + if (candidate.characterCount >= candidate.characters.size() || candidate.primarySoid == 0) { + investment::store::g_mutex.unlock(); + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=create_character stage=capacity_or_primary result=fail"); + return false; + } + + const std::size_t index = candidate.characterCount; + // The slot index cannot name the SOID: deletion keeps every survivor's SOID, so after + // deleting a middle character the next free index already belongs to a living one. The + // lowest unused offset is taken instead, which reuses a freed SOID without ever colliding. + for (std::uint64_t offset = 1U; offset <= candidate.characters.size(); ++offset) { + const std::uint64_t soidCandidate = candidate.primarySoid + offset; + bool taken = false; + for (std::size_t existing = 0; existing < candidate.characterCount; ++existing) { + if (candidate.characters[existing].soid == soidCandidate) { + taken = true; + break; + } + } + if (!taken) { + characterSoid = soidCandidate; + break; + } + } + if (characterSoid == 0) { + investment::store::g_mutex.unlock(); + core::log::write( + core::log::Channel::state, core::log::Level::warn, "ev=create_character stage=soid result=fail"); + return false; + } + + CharacterState& character = candidate.characters[index]; + character = {}; + character.soid = characterSoid; + character.race = static_cast(race); + character.gender = static_cast(gender); + character.characterClass = static_cast(characterClass); + // Captured from the creation request, so the character keeps the face the player built + // instead of the single fixed header every character used to share. + character.appearanceHeader = appearanceHeader; + character.appearanceHeaderValid = true; + + // The new character joins the roster unselected. Marking it selected here would be a pick + // the player never made, and a pick is what leaves the character-select step for orbit, so + // creation would skip the roster it just changed. Nothing is picked until the player picks. + ++candidate.characterCount; + + // Counted above so the fresh-SOID scan inside sees this character's own items as they are + // assigned, not just the characters that existed before it. Without a loadout the character + // has no equipped gear, and the Family-4 encoder refuses to publish a character with nothing + // equipped (an empty gear set has no light average to divide), so it would exist in the + // database but never actually reach the client. + if (!seed_starter_loadout(candidate, character)) { + investment::store::g_mutex.unlock(); + characterSoid = 0; + return false; + } + + if (!account::valid(candidate) || !investment::store::write_account(candidate)) { + investment::store::g_mutex.unlock(); + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=create_character stage=commit result=fail primary=0x%016llX chars=%zu", + static_cast(candidate.primarySoid), + candidate.characterCount); + characterSoid = 0; + return false; + } + for (std::size_t i = 0; i < candidate.characters.size(); ++i) { + investment::store::g_session.selected[i] = candidate.characters[i].selected; + } + investment::store::g_mutex.unlock(); + core::log::writef(core::log::Channel::state, + core::log::Level::info, + "ev=create_character stage=commit result=ok soid=0x%016llX chars=%zu", + static_cast(characterSoid), + candidate.characterCount); + return true; +} + +bool delete_character(std::uint64_t characterSoid) noexcept { + if (characterSoid == 0) { + return false; + } + + investment::store::g_mutex.lock(); + AccountState candidate = investment::store::account(); + if (candidate.characterCount == 0 || candidate.primarySoid == 0) { + investment::store::g_mutex.unlock(); + return false; + } + + std::size_t found = candidate.characterCount; + for (std::size_t i = 0; i < candidate.characterCount; ++i) { + if (candidate.characters[i].soid == characterSoid) { + found = i; + break; + } + } + if (found == candidate.characterCount) { + investment::store::g_mutex.unlock(); + return false; + } + + // Survivors keep the SOID they were created with. Rebasing them onto their new slot index + // silently renames living characters, which strands every object the client already holds + // under the old SOID -- its roster entry, its Family-4 body and its banner all keyed by it. + for (std::size_t i = found; i + 1 < candidate.characterCount; ++i) { + candidate.characters[i] = candidate.characters[i + 1U]; + } + --candidate.characterCount; + candidate.characters[candidate.characterCount] = {}; + + // Removing a character never creates a selection. The deleted character's own flag left with + // its slot, and handing the selection to a survivor would be a pick the player never made -- + // which is what leaves the character-select step for orbit, skipping the shrunken roster the + // delete just produced. The selection moves only when the player picks (opcodes 504 and 505). + + if (!account::valid(candidate) || !investment::store::write_account(candidate)) { + investment::store::g_mutex.unlock(); + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=delete_character stage=commit result=fail soid=0x%016llX", + static_cast(characterSoid)); + return false; + } + for (std::size_t i = 0; i < candidate.characters.size(); ++i) { + investment::store::g_session.selected[i] = + i < candidate.characterCount && candidate.characters[i].selected; + } + investment::store::g_mutex.unlock(); + core::log::writef(core::log::Channel::state, + core::log::Level::info, + "ev=delete_character stage=commit result=ok soid=0x%016llX chars=%zu", + static_cast(characterSoid), + candidate.characterCount); + return true; +} + +} // namespace sunrise::state