Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/ControlServer/PlayerSessionStore/PlayerSessionStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::mutex> 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<CharacterInfo> PlayerSessionStore::GetCharactersByUserId(int64_t user_id) {
std::lock_guard<std::mutex> lock(mutex_);
sqlite3* db = Database::GetConnection();
Expand Down
5 changes: 5 additions & 0 deletions src/ControlServer/PlayerSessionStore/PlayerSessionStore.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ class PlayerSessionStore {
uint32_t eye_mat_param_id = 0);
static std::vector<CharacterInfo> GetCharactersByUserId(int64_t user_id);
static std::optional<CharacterInfo> 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,
Expand Down
38 changes: 36 additions & 2 deletions src/ControlServer/TcpSession/TcpSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int64_t>(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<int64_t>(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();
Expand Down Expand Up @@ -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<uint8_t> 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<uint8_t> response;
Expand Down
2 changes: 2 additions & 0 deletions src/ControlServer/TcpSession/TcpSession.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ class TcpSession : public std::enable_shared_from_this<TcpSession> {

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();
Expand Down
101 changes: 65 additions & 36 deletions src/GameServer/Cosmetics/CosmeticEquip.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down