diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp index daf523b9..4e8bc6d5 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp @@ -453,6 +453,74 @@ int64_t PlayerSessionStore::InsertCharacter(int64_t user_id, uint32_t profile_id return newId; } +bool PlayerSessionStore::DeleteCharacter(int64_t character_id, int64_t user_id) { + std::lock_guard lock(mutex_); + sqlite3* db = Database::GetConnection(); + + // Ownership gate: only delete a character that belongs to this user. + { + sqlite3_stmt* own = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT 1 FROM ga_characters WHERE id = ? AND user_id = ?", + -1, &own, nullptr) != SQLITE_OK) { + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter own-check prepare failed: %s\n", + sqlite3_errmsg(db)); + return false; + } + sqlite3_bind_int64(own, 1, character_id); + sqlite3_bind_int64(own, 2, user_id); + const bool owned = (sqlite3_step(own) == SQLITE_ROW); + sqlite3_finalize(own); + if (!owned) { + Logger::Log("db", + "[PlayerSessionStore] DeleteCharacter refused: char=%lld not owned by user=%lld\n", + (long long)character_id, (long long)user_id); + return false; + } + } + + // Delete children before parent (correct whether or not FK enforcement is + // on). Inventory pool is account-scoped (user_id) and intentionally left + // alone. Match/stats history rows are kept for historical integrity. + sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr); + static const char* const kChildDeletes[] = { + "DELETE FROM ga_character_devices WHERE character_id = ?", + "DELETE FROM ga_character_skills WHERE character_id = ?", + "DELETE FROM ga_character_quests WHERE character_id = ?", + }; + for (const char* sql : kChildDeletes) { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, character_id); + sqlite3_step(st); + sqlite3_finalize(st); + } else { + // A missing optional table shouldn't abort the delete. + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter child delete skipped (%s): %s\n", + sql, sqlite3_errmsg(db)); + } + } + + bool removed = false; + { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, + "DELETE FROM ga_characters WHERE id = ? AND user_id = ?", + -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, character_id); + sqlite3_bind_int64(st, 2, user_id); + sqlite3_step(st); + sqlite3_finalize(st); + removed = (sqlite3_changes(db) > 0); + } + } + sqlite3_exec(db, removed ? "COMMIT" : "ROLLBACK", nullptr, nullptr, nullptr); + + Logger::Log("db", "[PlayerSessionStore] DeleteCharacter char=%lld user=%lld removed=%d\n", + (long long)character_id, (long long)user_id, (int)removed); + return removed; +} + std::vector PlayerSessionStore::GetCharactersByUserId(int64_t user_id) { std::lock_guard lock(mutex_); sqlite3* db = Database::GetConnection(); diff --git a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp index f64c0ac2..05e16969 100644 --- a/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp +++ b/src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp @@ -122,6 +122,11 @@ class PlayerSessionStore { uint32_t eye_mat_param_id = 0); static std::vector GetCharactersByUserId(int64_t user_id); static std::optional GetCharacterById(int64_t id); + // Deletes a character and its per-character rows (devices, skills, quests). + // Guarded by user_id so a client can only delete its own characters; the + // account-scoped ga_players_inventory pool is NOT touched (shared across + // characters). Returns true if a ga_characters row was actually removed. + static bool DeleteCharacter(int64_t character_id, int64_t user_id); static void SetSelectedCharacter(const std::string& guid, int64_t char_id, uint32_t profile_id); // Walk every profile's ClassLoadouts entry and INSERT any (user_id, diff --git a/src/ControlServer/TcpSession/TcpSession.cpp b/src/ControlServer/TcpSession/TcpSession.cpp index fdff88b0..3b7be192 100644 --- a/src/ControlServer/TcpSession/TcpSession.cpp +++ b/src/ControlServer/TcpSession/TcpSession.cpp @@ -1641,10 +1641,28 @@ void TcpSession::handle_packet(const uint8_t* data, size_t length) { wait_for_home_map_then_register(120); // 120 second timeout break; } - case GA_U::DELETE_CHARACTER: + case GA_U::DELETE_CHARACTER: { Logger::Log("tcp", "[%s] Received: DELETE_CHARACTER [0x%04X], item count: %d\n", Logger::GetTime(), packet_type, item_count); - // todo + PacketView pkt(data + 6, length - 6); + uint32_t charId = pkt.Read4B(GA_T::CHARACTER_ID).value_or(0); + + const bool ok = (charId != 0) && + PlayerSessionStore::DeleteCharacter(static_cast(charId), user_id_); + + // If the deleted character was the active selection, clear it so a + // later SELECT/register can't target a now-gone row. + if (ok && selected_character_id_ == static_cast(charId)) { + selected_character_id_ = 0; + selected_profile_id_ = 0; + } + + Logger::Log("tcp", "[%s] DELETE_CHARACTER: charId=%u user=%lld ok=%d\n", + Logger::GetTime(), charId, (long long)user_id_, (int)ok); + + send_delete_character_response(charId, ok ? 0 : 1); + send_character_list_response(); break; + } case GA_U::UPDATE_NEW_MAIL_COUNT: Logger::Log("tcp", "[%s] Received: UPDATE_NEW_MAIL_COUNT [0x%04X], item count: %d\n", Logger::GetTime(), packet_type, item_count); send_update_new_mail_count_response(); @@ -3157,6 +3175,22 @@ void TcpSession::send_add_player_character_response() send_response(response); } +void TcpSession::send_delete_character_response(uint32_t character_id, uint32_t error_code) +{ + std::vector response; + + uint16_t packet_type = GA_U::DELETE_CHARACTER; + uint16_t item_count = 2; + + append(response, packet_type & 0xFF, packet_type >> 8); + append(response, item_count & 0xFF, item_count >> 8); + + Write4B(response, GA_T::ERROR_CODE, error_code); + Write4B(response, GA_T::CHARACTER_ID, character_id); + + send_response(response); +} + void TcpSession::send_select_character_response() { std::vector response; diff --git a/src/ControlServer/TcpSession/TcpSession.hpp b/src/ControlServer/TcpSession/TcpSession.hpp index 7279a009..35d79f4f 100644 --- a/src/ControlServer/TcpSession/TcpSession.hpp +++ b/src/ControlServer/TcpSession/TcpSession.hpp @@ -567,6 +567,8 @@ class TcpSession : public std::enable_shared_from_this { void send_add_player_character_response(); + void send_delete_character_response(uint32_t character_id, uint32_t error_code); + void send_select_character_response(); void send_change_map_prep_response(); diff --git a/src/GameServer/Cosmetics/CosmeticEquip.cpp b/src/GameServer/Cosmetics/CosmeticEquip.cpp index c7363933..f1e515b3 100644 --- a/src/GameServer/Cosmetics/CosmeticEquip.cpp +++ b/src/GameServer/Cosmetics/CosmeticEquip.cpp @@ -477,6 +477,64 @@ bool ClearSlot(ATgPawn* Pawn, int64_t character_id, int item_profile_id, int slo return true; } +struct CharacterBaseState { + int headMesh = 1605; + int genderId = GA_G::GENDER_TYPE_ID_MALE; + uint32_t profileId = 680; + int hairMesh = 1757; +}; + +// Reads the per-character creation fields from ga_characters and decodes the +// morph blob into the pawn's replicated morph arrays. Returns the base state +// (head mesh, gender, profile, hair mesh) with defaults if the row is missing. +static CharacterBaseState ReadCharacterBaseState(int64_t character_id, ATgPawn_Character* charPawn) { + CharacterBaseState state; + sqlite3* db = Database::GetConnection(); + if (!db) return state; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT head_asm_id, gender_type_value_id, profile_id, morph_data, hair_asm_id " + "FROM ga_characters WHERE id = ?", + -1, &stmt, nullptr) != SQLITE_OK) return state; + + enum Col { head_asm_id = 0, gender_type_value_id, profile_id, morph_data, hair_asm_id }; + sqlite3_bind_int64(stmt, 1, character_id); + if (sqlite3_step(stmt) == SQLITE_ROW) { + const int h = sqlite3_column_int(stmt, Col::head_asm_id); + const int g = sqlite3_column_int(stmt, Col::gender_type_value_id); + const int p = sqlite3_column_int(stmt, Col::profile_id); + const int hr = sqlite3_column_int(stmt, Col::hair_asm_id); + if (h > 0) state.headMesh = h; + if (g > 0) state.genderId = g; + if (p > 0) state.profileId = (uint32_t)p; + if (hr > 0) state.hairMesh = hr; + + struct MorphPair { uint32_t index; uint32_t weight; }; + static_assert(sizeof(MorphPair) == 8, "morph blob stride"); + + const auto* blob = (const uint8_t*)sqlite3_column_blob(stmt, Col::morph_data); + const int pairCount = sqlite3_column_bytes(stmt, Col::morph_data) / (int)sizeof(MorphPair); + int maxIdx = -1; + for (int i = 0; i < pairCount; ++i) { + MorphPair pair; + memcpy(&pair, blob + i * sizeof(MorphPair), sizeof(MorphPair)); + if (pair.index < 255u) { + charPawn->r_nMorphSettings[pair.index] = (int)pair.weight; + if ((int)pair.index > maxIdx) maxIdx = (int)pair.index; + } + } + if (maxIdx >= 0) { + charPawn->r_nMaxMorphIndexSentFromServer = maxIdx + 1; + Logger::Log("spawn-asm", + "LoadFromDB morph: char=%lld decoded %d pairs (maxIdx=%d)\n", + (long long)character_id, pairCount, maxIdx); + } + } + sqlite3_finalize(stmt); + return state; +} + // Baseline assembly used as the starting point before overlaying DB cosmetics. // Mirrors what SpawnPlayerCharacter used to write inline, except SuitMeshId // starts at -1 instead of the 1225 Medic Acolyte placeholder — the cosmetic @@ -519,42 +577,13 @@ void LoadFromDB(ATgPawn* Pawn, int64_t character_id, int item_profile_id) { auto* PRI = (ATgRepInfo_Player*)Pawn->PlayerReplicationInfo; item_profile_id = NormalizeItemProfileId(Pawn, item_profile_id); - // (0) Pull head_asm_id + gender from ga_characters. These are character - // state (picked at character creation), not cosmetic-equip state. Engine - // class defaults (TgPawn_Character.uc:1097 male / line 907-908 female via - // GetDefaultAssembly) are used as the fallback when the row is missing. - int headMesh = 1605; // male class default - int genderId = GA_G::GENDER_TYPE_ID_MALE; // 853 - uint32_t profileId = 680; // Assault class default - { - sqlite3* db = Database::GetConnection(); - if (db) { - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, - "SELECT head_asm_id, gender_type_value_id, profile_id " - "FROM ga_characters WHERE id = ?", - -1, &stmt, nullptr) == SQLITE_OK) { - sqlite3_bind_int64(stmt, 1, character_id); - if (sqlite3_step(stmt) == SQLITE_ROW) { - const int h = sqlite3_column_int(stmt, 0); - const int g = sqlite3_column_int(stmt, 1); - const int p = sqlite3_column_int(stmt, 2); - if (h > 0) headMesh = h; - if (g > 0) genderId = g; - if (p > 0) profileId = (uint32_t)p; - } - sqlite3_finalize(stmt); - } - } - } - // Hair pairs with gender. Both branches now use 1757 (NewHair1) — the - // male class default that was already in use. The original code wrote - // 0 for female, which crashes the client at 0x109d1f5b on the hair- - // component deref through 0xCCCCCCCC. This is the MINIMAL change off - // the pre-session baseline. Earlier attempts in this session widened - // the SELECT and added a SpawnPlayerCharacter pre-Fill — both regressed - // the inventory pipeline and have been reverted. - const int hairMesh = 1757; + // (0) Read per-character creation state (head, hair, gender, morph poses) + // from ga_characters and write morph arrays onto the pawn. + const CharacterBaseState base = ReadCharacterBaseState(character_id, charPawn); + const int headMesh = base.headMesh; + const int genderId = base.genderId; + const uint32_t profileId = base.profileId; + const int hairMesh = base.hairMesh; // (1) Initialize the baseline assembly. Owning this here (instead of in // SpawnPlayerCharacter) keeps the cosmetic state machine in one place and