From a71eb7bee7fdd484d2f7a84e592bf856b2f6d1f6 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 26 Jul 2026 22:48:15 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client_patch/README.md | 39 ++++++ .../enUS/DBFilesClient/LFGDungeons.dbc | Bin 0 -> 45337 bytes docker-compose.override.yml | 1 + src/server/game/DungeonFinding/LFG.h | 2 + src/server/game/DungeonFinding/LFGMgr.cpp | 33 +++++- src/server/game/DungeonFinding/LFGMgr.h | 2 + src/server/game/DungeonFinding/LFGQueue.cpp | 32 ++++- src/server/game/DungeonFinding/LFGQueue.h | 7 +- tools/build-solo-lfg-dbc.py | 112 ++++++++++++++++++ 9 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 client_patch/README.md create mode 100644 client_patch/enUS/DBFilesClient/LFGDungeons.dbc create mode 100644 tools/build-solo-lfg-dbc.py diff --git a/client_patch/README.md b/client_patch/README.md new file mode 100644 index 000000000..8c7f41f6e --- /dev/null +++ b/client_patch/README.md @@ -0,0 +1,39 @@ +# Solo Random Dungeon client patch + +The server recognizes five custom `LFGDungeons.dbc` entries: + +| Custom ID | Stock category | +| --- | --- | +| 1258 | Random Classic Dungeon (258) | +| 1259 | Random Burning Crusade Dungeon (259) | +| 1260 | Random Burning Crusade Heroic (260) | +| 1261 | Random Lich King Dungeon (261) | +| 1262 | Random Lich King Heroic (262) | + +The generated server DBC is at: + +`client_patch/enUS/DBFilesClient/LFGDungeons.dbc` + +It is mounted into `ac-worldserver` by `docker-compose.override.yml`. + +## Client installation + +Use the DBC extracted from the exact locale of the target 3.3.5a client: + +```bash +./tools/build-solo-lfg-dbc.py \ + /path/to/client/DBFilesClient/LFGDungeons.dbc \ + /tmp/solo-rdf/DBFilesClient/LFGDungeons.dbc +``` + +Put the generated `DBFilesClient/LFGDungeons.dbc` into a client MPQ patch +loaded after the stock locale archives. For a Russian client this is normally +a new archive under `Data/ruRU/` named like `patch-ruRU-S.MPQ`. + +After installing the patch, delete the client's `Cache` directory. The +Dungeon Finder list will contain separate solo random categories appropriate +for the character's level. + +Do not replace a localized client DBC with the bundled enUS server DBC. Run +the generator against that client's own DBC so all stock localized rows remain +intact. diff --git a/client_patch/enUS/DBFilesClient/LFGDungeons.dbc b/client_patch/enUS/DBFilesClient/LFGDungeons.dbc new file mode 100644 index 0000000000000000000000000000000000000000..de3333348ff3ade32cd881b591f5f6d9b67bb787 GIT binary patch literal 45337 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 literal 0 HcmV?d00001 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index ba2037c5b..d59944bad 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -8,3 +8,4 @@ services: volumes: - ./modules:/azerothcore/modules:ro - ./lua_scripts:/azerothcore/lua_scripts + - ./client_patch/enUS/DBFilesClient/LFGDungeons.dbc:/azerothcore/env/dist/data/dbc/LFGDungeons.dbc:ro diff --git a/src/server/game/DungeonFinding/LFG.h b/src/server/game/DungeonFinding/LFG.h index 260f29f08..33162ecdf 100644 --- a/src/server/game/DungeonFinding/LFG.h +++ b/src/server/game/DungeonFinding/LFG.h @@ -108,6 +108,8 @@ namespace lfg RANDOM_DUNGEON_HEROIC_WOTLK = 262 }; + constexpr uint32 LFG_SOLO_RANDOM_DUNGEON_ID_OFFSET = 1000; + class Lfg5Guids; typedef std::list Lfg5GuidsList; diff --git a/src/server/game/DungeonFinding/LFGMgr.cpp b/src/server/game/DungeonFinding/LFGMgr.cpp index 1105d9258..fb617fdd3 100644 --- a/src/server/game/DungeonFinding/LFGMgr.cpp +++ b/src/server/game/DungeonFinding/LFGMgr.cpp @@ -519,6 +519,7 @@ namespace lfg LfgJoinResultData joinData; LfgGuidSet players; uint32 rDungeonId = 0; + bool soloQueue = false; bool isContinue = grp && grp->isLFGGroup() && GetState(gguid) != LFG_STATE_FINISHED_DUNGEON; if (grp && (grp->isBGGroup() || grp->isBFGroup())) @@ -611,6 +612,10 @@ namespace lfg } } + soloQueue = rDungeonId && IsSoloRandomDungeon(rDungeonId); + if (soloQueue && grp) + joinData.result = LFG_JOIN_PARTY_NOT_MEET_REQS; + if (!isRaid && joinData.result == LFG_JOIN_OK) { // Check player or group member restrictions @@ -775,7 +780,7 @@ namespace lfg LfgRolesMap rolesMap; rolesMap[guid] = roles; LFGQueue& queue = GetQueue(guid); - queue.AddQueueData(guid, GameTime::GetGameTime().count(), dungeons, rolesMap); + queue.AddQueueData(guid, GameTime::GetGameTime().count(), dungeons, rolesMap, soloQueue); if (!isContinue) { @@ -1671,7 +1676,11 @@ namespace lfg } // Xinef: Store amount of random players player grouped with - if (group) + LfgDungeonSet const& selectedDungeons = GetSelectedDungeons(pguid); + bool const soloRandom = !selectedDungeons.empty() && IsSoloRandomDungeon(*selectedDungeons.begin()); + if (soloRandom) + SetRandomPlayersCount(pguid, 0); + else if (group) { SetRandomPlayersCount(pguid, group->GetMembersCount() >= MAXGROUPSIZE ? 0 : MAXGROUPSIZE - group->GetMembersCount()); oldGroupGUID = group->GetGUID(); @@ -2345,8 +2354,12 @@ namespace lfg */ LfgReward const* LFGMgr::GetRandomDungeonReward(uint32 dungeon, uint8 level) { + uint32 dungeonId = dungeon & 0x00FFFFFF; + if (IsSoloRandomDungeon(dungeonId)) + dungeonId -= LFG_SOLO_RANDOM_DUNGEON_ID_OFFSET; + LfgReward const* rew = nullptr; - LfgRewardContainerBounds bounds = RewardMapStore.equal_range(dungeon & 0x00FFFFFF); + LfgRewardContainerBounds bounds = RewardMapStore.equal_range(dungeonId); for (LfgRewardContainer::const_iterator itr = bounds.first; itr != bounds.second; ++itr) { rew = itr->second; @@ -2358,6 +2371,20 @@ namespace lfg return rew; } + bool LFGMgr::IsSoloRandomDungeon(uint32 dungeon) + { + uint32 dungeonId = dungeon & 0x00FFFFFF; + if (dungeonId < LFG_SOLO_RANDOM_DUNGEON_ID_OFFSET) + return false; + + LFGDungeonData const* solo = GetLFGDungeon(dungeonId); + LFGDungeonData const* regular = GetLFGDungeon(dungeonId - LFG_SOLO_RANDOM_DUNGEON_ID_OFFSET); + return solo && regular && + solo->type == LFG_TYPE_RANDOM && + regular->type == LFG_TYPE_RANDOM && + solo->group == regular->group; + } + /** Given a Dungeon id returns the dungeon Type diff --git a/src/server/game/DungeonFinding/LFGMgr.h b/src/server/game/DungeonFinding/LFGMgr.h index 3d451e8eb..fb821d634 100644 --- a/src/server/game/DungeonFinding/LFGMgr.h +++ b/src/server/game/DungeonFinding/LFGMgr.h @@ -525,6 +525,8 @@ namespace lfg bool IsDungeonDisabled(uint32 mapId, Difficulty difficulty) const; /// Gets the random dungeon reward corresponding to given dungeon and player level LfgReward const* GetRandomDungeonReward(uint32 dungeon, uint8 level); + /// Checks whether an entry is a custom one-player random dungeon category + bool IsSoloRandomDungeon(uint32 dungeon); /// Returns all random and seasonal dungeons for given level and expansion LfgDungeonSet GetRandomAndSeasonalDungeons(uint8 level, uint8 expansion); /// Teleport a player to/from selected dungeon diff --git a/src/server/game/DungeonFinding/LFGQueue.cpp b/src/server/game/DungeonFinding/LFGQueue.cpp index 6e7f99a32..6bdf2c412 100644 --- a/src/server/game/DungeonFinding/LFGQueue.cpp +++ b/src/server/game/DungeonFinding/LFGQueue.cpp @@ -104,10 +104,10 @@ namespace lfg restoredAfterProposal.remove(guid); } - void LFGQueue::AddQueueData(ObjectGuid guid, time_t joinTime, LfgDungeonSet const& dungeons, LfgRolesMap const& rolesMap) + void LFGQueue::AddQueueData(ObjectGuid guid, time_t joinTime, LfgDungeonSet const& dungeons, LfgRolesMap const& rolesMap, bool solo) { LOG_DEBUG("lfg", "JOINED AddQueueData: {}", guid.ToString()); - QueueDataStore[guid] = LfgQueueData(joinTime, dungeons, rolesMap); + QueueDataStore[guid] = LfgQueueData(joinTime, dungeons, rolesMap, solo); AddToQueue(guid); } @@ -263,6 +263,7 @@ namespace lfg // Check if more than one LFG group and number of players joining uint8 numPlayers = 0; uint8 numLfgGroups = 0; + bool soloQueue = false; ObjectGuid guid; uint64 addToFoundMask = 0; @@ -280,6 +281,7 @@ namespace lfg for (LfgRolesMap::const_iterator it2 = itQueue->second.roles.begin(); it2 != itQueue->second.roles.end(); ++it2) proposalGroups[it2->first] = itQueue->first.IsGroup() ? itQueue->first : ObjectGuid::Empty; + soloQueue = soloQueue || itQueue->second.solo; numPlayers += itQueue->second.roles.size(); if (sLFGMgr->IsLfgGroup(guid)) @@ -293,8 +295,13 @@ namespace lfg if (numLfgGroups > 1) return LFG_INCOMPATIBLES_MULTIPLE_LFG_GROUPS; + // A solo RDF entry is a complete one-player match and must not absorb + // entries from the regular five-player queue. + if (soloQueue && (check.size() != 1 || numPlayers != 1)) + return LFG_INCOMPATIBLES_WRONG_GROUP_SIZE; + // Group with less that MAXGROUPSIZE members always compatible - if (!sLFGMgr->IsTesting() && check.size() == 1 && numPlayers < MAXGROUPSIZE) + if (!sLFGMgr->IsTesting() && !soloQueue && check.size() == 1 && numPlayers < MAXGROUPSIZE) { LfgQueueDataContainer::iterator itQueue = QueueDataStore.find(check.front()); LfgRolesMap roles = itQueue->second.roles; @@ -388,10 +395,27 @@ namespace lfg proposalDungeons = queue.dungeons; proposalRoles = queue.roles; LFGMgr::CheckGroupRoles(proposalRoles); // assing new roles + + // A one-player proposal still has to contain one of the three + // canonical combat roles. The stock queue never reaches a + // proposal with PLAYER_ROLE_NONE, while the solo queue can. + // Sending NONE (or a multi-role mask) makes the 3.3.5a client + // return "UNKNOWN" and LFDFrame.lua raises an error. + if (soloQueue) + { + uint8& role = proposalRoles.begin()->second; + role &= PLAYER_ROLE_TANK | PLAYER_ROLE_HEALER | PLAYER_ROLE_DAMAGE; + if (role & PLAYER_ROLE_TANK) + role = PLAYER_ROLE_TANK; + else if (role & PLAYER_ROLE_HEALER) + role = PLAYER_ROLE_HEALER; + else + role = PLAYER_ROLE_DAMAGE; + } } // Enough players? - if (!sLFGMgr->IsTesting() && numPlayers != MAXGROUPSIZE) + if (!sLFGMgr->IsTesting() && !soloQueue && numPlayers != MAXGROUPSIZE) { strGuids.addRoles(proposalRoles); for (uint8 i = 0; i < 5 && check.guids[i]; ++i) diff --git a/src/server/game/DungeonFinding/LFGQueue.h b/src/server/game/DungeonFinding/LFGQueue.h index 1ce753730..73b807217 100644 --- a/src/server/game/DungeonFinding/LFGQueue.h +++ b/src/server/game/DungeonFinding/LFGQueue.h @@ -40,9 +40,9 @@ namespace lfg { LfgQueueData(); - LfgQueueData(time_t _joinTime, LfgDungeonSet _dungeons, LfgRolesMap _roles): + LfgQueueData(time_t _joinTime, LfgDungeonSet _dungeons, LfgRolesMap _roles, bool _solo): joinTime(_joinTime), lastRefreshTime(_joinTime), tanks(LFG_TANKS_NEEDED), healers(LFG_HEALERS_NEEDED), - dps(LFG_DPS_NEEDED), dungeons(std::move(_dungeons)), roles(std::move(_roles)) + dps(LFG_DPS_NEEDED), dungeons(std::move(_dungeons)), roles(std::move(_roles)), solo(_solo) { } time_t joinTime; // Player queue join time (to calculate wait times) @@ -52,6 +52,7 @@ namespace lfg uint8 dps{LFG_DPS_NEEDED}; // Dps needed LfgDungeonSet dungeons; // Selected Player/Group Dungeon/s LfgRolesMap roles; // Selected Player Role/s + bool solo{false}; // Match as a one-player RDF group Lfg5Guids bestCompatible; // Best compatible combination of people queued }; @@ -75,7 +76,7 @@ namespace lfg // Add/Remove from queue void AddToQueue(ObjectGuid guid, bool failedProposal = false); void RemoveFromQueue(ObjectGuid guid, bool partial = false); // xinef: partial remove, dont delete data from list! - void AddQueueData(ObjectGuid guid, time_t joinTime, LfgDungeonSet const& dungeons, LfgRolesMap const& rolesMap); + void AddQueueData(ObjectGuid guid, time_t joinTime, LfgDungeonSet const& dungeons, LfgRolesMap const& rolesMap, bool solo = false); void RemoveQueueData(ObjectGuid guid); // Update Timers (when proposal success) diff --git a/tools/build-solo-lfg-dbc.py b/tools/build-solo-lfg-dbc.py new file mode 100644 index 000000000..1cef8fc05 --- /dev/null +++ b/tools/build-solo-lfg-dbc.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + +"""Add native solo RDF categories to a WotLK 3.3.5a LFGDungeons.dbc.""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +MAGIC = b"WDBC" +FIELD_COUNT = 49 +RECORD_SIZE = FIELD_COUNT * 4 +SOLO_ID_OFFSET = 1000 + +SOLO_NAMES = { + 258: "Случайное одиночное подземелье: Классика", + 259: "Случайное одиночное подземелье: Burning Crusade", + 260: "Случайное одиночное героическое: Burning Crusade", + 261: "Случайное одиночное подземелье Lich King", + 262: "Случайное одиночное героическое подземелье Lich King", +} + +SOLO_DESCRIPTION = ( + "Случайное подземелье для одного игрока. " + "Существа и боссы масштабируются системой AutoBalance." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Duplicate the five stock random dungeon rows as solo RDF categories." + ) + parser.add_argument("source", type=Path, help="Source LFGDungeons.dbc") + parser.add_argument("output", type=Path, help="Patched LFGDungeons.dbc") + return parser.parse_args() + + +def add_string(string_block: bytearray, value: str) -> int: + encoded = value.encode("utf-8") + b"\0" + offset = len(string_block) + string_block.extend(encoded) + return offset + + +def main() -> None: + args = parse_args() + raw = args.source.read_bytes() + if len(raw) < 20 or raw[:4] != MAGIC: + raise SystemExit(f"{args.source}: not a WDBC file") + + record_count, field_count, record_size, string_size = struct.unpack_from("<4I", raw, 4) + if field_count != FIELD_COUNT or record_size != RECORD_SIZE: + raise SystemExit( + f"{args.source}: unexpected layout " + f"(fields={field_count}, record_size={record_size})" + ) + + records_start = 20 + strings_start = records_start + record_count * record_size + strings_end = strings_start + string_size + if strings_end != len(raw): + raise SystemExit(f"{args.source}: inconsistent WDBC size") + + records: list[list[int]] = [] + source_records: dict[int, list[int]] = {} + solo_ids = {base_id + SOLO_ID_OFFSET for base_id in SOLO_NAMES} + + for index in range(record_count): + offset = records_start + index * record_size + record = list(struct.unpack_from(f"<{FIELD_COUNT}I", raw, offset)) + if record[0] in SOLO_NAMES: + source_records[record[0]] = record + if record[0] not in solo_ids: + records.append(record) + + missing = sorted(set(SOLO_NAMES) - set(source_records)) + if missing: + raise SystemExit(f"{args.source}: missing stock random dungeon rows: {missing}") + + string_block = bytearray(raw[strings_start:strings_end]) + for base_id, name in SOLO_NAMES.items(): + record = source_records[base_id].copy() + record[0] = base_id + SOLO_ID_OFFSET + + name_offset = add_string(string_block, name) + description_offset = add_string(string_block, SOLO_DESCRIPTION) + + # Fill every locale slot. This keeps the custom row readable regardless + # of which locale column the client selects. + record[1:17] = [name_offset] * 16 + record[32:48] = [description_offset] * 16 + records.append(record) + + records.sort(key=lambda record: record[0]) + output = bytearray() + output.extend(MAGIC) + output.extend(struct.pack("<4I", len(records), FIELD_COUNT, RECORD_SIZE, len(string_block))) + for record in records: + output.extend(struct.pack(f"<{FIELD_COUNT}I", *record)) + output.extend(string_block) + + args.output.parent.mkdir(parents=True, exist_ok=True) + temporary = args.output.with_suffix(args.output.suffix + ".tmp") + temporary.write_bytes(output) + temporary.replace(args.output) + print(f"Wrote {args.output} with solo RDF entries 1258-1262") + + +if __name__ == "__main__": + main()