Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e0f7011
refactor(items): build the item tooltip's lines apart from drawing them
ezequielzacca Oct 21, 2026
b892f33
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 22, 2026
b245598
test(control): the documented vocabulary has the tooltip commands
ezequielzacca Oct 22, 2026
17dc19f
docs(control): the tooltip command's row in one table row
ezequielzacca Oct 22, 2026
1f9d999
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 22, 2026
c2ab350
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 22, 2026
5ac570c
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 22, 2026
4227e4c
style(items): the moved lines CI's clang-format gate checks are forma…
ezequielzacca Oct 23, 2026
e248712
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
7755756
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
bb0cc91
style(items): the lines CI's clang-format gate checks are formatted
ezequielzacca Oct 23, 2026
1fabfd7
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
d90e1e2
fix(items): the pet tooltip asks for the pet it is drawn for, and the…
ezequielzacca Oct 23, 2026
7f3effa
fix(control): tooltip checks its values before it makes the item from…
ezequielzacca Oct 23, 2026
980b397
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
bfad7de
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
b83bb46
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
8deb35a
style(items): format the charm lines CI checks against the merged #818
ezequielzacca Oct 23, 2026
c4f64c5
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
95150fa
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
4cec8be
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 23, 2026
7d315dc
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 24, 2026
b2e1749
Merge branch 'merge/feat/item-tooltip-chain-texts' into merge/feat/it…
ezequielzacca Oct 24, 2026
71458a6
Merge remote-tracking branch 'origin/main' into merge/feat/item-toolt…
ezequielzacca Oct 24, 2026
78e5861
fix(control): tooltip commands refuse bad requests before touching an…
ezequielzacca Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/control-socket.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +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 (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 `<group>/<number> <variant> +<level>`, then one line ` <color> [bold ]<text>` per tooltip line (`<blank>` 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 |
Expand Down
6 changes: 6 additions & 0 deletions src/source/App/Control/ControlCommands.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ std::string EventsSince(const Request& request, std::unique_ptr<Act>& act);
std::string WaitFor(const Request& request, std::unique_ptr<Act>& act);
std::string Screenshot(const Request& request, std::unique_ptr<Act>& act);
std::string Hotkey(const Request& request, std::unique_ptr<Act>& 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>& 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>& act);
std::string ClickUi(const Request& request, std::unique_ptr<Act>& act);
std::string HoverUi(const Request& request, std::unique_ptr<Act>& act);
std::string DragUi(const Request& request, std::unique_ptr<Act>& act);
Expand Down
216 changes: 215 additions & 1 deletion src/source/App/Control/ControlCommandsQuery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
#include "Core/Utilities/Benchmark/BenchRecorder.h"
#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"
#include "Network/Server/WSclient.h"
#include "Scenes/SceneCore.h"
#include "Scenes/SceneManager.h"
Expand All @@ -20,6 +25,7 @@
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <optional>
Expand Down Expand Up @@ -228,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
Expand Down Expand Up @@ -476,6 +483,213 @@ std::string State(const Request& request, std::unique_ptr<Act>&)
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<BYTE>(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::vector<ItemTooltipLine> TooltipOf(const ItemCreationParams& params)
{
ITEM* item = g_pNewItemMng->CreateItemByParameters(&params);
std::vector<ItemTooltipLine> 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;
}},
};

// 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<Act>&)
{
int group = -1;
int number = -1;
if (!request.GetInt("group", group) || !request.GetInt("number", number) || group < 0 || group >= MAX_ITEM_TYPE ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. This only range-checks group and number. For a number no item uses, CreateItemByParameters still makes an item, and the reply is a tooltip of an empty item rather than an error. That looks like a valid answer to a script with a typo'd number. The "the item could not be made" branch can't trigger either, because CreateItemByParameters returns null only for a null pointer. Refusing with bad_request when g_ItemDatabase.Find(type) is null, as the snapshot skips items that don't Exists(), would make that branch real.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 78e5861: tooltip answers bad_request ("no item has group G and number N") when g_ItemDatabase.Find(group, number) is null. Since CreateItemByParameters can't fail for an item that exists, I dropped the unreachable "could not be made" branch and TooltipOf returns the lines directly. Tested.

number < 0 || number >= MAX_ITEM_INDEX)
{
return EncodeError(request.EncodedId(), ErrorCode::BadRequest,
"`tooltip` needs `group` and `number` of an item");
}

// 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. 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 = -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) ||
!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");
}
// 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)
params.Level = static_cast<BYTE>(level);
if (durability >= 0)
params.Durability = static_cast<BYTE>(durability);
bool flag = false;
if (request.GetBool("luck", flag))
params.WithLuck = flag;
if (request.GetBool("skill", flag))
params.WithSkill = flag;
if (option > 0)
{
params.WithOption = true;
params.OptionLevel = static_cast<BYTE>(option);
}
if (excellent > 0)
{
params.HasExcellentOption = true;
params.ExcellentFlags = static_cast<BYTE>(excellent);
}
if (ancient > 0)
{
params.IsAncient = true;
params.AncientDiscriminator = static_cast<BYTE>(ancient);
}
if (harmony > 0)
{
params.HasHarmonyOption = true;
params.HarmonyOptionType = static_cast<BYTE>(harmony);
if (harmonyLevel >= 0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. harmony_level only counts inside if (harmony > 0). So {"group":0,"number":0,"harmony_level":13} replies with a plain tooltip and no error, and harmony without harmony_level silently uses level 0. A bad_request for harmony_level without harmony would catch the forgotten field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 78e5861: harmony_level without harmony is a bad_request ("harmony_level needs harmony"), with a test. harmony alone still uses level 0, as documented.

params.HarmonyOptionLevel = static_cast<BYTE>(harmonyLevel);
}

json result;
result["lines"] = json::array();
for (const ItemTooltipLine& line : TooltipOf(params))
{
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<Act>&)
{
std::string path;
if (!request.GetString("out", path) || path.empty())
{
return EncodeError(request.EncodedId(), ErrorCode::BadRequest,
"`tooltip-snapshot` needs `out`, the file to write");
}
// 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())
{
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<BYTE>(level);
variant.apply(params);
out << definition.group << '/' << definition.number << ' ' << variant.name << " +" << level << '\n';
for (const ItemTooltipLine& line : TooltipOf(params))
{
const std::string text = line.text == L"\n" ? "<blank>" : Core::Text::ToUtf8(line.text.c_str());
out << " " << line.color << (line.bold ? " bold " : " ") << text << '\n';
}
++tooltips;
}
}
}
// 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;
result["out"] = Core::Text::ToUtf8(file.wstring().c_str());
return EncodeResult(request.EncodedId(), result.dump());
}

std::string Nearby(const Request& request, std::unique_ptr<Act>&)
{
json result;
Expand Down
2 changes: 2 additions & 0 deletions src/source/App/Control/ControlDispatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const std::vector<CommandEntry>& 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},
Expand Down
3 changes: 1 addition & 2 deletions src/source/App/Control/ControlProtocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ const std::vector<std::string>& 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;
}
Expand Down
30 changes: 0 additions & 30 deletions src/source/Core/Utilities/Debouncer.h

This file was deleted.

Loading
Loading