diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 5293b457f..327aa2ce5 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -734,6 +734,8 @@ set(common_headers util/uuid.h version.h zone_store.h + links.h + links.cpp ) source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "Source Files" FILES ${common_sources}) diff --git a/common/emu_oplist.h b/common/emu_oplist.h index 22972f799..5aea4a663 100644 --- a/common/emu_oplist.h +++ b/common/emu_oplist.h @@ -374,6 +374,7 @@ N(OP_MercenaryDismiss), N(OP_MercenaryHire), N(OP_MercenarySuspendRequest), N(OP_MercenarySuspendResponse), +N(OP_MercenarySwitch), N(OP_MercenaryTimer), N(OP_MercenaryTimerRequest), N(OP_MercenaryUnknown1), diff --git a/common/eq_packet_structs.h b/common/eq_packet_structs.h index 14b2b92c2..c1476a6cf 100644 --- a/common/eq_packet_structs.h +++ b/common/eq_packet_structs.h @@ -6244,6 +6244,12 @@ struct SuspendMercenary_Struct { /*0001*/ }; +// [OPCode: 0x1b37 (RoF2)] [Client->Server] [Size: 4] +struct SwitchMercenary_Struct { +/*0000*/ uint32 MercIndex; // 0-based UI index into owned merc list +/*0004*/ +}; + // [OPCode: 0x2528] On Live as of April 2 2012 [Server->Client] [Size: 4] // Response to suspend merc with timestamp struct SuspendMercenaryResponse_Struct { diff --git a/common/links.cpp b/common/links.cpp new file mode 100644 index 000000000..b8fa758af --- /dev/null +++ b/common/links.cpp @@ -0,0 +1,10 @@ +// +// Created by dannu on 4/18/2026. +// + +#include "links.h" + +std::string Links::FormatSpellLink(uint32_t SpellID, const std::string& SpellName) +{ + return fmt::format("{}63^{}^0^'{}{}", ITEM_TAG_CHAR, SpellID, SpellName.c_str(), ITEM_TAG_CHAR); +} diff --git a/common/links.h b/common/links.h new file mode 100644 index 000000000..49d2e8fab --- /dev/null +++ b/common/links.h @@ -0,0 +1,11 @@ +// +// Created by dannu on 4/18/2026. +// + +#pragma once + +namespace Links +{ + constexpr char ITEM_TAG_CHAR = '\x12'; + std::string FormatSpellLink(uint32_t SpellID, const std::string& SpellName); +} diff --git a/common/patches/rof2.cpp b/common/patches/rof2.cpp index 992f0c538..a2b3ba30e 100644 --- a/common/patches/rof2.cpp +++ b/common/patches/rof2.cpp @@ -2278,15 +2278,19 @@ namespace RoF2 // There are 2 different sized versions of this packet depending if a merc is hired or not if (emu->MercStatus >= 0) { - PacketSize += sizeof(structs::MercenaryDataUpdate_Struct) + (sizeof(structs::MercenaryData_Struct) - sizeof(structs::MercenaryStance_Struct)) * emu->MercCount; - + // Per-merc size: base struct minus Stances[1] and MercUnk05, + // then add back actual stances and name length per merc. + // MercUnk05 is a single trailing field after all mercs. + PacketSize += sizeof(structs::MercenaryDataUpdate_Struct); uint32 r; uint32 k; for (r = 0; r < emu->MercCount; r++) { + PacketSize += sizeof(structs::MercenaryData_Struct) - sizeof(structs::MercenaryStance_Struct) - sizeof(uint32); // subtract Stances[1] and MercUnk05 PacketSize += sizeof(structs::MercenaryStance_Struct) * emu->MercData[r].StanceCount; PacketSize += strlen(emu->MercData[r].MercName); // Null Terminator size already accounted for in the struct } + PacketSize += sizeof(uint32); // MercUnk05 - trailing field after all mercs outapp = new EQApplicationPacket(OP_MercenaryDataUpdate, PacketSize); Buffer = (char *)outapp->pBuffer; @@ -2312,15 +2316,14 @@ namespace RoF2 VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[r].StanceCount); VARSTRUCT_ENCODE_TYPE(int32, Buffer, emu->MercData[r].MercUnk03); VARSTRUCT_ENCODE_TYPE(uint8, Buffer, emu->MercData[r].MercUnk04); - //VARSTRUCT_ENCODE_TYPE(uint8, Buffer, 0); // MercName VARSTRUCT_ENCODE_STRING(Buffer, emu->MercData[r].MercName); for (k = 0; k < emu->MercData[r].StanceCount; k++) { VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[r].Stances[k].StanceIndex); VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[r].Stances[k].Stance); } - VARSTRUCT_ENCODE_TYPE(uint32, Buffer, 1); // MercUnk05 } + VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[0].MercUnk05); // MercUnk05 - trailing field (unlocked slot count) } else { diff --git a/common/patches/tob.cpp b/common/patches/tob.cpp index 90529b045..691ae49e9 100644 --- a/common/patches/tob.cpp +++ b/common/patches/tob.cpp @@ -23,7 +23,9 @@ #include #include #include +#include +#include "common/packet_dump.h" #include "world/sof_char_create_data.h" namespace TOB @@ -144,6 +146,20 @@ namespace TOB #include "ss_define.h" // ENCODE methods + ENCODE(OP_AAExpUpdate) { + ENCODE_LENGTH_EXACT(AltAdvStats_Struct); + SETUP_DIRECT_ENCODE(AltAdvStats_Struct, structs::AltAdvStats_Struct); + + //later we should change the underlying server to use this more accurate value + //and encode the 330 in the other patches + eq->experience = emu->experience * 100000 / 330; + + OUT(unspent); + OUT(percentage); + + FINISH_ENCODE(); + } + ENCODE(OP_Action) { ENCODE_LENGTH_EXACT(Action_Struct); SETUP_DIRECT_ENCODE(Action_Struct, structs::MissileHitInfo); @@ -217,19 +233,22 @@ namespace TOB ENCODE(OP_BlockedBuffs) { - ENCODE_LENGTH_EXACT(BlockedBuffs_Struct); - SETUP_DIRECT_ENCODE(BlockedBuffs_Struct, structs::BlockedBuffs_Struct); + // Blocked buffs are a major change. They are stored in a resizable array in TOB, so this sends size, then + // spells, then the final two bools -- see 0x140202750 + SETUP_VAR_ENCODE(BlockedBuffs_Struct); - for (uint32 i = 0; i < BLOCKED_BUFF_COUNT; ++i) - eq->SpellID[i] = emu->SpellID[i]; + // size is uint32 + count * int32 + uint8 + uint8 + uint32 sz = 6 + emu->Count * 4; + __packet->size = sz; + __packet->pBuffer = new unsigned char[sz]; + memset(__packet->pBuffer, 0, sz); - for (uint32 i = BLOCKED_BUFF_COUNT; i < structs::BLOCKED_BUFF_COUNT; ++i) - eq->SpellID[i] = -1; + __packet->WriteUInt32(emu->Count); + for (int i = 0; i < emu->Count; i++) + __packet->WriteSInt32(emu->SpellID[i]); - OUT(Count); - OUT(Pet); - OUT(Initialise); - OUT(Flags); + __packet->WriteUInt8(emu->Pet); + __packet->WriteUInt8(emu->Initialise); FINISH_ENCODE(); } @@ -333,6 +352,7 @@ namespace TOB ENCODE(OP_CastSpell) { + // I don't think the client handles this at all, it only sends the cast packet ENCODE_LENGTH_EXACT(CastSpell_Struct); SETUP_DIRECT_ENCODE(CastSpell_Struct, structs::CastSpell_Struct); @@ -453,7 +473,7 @@ namespace TOB int item_count = in->size / sizeof(EQ::InternalSerializedItem_Struct); if (!item_count || (in->size % sizeof(EQ::InternalSerializedItem_Struct)) != 0) { - Log(Logs::General, Logs::Netcode, "[STRUCTS] Wrong size on outbound %s: Got %d, expected multiple of %d", + LogNetcode("[STRUCTS] Wrong size on outbound {}: Got {}, expected multiple of {}", opcodes->EmuToName(in->GetOpcode()), in->size, sizeof(EQ::InternalSerializedItem_Struct)); delete in; @@ -650,8 +670,8 @@ namespace TOB } SerializeBuffer buffer; - buffer.WriteUInt32(emu->unknown0); - buffer.WriteUInt8(0); // Observed + buffer.WriteUInt32(0); // This is a string written like the message arrays + buffer.WriteUInt8(emu->unknown0); buffer.WriteUInt32(emu->string_id); buffer.WriteUInt32(emu->type); @@ -939,6 +959,27 @@ namespace TOB FINISH_ENCODE(); } + ENCODE(OP_MemorizeSpell) { + ENCODE_LENGTH_EXACT(MemorizeSpell_Struct); + SETUP_DIRECT_ENCODE(MemorizeSpell_Struct, structs::MemorizeSpell_Struct); + + // in TOB, 2 is "finish memming" so that becomes 1 in emu and 3 is "unmem" which becomes 2 + if (emu->scribing == 1) + eq->scribing = 2; + else if (emu->scribing == 2) + eq->scribing = 3; + else if (emu->scribing == 3) + eq->scribing = 4; + else + OUT(scribing); + + OUT(slot); + OUT(spell_id); + OUT(reduction); + + FINISH_ENCODE(); + } + ENCODE(OP_MobHealth) { ENCODE_LENGTH_EXACT(SpawnHPUpdate_Struct2); SETUP_DIRECT_ENCODE(SpawnHPUpdate_Struct2, structs::MobHealth_Struct); @@ -981,6 +1022,7 @@ namespace TOB ENCODE(OP_NewSpawn) { ENCODE_FORWARD(OP_ZoneSpawns); } ENCODE(OP_NewZone) { + // zoneHeader EQApplicationPacket* in = *p; *p = nullptr; @@ -2305,7 +2347,6 @@ namespace TOB eq->container_slot = ServerToTOBSlot(emu->unknown1); structs::InventorySlot_Struct TOBSlot; TOBSlot.Type = 8; // Observed - TOBSlot.Padding1 = 0; TOBSlot.Slot = 0xffff; TOBSlot.SubIndex = 0xffff; TOBSlot.AugIndex = 0xffff; @@ -2332,12 +2373,11 @@ namespace TOB eq->aapoints_assigned[4] = 0; eq->aapoints_assigned[5] = 0; - for (uint32 i = 0; i < MAX_PP_AA_ARRAY; ++i) + for (uint32 i = 0; i < structs::MAX_PP_AA_ARRAY; ++i) { eq->aa_list[i].AA = emu->aa_list[i].AA; eq->aa_list[i].value = emu->aa_list[i].value; eq->aa_list[i].charges = emu->aa_list[i].charges; - eq->aa_list[i].bUnknown0x0c = false; } FINISH_ENCODE(); @@ -2386,7 +2426,7 @@ namespace TOB s32 Desc; */ - buffer.WriteUInt32(emu->id); + buffer.WriteUInt32(emu->id); // Index buffer.WriteUInt8(1); buffer.WriteInt32(emu->upper_hotkey_sid); buffer.WriteInt32(emu->lower_hotkey_sid); @@ -2903,7 +2943,7 @@ namespace TOB buf.WriteString(new_message); - auto outapp = new EQApplicationPacket(OP_SpecialMesg, buf); + auto outapp = new EQApplicationPacket(OP_SpecialMesg, std::move(buf)); dest->FastQueuePacket(&outapp, ack_req); delete in; @@ -2934,14 +2974,14 @@ namespace TOB return; } - auto outapp = new EQApplicationPacket(OP_ChangeSize, sizeof(ChangeSize_Struct)); + auto outapp = new EQApplicationPacket(OP_ChangeSize, sizeof(structs::ChangeSize_Struct)); - ChangeSize_Struct* css = (ChangeSize_Struct*)outapp->pBuffer; + structs::ChangeSize_Struct* css = (structs::ChangeSize_Struct*)outapp->pBuffer; css->EntityID = sas->spawn_id; css->Size = (float)sas->parameter; - css->Unknown08 = 0; - css->Unknown12 = 1.0f; + css->CameraOffset = 0; + css->AnimationSpeedRelated = 1.0f; dest->FastQueuePacket(&outapp, ack_req); delete in; @@ -3574,18 +3614,28 @@ namespace TOB DECODE(OP_BlockedBuffs) { - DECODE_LENGTH_EXACT(structs::BlockedBuffs_Struct); - SETUP_DIRECT_DECODE(BlockedBuffs_Struct, structs::BlockedBuffs_Struct); + uint32 count = __packet->ReadUInt32(); + std::vector blocked_spell_ids; + blocked_spell_ids.reserve(count); + for (int i = 0; i < count; ++i) + blocked_spell_ids.push_back(static_cast(__packet->ReadUInt32())); - for (uint32 i = 0; i < BLOCKED_BUFF_COUNT; ++i) - emu->SpellID[i] = eq->SpellID[i]; + bool pet = __packet->ReadUInt8() == 1; + bool init = __packet->ReadUInt8() == 1; - IN(Count); - IN(Pet); - IN(Initialise); - IN(Flags); + __packet->SetReadPosition(0); // reset the packet read to pass it along - FINISH_DIRECT_DECODE(); + __packet->size = sizeof(BlockedBuffs_Struct); + __packet->pBuffer = new unsigned char[__packet->size]{}; + BlockedBuffs_Struct* emu = (BlockedBuffs_Struct*)__packet->pBuffer; + + memset(emu->SpellID, -1, sizeof(emu->SpellID)); + for (int i = 0; i < count; ++i) + emu->SpellID[i] = blocked_spell_ids[i]; + + emu->Count = count; + emu->Pet = pet; + emu->Initialise = init; } DECODE(OP_CastSpell) @@ -3794,6 +3844,35 @@ namespace TOB DECODE_FORWARD(OP_GroupInvite); } + DECODE(OP_MemorizeSpell) { + DECODE_LENGTH_EXACT(structs::MemorizeSpell_Struct); + SETUP_DIRECT_DECODE(MemorizeSpell_Struct, structs::MemorizeSpell_Struct); + + // TOB sends status 1 here to let the server know that it's started memming, but doesn't want a response + if (eq->scribing == 1) { + // TODO: There should be a timer set here to detect short-mem cheats, and then checked when the 2 packet is sent + // The previous detection will still happen on scribing == 2, the new client just handles it better + __packet->SetOpcode(OP_Unknown); + return; + } + + // in TOB, 2 is "finish memming" so that becomes 1 in emu and 3 is "unmem" which becomes 2 + if (eq->scribing == 2) + emu->scribing = 1; + else if (eq->scribing == 3) + emu->scribing = 2; + else if (eq->scribing == 4) + emu->scribing = 3; + else + IN(scribing); + + IN(slot); + IN(spell_id); + IN(reduction); + + FINISH_DIRECT_DECODE(); + } + DECODE(OP_MoveItem) { DECODE_LENGTH_EXACT(structs::MoveItem_Struct); @@ -3817,7 +3896,7 @@ namespace TOB int r; for (r = 0; r < 29; r++) { - // Size 68 in TOB + // Size 69 in TOB IN(filters[r]); } @@ -4121,7 +4200,7 @@ namespace TOB //s32 Variation; //s32 NewArmorId; //s32 NewArmorType; - buffer.WriteUInt32(item->Material); + buffer.WriteUInt32(item->Material); // this isn't labeled well, material is material *type* buffer.WriteUInt32(0); //unsupported atm buffer.WriteUInt32(item->EliteMaterial); buffer.WriteUInt32(item->HerosForgeModel); @@ -4239,9 +4318,20 @@ namespace TOB //u8 SpellDataSkillMask[78]; for (int j = 0; j < 78; ++j) { - buffer.WriteUInt8(0); //unsure what this is exactly + buffer.WriteUInt8(0); // TODO: collection of ints for bitfield for each skill required to use. reads 19 ints byte by byte in the client, leave like this for further investigation } + + /* There are a static 7 spell data entries on an item: + Clicky + Proc + Worn + Focus + Scroll + Focus2 + Blessing + */ + /* SpellData: s32 SpellId; u8 RequiredLevel; @@ -4650,55 +4740,42 @@ namespace TOB //ItemDefinition Item; SerializeItemDefinition(buffer, item); - //u32 RealEstateArrayCount; - // buffer.WriteInt32(0); - //s32 RealEstateArray[RealEstateArrayCount]; - - //bool bRealEstateItemPlaceable; - // buffer.WriteInt8(0); - //u32 SubContentSize; - uint32 subitem_count = 0; - int16 SubSlotNumber = EQ::invbag::SLOT_INVALID; if (slot_id_in <= EQ::invslot::GENERAL_END && slot_id_in >= EQ::invslot::GENERAL_BEGIN) - SubSlotNumber = EQ::invbag::GENERAL_BAGS_BEGIN + ((slot_id_in - EQ::invslot::GENERAL_BEGIN) * EQ::invbag::SLOT_COUNT); + SubSlotNumber = EQ::invbag::GENERAL_BAGS_BEGIN + (slot_id_in - EQ::invslot::GENERAL_BEGIN) * EQ::invbag::SLOT_COUNT; else if (slot_id_in == EQ::invslot::slotCursor) SubSlotNumber = EQ::invbag::CURSOR_BAG_BEGIN; else if (slot_id_in <= EQ::invslot::BANK_END && slot_id_in >= EQ::invslot::BANK_BEGIN) - SubSlotNumber = EQ::invbag::BANK_BAGS_BEGIN + ((slot_id_in - EQ::invslot::BANK_BEGIN) * EQ::invbag::SLOT_COUNT); + SubSlotNumber = EQ::invbag::BANK_BAGS_BEGIN + (slot_id_in - EQ::invslot::BANK_BEGIN) * EQ::invbag::SLOT_COUNT; else if (slot_id_in <= EQ::invslot::SHARED_BANK_END && slot_id_in >= EQ::invslot::SHARED_BANK_BEGIN) - SubSlotNumber = EQ::invbag::SHARED_BANK_BAGS_BEGIN + ((slot_id_in - EQ::invslot::SHARED_BANK_BEGIN) * EQ::invbag::SLOT_COUNT); + SubSlotNumber = EQ::invbag::SHARED_BANK_BAGS_BEGIN + (slot_id_in - EQ::invslot::SHARED_BANK_BEGIN) * EQ::invbag::SLOT_COUNT; else SubSlotNumber = slot_id_in; // not sure if this is the best way to handle this..leaving for now if (SubSlotNumber != EQ::invbag::SLOT_INVALID) { + std::vector> subitems; for (uint32 index = EQ::invbag::SLOT_BEGIN; index <= EQ::invbag::SLOT_END; ++index) { EQ::ItemInstance* sub = inst->GetItem(index); - if (!sub) - continue; - - ++subitem_count; + if (sub != nullptr) + subitems.emplace_back(index, sub); } - buffer.WriteUInt32(subitem_count); - - for (uint32 index = EQ::invbag::SLOT_BEGIN; index <= EQ::invbag::SLOT_END; ++index) { - EQ::ItemInstance* sub = inst->GetItem(index); - if (!sub) - continue; + buffer.WriteUInt32(subitems.size()); + // This must be guaranteed to have subitem_count members, where the index is the correct index. The client doesn't loop through all slots here + for (const auto& [index, subitem] : subitems) { buffer.WriteUInt32(index); - - SerializeItem(buffer, sub, SubSlotNumber, (depth + 1), packet_type); + SerializeItem(buffer, subitem, SubSlotNumber, depth + 1, packet_type); } - } + } else + buffer.WriteUInt32(0); // no subitems, client needs to know that //bool bCollected; buffer.WriteInt8(0); //unsupported atm //u64 DontKnow; - buffer.WriteUInt64(0); //unsupported atm + buffer.WriteInt64(0); //unsupported atm //s32 Luck; buffer.WriteInt32(0); //unsupported atm } @@ -4785,7 +4862,9 @@ namespace TOB } default: //unsupported etag right now; just pass it as is + message_out.push_back('\x12'); message_out.append(segments[segment_iter]); + message_out.push_back('\x12'); break; } } @@ -5019,8 +5098,8 @@ namespace TOB TOBSlot.Slot = server_slot - EQ::invslot::WORLD_BEGIN; } - Log(Logs::Detail, Logs::Netcode, "Convert Server Slot %i to TOB Slot [%i, %i, %i, %i]", - server_slot, TOBSlot.Type, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert Server Slot {} to TOB Slot [{}, {}, {}, {}]", + server_slot, TOBSlot.Type, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex).c_str()); return TOBSlot; } @@ -5036,8 +5115,8 @@ namespace TOB if (TOBSlot.Slot != invslot::SLOT_INVALID) TOBSlot.Type = invtype::typeCorpse; - Log(Logs::Detail, Logs::Netcode, "Convert Server Corpse Slot %i to TOB Corpse Slot [%i, %i, %i, %i]", - server_corpse_slot, TOBSlot.Type, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert Server Corpse Slot {} to TOB Corpse Slot [{}, {}, {}, {}]", + server_corpse_slot, TOBSlot.Type, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex).c_str()); return TOBSlot; } @@ -5077,8 +5156,8 @@ namespace TOB } } - Log(Logs::Detail, Logs::Netcode, "Convert Server Slot %i to TOB Typeless Slot [%i, %i, %i] (implied type: %i)", - server_slot, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex, server_type); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert Server Slot {} to TOB Typeless Slot [{}, {}, {}] (implied type: {})", + server_slot, TOBSlot.Slot, TOBSlot.SubIndex, TOBSlot.AugIndex, server_type).c_str()); return TOBSlot; } @@ -5086,8 +5165,8 @@ namespace TOB static inline uint32 TOBToServerSlot(structs::InventorySlot_Struct tob_slot) { if (tob_slot.AugIndex < invaug::SOCKET_INVALID || tob_slot.AugIndex >= invaug::SOCKET_COUNT) { - Log(Logs::Detail, Logs::Netcode, "Convert TOB Slot [%i, %i, %i, %i] to Server Slot %i", - tob_slot.Type, tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, EQ::invslot::SLOT_INVALID); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert TOB Slot [{}, {}, {}, {}] to Server Slot {}", + tob_slot.Type, tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, EQ::invslot::SLOT_INVALID).c_str()); return EQ::invslot::SLOT_INVALID; } @@ -5209,8 +5288,8 @@ namespace TOB } } - Log(Logs::Detail, Logs::Netcode, "Convert TOB Slot [%i, %i, %i, %i] to Server Slot %i", - tob_slot.Type, tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, server_slot); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert TOB Slot [{}, {}, {}, {}] to Server Slot {}", + tob_slot.Type, tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, server_slot).c_str()); return server_slot; } @@ -5227,8 +5306,8 @@ namespace TOB ServerSlot = TOBToServerCorpseMainSlot(tob_corpse_slot.Slot); } - Log(Logs::Detail, Logs::Netcode, "Convert TOB Slot [%i, %i, %i, %i] to Server Slot %i", - tob_corpse_slot.Type, tob_corpse_slot.Slot, tob_corpse_slot.SubIndex, tob_corpse_slot.AugIndex, ServerSlot); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert TOB Slot [{}, {}, {}, {}] to Server Slot {}", + tob_corpse_slot.Type, tob_corpse_slot.Slot, tob_corpse_slot.SubIndex, tob_corpse_slot.AugIndex, ServerSlot).c_str()); return ServerSlot; } @@ -5249,8 +5328,8 @@ namespace TOB static inline uint32 TOBToServerTypelessSlot(structs::TypelessInventorySlot_Struct tob_slot, int16 tob_type) { if (tob_slot.AugIndex < invaug::SOCKET_INVALID || tob_slot.AugIndex >= invaug::SOCKET_COUNT) { - Log(Logs::Detail, Logs::Netcode, "Convert TOB Typeless Slot [%i, %i, %i] (implied type: %i) to Server Slot %i", - tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, tob_type, EQ::invslot::SLOT_INVALID); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert TOB Typeless Slot [{}, {}, {}] (implied type: {}) to Server Slot {}", + tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, tob_type, EQ::invslot::SLOT_INVALID).c_str()); return EQ::invslot::SLOT_INVALID; } @@ -5363,8 +5442,8 @@ namespace TOB } } - Log(Logs::Detail, Logs::Netcode, "Convert TOB Typeless Slot [%i, %i, %i] (implied type: %i) to Server Slot %i", - tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, tob_type, ServerSlot); + Log(Logs::Detail, Logs::Netcode, fmt::format("Convert TOB Typeless Slot [{}, {}, {}] (implied type: {}) to Server Slot {}", + tob_slot.Slot, tob_slot.SubIndex, tob_slot.AugIndex, tob_type, ServerSlot).c_str()); return ServerSlot; } diff --git a/common/patches/tob_limits.h b/common/patches/tob_limits.h index 1288cf878..beb5e7b90 100644 --- a/common/patches/tob_limits.h +++ b/common/patches/tob_limits.h @@ -200,8 +200,8 @@ namespace TOB const int16 SLOT_INVALID = IINVALID; const int16 SLOT_BEGIN = INULL; - const int16 SLOT_END = 9; //254; - const int16 SLOT_COUNT = 10; //255; // server Size will be 255..unsure what actual client is (test) + const int16 SLOT_END = 199; + const int16 SLOT_COUNT = 200; // server Size will be 200..unsure what actual client is (test) const char* GetInvBagIndexName(int16 bag_index); diff --git a/common/patches/tob_ops.h b/common/patches/tob_ops.h index afaf305c3..40a7c9adf 100644 --- a/common/patches/tob_ops.h +++ b/common/patches/tob_ops.h @@ -1,4 +1,5 @@ //list of packets we need to encode on the way out: +E(OP_AAExpUpdate) E(OP_Action) E(OP_Animation) E(OP_ApplyPoison) @@ -32,6 +33,7 @@ E(OP_Illusion) E(OP_ItemPacket) E(OP_LogServer) E(OP_ManaChange) +E(OP_MemorizeSpell) E(OP_MobHealth) E(OP_MoneyOnCorpse) E(OP_MoveItem) @@ -83,6 +85,7 @@ D(OP_GMTraining) D(OP_GroupDisband) D(OP_GroupInvite) D(OP_GroupInvite2) +D(OP_MemorizeSpell) D(OP_MoveItem) D(OP_RemoveBlockedBuffs) D(OP_SetServerFilter) diff --git a/common/patches/tob_structs.h b/common/patches/tob_structs.h index 5f9803f3c..db3cd4ad7 100644 --- a/common/patches/tob_structs.h +++ b/common/patches/tob_structs.h @@ -12,7 +12,7 @@ namespace TOB { static const uint32 MAX_PP_UNKNOWN_ABILITIES = 25; static const uint32 MAX_RECAST_TYPES = 25; static const uint32 MAX_ITEM_RECAST_TYPES = 100; - static const uint32 BLOCKED_BUFF_COUNT = 40; + static const uint32 BLOCKED_BUFF_COUNT = 60; // this might not be needed? static const uint32 BUFF_COUNT = 62; static const uint32 MAX_PP_LANGUAGE = 32; #pragma pack(1) @@ -274,7 +274,6 @@ namespace TOB { Unknown39, Unknown40, Unknown41, - Unknown42, Birthdate, EncounterLock }; @@ -288,6 +287,15 @@ namespace TOB { /*0024*/ }; + struct ChangeSize_Struct + { + /*00*/ uint32 EntityID; + /*04*/ float Size; + /*08*/ float CameraOffset; + /*12*/ float AnimationSpeedRelated; + /*16*/ + }; + struct Spawn_Struct_Bitfields { union { @@ -400,14 +408,14 @@ namespace TOB { /*056*/ float X; /*060*/ float Z; /*064*/ float Heading; - /*068*/ float DoorAngle; //not sure if this is actually a float; it might be a uint32 like DefaultDoorAngle + /*068*/ float DoorAngle; /*072*/ uint32 ScaleFactor; //rof2's size /*076*/ uint32 Unknown76; //client doesn't seem to read this /*080*/ uint8 Id; //doorid /*081*/ uint8 Type; //opentype /*082*/ uint8 State; //state_at_spawn /*083*/ uint8 DefaultState; //invert_state - /*084*/ int32 Param; //door_param + /*084*/ int32 Param; //door_param (spell id?) /*088*/ uint32 AdventureDoorId; /*092*/ uint32 DynDoorID; /*096*/ uint32 RealEstateDoorID; @@ -495,8 +503,8 @@ namespace TOB { struct ExpUpdate_Struct { - /*000*/ uint64 exp; //This is exp % / 1000 now; eg 69250 = 69.25% - /*008*/ uint64 unknown; //unclear, I didn't see the client actually read this value but i might have missed it + /*000*/ uint64 exp; // This is exp % / 1000 now; eg 69250 = 69.25% + /*008*/ uint64 unknown; // if this is the value "2", it opens up the tip window }; struct DeleteSpawn_Struct @@ -508,15 +516,14 @@ namespace TOB { //OP_SetServerFilter struct SetServerFilter_Struct { - uint32 filters[68]; + uint32 filters[69]; }; // Was new to RoF2, doesn't look changed // The padding is because these structs are padded to the default 4 bytes struct InventorySlot_Struct { - /*000*/ int16 Type; - /*002*/ int16 Padding1; + /*000*/ int32 Type; /*004*/ int16 Slot; /*006*/ int16 SubIndex; /*008*/ int16 AugIndex; @@ -549,15 +556,6 @@ namespace TOB { /*024*/ }; - struct ChangeSize_Struct - { - /*00*/ uint32 EntityID; - /*04*/ float Size; - /*08*/ uint32 Unknown08; // Observed 0 - /*12*/ float Unknown12; // Observed 1.0f - /*16*/ - }; - struct SpawnHPUpdate_Struct { /*00*/ int16 spawn_id; @@ -677,11 +675,18 @@ namespace TOB { /*000*/ uint32 spell_id; /*004*/ uint16 caster_id; /*006*/ uint32 cast_time; // in miliseconds - /*010*/ uint32 unknown0a; // I think this is caster effective level but im not sure. live always sends 0 + /*010*/ uint32 unknown0a; // I think this is caster effective level but im not sure. live always sends 0. The client uses this for the spell link /*014*/ uint8 unknown0e; // 0 will short circuit the cast, seen 1 from live usually, maybe related to interrupts or particles or something /*015*/ }; + struct MemorizeSpell_Struct { + uint32 slot; // Spot in the spell book/memorized slot + uint32 spell_id; // Spell id (200 or c8 is minor healing, etc) + uint32 scribing; // -1 refreshes book, 0 scribe to book, 2 end mem, 1 start mem, 3 unmem, 4 set activated item keyring -- client will send back 2 if a 0 operation updated a memorized spell of the same group + subgroup + uint32 reduction; // lower reuse (only used if scribing is 4) + }; + //I've observed 5 s16 that are all -1. //Clicky items don't even trigger this as far as i can tell so not sure what this is for now. //One of these could have changed to a s32 but im not sure. @@ -708,6 +713,13 @@ namespace TOB { /*39*/ }; + struct InterruptCast_Struct + { + uint32 spawnid; + uint32 messageid; + char message[0]; + }; + struct EQAffectSlot_Struct { /*00*/ int32 slot; /*04*/ int32 padding; @@ -749,10 +761,10 @@ namespace TOB { struct ManaChange_Struct { uint32 new_mana; - uint32 stamina; + uint32 stamina; // endurance uint32 spell_id; uint32 keepcasting; - int32 slot; + int32 slot; // gem slot }; //This is what we call OP_Action @@ -819,9 +831,8 @@ namespace TOB { struct AA_Array { uint32 AA; - uint32 value; + uint32 value; // points spent uint32 charges; // expendable charges - bool bUnknown0x0c; // added test winter 2024; removed sometime in summer 2024 }; struct AATable_Struct { @@ -834,16 +845,7 @@ namespace TOB { /*000*/ uint32 experience; /*004*/ uint32 unspent; /*008*/ uint8 percentage; - /*009*/ uint8 unknown009[3]; - }; - - struct BlockedBuffs_Struct - { - /*000*/ int32 SpellID[BLOCKED_BUFF_COUNT]; - /*120*/ uint32 Count; - /*124*/ uint8 Pet; - /*125*/ uint8 Initialise; - /*126*/ uint16 Flags; + /*009*/ uint8 padding[3]; }; struct ZonePlayerToBind_Struct { diff --git a/common/ruletypes.h b/common/ruletypes.h index fad7e2a96..84ff0b663 100644 --- a/common/ruletypes.h +++ b/common/ruletypes.h @@ -255,6 +255,7 @@ RULE_BOOL(Mercs, AllowMercSuspendInCombat, true, "Allow merc suspend in combat") RULE_BOOL(Mercs, MercsIgnoreLevelBasedHasteCaps, false, "Ignores hard coded level based haste caps.") RULE_INT(Mercs, MercsHasteCap, 100, "Haste cap for non-v3(over haste) haste") RULE_INT(Mercs, MercsHastev3Cap, 25, "Haste cap for v3(over haste) haste") +RULE_INT(Mercs, MaxMercSlots, 6, "Maximum number of mercenary slots per character (max = MAXMERCS)") RULE_CATEGORY_END() RULE_CATEGORY(Guild) @@ -349,7 +350,7 @@ RULE_STRING(World, MOTD, "", "Server MOTD sent on login, change from empty to ha RULE_STRING(World, Rules, "", "Server Rules, change from empty to have this be used instead of variables table 'rules' value, lines are pipe (|) separated, example: A|B|C") RULE_BOOL(World, EnableAutoLogin, false, "Enables or disables auto login of characters, allowing people to log characters in directly from loginserver to ingame") RULE_BOOL(World, EnablePVPRegions, true, "Enables or disables PVP Regions automatically setting your PVP flag") -RULE_STRING(World, SupportedClients, "RoF2", "Comma-delimited list of clients to restrict to. Supported values are Titanium | SoF | SoD | UF | RoF | RoF2. Example: Titanium,RoF2") +RULE_STRING(World, SupportedClients, "RoF2,TOB", "Comma-delimited list of clients to restrict to. Supported values are Titanium | SoF | SoD | UF | RoF | RoF2 | TOB. Example: Titanium,RoF2,TOB") RULE_STRING(World, CustomFilesKey, "", "Enable if the server requires custom files and sends a key to validate. Empty string to disable. Example: eqcustom_v1") RULE_STRING(World, CustomFilesUrl, "github.com/knervous/eqnexus/releases", "URL to display at character select if client is missing custom files") RULE_INT(World, CustomFilesAdminLevel, 20, "Admin level at which custom file key is not required when CustomFilesKey is specified") diff --git a/common/spdat.cpp b/common/spdat.cpp index 1df6e55e5..01bf12efe 100644 --- a/common/spdat.cpp +++ b/common/spdat.cpp @@ -999,7 +999,7 @@ uint8 GetSpellLevel(uint16 spell_id, uint8 class_id) return UINT8_MAX; } - if (class_id >= Class::PLAYER_CLASS_COUNT) { + if (class_id < Class::Warrior || class_id > Class::PLAYER_CLASS_COUNT) { return UINT8_MAX; } diff --git a/tob/opcodes.md b/tob/opcodes.md index 5337728e6..62db50a03 100644 --- a/tob/opcodes.md +++ b/tob/opcodes.md @@ -9,7 +9,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | Opcode | Status | Notes | Working On | |:----------------------------------|:--------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------| | `OP_AAAction` | 🟡 Unverified | | | -| `OP_AAExpUpdate` | 🟡 Unverified | | | +| `OP_AAExpUpdate` | 🟢 Verified | | | | `OP_AcceptNewTask` | 🔴 Not-Set | | | | `OP_AckPacket` | 🟢 Verified | | | | `OP_Action` | 🟡 Unverified | | | @@ -63,9 +63,9 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_BecomeCorpse` | 🔴 Not-Set | | | | `OP_BecomeTrader` | 🔴 Not-Set | | | | `OP_Begging` | 🟡 Unverified | | | -| `OP_BeginCast` | 🟡 Unverified | | | +| `OP_BeginCast` | 🟢 Verified | | | | `OP_Bind_Wound` | 🟡 Unverified | | | -| `OP_BlockedBuffs` | 🟡 Unverified | | | +| `OP_BlockedBuffs` | 🟢 Verified | | | | `OP_BoardBoat` | 🟡 Unverified | | | | `OP_BookButton` | 🟡 Unverified | | | | `OP_Buff` | 🟡 Unverified | | | @@ -79,17 +79,17 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_CancelTask` | 🔴 Not-Set | | | | `OP_CancelTrade` | 🟡 Unverified | | | | `OP_CashReward` | 🟡 Unverified | | | -| `OP_CastSpell` | 🟡 Unverified | | | -| `OP_ChangeSize` | 🟡 Unverified | | | +| `OP_CastSpell` | 🟢 Verified | | | +| `OP_ChangeSize` | 🟢 Verified | | | | `OP_ChannelMessage` | 🟡 Unverified | | | | `OP_ChangePetName` | 🔴 Not-Set | | | | `OP_CharacterCreate` | 🟢 Verified | Sends heroic type, can be used for something? | | | `OP_CharacterCreateRequest` | 🟢 Verified | | | -| `OP_CharInventory` | 🟡 Unverified | | | +| `OP_CharInventory` | 🟢 Verified | | | | `OP_Charm` | 🟡 Unverified | | | | `OP_ChatMessage` | 🔴 Not-Set | | | -| `OP_ClearAA` | 🟡 Unverified | | | -| `OP_ClearBlockedBuffs` | 🟡 Unverified | | | +| `OP_ClearAA` | 🟢 Verified | | | +| `OP_ClearBlockedBuffs` | 🟢 Verified | | | | `OP_ClearLeadershipAbilities` | 🔴 Not-Set | | | | `OP_ClearNPCMarks` | 🔴 Not-Set | | | | `OP_ClearObject` | 🟡 Unverified | | | @@ -98,20 +98,20 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_ClickObject` | 🟡 Unverified | | | | `OP_ClickObjectAction` | 🟡 Unverified | | | | `OP_ClientError` | 🔴 Not-Set | | | -| `OP_ClientReady` | 🟡 Unverified | | | +| `OP_ClientReady` | 🟢 Verified | | | | `OP_ClientTimeStamp` | 🔴 Not-Set | | | -| `OP_ClientUpdate` | 🟡 Unverified | | | +| `OP_ClientUpdate` | 🟢 Verified | | | | `OP_CloseContainer` | 🔴 Not-Set | | | | `OP_CloseTributeMaster` | 🔴 Not-Set | | | -| `OP_ColoredText` | 🟡 Unverified | | | +| `OP_ColoredText` | 🟢 Verified | | | | `OP_CombatAbility` | 🟡 Unverified | | | | `OP_Command` | 🔴 Not-Set | | | | `OP_CompletedTasks` | 🔴 Not-Set | | | | `OP_ConfirmDelete` | 🟡 Unverified | | | | `OP_Consent` | 🟡 Unverified | | | | `OP_ConsentDeny` | 🟡 Unverified | | | -| `OP_ConsentResponse` | 🟡 Unverified | | | -| `OP_Consider` | 🟡 Unverified | | | +| `OP_ConsentResponse` | 🟢 Verified | | | +| `OP_Consider` | 🟢 Verified | | | | `OP_ConsiderCorpse` | 🟡 Unverified | | | | `OP_Consume` | 🟡 Unverified | | | | `OP_ControlBoat` | 🟡 Unverified | | | @@ -170,7 +170,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_EnvDamage` | 🟡 Unverified | | | | `OP_EvolveItem` | 🔴 Not-Set | | | | `OP_ExpansionInfo` | 🟢 Verified | Updated from u32 to u64 and works now | | -| `OP_ExpUpdate` | 🟡 Unverified | | | +| `OP_ExpUpdate` | 🟢 Verified | | | | `OP_FaceChange` | 🔴 Not-Set | | | | `OP_Feedback` | 🔴 Not-Set | | | | `OP_FeignDeath` | 🟡 Unverified | | | @@ -182,10 +182,10 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_FinishWindow2` | 🟡 Unverified | | | | `OP_Fishing` | 🟡 Unverified | | | | `OP_Fling` | 🟡 Unverified | | | -| `OP_FloatListThing` | 🟢 Verified | Movement History. Sent from client, but emu doesn't use it so setting it as varified. Reference is 0x1402FFAD0 | | +| `OP_FloatListThing` | 🟢 Verified | Movement History. Sent from client, but emu doesn't use it so setting it as verified. Reference is 0x1402FFAD0 | | | `OP_Forage` | 🟡 Unverified | | | | `OP_ForceFindPerson` | 🔴 Not-Set | | | -| `OP_FormattedMessage` | 🟡 Unverified | | | +| `OP_FormattedMessage` | 🟢 Verified | Some major work to do here -- the client now expects a spell link in the packet, will need to refactor the client work to decouple the stream from the internal representation | | | `OP_FriendsWho` | 🟡 Unverified | | | | `OP_GetGuildMOTD` | 🔴 Not-Set | | | | `OP_GetGuildMOTDReply` | 🔴 Not-Set | | | @@ -213,7 +213,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_GMTrainSkillConfirm` | 🟡 Unverified | | | | `OP_GMZoneRequest` | 🔴 Not-Set | | | | `OP_GMZoneRequest2` | 🔴 Not-Set | | | -| `OP_GroundSpawn` | 🟡 Unverified | | | +| `OP_GroundSpawn` | 🟢 Verified | | | | `OP_GroupAcknowledge` | 🔴 Not-Set | | | | `OP_GroupCancelInvite` | 🔴 Not-Set | | | | `OP_GroupDelete` | 🔴 Not-Set | | | @@ -279,7 +279,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_Heartbeat` | 🔴 Not-Set | | | | `OP_Hide` | 🟡 Unverified | | | | `OP_HideCorpse` | 🟡 Unverified | | | -| `OP_HPUpdate` | 🟡 Unverified | | | +| `OP_HPUpdate` | 🟢 Verified | | | | `OP_Illusion` | 🟡 Unverified | | | | `OP_IncreaseStats` | 🟡 Unverified | | | | `OP_InitialHPUpdate` | 🔴 Not-Set | | | @@ -289,7 +289,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_InspectMessageUpdate` | 🔴 Not-Set | | | | `OP_InspectRequest` | 🔴 Not-Set | | | | `OP_InstillDoubt` | 🟡 Unverified | | | -| `OP_InterruptCast` | 🟡 Unverified | | | +| `OP_InterruptCast` | 🟢 Verified | Some major work to do here -- the client now expects a spell link in the packet, will need to refactor the client work to decouple the stream from the internal representation | | | `OP_InvokeChangePetName` | 🔴 Not-Set | | | | `OP_InvokeChangePetNameImmediate` | 🔴 Not-Set | | | | `OP_InvokeNameChangeImmediate` | 🔴 Not-Set | | | @@ -330,7 +330,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_LFPCommand` | 🔴 Not-Set | | | | `OP_LFPGetMatchesRequest` | 🔴 Not-Set | | | | `OP_LFPGetMatchesResponse` | 🔴 Not-Set | | | -| `OP_LinkedReuse` | 🟡 Unverified | | | +| `OP_LinkedReuse` | 🟢 Verified | | | | `OP_LoadSpellSet` | 🔴 Not-Set | | | | `OP_LocInfo` | 🔴 Not-Set | | | | `OP_LockoutTimerInfo` | 🔴 Not-Set | | | @@ -346,12 +346,12 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_LootComplete` | 🟡 Unverified | | | | `OP_LootItem` | 🟡 Unverified | | | | `OP_LootRequest` | 🟡 Unverified | | | -| `OP_ManaChange` | 🟡 Unverified | | | +| `OP_ManaChange` | 🟢 Verified | | | | `OP_ManaUpdate` | 🔴 Not-Set | | | | `OP_MarkNPC` | 🔴 Not-Set | | | | `OP_MarkRaidNPC` | 🔴 Not-Set | | | | `OP_Marquee` | 🟡 Unverified | | | -| `OP_MemorizeSpell` | 🟡 Unverified | | | +| `OP_MemorizeSpell` | 🟢 Verified | | | | `OP_Mend` | 🟡 Unverified | | | | `OP_MendHPUpdate` | 🔴 Not-Set | | | | `OP_MercenaryAssign` | 🔴 Not-Set | | | @@ -379,7 +379,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_MOTD` | 🟢 Verified | | | | `OP_MoveCoin` | 🟡 Unverified | | | | `OP_MoveDoor` | 🟡 Unverified | | | -| `OP_MoveItem` | 🟡 Unverified | | | +| `OP_MoveItem` | 🟢 Verified | | | | `OP_MoveMultipleItems` | 🟡 Unverified | | | | `OP_MoveLogDisregard` | 🔴 Not-Set | | | | `OP_MoveLogRequest` | 🔴 Not-Set | | | @@ -435,7 +435,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_PVPLeaderBoardRequest` | 🔴 Not-Set | | | | `OP_PVPStats` | 🔴 Not-Set | | | | `OP_QueryResponseThing` | 🔴 Not-Set | | | -| `OP_QueryUCSServerStatus` | 🟡 Unverified | | | +| `OP_QueryUCSServerStatus` | 🟢 Verified | | | | `OP_RaidDelegateAbility` | 🔴 Not-Set | | | | `OP_RaidClearNPCMarks` | 🔴 Not-Set | | | | `OP_RaidInvite` | 🔴 Not-Set | | | @@ -453,7 +453,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_ReclaimCrystals` | 🔴 Not-Set | | | | `OP_ReloadUI` | 🔴 Not-Set | | | | `OP_RemoveAllDoors` | 🟡 Unverified | | | -| `OP_RemoveBlockedBuffs` | 🟡 Unverified | | | +| `OP_RemoveBlockedBuffs` | 🟢 Verified | | | | `OP_RemoveNimbusEffect` | 🟡 Unverified | | | | `OP_RemoveTrap` | 🔴 Not-Set | | | | `OP_Report` | 🟡 Unverified | | | @@ -465,7 +465,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_RequestKnowledgeBase` | 🔴 Not-Set | | | | `OP_RequestTitles` | 🔴 Not-Set | | | | `OP_RespawnWindow` | 🟡 Unverified | | | -| `OP_RespondAA` | 🟡 Unverified | | | +| `OP_RespondAA` | 🟢 Verified | | | | `OP_RestState` | 🟡 Unverified | | | | `OP_Rewind` | 🟡 Unverified | | | | `OP_RezzAnswer` | 🔴 Not-Set | | | @@ -478,9 +478,9 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_SaveOnZoneReq` | 🟡 Unverified | | | | `OP_SelectTribute` | 🔴 Not-Set | | | | `OP_SendAAStats` | 🟡 Unverified | | | -| `OP_SendAATable` | 🟡 Unverified | | | +| `OP_SendAATable` | 🟢 Verified | | | | `OP_SendCharInfo` | 🟢 Verified | | | -| `OP_SendExpZonein` | 🟡 Unverified | | | +| `OP_SendExpZonein` | 🟢 Verified | | | | `OP_SendFindableNPCs` | 🔴 Not-Set | | | | `OP_SendGuildTributes` | 🔴 Not-Set | | | | `OP_SendLoginInfo` | 🟢 Verified | | | @@ -490,7 +490,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_SendSystemStats` | 🔴 Not-Set | | | | `OP_SendTitleList` | 🔴 Not-Set | | | | `OP_SendTributes` | 🔴 Not-Set | | | -| `OP_SendZonepoints` | 🟡 Unverified | | | +| `OP_SendZonepoints` | 🟢 Verified | | | | `OP_SenseHeading` | 🟡 Unverified | | | | `OP_SenseTraps` | 🟡 Unverified | | | | `OP_ServerListRequest` | 🔴 Not-Set | | | @@ -503,7 +503,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_SetGuildMOTD` | 🔴 Not-Set | | | | `OP_SetGuildRank` | 🔴 Not-Set | | | | `OP_SetRunMode` | 🟡 Unverified | | | -| `OP_SetServerFilter` | 🟡 Unverified | | | +| `OP_SetServerFilter` | 🟢 Verified | | | | `OP_SetStartCity` | 🔴 Not-Set | | | | `OP_SetTitle` | 🔴 Not-Set | | | | `OP_SetTitleReply` | 🔴 Not-Set | | | @@ -533,23 +533,23 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_ShopRetrieveParcel` | 🟡 Unverified | | | | `OP_ShopParcelIcon` | 🟡 Unverified | | | | `OP_ShopRequest` | 🟡 Unverified | | | -| `OP_SimpleMessage` | 🟡 Unverified | | | +| `OP_SimpleMessage` | 🟢 Verified | | | | `OP_SkillUpdate` | 🟡 Unverified | | | | `OP_Sneak` | 🟡 Unverified | | | | `OP_Some3ByteHPUpdate` | 🔴 Not-Set | | | | `OP_Some6ByteHPUpdate` | 🔴 Not-Set | | | | `OP_SomeItemPacketMaybe` | 🔴 Not-Set | | | | `OP_Sound` | 🟡 Unverified | | | -| `OP_SpawnAppearance` | 🟡 Unverified | | | -| `OP_SpawnDoor` | 🟡 Unverified | | | +| `OP_SpawnAppearance` | 🟢 Verified | | | +| `OP_SpawnDoor` | 🟢 Verified | | | | `OP_SpawnPositionUpdate` | 🔴 Not-Set | | | -| `OP_SpecialMesg` | 🟡 Unverified | | | +| `OP_SpecialMesg` | 🟢 Verified | | | | `OP_SpellEffect` | 🟡 Unverified | | | | `OP_Split` | 🟡 Unverified | | | | `OP_Stamina` | 🟢 Verified | These values are 0-32k instead of 0-127 | | | `OP_Stun` | 🟡 Unverified | | | | `OP_Surname` | 🔴 Not-Set | | | -| `OP_SwapSpell` | 🟡 Unverified | | | +| `OP_SwapSpell` | 🟢 Verified | | | | `OP_SystemFingerprint` | 🔴 Not-Set | | | | `OP_TargetBuffs` | 🔴 Not-Set | | | | `OP_TargetCommand` | 🟡 Unverified | | | @@ -594,7 +594,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_TributeToggle` | 🔴 Not-Set | | | | `OP_TributeUpdate` | 🔴 Not-Set | | | | `OP_Untargetable` | 🟡 Unverified | | | -| `OP_UpdateAA` | 🟡 Unverified | | | +| `OP_UpdateAA` | 🟢 Verified | | | | `OP_UpdateAura` | 🔴 Not-Set | | | | `OP_UpdateLeadershipAA` | 🔴 Not-Set | | | | `OP_VetClaimReply` | 🔴 Not-Set | | | @@ -603,8 +603,8 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_VoiceMacroIn` | 🟡 Unverified | | | | `OP_VoiceMacroOut` | 🟡 Unverified | | | | `OP_WeaponEquip1` | 🔴 Not-Set | | | -| `OP_WearChange` | 🟡 Unverified | | | -| `OP_Weather` | 🟡 Unverified | | | +| `OP_WearChange` | 🟢 Verified | | | +| `OP_Weather` | 🟢 Verified | | | | `OP_Weblink` | 🟡 Unverified | | | | `OP_WhoAllRequest` | 🟡 Unverified | | | | `OP_WhoAllResponse` | 🟡 Unverified | | | @@ -614,7 +614,7 @@ Below is a status list for the 450 opcodes we currently use on the server for th | `OP_WorldClientReady` | 🟢 Verified | | | | `OP_WorldComplete` | 🟢 Verified | | | | `OP_WorldLogout` | 🔴 Not-Set | | | -| `OP_WorldObjectsSent` | 🟡 Unverified | | | +| `OP_WorldObjectsSent` | 🟢 Verified | | | | `OP_WorldUnknown001` | 🟢 Verified | SetServerTime. emu doesn't currently send it so setting it to verified, but the reference is 0x140292550 | | | `OP_XTargetAutoAddHaters` | 🔴 Not-Set | | | | `OP_XTargetOpen` | 🔴 Not-Set | | | diff --git a/utils/patches/patch_RoF2.conf b/utils/patches/patch_RoF2.conf index 3533aaa87..e5d9173cf 100644 --- a/utils/patches/patch_RoF2.conf +++ b/utils/patches/patch_RoF2.conf @@ -418,6 +418,7 @@ OP_MercenaryUnknown1=0x5d26 OP_MercenaryCommand=0x27f2 OP_MercenarySuspendRequest=0x4407 OP_MercenarySuspendResponse=0x6f03 +OP_MercenarySwitch=0x1b37 OP_MercenaryUnsuspendResponse=0x27a0 # Looting diff --git a/utils/patches/patch_TOB.conf b/utils/patches/patch_TOB.conf index 3102e24a3..de96f6841 100644 --- a/utils/patches/patch_TOB.conf +++ b/utils/patches/patch_TOB.conf @@ -93,7 +93,7 @@ OP_ClearAA=0x6093 OP_ClearLeadershipAbilities=0x0000 #removed; leadership abilities are baked in and always on OP_RespondAA=0x4449 OP_UpdateAA=0x1655 -OP_SendAAStats=0x7416 #i'll be honest i think this was removed at some point but this is the op at the spot in the list +OP_SendAAStats=0x7416 # Removed in TOB OP_AAExpUpdate=0x04c3 #need to look into whether this has changed; exp did OP_ExpUpdate=0x0e55 OP_HPUpdate=0x2723 @@ -222,7 +222,7 @@ OP_KeyRing=0x0000 OP_WhoAllRequest=0x3328 OP_WhoAllResponse=0x4dfd OP_FriendsWho=0x3547 -OP_ConfirmDelete=0x14a8 +OP_ConfirmDelete=0x14a8 # This is sent fromt the client after a movement update (with just spawn ID as the content) OP_Logout=0x46f8 OP_Rewind=0x898a OP_TargetCommand=0x46bf diff --git a/zone/bot.cpp b/zone/bot.cpp index 8c3f668a4..00662389c 100644 --- a/zone/bot.cpp +++ b/zone/bot.cpp @@ -5175,7 +5175,7 @@ int Bot::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) ac_bonus = inst->GetItemArmorClass(true) / 25.0f; if (ac_bonus > skill_bonus) ac_bonus = skill_bonus; - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillKick: { float skill_bonus = skill_level / 10.0f; @@ -5185,7 +5185,7 @@ int Bot::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) ac_bonus = inst->GetItemArmorClass(true) / 25.0f; if (ac_bonus > skill_bonus) ac_bonus = skill_bonus; - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillBash: { float skill_bonus = skill_level / 10.0f; @@ -5199,7 +5199,7 @@ int Bot::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) ac_bonus = inst->GetItemArmorClass(true) / 25.0f; if (ac_bonus > skill_bonus) ac_bonus = skill_bonus; - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillBackstab: { float skill_bonus = static_cast(skill_level) * 0.02f; diff --git a/zone/client.cpp b/zone/client.cpp index 9dca5570a..d50cb927e 100644 --- a/zone/client.cpp +++ b/zone/client.cpp @@ -1037,7 +1037,7 @@ bool Client::Save(uint8 iCommitNow) { } if (dead || (!GetMerc() && !GetMercInfo().IsSuspended)) { - memset(&m_mercinfo, 0, sizeof(struct MercInfo)); + memset(&m_mercinfo, 0, sizeof(m_mercinfo)); } m_pp.lastlogin = time(nullptr); @@ -7940,75 +7940,125 @@ void Client::SendWebLink(const char *website) void Client::SendMercPersonalInfo() { - uint32 mercTypeCount = 1; - uint32 mercCount = 1; //TODO: Un-hardcode this and support multiple mercs like in later clients than SoD. - uint32 i = 0; uint32 altCurrentType = 19; //TODO: Implement alternate currency purchases involving mercs! - MercTemplate *mercData = &zone->merc_templates[GetMercInfo().MercTemplateID]; - - int stancecount = 0; - stancecount += zone->merc_stance_list[GetMercInfo().MercTemplateID].size(); - if(stancecount > MAX_MERC_STANCES || mercCount > MAX_MERC || mercTypeCount > MAX_MERC_GRADES) - { - Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo canceled: (%i) (%i) (%i) for %s", stancecount, mercCount, mercTypeCount, GetName()); - SendMercMerchantResponsePacket(0); - return; - } - if (ClientVersion() >= EQ::versions::ClientVersion::RoF) { - auto outapp = new EQApplicationPacket(OP_MercenaryDataUpdate, sizeof(MercenaryDataUpdate_Struct)); - auto mdus = (MercenaryDataUpdate_Struct *) outapp->pBuffer; - - mdus->MercStatus = 0; - mdus->MercCount = mercCount; - mdus->MercData[i].MercID = mercData->MercTemplateID; - mdus->MercData[i].MercType = mercData->MercType; - mdus->MercData[i].MercSubType = mercData->MercSubType; - mdus->MercData[i].PurchaseCost = Merc::CalcPurchaseCost(mercData->MercTemplateID, GetLevel(), 0); - mdus->MercData[i].UpkeepCost = Merc::CalcUpkeepCost(mercData->MercTemplateID, GetLevel(), 0); - mdus->MercData[i].Status = 0; - mdus->MercData[i].AltCurrencyCost = Merc::CalcPurchaseCost( - mercData->MercTemplateID, - GetLevel(), - altCurrentType - ); - mdus->MercData[i].AltCurrencyUpkeep = Merc::CalcPurchaseCost( - mercData->MercTemplateID, - GetLevel(), - altCurrentType - ); - mdus->MercData[i].AltCurrencyType = altCurrentType; - mdus->MercData[i].MercUnk01 = 0; - mdus->MercData[i].TimeLeft = GetMercInfo().MercTimerRemaining; //GetMercTimer().GetRemainingTime(); - mdus->MercData[i].MerchantSlot = i + 1; - mdus->MercData[i].MercUnk02 = 1; - mdus->MercData[i].StanceCount = zone->merc_stance_list[mercData->MercTemplateID].size(); - mdus->MercData[i].MercUnk03 = 0; - mdus->MercData[i].MercUnk04 = 1; - - strn0cpy(mdus->MercData[i].MercName, GetMercInfo().merc_name, sizeof(mdus->MercData[i].MercName)); - - uint32 stanceindex = 0; - if (mdus->MercData[i].StanceCount != 0) { - auto iter = zone->merc_stance_list[mercData->MercTemplateID].begin(); - while (iter != zone->merc_stance_list[mercData->MercTemplateID].end()) { - mdus->MercData[i].Stances[stanceindex].StanceIndex = stanceindex; - mdus->MercData[i].Stances[stanceindex].Stance = (iter->StanceID); - stanceindex++; - ++iter; - } + // Count owned mercs across all slots + uint32 mercCount = GetNumberOfMercenaries(); + if (mercCount == 0) { + SendClearMercInfo(); + return; } - mdus->MercData[i].MercUnk05 = 1; + uint32 packetSize = sizeof(MercenaryDataUpdate_Struct); + auto outapp = new EQApplicationPacket(OP_MercenaryDataUpdate, packetSize); + memset(outapp->pBuffer, 0, packetSize); + auto mdus = (MercenaryDataUpdate_Struct *) outapp->pBuffer; + + mdus->MercStatus = 0; + mdus->MercCount = mercCount; + + // Lambda to populate a single merc entry in the packet + int max_slots = std::min(RuleI(Mercs, MaxMercSlots), MAXMERCS); + uint32 merc_index = 0; + + auto fillMercEntry = [&](int slot) { + auto& info = GetMercInfo(slot); + if (info.mercid == 0 || merc_index >= MAX_MERC) { + return; + } + + auto tmpl_it = zone->merc_templates.find(info.MercTemplateID); + if (tmpl_it == zone->merc_templates.end()) { + return; + } + + MercTemplate *mercData = &tmpl_it->second; + uint32 stancecount = 0; + auto stance_it = zone->merc_stance_list.find(mercData->MercTemplateID); + if (stance_it != zone->merc_stance_list.end()) { + stancecount = stance_it->second.size(); + } + + if (stancecount > MAX_MERC_STANCES) { + Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo: stance count %u exceeds max for slot %i, skipping", stancecount, slot); + return; + } + + mdus->MercData[merc_index].MercID = mercData->MercTemplateID; + mdus->MercData[merc_index].MercType = mercData->MercType; + mdus->MercData[merc_index].MercSubType = mercData->MercSubType; + mdus->MercData[merc_index].PurchaseCost = Merc::CalcPurchaseCost(mercData->MercTemplateID, GetLevel(), 0); + mdus->MercData[merc_index].UpkeepCost = Merc::CalcUpkeepCost(mercData->MercTemplateID, GetLevel(), 0); + mdus->MercData[merc_index].Status = info.IsSuspended ? 0 : 1; + mdus->MercData[merc_index].AltCurrencyCost = Merc::CalcPurchaseCost(mercData->MercTemplateID, GetLevel(), altCurrentType); + mdus->MercData[merc_index].AltCurrencyUpkeep = Merc::CalcPurchaseCost(mercData->MercTemplateID, GetLevel(), altCurrentType); + mdus->MercData[merc_index].AltCurrencyType = altCurrentType; + mdus->MercData[merc_index].MercUnk01 = 0; + mdus->MercData[merc_index].TimeLeft = info.MercTimerRemaining; + mdus->MercData[merc_index].MerchantSlot = merc_index + 1; + mdus->MercData[merc_index].MercUnk02 = (slot == GetMercSlot()) ? 1 : 0; + mdus->MercData[merc_index].StanceCount = stancecount; + mdus->MercData[merc_index].MercUnk03 = 0; + mdus->MercData[merc_index].MercUnk04 = 1; + + strn0cpy(mdus->MercData[merc_index].MercName, info.merc_name, sizeof(mdus->MercData[merc_index].MercName)); + + uint32 stanceindex = 0; + if (stance_it != zone->merc_stance_list.end()) { + for (const auto& stance : stance_it->second) { + mdus->MercData[merc_index].Stances[stanceindex].StanceIndex = stanceindex; + mdus->MercData[merc_index].Stances[stanceindex].Stance = stance.StanceID; + stanceindex++; + } + } + + mdus->MercData[merc_index].MercUnk05 = std::min(RuleI(Mercs, MaxMercSlots), MAXMERCS); + merc_index++; + }; + + // Emit the active merc slot first — the client marks the first entry + // in the list with the X (active marker), so order matters. + if (GetMercSlot() < max_slots && GetMercInfo().mercid != 0) { + fillMercEntry(GetMercSlot()); + } + + // Then emit remaining owned mercs in slot order + for (int slot = 0; slot < max_slots; slot++) { + if (slot == GetMercSlot()) { + continue; // already emitted + } + fillMercEntry(slot); + } + + // Update count in case we skipped any invalid entries + mdus->MercCount = merc_index; + FastQueuePacket(&outapp); safe_delete(outapp); return; } else { + // Pre-RoF path (SoD and earlier) — single merc only + if (GetMercInfo().MercTemplateID == 0) { + SendClearMercInfo(); + return; + } + + auto tmpl_it = zone->merc_templates.find(GetMercInfo().MercTemplateID); + if (tmpl_it == zone->merc_templates.end()) { + SendClearMercInfo(); + return; + } + + MercTemplate *mercData = &tmpl_it->second; + uint32 mercTypeCount = 1; + uint32 mercCount = 1; + uint32 i = 0; + auto outapp = new EQApplicationPacket(OP_MercenaryDataResponse, sizeof(MercenaryMerchantList_Struct)); auto mml = (MercenaryMerchantList_Struct *) outapp->pBuffer; - mml->MercTypeCount = mercTypeCount; //We should only have one merc entry. + mml->MercTypeCount = mercTypeCount; mml->MercGrades[i] = 1; mml->MercCount = mercCount; diff --git a/zone/client.h b/zone/client.h index 559165d60..915a6a383 100644 --- a/zone/client.h +++ b/zone/client.h @@ -1773,6 +1773,7 @@ public: MercInfo& GetMercInfo(uint8 slot) { return m_mercinfo[slot]; } MercInfo& GetMercInfo() { return m_mercinfo[mercSlot]; } uint8 GetNumberOfMercenaries(); + int GetFirstFreeMercSlot(); void SetMerc(Merc* newmerc); void SendMercResponsePackets(uint32 ResponseType); void SendMercMerchantResponsePacket(int32 response_type); diff --git a/zone/client_packet.cpp b/zone/client_packet.cpp index e568265c5..6568d6357 100644 --- a/zone/client_packet.cpp +++ b/zone/client_packet.cpp @@ -304,6 +304,7 @@ void MapOpcodes() ConnectedOpcodes[OP_MercenaryDismiss] = &Client::Handle_OP_MercenaryDismiss; ConnectedOpcodes[OP_MercenaryHire] = &Client::Handle_OP_MercenaryHire; ConnectedOpcodes[OP_MercenarySuspendRequest] = &Client::Handle_OP_MercenarySuspendRequest; + ConnectedOpcodes[OP_MercenarySwitch] = &Client::Handle_OP_MercenarySwitch; ConnectedOpcodes[OP_MercenaryTimerRequest] = &Client::Handle_OP_MercenaryTimerRequest; ConnectedOpcodes[OP_MoveCoin] = &Client::Handle_OP_MoveCoin; ConnectedOpcodes[OP_MoveItem] = &Client::Handle_OP_MoveItem; @@ -10423,7 +10424,7 @@ void Client::Handle_OP_MercenaryCommand(const EQApplicationPacket *app) } MercenaryCommand_Struct* mc = (MercenaryCommand_Struct*)app->pBuffer; - uint32 merc_command = mc->MercCommand; // Seen 0 (zone in with no merc or suspended), 1 (dismiss merc), 5 (normal state), 20 (unknown), 36 (zone in with merc) + uint32 merc_command = mc->MercCommand; // Seen 0 (zone in with no merc or suspended), 1 (stance/state update), 5 (normal state), 20 (unknown), 36 (zone in with merc) int32 option = mc->Option; // Seen -1 (zone in with no merc), 0 (setting to passive stance), 1 (normal or setting to balanced stance) Log(Logs::General, Logs::Mercenaries, "Command %i, Option %i received from %s.", merc_command, option, GetName()); @@ -10431,9 +10432,6 @@ void Client::Handle_OP_MercenaryCommand(const EQApplicationPacket *app) if (!RuleB(Mercs, AllowMercs)) return; - // Handle the Command here... - // Will need a list of what every type of command is supposed to do - // Unsure if there is a server response to this packet if (option >= 0) { Merc* merc = GetMerc(); @@ -10489,14 +10487,14 @@ void Client::Handle_OP_MercenaryDataRequest(const EQApplicationPacket *app) if (merchant_id == 0) { //send info about your current merc(s) - if (GetMercInfo().mercid) + if (GetNumberOfMercenaries() > 0) { Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo Request for %s.", GetName()); SendMercPersonalInfo(); } else { - Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo Not Sent - MercID (%i) for %s.", GetMercInfo().mercid, GetName()); + Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo Not Sent - no mercs owned for %s.", GetName()); } } @@ -10680,6 +10678,22 @@ void Client::Handle_OP_MercenaryHire(const EQApplicationPacket *app) return; } + // Suspend active merc if one exists before hiring into a new slot + Merc* current_merc = GetMerc(); + if (current_merc) { + current_merc->Suspend(); + current_merc->SetOwnerID(0); + SetMercID(0); + } + + // Select a free slot for the new hire + int free_slot = GetFirstFreeMercSlot(); + if (free_slot < 0) { + SendMercResponsePackets(6); + return; + } + SetMercSlot(static_cast(free_slot)); + // Set time remaining to max on Hire GetMercInfo().MercTimerRemaining = RuleI(Mercs, UpkeepIntervalMS); @@ -10699,6 +10713,10 @@ void Client::Handle_OP_MercenaryHire(const EQApplicationPacket *app) // approved hire request SendMercMerchantResponsePacket(0); + + // Update the client's Manage tab with the newly hired merc info + SendMercPersonalInfo(); + SendMercTimer(merc); } else { @@ -10735,6 +10753,88 @@ void Client::Handle_OP_MercenarySuspendRequest(const EQApplicationPacket *app) SuspendMercCommand(); } +void Client::Handle_OP_MercenarySwitch(const EQApplicationPacket *app) +{ + if (app->size != sizeof(SwitchMercenary_Struct)) { + LogDebug("Size mismatch in OP_MercenarySwitch expected [{}] got [{}]", sizeof(SwitchMercenary_Struct), app->size); + DumpPacket(app); + return; + } + + if (!RuleB(Mercs, AllowMercs)) + return; + + SwitchMercenary_Struct* sm = (SwitchMercenary_Struct*)app->pBuffer; + uint32 merc_ui_index = sm->MercIndex; + + Log(Logs::General, Logs::Mercenaries, "Switch request to UI index %u received from %s.", merc_ui_index, GetName()); + + // The client sends a dense UI index (0, 1, 2...) that corresponds to the Nth + // owned merc in the list, matching the order sent by SendMercPersonalInfo(). + // SendMercPersonalInfo emits the active slot first, then remaining slots in order. + // We must replicate that same ordering to map UI index -> internal slot. + int target_slot = -1; + int max_slots = std::min(RuleI(Mercs, MaxMercSlots), MAXMERCS); + uint32 ui_pos = 0; + + // First: the active merc slot (emitted first in the packet) + if (GetMercSlot() < max_slots && m_mercinfo[GetMercSlot()].mercid != 0) { + if (ui_pos == merc_ui_index) { + target_slot = GetMercSlot(); + } + ui_pos++; + } + + // Then: remaining slots in order (skipping active slot) + if (target_slot < 0) { + for (int slot = 0; slot < max_slots; slot++) { + if (slot == GetMercSlot()) { + continue; + } + if (m_mercinfo[slot].mercid != 0) { + if (ui_pos == merc_ui_index) { + target_slot = slot; + break; + } + ui_pos++; + } + } + } + + if (target_slot < 0) { + Log(Logs::General, Logs::Mercenaries, "Switch request denied — UI index %u has no corresponding merc for %s.", merc_ui_index, GetName()); + SendMercResponsePackets(0); + return; + } + + if (target_slot == GetMercSlot()) { + Log(Logs::General, Logs::Mercenaries, "Switch request ignored — already on slot %i for %s.", target_slot, GetName()); + return; + } + + // Suspend the currently active merc if one is spawned + Merc* current_merc = GetMerc(); + if (current_merc) { + current_merc->Suspend(); + // Clear merc pointer without wiping slot data (SetMerc(nullptr) would zero the slot) + current_merc->SetOwnerID(0); + SetMercID(0); + } + + // Clear the suspend timer so the target merc can be unsuspended immediately. + // The cooldown is meant for rapid suspend/unsuspend of the same merc, not for switching. + if (!GetPTimers().Expired(&database, pTimerMercSuspend, false)) { + GetPTimers().Clear(&database, pTimerMercSuspend); + } + + SetMercSlot(static_cast(target_slot)); + + Log(Logs::General, Logs::Mercenaries, "Switched active merc slot to %i (UI index %u) for %s.", target_slot, merc_ui_index, GetName()); + + // Unsuspend the target merc + SuspendMercCommand(); +} + void Client::Handle_OP_MercenaryTimerRequest(const EQApplicationPacket *app) { // The payload is 0 bytes. @@ -12239,6 +12339,9 @@ void Client::Handle_OP_QueryUCSServerStatus(const EQApplicationPacket *app) case EQ::versions::ClientVersion::RoF2: ConnectionType = EQ::versions::ucsRoF2Combined; break; + case EQ::versions::ClientVersion::TOB: + ConnectionType = EQ::versions::ucsTOBCombined; + break; default: ConnectionType = EQ::versions::ucsUnknown; break; diff --git a/zone/client_packet.h b/zone/client_packet.h index 0adb29fd1..0946f0438 100644 --- a/zone/client_packet.h +++ b/zone/client_packet.h @@ -237,6 +237,7 @@ void Handle_OP_MercenaryDismiss(const EQApplicationPacket *app); void Handle_OP_MercenaryHire(const EQApplicationPacket *app); void Handle_OP_MercenarySuspendRequest(const EQApplicationPacket *app); + void Handle_OP_MercenarySwitch(const EQApplicationPacket *app); void Handle_OP_MercenaryTimerRequest(const EQApplicationPacket *app); void Handle_OP_MoveCoin(const EQApplicationPacket *app); void Handle_OP_MoveItem(const EQApplicationPacket *app); diff --git a/zone/merc.cpp b/zone/merc.cpp index 283132a2d..d9212db8d 100644 --- a/zone/merc.cpp +++ b/zone/merc.cpp @@ -4885,14 +4885,8 @@ bool Client::CheckCanHireMerc(Mob* merchant, uint32 template_id) { MercTemplate* mercTemplate = zone->GetMercTemplate(template_id); - //check for suspended merc - if(GetMercInfo().mercid != 0 && GetMercInfo().IsSuspended) { - SendMercResponsePackets(6); - return false; - } - - // Check if max number of mercs is already reached - if(GetNumberOfMercenaries() >= MAXMERCS) { + // Check if all merc slots are full (counts both active and suspended mercs) + if (GetFirstFreeMercSlot() < 0) { SendMercResponsePackets(6); return false; } @@ -5046,8 +5040,21 @@ void Client::SuspendMercCommand() { return; } + // Suspend any currently active merc before unsuspending this one + Merc* active_merc = GetMerc(); + if (active_merc) { + active_merc->Suspend(); + SetMerc(nullptr); + } + // Get merc, assign it to client & spawn - Merc* merc = Merc::LoadMercenary(this, &zone->merc_templates[GetMercInfo().MercTemplateID], 0, true); + auto tmpl_it = zone->merc_templates.find(GetMercInfo().MercTemplateID); + if (tmpl_it == zone->merc_templates.end()) { + SendMercResponsePackets(3); + Log(Logs::General, Logs::Mercenaries, "SuspendMercCommand Invalid template for %s.", GetName()); + return; + } + Merc* merc = Merc::LoadMercenary(this, &tmpl_it->second, 0, true); if(merc) { SpawnMerc(merc, false); @@ -5119,40 +5126,56 @@ void Client::SpawnMercOnZone() { if(database.LoadMercenaryInfo(this)) { - if(!GetMercInfo().IsSuspended) - { + // Find the active (non-suspended) merc slot, or fall back to the first owned slot + int active_slot = -1; + int first_owned_slot = -1; + int max_slots = std::min(RuleI(Mercs, MaxMercSlots), MAXMERCS); + for (int slot = 0; slot < max_slots; slot++) { + if (GetMercInfo(slot).mercid != 0) { + if (first_owned_slot < 0) { + first_owned_slot = slot; + } + if (!GetMercInfo(slot).IsSuspended) { + active_slot = slot; + break; + } + } + } + + if (active_slot >= 0) { + SetMercSlot(static_cast(active_slot)); GetMercInfo().SuspendedTime = 0; // Get merc, assign it to client & spawn - Merc* merc = Merc::LoadMercenary(this, &zone->merc_templates[GetMercInfo().MercTemplateID], 0, true); - if(merc) - { - SpawnMerc(merc, false); + auto tmpl_it = zone->merc_templates.find(GetMercInfo().MercTemplateID); + if (tmpl_it != zone->merc_templates.end()) { + Merc* merc = Merc::LoadMercenary(this, &tmpl_it->second, 0, true); + if (merc) { + SpawnMerc(merc, false); + } } - Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone Normal Merc for %s.", GetName()); - } - else - { + Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone Normal Merc (slot %i) for %s.", active_slot, GetName()); + } else if (first_owned_slot >= 0) { + SetMercSlot(static_cast(first_owned_slot)); int32 TimeDiff = GetMercInfo().SuspendedTime - time(nullptr); - if (TimeDiff > 0) - { - if (!GetPTimers().Enabled(pTimerMercSuspend)) - { - // Start the timer to send the packet that refreshes the Unsuspend Button + if (TimeDiff > 0) { + if (!GetPTimers().Enabled(pTimerMercSuspend)) { GetPTimers().Start(pTimerMercSuspend, TimeDiff); } } - // Send Mercenary Status/Timer packet SendMercTimer(GetMerc()); + Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone Suspended Merc (slot %i) for %s.", first_owned_slot, GetName()); + } else { + Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone No valid merc slots found for %s.", GetName()); + } - Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone Suspended Merc for %s.", GetName()); + // Send merc personal info for all owned mercs (populates the Manage tab) + if (GetNumberOfMercenaries() > 0) { + SendMercPersonalInfo(); } } else { - // No Merc Hired - // RoF+ displays a message from the following packet, which seems useless - //SendClearMercInfo(); - Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone Failed to load Merc Info from the Database for %s.", GetName()); + Log(Logs::General, Logs::Mercenaries, "SpawnMercOnZone No merc info in database for %s.", GetName()); } } @@ -5323,9 +5346,18 @@ bool Client::DismissMerc(uint32 MercID) { GetMerc()->Depop(); } - SendClearMercInfo(); + // Clear the dismissed merc's slot data so it becomes available + memset(&GetMercInfo(), 0, sizeof(MercInfo)); + SetMerc(nullptr); + // Update the client with remaining mercs or clear if none left + if (GetNumberOfMercenaries() > 0) { + SendMercPersonalInfo(); + } else { + SendClearMercInfo(); + } + return Dismissed; } @@ -5579,6 +5611,17 @@ uint8 Client::GetNumberOfMercenaries() return count; } +int Client::GetFirstFreeMercSlot() +{ + int max_slots = std::min(RuleI(Mercs, MaxMercSlots), MAXMERCS); + for (int slot_id = 0; slot_id < max_slots; slot_id++) { + if (m_mercinfo[slot_id].mercid == 0) { + return slot_id; + } + } + return -1; +} + void Merc::SetMercData( uint32 template_id ) { MercTemplate* merc_template = zone->GetMercTemplate(template_id); diff --git a/zone/merc.h b/zone/merc.h index fb27c9f87..0eba77f17 100644 --- a/zone/merc.h +++ b/zone/merc.h @@ -33,7 +33,7 @@ namespace EQ struct ItemData; } -#define MAXMERCS 1 +constexpr int MAXMERCS = 11; #define TANK 1 #define HEALER 2 #define MELEEDPS 9 diff --git a/zone/special_attacks.cpp b/zone/special_attacks.cpp index 6c367101c..466ab7d58 100644 --- a/zone/special_attacks.cpp +++ b/zone/special_attacks.cpp @@ -102,10 +102,10 @@ int Mob::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) } if (RuleB(Character, ItemExtraSkillDamageCalcAsPercent) && GetSkillDmgAmt(skill) > 0) { - return static_cast(ac_bonus + skill_bonus) * std::abs(GetSkillDmgAmt(skill) / 100); + return (base + static_cast(ac_bonus + skill_bonus)) * std::abs(GetSkillDmgAmt(skill) / 100); } - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillKick: case EQ::skills::SkillRoundKick: { @@ -128,10 +128,10 @@ int Mob::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) } if (RuleB(Character, ItemExtraSkillDamageCalcAsPercent) && GetSkillDmgAmt(skill) > 0) { - return static_cast(ac_bonus + skill_bonus) * std::abs(GetSkillDmgAmt(skill) / 100); + return (base + static_cast(ac_bonus + skill_bonus)) * std::abs(GetSkillDmgAmt(skill) / 100); } - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillBash: { float skill_bonus = skill_level / 10.0f; @@ -160,10 +160,10 @@ int Mob::GetBaseSkillDamage(EQ::skills::SkillType skill, Mob *target) } if (RuleB(Character, ItemExtraSkillDamageCalcAsPercent) && GetSkillDmgAmt(skill) > 0) { - return static_cast(ac_bonus + skill_bonus) * std::abs(GetSkillDmgAmt(skill) / 100); + return (base + static_cast(ac_bonus + skill_bonus)) * std::abs(GetSkillDmgAmt(skill) / 100); } - return static_cast(ac_bonus + skill_bonus); + return base + static_cast(ac_bonus + skill_bonus); } case EQ::skills::SkillBackstab: { float skill_bonus = static_cast(skill_level) * 0.02f; diff --git a/zone/spell_effects.cpp b/zone/spell_effects.cpp index 7b29ca2c5..4930f1154 100644 --- a/zone/spell_effects.cpp +++ b/zone/spell_effects.cpp @@ -4394,9 +4394,9 @@ void Mob::BuffFadeBySlot(int slot, bool iRecalcBonuses) case SpellEffect::Familiar: { Mob *mypet = GetPet(); - if (mypet){ - if(mypet->IsNPC()) - mypet->CastToNPC()->Depop(); + if (mypet && mypet->IsNPC() && + mypet->CastToNPC()->GetPetSpellID() == buffs[slot].spellid) { + mypet->CastToNPC()->Depop(); SetPetID(0); } break; diff --git a/zone/spells.cpp b/zone/spells.cpp index d20597b5c..409cbadf3 100644 --- a/zone/spells.cpp +++ b/zone/spells.cpp @@ -94,6 +94,9 @@ #include #include +#include "common/links.h" +#include "common/packet_dump.h" + extern Zone *zone; extern volatile bool is_zone_loaded; extern WorldServer worldserver; @@ -319,6 +322,10 @@ bool Mob::DoCastSpell(uint16 spell_id, uint16 target_id, CastingSlot slot, // note that CheckFizzle itself doesn't let NPCs fizzle, // but this code allows for it. if (slot < CastingSlot::MaxGems && !CheckFizzle(spell_id)) { + /* + MessageFormat: You miss a note, bringing your song to a close! (TOB: You miss a note, bringing your %1 to a close!) + MessageFormat: Your spell fizzles! (TOB: Your %1 spell fizzles!) + */ int fizzle_msg = IsBardSong(spell_id) ? MISS_NOTE : SPELL_FIZZLE; uint32 use_mana = ((spells[spell_id].mana) / 4); @@ -328,10 +335,16 @@ bool Mob::DoCastSpell(uint16 spell_id, uint16 target_id, CastingSlot slot, Mob::SetMana(GetMana() - use_mana); // We send StopCasting which will update mana StopCasting(); - MessageString(Chat::SpellFailure, fizzle_msg); + // TODO: can handle spell name overrides here + std::string spell_name(GetSpellName(spell_id)); + std::string spell_link = Links::FormatSpellLink(spell_id, spell_name); + + // pre-TOB clients will just discard the extra argument here, so don't worry about patching them out in patches + MessageString(Chat::SpellFailure, fizzle_msg, spell_link.c_str()); /** * Song Failure message + * pre-TOB clients will just discard the extra argument here, so don't worry about patching them out in patches */ entity_list.FilteredMessageCloseString( this, @@ -342,11 +355,11 @@ bool Mob::DoCastSpell(uint16 spell_id, uint16 target_id, CastingSlot slot, (fizzle_msg == MISS_NOTE ? MISSED_NOTE_OTHER : SPELL_FIZZLE_OTHER), 0, /* - MessageFormat: You miss a note, bringing your song to a close! (if missed note) - MessageFormat: A missed note brings %1's song to a close! - MessageFormat: %1's spell fizzles! + MessageFormat: A missed note brings %1's song to a close! (TOB: A missed note brings %1's %2 to a close!) + MessageFormat: %1's spell fizzles! (TOB: %1's %2 spell fizzles!) */ - GetName() + GetName(), + spell_link.c_str() ); TryTriggerOnCastRequirement(); @@ -1299,14 +1312,20 @@ void Mob::InterruptSpell(uint16 message, uint16 color, uint16 spellid) if(!message) message = IsBardSong(spellid) ? SONG_ENDS_ABRUPTLY : INTERRUPT_SPELL; + // TODO: can handle spell name overrides here + std::string spellname(GetSpellName(spellid)); + std::string spelllink = Links::FormatSpellLink(spellid, spellname); + // clients need some packets if (IsClient() && message != SONG_ENDS) { // the interrupt message - outapp = new EQApplicationPacket(OP_InterruptCast, sizeof(InterruptCast_Struct)); + outapp = new EQApplicationPacket(OP_InterruptCast, sizeof(InterruptCast_Struct) + spelllink.size() + 1); InterruptCast_Struct* ic = (InterruptCast_Struct*) outapp->pBuffer; ic->messageid = message; ic->spawnid = GetID(); + // pre-TOB clients will just discard the extra argument here, so don't worry about patching them out in patches + fmt::format_to_n(ic->message, spelllink.size(), "{}", spelllink); outapp->priority = 5; CastToClient()->QueuePacket(outapp); safe_delete(outapp); @@ -1336,11 +1355,12 @@ void Mob::InterruptSpell(uint16 message, uint16 color, uint16 spellid) } // this is the actual message, it works the same as a formatted message - outapp = new EQApplicationPacket(OP_InterruptCast, sizeof(InterruptCast_Struct) + strlen(GetCleanName()) + 1); + outapp = new EQApplicationPacket(OP_InterruptCast, sizeof(InterruptCast_Struct) + strlen(GetCleanName()) + spelllink.size() + 2); InterruptCast_Struct* ic = (InterruptCast_Struct*) outapp->pBuffer; ic->messageid = message_other; ic->spawnid = GetID(); - strcpy(ic->message, GetCleanName()); + // pre-TOB clients will just discard the extra argument here, so don't worry about patching them out in patches + fmt::format_to_n(ic->message, sizeof(GetCleanName()) + spelllink.size() + 1, "{}\x00{}", GetCleanName(), spelllink); entity_list.QueueCloseClients(this, outapp, true, RuleI(Range, SongMessages), 0, true, IsClient() ? FilterPCSpells : FilterNPCSpells); safe_delete(outapp); diff --git a/zone/zonedb.cpp b/zone/zonedb.cpp index 703f012ad..24414a2d0 100644 --- a/zone/zonedb.cpp +++ b/zone/zonedb.cpp @@ -2225,7 +2225,7 @@ bool ZoneDatabase::LoadCurrentMercenary(Client* c) { const uint8 mercenary_slot = c->GetMercSlot(); - if (mercenary_slot > MAXMERCS) { + if (mercenary_slot >= MAXMERCS) { return false; } @@ -2277,7 +2277,7 @@ bool ZoneDatabase::SaveMercenary(Merc* m) auto e = MercsRepository::NewEntity(); e.OwnerCharacterID = m->GetMercenaryCharacterID(); - e.Slot = (c->GetNumberOfMercenaries() - 1); + e.Slot = c->GetMercSlot(); e.Name = m->GetCleanName(); e.TemplateID = m->GetMercenaryTemplateID(); e.SuspendedTime = c->GetMercInfo().SuspendedTime; @@ -2318,7 +2318,7 @@ bool ZoneDatabase::SaveMercenary(Merc* m) auto e = MercsRepository::FindOne(*this, m->GetMercenaryID()); e.OwnerCharacterID = m->GetMercenaryCharacterID(); - e.Slot = (c->GetNumberOfMercenaries() - 1); + e.Slot = c->GetMercSlot(); e.Name = m->GetCleanName(); e.TemplateID = m->GetMercenaryTemplateID(); e.SuspendedTime = c->GetMercInfo().SuspendedTime;