From 5c17a624d13fa1f8c4acb132da85665dc34616d0 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 10:47:27 -0300 Subject: [PATCH 01/11] feat(items): item descriptions in the item data A "description" list in the item data: lines the tooltip shows for an item after its own, each with a text written like the item's name (plain English, or texts by language; "" is a blank line) and an optional tooltip color by name. Read, written (left out when empty), kept by the bmd import, and checked: a line longer than the tooltip's 99 characters gets a warning. The tooltip draws them in the UI's language. So a new item's text is data instead of another case in the tooltip code. No shipped item has one yet; the original items keep their texts in the tooltip code. --- docs/item-data.md | 1 + .../ItemData/ItemAttributeConversion.cpp | 1 + .../GameData/ItemData/ItemDataValidation.cpp | 29 ++++++++ .../Data/GameData/ItemData/ItemDefinition.h | 35 ++++++++++ .../Data/GameData/ItemData/ItemEnumNames.cpp | 22 ++++++ .../Data/GameData/ItemData/ItemEnumNames.h | 1 + .../Data/GameData/ItemData/ItemJsonFormat.cpp | 69 +++++++++++++++++++ src/source/Engine/Object/ZzzInventory.cpp | 24 +++++++ tests/data/test_item_json.cpp | 40 +++++++++++ 9 files changed, 222 insertions(+) diff --git a/docs/item-data.md b/docs/item-data.md index ddc637e1b8..d15951743e 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -81,6 +81,7 @@ read as the English name only; saving writes the object form. | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | +| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), `""` is a blank line, and lines don't wrap, so each is at most 99 characters (longer ones get a warning and are cut). `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | diff --git a/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp b/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp index ef8e235de5..836fab1568 100644 --- a/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp +++ b/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp @@ -109,6 +109,7 @@ void CopyItemAttributeStats(const ITEM_ATTRIBUTE& attribute, ItemDefinition& def void CopyFieldsNotInItemAttribute(const ItemDefinition& source, ItemDefinition& target) { target.tags = source.tags; + target.description = source.description; target.wingTier = source.wingTier; target.weaponKind = source.weaponKind; target.statProfile = source.statProfile; diff --git a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp index e20adf4d31..ee82b8d06d 100644 --- a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp +++ b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp @@ -24,6 +24,12 @@ void AddIssue(std::vector& issues, ItemDataIssueSeverity severity issues.push_back({severity, "", definition.group, definition.number, field, message}); } +void AddIssue(std::vector& issues, ItemDataIssueSeverity severity, const ItemDefinition& definition, + const std::string& message, const std::string& field) +{ + issues.push_back({severity, "", definition.group, definition.number, field, message}); +} + bool ContainsSeparator(const std::string& text) { return text.find(LocalizedString::Separator) != std::string::npos; @@ -202,6 +208,28 @@ void ValidatePotionPriority(const ItemDefinition& definition, std::vector& issues) +{ + const auto characters = [](const std::string& text) + { return static_cast(std::count_if(text.begin(), text.end(), [](char c) { return (c & 0xC0) != 0x80; })); }; + for (size_t index = 0; index < definition.description.size(); ++index) + { + const LocalizedString& text = definition.description[index].text; + bool tooLong = characters(text.GetNeutral()) > MaxItemDescriptionLineLength; + for (const auto& [locale, translation] : text.GetTranslations()) + { + tooLong = tooLong || characters(translation) > MaxItemDescriptionLineLength; + } + if (tooLong) + { + AddIssue(issues, ItemDataIssueSeverity::Warning, definition, + "has a line longer than " + std::to_string(MaxItemDescriptionLineLength) + + " characters; the tooltip cuts it", + "description[" + std::to_string(index) + "]"); + } + } +} } // namespace void ValidateItems(std::span items, std::vector& issues) @@ -229,6 +257,7 @@ void ValidateItems(std::span items, std::vector description; + BYTE width = 0; BYTE height = 0; ItemSlot slot = ItemSlot::None; diff --git a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp index 596c46a6e8..9ccced151d 100644 --- a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp +++ b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp @@ -82,6 +82,23 @@ constexpr std::array, static_cast(ItemTag::Count)> Ite }}; } // namespace +constexpr std::array, 14> ItemTextColorNames = {{ + {ItemTextColor::White, "white"}, + {ItemTextColor::Blue, "blue"}, + {ItemTextColor::Red, "red"}, + {ItemTextColor::Yellow, "yellow"}, + {ItemTextColor::Green, "green"}, + {ItemTextColor::DarkRed, "darkRed"}, + {ItemTextColor::Purple, "purple"}, + {ItemTextColor::DarkBlue, "darkBlue"}, + {ItemTextColor::DarkYellow, "darkYellow"}, + {ItemTextColor::GreenBlue, "greenBlue"}, + {ItemTextColor::Gray, "gray"}, + {ItemTextColor::RedPurple, "redPurple"}, + {ItemTextColor::Violet, "violet"}, + {ItemTextColor::Orange, "orange"}, +}}; + std::span> GetEnumNames(ItemSlot) { return ItemSlotNames; @@ -101,4 +118,9 @@ std::span> GetEnumNames(ItemTag) { return ItemTagNames; } + +std::span> GetEnumNames(ItemTextColor) +{ + return ItemTextColorNames; +} } // namespace Data::Items diff --git a/src/source/Data/GameData/ItemData/ItemEnumNames.h b/src/source/Data/GameData/ItemData/ItemEnumNames.h index 878dfae1f9..ec9d2dcd2d 100644 --- a/src/source/Data/GameData/ItemData/ItemEnumNames.h +++ b/src/source/Data/GameData/ItemData/ItemEnumNames.h @@ -18,6 +18,7 @@ std::span> GetEnumNames(ItemSlot); std::span> GetEnumNames(WingTier); std::span> GetEnumNames(WeaponKind); std::span> GetEnumNames(ItemTag); +std::span> GetEnumNames(ItemTextColor); // nullptr when the value has no name. template const char* FindEnumName(TEnum value) diff --git a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp index 91bb798c95..9cebc5aeee 100644 --- a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp +++ b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp @@ -24,6 +24,9 @@ using namespace Json::Keys; constexpr const char* Items = "items"; constexpr const char* Name = "name"; constexpr const char* Tags = "tags"; +constexpr const char* Description = "description"; +constexpr const char* Text = "text"; +constexpr const char* Color = "color"; constexpr const char* Requirements = "requirements"; constexpr const char* RequirementsByLevel = "requirementsByLevel"; constexpr const char* ClassRequirements = "classRequirements"; @@ -157,6 +160,24 @@ OrderedJson WriteTags(const ItemTagSet& tags) return json; } +// One object per line: its text (as a name is written; "" for a blank line) and its color unless white. +OrderedJson WriteDescription(const std::vector& description) +{ + OrderedJson json = OrderedJson::array(); + for (const ItemDescriptionLine& line : description) + { + OrderedJson entry; + // A blank line is a plain empty text, as it is written by hand. + entry[Keys::Text] = line.text.IsEmpty() ? OrderedJson("") : Json::WriteLocalizedString(line.text); + if (line.color != ItemTextColor::White) + { + entry[Keys::Color] = WriteValue(line.color); + } + json.push_back(std::move(entry)); + } + return json; +} + OrderedJson WriteItem(const ItemDefinition& definition) { OrderedJson item; @@ -166,6 +187,10 @@ OrderedJson WriteItem(const ItemDefinition& definition) { item[Keys::Tags] = WriteTags(definition.tags); } + if (!definition.description.empty()) + { + item[Keys::Description] = WriteDescription(definition.description); + } const auto writeIfNotDefault = [](OrderedJson& target) { @@ -226,6 +251,7 @@ class ItemReader bool ReadIdentity(const OrderedJson& json, ItemDefinition& definition); bool ReadNames(const OrderedJson& json, LocalizedString& names); void ReadTags(const OrderedJson& json, ItemTagSet& tags); + void ReadDescription(const OrderedJson& json, std::vector& description); void ReadStats(const OrderedJson& json, ItemDefinition& definition); void ReadRequirements(const OrderedJson& json, ItemDefinition& definition); void ReadRequirementsByLevel(const OrderedJson& json, ItemDefinition& definition); @@ -368,6 +394,47 @@ void ItemReader::ReadTags(const OrderedJson& json, ItemTagSet& tags) } } +void ItemReader::ReadDescription(const OrderedJson& json, std::vector& description) +{ + const auto list = json.find(Keys::Description); + if (list == json.end()) + { + return; + } + if (!list->is_array()) + { + AddIssue(ItemDataIssueSeverity::Error, Keys::Description, "must be a list of lines"); + return; + } + + for (size_t index = 0; index < list->size(); ++index) + { + const OrderedJson& entry = (*list)[index]; + const std::string field = std::string(Keys::Description) + "[" + std::to_string(index) + "]"; + const auto text = entry.is_object() ? entry.find(Keys::Text) : entry.end(); + if (!entry.is_object() || text == entry.end()) + { + AddIssue(ItemDataIssueSeverity::Error, field, "must be an object with a text"); + continue; + } + + ItemDescriptionLine line; + const bool read = Json::ReadLocalizedString(*text, field + "." + Keys::Text, line.text, + [this](const std::string& errorField, const std::string& message) + { AddIssue(ItemDataIssueSeverity::Error, errorField, message); }); + const auto color = entry.find(Keys::Color); + if (color != entry.end()) + { + ReadValue(*color, field + "." + Keys::Color, line.color); + } + WarnAboutUnknownKeys(entry, {Keys::Text, Keys::Color}, field + "."); + if (read) + { + description.push_back(std::move(line)); + } + } +} + void ItemReader::ReadStats(const OrderedJson& json, ItemDefinition& definition) { VisitStatFields(definition, @@ -481,6 +548,7 @@ std::set> GetKnownItemKeys() std::set> keys{Keys::Number, Keys::Name, Keys::Tags, + Keys::Description, Keys::Requirements, Keys::RequirementsByLevel, Keys::ClassRequirements, @@ -503,6 +571,7 @@ bool ItemReader::Read(const OrderedJson& json, ItemDefinition& definition) } ReadTags(json, definition.tags); + ReadDescription(json, definition.description); ReadStats(json, definition); ReadRequirements(json, definition); ReadRequirementsByLevel(json, definition); diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index c7bba6d4ef..f1fd0fb70a 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -40,6 +40,8 @@ #include "GameLogic/Items/ItemStats.h" #include "Render/Items/ItemDisplay.h" #include "Data/GameData/ItemData/ItemModelSlots.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Core/Text/Utf8.h" #include "UI/Scaling/UITransform.h" #include "GameLogic/Items/ShopRestrictions.h" #include "GameLogic/Items/TradeRestrictions.h" @@ -3724,6 +3726,28 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; } } + // The item data's description, line by line in the UI's language. + if (const Data::Items::ItemDefinition* definition = g_ItemDatabase.Find(ip->Type)) + { + for (const Data::Items::ItemDescriptionLine& line : definition->description) + { + if (TextNum >= static_cast(std::size(TextList))) + break; + if (line.text.IsEmpty()) + { + // A blank line, as the tooltip writes its own. + mu_swprintf(TextList[TextNum], L"\n"); + TextNum++; + SkipNum++; + continue; + } + const std::wstring text = Core::Text::FromUtf8(line.text.Get(g_ItemDatabase.GetDisplayLocale())); + mu_swprintf(TextList[TextNum], L"%.99ls", text.c_str()); + TextListColor[TextNum] = static_cast(line.color); + TextBold[TextNum] = false; + TextNum++; + } + } if (ip->Type == ITEM_JEWEL_OF_BLESS) { mu_swprintf(TextList[TextNum], I18N::Game::ItIsUsedToIncreaseYourItemLevelUpTo6); diff --git a/tests/data/test_item_json.cpp b/tests/data/test_item_json.cpp index cf82581f3d..397c133647 100644 --- a/tests/data/test_item_json.cpp +++ b/tests/data/test_item_json.cpp @@ -285,6 +285,46 @@ TEST_CASE("Item tags, wing tier, potion priority and rule flags are kept through CHECK(read.sellable); } +TEST_CASE("An item's description is kept through write and read, and checked [data][items]") +{ + ItemDefinition jewel = MakeKris(); + jewel.group = 14; + jewel.number = 13; + jewel.slot = ItemSlot::None; + jewel.weaponKind = WeaponKind::None; + jewel.statProfile.clear(); + ItemDescriptionLine first; + first.text.Set("en", "Increases the level of an item up to +6."); + first.text.Set("de", "Erhöht die Stufe eines Gegenstands bis +6."); + ItemDescriptionLine blank; + ItemDescriptionLine last; + last.text.Set("en", "Drop it on the item to use it."); + last.color = ItemTextColor::Blue; + jewel.description = {first, blank, last}; + + const std::string text = WriteItemGroupJson(14, std::vector{jewel}); + CHECK(text.find("\"color\": \"blue\"") != std::string::npos); + const ReadResult result = Read(text); + REQUIRE(result.items.size() == 1); + CHECK(result.items.front().description == jewel.description); + CHECK_FALSE(HasErrors(result.issues)); + CHECK(WriteItemGroupJson(14, result.items) == text); + + std::vector issues; + ValidateItems(std::vector{jewel}, issues); + CHECK_FALSE(HasIssue(issues, ItemDataIssueSeverity::Warning, "description[0]")); + + ItemDefinition tooLong = jewel; + tooLong.description[0].text.Set("de", std::string(MaxItemDescriptionLineLength + 1, 'x')); + issues.clear(); + ValidateItems(std::vector{tooLong}, issues); + CHECK(HasIssue(issues, ItemDataIssueSeverity::Warning, "description[0]")); + + const ReadResult badColor = Read(R"({"formatVersion": 1, "group": 14, "items": [ + {"number": 13, "name": "Jewel of Bless", "description": [{"text": "x", "color": "pink"}]}]})"); + CHECK(HasErrors(badColor.issues)); +} + TEST_CASE("An item's stat profile is kept through write and read [data][items]") { ItemDefinition kris = MakeKris(); From 99e19fd1e80a5450e32439fedeba01d92952c71c Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 10:54:37 -0300 Subject: [PATCH 02/11] test(items): the description test uses the Jewel of Bless's own tooltip line --- tests/data/test_item_json.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/data/test_item_json.cpp b/tests/data/test_item_json.cpp index 397c133647..bd33c72640 100644 --- a/tests/data/test_item_json.cpp +++ b/tests/data/test_item_json.cpp @@ -293,12 +293,14 @@ TEST_CASE("An item's description is kept through write and read, and checked [da jewel.slot = ItemSlot::None; jewel.weaponKind = WeaponKind::None; jewel.statProfile.clear(); + // The Jewel of Bless's own tooltip line, as the client's translations have it. ItemDescriptionLine first; - first.text.Set("en", "Increases the level of an item up to +6."); - first.text.Set("de", "Erhöht die Stufe eines Gegenstands bis +6."); + first.text.Set("en", "It is used to increase your item level up to 6"); + first.text.Set("de", "Wird verwendet, um das Gegenstandslevel auf bis zu 6 zu erhöhen"); + first.text.Set("es", "Se utiliza para aumentar el nivel de tu objeto hasta 6."); + first.text.Set("pt", "É usado para aumentar o nível do seu item até 6"); ItemDescriptionLine blank; - ItemDescriptionLine last; - last.text.Set("en", "Drop it on the item to use it."); + ItemDescriptionLine last = first; last.color = ItemTextColor::Blue; jewel.description = {first, blank, last}; From efcd7ff76924f53e40bc6f486a6f3d289d1a583f Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 11:14:51 -0300 Subject: [PATCH 03/11] feat(items): description lines wrap to the tooltip's width A description line longer than the tooltip's widest lines wraps there, measured with the tooltip's font, and a "\n" in a text breaks the line, so long texts need neither hand-split lines nor the 99-character limit and its warning, which go. Each piece keeps the line's color. --- docs/item-data.md | 2 +- .../GameData/ItemData/ItemDataValidation.cpp | 29 ------------------- .../Data/GameData/ItemData/ItemDefinition.h | 5 ++-- src/source/Engine/Object/ZzzInventory.cpp | 24 ++++++++++----- tests/data/test_item_json.cpp | 18 +++++------- 5 files changed, 29 insertions(+), 49 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index d15951743e..ffe5cc48b8 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -81,7 +81,7 @@ read as the English name only; saving writes the object form. | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | -| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), `""` is a blank line, and lines don't wrap, so each is at most 99 characters (longer ones get a warning and are cut). `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | +| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), `""` is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | diff --git a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp index ee82b8d06d..e20adf4d31 100644 --- a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp +++ b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp @@ -24,12 +24,6 @@ void AddIssue(std::vector& issues, ItemDataIssueSeverity severity issues.push_back({severity, "", definition.group, definition.number, field, message}); } -void AddIssue(std::vector& issues, ItemDataIssueSeverity severity, const ItemDefinition& definition, - const std::string& message, const std::string& field) -{ - issues.push_back({severity, "", definition.group, definition.number, field, message}); -} - bool ContainsSeparator(const std::string& text) { return text.find(LocalizedString::Separator) != std::string::npos; @@ -208,28 +202,6 @@ void ValidatePotionPriority(const ItemDefinition& definition, std::vector& issues) -{ - const auto characters = [](const std::string& text) - { return static_cast(std::count_if(text.begin(), text.end(), [](char c) { return (c & 0xC0) != 0x80; })); }; - for (size_t index = 0; index < definition.description.size(); ++index) - { - const LocalizedString& text = definition.description[index].text; - bool tooLong = characters(text.GetNeutral()) > MaxItemDescriptionLineLength; - for (const auto& [locale, translation] : text.GetTranslations()) - { - tooLong = tooLong || characters(translation) > MaxItemDescriptionLineLength; - } - if (tooLong) - { - AddIssue(issues, ItemDataIssueSeverity::Warning, definition, - "has a line longer than " + std::to_string(MaxItemDescriptionLineLength) + - " characters; the tooltip cuts it", - "description[" + std::to_string(index) + "]"); - } - } -} } // namespace void ValidateItems(std::span items, std::vector& issues) @@ -257,7 +229,6 @@ void ValidateItems(std::span items, std::vectorType)) { + g_pRenderText->SetFont(g_hFont); + const auto measure = [](const wchar_t* text, size_t length) + { return g_pRenderText->MeasureText(text, static_cast(length)).cx; }; for (const Data::Items::ItemDescriptionLine& line : definition->description) { - if (TextNum >= static_cast(std::size(TextList))) - break; if (line.text.IsEmpty()) { // A blank line, as the tooltip writes its own. + if (TextNum >= static_cast(std::size(TextList))) + break; mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; continue; } const std::wstring text = Core::Text::FromUtf8(line.text.Get(g_ItemDatabase.GetDisplayLocale())); - mu_swprintf(TextList[TextNum], L"%.99ls", text.c_str()); - TextListColor[TextNum] = static_cast(line.color); - TextBold[TextNum] = false; - TextNum++; + for (const std::wstring& piece : WrapTextToWidth(text, Data::Items::ItemDescriptionWrapWidth, measure)) + { + if (TextNum >= static_cast(std::size(TextList))) + break; + mu_swprintf(TextList[TextNum], L"%.99ls", piece.c_str()); + TextListColor[TextNum] = static_cast(line.color); + TextBold[TextNum] = false; + TextNum++; + } } } if (ip->Type == ITEM_JEWEL_OF_BLESS) diff --git a/tests/data/test_item_json.cpp b/tests/data/test_item_json.cpp index bd33c72640..b26de852c7 100644 --- a/tests/data/test_item_json.cpp +++ b/tests/data/test_item_json.cpp @@ -285,7 +285,7 @@ TEST_CASE("Item tags, wing tier, potion priority and rule flags are kept through CHECK(read.sellable); } -TEST_CASE("An item's description is kept through write and read, and checked [data][items]") +TEST_CASE("An item's description is kept through write and read [data][items]") { ItemDefinition jewel = MakeKris(); jewel.group = 14; @@ -312,15 +312,13 @@ TEST_CASE("An item's description is kept through write and read, and checked [da CHECK_FALSE(HasErrors(result.issues)); CHECK(WriteItemGroupJson(14, result.items) == text); - std::vector issues; - ValidateItems(std::vector{jewel}, issues); - CHECK_FALSE(HasIssue(issues, ItemDataIssueSeverity::Warning, "description[0]")); - - ItemDefinition tooLong = jewel; - tooLong.description[0].text.Set("de", std::string(MaxItemDescriptionLineLength + 1, 'x')); - issues.clear(); - ValidateItems(std::vector{tooLong}, issues); - CHECK(HasIssue(issues, ItemDataIssueSeverity::Warning, "description[0]")); + // A line break in a text is kept; the tooltip starts a new line there. + ItemDefinition twoLines = jewel; + twoLines.description = {first}; + twoLines.description[0].text.Set("en", "It is used to increase\nyour item level up to 6"); + const ReadResult broken = Read(WriteItemGroupJson(14, std::vector{twoLines})); + REQUIRE(broken.items.size() == 1); + CHECK(broken.items.front().description == twoLines.description); const ReadResult badColor = Read(R"({"formatVersion": 1, "group": 14, "items": [ {"number": 13, "name": "Jewel of Bless", "description": [{"text": "x", "color": "pink"}]}]})"); From bd50b8eb8dbb0cfd9b25906a7273a0f7743fe767 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 11:29:29 -0300 Subject: [PATCH 04/11] docs(items): a blank description line is an empty text --- docs/item-data.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/item-data.md b/docs/item-data.md index ffe5cc48b8..0e223e9b1a 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -81,7 +81,7 @@ read as the English name only; saving writes the object form. | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | -| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), `""` is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | +| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | From 750046f3a26eb10a89b7d0e67123008c780bd269 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Wed, 21 Oct 2026 11:40:22 -0300 Subject: [PATCH 05/11] docs(items): where the description sits in the tooltip --- docs/item-data.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/item-data.md b/docs/item-data.md index 0e223e9b1a..c1363345b3 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -81,7 +81,7 @@ read as the English name only; saving writes the object form. | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | -| `description` | Lines the tooltip shows for the item, after its own lines (the texts of the original client's items are still in the tooltip code). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | +| `description` | Lines the tooltip shows for the item, after its stats (the original client's items still have their texts in the tooltip code, written after the description). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | From 94af869bbff140ebf6660d3bcce9b0d88f2bc352 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 17:37:01 -0300 Subject: [PATCH 06/11] perf(text): WrapTextToWidth measures a line once per word It measured the whole line after every letter. A tooltip is built every frame while an item is hovered, and through the SDL_ttf renderer each measured prefix is a new text for the shared text cache, which empties itself when full. Now a line is measured when a word ends, and letter by letter only inside a word wider than a whole line. Compared with the old version on 200,000 random texts: the same lines, except where the old one left a line wider than the limit (a word too wide for a line, after a break), and about half the measure calls. --- src/source/Core/Text/TextLineWrap.cpp | 65 ++++++++++++++++----------- tests/text/test_text_line_wrap.cpp | 41 +++++++++++++++++ 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/src/source/Core/Text/TextLineWrap.cpp b/src/source/Core/Text/TextLineWrap.cpp index d22990298c..883b704bd0 100644 --- a/src/source/Core/Text/TextLineWrap.cpp +++ b/src/source/Core/Text/TextLineWrap.cpp @@ -125,48 +125,63 @@ std::vector WrapTextToWidth(const std::wstring& text, int maxWidth // has seen. npos while it has none. size_t breakAt = std::wstring::npos; - for (const wchar_t character : text) + const auto fits = [&](size_t length) { return measureWidth(line.c_str(), length) <= maxWidth; }; + // Called when a word ends: the line is measured once per word, not after + // every letter (a tooltip is built every frame, and each measured piece + // is a new text for the renderer's text cache). + const auto wrapAfterWord = [&]() { - if (character == L'\n') + // Spaces never make a line too wide: it is measured up to its last + // word, and the spaces it ends with are dropped when it is broken. + if (line.empty() || line.back() == L' ' || fits(line.size())) + { + return; + } + if (breakAt != std::wstring::npos) { + std::wstring word = line.substr(breakAt); + line.erase(breakAt); TrimTrailingSpaces(line); lines.push_back(line); - line.clear(); + line = std::move(word); breakAt = std::wstring::npos; - continue; } - - line += character; - if (character == L' ') - { - breakAt = line.size(); - continue; - } - - if (measureWidth(line.c_str(), line.size()) <= maxWidth) + // A single word which is wider than a whole line. It has to be broken + // somewhere, otherwise it would never fit: where it stops fitting. + while (line.size() > 1 && !fits(line.size())) { - continue; + size_t length = 1; + while (length + 1 < line.size() && fits(length + 1)) + { + ++length; + } + lines.push_back(line.substr(0, length)); + line.erase(0, length); } + }; - if (breakAt != std::wstring::npos) + for (const wchar_t character : text) + { + if (character == L'\n') { - std::wstring rest = line.substr(breakAt); - line.erase(breakAt); + wrapAfterWord(); TrimTrailingSpaces(line); lines.push_back(line); - line = std::move(rest); + line.clear(); + breakAt = std::wstring::npos; + continue; } - else if (line.size() > 1) + if (character == L' ') { - // A single word which is wider than a whole line. It has to be - // broken somewhere, otherwise it would never fit. - lines.push_back(line.substr(0, line.size() - 1)); - line = line.substr(line.size() - 1); + wrapAfterWord(); + line += character; + breakAt = line.size(); + continue; } - - breakAt = std::wstring::npos; + line += character; } + wrapAfterWord(); TrimTrailingSpaces(line); if (!line.empty()) { diff --git a/tests/text/test_text_line_wrap.cpp b/tests/text/test_text_line_wrap.cpp index 2a535d55a1..89933f0e3e 100644 --- a/tests/text/test_text_line_wrap.cpp +++ b/tests/text/test_text_line_wrap.cpp @@ -260,3 +260,44 @@ TEST_CASE("wrap to width: an own line break starts a new line") CHECK(lines[0] == L"first"); CHECK(lines[1] == L"second"); } + +TEST_CASE("wrap to width: a blank line inside the text is an empty line") +{ + const auto lines = WrapTextToWidth(L"Line one\n\nLine two", 32, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 3); + CHECK(lines[0] == L"Line one"); + CHECK(lines[1].empty()); + CHECK(lines[2] == L"Line two"); + + const auto leading = WrapTextToWidth(L"\nLeading break", 32, OneUnitPerCharacter()); + REQUIRE(leading.size() == 2); + CHECK(leading[0].empty()); + CHECK(leading[1] == L"Leading break"); +} + +TEST_CASE("wrap to width: a line is measured once per word, not per letter") +{ + int measured = 0; + const MeasureTextWidth counting = [&measured](const wchar_t*, size_t length) + { + ++measured; + return static_cast(length); + }; + + const auto lines = WrapTextToWidth(L"Handles the chat command", 12, counting); + + REQUIRE(lines.size() == 2); + CHECK(measured <= 5); // four words, and the line measured again after its break +} + +TEST_CASE("wrap to width: a word wider than the line after a break is broken too") +{ + const auto lines = WrapTextToWidth(L"set masterleveluppoints", 8, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 4); + CHECK(lines[0] == L"set"); + CHECK(lines[1] == L"masterle"); + CHECK(lines[2] == L"veluppoi"); + CHECK(lines[3] == L"nts"); +} From f62aa0c5dde9b27bf3217aa752f8b753898c284b Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 17:37:01 -0300 Subject: [PATCH 07/11] fix(items): blank description lines, the description's rows, checks like names Review of #813: - A blank line inside a description text ("a\n\nb", or a leading "\n") wrote an empty row, and an empty row ends the tooltip in both renderers and in the height count: the rest of the tooltip was gone. A blank piece is now the tooltip's own blank row, "\n", as a blank line is. - The description stopped at TextList's end, but the rows after it are written unchecked, so a long description wrote past the array. TextList, TextListColor, TextBold and Size have 32 rows more than the original client's 50, and the description writes at most those 32: it never takes the room the rest of the tooltip needs. The files that declared the arrays themselves with 50 rows use ZzzInventory.h's declarations now. - The block is WriteItemDescriptionLines (rule 1), with the measure passed in, so a test can check its rows; the wrap width moved from the item data header to it, and the row width comes from TextList. - Description texts get the checks names get: the English text is required, a language code has only letters, digits and "-", and a language the game has no texts in is a warning. - The color names are in the anonymous namespace with the other tables, and a color without a name fails to compile. --- .../GameData/ItemData/ItemDataValidation.cpp | 50 ++++++- .../Data/GameData/ItemData/ItemDefinition.h | 6 +- .../Data/GameData/ItemData/ItemEnumNames.cpp | 19 ++- src/source/Engine/Object/ZzzInterface.cpp | 4 - src/source/Engine/Object/ZzzInventory.cpp | 74 +++++----- src/source/Engine/Object/ZzzInventory.h | 25 +++- .../GameLogic/Events/w_CursedTemple.cpp | 3 +- src/source/GameLogic/Items/CSItemOption.cpp | 3 - src/source/GameLogic/Pets/CSPetSystem.cpp | 3 - src/source/GameLogic/Pets/GIPetManager.cpp | 3 - src/source/UI/Core/UIManager.cpp | 8 +- src/source/UI/Events/CursedTempleSystem.cpp | 2 - src/source/World/GameMaps/GMHellas.cpp | 4 +- tests/data/CMakeLists.txt | 1 + tests/data/test_item_descriptions.cpp | 131 ++++++++++++++++++ 15 files changed, 268 insertions(+), 68 deletions(-) create mode 100644 tests/data/test_item_descriptions.cpp diff --git a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp index 3bebfb8c52..d6378549da 100644 --- a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp +++ b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp @@ -79,6 +79,33 @@ void ValidateName(const ItemDefinition& definition, std::vector& check(locale, text); } } +// A description line's text is written like a name: an English text, and translations under language codes. A blank +// line has no text in any language. +void ValidateDescription(const ItemDefinition& definition, std::vector& issues) +{ + for (size_t index = 0; index < definition.description.size(); ++index) + { + const LocalizedString& text = definition.description[index].text; + if (text.IsEmpty()) + { + continue; + } + const std::string field = "description[" + std::to_string(index) + "].text"; + if (text.GetNeutral().empty()) + { + AddIssue(issues, ItemDataIssueSeverity::Error, definition, "the English text is missing", field.c_str()); + } + for (const auto& [locale, translation] : text.GetTranslations()) + { + if (!IsValidLocaleCode(locale)) + { + AddIssue(issues, ItemDataIssueSeverity::Error, definition, + "\"" + locale + "\" is not a language code (letters, digits and \"-\" only)", field.c_str()); + } + } + } +} + // The slot of each weapon kind: the left hand (offHand) for bows, shields and // books, the right hand (mainHand) for the others. The code looks for a bow or // a shield only in the left hand and for a crossbow or a spear only in the @@ -224,6 +251,7 @@ void ValidateItems(std::span items, std::vector items, std::span(ItemTextColor::Count); // One line of an item's description in its tooltip: a text with its translations (empty: a blank line) and its color. struct ItemDescriptionLine @@ -91,10 +93,6 @@ struct ItemDescriptionLine bool operator==(const ItemDescriptionLine& other) const = default; }; -// How wide a description line is drawn before it wraps, in the tooltip's text units: about the widest lines of the -// original tooltips. -constexpr int ItemDescriptionWrapWidth = 200; - // What a player may do with an item. See the rule flags in ItemDefinition. enum class ItemAction : BYTE { diff --git a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp index 5244cbbd26..65cd30e71a 100644 --- a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp +++ b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp @@ -89,9 +89,8 @@ constexpr std::array, ItemRequirementCount> ItemRequir {ItemRequirement::Vitality, "vitality"}, {ItemRequirement::Leadership, "leadership"}, }}; -} // namespace -constexpr std::array, 14> ItemTextColorNames = {{ +constexpr std::array, ItemTextColorCount> ItemTextColorNames = {{ {ItemTextColor::White, "white"}, {ItemTextColor::Blue, "blue"}, {ItemTextColor::Red, "red"}, @@ -108,6 +107,22 @@ constexpr std::array, 14> ItemTextColorNames = {{ {ItemTextColor::Orange, "orange"}, }}; +// One entry per color, in the order of their values: a color added to ItemTextColor without a name here fails to +// compile, instead of being saved as a number that reading the file then refuses. +constexpr bool NamesEveryTextColor() +{ + for (size_t index = 0; index < ItemTextColorNames.size(); ++index) + { + if (ItemTextColorNames[index].name == nullptr || static_cast(ItemTextColorNames[index].value) != index) + { + return false; + } + } + return true; +} +static_assert(NamesEveryTextColor(), "every ItemTextColor needs its name in ItemTextColorNames"); +} // namespace + std::span> GetEnumNames(ItemSlot) { return ItemSlotNames; diff --git a/src/source/Engine/Object/ZzzInterface.cpp b/src/source/Engine/Object/ZzzInterface.cpp index a78108bd0c..735f0e73ca 100644 --- a/src/source/Engine/Object/ZzzInterface.cpp +++ b/src/source/Engine/Object/ZzzInterface.cpp @@ -3621,10 +3621,6 @@ bool CheckSkillUseCondition(OBJECT* o, int Type) return true; } -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; - void GetTime(DWORD time, std::wstring& timeText, bool isSecond) { wchar_t buff[100]; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 96b7ec156f..f209e5751f 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -190,10 +190,10 @@ int getLevelGeneration(int level, unsigned int* color) return lvl; } -wchar_t TextList[50][100]; -int TextListColor[50]; -int TextBold[50]; -SIZE Size[50]; +wchar_t TextList[MaxTextListRows][100]; +int TextListColor[MaxTextListRows]; +int TextBold[MaxTextListRows]; +SIZE Size[MaxTextListRows]; int RenderTextList(int sx, int sy, int TextNum, int Tab, int iSort = RT3_SORT_CENTER) { @@ -1602,6 +1602,43 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int } } +// How wide a description line is drawn before it wraps, in the tooltip's text units: about the widest lines of the +// original tooltips. +static constexpr int ItemDescriptionWrapWidth = 200; + +void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, ItemTooltipTextMeasure measure) +{ + const int lastRow = std::min(TextNum + MaxItemDescriptionRows, static_cast(std::size(TextList))); + for (const Data::Items::ItemDescriptionLine& line : definition.description) + { + const std::wstring text = Core::Text::FromUtf8(line.text.Get(g_ItemDatabase.GetDisplayLocale())); + // A blank line is one blank piece. So is a blank line inside a text (`\n\n`, or a text that starts with + // `\n`): a row left empty would end the tooltip there. + const std::vector pieces = + text.empty() ? std::vector(1) : WrapTextToWidth(text, ItemDescriptionWrapWidth, measure); + for (const std::wstring& piece : pieces) + { + if (TextNum >= lastRow) + { + return; + } + if (piece.empty()) + { + // As the tooltip writes its own blank rows. + mu_swprintf(TextList[TextNum], L"\n"); + SkipNum++; + } + else + { + mu_swprintf(TextList[TextNum], L"%.*ls", static_cast(std::size(TextList[0]) - 1), piece.c_str()); + } + TextListColor[TextNum] = static_cast(line.color); + TextBold[TextNum] = false; + TextNum++; + } + } +} + // (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) @@ -3727,36 +3764,11 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; } } - // The item data's description in the UI's language: each line wrapped to the tooltip's width and at its line - // breaks, the pieces in the line's color. if (const Data::Items::ItemDefinition* definition = g_ItemDatabase.Find(ip->Type)) { g_pRenderText->SetFont(g_hFont); - const auto measure = [](const wchar_t* text, size_t length) - { return g_pRenderText->MeasureText(text, static_cast(length)).cx; }; - for (const Data::Items::ItemDescriptionLine& line : definition->description) - { - if (line.text.IsEmpty()) - { - // A blank line, as the tooltip writes its own. - if (TextNum >= static_cast(std::size(TextList))) - break; - mu_swprintf(TextList[TextNum], L"\n"); - TextNum++; - SkipNum++; - continue; - } - const std::wstring text = Core::Text::FromUtf8(line.text.Get(g_ItemDatabase.GetDisplayLocale())); - for (const std::wstring& piece : WrapTextToWidth(text, Data::Items::ItemDescriptionWrapWidth, measure)) - { - if (TextNum >= static_cast(std::size(TextList))) - break; - mu_swprintf(TextList[TextNum], L"%.99ls", piece.c_str()); - TextListColor[TextNum] = static_cast(line.color); - TextBold[TextNum] = false; - TextNum++; - } - } + WriteItemDescriptionLines(*definition, [](const wchar_t* text, size_t length) -> int + { return g_pRenderText->MeasureText(text, static_cast(length)).cx; }); } if (ip->Type == ITEM_JEWEL_OF_BLESS) { diff --git a/src/source/Engine/Object/ZzzInventory.h b/src/source/Engine/Object/ZzzInventory.h index 7373922d63..a30904ba34 100644 --- a/src/source/Engine/Object/ZzzInventory.h +++ b/src/source/Engine/Object/ZzzInventory.h @@ -82,10 +82,27 @@ extern int AllRepairGold; ////////////////////////////////////////////////////////////////////////// // text 관련 ////////////////////////////////////////////////////////////////////////// -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; -extern SIZE Size[50]; +// The rows an item's description may write into a tooltip, on top of the original client's 50: the description +// gets rows of its own, so it never takes the room the rest of the tooltip needs. +constexpr int MaxItemDescriptionRows = 32; +constexpr int MaxTextListRows = 50 + MaxItemDescriptionRows; + +extern wchar_t TextList[MaxTextListRows][100]; +extern int TextListColor[MaxTextListRows]; +extern int TextBold[MaxTextListRows]; +extern SIZE Size[MaxTextListRows]; + +namespace Data::Items +{ +struct ItemDefinition; +} + +// 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 into TextList, TextListColor and TextBold from TextNum on: +// each line wrapped to the tooltip's width and at its line breaks, the pieces in the line's color, a blank line or +// piece as the tooltip's own blank rows. At most MaxItemDescriptionRows rows. +void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, ItemTooltipTextMeasure measure); /////////////////////////////////////////////////////////////////////////////// // party diff --git a/src/source/GameLogic/Events/w_CursedTemple.cpp b/src/source/GameLogic/Events/w_CursedTemple.cpp index 2db70bc338..dde068b96b 100644 --- a/src/source/GameLogic/Events/w_CursedTemple.cpp +++ b/src/source/GameLogic/Events/w_CursedTemple.cpp @@ -1,5 +1,6 @@  #include "stdafx.h" +#include "Engine/Object/ZzzInventory.h" #include "w_CursedTemple.h" #include "Render/Textures/ZzzOpenglUtil.h" #include "Render/Textures/ZzzTexture.h" @@ -21,8 +22,6 @@ extern int TextNum; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; using namespace SEASON3A; diff --git a/src/source/GameLogic/Items/CSItemOption.cpp b/src/source/GameLogic/Items/CSItemOption.cpp index f9ce378eee..8643ddfa2d 100644 --- a/src/source/GameLogic/Items/CSItemOption.cpp +++ b/src/source/GameLogic/Items/CSItemOption.cpp @@ -29,9 +29,6 @@ #include #include -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; namespace { diff --git a/src/source/GameLogic/Pets/CSPetSystem.cpp b/src/source/GameLogic/Pets/CSPetSystem.cpp index 80c0c2f2b9..25d34090d3 100644 --- a/src/source/GameLogic/Pets/CSPetSystem.cpp +++ b/src/source/GameLogic/Pets/CSPetSystem.cpp @@ -41,9 +41,6 @@ bool IsValidCharacterIndex(int index) extern bool g_PetEnableDuel; extern double WorldTime; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; CSPetSystem::CSPetSystem() : m_PetOwner(nullptr), m_PetTarget(nullptr), m_PetCharacter(), m_PetType(PET_TYPE_NONE), m_pPetInfo(nullptr), diff --git a/src/source/GameLogic/Pets/GIPetManager.cpp b/src/source/GameLogic/Pets/GIPetManager.cpp index c513ab1344..81b842f34e 100644 --- a/src/source/GameLogic/Pets/GIPetManager.cpp +++ b/src/source/GameLogic/Pets/GIPetManager.cpp @@ -34,9 +34,6 @@ #include "Render/Text/CUIRenderText.h" extern bool SkillEnable; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; extern int CheckX; extern int CheckY; extern int CheckSkill; diff --git a/src/source/UI/Core/UIManager.cpp b/src/source/UI/Core/UIManager.cpp index 0bf4c86b51..023b2ad26d 100644 --- a/src/source/UI/Core/UIManager.cpp +++ b/src/source/UI/Core/UIManager.cpp @@ -33,10 +33,10 @@ namespace mu::ui::window { int& MouseY = ::MouseY; int& g_iCancelSkillTarget = ::g_iCancelSkillTarget; - wchar_t (&TextList)[50][100] = ::TextList; - int (&TextListColor)[50] = ::TextListColor; - int (&TextBold)[50] = ::TextBold; - int& TextNum = ::TextNum; + wchar_t (&TextList)[MaxTextListRows][100] = ::TextList; + int (&TextListColor)[MaxTextListRows] = ::TextListColor; + int (&TextBold)[MaxTextListRows] = ::TextBold; + int& TextNum = ::TextNum; int& ItemHelp = ::ItemHelp; JewelHarmonyInfo*& g_pUIJewelHarmonyinfo = ::g_pUIJewelHarmonyinfo; int& SelectedCharacter = ::SelectedCharacter; diff --git a/src/source/UI/Events/CursedTempleSystem.cpp b/src/source/UI/Events/CursedTempleSystem.cpp index 383419dce3..fa43d4edcc 100644 --- a/src/source/UI/Events/CursedTempleSystem.cpp +++ b/src/source/UI/Events/CursedTempleSystem.cpp @@ -44,8 +44,6 @@ #include extern int TextNum; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; using namespace SEASON3B; using namespace mu::ui::window; diff --git a/src/source/World/GameMaps/GMHellas.cpp b/src/source/World/GameMaps/GMHellas.cpp index c234ea2965..fd435d8332 100644 --- a/src/source/World/GameMaps/GMHellas.cpp +++ b/src/source/World/GameMaps/GMHellas.cpp @@ -2,6 +2,7 @@ // GMHellas.cpp ////////////////////////////////////////////////////////////////////////// #include "stdafx.h" +#include "Engine/Object/ZzzInventory.h" #include "Render/Textures/ZzzOpenglUtil.h" #include "Render/Renderer/MuRenderer.h" #include "Render/Textures/ZzzTexture.h" @@ -24,9 +25,6 @@ #include "Render/Text/CUIRenderText.h" extern int WaterTextureNumber; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; static CSWaterTerrain* g_pCSWaterTerrain = NULL; static std::queue g_qObjDes; diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 23c59db4e7..cbee18ac13 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -55,6 +55,7 @@ mu_add_test( NAME test_item_database_files SOURCES test_item_database_files.cpp + test_item_descriptions.cpp test_item_model_files.cpp test_item_requirement_weights.cpp test_item_rules.cpp diff --git a/tests/data/test_item_descriptions.cpp b/tests/data/test_item_descriptions.cpp new file mode 100644 index 0000000000..56082bf2cd --- /dev/null +++ b/tests/data/test_item_descriptions.cpp @@ -0,0 +1,131 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "Data/GameData/ItemData/ItemDataValidation.h" +#include "Engine/Object/ZzzInventory.h" + +#include +#include +#include + +extern int TextNum; +extern int SkipNum; + +using namespace Data::Items; + +namespace +{ +// Five units a letter: the description wraps at 200, so 40 letters fit a row. +int FiveUnitsPerLetter(const wchar_t*, size_t length) +{ + return static_cast(length) * 5; +} + +ItemDescriptionLine Line(const char* english, ItemTextColor color = ItemTextColor::White) +{ + ItemDescriptionLine line; + line.text.Set("en", english); + line.color = color; + return line; +} + +// The tooltip rows the description writes, from an empty list. +std::vector WriteRows(const ItemDefinition& definition) +{ + TextNum = 0; + SkipNum = 0; + WriteItemDescriptionLines(definition, FiveUnitsPerLetter); + return {std::begin(TextList), std::begin(TextList) + TextNum}; +} + +std::vector DescriptionIssues(const ItemDefinition& definition, std::span uiLocales) +{ + std::vector issues; + ValidateItems(std::vector{definition}, issues); + ValidateNameLocales(std::vector{definition}, uiLocales, issues); + std::erase_if(issues, [](const ItemDataIssue& issue) { return !issue.field.starts_with("description"); }); + return issues; +} + +ItemDefinition MakeJewel() +{ + ItemDefinition jewel; + jewel.group = 14; + jewel.number = 13; + jewel.names.Set("en", "Jewel of Bless"); + return jewel; +} +} // namespace + +TEST_CASE("A blank line inside a description text is a blank tooltip row, not the tooltip's end [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description = {Line("Line one\n\nLine two"), Line("\nLeading break"), Line("")}; + + const std::vector rows = WriteRows(jewel); + + // An empty row would end the tooltip there: a blank one is "\n", as the tooltip writes its own. + const std::vector expected = {L"Line one", L"\n", L"Line two", L"\n", L"Leading break", L"\n"}; + CHECK(rows == expected); + CHECK(std::none_of(rows.begin(), rows.end(), [](const std::wstring& row) { return row.empty(); })); + CHECK(SkipNum == 3); +} + +TEST_CASE("A description wraps to the tooltip's width in its line's color [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description = { + Line("It is used to increase your item level up to 6 with a little more", ItemTextColor::Blue)}; + + const std::vector rows = WriteRows(jewel); + + REQUIRE(rows.size() == 2); + CHECK(rows[0] == L"It is used to increase your item level"); + CHECK(rows[1] == L"up to 6 with a little more"); + CHECK(TextListColor[0] == TEXT_COLOR_BLUE); + CHECK(TextListColor[1] == TEXT_COLOR_BLUE); +} + +TEST_CASE("A description writes at most its own rows, leaving the tooltip's rows to the rest [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description.assign(MaxItemDescriptionRows + 10, Line("A line")); + + TextNum = 5; + WriteItemDescriptionLines(jewel, FiveUnitsPerLetter); + CHECK(TextNum == 5 + MaxItemDescriptionRows); + + // Near the end of the list it stops at the list's end. + TextNum = MaxTextListRows - 3; + WriteItemDescriptionLines(jewel, FiveUnitsPerLetter); + CHECK(TextNum == MaxTextListRows); +} + +TEST_CASE("Description texts are checked like item names [data][items]") +{ + const char* uiLocales[] = {"en", "de"}; + + ItemDefinition jewel = MakeJewel(); + ItemDescriptionLine noEnglish; + noEnglish.text.Set("de", "Nur Deutsch"); + ItemDescriptionLine badCode = Line("A text"); + badCode.text.Set("d e", "Ein Text"); + ItemDescriptionLine unknownLanguage = Line("A text"); + unknownLanguage.text.Set("xx", "Ein Text"); + jewel.description = {Line(""), noEnglish, badCode, unknownLanguage}; + + const std::vector issues = DescriptionIssues(jewel, uiLocales); + + // The blank line has none; "d e" is no language code and, like "xx", no language the game shows. + REQUIRE(issues.size() == 4); + CHECK(issues[0].severity == ItemDataIssueSeverity::Error); + CHECK(issues[0].field == "description[1].text"); + CHECK(issues[0].message == "the English text is missing"); + CHECK(issues[1].severity == ItemDataIssueSeverity::Error); + CHECK(issues[1].field == "description[2].text"); + CHECK(issues[2].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[2].field == "description[2].text"); + CHECK(issues[3].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[3].field == "description[3].text"); +} From 8b26e6594b6269237997469a8ec1eb93fc9d6aec Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 17:37:01 -0300 Subject: [PATCH 08/11] docs(items): where the description sits, its checks and rows; tooltip texts in the items design Review of #813: the description comes after the damage, defense and speed lines and before durability, the requirements and the options; the Dark Horse and Dark Raven show it only in shops. The items design records the split this step makes: an item's own tooltip texts are client-only fields of its item file (D15, D22, the field table), which the exchange file of phase 7 leaves out, and phase 8 goes in the steps of #815. --- docs/item-data.md | 2 +- .../specs/2026-09-25-data-driven-items-design.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index 3f2b4235f2..07631ec825 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -81,7 +81,7 @@ read as the English name only; saving writes the object form. | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | -| `description` | Lines the tooltip shows for the item, after its stats (the original client's items still have their texts in the tooltip code, written after the description). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | +| `description` | Lines the tooltip shows for the item, after its damage, defense and speed lines and before its durability, requirements and options (the original client's items still have their texts in the tooltip code, written after the description). The Dark Horse and the Dark Raven show it only in shops: elsewhere the pet window writes their tooltip. Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language) and checked like it (the English text is required, a language code has only letters, digits and `-`, and a language the game has no texts in is a warning), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there (`\n\n` leaves a blank line). The description draws at most 32 rows, which it has to itself, so a long one never takes the room of the tooltip's other lines. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | diff --git a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md index 7895169abc..f77be4b255 100644 --- a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md +++ b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md @@ -49,12 +49,12 @@ share with items) is planned in the | D12 | Id ranges | No reserved number ranges for custom items, and no range checks (`type >= X && type <= Y`) in new code; everything an item "is" comes from its data. OpenMU must keep supporting the original Season 6 client **and** this client (see section 7). | | D13 | Other item files | `ItemAddOption`, `SocketItem`, `Mix`, `pet` and set options are decided later, when we know more. | | D14 | Item options | The item file only holds **links** to the option groups an item can have (Luck, additional option, excellent, wing, harmony, guardian, socket, …). The option definitions (values, chances, levels) live in their own files. Related data stays together in one file, e.g. an option definition with all its levels and values. | -| D15 | Tooltips | Tooltips become data-driven with JSON data, converted from `ItemTooltip*` / `ItemLevelTooltip` (see "Unused item files in the repo"). Phase 8, after the item phases; moved earlier if the item changes turn out to need it. The tooltip bmd files stay in the repo until then. | +| D15 | Tooltips | Tooltips become data-driven with JSON data, converted from `ItemTooltip*` / `ItemLevelTooltip` (see "Unused item files in the repo"). Phase 8, after the item phases; moved earlier if the item changes turn out to need it. The tooltip bmd files stay in the repo until then. **Update (2026-10-22, #815):** an item's own tooltip texts (its `description` lines) live in its item file, with their translations, as client-only fields that the OpenMU exchange leaves out (D22). Texts and layouts that many items share get a tooltip file of their own. | | D16 | Unused item bmd copies | Removed on this branch; the game only loads `Item_.bmd`. | | D17 | Translations in phase 2 | Item names moved into phase 2, because the per-language bmd files were the only place the Portuguese and Spanish names existed. Names are `LocalizedString`s imported from all three files; the UI locale (the same setting as the `.resx` texts) picks the shown name, also when it changes at runtime. On disk, each item's `name` is an object of names by language (`{"en": "Blade", "es": "Espada", "pt": "Lâmina"}`, English first) in the item files; separate translation files were tried and dropped as extra maintenance. Phase 5 keeps only the translation tooling. | | D18 | Repairing the legacy data | The bmd import recovers names that ran past the 30-byte name field and takes the fields they overwrote from a language whose name did not reach them (defaults when none has them). Names that are not UTF-8 are read as Windows-1252. The changes are listed in the phase 2 PR and in `docs/item-data.md`. | | D19 | Editor sync | Every item editor change goes into the database right away (phase 2), so the editor and the database never differ. Moving the editor fully onto the database stays in phase 6 (done: the item tools edit the database, the item table is gone). | -| D22 | Model data files | Model and display data (model file, textures, inventory and ground display, cloth, effects) lives in separate files, `Data/Items/Models/GroupNN_*.json`, one per item group, items by `number`. It is client-only: the item files keep what client and server share, and only those take part in the OpenMU exchange. Separate files also keep the item files small and let the model editor and the stats editor change different files. | +| D22 | Model data files | Model and display data (model file, textures, inventory and ground display, cloth, effects) lives in separate files, `Data/Items/Models/GroupNN_*.json`, one per item group, items by `number`. It is client-only: the item files keep what client and server share, and only those take part in the OpenMU exchange. The exception is an item's own tooltip texts (D15): they stay in the item file next to its name, as client-only fields that the exchange leaves out. Separate files also keep the item files small and let the model editor and the stats editor change different files. | | D23 | Model slots | Two steps. **Own model slots (phase 4a–4c):** every item keeps its own model slot, `MODEL_ITEM + item type`, as today; the data only says which file is loaded into it, so the ~100 places that compute `+ MODEL_ITEM` / `- MODEL_ITEM` stay unchanged. **Shared models (phase 4d, right after them):** models become entries of their own, and items reference them by name, so several items can share one model (loaded once; 4d1, done). Every item keeps a slot of its own, and the conversions between item types and model slots go through one place, `ToModelSlot` / `ToItemType` (4d2), so a moved item can keep its slot (phase 12) and the named slots of items (`MODEL_KRIS`, …) stay valid. One slot per shared model, with objects keeping item types, is not planned: about 240 places identify items by their model slot (see "Item-specific code that is left"), and none of them gains anything while each item has its own slot. Phase 12 (level variants as items of their own) shares models without loading them twice. | | D24 | Render effects | The effect code stays code, but which item uses which effect moves into the model data: the glow as values (color names from a glow color list, `Data/Effects/GlowColors.json`, which has the colors of the old `PartObjectColor*` palettes; the meshes it is drawn on; the level it glows like), a render style (`RenderPartObjectBody` recipes; identical recipes share one name) and an item effect (the item branches of `RenderPartObjectEffect`). Effects that depend on the item level (+7 glow, excellent, ancient) stay generic code, and the entries for monsters and NPCs stay in code. The named looks are code for now; D25 moves what they are made of into data. | | D25 | Looks in data | Items keep naming their looks; what a named look is made of moves from code into data (phase 13). Skills, monsters and NPCs use the same look format, so the decision lives in the [roadmap](2026-09-29-data-driven-content-roadmap-design.md). | @@ -292,6 +292,7 @@ Each field belongs to one of two groups: | Categories/tags | wing, flying, mount, pet, potion, jewel, ticket, cash shop, event, … | client-only at first; shared where OpenMU gets matching data | | Model | model file, texture folders, cloth flag (in the model files, D22) | client-only | | Rendering | inventory offset, rotation and scale; ground rotation and scale; glow color, render style, effects (D24) | client-only | +| Tooltip texts | `description` (D15) | client-only, in the item files; the exchange file leaves them out | ### 4. Rule code on top of data @@ -413,9 +414,9 @@ in both repos (as separate PRs, one per repo). | 4e | Clear model loading errors | Client | MuMain | 4 | One message for missing model files and textures of item models that names the item, the model entry, the texture and the searched folders. | | 5 | Translation tooling | Client | MuMain | 2 | Translations editor (items × languages), missing names counted in the editor. The names themselves moved to phase 2 (D17). Done before phase 6. | | 6 | Editors | Client | MuMain | 2–5 | Focused MuEditor tools (section 9) on the rules of D26, including add/remove items and picking the looks of an item from lists. Takes over phase 5's item list. Done. | -| 7 | Item sync, client side | Client | MuMain | 2, 6 | MuEditor import/export of the item exchange file, with diff. | +| 7 | Item sync, client side | Client | MuMain | 2, 6 | MuEditor import/export of the item exchange file, with diff. The client-only fields of the item files (tags at first, `description`) are left out. | | B | Item sync, server side | Server | OpenMU | 7 (file format) | Admin panel import/export pages for the item exchange file, with diff. | -| 8 | Data-driven tooltips | Client | MuMain | 2, 5 | Tooltip JSON converted from the `ItemTooltip*` files; `RenderItemInfo()` reads data; tooltip editor. Moved earlier if needed. | +| 8 | Data-driven tooltips | Client | MuMain | 2, 5 | Tooltip JSON converted from the `ItemTooltip*` files; `RenderItemInfo()` reads data; tooltip editor. Moved earlier if needed. In steps (#815): an item's own texts go into its item file (`description`, D15), shared texts and layouts into a tooltip file. | | 9 | Item option definitions | Both | MuMain + C | 2 | Option groups in their own JSON files matching OpenMU's `ItemOptionDefinition`; exchange file and editor. | | 10 | Item sets | Both | MuMain + C | 2 | Item sets in JSON matching OpenMU's `ItemSetGroup`; exchange file and editor; based on the `item-set-editor` branch. Moved earlier if needed. | | C | Option and set sync, server side | Server | OpenMU | 9, 10 | Admin panel import/export for the option definition and item set exchange files. | From 4a25c7163aee384f47a6ff33d29a3e90578ed2fd Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 19:08:11 -0300 Subject: [PATCH 09/11] style: the window references to the tooltip rows are formatted as clang-format wants --- src/source/UI/Core/UIManager.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/source/UI/Core/UIManager.cpp b/src/source/UI/Core/UIManager.cpp index 023b2ad26d..4a1fd28f7f 100644 --- a/src/source/UI/Core/UIManager.cpp +++ b/src/source/UI/Core/UIManager.cpp @@ -31,16 +31,16 @@ extern int TextNum; namespace mu::ui::window { - int& MouseY = ::MouseY; - int& g_iCancelSkillTarget = ::g_iCancelSkillTarget; - wchar_t (&TextList)[MaxTextListRows][100] = ::TextList; - int (&TextListColor)[MaxTextListRows] = ::TextListColor; - int (&TextBold)[MaxTextListRows] = ::TextBold; - int& TextNum = ::TextNum; - int& ItemHelp = ::ItemHelp; - JewelHarmonyInfo*& g_pUIJewelHarmonyinfo = ::g_pUIJewelHarmonyinfo; - int& SelectedCharacter = ::SelectedCharacter; -} +int& MouseY = ::MouseY; +int& g_iCancelSkillTarget = ::g_iCancelSkillTarget; +wchar_t (&TextList)[MaxTextListRows][100] = ::TextList; +int (&TextListColor)[MaxTextListRows] = ::TextListColor; +int (&TextBold)[MaxTextListRows] = ::TextBold; +int& TextNum = ::TextNum; +int& ItemHelp = ::ItemHelp; +JewelHarmonyInfo*& g_pUIJewelHarmonyinfo = ::g_pUIJewelHarmonyinfo; +int& SelectedCharacter = ::SelectedCharacter; +} // namespace mu::ui::window extern int ShopInventoryStartX; extern int ShopInventoryStartY; From c3da69c52a7f38e00fc8f052bb6dba69dfcb37e2 Mon Sep 17 00:00:00 2001 From: Ezequiel Zacca Date: Thu, 22 Oct 2026 19:27:20 -0300 Subject: [PATCH 10/11] fix(items): descriptions stay in the UI's language when the item names stay English #843 makes the item database's display locale the item names' language, which is English when "Translate item names" is off. The description read its texts in that locale, so the whole description turned English with the names. It is read in the UI's language now: only the names follow the option. --- docs/item-data.md | 2 +- src/source/Engine/Object/ZzzInventory.cpp | 3 ++- tests/data/test_item_descriptions.cpp | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index a0ca961a81..3f246c231d 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -111,7 +111,7 @@ variants or option words. An online keyword need not select the same items offli | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | -| `description` | Lines the tooltip shows for the item, after its damage, defense and speed lines and before its durability, requirements and options (the original client's items still have their texts in the tooltip code, written after the description). The Dark Horse and the Dark Raven show it only in shops: elsewhere the pet window writes their tooltip. Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language) and checked like it (the English text is required, a language code has only letters, digits and `-`, and a language the game has no texts in is a warning), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there (`\n\n` leaves a blank line). The description draws at most 32 rows, which it has to itself, so a long one never takes the room of the tooltip's other lines. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | +| `description` | Lines the tooltip shows for the item, after its damage, defense and speed lines and before its durability, requirements and options (the original client's items still have their texts in the tooltip code, written after the description). The Dark Horse and the Dark Raven show it only in shops: elsewhere the pet window writes their tooltip. Its texts are in the UI's language, also when the item names stay English ("Translate item names" off). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language) and checked like it (the English text is required, a language code has only letters, digits and `-`, and a language the game has no texts in is a warning), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there (`\n\n` leaves a blank line). The description draws at most 32 rows, which it has to itself, so a long one never takes the room of the tooltip's other lines. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index a3fad4c865..e602fb560e 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -1635,7 +1635,8 @@ void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, It const int lastRow = std::min(TextNum + MaxItemDescriptionRows, static_cast(std::size(TextList))); for (const Data::Items::ItemDescriptionLine& line : definition.description) { - const std::wstring text = Core::Text::FromUtf8(line.text.Get(g_ItemDatabase.GetDisplayLocale())); + // In the UI's language, also when the item names stay English ("Translate item names" off). + const std::wstring text = Core::Text::FromUtf8(line.text.Get(I18N::GetCurrentLocale())); // A blank line is one blank piece. So is a blank line inside a text (`\n\n`, or a text that starts with // `\n`): a row left empty would end the tooltip there. const std::vector pieces = diff --git a/tests/data/test_item_descriptions.cpp b/tests/data/test_item_descriptions.cpp index 56082bf2cd..37a0f94d54 100644 --- a/tests/data/test_item_descriptions.cpp +++ b/tests/data/test_item_descriptions.cpp @@ -3,6 +3,8 @@ #include "doctest.h" #include "Data/GameData/ItemData/ItemDataValidation.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "I18N/All.h" #include "Engine/Object/ZzzInventory.h" #include @@ -129,3 +131,23 @@ TEST_CASE("Description texts are checked like item names [data][items]") CHECK(issues[3].severity == ItemDataIssueSeverity::Warning); CHECK(issues[3].field == "description[3].text"); } + +TEST_CASE("A description is in the UI's language also when the item names stay English [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + ItemDescriptionLine line = Line("It is used to increase your item level up to 6"); + line.text.Set("de", "Wird verwendet, um das Gegenstandslevel auf bis zu 6 zu erhöhen"); + jewel.description = {line}; + + // "Translate item names" off: the item names are English, the UI German. + const std::string itemNameLocale = g_ItemDatabase.GetDisplayLocale(); + const std::string uiLocale = I18N::GetCurrentLocale(); + g_ItemDatabase.SetDisplayLocale("en"); + I18N::SetLocale("de"); + const std::vector rows = WriteRows(jewel); + I18N::SetLocale(uiLocale.c_str()); + g_ItemDatabase.SetDisplayLocale(itemNameLocale); + + REQUIRE_FALSE(rows.empty()); + CHECK(rows[0].starts_with(L"Wird verwendet")); +} From b9a7430deff9229b77e180856fd6b69eeaeba247 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 11:15:35 +0200 Subject: [PATCH 11/11] fix(text): no empty line above an indented word too wide for a line A line that starts with spaces and whose first word is wider than the line was broken at those spaces, which left an empty line above the word. In a tooltip that is an extra blank row. The spaces now go with the break instead of becoming a line of their own; the text's own blank lines stay. --- src/source/Core/Text/TextLineWrap.cpp | 7 ++++++- tests/text/test_text_line_wrap.cpp | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/source/Core/Text/TextLineWrap.cpp b/src/source/Core/Text/TextLineWrap.cpp index 883b704bd0..6bf319f9ed 100644 --- a/src/source/Core/Text/TextLineWrap.cpp +++ b/src/source/Core/Text/TextLineWrap.cpp @@ -142,7 +142,12 @@ std::vector WrapTextToWidth(const std::wstring& text, int maxWidth std::wstring word = line.substr(breakAt); line.erase(breakAt); TrimTrailingSpaces(line); - lines.push_back(line); + // Only spaces before the word (an indented line): no line of + // their own, or an empty line would appear above the word. + if (!line.empty()) + { + lines.push_back(line); + } line = std::move(word); breakAt = std::wstring::npos; } diff --git a/tests/text/test_text_line_wrap.cpp b/tests/text/test_text_line_wrap.cpp index 89933f0e3e..f4684b1d96 100644 --- a/tests/text/test_text_line_wrap.cpp +++ b/tests/text/test_text_line_wrap.cpp @@ -301,3 +301,23 @@ TEST_CASE("wrap to width: a word wider than the line after a break is broken too CHECK(lines[2] == L"veluppoi"); CHECK(lines[3] == L"nts"); } + +TEST_CASE("wrap to width: an indented word wider than the line gets no empty line above it") +{ + const auto lines = WrapTextToWidth(L" masterleveluppoints", 8, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 3); + CHECK(lines[0] == L"masterle"); + CHECK(lines[1] == L"veluppoi"); + CHECK(lines[2] == L"nts"); + + // The same after a line break: the text's own blank lines stay. + const auto afterBreak = WrapTextToWidth(L"set\n masterleveluppoints\n\nend", 8, OneUnitPerCharacter()); + REQUIRE(afterBreak.size() == 6); + CHECK(afterBreak[0] == L"set"); + CHECK(afterBreak[1] == L"masterle"); + CHECK(afterBreak[2] == L"veluppoi"); + CHECK(afterBreak[3] == L"nts"); + CHECK(afterBreak[4].empty()); + CHECK(afterBreak[5] == L"end"); +}