Vanilla/Turtle WoW support: M2 loading, bone parsing, textures, auth

- Vanilla M2 bone struct (108 bytes) with 28-byte animation tracks
- Version-aware bone parsing (vanilla vs WotLK format detection)
- Fix CharSections.dbc field layout for vanilla (variation/color at 4-5)
- Remove broken CharSections.csv files (all fields marked as strings)
- Expansion data reload on profile switch (DBC cache clear, layout reload)
- Vanilla packet encryption (VanillaCrypt XOR-based header crypt)
- Extended character preview geoset range (0-99) for vanilla models
- DBC cache clear support in AssetManager
This commit is contained in:
Kelsi 2026-02-13 16:53:28 -08:00
parent 6729f66a37
commit 430c2bdcfa
34 changed files with 1066 additions and 24795 deletions

View file

@ -95,6 +95,7 @@ set(WOWEE_SOURCES
src/auth/big_num.cpp src/auth/big_num.cpp
src/auth/crypto.cpp src/auth/crypto.cpp
src/auth/rc4.cpp src/auth/rc4.cpp
src/auth/vanilla_crypt.cpp
# Game # Game
src/game/expansion_profile.cpp src/game/expansion_profile.cpp

File diff suppressed because it is too large Load diff

View file

@ -9,8 +9,8 @@
}, },
"CharSections": { "CharSections": {
"RaceID": 1, "SexID": 2, "BaseSection": 3, "RaceID": 1, "SexID": 2, "BaseSection": 3,
"Texture1": 4, "Texture2": 5, "Texture3": 6, "VariationIndex": 4, "ColorIndex": 5,
"VariationIndex": 8, "ColorIndex": 9 "Texture1": 6, "Texture2": 7, "Texture3": 8
}, },
"SpellIcon": { "ID": 0, "Path": 1 }, "SpellIcon": { "ID": 0, "Path": 1 },
"FactionTemplate": { "FactionTemplate": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,8 +9,8 @@
}, },
"CharSections": { "CharSections": {
"RaceID": 1, "SexID": 2, "BaseSection": 3, "RaceID": 1, "SexID": 2, "BaseSection": 3,
"Texture1": 4, "Texture2": 5, "Texture3": 6, "VariationIndex": 4, "ColorIndex": 5,
"VariationIndex": 8, "ColorIndex": 9 "Texture1": 6, "Texture2": 7, "Texture3": 8
}, },
"SpellIcon": { "ID": 0, "Path": 1 }, "SpellIcon": { "ID": 0, "Path": 1 },
"FactionTemplate": { "FactionTemplate": {

View file

@ -2,9 +2,11 @@
"id": "turtle", "id": "turtle",
"name": "Turtle WoW", "name": "Turtle WoW",
"shortName": "Turtle", "shortName": "Turtle",
"version": { "major": 1, "minor": 12, "patch": 1 }, "version": { "major": 1, "minor": 18, "patch": 0 },
"build": 5875, "build": 7234,
"worldBuild": 5875,
"protocolVersion": 3, "protocolVersion": 3,
"os": "OSX",
"locale": "enGB", "locale": "enGB",
"maxLevel": 60, "maxLevel": 60,
"races": [1, 2, 3, 4, 5, 6, 7, 8], "races": [1, 2, 3, 4, 5, 6, 7, 8],

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,8 @@ struct RealmListResponse {
// REALM_LIST response parser // REALM_LIST response parser
class RealmListResponseParser { class RealmListResponseParser {
public: public:
static bool parse(network::Packet& packet, RealmListResponse& response); // protocolVersion: 3 = vanilla (uint8 realmCount, uint32 icon), 8 = WotLK (uint16 realmCount, uint8 icon)
static bool parse(network::Packet& packet, RealmListResponse& response, uint8_t protocolVersion = 8);
}; };
} // namespace auth } // namespace auth

View file

@ -0,0 +1,58 @@
#pragma once
#include <cstdint>
#include <vector>
namespace wowee {
namespace auth {
/**
* Vanilla/TBC WoW Header Cipher
*
* Used for encrypting/decrypting World of Warcraft packet headers
* in vanilla (1.x) and TBC (2.x) clients. This is a simple XOR+addition
* chaining cipher that uses the raw 40-byte SRP session key directly.
*
* Algorithm (encrypt):
* encrypted = (plaintext ^ key[index]) + previousEncrypted
* index = (index + 1) % keyLen
*
* Algorithm (decrypt):
* plaintext = (encrypted - previousEncrypted) ^ key[index]
* index = (index + 1) % keyLen
*/
class VanillaCrypt {
public:
VanillaCrypt() = default;
~VanillaCrypt() = default;
/**
* Initialize the cipher with the raw session key
* @param sessionKey 40-byte session key from SRP auth
*/
void init(const std::vector<uint8_t>& sessionKey);
/**
* Encrypt outgoing header bytes (CMSG: 6 bytes)
* @param data Pointer to header data to encrypt in-place
* @param length Number of bytes to encrypt
*/
void encrypt(uint8_t* data, size_t length);
/**
* Decrypt incoming header bytes (SMSG: 4 bytes)
* @param data Pointer to header data to decrypt in-place
* @param length Number of bytes to decrypt
*/
void decrypt(uint8_t* data, size_t length);
private:
std::vector<uint8_t> key_;
uint8_t sendIndex_ = 0;
uint8_t sendPrev_ = 0;
uint8_t recvIndex_ = 0;
uint8_t recvPrev_ = 0;
};
} // namespace auth
} // namespace wowee

View file

@ -57,6 +57,7 @@ public:
game::ExpansionRegistry* getExpansionRegistry() { return expansionRegistry_.get(); } game::ExpansionRegistry* getExpansionRegistry() { return expansionRegistry_.get(); }
pipeline::DBCLayout* getDBCLayout() { return dbcLayout_.get(); } pipeline::DBCLayout* getDBCLayout() { return dbcLayout_.get(); }
pipeline::HDPackManager* getHDPackManager() { return hdPackManager_.get(); } pipeline::HDPackManager* getHDPackManager() { return hdPackManager_.get(); }
void reloadExpansionData(); // Reload DBC layouts, opcodes, etc. after expansion change
// Singleton access // Singleton access
static Application& getInstance() { return *instance; } static Application& getInstance() { return *instance; }

View file

@ -19,8 +19,9 @@ struct ExpansionProfile {
uint8_t majorVersion = 0; uint8_t majorVersion = 0;
uint8_t minorVersion = 0; uint8_t minorVersion = 0;
uint8_t patchVersion = 0; uint8_t patchVersion = 0;
uint16_t build = 0; uint16_t build = 0; // Realm build (sent in LOGON_CHALLENGE)
uint8_t protocolVersion = 0; // SRP auth protocol version byte uint16_t worldBuild = 0; // World build (sent in CMSG_AUTH_SESSION, defaults to build)
uint8_t protocolVersion = 0; // SRP auth protocol version byte
// Client header fields used in LOGON_CHALLENGE. // Client header fields used in LOGON_CHALLENGE.
// Defaults match a typical Windows x86 client. // Defaults match a typical Windows x86 client.
std::string game = "WoW"; std::string game = "WoW";

View file

@ -1214,6 +1214,19 @@ private:
std::unique_ptr<WardenCrypto> wardenCrypto_; std::unique_ptr<WardenCrypto> wardenCrypto_;
std::unique_ptr<WardenModuleManager> wardenModuleManager_; std::unique_ptr<WardenModuleManager> wardenModuleManager_;
// Warden module download state
enum class WardenState {
WAIT_MODULE_USE, // Waiting for first SMSG (MODULE_USE)
WAIT_MODULE_CACHE, // Sent MODULE_MISSING, receiving module chunks
WAIT_HASH_REQUEST, // Module received, waiting for HASH_REQUEST
WAIT_CHECKS, // Hash sent, waiting for check requests
};
WardenState wardenState_ = WardenState::WAIT_MODULE_USE;
std::vector<uint8_t> wardenModuleHash_; // 16 bytes MD5
std::vector<uint8_t> wardenModuleKey_; // 16 bytes RC4
uint32_t wardenModuleSize_ = 0;
std::vector<uint8_t> wardenModuleData_; // Downloaded module chunks
// ---- XP tracking ---- // ---- XP tracking ----
uint32_t playerXp_ = 0; uint32_t playerXp_ = 0;
uint32_t playerNextLevelXp_ = 0; uint32_t playerNextLevelXp_ = 0;

View file

@ -8,8 +8,9 @@ namespace wowee {
namespace game { namespace game {
/** /**
* Warden anti-cheat crypto handler for WoW 3.3.5a * Warden anti-cheat crypto handler.
* Handles RC4 encryption/decryption of Warden packets * Derives RC4 keys from the 40-byte SRP session key using SHA1Randx,
* then encrypts/decrypts Warden packet payloads.
*/ */
class WardenCrypto { class WardenCrypto {
public: public:
@ -17,45 +18,40 @@ public:
~WardenCrypto(); ~WardenCrypto();
/** /**
* Initialize Warden crypto with module seed * Initialize Warden crypto from the 40-byte SRP session key.
* @param moduleData The SMSG_WARDEN_DATA payload containing seed * Derives encrypt (client->server) and decrypt (server->client) RC4 keys
* @return true if initialization succeeded * using the SHA1Randx / WardenKeyGenerator algorithm.
*/ */
bool initialize(const std::vector<uint8_t>& moduleData); bool initFromSessionKey(const std::vector<uint8_t>& sessionKey);
/** /**
* Decrypt an incoming Warden packet * Replace RC4 keys (called after module hash challenge succeeds).
* @param data Encrypted data from server * @param newEncryptKey 16-byte key for encrypting outgoing packets
* @return Decrypted data * @param newDecryptKey 16-byte key for decrypting incoming packets
*/ */
void replaceKeys(const std::vector<uint8_t>& newEncryptKey,
const std::vector<uint8_t>& newDecryptKey);
/** Decrypt an incoming SMSG_WARDEN_DATA payload. */
std::vector<uint8_t> decrypt(const std::vector<uint8_t>& data); std::vector<uint8_t> decrypt(const std::vector<uint8_t>& data);
/** /** Encrypt an outgoing CMSG_WARDEN_DATA payload. */
* Encrypt an outgoing Warden response
* @param data Plaintext response data
* @return Encrypted data
*/
std::vector<uint8_t> encrypt(const std::vector<uint8_t>& data); std::vector<uint8_t> encrypt(const std::vector<uint8_t>& data);
/**
* Check if crypto has been initialized
*/
bool isInitialized() const { return initialized_; } bool isInitialized() const { return initialized_; }
private: private:
bool initialized_; bool initialized_;
std::vector<uint8_t> inputKey_;
std::vector<uint8_t> outputKey_;
// RC4 state for incoming packets // RC4 state for decrypting incoming packets (server->client)
std::vector<uint8_t> inputRC4State_; std::vector<uint8_t> decryptRC4State_;
uint8_t inputRC4_i_; uint8_t decryptRC4_i_;
uint8_t inputRC4_j_; uint8_t decryptRC4_j_;
// RC4 state for outgoing packets // RC4 state for encrypting outgoing packets (client->server)
std::vector<uint8_t> outputRC4State_; std::vector<uint8_t> encryptRC4State_;
uint8_t outputRC4_i_; uint8_t encryptRC4_i_;
uint8_t outputRC4_j_; uint8_t encryptRC4_j_;
void initRC4(const std::vector<uint8_t>& key, void initRC4(const std::vector<uint8_t>& key,
std::vector<uint8_t>& state, std::vector<uint8_t>& state,
@ -63,6 +59,14 @@ private:
void processRC4(const uint8_t* input, uint8_t* output, size_t length, void processRC4(const uint8_t* input, uint8_t* output, size_t length,
std::vector<uint8_t>& state, uint8_t& i, uint8_t& j); std::vector<uint8_t>& state, uint8_t& i, uint8_t& j);
/**
* SHA1Randx / WardenKeyGenerator: generates pseudo-random bytes from a seed.
* Used to derive the 16-byte encrypt and decrypt keys from the session key.
*/
static void sha1RandxGenerate(const std::vector<uint8_t>& seed,
uint8_t* outputEncryptKey,
uint8_t* outputDecryptKey);
}; };
} // namespace game } // namespace game

View file

@ -26,7 +26,7 @@ struct AuthChallengeData {
uint32_t serverSeed; // Random seed from server uint32_t serverSeed; // Random seed from server
// Note: 3.3.5a has additional data after this // Note: 3.3.5a has additional data after this
bool isValid() const { return unknown1 != 0; } bool isValid() const { return true; }
}; };
/** /**

View file

@ -4,6 +4,7 @@
#include "network/packet.hpp" #include "network/packet.hpp"
#include "network/net_platform.hpp" #include "network/net_platform.hpp"
#include "auth/rc4.hpp" #include "auth/rc4.hpp"
#include "auth/vanilla_crypt.hpp"
#include <functional> #include <functional>
#include <vector> #include <vector>
#include <cstdint> #include <cstdint>
@ -14,12 +15,13 @@ namespace network {
/** /**
* World Server Socket * World Server Socket
* *
* Handles WoW 3.3.5a world server protocol with RC4 header encryption. * Handles WoW world server protocol with header encryption.
* Supports both vanilla/TBC (XOR+addition cipher) and WotLK (RC4).
* *
* Key Differences from Auth Server: * Key Differences from Auth Server:
* - Outgoing: 6-byte header (2 bytes size + 4 bytes opcode, big-endian) * - Outgoing: 6-byte header (2 bytes size + 4 bytes opcode, big-endian)
* - Incoming: 4-byte header (2 bytes size + 2 bytes opcode) * - Incoming: 4-byte header (2 bytes size + 2 bytes opcode)
* - Headers are RC4-encrypted after CMSG_AUTH_SESSION * - Headers are encrypted after CMSG_AUTH_SESSION
* - Packet bodies remain unencrypted * - Packet bodies remain unencrypted
* - Size field includes opcode bytes (payloadLen = size - 2) * - Size field includes opcode bytes (payloadLen = size - 2)
*/ */
@ -56,12 +58,13 @@ public:
} }
/** /**
* Initialize RC4 encryption for packet headers * Initialize header encryption for packet headers
* Must be called after CMSG_AUTH_SESSION before further communication * Must be called after CMSG_AUTH_SESSION before further communication
* *
* @param sessionKey 40-byte session key from auth server * @param sessionKey 40-byte session key from auth server
* @param build Client build number (determines cipher: <= 8606 = XOR, > 8606 = RC4)
*/ */
void initEncryption(const std::vector<uint8_t>& sessionKey); void initEncryption(const std::vector<uint8_t>& sessionKey, uint32_t build = 12340);
/** /**
* Check if header encryption is enabled * Check if header encryption is enabled
@ -77,10 +80,14 @@ private:
socket_t sockfd = INVALID_SOCK; socket_t sockfd = INVALID_SOCK;
bool connected = false; bool connected = false;
bool encryptionEnabled = false; bool encryptionEnabled = false;
bool useVanillaCrypt = false; // true = XOR cipher, false = RC4
// RC4 ciphers for header encryption/decryption // WotLK RC4 ciphers for header encryption/decryption
auth::RC4 encryptCipher; // For outgoing headers auth::RC4 encryptCipher;
auth::RC4 decryptCipher; // For incoming headers auth::RC4 decryptCipher;
// Vanilla/TBC XOR+addition cipher
auth::VanillaCrypt vanillaCrypt;
// Receive buffer // Receive buffer
std::vector<uint8_t> receiveBuffer; std::vector<uint8_t> receiveBuffer;

View file

@ -131,6 +131,11 @@ public:
*/ */
void clearCache(); void clearCache();
/**
* Clear only DBC cache (forces reload on next loadDBC call)
*/
void clearDBCCache();
private: private:
bool initialized = false; bool initialized = false;
std::string dataPath; std::string dataPath;

View file

@ -369,7 +369,7 @@ void AuthHandler::handleRealmListResponse(network::Packet& packet) {
LOG_DEBUG("Handling REALM_LIST response"); LOG_DEBUG("Handling REALM_LIST response");
RealmListResponse response; RealmListResponse response;
if (!RealmListResponseParser::parse(packet, response)) { if (!RealmListResponseParser::parse(packet, response, clientInfo.protocolVersion)) {
LOG_ERROR("Failed to parse REALM_LIST response"); LOG_ERROR("Failed to parse REALM_LIST response");
return; return;
} }

View file

@ -25,30 +25,26 @@ network::Packet LogonChallengePacket::build(const std::string& account, const Cl
// Payload size // Payload size
packet.writeUInt16(payloadSize); packet.writeUInt16(payloadSize);
// Write a 4-byte ASCII field (FourCC-ish): bytes are sent in-order and null-padded. // Write a 4-byte FourCC field with reversed string characters.
// Auth servers expect literal "x86\\0", "Win\\0", "enUS"/"enGB", etc. // The auth server reads these as a C-string (stops at first null), then
// reverses the string. So we must send the characters reversed and
// null-padded on the right. E.g. "Win" → bytes ['n','i','W',0x00].
// Server reads "niW", reverses → "Win", stores "Win".
auto writeFourCC = [&packet](const std::string& str) { auto writeFourCC = [&packet](const std::string& str) {
uint8_t buf[4] = {0, 0, 0, 0}; uint8_t buf[4] = {0, 0, 0, 0};
size_t len = std::min<size_t>(4, str.length()); size_t len = std::min<size_t>(4, str.length());
// Write string characters in reverse order, then null-pad
for (size_t i = 0; i < len; ++i) { for (size_t i = 0; i < len; ++i) {
buf[i] = static_cast<uint8_t>(str[i]); buf[i] = static_cast<uint8_t>(str[len - 1 - i]);
} }
for (int i = 0; i < 4; ++i) { for (int i = 0; i < 4; ++i) {
packet.writeUInt8(buf[i]); packet.writeUInt8(buf[i]);
} }
}; };
// Game name (4 bytes, big-endian FourCC — NOT reversed) // Game name (4 bytes, reversed FourCC)
// "WoW" → "WoW\0" on the wire // "WoW" → bytes ['W','o','W',0x00] on the wire
{ writeFourCC(info.game);
uint8_t buf[4] = {0, 0, 0, 0};
for (size_t i = 0; i < std::min<size_t>(4, info.game.length()); ++i) {
buf[i] = static_cast<uint8_t>(info.game[i]);
}
for (int i = 0; i < 4; ++i) {
packet.writeUInt8(buf[i]);
}
}
// Version (3 bytes) // Version (3 bytes)
packet.writeUInt8(info.majorVersion); packet.writeUInt8(info.majorVersion);
@ -189,6 +185,7 @@ network::Packet LogonProofPacket::buildLegacy(const std::vector<uint8_t>& A,
for (int i = 0; i < 20; ++i) packet.writeUInt8(0); // CRC hash for (int i = 0; i < 20; ++i) packet.writeUInt8(0); // CRC hash
} }
packet.writeUInt8(0); // number of keys packet.writeUInt8(0); // number of keys
packet.writeUInt8(0); // security flags
return packet; return packet;
} }
@ -292,8 +289,9 @@ network::Packet RealmListPacket::build() {
return packet; return packet;
} }
bool RealmListResponseParser::parse(network::Packet& packet, RealmListResponse& response) { bool RealmListResponseParser::parse(network::Packet& packet, RealmListResponse& response, uint8_t protocolVersion) {
// Note: opcode byte already consumed by handlePacket() // Note: opcode byte already consumed by handlePacket()
const bool isVanilla = (protocolVersion < 8);
// Packet size (2 bytes) - we already know the size, skip it // Packet size (2 bytes) - we already know the size, skip it
uint16_t packetSize = packet.readUInt16(); uint16_t packetSize = packet.readUInt16();
@ -302,8 +300,13 @@ bool RealmListResponseParser::parse(network::Packet& packet, RealmListResponse&
// Unknown uint32 // Unknown uint32
packet.readUInt32(); packet.readUInt32();
// Realm count // Realm count: uint8 for vanilla/classic, uint16 for WotLK+
uint16_t realmCount = packet.readUInt16(); uint16_t realmCount;
if (isVanilla) {
realmCount = packet.readUInt8();
} else {
realmCount = packet.readUInt16();
}
LOG_INFO("REALM_LIST response: ", realmCount, " realms"); LOG_INFO("REALM_LIST response: ", realmCount, " realms");
response.realms.clear(); response.realms.clear();
@ -312,11 +315,19 @@ bool RealmListResponseParser::parse(network::Packet& packet, RealmListResponse&
for (uint16_t i = 0; i < realmCount; ++i) { for (uint16_t i = 0; i < realmCount; ++i) {
Realm realm; Realm realm;
// Icon // Icon/type: uint32 for vanilla, uint8 for WotLK
realm.icon = packet.readUInt8(); if (isVanilla) {
realm.icon = static_cast<uint8_t>(packet.readUInt32());
} else {
realm.icon = packet.readUInt8();
}
// Lock // Lock: not present in vanilla (added in TBC)
realm.lock = packet.readUInt8(); if (!isVanilla) {
realm.lock = packet.readUInt8();
} else {
realm.lock = 0;
}
// Flags // Flags
realm.flags = packet.readUInt8(); realm.flags = packet.readUInt8();

View file

@ -0,0 +1,34 @@
#include "auth/vanilla_crypt.hpp"
namespace wowee {
namespace auth {
void VanillaCrypt::init(const std::vector<uint8_t>& sessionKey) {
key_ = sessionKey;
sendIndex_ = 0;
sendPrev_ = 0;
recvIndex_ = 0;
recvPrev_ = 0;
}
void VanillaCrypt::encrypt(uint8_t* data, size_t length) {
for (size_t i = 0; i < length; ++i) {
uint8_t x = (data[i] ^ key_[sendIndex_]) + sendPrev_;
sendIndex_ = (sendIndex_ + 1) % key_.size();
data[i] = x;
sendPrev_ = x;
}
}
void VanillaCrypt::decrypt(uint8_t* data, size_t length) {
for (size_t i = 0; i < length; ++i) {
uint8_t enc = data[i];
uint8_t x = (enc - recvPrev_) ^ key_[recvIndex_];
recvIndex_ = (recvIndex_ + 1) % key_.size();
recvPrev_ = enc;
data[i] = x;
}
}
} // namespace auth
} // namespace wowee

View file

@ -148,6 +148,7 @@ bool Application::initialize() {
if (profile) { if (profile) {
std::string opcodesPath = profile->dataPath + "/opcodes.json"; std::string opcodesPath = profile->dataPath + "/opcodes.json";
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) { if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
gameHandler->getOpcodeTable().loadWotlkDefaults();
LOG_INFO("Using built-in WotLK opcode defaults"); LOG_INFO("Using built-in WotLK opcode defaults");
} }
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable()); game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
@ -155,6 +156,7 @@ bool Application::initialize() {
// Load expansion-specific update field table // Load expansion-specific update field table
std::string updateFieldsPath = profile->dataPath + "/update_fields.json"; std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) { if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
gameHandler->getUpdateFieldTable().loadWotlkDefaults();
LOG_INFO("Using built-in WotLK update field defaults"); LOG_INFO("Using built-in WotLK update field defaults");
} }
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable()); game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
@ -458,6 +460,42 @@ void Application::setState(AppState newState) {
} }
} }
void Application::reloadExpansionData() {
if (!expansionRegistry_ || !gameHandler) return;
auto* profile = expansionRegistry_->getActive();
if (!profile) return;
LOG_INFO("Reloading expansion data for: ", profile->name);
std::string opcodesPath = profile->dataPath + "/opcodes.json";
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
gameHandler->getOpcodeTable().loadWotlkDefaults();
}
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
gameHandler->getUpdateFieldTable().loadWotlkDefaults();
}
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
if (dbcLayout_) {
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
dbcLayout_->loadWotlkDefaults();
}
pipeline::setActiveDBCLayout(dbcLayout_.get());
}
// Update expansion data path for CSV DBC lookups and clear DBC cache
if (assetManager && !profile->dataPath.empty()) {
assetManager->setExpansionDataPath(profile->dataPath);
assetManager->clearDBCCache();
}
}
void Application::logoutToLogin() { void Application::logoutToLogin() {
LOG_INFO("Logout requested"); LOG_INFO("Logout requested");
if (gameHandler) { if (gameHandler) {
@ -908,7 +946,7 @@ void Application::setupUICallbacks() {
uint32_t clientBuild = 12340; // default WotLK uint32_t clientBuild = 12340; // default WotLK
if (expansionRegistry_) { if (expansionRegistry_) {
auto* profile = expansionRegistry_->getActive(); auto* profile = expansionRegistry_->getActive();
if (profile) clientBuild = profile->build; if (profile) clientBuild = profile->worldBuild;
} }
if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) { if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) {
LOG_INFO("Connected to world server, transitioning to character selection"); LOG_INFO("Connected to world server, transitioning to character selection");

View file

@ -165,6 +165,7 @@ bool ExpansionRegistry::loadProfile(const std::string& jsonPath, const std::stri
} }
p.build = static_cast<uint16_t>(jsonInt(json, "build")); p.build = static_cast<uint16_t>(jsonInt(json, "build"));
p.worldBuild = static_cast<uint16_t>(jsonInt(json, "worldBuild", p.build));
p.protocolVersion = static_cast<uint8_t>(jsonInt(json, "protocolVersion")); p.protocolVersion = static_cast<uint8_t>(jsonInt(json, "protocolVersion"));
// Optional client header fields (LOGON_CHALLENGE) // Optional client header fields (LOGON_CHALLENGE)
{ {

View file

@ -136,6 +136,13 @@ bool GameHandler::connect(const std::string& host,
this->accountName = accountName; this->accountName = accountName;
this->build = build; this->build = build;
this->realmId_ = realmId; this->realmId_ = realmId;
// Diagnostic: dump session key for AUTH_REJECT debugging
{
std::string hex;
for (uint8_t b : sessionKey) { char buf[4]; snprintf(buf, sizeof(buf), "%02x", b); hex += buf; }
LOG_INFO("GameHandler session key (", sessionKey.size(), "): ", hex);
}
requiresWarden_ = false; requiresWarden_ = false;
wardenGateSeen_ = false; wardenGateSeen_ = false;
wardenGateElapsed_ = 0.0f; wardenGateElapsed_ = 0.0f;
@ -1448,7 +1455,7 @@ void GameHandler::sendAuthSession() {
// AzerothCore enables encryption before sending AUTH_RESPONSE, // AzerothCore enables encryption before sending AUTH_RESPONSE,
// so we need to be ready to decrypt the response // so we need to be ready to decrypt the response
LOG_INFO("Enabling encryption immediately after AUTH_SESSION"); LOG_INFO("Enabling encryption immediately after AUTH_SESSION");
socket->initEncryption(sessionKey); socket->initEncryption(sessionKey, build);
setState(WorldState::AUTH_SENT); setState(WorldState::AUTH_SENT);
LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE..."); LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE...");
@ -1529,7 +1536,15 @@ void GameHandler::handleCharEnum(network::Packet& packet) {
LOG_INFO("Handling SMSG_CHAR_ENUM"); LOG_INFO("Handling SMSG_CHAR_ENUM");
CharEnumResponse response; CharEnumResponse response;
if (!CharEnumParser::parse(packet, response)) { bool parsed = false;
if (build <= 6005) {
// Vanilla 1.12.x format (different equipment layout, no customization flag)
ClassicPacketParsers classicParser;
parsed = classicParser.parseCharEnum(packet, response);
} else {
parsed = CharEnumParser::parse(packet, response);
}
if (!parsed) {
fail("Failed to parse SMSG_CHAR_ENUM"); fail("Failed to parse SMSG_CHAR_ENUM");
return; return;
} }
@ -1901,290 +1916,232 @@ void GameHandler::handleWardenData(network::Packet& packet) {
wardenPacketsAfterGate_ = 0; wardenPacketsAfterGate_ = 0;
} }
// Log the raw encrypted packet // Initialize Warden crypto from session key on first packet
std::string hex;
hex.reserve(data.size() * 3);
for (size_t i = 0; i < data.size(); ++i) {
char b[4];
snprintf(b, sizeof(b), "%02x ", data[i]);
hex += b;
}
LOG_INFO("Received SMSG_WARDEN_DATA (len=", data.size(), ", raw: ", hex, ")");
// Initialize crypto on first packet (usually module load)
if (!wardenCrypto_) { if (!wardenCrypto_) {
wardenCrypto_ = std::make_unique<WardenCrypto>(); wardenCrypto_ = std::make_unique<WardenCrypto>();
if (!wardenCrypto_->initialize(data)) { if (sessionKey.size() != 40) {
LOG_ERROR("Warden: Failed to initialize crypto"); LOG_ERROR("Warden: No valid session key (size=", sessionKey.size(), "), cannot init crypto");
wardenCrypto_.reset(); wardenCrypto_.reset();
return; return;
} }
LOG_INFO("Warden: Crypto initialized, analyzing module structure"); if (!wardenCrypto_->initFromSessionKey(sessionKey)) {
LOG_ERROR("Warden: Failed to initialize crypto from session key");
// Parse module structure (37 bytes typical): wardenCrypto_.reset();
// [1 byte opcode][16 bytes seed][20 bytes challenge/hash] return;
std::vector<uint8_t> trailingBytes;
uint8_t opcodeByte = data[0];
if (data.size() >= 37) {
std::string trailingHex;
for (size_t i = 17; i < 37; ++i) {
trailingBytes.push_back(data[i]);
char b[4];
snprintf(b, sizeof(b), "%02x ", data[i]);
trailingHex += b;
}
LOG_INFO("Warden: Opcode byte: 0x", std::hex, (int)opcodeByte, std::dec);
LOG_INFO("Warden: Challenge/hash (20 bytes): ", trailingHex);
} }
wardenState_ = WardenState::WAIT_MODULE_USE;
// For opcode 0xF6, try empty response (some servers expect nothing)
std::vector<uint8_t> moduleResponse;
LOG_INFO("Warden: Trying response strategy: Empty response (0 bytes)");
// Send empty/null response
// moduleResponse remains empty (size = 0)
LOG_INFO("Warden: Crypto initialized, sending MODULE_OK with challenge");
LOG_INFO("Warden: Opcode 0x01 + 20-byte challenge");
// Try opcode 0x01 (MODULE_OK) followed by 20-byte challenge
std::vector<uint8_t> hashResponse;
hashResponse.push_back(0x01); // WARDEN_CMSG_MODULE_OK
// Append the 20-byte challenge
for (size_t i = 0; i < trailingBytes.size(); ++i) {
hashResponse.push_back(trailingBytes[i]);
}
LOG_INFO("Warden: SHA1 hash computed (20 bytes), total response: ", hashResponse.size(), " bytes");
// Encrypt the response
std::vector<uint8_t> encryptedResponse = wardenCrypto_->encrypt(hashResponse);
// Send HASH_RESULT response
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
for (uint8_t byte : encryptedResponse) {
response.writeUInt8(byte);
}
if (socket && socket->isConnected()) {
socket->send(response);
LOG_INFO("Sent CMSG_WARDEN_DATA MODULE_OK+challenge (", encryptedResponse.size(), " bytes encrypted)");
}
// Mark that we've seen the initial seed packet
wardenGateSeen_ = true;
return;
} }
// Decrypt the packet // Decrypt the payload
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data); std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data);
// Log decrypted data (first 64 bytes for readability) // Log decrypted data
std::string decHex; {
size_t logSize = std::min(decrypted.size(), size_t(64)); std::string hex;
decHex.reserve(logSize * 3); size_t logSize = std::min(decrypted.size(), size_t(64));
for (size_t i = 0; i < logSize; ++i) { hex.reserve(logSize * 3);
char b[4]; for (size_t i = 0; i < logSize; ++i) {
snprintf(b, sizeof(b), "%02x ", decrypted[i]); char b[4]; snprintf(b, sizeof(b), "%02x ", decrypted[i]); hex += b;
decHex += b;
}
if (decrypted.size() > 64) {
decHex += "... (" + std::to_string(decrypted.size() - 64) + " more bytes)";
}
LOG_INFO("Warden: Decrypted (", decrypted.size(), " bytes): ", decHex);
// Check if this looks like a module download (large size)
if (decrypted.size() > 256) {
LOG_INFO("Warden: Received large packet (", decrypted.size(), " bytes) - attempting module load");
// Try to load this as a Warden module
// Compute MD5 hash of the decrypted data for module identification
std::vector<uint8_t> moduleMD5 = auth::Crypto::md5(decrypted);
// The data is already decrypted by our crypto layer, but the module loader
// expects encrypted data. We need to pass the original encrypted data.
// For now, try loading with what we have.
auto module = wardenModuleManager_->getModule(moduleMD5);
// Extract RC4 key from current crypto state (we already initialized it)
std::vector<uint8_t> dummyKey(16, 0); // Module will use existing crypto
if (module->load(decrypted, moduleMD5, dummyKey)) {
LOG_INFO("Warden: ✓ Module loaded successfully!");
// Module is now ready to process check requests
// No response needed for module download
return;
} else {
LOG_WARNING("Warden: ✗ Module load failed, falling back to fake responses");
} }
if (decrypted.size() > 64)
hex += "... (" + std::to_string(decrypted.size() - 64) + " more)";
LOG_INFO("Warden: Decrypted (", decrypted.size(), " bytes): ", hex);
} }
// Prepare response data
std::vector<uint8_t> responseData;
if (decrypted.empty()) { if (decrypted.empty()) {
LOG_INFO("Warden: Empty decrypted packet"); LOG_WARNING("Warden: Empty decrypted payload");
} else {
uint8_t opcode = decrypted[0];
// If we have a loaded module, try to use it for check processing
std::vector<uint8_t> moduleMD5 = auth::Crypto::md5(decrypted);
auto module = wardenModuleManager_->getModule(moduleMD5);
if (module && module->isLoaded()) {
LOG_INFO("Warden: Using loaded module to process check request (opcode 0x",
std::hex, (int)opcode, std::dec, ")");
if (module->processCheckRequest(decrypted, responseData)) {
LOG_INFO("Warden: ✓ Module generated response (", responseData.size(), " bytes)");
// Response will be encrypted and sent below
} else {
LOG_WARNING("Warden: ✗ Module failed to process check, using fallback");
// Fall through to fake responses
}
}
// If module processing didn't generate a response, use fake responses
if (responseData.empty()) {
uint8_t opcode = decrypted[0];
// Warden check opcodes (from WoW 3.3.5a protocol)
switch (opcode) {
case 0x00: // Module info request
LOG_INFO("Warden: Module info request");
responseData.push_back(0x00);
break;
case 0x01: { // Hash request
LOG_INFO("Warden: Hash request (file/memory hash check)");
// Parse hash request structure
// Format: [0x01][seed][hash_to_check][...]
responseData.push_back(0x01);
// For each hash check, respond with matching result
if (decrypted.size() > 1) {
// Extract number of hash checks (varies by server)
size_t pos = 1;
while (pos < decrypted.size() && responseData.size() < 64) {
responseData.push_back(0x00); // Hash matches (no violation)
pos += 20; // Skip 20-byte hash (SHA1)
}
} else {
responseData.push_back(0x00); // Pass
}
LOG_INFO("Warden: Hash check response with ", responseData.size() - 1, " results");
break;
}
case 0x02: { // Lua string check
LOG_INFO("Warden: Lua string check (anti-addon detection)");
// Format: [0x02][lua_length][lua_string]
responseData.push_back(0x02);
// Return empty string = no suspicious Lua found
responseData.push_back(0x00); // String length = 0
LOG_INFO("Warden: Lua check - returned empty (no detection)");
break;
}
case 0x05: { // Memory/page check
LOG_INFO("Warden: Memory page check (anti-cheat scan)");
// Format: [0x05][seed][address_count][addresses...][length]
if (decrypted.size() >= 2) {
uint8_t numChecks = decrypted[1];
LOG_INFO("Warden: Processing ", (int)numChecks, " memory checks");
responseData.push_back(0x05);
// For each memory region checked, return "clean" data
for (uint8_t i = 0; i < numChecks; ++i) {
// Return zeroed memory (appears clean/unmodified)
responseData.push_back(0x00);
}
LOG_INFO("Warden: Memory check - all regions clean");
} else {
responseData.push_back(0x05);
responseData.push_back(0x00);
}
break;
}
case 0x04: { // Timing check
LOG_INFO("Warden: Timing check (detect speedhacks)");
// Return current timestamp
responseData.push_back(0x04);
uint32_t timestamp = static_cast<uint32_t>(std::time(nullptr));
responseData.push_back((timestamp >> 0) & 0xFF);
responseData.push_back((timestamp >> 8) & 0xFF);
responseData.push_back((timestamp >> 16) & 0xFF);
responseData.push_back((timestamp >> 24) & 0xFF);
break;
}
default:
LOG_INFO("Warden: Unknown check opcode 0x", std::hex, (int)opcode, std::dec);
LOG_INFO("Warden: Packet dump: ", decHex);
// Try to detect packet type from size/structure
if (decrypted.size() > 20) {
LOG_INFO("Warden: Large packet - might be batched checks");
// Some servers send multiple checks in one packet
// Try to respond to each one
responseData.push_back(0x00); // Generic "OK" response
} else {
// Small unknown packet - echo with success
responseData.push_back(opcode);
responseData.push_back(0x00);
}
break;
}
}
}
// If we have no response data, don't send anything
if (responseData.empty()) {
LOG_INFO("Warden: No response generated (module loaded or waiting for checks)");
return; return;
} }
// Log plaintext response uint8_t wardenOpcode = decrypted[0];
std::string respPlainHex;
respPlainHex.reserve(responseData.size() * 3);
for (uint8_t byte : responseData) {
char b[4];
snprintf(b, sizeof(b), "%02x ", byte);
respPlainHex += b;
}
LOG_INFO("Warden: Response plaintext (", responseData.size(), " bytes): ", respPlainHex);
// Encrypt response // Helper to send an encrypted Warden response
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(responseData); auto sendWardenResponse = [&](const std::vector<uint8_t>& plaintext) {
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
for (uint8_t byte : encrypted) {
response.writeUInt8(byte);
}
if (socket && socket->isConnected()) {
socket->send(response);
LOG_INFO("Warden: Sent response (", plaintext.size(), " bytes plaintext)");
}
};
// Log encrypted response switch (wardenOpcode) {
std::string respEncHex; case 0x00: { // WARDEN_SMSG_MODULE_USE
respEncHex.reserve(encrypted.size() * 3); // Format: [1 opcode][16 moduleHash][16 moduleKey][4 moduleSize]
for (uint8_t byte : encrypted) { if (decrypted.size() < 37) {
char b[4]; LOG_ERROR("Warden: MODULE_USE too short (", decrypted.size(), " bytes, need 37)");
snprintf(b, sizeof(b), "%02x ", byte); return;
respEncHex += b; }
}
LOG_INFO("Warden: Response encrypted (", encrypted.size(), " bytes): ", respEncHex);
// Build and send response packet wardenModuleHash_.assign(decrypted.begin() + 1, decrypted.begin() + 17);
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA)); wardenModuleKey_.assign(decrypted.begin() + 17, decrypted.begin() + 33);
for (uint8_t byte : encrypted) { wardenModuleSize_ = static_cast<uint32_t>(decrypted[33])
response.writeUInt8(byte); | (static_cast<uint32_t>(decrypted[34]) << 8)
} | (static_cast<uint32_t>(decrypted[35]) << 16)
| (static_cast<uint32_t>(decrypted[36]) << 24);
wardenModuleData_.clear();
if (socket && socket->isConnected()) { {
socket->send(response); std::string hashHex, keyHex;
LOG_INFO("Sent CMSG_WARDEN_DATA encrypted response"); for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
for (auto b : wardenModuleKey_) { char s[4]; snprintf(s, 4, "%02x", b); keyHex += s; }
LOG_INFO("Warden: MODULE_USE hash=", hashHex,
" key=", keyHex, " size=", wardenModuleSize_);
}
// Respond with MODULE_MISSING (opcode 0x00) to request the module data
std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING
sendWardenResponse(resp);
wardenState_ = WardenState::WAIT_MODULE_CACHE;
LOG_INFO("Warden: Sent MODULE_MISSING, waiting for module data chunks");
break;
}
case 0x01: { // WARDEN_SMSG_MODULE_CACHE (module data chunk)
// Format: [1 opcode][2 chunkSize LE][chunkSize bytes data]
if (decrypted.size() < 3) {
LOG_ERROR("Warden: MODULE_CACHE too short");
return;
}
uint16_t chunkSize = static_cast<uint16_t>(decrypted[1])
| (static_cast<uint16_t>(decrypted[2]) << 8);
if (decrypted.size() < 3u + chunkSize) {
LOG_ERROR("Warden: MODULE_CACHE chunk truncated (claimed ", chunkSize,
", have ", decrypted.size() - 3, ")");
return;
}
wardenModuleData_.insert(wardenModuleData_.end(),
decrypted.begin() + 3,
decrypted.begin() + 3 + chunkSize);
LOG_INFO("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ",
wardenModuleData_.size(), "/", wardenModuleSize_);
// Check if module download is complete
if (wardenModuleData_.size() >= wardenModuleSize_) {
LOG_INFO("Warden: Module download complete (",
wardenModuleData_.size(), " bytes)");
wardenState_ = WardenState::WAIT_HASH_REQUEST;
// Send MODULE_OK (opcode 0x01)
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
sendWardenResponse(resp);
LOG_INFO("Warden: Sent MODULE_OK");
}
// No response for intermediate chunks
break;
}
case 0x05: { // WARDEN_SMSG_HASH_REQUEST
// Format: [1 opcode][16 seed]
if (decrypted.size() < 17) {
LOG_ERROR("Warden: HASH_REQUEST too short (", decrypted.size(), " bytes, need 17)");
return;
}
std::vector<uint8_t> seed(decrypted.begin() + 1, decrypted.begin() + 17);
{
std::string seedHex;
for (auto b : seed) { char s[4]; snprintf(s, 4, "%02x", b); seedHex += s; }
LOG_INFO("Warden: HASH_REQUEST seed=", seedHex);
}
// The server expects SHA1 of the module's init function stub XOR'd with the seed.
// Without the actual module execution, we compute a plausible hash.
// For now, try to use the module data we downloaded.
// The correct approach: decrypt module with moduleKey RC4, then hash the init stub.
// But we need to at least send a HASH_RESULT to not get kicked immediately.
// Decrypt the downloaded module data using the module RC4 key
// VMaNGOS: The module is RC4-encrypted with wardenModuleKey_
if (!wardenModuleData_.empty() && !wardenModuleKey_.empty()) {
LOG_INFO("Warden: Attempting to compute hash from downloaded module (",
wardenModuleData_.size(), " bytes)");
// The module data is RC4-encrypted. Decrypt it.
// Standard RC4 decryption with the module key
std::vector<uint8_t> moduleDecrypted(wardenModuleData_.size());
{
// RC4 KSA + PRGA with wardenModuleKey_
std::vector<uint8_t> S(256);
for (int i = 0; i < 256; ++i) S[i] = static_cast<uint8_t>(i);
uint8_t j = 0;
for (int i = 0; i < 256; ++i) {
j = (j + S[i] + wardenModuleKey_[i % wardenModuleKey_.size()]) & 0xFF;
std::swap(S[i], S[j]);
}
uint8_t ri = 0, rj = 0;
for (size_t k = 0; k < wardenModuleData_.size(); ++k) {
ri = (ri + 1) & 0xFF;
rj = (rj + S[ri]) & 0xFF;
std::swap(S[ri], S[rj]);
moduleDecrypted[k] = wardenModuleData_[k] ^ S[(S[ri] + S[rj]) & 0xFF];
}
}
LOG_INFO("Warden: Module decrypted, computing hash response");
// The hash is SHA1 of the seed concatenated with module data
// Actually in VMaNGOS, the init function stub computes SHA1 of seed + specific module regions.
// We'll try a simple SHA1(seed + first 16 bytes of decrypted module) approach,
// which won't pass verification but at least sends a properly formatted response.
std::vector<uint8_t> hashInput;
hashInput.insert(hashInput.end(), seed.begin(), seed.end());
// Add decrypted module data
hashInput.insert(hashInput.end(), moduleDecrypted.begin(), moduleDecrypted.end());
auto hash = auth::Crypto::sha1(hashInput);
// Send HASH_RESULT (WARDEN_CMSG_HASH_RESULT = opcode 0x04)
std::vector<uint8_t> resp;
resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT
resp.insert(resp.end(), hash.begin(), hash.end()); // 20-byte SHA1
sendWardenResponse(resp);
LOG_INFO("Warden: Sent HASH_RESULT (21 bytes)");
} else {
// No module data available, send a dummy hash
LOG_WARNING("Warden: No module data for hash computation, sending dummy");
std::vector<uint8_t> hashInput;
hashInput.insert(hashInput.end(), seed.begin(), seed.end());
auto hash = auth::Crypto::sha1(hashInput);
std::vector<uint8_t> resp;
resp.push_back(0x04);
resp.insert(resp.end(), hash.begin(), hash.end());
sendWardenResponse(resp);
}
wardenState_ = WardenState::WAIT_CHECKS;
break;
}
case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST
LOG_INFO("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)");
// We don't have the module to properly process checks.
// Send a minimal valid-looking response.
// WARDEN_CMSG_CHEAT_CHECKS_RESULT (opcode 0x02):
// [1 opcode][2 length LE][4 checksum][result data]
// Minimal response: length=0, checksum=0
std::vector<uint8_t> resp;
resp.push_back(0x02); // WARDEN_CMSG_CHEAT_CHECKS_RESULT
resp.push_back(0x00); resp.push_back(0x00); // length = 0
resp.push_back(0x00); resp.push_back(0x00);
resp.push_back(0x00); resp.push_back(0x00); // checksum = 0
sendWardenResponse(resp);
LOG_INFO("Warden: Sent CHEAT_CHECKS_RESULT (minimal)");
break;
}
default:
LOG_INFO("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec,
" (state=", (int)wardenState_, ", size=", decrypted.size(), ")");
break;
} }
} }

View file

@ -572,6 +572,10 @@ bool OpcodeTable::loadFromJson(const std::string& path) {
std::string json((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>()); std::string json((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
// Save old tables so we can restore on failure
auto savedLogicalToWire = logicalToWire_;
auto savedWireToLogical = wireToLogical_;
logicalToWire_.clear(); logicalToWire_.clear();
wireToLogical_.clear(); wireToLogical_.clear();
@ -624,8 +628,15 @@ bool OpcodeTable::loadFromJson(const std::string& path) {
pos = valEnd + 1; pos = valEnd + 1;
} }
if (loaded == 0) {
LOG_WARNING("OpcodeTable: no opcodes loaded from ", path, ", restoring previous tables");
logicalToWire_ = std::move(savedLogicalToWire);
wireToLogical_ = std::move(savedWireToLogical);
return false;
}
LOG_INFO("OpcodeTable: loaded ", loaded, " opcodes from ", path); LOG_INFO("OpcodeTable: loaded ", loaded, " opcodes from ", path);
return loaded > 0; return true;
} }
uint16_t OpcodeTable::toWire(LogicalOpcode op) const { uint16_t OpcodeTable::toWire(LogicalOpcode op) const {

View file

@ -95,6 +95,7 @@ bool UpdateFieldTable::loadFromJson(const std::string& path) {
std::string json((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>()); std::string json((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
auto savedFieldMap = fieldMap_;
fieldMap_.clear(); fieldMap_.clear();
size_t loaded = 0; size_t loaded = 0;
size_t pos = 0; size_t pos = 0;
@ -139,8 +140,14 @@ bool UpdateFieldTable::loadFromJson(const std::string& path) {
pos = valEnd + 1; pos = valEnd + 1;
} }
if (loaded == 0) {
LOG_WARNING("UpdateFieldTable: no fields loaded from ", path, ", restoring previous table");
fieldMap_ = std::move(savedFieldMap);
return false;
}
LOG_INFO("UpdateFieldTable: loaded ", loaded, " fields from ", path); LOG_INFO("UpdateFieldTable: loaded ", loaded, " fields from ", path);
return loaded > 0; return true;
} }
uint16_t UpdateFieldTable::index(UF field) const { uint16_t UpdateFieldTable::index(UF field) const {

View file

@ -1,4 +1,5 @@
#include "game/warden_crypto.hpp" #include "game/warden_crypto.hpp"
#include "auth/crypto.hpp"
#include "core/logger.hpp" #include "core/logger.hpp"
#include <cstring> #include <cstring>
#include <algorithm> #include <algorithm>
@ -6,65 +7,109 @@
namespace wowee { namespace wowee {
namespace game { namespace game {
// Warden module keys for WoW 3.3.5a (from client analysis)
// These are the standard keys used by most 3.3.5a servers
static const uint8_t WARDEN_MODULE_KEY[16] = {
0xC5, 0x35, 0xB2, 0x1E, 0xF8, 0xE7, 0x9F, 0x4B,
0x91, 0xB6, 0xD1, 0x34, 0xA7, 0x2F, 0x58, 0x8C
};
WardenCrypto::WardenCrypto() WardenCrypto::WardenCrypto()
: initialized_(false) : initialized_(false)
, inputRC4_i_(0) , decryptRC4_i_(0)
, inputRC4_j_(0) , decryptRC4_j_(0)
, outputRC4_i_(0) , encryptRC4_i_(0)
, outputRC4_j_(0) { , encryptRC4_j_(0) {
} }
WardenCrypto::~WardenCrypto() { WardenCrypto::~WardenCrypto() {
} }
bool WardenCrypto::initialize(const std::vector<uint8_t>& moduleData) { void WardenCrypto::sha1RandxGenerate(const std::vector<uint8_t>& seed,
if (moduleData.empty()) { uint8_t* outputEncryptKey,
LOG_ERROR("Warden: Cannot initialize with empty module data"); uint8_t* outputDecryptKey) {
// SHA1Randx / WardenKeyGenerator algorithm (from MaNGOS/VMaNGOS)
// Split the 40-byte session key in half
size_t half = seed.size() / 2;
// o1 = SHA1(first half of session key)
std::vector<uint8_t> firstHalf(seed.begin(), seed.begin() + half);
auto o1 = auth::Crypto::sha1(firstHalf);
// o2 = SHA1(second half of session key)
std::vector<uint8_t> secondHalf(seed.begin() + half, seed.end());
auto o2 = auth::Crypto::sha1(secondHalf);
// o0 = 20 zero bytes initially
std::vector<uint8_t> o0(20, 0);
uint32_t taken = 20; // Force FillUp on first Generate
// Lambda: FillUp - compute o0 = SHA1(o1 || o0 || o2)
auto fillUp = [&]() {
std::vector<uint8_t> combined;
combined.reserve(60);
combined.insert(combined.end(), o1.begin(), o1.end());
combined.insert(combined.end(), o0.begin(), o0.end());
combined.insert(combined.end(), o2.begin(), o2.end());
o0 = auth::Crypto::sha1(combined);
taken = 0;
};
// Generate first 16 bytes → encrypt key (client→server)
for (int i = 0; i < 16; ++i) {
if (taken == 20) fillUp();
outputEncryptKey[i] = o0[taken++];
}
// Generate next 16 bytes → decrypt key (server→client)
for (int i = 0; i < 16; ++i) {
if (taken == 20) fillUp();
outputDecryptKey[i] = o0[taken++];
}
}
bool WardenCrypto::initFromSessionKey(const std::vector<uint8_t>& sessionKey) {
if (sessionKey.size() != 40) {
LOG_ERROR("Warden: Session key must be 40 bytes, got ", sessionKey.size());
return false; return false;
} }
LOG_INFO("Warden: Initializing crypto with ", moduleData.size(), " byte module"); uint8_t encryptKey[16];
uint8_t decryptKey[16];
sha1RandxGenerate(sessionKey, encryptKey, decryptKey);
// Warden 3.3.5a module format (typically): // Log derived keys
// [1 byte opcode][16 bytes seed/key][remaining bytes = encrypted module data] {
std::string hex;
if (moduleData.size() < 17) { for (int i = 0; i < 16; ++i) {
LOG_WARNING("Warden: Module too small (", moduleData.size(), " bytes), using default keys"); char b[4]; snprintf(b, sizeof(b), "%02x ", encryptKey[i]); hex += b;
// Use default keys
inputKey_.assign(WARDEN_MODULE_KEY, WARDEN_MODULE_KEY + 16);
outputKey_.assign(WARDEN_MODULE_KEY, WARDEN_MODULE_KEY + 16);
} else {
// Extract seed from module (skip first opcode byte)
inputKey_.assign(moduleData.begin() + 1, moduleData.begin() + 17);
outputKey_ = inputKey_;
// XOR with module key for output
for (size_t i = 0; i < 16; ++i) {
outputKey_[i] ^= WARDEN_MODULE_KEY[i];
} }
LOG_INFO("Warden: Encrypt key (C→S): ", hex);
LOG_INFO("Warden: Extracted 16-byte seed from module"); hex.clear();
for (int i = 0; i < 16; ++i) {
char b[4]; snprintf(b, sizeof(b), "%02x ", decryptKey[i]); hex += b;
}
LOG_INFO("Warden: Decrypt key (S→C): ", hex);
} }
// Initialize RC4 ciphers // Initialize RC4 ciphers
inputRC4State_.resize(256); std::vector<uint8_t> ek(encryptKey, encryptKey + 16);
outputRC4State_.resize(256); std::vector<uint8_t> dk(decryptKey, decryptKey + 16);
initRC4(inputKey_, inputRC4State_, inputRC4_i_, inputRC4_j_); encryptRC4State_.resize(256);
initRC4(outputKey_, outputRC4State_, outputRC4_i_, outputRC4_j_); decryptRC4State_.resize(256);
initRC4(ek, encryptRC4State_, encryptRC4_i_, encryptRC4_j_);
initRC4(dk, decryptRC4State_, decryptRC4_i_, decryptRC4_j_);
initialized_ = true; initialized_ = true;
LOG_INFO("Warden: Crypto initialized successfully"); LOG_INFO("Warden: Crypto initialized from session key");
return true; return true;
} }
void WardenCrypto::replaceKeys(const std::vector<uint8_t>& newEncryptKey,
const std::vector<uint8_t>& newDecryptKey) {
encryptRC4State_.resize(256);
decryptRC4State_.resize(256);
initRC4(newEncryptKey, encryptRC4State_, encryptRC4_i_, encryptRC4_j_);
initRC4(newDecryptKey, decryptRC4State_, decryptRC4_i_, decryptRC4_j_);
LOG_INFO("Warden: RC4 keys replaced (module-specific keys)");
}
std::vector<uint8_t> WardenCrypto::decrypt(const std::vector<uint8_t>& data) { std::vector<uint8_t> WardenCrypto::decrypt(const std::vector<uint8_t>& data) {
if (!initialized_) { if (!initialized_) {
LOG_WARNING("Warden: Attempted to decrypt without initialization"); LOG_WARNING("Warden: Attempted to decrypt without initialization");
@ -73,7 +118,7 @@ std::vector<uint8_t> WardenCrypto::decrypt(const std::vector<uint8_t>& data) {
std::vector<uint8_t> result(data.size()); std::vector<uint8_t> result(data.size());
processRC4(data.data(), result.data(), data.size(), processRC4(data.data(), result.data(), data.size(),
inputRC4State_, inputRC4_i_, inputRC4_j_); decryptRC4State_, decryptRC4_i_, decryptRC4_j_);
return result; return result;
} }
@ -85,19 +130,17 @@ std::vector<uint8_t> WardenCrypto::encrypt(const std::vector<uint8_t>& data) {
std::vector<uint8_t> result(data.size()); std::vector<uint8_t> result(data.size());
processRC4(data.data(), result.data(), data.size(), processRC4(data.data(), result.data(), data.size(),
outputRC4State_, outputRC4_i_, outputRC4_j_); encryptRC4State_, encryptRC4_i_, encryptRC4_j_);
return result; return result;
} }
void WardenCrypto::initRC4(const std::vector<uint8_t>& key, void WardenCrypto::initRC4(const std::vector<uint8_t>& key,
std::vector<uint8_t>& state, std::vector<uint8_t>& state,
uint8_t& i, uint8_t& j) { uint8_t& i, uint8_t& j) {
// Initialize permutation
for (int idx = 0; idx < 256; ++idx) { for (int idx = 0; idx < 256; ++idx) {
state[idx] = static_cast<uint8_t>(idx); state[idx] = static_cast<uint8_t>(idx);
} }
// Key scheduling algorithm (KSA)
j = 0; j = 0;
for (int idx = 0; idx < 256; ++idx) { for (int idx = 0; idx < 256; ++idx) {
j = (j + state[idx] + key[idx % key.size()]) & 0xFF; j = (j + state[idx] + key[idx % key.size()]) & 0xFF;

View file

@ -59,11 +59,11 @@ network::Packet AuthSessionPacket::build(uint32_t build,
packet.writeString(upperAccount); packet.writeString(upperAccount);
packet.writeUInt32(0); // LoginServerType packet.writeUInt32(0); // LoginServerType
packet.writeUInt32(clientSeed); packet.writeUInt32(clientSeed);
// Some private cores validate these fields against realmlist/worldserver settings. // AzerothCore ignores these fields; other cores may validate them.
// Default to 1/1 and realmId (falling back to 1) rather than all zeros. // Use 0 for maximum compatibility.
packet.writeUInt32(1); // RegionID packet.writeUInt32(0); // RegionID
packet.writeUInt32(1); // BattlegroupID packet.writeUInt32(0); // BattlegroupID
packet.writeUInt32(realmId ? realmId : 1); // RealmID packet.writeUInt32(realmId); // RealmID
LOG_DEBUG(" Realm ID: ", realmId); LOG_DEBUG(" Realm ID: ", realmId);
packet.writeUInt32(0); // DOS response (uint64) packet.writeUInt32(0); // DOS response (uint64)
packet.writeUInt32(0); packet.writeUInt32(0);
@ -148,10 +148,36 @@ std::vector<uint8_t> AuthSessionPacket::computeAuthHash(
// Session key (40 bytes) // Session key (40 bytes)
hashInput.insert(hashInput.end(), sessionKey.begin(), sessionKey.end()); hashInput.insert(hashInput.end(), sessionKey.begin(), sessionKey.end());
LOG_DEBUG("Auth hash input: ", hashInput.size(), " bytes"); // Diagnostic: dump auth hash inputs for debugging AUTH_REJECT
{
auto toHex = [](const uint8_t* data, size_t len) {
std::string s;
for (size_t i = 0; i < len; ++i) {
char buf[4]; snprintf(buf, sizeof(buf), "%02x", data[i]); s += buf;
}
return s;
};
LOG_INFO("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed,
" serverSeed=0x", serverSeed, std::dec);
LOG_INFO("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size()));
LOG_INFO("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size()));
}
// Compute SHA1 hash // Compute SHA1 hash
return auth::Crypto::sha1(hashInput); auto result = auth::Crypto::sha1(hashInput);
{
auto toHex = [](const uint8_t* data, size_t len) {
std::string s;
for (size_t i = 0; i < len; ++i) {
char buf[4]; snprintf(buf, sizeof(buf), "%02x", data[i]); s += buf;
}
return s;
};
LOG_INFO("AUTH HASH: digest=", toHex(result.data(), result.size()));
}
return result;
} }
bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data) { bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data) {

View file

@ -246,15 +246,17 @@ size_t TCPSocket::getExpectedPacketSize(uint8_t opcode) {
return 0; // Need more data to determine return 0; // Need more data to determine
case 0x01: // LOGON_PROOF response case 0x01: // LOGON_PROOF response
// Success: opcode(1) + status(1) + M2(20) + accountFlags(4) + surveyId(4) + loginFlags(2) = 32 // Success response varies by server build (determined by client build sent in challenge):
// Some vanilla-era servers send a shorter success response: // Build >= 8089: cmd(1)+error(1)+M2(20)+accountFlags(4)+surveyId(4)+loginFlags(2) = 32
// opcode(1) + status(1) + M2(20) = 22 // Build 6299-8088: cmd(1)+error(1)+M2(20)+surveyId(4)+loginFlags(2) = 28
// Build < 6299: cmd(1)+error(1)+M2(20)+surveyId(4) = 26
// Failure: varies by server — minimum 2 bytes (opcode + status), some send 4 // Failure: varies by server — minimum 2 bytes (opcode + status), some send 4
if (receiveBuffer.size() >= 2) { if (receiveBuffer.size() >= 2) {
uint8_t status = receiveBuffer[1]; uint8_t status = receiveBuffer[1];
if (status == 0x00) { if (status == 0x00) {
if (receiveBuffer.size() >= 32) return 32; // WotLK-style if (receiveBuffer.size() >= 32) return 32;
if (receiveBuffer.size() >= 22) return 22; // minimal/vanilla-style if (receiveBuffer.size() >= 28) return 28;
if (receiveBuffer.size() >= 26) return 26;
return 0; return 0;
} else { } else {
// Consume up to 4 bytes if available, minimum 2 // Consume up to 4 bytes if available, minimum 2

View file

@ -115,6 +115,7 @@ void WorldSocket::disconnect() {
} }
connected = false; connected = false;
encryptionEnabled = false; encryptionEnabled = false;
useVanillaCrypt = false;
receiveBuffer.clear(); receiveBuffer.clear();
headerBytesDecrypted = 0; headerBytesDecrypted = 0;
LOG_INFO("Disconnected from world server"); LOG_INFO("Disconnected from world server");
@ -214,7 +215,11 @@ void WorldSocket::send(const Packet& packet) {
// Encrypt header if encryption is enabled (all 6 bytes) // Encrypt header if encryption is enabled (all 6 bytes)
if (encryptionEnabled) { if (encryptionEnabled) {
encryptCipher.process(sendData.data(), 6); if (useVanillaCrypt) {
vanillaCrypt.encrypt(sendData.data(), 6);
} else {
encryptCipher.process(sendData.data(), 6);
}
} }
// Add payload (unencrypted) // Add payload (unencrypted)
@ -291,17 +296,26 @@ void WorldSocket::update() {
} }
if (receivedAny) { if (receivedAny) {
LOG_DEBUG("World socket read ", bytesReadThisTick, " bytes in ", readOps, LOG_INFO("World socket read ", bytesReadThisTick, " bytes in ", readOps,
" recv call(s), buffered=", receiveBuffer.size()); " recv call(s), buffered=", receiveBuffer.size());
// Hex dump received bytes for auth debugging
if (bytesReadThisTick <= 128) {
std::string hex;
for (size_t i = 0; i < receiveBuffer.size(); ++i) {
char buf[4]; snprintf(buf, sizeof(buf), "%02x ", receiveBuffer[i]); hex += buf;
}
LOG_INFO("World socket raw bytes: ", hex);
}
tryParsePackets(); tryParsePackets();
if (connected && !receiveBuffer.empty()) { if (connected && !receiveBuffer.empty()) {
LOG_DEBUG("World socket parse left ", receiveBuffer.size(), LOG_INFO("World socket parse left ", receiveBuffer.size(),
" bytes buffered (awaiting complete packet)"); " bytes buffered (awaiting complete packet)");
} }
} }
if (sawClose) { if (sawClose) {
LOG_INFO("World server connection closed"); LOG_INFO("World server connection closed (receivedAny=", receivedAny,
" buffered=", receiveBuffer.size(), ")");
disconnect(); disconnect();
return; return;
} }
@ -317,7 +331,11 @@ void WorldSocket::tryParsePackets() {
// Only decrypt bytes we haven't already decrypted // Only decrypt bytes we haven't already decrypted
if (encryptionEnabled && headerBytesDecrypted < 4) { if (encryptionEnabled && headerBytesDecrypted < 4) {
size_t toDecrypt = 4 - headerBytesDecrypted; size_t toDecrypt = 4 - headerBytesDecrypted;
decryptCipher.process(receiveBuffer.data() + headerBytesDecrypted, toDecrypt); if (useVanillaCrypt) {
vanillaCrypt.decrypt(receiveBuffer.data() + headerBytesDecrypted, toDecrypt);
} else {
decryptCipher.process(receiveBuffer.data() + headerBytesDecrypted, toDecrypt);
}
headerBytesDecrypted = 4; headerBytesDecrypted = 4;
} }
@ -402,33 +420,36 @@ void WorldSocket::tryParsePackets() {
} }
} }
void WorldSocket::initEncryption(const std::vector<uint8_t>& sessionKey) { void WorldSocket::initEncryption(const std::vector<uint8_t>& sessionKey, uint32_t build) {
if (sessionKey.size() != 40) { if (sessionKey.size() != 40) {
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)"); LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
return; return;
} }
LOG_INFO(">>> ENABLING ENCRYPTION - encryptionEnabled will become true <<<"); // Vanilla/TBC (build <= 8606) uses XOR+addition cipher with raw session key
// WotLK (build > 8606) uses HMAC-SHA1 derived RC4 with 1024-byte drop
useVanillaCrypt = (build <= 8606);
// Convert hardcoded keys to vectors LOG_INFO(">>> ENABLING ENCRYPTION (", useVanillaCrypt ? "vanilla XOR" : "WotLK RC4",
std::vector<uint8_t> encryptKey(ENCRYPT_KEY, ENCRYPT_KEY + 16); ") build=", build, " <<<");
std::vector<uint8_t> decryptKey(DECRYPT_KEY, DECRYPT_KEY + 16);
// Compute HMAC-SHA1(seed, sessionKey) for each cipher if (useVanillaCrypt) {
// The 16-byte seed is the HMAC key, session key is the message vanillaCrypt.init(sessionKey);
std::vector<uint8_t> encryptHash = auth::Crypto::hmacSHA1(encryptKey, sessionKey); } else {
std::vector<uint8_t> decryptHash = auth::Crypto::hmacSHA1(decryptKey, sessionKey); // WotLK: HMAC-SHA1(hardcoded seed, sessionKey) → RC4 key
std::vector<uint8_t> encryptKey(ENCRYPT_KEY, ENCRYPT_KEY + 16);
std::vector<uint8_t> decryptKey(DECRYPT_KEY, DECRYPT_KEY + 16);
LOG_DEBUG("Encrypt hash: ", encryptHash.size(), " bytes"); std::vector<uint8_t> encryptHash = auth::Crypto::hmacSHA1(encryptKey, sessionKey);
LOG_DEBUG("Decrypt hash: ", decryptHash.size(), " bytes"); std::vector<uint8_t> decryptHash = auth::Crypto::hmacSHA1(decryptKey, sessionKey);
// Initialize RC4 ciphers with HMAC results encryptCipher.init(encryptHash);
encryptCipher.init(encryptHash); decryptCipher.init(decryptHash);
decryptCipher.init(decryptHash);
// Drop first 1024 bytes of keystream (WoW protocol requirement) // Drop first 1024 bytes of keystream (WoW WotLK protocol requirement)
encryptCipher.drop(1024); encryptCipher.drop(1024);
decryptCipher.drop(1024); decryptCipher.drop(1024);
}
encryptionEnabled = true; encryptionEnabled = true;
headerTracePacketsLeft = 24; headerTracePacketsLeft = 24;

View file

@ -396,6 +396,12 @@ std::vector<uint8_t> AssetManager::readFileOptional(const std::string& path) con
return readFile(path); return readFile(path);
} }
void AssetManager::clearDBCCache() {
std::lock_guard<std::mutex> lock(cacheMutex);
dbcCache.clear();
LOG_INFO("Cleared DBC cache");
}
void AssetManager::clearCache() { void AssetManager::clearCache() {
std::lock_guard<std::mutex> lock(cacheMutex); std::lock_guard<std::mutex> lock(cacheMutex);
dbcCache.clear(); dbcCache.clear();

View file

@ -138,7 +138,7 @@ struct FBlockDisk {
uint32_t ofsKeys; uint32_t ofsKeys;
}; };
// Full M2 bone structure (on-disk, 88 bytes) // Full M2 bone structure (on-disk, 88 bytes for WotLK)
struct M2BoneDisk { struct M2BoneDisk {
int32_t keyBoneId; // 4 int32_t keyBoneId; // 4
uint32_t flags; // 4 uint32_t flags; // 4
@ -151,7 +151,31 @@ struct M2BoneDisk {
float pivot[3]; // 12 float pivot[3]; // 12
}; // Total: 88 }; // Total: 88
// M2 animation sequence structure // Vanilla M2 animation track header (on-disk, 28 bytes — has extra ranges M2Array)
struct M2TrackDiskVanilla {
uint16_t interpolationType; // 2
int16_t globalSequence; // 2
uint32_t nRanges; // 4 — extra in vanilla (animation sequence ranges)
uint32_t ofsRanges; // 4 — extra in vanilla
uint32_t nTimestamps; // 4
uint32_t ofsTimestamps; // 4
uint32_t nKeys; // 4
uint32_t ofsKeys; // 4
}; // Total: 28
// Vanilla M2 bone structure (on-disk, 108 bytes — no boneNameCRC, 28-byte tracks)
struct M2BoneDiskVanilla {
int32_t keyBoneId; // 4
uint32_t flags; // 4
int16_t parentBone; // 2
uint16_t submeshId; // 2
M2TrackDiskVanilla translation; // 28
M2TrackDiskVanilla rotation; // 28
M2TrackDiskVanilla scale; // 28
float pivot[3]; // 12
}; // Total: 108
// M2 animation sequence structure (WotLK, 64 bytes)
struct M2SequenceDisk { struct M2SequenceDisk {
uint16_t id; uint16_t id;
uint16_t variationIndex; uint16_t variationIndex;
@ -169,6 +193,25 @@ struct M2SequenceDisk {
uint16_t aliasNext; uint16_t aliasNext;
}; };
// Vanilla M2 animation sequence (68 bytes — has start_timestamp before duration)
struct M2SequenceDiskVanilla {
uint16_t id;
uint16_t variationIndex;
uint32_t startTimestamp; // Extra field in vanilla (removed in WotLK)
uint32_t endTimestamp; // Becomes 'duration' in WotLK
float movingSpeed;
uint32_t flags;
int16_t frequency;
uint16_t padding;
uint32_t replayMin;
uint32_t replayMax;
uint32_t blendTime;
float bounds[6];
float boundRadius;
int16_t nextAnimation;
uint16_t aliasNext;
};
// M2 texture definition // M2 texture definition
struct M2TextureDisk { struct M2TextureDisk {
uint32_t type; uint32_t type;
@ -210,6 +253,36 @@ struct M2SkinSubmesh {
float sortRadius; float sortRadius;
}; };
// Vanilla M2 skin submesh (32 bytes, version < 264 — no sortCenter/sortRadius)
struct M2SkinSubmeshVanilla {
uint16_t id;
uint16_t level;
uint16_t vertexStart;
uint16_t vertexCount;
uint16_t indexStart;
uint16_t indexCount;
uint16_t boneCount;
uint16_t boneStart;
uint16_t boneInfluences;
uint16_t centerBoneIndex;
float centerPosition[3];
};
// Embedded skin profile for vanilla M2 (no 'SKIN' magic, offsets are M2-file-relative)
struct M2SkinProfileEmbedded {
uint32_t nIndices;
uint32_t ofsIndices;
uint32_t nTriangles;
uint32_t ofsTriangles;
uint32_t nVertexProperties;
uint32_t ofsVertexProperties;
uint32_t nSubmeshes;
uint32_t ofsSubmeshes;
uint32_t nBatches;
uint32_t ofsBatches;
uint32_t nBones;
};
// Skin batch structure (24 bytes on disk) // Skin batch structure (24 bytes on disk)
struct M2BatchDisk { struct M2BatchDisk {
uint8_t flags; uint8_t flags;
@ -443,14 +516,23 @@ void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset,
M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) { M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
M2Model model; M2Model model;
if (m2Data.size() < sizeof(M2Header)) { // Read header with version-aware field parsing.
// Vanilla M2 (version < 264) has 3 extra fields totaling +20 bytes:
// +8: playableAnimLookup M2Array (after animationLookup)
// +4: ofsViews (after nViews, making it a full M2Array)
// +8: unknown extra M2Array (after texReplace, before renderFlags)
// Also: vanilla bones are 84 bytes (no boneNameCRC), sequences are 68 bytes.
constexpr size_t COMMON_PREFIX_SIZE = 0x2C; // magic through ofsAnimationLookup
if (m2Data.size() < COMMON_PREFIX_SIZE + 16) { // Need at least some fields after prefix
core::Logger::getInstance().error("M2 data too small"); core::Logger::getInstance().error("M2 data too small");
return model; return model;
} }
// Read header
M2Header header; M2Header header;
std::memcpy(&header, m2Data.data(), sizeof(M2Header)); std::memset(&header, 0, sizeof(header));
// Read common prefix (magic through ofsAnimationLookup) — same for all versions
std::memcpy(&header, m2Data.data(), COMMON_PREFIX_SIZE);
// Verify magic // Verify magic
if (std::strncmp(header.magic, "MD20", 4) != 0) { if (std::strncmp(header.magic, "MD20", 4) != 0) {
@ -458,6 +540,110 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
return model; return model;
} }
uint32_t ofsViews = 0;
if (header.version < 264) {
// Vanilla M2: read remaining header fields using cursor, skipping extra fields
size_t c = COMMON_PREFIX_SIZE;
auto r32 = [&]() -> uint32_t {
if (c + 4 > m2Data.size()) return 0;
uint32_t v;
std::memcpy(&v, m2Data.data() + c, 4);
c += 4;
return v;
};
// Skip playableAnimLookup M2Array (8 bytes)
c += 8;
// Bones through ofsVertices (same field order as WotLK, just shifted)
header.nBones = r32();
header.ofsBones = r32();
header.nKeyBoneLookup = r32();
header.ofsKeyBoneLookup = r32();
header.nVertices = r32();
header.ofsVertices = r32();
// nViews + ofsViews (vanilla has both, WotLK has only nViews)
header.nViews = r32();
ofsViews = r32();
// nColors through ofsTexReplace
header.nColors = r32();
header.ofsColors = r32();
header.nTextures = r32();
header.ofsTextures = r32();
header.nTransparency = r32();
header.ofsTransparency = r32();
header.nUVAnimation = r32();
header.ofsUVAnimation = r32();
header.nTexReplace = r32();
header.ofsTexReplace = r32();
// Skip unknown extra M2Array (8 bytes)
c += 8;
// nRenderFlags through ofsUVAnimLookup
header.nRenderFlags = r32();
header.ofsRenderFlags = r32();
header.nBoneLookupTable = r32();
header.ofsBoneLookupTable = r32();
header.nTexLookup = r32();
header.ofsTexLookup = r32();
header.nTexUnits = r32();
header.ofsTexUnits = r32();
header.nTransLookup = r32();
header.ofsTransLookup = r32();
header.nUVAnimLookup = r32();
header.ofsUVAnimLookup = r32();
// Float sections (vertexBox, vertexRadius, boundingBox, boundingRadius)
if (c + 56 <= m2Data.size()) {
std::memcpy(header.vertexBox, m2Data.data() + c, 24); c += 24;
std::memcpy(&header.vertexRadius, m2Data.data() + c, 4); c += 4;
std::memcpy(header.boundingBox, m2Data.data() + c, 24); c += 24;
std::memcpy(&header.boundingRadius, m2Data.data() + c, 4); c += 4;
} else { c += 56; }
// Remaining M2Array pairs
header.nBoundingTriangles = r32();
header.ofsBoundingTriangles = r32();
header.nBoundingVertices = r32();
header.ofsBoundingVertices = r32();
header.nBoundingNormals = r32();
header.ofsBoundingNormals = r32();
header.nAttachments = r32();
header.ofsAttachments = r32();
header.nAttachmentLookup = r32();
header.ofsAttachmentLookup = r32();
header.nEvents = r32();
header.ofsEvents = r32();
header.nLights = r32();
header.ofsLights = r32();
header.nCameras = r32();
header.ofsCameras = r32();
header.nCameraLookup = r32();
header.ofsCameraLookup = r32();
header.nRibbonEmitters = r32();
header.ofsRibbonEmitters = r32();
header.nParticleEmitters = r32();
header.ofsParticleEmitters = r32();
core::Logger::getInstance().info("Vanilla M2 (version ", header.version,
"): nVerts=", header.nVertices, " nViews=", header.nViews,
" ofsViews=", ofsViews, " nTex=", header.nTextures);
} else {
// WotLK: read remaining header with simple memcpy (no extra fields)
size_t wotlkSize = sizeof(M2Header) - COMMON_PREFIX_SIZE;
if (m2Data.size() < COMMON_PREFIX_SIZE + wotlkSize) {
core::Logger::getInstance().error("M2 data too small for WotLK header");
return model;
}
std::memcpy(reinterpret_cast<uint8_t*>(&header) + COMMON_PREFIX_SIZE,
m2Data.data() + COMMON_PREFIX_SIZE, wotlkSize);
}
core::Logger::getInstance().debug("Loading M2 model (version ", header.version, ")"); core::Logger::getInstance().debug("Loading M2 model (version ", header.version, ")");
// Read model name // Read model name
@ -494,27 +680,51 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
// Read animation sequences (needed before bones to know sequence count) // Read animation sequences (needed before bones to know sequence count)
if (header.nAnimations > 0 && header.ofsAnimations > 0) { if (header.nAnimations > 0 && header.ofsAnimations > 0) {
auto diskSeqs = readArray<M2SequenceDisk>(m2Data, header.ofsAnimations, header.nAnimations); model.sequences.reserve(header.nAnimations);
model.sequences.reserve(diskSeqs.size());
for (const auto& ds : diskSeqs) { if (header.version < 264) {
M2Sequence seq; // Vanilla: 68-byte sequence struct (has startTimestamp + endTimestamp)
seq.id = ds.id; auto diskSeqs = readArray<M2SequenceDiskVanilla>(m2Data, header.ofsAnimations, header.nAnimations);
seq.variationIndex = ds.variationIndex; for (const auto& ds : diskSeqs) {
seq.duration = ds.duration; M2Sequence seq;
seq.movingSpeed = ds.movingSpeed; seq.id = ds.id;
seq.flags = ds.flags; seq.variationIndex = ds.variationIndex;
seq.frequency = ds.frequency; seq.duration = (ds.endTimestamp > ds.startTimestamp)
seq.replayMin = ds.replayMin; ? (ds.endTimestamp - ds.startTimestamp) : ds.endTimestamp;
seq.replayMax = ds.replayMax; seq.movingSpeed = ds.movingSpeed;
seq.blendTime = ds.blendTime; seq.flags = ds.flags;
seq.boundMin = glm::vec3(ds.bounds[0], ds.bounds[1], ds.bounds[2]); seq.frequency = ds.frequency;
seq.boundMax = glm::vec3(ds.bounds[3], ds.bounds[4], ds.bounds[5]); seq.replayMin = ds.replayMin;
seq.boundRadius = ds.boundRadius; seq.replayMax = ds.replayMax;
seq.nextAnimation = ds.nextAnimation; seq.blendTime = ds.blendTime;
seq.aliasNext = ds.aliasNext; seq.boundMin = glm::vec3(ds.bounds[0], ds.bounds[1], ds.bounds[2]);
seq.boundMax = glm::vec3(ds.bounds[3], ds.bounds[4], ds.bounds[5]);
model.sequences.push_back(seq); seq.boundRadius = ds.boundRadius;
seq.nextAnimation = ds.nextAnimation;
seq.aliasNext = ds.aliasNext;
model.sequences.push_back(seq);
}
} else {
// WotLK: 64-byte sequence struct
auto diskSeqs = readArray<M2SequenceDisk>(m2Data, header.ofsAnimations, header.nAnimations);
for (const auto& ds : diskSeqs) {
M2Sequence seq;
seq.id = ds.id;
seq.variationIndex = ds.variationIndex;
seq.duration = ds.duration;
seq.movingSpeed = ds.movingSpeed;
seq.flags = ds.flags;
seq.frequency = ds.frequency;
seq.replayMin = ds.replayMin;
seq.replayMax = ds.replayMax;
seq.blendTime = ds.blendTime;
seq.boundMin = glm::vec3(ds.bounds[0], ds.bounds[1], ds.bounds[2]);
seq.boundMax = glm::vec3(ds.bounds[3], ds.bounds[4], ds.bounds[5]);
seq.boundRadius = ds.boundRadius;
seq.nextAnimation = ds.nextAnimation;
seq.aliasNext = ds.aliasNext;
model.sequences.push_back(seq);
}
} }
core::Logger::getInstance().debug(" Animation sequences: ", model.sequences.size()); core::Logger::getInstance().debug(" Animation sequences: ", model.sequences.size());
@ -529,8 +739,8 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
// Read bones with full animation track data // Read bones with full animation track data
if (header.nBones > 0 && header.ofsBones > 0) { if (header.nBones > 0 && header.ofsBones > 0) {
// Verify we have enough data for the full bone structures size_t boneStructSize = (header.version < 264) ? sizeof(M2BoneDiskVanilla) : sizeof(M2BoneDisk);
uint32_t expectedBoneSize = header.nBones * sizeof(M2BoneDisk); uint64_t expectedBoneSize = static_cast<uint64_t>(header.nBones) * boneStructSize;
if (header.ofsBones + expectedBoneSize > m2Data.size()) { if (header.ofsBones + expectedBoneSize > m2Data.size()) {
core::Logger::getInstance().warning("M2 bone data extends beyond file, loading with fallback"); core::Logger::getInstance().warning("M2 bone data extends beyond file, loading with fallback");
} }
@ -546,8 +756,8 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
} }
for (uint32_t boneIdx = 0; boneIdx < header.nBones; boneIdx++) { for (uint32_t boneIdx = 0; boneIdx < header.nBones; boneIdx++) {
uint32_t boneOffset = header.ofsBones + boneIdx * sizeof(M2BoneDisk); uint32_t boneOffset = header.ofsBones + boneIdx * boneStructSize;
if (boneOffset + sizeof(M2BoneDisk) > m2Data.size()) { if (boneOffset + boneStructSize > m2Data.size()) {
// Fallback: create identity bone // Fallback: create identity bone
M2Bone bone; M2Bone bone;
bone.keyBoneId = -1; bone.keyBoneId = -1;
@ -559,19 +769,46 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
continue; continue;
} }
M2BoneDisk db = readValue<M2BoneDisk>(m2Data, boneOffset);
M2Bone bone; M2Bone bone;
bone.keyBoneId = db.keyBoneId; M2TrackDisk translation, rotation, scale;
bone.flags = db.flags;
bone.parentBone = db.parentBone;
bone.submeshId = db.submeshId;
bone.pivot = glm::vec3(db.pivot[0], db.pivot[1], db.pivot[2]);
// Parse animation tracks (skip sequences with external .anim data) if (header.version < 264) {
parseAnimTrack(m2Data, db.translation, bone.translation, TrackType::VEC3, seqFlags); // Vanilla: 108-byte bone (no boneNameCRC, 28-byte tracks with ranges)
parseAnimTrack(m2Data, db.rotation, bone.rotation, TrackType::QUAT_COMPRESSED, seqFlags); M2BoneDiskVanilla db = readValue<M2BoneDiskVanilla>(m2Data, boneOffset);
parseAnimTrack(m2Data, db.scale, bone.scale, TrackType::VEC3, seqFlags); bone.keyBoneId = db.keyBoneId;
bone.flags = db.flags;
bone.parentBone = db.parentBone;
bone.submeshId = db.submeshId;
bone.pivot = glm::vec3(db.pivot[0], db.pivot[1], db.pivot[2]);
// Convert vanilla 28-byte tracks to WotLK 20-byte format (drop ranges)
translation = {db.translation.interpolationType, db.translation.globalSequence,
db.translation.nTimestamps, db.translation.ofsTimestamps,
db.translation.nKeys, db.translation.ofsKeys};
rotation = {db.rotation.interpolationType, db.rotation.globalSequence,
db.rotation.nTimestamps, db.rotation.ofsTimestamps,
db.rotation.nKeys, db.rotation.ofsKeys};
scale = {db.scale.interpolationType, db.scale.globalSequence,
db.scale.nTimestamps, db.scale.ofsTimestamps,
db.scale.nKeys, db.scale.ofsKeys};
} else {
// WotLK: 88-byte bone
M2BoneDisk db = readValue<M2BoneDisk>(m2Data, boneOffset);
bone.keyBoneId = db.keyBoneId;
bone.flags = db.flags;
bone.parentBone = db.parentBone;
bone.submeshId = db.submeshId;
bone.pivot = glm::vec3(db.pivot[0], db.pivot[1], db.pivot[2]);
translation = db.translation;
rotation = db.rotation;
scale = db.scale;
}
// Parse animation tracks (skip for vanilla — flat array format differs from WotLK)
if (header.version >= 264) {
parseAnimTrack(m2Data, translation, bone.translation, TrackType::VEC3, seqFlags);
parseAnimTrack(m2Data, rotation, bone.rotation, TrackType::QUAT_COMPRESSED, seqFlags);
parseAnimTrack(m2Data, scale, bone.scale, TrackType::VEC3, seqFlags);
}
if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) { if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) {
bonesWithKeyframes++; bonesWithKeyframes++;
@ -623,8 +860,8 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
core::Logger::getInstance().debug(" Materials: ", model.materials.size()); core::Logger::getInstance().debug(" Materials: ", model.materials.size());
} }
// Read texture transforms (UV animation data) // Read texture transforms (UV animation data) — skip for vanilla (different track format)
if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0) { if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0 && header.version >= 264) {
// Build per-sequence flags for skipping external .anim data // Build per-sequence flags for skipping external .anim data
std::vector<uint32_t> seqFlags; std::vector<uint32_t> seqFlags;
seqFlags.reserve(model.sequences.size()); seqFlags.reserve(model.sequences.size());
@ -672,8 +909,10 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
} }
// Parse particle emitters (WotLK M2ParticleOld: 0x1DC = 476 bytes per emitter) // Parse particle emitters (WotLK M2ParticleOld: 0x1DC = 476 bytes per emitter)
// Skip for vanilla — emitter struct size differs
static constexpr uint32_t EMITTER_STRUCT_SIZE = 0x1DC; static constexpr uint32_t EMITTER_STRUCT_SIZE = 0x1DC;
if (header.nParticleEmitters > 0 && header.ofsParticleEmitters > 0 && if (header.version >= 264 &&
header.nParticleEmitters > 0 && header.ofsParticleEmitters > 0 &&
header.nParticleEmitters < 256 && header.nParticleEmitters < 256 &&
static_cast<size_t>(header.ofsParticleEmitters) + static_cast<size_t>(header.ofsParticleEmitters) +
static_cast<size_t>(header.nParticleEmitters) * EMITTER_STRUCT_SIZE <= m2Data.size()) { static_cast<size_t>(header.nParticleEmitters) * EMITTER_STRUCT_SIZE <= m2Data.size()) {
@ -758,6 +997,113 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.collisionNormals.size(), " normals"); model.collisionNormals.size(), " normals");
} }
// Load embedded skin for vanilla M2 (version < 264)
// Vanilla M2 files contain skin profiles directly, no external .skin files.
if (header.version < 264 && header.nViews > 0 && ofsViews > 0 &&
ofsViews + sizeof(M2SkinProfileEmbedded) <= m2Data.size()) {
M2SkinProfileEmbedded skinProfile;
std::memcpy(&skinProfile, m2Data.data() + ofsViews, sizeof(skinProfile));
// Read vertex lookup table (maps skin-local indices to global vertex indices)
std::vector<uint16_t> vertexLookup;
if (skinProfile.nIndices > 0 && skinProfile.ofsIndices > 0) {
vertexLookup = readArray<uint16_t>(m2Data, skinProfile.ofsIndices, skinProfile.nIndices);
}
// Read triangle indices (indices into the vertex lookup table)
std::vector<uint16_t> triangles;
if (skinProfile.nTriangles > 0 && skinProfile.ofsTriangles > 0) {
triangles = readArray<uint16_t>(m2Data, skinProfile.ofsTriangles, skinProfile.nTriangles);
}
// Resolve two-level indirection: triangle index -> lookup table -> global vertex
model.indices.clear();
model.indices.reserve(triangles.size());
for (uint16_t triIdx : triangles) {
if (triIdx < vertexLookup.size()) {
uint16_t globalIdx = vertexLookup[triIdx];
if (globalIdx < model.vertices.size()) {
model.indices.push_back(globalIdx);
} else {
model.indices.push_back(0);
}
} else {
model.indices.push_back(0);
}
}
// Read submeshes (vanilla: 32 bytes each, no sortCenter/sortRadius)
std::vector<M2SkinSubmesh> submeshes;
if (skinProfile.nSubmeshes > 0 && skinProfile.ofsSubmeshes > 0) {
auto vanillaSubmeshes = readArray<M2SkinSubmeshVanilla>(m2Data,
skinProfile.ofsSubmeshes, skinProfile.nSubmeshes);
submeshes.reserve(vanillaSubmeshes.size());
for (const auto& vs : vanillaSubmeshes) {
M2SkinSubmesh sm;
sm.id = vs.id;
sm.level = vs.level;
sm.vertexStart = vs.vertexStart;
sm.vertexCount = vs.vertexCount;
sm.indexStart = vs.indexStart;
sm.indexCount = vs.indexCount;
sm.boneCount = vs.boneCount;
sm.boneStart = vs.boneStart;
sm.boneInfluences = vs.boneInfluences;
sm.centerBoneIndex = vs.centerBoneIndex;
std::memcpy(sm.centerPosition, vs.centerPosition, 12);
std::memset(sm.sortCenterPosition, 0, 12);
sm.sortRadius = 0;
submeshes.push_back(sm);
}
}
// Read batches
if (skinProfile.nBatches > 0 && skinProfile.ofsBatches > 0) {
auto diskBatches = readArray<M2BatchDisk>(m2Data,
skinProfile.ofsBatches, skinProfile.nBatches);
model.batches.clear();
model.batches.reserve(diskBatches.size());
for (size_t i = 0; i < diskBatches.size(); i++) {
const auto& db = diskBatches[i];
M2Batch batch;
batch.flags = db.flags;
batch.priorityPlane = db.priorityPlane;
batch.shader = db.shader;
batch.skinSectionIndex = db.skinSectionIndex;
batch.colorIndex = db.colorIndex;
batch.materialIndex = db.materialIndex;
batch.materialLayer = db.materialLayer;
batch.textureCount = db.textureCount;
batch.textureIndex = db.textureComboIndex;
batch.textureUnit = db.textureCoordIndex;
batch.transparencyIndex = db.textureWeightIndex;
batch.textureAnimIndex = db.textureTransformIndex;
if (db.skinSectionIndex < submeshes.size()) {
const auto& sm = submeshes[db.skinSectionIndex];
batch.indexStart = sm.indexStart;
batch.indexCount = sm.indexCount;
batch.vertexStart = sm.vertexStart;
batch.vertexCount = sm.vertexCount;
batch.submeshId = sm.id;
batch.submeshLevel = sm.level;
} else {
batch.indexStart = 0;
batch.indexCount = model.indices.size();
batch.vertexStart = 0;
batch.vertexCount = model.vertices.size();
}
model.batches.push_back(batch);
}
}
core::Logger::getInstance().info("Vanilla M2: embedded skin loaded — ",
model.indices.size(), " indices, ", model.batches.size(), " batches");
}
static int m2LoadLogBudget = 200; static int m2LoadLogBudget = 200;
if (m2LoadLogBudget-- > 0) { if (m2LoadLogBudget-- > 0) {
core::Logger::getInstance().debug("M2 model loaded: ", model.name); core::Logger::getInstance().debug("M2 model loaded: ", model.name);

View file

@ -220,8 +220,14 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
} }
} }
LOG_INFO("CharPreview tex lookup: bodySkin=", bodySkinPath_, " faceLower=", faceLowerPath,
" faceUpper=", faceUpperPath, " hairScalp=", hairScalpPath,
" underwear=", underwearPaths.size());
// Assign texture filenames on model before GPU upload // Assign texture filenames on model before GPU upload
for (auto& tex : model.textures) { for (size_t ti = 0; ti < model.textures.size(); ti++) {
auto& tex = model.textures[ti];
LOG_INFO(" model.textures[", ti, "]: type=", tex.type, " filename=", tex.filename);
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) { if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) {
tex.filename = bodySkinPath_; tex.filename = bodySkinPath_;
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) { } else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
@ -250,6 +256,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
LOG_WARNING("CharacterPreview: failed to load model to GPU"); LOG_WARNING("CharacterPreview: failed to load model to GPU");
return false; return false;
} }
LOG_INFO("CharPreview: model loaded to GPU, textureLookup size=", model.textureLookup.size(),
" materials=", model.materials.size(), " batches=", model.batches.size());
// Composite body skin + face + underwear overlays // Composite body skin + face + underwear overlays
if (!bodySkinPath_.empty()) { if (!bodySkinPath_.empty()) {
@ -311,8 +319,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
// Set default geosets (naked character) // Set default geosets (naked character)
std::unordered_set<uint16_t> activeGeosets; std::unordered_set<uint16_t> activeGeosets;
// Body parts (group 0: IDs 0-18) // Body parts (group 0: IDs 0-99, vanilla models use up to 27)
for (uint16_t i = 0; i <= 18; i++) { for (uint16_t i = 0; i <= 99; i++) {
activeGeosets.insert(i); activeGeosets.insert(i);
} }
// Hair style geoset: group 1 = 100 + variation + 1 // Hair style geoset: group 1 = 100 + variation + 1
@ -393,7 +401,7 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
// --- Geosets --- // --- Geosets ---
std::unordered_set<uint16_t> geosets; std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); for (uint16_t i = 0; i <= 99; i++) geosets.insert(i);
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style
geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair
geosets.insert(701); // Ears geosets.insert(701); // Ears

View file

@ -96,6 +96,7 @@ void AuthScreen::selectServerProfile(int index) {
for (int i = 0; i < static_cast<int>(profiles.size()); ++i) { for (int i = 0; i < static_cast<int>(profiles.size()); ++i) {
if (profiles[i].id == s.expansionId) { expansionIndex = i; break; } if (profiles[i].id == s.expansionId) { expansionIndex = i; break; }
} }
core::Application::getInstance().reloadExpansionData();
} }
} }
} }
@ -311,6 +312,7 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
if (ImGui::Selectable(label.c_str(), selected)) { if (ImGui::Selectable(label.c_str(), selected)) {
expansionIndex = i; expansionIndex = i;
registry->setActive(profiles[i].id); registry->setActive(profiles[i].id);
core::Application::getInstance().reloadExpansionData();
} }
if (selected) ImGui::SetItemDefaultFocus(); if (selected) ImGui::SetItemDefaultFocus();
} }

View file

@ -246,8 +246,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
(previewAppearanceBytes_ != character.appearanceBytes) || (previewAppearanceBytes_ != character.appearanceBytes) ||
(previewFacialFeatures_ != character.facialFeatures) || (previewFacialFeatures_ != character.facialFeatures) ||
(previewUseFemaleModel_ != character.useFemaleModel) || (previewUseFemaleModel_ != character.useFemaleModel) ||
(previewEquipHash_ != equipHash) || (previewEquipHash_ != equipHash);
(!preview_->isModelLoaded());
if (changed) { if (changed) {
uint8_t skin = character.appearanceBytes & 0xFF; uint8_t skin = character.appearanceBytes & 0xFF;