From 804447a6d0eec0ca18ebd123e6e46cdb3c79d022 Mon Sep 17 00:00:00 2001 From: xjeris Date: Wed, 8 Apr 2026 18:28:34 -0400 Subject: [PATCH] opening additional merc slots --- common/emu_oplist.h | 1 + common/eq_packet_structs.h | 6 +++ common/patches/rof2.cpp | 2 +- common/ruletypes.h | 2 +- utils/patches/patch_RoF2.conf | 1 + zone/client.cpp | 34 ++++++++----- zone/client_packet.cpp | 92 ++++++++++++++++++++++++++++++++--- zone/client_packet.h | 1 + 8 files changed, 120 insertions(+), 19 deletions(-) diff --git a/common/emu_oplist.h b/common/emu_oplist.h index 1f8366662..e85c83e9e 100644 --- a/common/emu_oplist.h +++ b/common/emu_oplist.h @@ -376,6 +376,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 c11945cc4..d6736c071 100644 --- a/common/eq_packet_structs.h +++ b/common/eq_packet_structs.h @@ -6236,6 +6236,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/patches/rof2.cpp b/common/patches/rof2.cpp index 177fedfb2..a2b3ba30e 100644 --- a/common/patches/rof2.cpp +++ b/common/patches/rof2.cpp @@ -2323,7 +2323,7 @@ namespace RoF2 VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[r].Stances[k].Stance); } } - VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[0].MercUnk05); // MercUnk05 + VARSTRUCT_ENCODE_TYPE(uint32, Buffer, emu->MercData[0].MercUnk05); // MercUnk05 - trailing field (unlocked slot count) } else { diff --git a/common/ruletypes.h b/common/ruletypes.h index 02ff8d39b..6e3c9dd91 100644 --- a/common/ruletypes.h +++ b/common/ruletypes.h @@ -255,7 +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 11)") +RULE_INT(Mercs, MaxMercSlots, 6, "Maximum number of mercenary slots per character (max 6)") RULE_CATEGORY_END() RULE_CATEGORY(Guild) 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/zone/client.cpp b/zone/client.cpp index 3387b08d0..f70f1e808 100644 --- a/zone/client.cpp +++ b/zone/client.cpp @@ -7958,21 +7958,19 @@ void Client::SendMercPersonalInfo() 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; - for (int slot = 0; slot < max_slots && merc_index < MAX_MERC; slot++) { - auto& info = GetMercInfo(slot); - if (info.mercid == 0) { - continue; - } - Message(Chat::Yellow, "SendMercPersonalInfo: slot %i, mercid %u, templateid %u, name '%s', suspended %i", - slot, info.mercid, info.MercTemplateID, info.merc_name, info.IsSuspended); + 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()) { - Message(Chat::Red, "SendMercPersonalInfo: slot %i template %u NOT FOUND, skipping", slot, info.MercTemplateID); - continue; + return; } MercTemplate *mercData = &tmpl_it->second; @@ -7984,7 +7982,7 @@ void Client::SendMercPersonalInfo() if (stancecount > MAX_MERC_STANCES) { Log(Logs::General, Logs::Mercenaries, "SendMercPersonalInfo: stance count %u exceeds max for slot %i, skipping", stancecount, slot); - continue; + return; } mdus->MercData[merc_index].MercID = mercData->MercTemplateID; @@ -7999,7 +7997,7 @@ void Client::SendMercPersonalInfo() 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 = 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; @@ -8017,6 +8015,20 @@ void Client::SendMercPersonalInfo() 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 diff --git a/zone/client_packet.cpp b/zone/client_packet.cpp index d8ec9a16f..5ad6b46cb 100644 --- a/zone/client_packet.cpp +++ b/zone/client_packet.cpp @@ -305,6 +305,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; @@ -10430,7 +10431,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()); @@ -10438,9 +10439,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(); @@ -10496,14 +10494,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()); } } @@ -10761,6 +10759,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. diff --git a/zone/client_packet.h b/zone/client_packet.h index 009dd75d6..b2a455b97 100644 --- a/zone/client_packet.h +++ b/zone/client_packet.h @@ -239,6 +239,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);