Fix character appearance, previews, mount seat, and online unequip

This commit is contained in:
Kelsi 2026-02-12 14:55:27 -08:00
parent 4a023e773b
commit 275914b4db
19 changed files with 743 additions and 113 deletions

View file

@ -117,6 +117,9 @@ private:
game::Race playerRace_ = game::Race::HUMAN;
game::Gender playerGender_ = game::Gender::MALE;
game::Class playerClass_ = game::Class::WARRIOR;
uint64_t spawnedPlayerGuid_ = 0;
uint32_t spawnedAppearanceBytes_ = 0;
uint8_t spawnedFacialFeatures_ = 0;
// Weapon model ID counter (starting high to avoid collision with character model IDs)
uint32_t nextWeaponModelId_ = 1000;

View file

@ -236,6 +236,7 @@ public:
Inventory& getInventory() { return inventory; }
const Inventory& getInventory() const { return inventory; }
bool consumeOnlineEquipmentDirty() { bool d = onlineEquipDirty_; onlineEquipDirty_ = false; return d; }
void unequipToBackpack(EquipSlot equipSlot);
// Targeting
void setTarget(uint64_t guid);

View file

@ -261,6 +261,8 @@ enum class Opcode : uint16_t {
SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058,
CMSG_USE_ITEM = 0x00AB,
CMSG_AUTOEQUIP_ITEM = 0x10A,
CMSG_SWAP_ITEM = 0x10C,
CMSG_SWAP_INV_ITEM = 0x10D,
SMSG_INVENTORY_CHANGE_FAILURE = 0x112,
CMSG_INSPECT = 0x114,
SMSG_INSPECT_RESULTS = 0x115,

View file

@ -1495,6 +1495,21 @@ public:
static network::Packet build(uint8_t srcBag, uint8_t srcSlot);
};
/** CMSG_SWAP_ITEM packet builder */
class SwapItemPacket {
public:
// Order matches AzerothCore handler: destBag, destSlot, srcBag, srcSlot.
static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot);
};
/** CMSG_SWAP_INV_ITEM packet builder */
class SwapInvItemPacket {
public:
// WoW inventory: slots are in the "inventory" range (equipment 0-18, bags 19-22, backpack 23-38).
// This swaps two inventory slots directly.
static network::Packet build(uint8_t srcSlot, uint8_t dstSlot);
};
/** CMSG_LOOT_MONEY packet builder (empty body) */
class LootMoneyPacket {
public:

View file

@ -4,6 +4,8 @@
#include <GL/glew.h>
#include <memory>
#include <cstdint>
#include <string>
#include <vector>
namespace wowee {
namespace pipeline { class AssetManager; }
@ -25,6 +27,9 @@ public:
uint8_t hairStyle, uint8_t hairColor,
uint8_t facialHair, bool useFemaleModel = false);
// Apply equipment overlays/geosets using SMSG_CHAR_ENUM equipment data (ItemDisplayInfo.dbc).
bool applyEquipment(const std::vector<game::EquipmentItem>& equipment);
void update(float deltaTime);
void render();
void rotate(float yawDelta);
@ -56,6 +61,16 @@ private:
uint32_t instanceId_ = 0;
bool modelLoaded_ = false;
float modelYaw_ = 180.0f;
// Cached info from loadCharacter() for later recompositing.
game::Race race_ = game::Race::HUMAN;
game::Gender gender_ = game::Gender::MALE;
bool useFemaleModel_ = false;
uint8_t hairStyle_ = 0;
uint8_t facialHair_ = 0;
std::string bodySkinPath_;
std::vector<std::string> baseLayers_; // face + underwear, etc.
uint32_t skinTextureSlotIndex_ = 0;
};
} // namespace rendering

View file

@ -7,6 +7,7 @@
#include <functional>
#include <vector>
#include <memory>
#include <cstdint>
namespace wowee {
namespace game { class GameHandler; }
@ -39,6 +40,10 @@ private:
std::string statusMessage;
bool statusIsError = false;
// For many races/styles, CharSections hair color IDs are not guaranteed to be contiguous.
// We expose an index (hairColor) in the UI and map it to the actual DBC hairColorId here.
std::vector<uint8_t> hairColorIds_;
std::vector<game::Class> availableClasses;
void updateAvailableClasses();

View file

@ -4,8 +4,12 @@
#include <imgui.h>
#include <string>
#include <functional>
#include <memory>
namespace wowee { namespace ui {
namespace wowee {
namespace pipeline { class AssetManager; }
namespace rendering { class CharacterPreview; }
namespace ui {
/**
* Character selection screen UI
@ -22,6 +26,11 @@ public:
*/
void render(game::GameHandler& gameHandler);
void setAssetManager(pipeline::AssetManager* am) {
assetManager_ = am;
previewInitialized_ = false;
}
/**
* Set callback for character selection
* @param callback Function to call when character is selected (receives character GUID)
@ -83,6 +92,17 @@ private:
static std::string getConfigDir();
void saveLastCharacter(uint64_t guid);
uint64_t loadLastCharacter();
// Preview (3D character portrait)
pipeline::AssetManager* assetManager_ = nullptr;
std::unique_ptr<rendering::CharacterPreview> preview_;
bool previewInitialized_ = false;
uint64_t previewGuid_ = 0;
uint32_t previewAppearanceBytes_ = 0;
uint8_t previewFacialFeatures_ = 0;
bool previewUseFemaleModel_ = false;
uint64_t previewEquipHash_ = 0;
};
}} // namespace wowee::ui
} // namespace ui
} // namespace wowee

View file

@ -131,6 +131,13 @@ bool Application::initialize() {
// Eagerly load creature display DBC lookups so first spawn doesn't stall
buildCreatureDisplayLookups();
// Ensure the main in-world CharacterRenderer can load textures immediately.
// Previously this was only wired during terrain initialization, which meant early spawns
// (before terrain load) would render with white fallback textures (notably hair).
if (renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->setAssetManager(assetManager.get());
}
// Load transport paths from TransportAnimation.dbc
if (gameHandler && gameHandler->getTransportManager()) {
gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get());
@ -314,6 +321,26 @@ void Application::setState(AppState newState) {
break;
case AppState::CHARACTER_SELECTION:
// Show character screen
if (uiManager && assetManager) {
uiManager->getCharacterScreen().setAssetManager(assetManager.get());
}
// Ensure no stale in-world player model leaks into the next login attempt.
// If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync.
npcsSpawned = false;
playerCharacterSpawned = false;
weaponsSheathed_ = false;
wasAutoAttacking_ = false;
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
if (renderer && renderer->getCharacterRenderer()) {
uint32_t oldInst = renderer->getCharacterInstanceId();
if (oldInst > 0) {
renderer->setCharacterFollow(0);
renderer->clearMount();
renderer->getCharacterRenderer()->removeInstance(oldInst);
}
}
break;
case AppState::IN_GAME: {
// Wire up movement opcodes from camera controller
@ -1829,29 +1856,37 @@ void Application::spawnPlayerCharacter() {
glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size
if (instanceId > 0) {
// Set up third-person follow
renderer->getCharacterPosition() = spawnPos;
renderer->setCharacterFollow(instanceId);
// Set up third-person follow
renderer->getCharacterPosition() = spawnPos;
renderer->setCharacterFollow(instanceId);
// Default geosets for naked human male
// Use actual submesh IDs from the model (logged at load time)
std::unordered_set<uint16_t> activeGeosets;
// Body parts (group 0: IDs 0-99) - humanoid models may have many body submeshes
for (uint16_t i = 0; i < 100; i++) {
activeGeosets.insert(i);
}
// Equipment groups: "01" = bare skin, "02" = first equipped variant
activeGeosets.insert(101); // Hair style 1
activeGeosets.insert(201); // Facial hair: none
activeGeosets.insert(301); // Gloves: bare hands
activeGeosets.insert(401); // Boots: bare feet
activeGeosets.insert(501); // Chest: bare
activeGeosets.insert(701); // Ears: default
activeGeosets.insert(1301); // Trousers: bare legs
activeGeosets.insert(1501); // Back body (cloak=none)
// 1703 = DK eye glow mesh — skip for normal characters
// Normal eyes are part of the face texture on the body mesh
charRenderer->setActiveGeosets(instanceId, activeGeosets);
// Default geosets for the active character (match CharacterPreview logic).
// Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world.
std::unordered_set<uint16_t> activeGeosets;
// Body parts (group 0)
for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i);
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (gameHandler) {
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
facialId = ch->facialFeatures;
}
}
// Hair style geoset: group 1 = 100 + variation + 1
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
// Facial hair geoset: group 2 = 200 + variation + 1
activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1));
activeGeosets.insert(301); // Gloves: bare hands
activeGeosets.insert(401); // Boots: bare feet
activeGeosets.insert(501); // Chest: bare
activeGeosets.insert(701); // Ears: default
activeGeosets.insert(1301); // Trousers: bare legs
activeGeosets.insert(1501); // Back body (cloak=none)
// 1703 = DK eye glow mesh — skip for normal characters
// Normal eyes are part of the face texture on the body mesh
charRenderer->setActiveGeosets(instanceId, activeGeosets);
// Play idle animation (Stand = animation ID 0)
charRenderer->playAnimation(instanceId, 0, true);
@ -1861,6 +1896,18 @@ void Application::spawnPlayerCharacter() {
static_cast<int>(spawnPos.z), ")");
playerCharacterSpawned = true;
// Track which character's appearance this instance represents so we can
// respawn if the user logs into a different character without restarting.
spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
if (gameHandler) {
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
spawnedAppearanceBytes_ = ch->appearanceBytes;
spawnedFacialFeatures_ = ch->facialFeatures;
}
}
// Set up camera controller for first-person player hiding
if (renderer->getCameraController()) {
renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId);
@ -2235,11 +2282,40 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
if (gameHandler) {
const game::Character* activeChar = gameHandler->getActiveCharacter();
if (activeChar) {
if (!playerCharacterSpawned) {
const uint64_t activeGuid = gameHandler->getActiveCharacterGuid();
const bool appearanceChanged =
(activeGuid != spawnedPlayerGuid_) ||
(activeChar->appearanceBytes != spawnedAppearanceBytes_) ||
(activeChar->facialFeatures != spawnedFacialFeatures_) ||
(activeChar->race != playerRace_) ||
(activeChar->gender != playerGender_) ||
(activeChar->characterClass != playerClass_);
if (!playerCharacterSpawned || appearanceChanged) {
if (appearanceChanged) {
LOG_INFO("Respawning player model for new/changed character: guid=0x",
std::hex, activeGuid, std::dec);
}
// Remove old instance so we don't keep stale visuals.
if (renderer && renderer->getCharacterRenderer()) {
uint32_t oldInst = renderer->getCharacterInstanceId();
if (oldInst > 0) {
renderer->setCharacterFollow(0);
renderer->clearMount();
renderer->getCharacterRenderer()->removeInstance(oldInst);
}
}
playerCharacterSpawned = false;
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
playerRace_ = activeChar->race;
playerGender_ = activeChar->gender;
playerClass_ = activeChar->characterClass;
spawnSnapToGround = false;
weaponsSheathed_ = false;
loadEquippedWeapons(); // will no-op until instance exists
spawnPlayerCharacter();
}
renderer->getCharacterPosition() = spawnRender;

View file

@ -1485,6 +1485,10 @@ void GameHandler::requestCharacterList() {
LOG_INFO("Requesting character list from server...");
// Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM.
// This matters after character create/delete where the old list can linger for a few frames.
characters.clear();
// Build CMSG_CHAR_ENUM packet (no body, just opcode)
auto packet = CharEnumPacket::build();
@ -1663,6 +1667,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
return;
}
// Make the selected character authoritative in GameHandler.
// This avoids relying on UI/Application ordering for appearance-dependent logic.
activeCharacterGuid_ = characterGuid;
LOG_INFO("========================================");
LOG_INFO(" ENTERING WORLD");
LOG_INFO("========================================");
@ -5980,6 +5988,28 @@ void GameHandler::autoEquipItemBySlot(int backpackIndex) {
}
}
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
if (state != WorldState::IN_WORLD || !socket) return;
int freeSlot = inventory.findFreeBackpackSlot();
if (freeSlot < 0) {
addSystemChatMessage("Cannot unequip: no free backpack slots.");
return;
}
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
uint8_t srcBag = 0xFF;
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
socket->send(packet);
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);

View file

@ -2398,6 +2398,22 @@ network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) {
return packet;
}
network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SWAP_ITEM));
packet.writeUInt8(dstBag);
packet.writeUInt8(dstSlot);
packet.writeUInt8(srcBag);
packet.writeUInt8(srcSlot);
return packet;
}
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SWAP_INV_ITEM));
packet.writeUInt8(srcSlot);
packet.writeUInt8(dstSlot);
return packet;
}
network::Packet LootMoneyPacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LOOT_MONEY));
return packet;

View file

@ -6,6 +6,7 @@
#include <iomanip>
#include <sstream>
#include <cstdio>
#include <fstream>
namespace {
constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024;
@ -130,6 +131,64 @@ void WorldSocket::send(const Packet& packet) {
uint16_t opcode = packet.getOpcode();
uint16_t payloadLen = static_cast<uint16_t>(data.size());
// Debug: parse and log character-create payload fields (helps diagnose appearance issues).
if (opcode == 0x036) { // CMSG_CHAR_CREATE
size_t pos = 0;
std::string name;
while (pos < data.size()) {
uint8_t c = data[pos++];
if (c == 0) break;
name.push_back(static_cast<char>(c));
}
auto rd8 = [&](uint8_t& out) -> bool {
if (pos >= data.size()) return false;
out = data[pos++];
return true;
};
uint8_t race = 0, cls = 0, gender = 0;
uint8_t skin = 0, face = 0, hairStyle = 0, hairColor = 0, facial = 0, outfit = 0;
bool ok =
rd8(race) && rd8(cls) && rd8(gender) &&
rd8(skin) && rd8(face) && rd8(hairStyle) && rd8(hairColor) && rd8(facial) && rd8(outfit);
if (ok) {
LOG_INFO("CMSG_CHAR_CREATE payload: name='", name,
"' race=", (int)race, " class=", (int)cls, " gender=", (int)gender,
" skin=", (int)skin, " face=", (int)face,
" hairStyle=", (int)hairStyle, " hairColor=", (int)hairColor,
" facial=", (int)facial, " outfit=", (int)outfit,
" payloadLen=", payloadLen);
// Persist to disk so we can compare TX vs DB even if the console scrolls away.
std::ofstream f("charcreate_payload.log", std::ios::app);
if (f.is_open()) {
f << "name='" << name << "'"
<< " race=" << (int)race
<< " class=" << (int)cls
<< " gender=" << (int)gender
<< " skin=" << (int)skin
<< " face=" << (int)face
<< " hairStyle=" << (int)hairStyle
<< " hairColor=" << (int)hairColor
<< " facial=" << (int)facial
<< " outfit=" << (int)outfit
<< " payloadLen=" << payloadLen
<< "\n";
}
} else {
LOG_WARNING("CMSG_CHAR_CREATE payload too short to parse (name='", name,
"' payloadLen=", payloadLen, " pos=", pos, ")");
}
}
if (opcode == 0x10C || opcode == 0x10D) { // CMSG_SWAP_ITEM / CMSG_SWAP_INV_ITEM
std::string hex;
for (size_t i = 0; i < data.size(); i++) {
char buf[4];
snprintf(buf, sizeof(buf), "%02x ", data[i]);
hex += buf;
}
LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]");
}
// WotLK 3.3.5 CMSG header (6 bytes total):
// - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG)
// - opcode (4 bytes, little-endian)

View file

@ -150,11 +150,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
uint32_t targetRaceId = static_cast<uint32_t>(race);
uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u;
std::string bodySkinPath;
std::string faceLowerPath;
std::string faceUpperPath;
std::string hairScalpPath;
std::vector<std::string> underwearPaths;
bodySkinPath_.clear();
baseLayers_.clear();
auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
if (charSectionsDbc) {
@ -177,7 +178,7 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
variationIndex == 0 && colorIndex == static_cast<uint32_t>(skin)) {
std::string tex1 = charSectionsDbc->getString(r, 4);
if (!tex1.empty()) {
bodySkinPath = tex1;
bodySkinPath_ = tex1;
foundSkin = true;
}
}
@ -217,8 +218,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
// Assign texture filenames on model before GPU upload
for (auto& tex : model.textures) {
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath.empty()) {
tex.filename = bodySkinPath;
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) {
tex.filename = bodySkinPath_;
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
tex.filename = hairScalpPath;
}
@ -247,9 +248,9 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
}
// Composite body skin + face + underwear overlays
if (!bodySkinPath.empty()) {
if (!bodySkinPath_.empty()) {
std::vector<std::string> layers;
layers.push_back(bodySkinPath);
layers.push_back(bodySkinPath_);
// Face lower texture composited onto body at the face region
if (!faceLowerPath.empty()) {
layers.push_back(faceLowerPath);
@ -261,6 +262,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
layers.push_back(up);
}
// Cache for later equipment compositing.
// Keep baseLayers_ without the base skin (compositeWithRegions takes basePath separately).
if (!faceLowerPath.empty()) baseLayers_.push_back(faceLowerPath);
if (!faceUpperPath.empty()) baseLayers_.push_back(faceUpperPath);
for (const auto& up : underwearPaths) baseLayers_.push_back(up);
if (layers.size() > 1) {
GLuint compositeTex = charRenderer_->compositeTextures(layers);
if (compositeTex != 0) {
@ -319,6 +326,22 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
// Play idle animation (Stand = animation ID 0)
charRenderer_->playAnimation(instanceId_, 0, true);
// Cache core appearance for later equipment geosets.
race_ = race;
gender_ = gender;
useFemaleModel_ = useFemaleModel;
hairStyle_ = hairStyle;
facialHair_ = facialHair;
// Cache the type-1 texture slot index so applyEquipment can update it.
skinTextureSlotIndex_ = 0;
for (size_t ti = 0; ti < model.textures.size(); ti++) {
if (model.textures[ti].type == 1) {
skinTextureSlotIndex_ = static_cast<uint32_t>(ti);
break;
}
}
modelLoaded_ = true;
LOG_INFO("CharacterPreview: loaded ", m2Path,
" skin=", (int)skin, " face=", (int)face,
@ -327,6 +350,150 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
return true;
}
bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& equipment) {
if (!modelLoaded_ || instanceId_ == 0 || !charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) {
return false;
}
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc || !displayInfoDbc->isLoaded()) {
return false;
}
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
for (uint8_t t : types) {
if (it.inventoryType == t) return true;
}
}
return false;
};
auto findDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
for (uint8_t t : types) {
if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum)
}
}
return 0;
};
auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t {
if (displayInfoId == 0) return 0;
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
};
// --- Geosets ---
std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 18; i++) geosets.insert(i);
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style
geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair
geosets.insert(701); // Ears
// Default naked geosets
uint16_t geosetGloves = 301;
uint16_t geosetBoots = 401;
uint16_t geosetChest = 501;
uint16_t geosetPants = 1301;
// Chest/Shirt/Robe
{
uint32_t did = findDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetChest = static_cast<uint16_t>(501 + gg);
// Robe kilt legs
uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
}
// Legs
{
uint32_t did = findDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetPants = static_cast<uint16_t>(1301 + gg);
}
// Feet
{
uint32_t did = findDisplayId({8});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetBoots = static_cast<uint16_t>(401 + gg);
}
// Hands
{
uint32_t did = findDisplayId({10});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetGloves = static_cast<uint16_t>(301 + gg);
}
geosets.insert(geosetGloves);
geosets.insert(geosetBoots);
geosets.insert(geosetChest);
geosets.insert(geosetPants);
geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited)
if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle
// Hide hair under helmets (helmets are separate models; this still avoids hair clipping)
if (hasInvType({1})) {
geosets.erase(static_cast<uint16_t>(100 + hairStyle_ + 1));
geosets.insert(1); // Bald scalp cap
geosets.insert(101); // Default group-1 connector
}
charRenderer_->setActiveGeosets(instanceId_, geosets);
// --- Textures (equipment overlays onto body skin) ---
if (bodySkinPath_.empty()) return true; // geosets applied, but can't composite
static const char* componentDirs[] = {
"ArmUpperTexture", "ArmLowerTexture", "HandTexture",
"TorsoUpperTexture", "TorsoLowerTexture",
"LegUpperTexture", "LegLowerTexture", "FootTexture",
};
std::vector<std::pair<int, std::string>> regionLayers;
regionLayers.reserve(32);
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
int32_t recIdx = displayInfoDbc->findRecordById(it.displayModel);
if (recIdx < 0) continue;
for (int region = 0; region < 8; region++) {
uint32_t fieldIdx = 15 + region; // texture_1..texture_8
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
if (texName.empty()) continue;
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
std::string genderSuffix = (gender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp";
std::string genderPath = base + genderSuffix;
std::string unisexPath = base + "_U.blp";
std::string fullPath;
if (assetManager_->fileExists(genderPath)) {
fullPath = genderPath;
} else if (assetManager_->fileExists(unisexPath)) {
fullPath = unisexPath;
} else {
fullPath = base + ".blp";
}
regionLayers.emplace_back(region, fullPath);
}
}
if (!regionLayers.empty()) {
GLuint newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers);
if (newTex != 0) {
charRenderer_->setModelTexture(PREVIEW_MODEL_ID, skinTextureSlotIndex_, newTex);
}
}
return true;
}
void CharacterPreview::update(float deltaTime) {
if (charRenderer_ && modelLoaded_) {
charRenderer_->update(deltaTime);

View file

@ -1257,16 +1257,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
// Bind VAO and draw
glBindVertexArray(gpuModel.vao);
if (!gpuModel.data.batches.empty()) {
bool applyGeosetFilter = !instance.activeGeosets.empty();
if (applyGeosetFilter) {
bool hasRenderableGeoset = false;
for (const auto& batch : gpuModel.data.batches) {
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
hasRenderableGeoset = true;
break;
}
}
if (!gpuModel.data.batches.empty()) {
bool applyGeosetFilter = !instance.activeGeosets.empty();
if (applyGeosetFilter) {
bool hasRenderableGeoset = false;
for (const auto& batch : gpuModel.data.batches) {
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
hasRenderableGeoset = true;
break;
}
}
if (!hasRenderableGeoset) {
static std::unordered_set<uint32_t> loggedGeosetFallback;
if (loggedGeosetFallback.insert(instance.id).second) {
@ -1274,13 +1274,64 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
instance.id, " (model ", instance.modelId,
"); rendering all batches as fallback");
}
applyGeosetFilter = false;
}
}
applyGeosetFilter = false;
}
}
// One-time debug dump of rendered batches per model
static std::unordered_set<uint32_t> dumpedModels;
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
// A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex.
// We currently bind only a single texture, so pick the most appropriate one.
//
// This matters for hair: the first texture in the combo can be a mask/empty slot,
// causing the hair to render as solid white.
if (b.textureIndex == 0xFFFF) return whiteTexture;
if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture;
uint32_t comboCount = b.textureCount ? static_cast<uint32_t>(b.textureCount) : 1u;
comboCount = std::min<uint32_t>(comboCount, 8u);
struct Candidate { GLuint id; uint32_t type; };
Candidate first{whiteTexture, 0};
bool hasFirst = false;
Candidate firstNonWhite{whiteTexture, 0};
bool hasFirstNonWhite = false;
for (uint32_t i = 0; i < comboCount; i++) {
uint32_t lookupPos = static_cast<uint32_t>(b.textureIndex) + i;
if (lookupPos >= gm.data.textureLookup.size()) break;
uint16_t texSlot = gm.data.textureLookup[lookupPos];
if (texSlot >= gm.textureIds.size()) continue;
GLuint texId = gm.textureIds[texSlot];
uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0;
if (!hasFirst) {
first = {texId, texType};
hasFirst = true;
}
if (texId == 0 || texId == whiteTexture) continue;
// Prefer the hair texture slot (type 6) whenever present in the combo.
// Humanoid scalp meshes can live in group 0, so group-based checks are insufficient.
if (texType == 6) {
return texId;
}
if (!hasFirstNonWhite) {
firstNonWhite = {texId, texType};
hasFirstNonWhite = true;
}
}
if (hasFirstNonWhite) return firstNonWhite.id;
if (hasFirst && first.id != 0) return first.id;
return whiteTexture;
};
// One-time debug dump of rendered batches per model
static std::unordered_set<uint32_t> dumpedModels;
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
dumpedModels.insert(instance.modelId);
int bIdx = 0;
int rendered = 0, skipped = 0;
@ -1289,24 +1340,11 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
(b.submeshId / 100 != 0) &&
instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end();
GLuint resolvedTex = whiteTexture;
std::string texInfo = "white(fallback)";
if (b.textureIndex != 0xFFFF && b.textureIndex < gpuModel.data.textureLookup.size()) {
uint16_t lk = gpuModel.data.textureLookup[b.textureIndex];
if (lk < gpuModel.textureIds.size()) {
resolvedTex = gpuModel.textureIds[lk];
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->tex[" + std::to_string(lk) + "]=GL" + std::to_string(resolvedTex);
} else {
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->OOB(" + std::to_string(lk) + ")";
}
} else if (b.textureIndex == 0xFFFF) {
texInfo = "texIdx=FFFF";
} else {
texInfo = "texIdx=" + std::to_string(b.textureIndex) + " OOB(lookupSz=" + std::to_string(gpuModel.data.textureLookup.size()) + ")";
}
GLuint resolvedTex = resolveBatchTexture(gpuModel, b);
std::string texInfo = "GL" + std::to_string(resolvedTex);
if (filtered) skipped++; else rendered++;
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
if (filtered) skipped++; else rendered++;
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
" level=", b.submeshLevel,
" idxStart=", b.indexStart, " idxCount=", b.indexCount,
" tex=", texInfo,
@ -1317,28 +1355,22 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
gpuModel.textureIds.size(), " textures loaded, ",
gpuModel.data.textureLookup.size(), " in lookup table");
for (size_t t = 0; t < gpuModel.data.textures.size(); t++) {
}
}
}
}
// Draw batches (submeshes) with per-batch textures
// Geoset filtering: skip batches whose submeshId is not in activeGeosets.
// For character models, group 0 (body/scalp) is also filtered so that only
// the correct scalp mesh renders (not all overlapping variants).
for (const auto& batch : gpuModel.data.batches) {
// Draw batches (submeshes) with per-batch textures
// Geoset filtering: skip batches whose submeshId is not in activeGeosets.
// For character models, group 0 (body/scalp) is also filtered so that only
// the correct scalp mesh renders (not all overlapping variants).
for (const auto& batch : gpuModel.data.batches) {
if (applyGeosetFilter) {
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
continue;
}
}
// Resolve texture for this batch
GLuint texId = whiteTexture;
if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex];
if (lookupIdx < gpuModel.textureIds.size()) {
texId = gpuModel.textureIds[lookupIdx];
}
}
// Resolve texture for this batch (prefer hair textures for hair geosets).
GLuint texId = resolveBatchTexture(gpuModel, batch);
// For body parts with white/fallback texture, use skin (type 1) texture
// This handles humanoid models where some body parts use different texture slots

View file

@ -1231,7 +1231,12 @@ void Renderer::updateCharacterAnimation() {
// Keep seat offset minimal; large offsets amplify visible bobble.
glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f);
glm::vec3 targetRiderPos = mountSeatPos + seatOffset;
if (!mountSeatSmoothingInit_) {
// When moving, smoothing the seat position produces visible lag that looks like
// the rider sliding toward the rump. Anchor rigidly while moving.
if (moving) {
mountSeatSmoothingInit_ = false;
smoothedMountSeatPos_ = targetRiderPos;
} else if (!mountSeatSmoothingInit_) {
smoothedMountSeatPos_ = targetRiderPos;
mountSeatSmoothingInit_ = true;
} else {

View file

@ -49,6 +49,7 @@ void CharacterCreateScreen::reset() {
maxFacialHair = 8;
statusMessage.clear();
statusIsError = false;
hairColorIds_.clear();
updateAvailableClasses();
// Reset preview tracking to force model reload on next render
@ -114,13 +115,19 @@ void CharacterCreateScreen::updatePreviewIfNeeded() {
if (changed) {
bool useFemaleModel = (genderIndex == 2 && bodyTypeIndex == 1); // Nonbinary + Feminine
uint8_t hairColorId = 0;
if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast<int>(hairColorIds_.size())) {
hairColorId = hairColorIds_[hairColor];
} else {
hairColorId = static_cast<uint8_t>(hairColor);
}
preview_->loadCharacter(
allRaces[raceIndex],
static_cast<game::Gender>(genderIndex),
static_cast<uint8_t>(skin),
static_cast<uint8_t>(face),
static_cast<uint8_t>(hairStyle),
static_cast<uint8_t>(hairColor),
hairColorId,
static_cast<uint8_t>(facialHair),
useFemaleModel);
@ -153,6 +160,7 @@ void CharacterCreateScreen::updateAppearanceRanges() {
maxHairStyle = 11;
maxHairColor = 9;
maxFacialHair = 8;
hairColorIds_.clear();
if (!assetManager_) return;
auto dbc = assetManager_->loadDBC("CharSections.dbc");
@ -189,7 +197,7 @@ void CharacterCreateScreen::updateAppearanceRanges() {
}
int faceMax = -1;
int hairColorMax = -1;
std::vector<uint8_t> hairColorIds;
for (uint32_t r = 0; r < dbc->getRecordCount(); r++) {
uint32_t raceId = dbc->getUInt32(r, 1);
uint32_t sexId = dbc->getUInt32(r, 2);
@ -202,7 +210,9 @@ void CharacterCreateScreen::updateAppearanceRanges() {
if (baseSection == 1 && colorIndex == static_cast<uint32_t>(skin)) {
faceMax = std::max(faceMax, static_cast<int>(variationIndex));
} else if (baseSection == 3 && variationIndex == static_cast<uint32_t>(hairStyle)) {
hairColorMax = std::max(hairColorMax, static_cast<int>(colorIndex));
if (colorIndex <= 255) {
hairColorIds.push_back(static_cast<uint8_t>(colorIndex));
}
}
}
@ -210,9 +220,15 @@ void CharacterCreateScreen::updateAppearanceRanges() {
maxFace = faceMax;
if (face > maxFace) face = maxFace;
}
if (hairColorMax >= 0) {
maxHairColor = hairColorMax;
// Hair colors: use actual available DBC IDs (not "0..maxId"), since IDs may be sparse.
if (!hairColorIds.empty()) {
std::sort(hairColorIds.begin(), hairColorIds.end());
hairColorIds.erase(std::unique(hairColorIds.begin(), hairColorIds.end()), hairColorIds.end());
hairColorIds_ = std::move(hairColorIds);
maxHairColor = std::max(0, static_cast<int>(hairColorIds_.size()) - 1);
if (hairColor > maxHairColor) hairColor = maxHairColor;
if (hairColor < 0) hairColor = 0;
}
int facialMax = -1;
auto facialDbc = assetManager_->loadDBC("CharacterFacialHairStyles.dbc");
@ -450,7 +466,11 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
data.skin = static_cast<uint8_t>(skin);
data.face = static_cast<uint8_t>(face);
data.hairStyle = static_cast<uint8_t>(hairStyle);
data.hairColor = static_cast<uint8_t>(hairColor);
if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast<int>(hairColorIds_.size())) {
data.hairColor = hairColorIds_[hairColor];
} else {
data.hairColor = static_cast<uint8_t>(hairColor);
}
data.facialHair = static_cast<uint8_t>(facialHair);
if (onCreate) {
onCreate(data);

View file

@ -1,4 +1,8 @@
#include "ui/character_screen.hpp"
#include "rendering/character_preview.hpp"
#include "pipeline/asset_manager.hpp"
#include "core/application.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <cstdlib>
#include <filesystem>
@ -11,26 +15,59 @@ namespace wowee { namespace ui {
CharacterScreen::CharacterScreen() {
}
void CharacterScreen::render(game::GameHandler& gameHandler) {
// Size the window to fill most of the viewport
ImVec2 vpSize = ImGui::GetMainViewport()->Size;
ImVec2 winSize(vpSize.x * 0.6f, vpSize.y * 0.7f);
if (winSize.x < 700.0f) winSize.x = 700.0f;
if (winSize.y < 500.0f) winSize.y = 500.0f;
ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(
ImVec2(vpSize.x * 0.5f, vpSize.y * 0.5f),
ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
static uint64_t hashEquipment(const std::vector<game::EquipmentItem>& eq) {
// FNV-1a 64-bit over (displayModel, inventoryType, enchantment)
uint64_t h = 1469598103934665603ull;
auto mix8 = [&](uint8_t b) {
h ^= b;
h *= 1099511628211ull;
};
auto mix32 = [&](uint32_t v) {
mix8(static_cast<uint8_t>(v & 0xFF));
mix8(static_cast<uint8_t>((v >> 8) & 0xFF));
mix8(static_cast<uint8_t>((v >> 16) & 0xFF));
mix8(static_cast<uint8_t>((v >> 24) & 0xFF));
};
for (const auto& it : eq) {
mix32(it.displayModel);
mix8(it.inventoryType);
mix32(it.enchantment);
}
return h;
}
ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse);
void CharacterScreen::render(game::GameHandler& gameHandler) {
ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 pad(24.0f, 24.0f);
ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f);
if (winSize.x < 860.0f) winSize.x = 860.0f;
if (winSize.y < 620.0f) winSize.y = 620.0f;
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f,
vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f),
ImGuiCond_Always);
ImGui::SetNextWindowSize(winSize, ImGuiCond_Always);
ImGui::Begin("Character Selection", nullptr,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove);
// Ensure we can render a preview even if the state transition hook didn't inject the AssetManager.
if (!assetManager_) {
assetManager_ = core::Application::getInstance().getAssetManager();
}
// Get character list
const auto& characters = gameHandler.getCharacters();
// Request character list if not available
if (characters.empty() && gameHandler.getState() == game::WorldState::READY) {
// Request character list if not available.
// Also show a loading state while CHAR_LIST_REQUESTED is in-flight (characters may be cleared to avoid stale UI).
if (characters.empty() &&
(gameHandler.getState() == game::WorldState::READY ||
gameHandler.getState() == game::WorldState::CHAR_LIST_REQUESTED)) {
ImGui::Text("Loading characters...");
gameHandler.requestCharacterList();
if (gameHandler.getState() == game::WorldState::READY) {
gameHandler.requestCharacterList();
}
ImGui::End();
return;
}
@ -54,6 +91,22 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
return;
}
// If the list refreshed, keep selection stable by GUID.
if (selectedCharacterGuid != 0) {
const bool needReselect =
(selectedCharacterIndex < 0) ||
(selectedCharacterIndex >= static_cast<int>(characters.size())) ||
(characters[static_cast<size_t>(selectedCharacterIndex)].guid != selectedCharacterGuid);
if (needReselect) {
for (size_t i = 0; i < characters.size(); ++i) {
if (characters[i].guid == selectedCharacterGuid) {
selectedCharacterIndex = static_cast<int>(i);
break;
}
}
}
}
// Restore last-selected character (once per screen visit)
if (!restoredLastCharacter) {
// Priority 1: Select newly created character if set
@ -99,7 +152,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
// ── Two-column layout: character list (left) | details (right) ──
float availW = ImGui::GetContentRegionAvail().x;
float detailPanelW = 260.0f;
float detailPanelW = 360.0f;
float listW = availW - detailPanelW - ImGui::GetStyle().ItemSpacing.x;
if (listW < 300.0f) { listW = availW; detailPanelW = 0.0f; }
@ -174,9 +227,83 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
const auto& character = characters[selectedCharacterIndex];
// Keep the 3D preview in sync with the selected character.
if (assetManager_ && assetManager_->isInitialized()) {
if (!preview_) {
preview_ = std::make_unique<rendering::CharacterPreview>();
}
if (!previewInitialized_) {
previewInitialized_ = preview_->initialize(assetManager_);
if (!previewInitialized_) {
LOG_WARNING("CharacterScreen: failed to init CharacterPreview");
preview_.reset();
}
}
if (preview_) {
const uint64_t equipHash = hashEquipment(character.equipment);
const bool changed =
(previewGuid_ != character.guid) ||
(previewAppearanceBytes_ != character.appearanceBytes) ||
(previewFacialFeatures_ != character.facialFeatures) ||
(previewUseFemaleModel_ != character.useFemaleModel) ||
(previewEquipHash_ != equipHash) ||
(!preview_->isModelLoaded());
if (changed) {
uint8_t skin = character.appearanceBytes & 0xFF;
uint8_t face = (character.appearanceBytes >> 8) & 0xFF;
uint8_t hairStyle = (character.appearanceBytes >> 16) & 0xFF;
uint8_t hairColor = (character.appearanceBytes >> 24) & 0xFF;
if (preview_->loadCharacter(character.race, character.gender,
skin, face, hairStyle, hairColor,
character.facialFeatures, character.useFemaleModel)) {
preview_->applyEquipment(character.equipment);
}
previewGuid_ = character.guid;
previewAppearanceBytes_ = character.appearanceBytes;
previewFacialFeatures_ = character.facialFeatures;
previewUseFemaleModel_ = character.useFemaleModel;
previewEquipHash_ = equipHash;
}
// Drive preview animation and render to its FBO.
preview_->update(ImGui::GetIO().DeltaTime);
preview_->render();
}
}
ImGui::SameLine();
ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true);
// 3D preview portrait
if (preview_ && preview_->getTextureId() != 0) {
float imgW = ImGui::GetContentRegionAvail().x;
float imgH = imgW * (static_cast<float>(preview_->getHeight()) /
static_cast<float>(preview_->getWidth()));
// Clamp to avoid taking the entire panel
float maxH = 320.0f;
if (imgH > maxH) {
imgH = maxH;
imgW = imgH * (static_cast<float>(preview_->getWidth()) /
static_cast<float>(preview_->getHeight()));
}
ImGui::Image(
static_cast<ImTextureID>(preview_->getTextureId()),
ImVec2(imgW, imgH),
ImVec2(0.0f, 1.0f), // flip Y for OpenGL
ImVec2(1.0f, 0.0f));
if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
preview_->rotate(ImGui::GetIO().MouseDelta.x * 0.2f);
}
ImGui::Spacing();
} else if (!assetManager_ || !assetManager_->isInitialized()) {
ImGui::TextDisabled("Preview unavailable (assets not loaded)");
ImGui::Spacing();
}
ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str());
ImGui::Separator();
ImGui::Spacing();

View file

@ -2201,8 +2201,20 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
for (uint16_t i = 0; i <= 18; i++) {
geosets.insert(i);
}
geosets.insert(101); // Hair
geosets.insert(201); // Facial
// Hair/facial geosets must match the active character's appearance, otherwise
// we end up forcing a default hair mesh (often perceived as "wrong hair").
{
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (auto* gh = app.getGameHandler()) {
if (const auto* ch = gh->getActiveCharacter()) {
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
facialId = ch->facialFeatures;
}
}
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair
geosets.insert(static_cast<uint16_t>(200 + facialId + 1)); // Group 2 facial
}
geosets.insert(701); // Ears
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe)

View file

@ -1105,7 +1105,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
pickupFromBackpack(inventory, backpackIndex);
} else if (kind == SlotKind::EQUIPMENT) {
pickupFromEquipment(inventory, equipSlot);
if (gameHandler_) {
// Online mode: don't mutate local equipment state.
game::MessageChatData msg{};
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Moving equipped items not supported yet (online mode).";
gameHandler_->addLocalChatMessage(msg);
} else {
pickupFromEquipment(inventory, equipSlot);
}
}
} else {
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
@ -1126,13 +1135,19 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
// Sell to vendor
gameHandler_->sellItemBySlot(backpackIndex);
} else if (kind == SlotKind::EQUIPMENT) {
// Unequip: move to free backpack slot
int freeSlot = inventory.findFreeBackpackSlot();
if (freeSlot >= 0) {
inventory.setBackpackSlot(freeSlot, item);
inventory.clearEquipSlot(equipSlot);
equipmentDirty = true;
inventoryDirty = true;
if (gameHandler_) {
// Online mode: request server-side unequip (move to first free backpack slot).
LOG_INFO("UI unequip request: equipSlot=", (int)equipSlot);
gameHandler_->unequipToBackpack(equipSlot);
} else {
// Offline mode: Unequip: move to free backpack slot
int freeSlot = inventory.findFreeBackpackSlot();
if (freeSlot >= 0) {
inventory.setBackpackSlot(freeSlot, item);
inventory.clearEquipSlot(equipSlot);
equipmentDirty = true;
inventoryDirty = true;
}
}
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
if (gameHandler_) {

View file

@ -7,8 +7,18 @@ RealmScreen::RealmScreen() {
}
void RealmScreen::render(auth::AuthHandler& authHandler) {
ImGui::SetNextWindowSize(ImVec2(700, 500), ImGuiCond_FirstUseEver);
ImGui::Begin("Realm Selection", nullptr, ImGuiWindowFlags_NoCollapse);
ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 pad(24.0f, 24.0f);
ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f);
if (winSize.x < 720.0f) winSize.x = 720.0f;
if (winSize.y < 540.0f) winSize.y = 540.0f;
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f,
vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f),
ImGuiCond_Always);
ImGui::SetNextWindowSize(winSize, ImGuiCond_Always);
ImGui::Begin("Realm Selection", nullptr,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove);
ImGui::Text("Select a Realm");
ImGui::Separator();