From e0f7011670d8a0307797158bc11a0c0c9b4b8343 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 14:43:41 -0300 Subject: [PATCH 1/9] refactor(items): build the item tooltip's lines apart from drawing them RenderItemInfo() is two functions now: BuildItemTooltipText() writes the tooltip's lines into TextList / TextListColor / TextBold as before, and RenderItemInfo() draws them (the Dark Lord's pets still draw their own). The code only moves, in the same order: the early returns of the item, the pet check and the set and socket lines stay in the builder, the height and position of the box in the drawing. Nothing changes in game. BuildItemTooltipLines() returns the built lines without drawing, and two control socket commands use it (in builds with the control socket): - `tooltip` gives the lines of an item made from the fields the server sends (level, luck, skill, option, excellent, ancient, harmony); - `tooltip-snapshot` writes the lines of every item at every level, as plain, full, ancient and harmony items, for the hero as he is, to a text file: 60,736 tooltips in about a second, the same every run, to compare the tooltip code before and after a change. This is step 5b-1 of #815: the snapshot is the check for moving the stat lines into data. --- docs/control-socket.md | 3 + src/source/App/Control/ControlCommands.h | 6 + .../App/Control/ControlCommandsQuery.cpp | 178 ++++++++++++++++++ src/source/App/Control/ControlDispatcher.cpp | 2 + src/source/App/Control/ControlProtocol.cpp | 3 +- src/source/Engine/Object/ZzzInventory.cpp | 110 +++++++---- src/source/Engine/Object/ZzzInventory.h | 15 ++ 7 files changed, 283 insertions(+), 34 deletions(-) diff --git a/docs/control-socket.md b/docs/control-socket.md index 5d192f0825..3a17f078e5 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -81,6 +81,9 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `bones` | in the world: how render's bones compare with the character animation pre-pass. `{"start":true}` clears the counts and starts checking; each answer gives `checked` (characters drawn), `players` (of those, player models), `mismatched` (any bone off by more than 0.01), `max_delta`, and the pre-pass's last `characters` count and whether it ran `parallel` | | `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, `npc_quest` (the quest dialog of Sebina, Marlon and Devin), and `message_box` while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: `trade.confirm`, `trade.zen`, `inventory.repair`, `inventory.my_shop`, `npc_shop.repair`, `npc_shop.repair_all`, `my_shop.title`, `my_shop.open`, `my_shop.close`, `command.trade`, `command.purchase`, `command.party`, `npc_quest.answer.0` and on (the rows of the quest dialog's answers), `npc_quest.complete`, `npc_quest.close`, `character.stat.strength` (`agility`, `vitality`, `energy`, `command`: the character window's "+" buttons, while there are level-up points) | | `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`, `npc_shop`, and the personal shops `my_shop` and `purchase_shop` (slots 204 and up, as `state` and the server number them); `not_open` while that window is closed, `bad_request` for a slot the grid does not have | +| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip, as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`" +| `tooltip-snapshot` (`out`, `group`) | writes the `tooltip` lines of every item (of `group` only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items, for the hero as he is, to the text file `out`: a line `/ +`, then one line ` [bold ]` per tooltip line (`` for a blank line). Two files compare with any diff tool, e.g. before and after a change of the tooltip code; replies `tooltips`, the count | +"` a blank line), `color` (the `TEXT_COLOR_*` number) and `bold`. `tooltip-snapshot` records every item | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | | `logout`, `quit` | back to the character list; close the client | diff --git a/src/source/App/Control/ControlCommands.h b/src/source/App/Control/ControlCommands.h index bec260dc28..89093c476b 100644 --- a/src/source/App/Control/ControlCommands.h +++ b/src/source/App/Control/ControlCommands.h @@ -33,6 +33,12 @@ std::string EventsSince(const Request& request, std::unique_ptr& act); std::string WaitFor(const Request& request, std::unique_ptr& act); std::string Screenshot(const Request& request, std::unique_ptr& act); std::string Hotkey(const Request& request, std::unique_ptr& act); +// The lines of an item's information tooltip, built for an item the command makes (as the server sends one) and the +// hero as it is, without drawing them. +std::string ItemTooltip(const Request& request, std::unique_ptr& act); +// Writes the tooltip lines of every item at every level, in a few variants, to a text file (`out`): a snapshot to +// compare the tooltip code before and after a change with. +std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr& act); std::string ClickUi(const Request& request, std::unique_ptr& act); std::string HoverUi(const Request& request, std::unique_ptr& act); std::string DragUi(const Request& request, std::unique_ptr& act); diff --git a/src/source/App/Control/ControlCommandsQuery.cpp b/src/source/App/Control/ControlCommandsQuery.cpp index 2f2dff24f0..5fcd9f1aea 100644 --- a/src/source/App/Control/ControlCommandsQuery.cpp +++ b/src/source/App/Control/ControlCommandsQuery.cpp @@ -7,6 +7,10 @@ #include "Core/Utilities/Benchmark/BenchRecorder.h" #include "Core/Text/Utf8.h" #include "Engine/Object/ZzzCharacter.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Engine/Object/ZzzInventory.h" +#include "UI/Core/WindowSystem.h" +#include "UI/Inventory/ItemMng.h" #include "Network/Server/WSclient.h" #include "Scenes/SceneCore.h" #include "Scenes/SceneManager.h" @@ -20,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -476,6 +481,179 @@ std::string State(const Request& request, std::unique_ptr&) return EncodeResult(request.EncodedId(), WorldStateObject()); } +namespace +{ +// An item as the server would send it: the fields not set are 0, the durability is full. +ItemCreationParams FullItem(int group, int number) +{ + ItemCreationParams params{}; + params.Group = group; + params.Number = number; + params.Durability = static_cast(ItemAttribute[group * MAX_ITEM_INDEX + number].Durability); + return params; +} + +// The tooltip lines of such an item and the hero as it is; nothing when the item can't be made. +std::optional> TooltipOf(const ItemCreationParams& params) +{ + ITEM* item = g_pNewItemMng->CreateItemByParameters(¶ms); + if (item == nullptr) + { + return std::nullopt; + } + std::vector lines = BuildItemTooltipLines(item, false); + g_pNewItemMng->DeleteItem(item); + return lines; +} + +// The kinds of item `tooltip-snapshot` records at every level: plain; with luck, skill, the +4 option and every +// excellent option; ancient (set 1); with a Jewel of Harmony option (option 1 at level 13). +struct TooltipVariant +{ + const char* name; + void (*apply)(ItemCreationParams& params); +}; +constexpr TooltipVariant TooltipVariants[] = { + {"plain", [](ItemCreationParams&) {}}, + {"full", + [](ItemCreationParams& params) + { + params.WithLuck = true; + params.WithSkill = true; + params.WithOption = true; + params.OptionLevel = 4; + params.HasExcellentOption = true; + params.ExcellentFlags = 63; + }}, + {"ancient", + [](ItemCreationParams& params) + { + params.IsAncient = true; + params.AncientDiscriminator = 1; + }}, + {"harmony", + [](ItemCreationParams& params) + { + params.HasHarmonyOption = true; + params.HarmonyOptionType = 1; + params.HarmonyOptionLevel = 13; + }}, +}; +} // namespace + +std::string ItemTooltip(const Request& request, std::unique_ptr&) +{ + int group = -1; + int number = -1; + if (!request.GetInt("group", group) || !request.GetInt("number", number) || group < 0 || group >= MAX_ITEM_TYPE || + number < 0 || number >= MAX_ITEM_INDEX) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`tooltip` needs `group` and `number` of an item"); + } + + ItemCreationParams params = FullItem(group, number); + int value = 0; + bool flag = false; + if (request.GetInt("level", value)) + params.Level = static_cast(value); + if (request.GetInt("durability", value)) + params.Durability = static_cast(value); + if (request.GetBool("luck", flag)) + params.WithLuck = flag; + if (request.GetBool("skill", flag)) + params.WithSkill = flag; + if (request.GetInt("option", value) && value > 0) + { + params.WithOption = true; + params.OptionLevel = static_cast(value); + } + if (request.GetInt("excellent", value) && value > 0) + { + params.HasExcellentOption = true; + params.ExcellentFlags = static_cast(value); + } + if (request.GetInt("ancient", value) && value > 0) + { + params.IsAncient = true; + params.AncientDiscriminator = static_cast(value); + } + if (request.GetInt("harmony", value) && value > 0) + { + params.HasHarmonyOption = true; + params.HarmonyOptionType = static_cast(value); + if (request.GetInt("harmony_level", value)) + params.HarmonyOptionLevel = static_cast(value); + } + + const std::optional> lines = TooltipOf(params); + if (!lines) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "the item could not be made"); + } + + json result; + result["lines"] = json::array(); + for (const ItemTooltipLine& line : *lines) + { + result["lines"].push_back( + {{"text", Core::Text::ToUtf8(line.text.c_str())}, {"color", line.color}, {"bold", line.bold}}); + } + return EncodeResult(request.EncodedId(), result.dump()); +} + +std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) +{ + std::string path; + if (!request.GetString("out", path) || path.empty()) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`tooltip-snapshot` needs `out`, the file to write"); + } + std::ofstream out(std::filesystem::path(Core::Text::FromUtf8(path)), std::ios::binary); + if (!out) + { + return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written"); + } + + // One group per request keeps the frame that builds them short. + int onlyGroup = -1; + if (request.Has("group") && (!request.GetInt("group", onlyGroup) || onlyGroup < 0 || onlyGroup >= MAX_ITEM_TYPE)) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "`group` is an item group, 0 to 15"); + } + + int tooltips = 0; + for (const Data::Items::ItemDefinition& definition : g_ItemDatabase.GetAllSlots()) + { + if (!definition.Exists() || (onlyGroup >= 0 && definition.group != onlyGroup)) + { + continue; + } + for (const TooltipVariant& variant : TooltipVariants) + { + for (int level = 0; level < Data::Items::ItemLevelCount; ++level) + { + ItemCreationParams params = FullItem(definition.group, definition.number); + params.Level = static_cast(level); + variant.apply(params); + out << definition.group << '/' << definition.number << ' ' << variant.name << " +" << level << '\n'; + for (const ItemTooltipLine& line : TooltipOf(params).value_or(std::vector{})) + { + const std::string text = line.text == L"\n" ? "" : Core::Text::ToUtf8(line.text.c_str()); + out << " " << line.color << (line.bold ? " bold " : " ") << text << '\n'; + } + ++tooltips; + } + } + } + + json result; + result["tooltips"] = tooltips; + result["out"] = path; + return EncodeResult(request.EncodedId(), result.dump()); +} + std::string Nearby(const Request& request, std::unique_ptr&) { json result; diff --git a/src/source/App/Control/ControlDispatcher.cpp b/src/source/App/Control/ControlDispatcher.cpp index 5c7bb7b5cb..e21ea4342f 100644 --- a/src/source/App/Control/ControlDispatcher.cpp +++ b/src/source/App/Control/ControlDispatcher.cpp @@ -47,6 +47,8 @@ const std::vector& CommandTable() {"drag-ui", SceneRequirement::Any, &Commands::DragUi}, {"ui", SceneRequirement::World, &Commands::Ui}, {"slot-pixel", SceneRequirement::World, &Commands::SlotPixel}, + {"tooltip", SceneRequirement::World, &Commands::ItemTooltip}, + {"tooltip-snapshot", SceneRequirement::World, &Commands::ItemTooltipSnapshot}, {"login", SceneRequirement::PreGame, &Commands::Login}, {"select-char", SceneRequirement::PreGame, &Commands::SelectCharacter}, {"logout", SceneRequirement::World, &Commands::Logout}, diff --git a/src/source/App/Control/ControlProtocol.cpp b/src/source/App/Control/ControlProtocol.cpp index 1cae1a2bda..2e23543330 100644 --- a/src/source/App/Control/ControlProtocol.cpp +++ b/src/source/App/Control/ControlProtocol.cpp @@ -196,8 +196,7 @@ const std::vector& CommandNames() "ping", "scene", "state", "nearby", "events", "wait-for", "screenshot", "login", "select-char", "logout", "quit", "move", "warp", "teleport", "attack", "skill", "pickup", "use", "equip", "say", "whisper", "party", "halt", "hotkey", "click-ui", "drag-ui", "hover-ui", - "type", "ui", "slot-pixel", "trade", "console", "bench", - "bones", + "type", "ui", "slot-pixel", "trade", "console", "bench", "bones", "tooltip", "tooltip-snapshot", }; return names; } diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 5caf51218d..6ea68c88c5 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -1604,25 +1604,27 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int } // (sx, sy) in the screen's 640x480 stretch, `screen`. -static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, ITEM* ip, bool Sell, int Inventype, - bool bItemTextListBoxUse) +// What BuildItemTooltipText made of an item. +enum class ItemTooltipBuild { - // Unconditional: the early returns below (and the pet-item delegation further down, which - // renders its own tooltip via giPetManager::RenderPetItemInfo() instead) used to mean "this - // frame draws nothing" under the old per-frame native draw -- already equivalent to "hidden" - // for that item. The shared tooltip document is persistent, so this replicates that; the real - // Show() call near the end of this function makes it visible again once actually reached. - UI::Tooltip::HideLegacyTextList(); + Nothing, // no tooltip for it + DarkLordPet, // the pet manager draws its own + Lines, // TextList, TextListColor and TextBold hold its TextNum lines +}; +// Writes the lines of an item's tooltip into TextList, TextListColor and TextBold (TextNum, SkipNum), without drawing +// them; RenderItemInfo draws them. +static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, bool bItemTextListBoxUse) +{ if (ip->Type == -1) - return; + return ItemTooltipBuild::Nothing; tm* ExpireTime; if (ip->bPeriodItem == true && ip->bExpiredPeriod == false) { _tzset(); if (ip->lExpireTime == 0) - return; + return ItemTooltipBuild::Nothing; ExpireTime = localtime((time_t*)&(ip->lExpireTime)); } @@ -1639,28 +1641,7 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, if (!Sell && (GameLogic::Items::IsDarkLordPet(ip))) { - static DebouncedAction debouncedPetInfoRequest([ip, Inventype]() - { - PetType PetType = PetType::DarkRaven; - if (ip->Type == ITEM_DARK_HORSE_ITEM) - { - PetType = PetType::DarkHorse; - - if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_HELPER) - { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), EQUIPMENT_HELPER); - } - } - else if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_WEAPON_LEFT) - { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), EQUIPMENT_WEAPON_LEFT); - } - }, 1000); // 1-second intervals - - debouncedPetInfoRequest.invoke(); - - giPetManager::RenderPetItemInfo(screen, sx, sy, ip, Inventype); - return; + return ItemTooltipBuild::DarkLordPet; } mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; @@ -4325,7 +4306,72 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextNum = g_csItemOption.RenderSetOptionListInItem(ip, TextNum, bThisisEquippedItem); TextNum = g_SocketItemMgr.AttachToolTipForSocketItem(ip, TextNum); + } + + return ItemTooltipBuild::Lines; +} + +std::vector BuildItemTooltipLines(ITEM* ip, bool Sell, int Inventype, bool bItemTextListBoxUse) +{ + std::vector lines; + if (BuildItemTooltipText(ip, Sell, Inventype, bItemTextListBoxUse) != ItemTooltipBuild::Lines) + { + return lines; + } + lines.reserve(TextNum); + for (int index = 0; index < TextNum; ++index) + { + lines.push_back({TextList[index], TextListColor[index], TextBold[index] != 0}); + } + return lines; +} + +static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, ITEM* ip, bool Sell, int Inventype, + bool bItemTextListBoxUse) +{ + // Unconditional: the early returns below (and the pet-item delegation further down, which + // renders its own tooltip via giPetManager::RenderPetItemInfo() instead) used to mean "this + // frame draws nothing" under the old per-frame native draw -- already equivalent to "hidden" + // for that item. The shared tooltip document is persistent, so this replicates that; the real + // Show() call near the end of this function makes it visible again once actually reached. + UI::Tooltip::HideLegacyTextList(); + const ItemTooltipBuild build = BuildItemTooltipText(ip, Sell, Inventype, bItemTextListBoxUse); + if (build == ItemTooltipBuild::Nothing) + return; + if (build == ItemTooltipBuild::DarkLordPet) + { + static DebouncedAction debouncedPetInfoRequest( + [ip, Inventype]() + { + PetType PetType = PetType::DarkRaven; + if (ip->Type == ITEM_DARK_HORSE_ITEM) + { + PetType = PetType::DarkHorse; + + if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_HELPER) + { + SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), + EQUIPMENT_HELPER); + } + } + else if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_WEAPON_LEFT) + { + SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), + EQUIPMENT_WEAPON_LEFT); + } + }, + 1000); // 1-second intervals + + debouncedPetInfoRequest.invoke(); + + giPetManager::RenderPetItemInfo(screen, sx, sy, ip, Inventype); + return; + } + + ITEM_ATTRIBUTE* p = &ItemAttribute[ip->Type]; + if (!bItemTextListBoxUse) + { SIZE TextSize = { 0, 0 }; int Height = 0; int EmptyLine = 0; diff --git a/src/source/Engine/Object/ZzzInventory.h b/src/source/Engine/Object/ZzzInventory.h index 7373922d63..ab30124c0f 100644 --- a/src/source/Engine/Object/ZzzInventory.h +++ b/src/source/Engine/Object/ZzzInventory.h @@ -1,6 +1,9 @@ #ifndef __ZZZINVENTORY_H__ #define __ZZZINVENTORY_H__ +#include +#include + enum _COLUMN_TYPE { _COLUMN_TYPE_LEVEL = 0, @@ -189,6 +192,18 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int // The item's information and repair tooltips, anchored in window pixels: their layout runs in the // screen's 640x480 stretch, the space the original computed it in, whichever window asks. void RenderItemInfoAtPx(float x, float y, ITEM* ip, bool Sell, int Inventype = 0, bool bItemTextListBoxUse = false); +// One line of an item's information tooltip as RenderItemInfoAtPx builds it: its text ("\n" for a blank line), its +// TEXT_COLOR_* and whether it is bold. +struct ItemTooltipLine +{ + std::wstring text; + int color = 0; + bool bold = false; +}; +// The lines of `ip`'s information tooltip, built as RenderItemInfoAtPx builds them but not drawn; none when it shows +// no tooltip or the pet manager draws its own (the Dark Lord's pets). +std::vector BuildItemTooltipLines(ITEM* ip, bool Sell, int Inventype = 0, + bool bItemTextListBoxUse = false); void RenderRepairInfoAtPx(float x, float y, ITEM* ip, bool Sell); void RenderSkillInfo(int sx, int sy, int Type, int SkillNum = 0, int iRenderPoint = STRP_NONE); void RequireClass(ITEM_ATTRIBUTE* p); From b245598e72056a966bd5f58e8c19a9d70889329e Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 08:05:54 -0300 Subject: [PATCH 2/9] test(control): the documented vocabulary has the tooltip commands The tooltip and tooltip-snapshot commands this branch adds were missing from the test's list, so the editor build's "Control protocol serves the documented command vocabulary" counted 36 commands against 34. --- tests/network/test_control_protocol.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/network/test_control_protocol.cpp b/tests/network/test_control_protocol.cpp index 052d432196..b4a6d1cfb6 100644 --- a/tests/network/test_control_protocol.cpp +++ b/tests/network/test_control_protocol.cpp @@ -86,8 +86,7 @@ TEST_CASE("Control protocol serves the documented command vocabulary [network][c "ping", "scene", "state", "nearby", "events", "wait-for", "screenshot", "login", "select-char", "logout", "quit", "move", "warp", "teleport", "attack", "skill", "pickup", "use", "equip", "say", "whisper", "party", "halt", "hotkey", "click-ui", "drag-ui", "hover-ui", - "type", "ui", "slot-pixel", "trade", "console", "bench", - "bones", + "type", "ui", "slot-pixel", "trade", "console", "bench", "bones", "tooltip", "tooltip-snapshot", }; for (const std::string& command : expected) From 17dc19f53ab8e94bb425c3821ba74b2a3e2d7316 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 11:05:12 -0300 Subject: [PATCH 3/9] docs(control): the tooltip command's row in one table row A line break inside its text split the row in two and broke the commands table. --- docs/control-socket.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/control-socket.md b/docs/control-socket.md index 3a17f078e5..8994c81858 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -81,9 +81,8 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `bones` | in the world: how render's bones compare with the character animation pre-pass. `{"start":true}` clears the counts and starts checking; each answer gives `checked` (characters drawn), `players` (of those, player models), `mismatched` (any bone off by more than 0.01), `max_delta`, and the pre-pass's last `characters` count and whether it ran `parallel` | | `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, `npc_quest` (the quest dialog of Sebina, Marlon and Devin), and `message_box` while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: `trade.confirm`, `trade.zen`, `inventory.repair`, `inventory.my_shop`, `npc_shop.repair`, `npc_shop.repair_all`, `my_shop.title`, `my_shop.open`, `my_shop.close`, `command.trade`, `command.purchase`, `command.party`, `npc_quest.answer.0` and on (the rows of the quest dialog's answers), `npc_quest.complete`, `npc_quest.close`, `character.stat.strength` (`agility`, `vitality`, `energy`, `command`: the character window's "+" buttons, while there are level-up points) | | `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`, `npc_shop`, and the personal shops `my_shop` and `purchase_shop` (slots 204 and up, as `state` and the server number them); `not_open` while that window is closed, `bad_request` for a slot the grid does not have | -| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip, as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`" +| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip, as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`"\n"` for a blank line), `color` (the `TEXT_COLOR_*` number) and `bold` | | `tooltip-snapshot` (`out`, `group`) | writes the `tooltip` lines of every item (of `group` only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items, for the hero as he is, to the text file `out`: a line `/ +`, then one line ` [bold ]` per tooltip line (`` for a blank line). Two files compare with any diff tool, e.g. before and after a change of the tooltip code; replies `tooltips`, the count | -"` a blank line), `color` (the `TEXT_COLOR_*` number) and `bold`. `tooltip-snapshot` records every item | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | | `logout`, `quit` | back to the character list; close the client | From 4227e4c1698d6fce94a098e6762ae8723062b6e7 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 22:27:41 -0300 Subject: [PATCH 4/9] style(items): the moved lines CI's clang-format gate checks are formatted --- src/source/Engine/Object/ZzzInventory.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index bd3cd5c27a..e5fe2132cd 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -2925,7 +2925,8 @@ static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; case ITEM_COMPLETE_SECROMICON: { mu_swprintf(TextList[TextNum], I18N::Game::IndestructibleMetalSecromicon); @@ -2936,7 +2937,8 @@ static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; } } else if (ITEM_OPEN_ACCESS_TICKET_TO_CHAOS_CASTLE == ip->Type) From bb0cc913564c85496846dd65c4794c4db6add681 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Fri, 23 Oct 2026 09:26:00 -0300 Subject: [PATCH 5/9] style(items): the lines CI's clang-format gate checks are formatted --- src/source/Engine/Object/ZzzInventory.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index e5fe2132cd..df83ea8593 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -2897,7 +2897,8 @@ static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; case ITEM_GAIONS_ORDER: { mu_swprintf(TextList[TextNum], I18N::Game::ItContainsGaionSPlansForTheDestructionOfTheEmpire); From d90e1e273cb83a4a345fe6d1d35fb3c51fe8025d Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Fri, 23 Oct 2026 13:57:22 -0300 Subject: [PATCH 6/9] fix(items): the pet tooltip asks for the pet it is drawn for, and the tooltip commands refuse what wraps The static debounced pet information request captured the first Dark Lord pet tooltip's item and storage for good; it reads the ones of the latest call now, and GetPetInfoRequest, which picks the request, is tested with the raven and the horse in turn from other storages. The control socket's tooltip command refuses a level, durability, option, excellent, ancient or harmony value its item's fields can't hold instead of wrapping it, and tooltip-snapshot reports a file it couldn't write completely. --- .../App/Control/ControlCommandsQuery.cpp | 56 ++++++++++++++----- src/source/Engine/Object/ZzzInventory.cpp | 39 ++++++++----- src/source/Engine/Object/ZzzInventory.h | 12 ++++ tests/core/CMakeLists.txt | 10 ++++ tests/core/test_control_tooltip_commands.cpp | 49 ++++++++++++++++ tests/data/CMakeLists.txt | 1 + tests/data/test_dark_lord_pet_tooltip.cpp | 33 +++++++++++ 7 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 tests/core/test_control_tooltip_commands.cpp create mode 100644 tests/data/test_dark_lord_pet_tooltip.cpp diff --git a/src/source/App/Control/ControlCommandsQuery.cpp b/src/source/App/Control/ControlCommandsQuery.cpp index 5fcd9f1aea..a8b6e65526 100644 --- a/src/source/App/Control/ControlCommandsQuery.cpp +++ b/src/source/App/Control/ControlCommandsQuery.cpp @@ -539,6 +539,12 @@ constexpr TooltipVariant TooltipVariants[] = { params.HarmonyOptionLevel = 13; }}, }; + +// Reads the integer `key` into `value` when the request has it; false when it is not a number from 0 to `max`. +bool ReadBounded(const Request& request, const char* key, int max, int& value) +{ + return !request.Has(key) || (request.GetInt(key, value) && value >= 0 && value <= max); +} } // namespace std::string ItemTooltip(const Request& request, std::unique_ptr&) @@ -553,37 +559,51 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) } ItemCreationParams params = FullItem(group, number); - int value = 0; + // The item's fields hold these values (as the item packet has them); a value they can't hold is a `bad_request`, + // not a wrapped one. + int level = params.Level; + int durability = params.Durability; + int option = 0; + int excellent = 0; + int ancient = 0; + int harmony = 0; + int harmonyLevel = params.HarmonyOptionLevel; + if (!ReadBounded(request, "level", Data::Items::ItemLevelCount - 1, level) || + !ReadBounded(request, "durability", 255, durability) || !ReadBounded(request, "option", 15, option) || + !ReadBounded(request, "excellent", 63, excellent) || !ReadBounded(request, "ancient", 255, ancient) || + !ReadBounded(request, "harmony", 15, harmony) || !ReadBounded(request, "harmony_level", 15, harmonyLevel)) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`level`, `option`, `harmony` and `harmony_level` are 0 to 15, `excellent` the excellent " + "options' bits 0 to 63, `durability` and `ancient` 0 to 255"); + } + params.Level = static_cast(level); + params.Durability = static_cast(durability); bool flag = false; - if (request.GetInt("level", value)) - params.Level = static_cast(value); - if (request.GetInt("durability", value)) - params.Durability = static_cast(value); if (request.GetBool("luck", flag)) params.WithLuck = flag; if (request.GetBool("skill", flag)) params.WithSkill = flag; - if (request.GetInt("option", value) && value > 0) + if (option > 0) { params.WithOption = true; - params.OptionLevel = static_cast(value); + params.OptionLevel = static_cast(option); } - if (request.GetInt("excellent", value) && value > 0) + if (excellent > 0) { params.HasExcellentOption = true; - params.ExcellentFlags = static_cast(value); + params.ExcellentFlags = static_cast(excellent); } - if (request.GetInt("ancient", value) && value > 0) + if (ancient > 0) { params.IsAncient = true; - params.AncientDiscriminator = static_cast(value); + params.AncientDiscriminator = static_cast(ancient); } - if (request.GetInt("harmony", value) && value > 0) + if (harmony > 0) { params.HasHarmonyOption = true; - params.HarmonyOptionType = static_cast(value); - if (request.GetInt("harmony_level", value)) - params.HarmonyOptionLevel = static_cast(value); + params.HarmonyOptionType = static_cast(harmony); + params.HarmonyOptionLevel = static_cast(harmonyLevel); } const std::optional> lines = TooltipOf(params); @@ -647,6 +667,12 @@ std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) } } } + // A full disk or a failed write leaves the file short: not a snapshot. + out.flush(); + if (!out) + { + return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written completely"); + } json result; result["tooltips"] = tooltips; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index df83ea8593..79cf1c4f4d 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -1631,6 +1631,20 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int // original tooltips. static constexpr int ItemDescriptionWrapWidth = 200; +std::optional GetPetInfoRequest(int itemType, int pointedIndex, int inventype) +{ + const StorageType storage = static_cast(inventype); + if (itemType == ITEM_DARK_HORSE_ITEM) + { + return pointedIndex == EQUIPMENT_HELPER + ? std::optional(PetInfoRequest{PetType::DarkHorse, storage, EQUIPMENT_HELPER}) + : std::nullopt; + } + return pointedIndex == EQUIPMENT_WEAPON_LEFT + ? std::optional(PetInfoRequest{PetType::DarkRaven, storage, EQUIPMENT_WEAPON_LEFT}) + : std::nullopt; +} + void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, int level, ItemTooltipTextMeasure measure) { const int lastRow = std::min(TextNum + MaxItemDescriptionRows, static_cast(std::size(TextList))); @@ -4381,24 +4395,19 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, return; if (build == ItemTooltipBuild::DarkLordPet) { + // The request is made once a second at most, for the pet whose tooltip is drawn then: the callback lives on, + // so it reads the item of this call, not one it was made with. + static int petItemType = -1; + static int petInventype = 0; + petItemType = ip->Type; + petInventype = Inventype; static DebouncedAction debouncedPetInfoRequest( - [ip, Inventype]() + []() { - PetType PetType = PetType::DarkRaven; - if (ip->Type == ITEM_DARK_HORSE_ITEM) - { - PetType = PetType::DarkHorse; - - if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_HELPER) - { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), - EQUIPMENT_HELPER); - } - } - else if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_WEAPON_LEFT) + if (const std::optional request = + GetPetInfoRequest(petItemType, g_pMyInventory->GetPointedItemIndex(), petInventype)) { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), - EQUIPMENT_WEAPON_LEFT); + SocketClient->ToGameServer()->SendPetInfoRequest(request->pet, request->storage, request->slot); } }, 1000); // 1-second intervals diff --git a/src/source/Engine/Object/ZzzInventory.h b/src/source/Engine/Object/ZzzInventory.h index 314fd1d6cf..d1f24751e7 100644 --- a/src/source/Engine/Object/ZzzInventory.h +++ b/src/source/Engine/Object/ZzzInventory.h @@ -1,6 +1,7 @@ #ifndef __ZZZINVENTORY_H__ #define __ZZZINVENTORY_H__ +#include #include #include @@ -100,6 +101,17 @@ namespace Data::Items struct ItemDefinition; } +// The server's pet information that a Dark Lord pet's tooltip asks for: the Dark Horse's when it is pointed at as the +// equipped helper, the Dark Raven's when it is pointed at in the left hand; nothing elsewhere. `inventype` is the +// tooltip's storage. +struct PetInfoRequest +{ + PetType pet; + StorageType storage; + BYTE slot; +}; +std::optional GetPetInfoRequest(int itemType, int pointedIndex, int inventype); + // How wide a text is in the tooltip's text units. using ItemTooltipTextMeasure = int (*)(const wchar_t* text, size_t length); // Writes the item data's description in the UI's language, with its placeholders filled in for the item's level, into diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 8ad366fd2f..3217349c20 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -111,6 +111,16 @@ if(ENABLE_CONTROL_SOCKET) LINK_LIBS MuClient ) + + # The tooltip commands' argument checks, which return before they need item data. + mu_add_test( + NAME test_control_tooltip_commands + SOURCES + test_control_tooltip_commands.cpp + ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp + LINK_LIBS + MuClient + ) endif() mu_add_test( diff --git a/tests/core/test_control_tooltip_commands.cpp b/tests/core/test_control_tooltip_commands.cpp new file mode 100644 index 0000000000..96fa8b8cd5 --- /dev/null +++ b/tests/core/test_control_tooltip_commands.cpp @@ -0,0 +1,49 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "App/Control/ControlCommands.h" +#include "App/Control/ControlProtocol.h" + +#include +#include +#include + +namespace +{ +std::string Run(std::string (*command)(const App::Control::Request&, std::unique_ptr&), + const std::string& raw) +{ + std::unique_ptr act; + return command(App::Control::Request::Parse(raw), act); +} + +bool IsBadRequest(const std::string& response) +{ + return response.find(R"("error":"bad_request")") != std::string::npos; +} +} // namespace + +TEST_CASE("tooltip refuses a value its item's fields can't hold [core][control]") +{ + const auto tooltip = [](const std::string& fields) + { return Run(&App::Control::Commands::ItemTooltip, R"({"cmd":"tooltip","group":0,"number":0,)" + fields + "}"); }; + // Each would wrap around in the item's byte (or its packet's four bits) instead of being refused. + for (const char* fields : + {R"("level":-1)", R"("level":16)", R"("durability":-1)", R"("durability":256)", R"("option":-1)", + R"("option":16)", R"("excellent":64)", R"("excellent":256)", R"("ancient":-1)", R"("ancient":256)", + R"("harmony":16)", R"("harmony":1,"harmony_level":16)", R"("level":"high")"}) + { + INFO(fields); + CHECK(IsBadRequest(tooltip(fields))); + } +} + +TEST_CASE("tooltip-snapshot fails when it can't write its file [core][control]") +{ + // A folder can't be opened as the file. + const std::filesystem::path folder = std::filesystem::temp_directory_path(); + const std::string response = Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":")" + folder.generic_string() + R"("})"); + CHECK(response.find(R"("error":"failed")") != std::string::npos); +} diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 68b48ed70f..ccd28597b6 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -74,6 +74,7 @@ mu_add_test( NAME test_item_database_files SOURCES test_helper_pickup_filters.cpp + test_dark_lord_pet_tooltip.cpp test_item_database_files.cpp test_item_descriptions.cpp test_item_model_files.cpp diff --git a/tests/data/test_dark_lord_pet_tooltip.cpp b/tests/data/test_dark_lord_pet_tooltip.cpp new file mode 100644 index 0000000000..fb423eb89f --- /dev/null +++ b/tests/data/test_dark_lord_pet_tooltip.cpp @@ -0,0 +1,33 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "Engine/Object/ZzzInventory.h" + +#include + +TEST_CASE("A Dark Lord pet's tooltip asks for the pet it is drawn for, also after another pet's [data][items][tooltip]") +{ + // The raven in the left hand, then the horse as the helper, then the raven again, from other storages. + const std::optional raven = GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_WEAPON_LEFT, 0); + REQUIRE(raven.has_value()); + CHECK(raven->pet == PetType::DarkRaven); + CHECK(raven->slot == EQUIPMENT_WEAPON_LEFT); + CHECK(raven->storage == static_cast(0)); + + const std::optional horse = GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, EQUIPMENT_HELPER, 2); + REQUIRE(horse.has_value()); + CHECK(horse->pet == PetType::DarkHorse); + CHECK(horse->slot == EQUIPMENT_HELPER); + CHECK(horse->storage == static_cast(2)); + + const std::optional ravenAgain = GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_WEAPON_LEFT, 1); + REQUIRE(ravenAgain.has_value()); + CHECK(ravenAgain->pet == PetType::DarkRaven); + CHECK(ravenAgain->storage == static_cast(1)); + + // A pet pointed at anywhere else asks for nothing. + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, EQUIPMENT_WEAPON_LEFT, 0).has_value()); + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_HELPER, 0).has_value()); + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, MAX_EQUIPMENT + 3, 0).has_value()); +} From 7f3effa82e88b78ca47d42a8e69b5d284b868857 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Fri, 23 Oct 2026 14:20:31 -0300 Subject: [PATCH 7/9] fix(control): tooltip checks its values before it makes the item from the item data, and the gate's lines are formatted --- .../App/Control/ControlCommandsQuery.cpp | 21 ++++++++++++------- src/source/Engine/Object/ZzzInventory.cpp | 4 +++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/source/App/Control/ControlCommandsQuery.cpp b/src/source/App/Control/ControlCommandsQuery.cpp index a8b6e65526..7ca9b0b14d 100644 --- a/src/source/App/Control/ControlCommandsQuery.cpp +++ b/src/source/App/Control/ControlCommandsQuery.cpp @@ -8,6 +8,7 @@ #include "Core/Text/Utf8.h" #include "Engine/Object/ZzzCharacter.h" #include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemType.h" #include "Engine/Object/ZzzInventory.h" #include "UI/Core/WindowSystem.h" #include "UI/Inventory/ItemMng.h" @@ -558,16 +559,15 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) "`tooltip` needs `group` and `number` of an item"); } - ItemCreationParams params = FullItem(group, number); // The item's fields hold these values (as the item packet has them); a value they can't hold is a `bad_request`, - // not a wrapped one. - int level = params.Level; - int durability = params.Durability; + // not a wrapped one. Checked before the item is made from the item data. -1: left out. + int level = -1; + int durability = -1; int option = 0; int excellent = 0; int ancient = 0; int harmony = 0; - int harmonyLevel = params.HarmonyOptionLevel; + int harmonyLevel = -1; if (!ReadBounded(request, "level", Data::Items::ItemLevelCount - 1, level) || !ReadBounded(request, "durability", 255, durability) || !ReadBounded(request, "option", 15, option) || !ReadBounded(request, "excellent", 63, excellent) || !ReadBounded(request, "ancient", 255, ancient) || @@ -577,8 +577,12 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) "`level`, `option`, `harmony` and `harmony_level` are 0 to 15, `excellent` the excellent " "options' bits 0 to 63, `durability` and `ancient` 0 to 255"); } - params.Level = static_cast(level); - params.Durability = static_cast(durability); + + ItemCreationParams params = FullItem(group, number); + if (level >= 0) + params.Level = static_cast(level); + if (durability >= 0) + params.Durability = static_cast(durability); bool flag = false; if (request.GetBool("luck", flag)) params.WithLuck = flag; @@ -603,7 +607,8 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) { params.HasHarmonyOption = true; params.HarmonyOptionType = static_cast(harmony); - params.HarmonyOptionLevel = static_cast(harmonyLevel); + if (harmonyLevel >= 0) + params.HarmonyOptionLevel = static_cast(harmonyLevel); } const std::optional> lines = TooltipOf(params); diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 79cf1c4f4d..7285476269 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -2886,7 +2886,9 @@ static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; + mu_swprintf(TextList[TextNum], L"\n"); + TextNum++; + SkipNum++; mu_swprintf(TextList[TextNum], I18N::Game::DD, ip->Durability, 5); TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; From 8deb35a588f812afa91939f23bb5098e85649267 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Fri, 23 Oct 2026 18:37:47 -0300 Subject: [PATCH 8/9] style(items): format the charm lines CI checks against the merged #818 --- src/source/Engine/Object/ZzzInventory.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 7285476269..b4971ea004 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -2846,27 +2846,32 @@ static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, case ITEM_TYPE_CHARM_MIXWING + EWS_KNIGHT_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfDragon, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_MAGICIAN_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfSoul, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_ELF_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfSpirits, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_SUMMONER_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingOfDespair, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_DARKKNIGHT_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfDarkness, Item_data.m_byValue1); - }break; + } + break; } mu_swprintf(TextList[TextNum], I18N::Game::Lookup(2732 + (ip->Type - (ITEM_TYPE_CHARM_MIXWING + EWS_BEGIN))), - Item_data.m_byValue1); + Item_data.m_byValue1); TextListColor[TextNum] = TEXT_COLOR_BLUE; TextBold[TextNum] = false; From 78e5861831e30a2a9b9f065484d43fe78d1f51c3 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Sat, 24 Oct 2026 15:36:10 -0300 Subject: [PATCH 9/9] fix(control): tooltip commands refuse bad requests before touching anything - tooltip-snapshot checks `group` before opening `out`, so a refused request no longer empties a recorded snapshot; `out` is resolved like screenshot's and the reply names the absolute path written. - tooltip refuses an item number no item uses and a `harmony_level` without `harmony`, instead of answering with an empty or a plain tooltip. - The Dark Lord pet's information request keeps only its last request time instead of passing the item through two statics; the debouncer goes. - control-socket.md: one file per group, and the pets' header-only entries. --- docs/control-socket.md | 4 +- .../App/Control/ControlCommandsQuery.cpp | 49 +++++++++-------- src/source/Core/Utilities/Debouncer.h | 30 ----------- src/source/Engine/Object/ZzzInventory.cpp | 31 +++++------ tests/core/test_control_tooltip_commands.cpp | 53 ++++++++++++++++++- 5 files changed, 94 insertions(+), 73 deletions(-) delete mode 100644 src/source/Core/Utilities/Debouncer.h diff --git a/docs/control-socket.md b/docs/control-socket.md index 8994c81858..901c50aacb 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -81,8 +81,8 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `bones` | in the world: how render's bones compare with the character animation pre-pass. `{"start":true}` clears the counts and starts checking; each answer gives `checked` (characters drawn), `players` (of those, player models), `mismatched` (any bone off by more than 0.01), `max_delta`, and the pre-pass's last `characters` count and whether it ran `parallel` | | `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, `npc_quest` (the quest dialog of Sebina, Marlon and Devin), and `message_box` while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: `trade.confirm`, `trade.zen`, `inventory.repair`, `inventory.my_shop`, `npc_shop.repair`, `npc_shop.repair_all`, `my_shop.title`, `my_shop.open`, `my_shop.close`, `command.trade`, `command.purchase`, `command.party`, `npc_quest.answer.0` and on (the rows of the quest dialog's answers), `npc_quest.complete`, `npc_quest.close`, `character.stat.strength` (`agility`, `vitality`, `energy`, `command`: the character window's "+" buttons, while there are level-up points) | | `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`, `npc_shop`, and the personal shops `my_shop` and `purchase_shop` (slots 204 and up, as `state` and the server number them); `not_open` while that window is closed, `bad_request` for a slot the grid does not have | -| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip, as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`"\n"` for a blank line), `color` (the `TEXT_COLOR_*` number) and `bold` | -| `tooltip-snapshot` (`out`, `group`) | writes the `tooltip` lines of every item (of `group` only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items, for the hero as he is, to the text file `out`: a line `/ +`, then one line ` [bold ]` per tooltip line (`` for a blank line). Two files compare with any diff tool, e.g. before and after a change of the tooltip code; replies `tooltips`, the count | +| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip (an item number no item uses, or `harmony_level` without `harmony`, is a `bad_request`), as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`"\n"` for a blank line), `color` (the `TEXT_COLOR_*` number) and `bold` | +| `tooltip-snapshot` (`out`, `group`) | writes the `tooltip` lines of every item (of `group` only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items, for the hero as he is, to the text file `out`: a line `/ +`, then one line ` [bold ]` per tooltip line (`` for a blank line). Two files compare with any diff tool, e.g. before and after a change of the tooltip code. Each request writes `out` anew, so a snapshot by groups takes one file per group. `out` is resolved like `screenshot`'s; replies `tooltips`, the count, and `out`, the absolute path written. The Dark Horse and the Dark Raven get their header line but no tooltip lines, since the pet manager draws their tooltip | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | | `logout`, `quit` | back to the character list; close the client | diff --git a/src/source/App/Control/ControlCommandsQuery.cpp b/src/source/App/Control/ControlCommandsQuery.cpp index 7ca9b0b14d..3fa5e43ea5 100644 --- a/src/source/App/Control/ControlCommandsQuery.cpp +++ b/src/source/App/Control/ControlCommandsQuery.cpp @@ -234,7 +234,8 @@ std::string& MutableBuildIdentifier() } // Absolute path for a caller-given one, so the answer names a file the -// caller can open regardless of the client's working directory. +// caller can open regardless of the client's working directory. Used by every +// command that writes a file (`screenshot`, `tooltip-snapshot`). std::filesystem::path ResolveScreenshotPath(const std::string& requested) { // Through the wide form: a std::string given to std::filesystem::path is @@ -495,13 +496,9 @@ ItemCreationParams FullItem(int group, int number) } // The tooltip lines of such an item and the hero as it is; nothing when the item can't be made. -std::optional> TooltipOf(const ItemCreationParams& params) +std::vector TooltipOf(const ItemCreationParams& params) { ITEM* item = g_pNewItemMng->CreateItemByParameters(¶ms); - if (item == nullptr) - { - return std::nullopt; - } std::vector lines = BuildItemTooltipLines(item, false); g_pNewItemMng->DeleteItem(item); return lines; @@ -577,6 +574,18 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) "`level`, `option`, `harmony` and `harmony_level` are 0 to 15, `excellent` the excellent " "options' bits 0 to 63, `durability` and `ancient` 0 to 255"); } + // A harmony level counts only with a harmony option; alone it would be ignored. + if (harmonyLevel >= 0 && harmony == 0) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`harmony_level` needs `harmony`, the Jewel of Harmony option"); + } + // An item number no item uses would answer with the tooltip of an empty item. + if (g_ItemDatabase.Find(group, number) == nullptr) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "no item has group " + std::to_string(group) + " and number " + std::to_string(number)); + } ItemCreationParams params = FullItem(group, number); if (level >= 0) @@ -611,15 +620,9 @@ std::string ItemTooltip(const Request& request, std::unique_ptr&) params.HarmonyOptionLevel = static_cast(harmonyLevel); } - const std::optional> lines = TooltipOf(params); - if (!lines) - { - return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "the item could not be made"); - } - json result; result["lines"] = json::array(); - for (const ItemTooltipLine& line : *lines) + for (const ItemTooltipLine& line : TooltipOf(params)) { result["lines"].push_back( {{"text", Core::Text::ToUtf8(line.text.c_str())}, {"color", line.color}, {"bold", line.bold}}); @@ -635,19 +638,21 @@ std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "`tooltip-snapshot` needs `out`, the file to write"); } - std::ofstream out(std::filesystem::path(Core::Text::FromUtf8(path)), std::ios::binary); - if (!out) - { - return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written"); - } - - // One group per request keeps the frame that builds them short. + // One group per request keeps the frame that builds them short. Checked before `out` is opened: opening it + // empties it, and a mistyped request must not wipe a recorded snapshot. int onlyGroup = -1; if (request.Has("group") && (!request.GetInt("group", onlyGroup) || onlyGroup < 0 || onlyGroup >= MAX_ITEM_TYPE)) { return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "`group` is an item group, 0 to 15"); } + const std::filesystem::path file = ResolveScreenshotPath(path); + std::ofstream out(file, std::ios::binary); + if (!out) + { + return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written"); + } + int tooltips = 0; for (const Data::Items::ItemDefinition& definition : g_ItemDatabase.GetAllSlots()) { @@ -663,7 +668,7 @@ std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) params.Level = static_cast(level); variant.apply(params); out << definition.group << '/' << definition.number << ' ' << variant.name << " +" << level << '\n'; - for (const ItemTooltipLine& line : TooltipOf(params).value_or(std::vector{})) + for (const ItemTooltipLine& line : TooltipOf(params)) { const std::string text = line.text == L"\n" ? "" : Core::Text::ToUtf8(line.text.c_str()); out << " " << line.color << (line.bold ? " bold " : " ") << text << '\n'; @@ -681,7 +686,7 @@ std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) json result; result["tooltips"] = tooltips; - result["out"] = path; + result["out"] = Core::Text::ToUtf8(file.wstring().c_str()); return EncodeResult(request.EncodedId(), result.dump()); } diff --git a/src/source/Core/Utilities/Debouncer.h b/src/source/Core/Utilities/Debouncer.h deleted file mode 100644 index 7846ce65a9..0000000000 --- a/src/source/Core/Utilities/Debouncer.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include - -class DebouncedAction -{ -public: - explicit DebouncedAction(std::function callback, int period) - : interval(std::chrono::milliseconds(period)), lastCallTime(), callback(std::move(callback)) - { - } - - void invoke() - { - auto now = std::chrono::high_resolution_clock::now(); - - // Call immediately on the first invocation or on the next interval - if (lastCallTime == std::chrono::high_resolution_clock::time_point() || now - lastCallTime >= interval) - { - callback(); - lastCallTime = now; - } - } - -private: - std::chrono::milliseconds interval; - std::chrono::high_resolution_clock::time_point lastCallTime; - std::function callback; -}; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index b4971ea004..027d10000e 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -21,7 +21,6 @@ #include "Scenes/SceneCore.h" -#include "Core/Utilities/Debouncer.h" #include "Core/Utilities/Log/MuLogger.h" #include "GameLogic/Quests/CSQuest.h" #include "App/Platform/Windows/Local.h" @@ -39,6 +38,7 @@ #include "GameLogic/Items/ItemCategories.h" #include "GameLogic/Items/ItemStats.h" #include "Render/Items/ItemDisplay.h" +#include #include "Data/GameData/ItemData/ItemModelSlots.h" #include "Data/GameData/ItemData/ItemDatabase.h" #include "Data/GameData/ItemData/ItemDescriptionPlaceholders.h" @@ -4402,24 +4402,19 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, return; if (build == ItemTooltipBuild::DarkLordPet) { - // The request is made once a second at most, for the pet whose tooltip is drawn then: the callback lives on, - // so it reads the item of this call, not one it was made with. - static int petItemType = -1; - static int petInventype = 0; - petItemType = ip->Type; - petInventype = Inventype; - static DebouncedAction debouncedPetInfoRequest( - []() + // The pet's information is requested once a second at most, for the pet whose tooltip is drawn. + static std::chrono::steady_clock::time_point lastPetInfoRequest; + const auto now = std::chrono::steady_clock::now(); + if (lastPetInfoRequest == std::chrono::steady_clock::time_point() || + now - lastPetInfoRequest >= std::chrono::seconds(1)) + { + lastPetInfoRequest = now; + if (const std::optional request = + GetPetInfoRequest(ip->Type, g_pMyInventory->GetPointedItemIndex(), Inventype)) { - if (const std::optional request = - GetPetInfoRequest(petItemType, g_pMyInventory->GetPointedItemIndex(), petInventype)) - { - SocketClient->ToGameServer()->SendPetInfoRequest(request->pet, request->storage, request->slot); - } - }, - 1000); // 1-second intervals - - debouncedPetInfoRequest.invoke(); + SocketClient->ToGameServer()->SendPetInfoRequest(request->pet, request->storage, request->slot); + } + } giPetManager::RenderPetItemInfo(screen, sx, sy, ip, Inventype); return; diff --git a/tests/core/test_control_tooltip_commands.cpp b/tests/core/test_control_tooltip_commands.cpp index 96fa8b8cd5..77672e65d9 100644 --- a/tests/core/test_control_tooltip_commands.cpp +++ b/tests/core/test_control_tooltip_commands.cpp @@ -6,7 +6,9 @@ #include "App/Control/ControlProtocol.h" #include +#include #include +#include #include namespace @@ -35,10 +37,59 @@ TEST_CASE("tooltip refuses a value its item's fields can't hold [core][control]" R"("harmony":16)", R"("harmony":1,"harmony_level":16)", R"("level":"high")"}) { INFO(fields); - CHECK(IsBadRequest(tooltip(fields))); + const std::string response = tooltip(fields); + CHECK(IsBadRequest(response)); + CHECK(response.find("0 to 15") != std::string::npos); } } +TEST_CASE("tooltip refuses a harmony level without a harmony option [core][control]") +{ + const std::string response = Run(&App::Control::Commands::ItemTooltip, + R"({"cmd":"tooltip","group":0,"number":0,"harmony_level":13})"); + CHECK(IsBadRequest(response)); + CHECK(response.find("`harmony_level` needs `harmony`") != std::string::npos); +} + +TEST_CASE("tooltip refuses an item number no item uses [core][control]") +{ + // The tests load no item data, so no item exists. + const std::string response = + Run(&App::Control::Commands::ItemTooltip, R"({"cmd":"tooltip","group":15,"number":511})"); + CHECK(IsBadRequest(response)); + CHECK(response.find("no item has group 15 and number 511") != std::string::npos); +} + +TEST_CASE("tooltip-snapshot leaves its file alone when the request is refused [core][control]") +{ + const std::filesystem::path file = std::filesystem::temp_directory_path() / "mu_tooltip_snapshot_keep.txt"; + { + std::ofstream(file, std::ios::binary) << "before"; + } + const std::string response = + Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":")" + file.generic_string() + R"(","group":16})"); + CHECK(IsBadRequest(response)); + std::ostringstream text; + text << std::ifstream(file, std::ios::binary).rdbuf(); + CHECK(text.str() == "before"); + std::filesystem::remove(file); +} + +TEST_CASE("tooltip-snapshot replies the absolute path it wrote [core][control]") +{ + const std::filesystem::path previous = std::filesystem::current_path(); + std::filesystem::current_path(std::filesystem::temp_directory_path()); + const std::string response = Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":"mu_tooltip_snapshot_relative.txt"})"); + const std::filesystem::path written = std::filesystem::absolute("mu_tooltip_snapshot_relative.txt"); + std::filesystem::current_path(previous); + CHECK(std::filesystem::exists(written)); + CHECK(response.find("mu_tooltip_snapshot_relative.txt") != std::string::npos); + CHECK(response.find("\"out\":\"mu_tooltip_snapshot_relative.txt\"") == std::string::npos); + std::filesystem::remove(written); +} + TEST_CASE("tooltip-snapshot fails when it can't write its file [core][control]") { // A folder can't be opened as the file.