Repository navigation
test(items): every item's tooltip in a test, against recorded hashes - #822
Conversation
The tooltip lines of every item now build in a test, without the game's
windows or fonts:
- Items are made with FillItemFromParameters, the part of the item
manager's CreateItemByParameters that needs no item list or key.
- Without the inventory window an item counts as worn (as an item the
inventory grid does not hold always did).
- A test can measure description text with a fixed width
(SetItemTooltipTextMeasure) instead of the game's font.
- The test loads the game data the tooltip reads as the game does:
skills, socket items, item sets, monster names, the Jewel of Harmony
and add option tables, and the buff system's time texts.
"Every item's tooltip is as recorded" builds the 60,736 tooltips of
tooltip-snapshot for two heroes (a new Dark Wizard, a level 400 Blade
Master) and compares a hash per item with item_tooltip_snapshot.txt;
a change prints the item's new lines. MU_TOOLTIP_SNAPSHOT_UPDATE=1
records the hashes after a change that is meant. The snapshot code is
shared with the control socket's tooltip-snapshot
(Engine/Object/ItemTooltipSnapshot).
The test's snapshot equals the game's for the same hero, apart from the
wrapped description lines (the font). That comparison found one bug: a
Transformation Ring above +5 read past SommonTable (six monsters, +0 to
+5), so its name was whatever memory held there ("Bull Fighter" at +7 in
the game, another monster in the test). Such a ring has no monster, and
so an empty name, as at the other levels above +5.
…ltip-context # Conflicts: # tests/data/CMakeLists.txt
Main dropped the 38 "-J" items and renamed the elf (x,50) and Dark Lord lucky (x,66) sets, so the snapshot records 911 items and their new tooltips.
…ltip-context # Conflicts: # src/source/Engine/Object/ZzzInventory.cpp
…ave is defined The tooltip snapshot draws every item at every level. The Weapon of Archangel (13/19) exists at +0 to +2, and the original client's code for it set its name, attack speed and requirements only for those levels: at another level the name row kept what an earlier tooltip left there, and the three values were whatever the stack held. So the snapshot recorded undefined values, and the test failed in about half of its runs (11 of 20 after current main changed the stack). At another level the item now has its own name and 0 for the three values; 20 of 20 runs pass.
…list The event item 14/12 is named at +2 after a prize, ChaosEventName[durability]; a durability past its 10 prizes read past the list, and the tooltip had no name at other levels (the row an earlier tooltip left). The tooltip snapshot, which draws every level with full durability, recorded what happened to be there, so macOS and Windows differed. Past the list or at another level the tooltip names the item itself; GetItemName leaves the name empty there, so its callers' fallback writes the name with " +level" as before (sven-n#840's helper filters check that).
The client reads the item set names of ItemSetOption_<lang>.bmd as UTF-8, but eight weren't: the English
"Chrono's" and "Semeden's" had their typographic apostrophe in the Korean code page (CP949 A1 AF), and four Spanish
and two Portuguese names had their accents in Windows-1252. Windows showed replacement characters ("Chrono??s",
"de An?nimo"), and macOS and Linux an empty name, which made the tooltip snapshot differ between platforms. The
names are UTF-8 now (Chrono's, Semeden's, anonimo/nevoa, Anonimo/Hiperion/Energumeno/Odin with their accents), the
files' checksums written again; nothing else in the files changed. The snapshot is recorded again for these items
(the ancient Red Wing pieces) and the event item 14/12.
…so macOS writes non-Latin-1 names
sven-n
left a comment
There was a problem hiding this comment.
Snapshot test gap: src/source/Engine/Object/ItemTooltipSnapshot.cpp, WriteItemTooltipSnapshot creates a Cartesian product of every item and plain/full/ancient/harmony variants at every level. Most combinations are not valid in normal gameplay (e.g. excellent options on items without excellent options), while important real-world variants such as different durability values, timed items, and individual option flags are not represented by the four default variants. Since this snapshot is intended to catch behavioral regressions while moving tooltip rules to JSON, please explicitly cover representative supported variants and add targeted assertions for conditional lines (not only a hash of the default combinations). In particular, a tooltip regression that only appears at low durability would not be detected by this baseline. Reviewed against AGENTS.md / CODING_RULES.md; tests not run; this is a coverage concern, not a demonstrated bug.
…ltip-context # Conflicts: # src/source/App/Control/ControlCommandsQuery.cpp
…ltip-context # Conflicts: # src/source/App/Control/ControlCommandsQuery.cpp
…ken and expired items The four variants (plain, full, ancient, harmony) showed no line that depends on one option alone, on the durability or on the expiry. The snapshot adds luck, skill, the +1 option and one excellent option alone, durability 1 and 0 (a stack's count too) and expired. A test checks the durability, count and expiry lines themselves, and the timed item's end date, which depends on the time zone and so stays out of the snapshot.
… texts written out
|
Thanks, agreed. Both parts are in 72eca0f (plus 6df23b2):
On the combinations that can't happen in the game, such as excellent options on an item that has none: I kept them. They don't cost much, and they record what the tooltip does with whatever data the server sends. Every later PR in the stack is re-recorded with the eleven variants. For each one, I compared which items change against the old four-variant recording, and the sets are the same. Where a whole snapshot changed (#825 added its own two variants, #829 changed how blank lines are written), I diffed the full tooltip text instead. Only the changes those PRs describe show up. #825's own |
…ltip-context # Conflicts: # tests/data/CMakeLists.txt
…ip-context # Conflicts: # docs/control-socket.md # src/source/App/Control/ControlCommandsQuery.cpp
OpenMonsterScript appended to MonsterScript with a count it never reset, so a second load wrote 678 more names after the first 678, past the 1024-row table, into the globals after it. The game loads the names once, but the tooltip tests load them in two test cases: in a random order where "A tooltip's durability, count and expiry lines follow the item" ran first, "Every item's tooltip is as recorded" crashed. The load now starts the count at zero and stops at the end of the table.
|
Caught up with main now that #821 is in (
|
Mosch0512
left a comment
There was a problem hiding this comment.
Verdict: changes requested for one item; the rest is non-blocking. A test of every tooltip is the right safety net for 5b, and it holds up well:
- The recording is sound. I ran the snapshot test in a Windows Debug build, with a local guard for the item below. Every recorded hash matched your Release recording except 13/70, for both heroes, so the hashes don't depend on the build type.
- The set-file fix is right. I decoded the old and new
itemsetoption_*.bmd. Only the 8 names changed, each is now valid UTF-8 and fits its 64-byte field, every other byte of the records is the same, and all six checksums are valid. That includes Portuguese and Spanish, which the test doesn't load. - The Transformation Ring fix covers every use of
SommonTable; all of them now go throughIsTransformationRingMonster. - Other fixes: the Weapon of Archangel's values are now initialized, and the stack items' key 0 never matches an inventory item, since keys start at 1.
- The monster names: I found the same
OpenMonsterScriptoverflow, and e9491df fixes it: the count starts at zero and stops at the table's end. Thanks for catching it. - #821's fixes survived the merge into
WriteItemTooltipSnapshot.
Required
- The Talisman of Mobility's "durability 1" line asserts in Debug and records a leftover row in Release (inline, ItemTooltipSnapshot.cpp:59).
Non-blocking
- The expiry date is read through a cast that includes
RefCounton Windows; the new test can't see it (inline, test_item_tooltips.cpp:356). TooltipDataScopeleaves the item sets, socket items and monster names loaded (inline, test_item_tooltips.cpp:73).- The new file's free functions aren't in a namespace, as CODING_RULES.md asks for new files (inline, ItemTooltipSnapshot.h:10).
- The snapshot test keeps every tooltip's text in memory (inline, test_item_tooltips.cpp:240).
SetItemTooltipTextMeasuresplitsBuildItemTooltipLinesfrom its comment (inline, ZzzInventory.h:239).GetItemNameleaves raw buffers unwritten for a prize past the list (inline, ZzzInventory.cpp:1189).
What I checked:
- the snapshot and durability tests, alone and in whole-binary runs of
test_item_database_files, in file order and in random orders (Debug); - the set files, decoded byte by byte, with the game's checksum;
- every use of
SommonTable, and how the builder reads the portal position and the expiry time; - what
TooltipDataScopeloads and what it restores.
Built on Windows x64 Debug at f6896ff, before the merge of main and e9491df; I read e9491df but didn't rebuild after it.
| params.ExcellentFlags = 1; | ||
| }}, | ||
| // A worn and a broken item; for a stack, its count (one, none). | ||
| {"durability 1", [](ItemCreationParams& params) { params.Durability = 1; }}, |
There was a problem hiding this comment.
Required. With this variant, the Talisman of Mobility (13/70) at durability 1 reaches g_PortalMgr.GetPortalPositionText(TextList[TextNum]) (ZzzInventory.cpp:3550). That function asserts when no position is saved, and a test never has one (PortalMgr.cpp:97).
- Debug: the assert aborts the test binary. "Every item's tooltip is as recorded" ends with SIGABRT in every run, and every test after it is skipped.
- Release: the assert is compiled out and nothing is written, so the row keeps whatever the previous tooltip left there. The recorded hash for 13/70 contains another tooltip's line. With a local guard, my Debug snapshot matched every recorded hash except 13/70, for both heroes.
The Talisman of Resurrection's revive-position line (ZzzInventory.cpp:2621) checks g_PortalMgr.IsRevivePositionSaved() first. Guarding this line with g_PortalMgr.IsPortalPositionSaved() the same way and re-recording 13/70 fixes both.
There was a problem hiding this comment.
Fixed in 3d3e7ec: the position line is now guarded by g_PortalMgr.IsPortalPositionSaved(), like the Talisman of Resurrection's. I re-recorded the snapshot: only 13/70 changed, for both heroes (two rows in item_tooltip_snapshot.txt), which matches your Debug run.
| kris.IsExpired = false; | ||
| ITEM timed; | ||
| FillItemFromParameters(timed, kris); | ||
| timed.lExpireTime = 1767225600; // 2026-01-01 00:00 UTC |
There was a problem hiding this comment.
Non-blocking, pre-existing. The builder reads this value as localtime((time_t*)&(ip->lExpireTime)) (ZzzInventory.cpp:1730). On Windows, long is 4 bytes and time_t 8, and ITEM is packed, so the cast also reads the next field, int RefCount, as the time's high half.
This test's item comes from FillItemFromParameters, which leaves RefCount at 0, so it sees the right date. An item from the item manager has RefCount 1, so in the game a timed item's "Expiration Day" would show a date about 136 years late (2^32 seconds). The test says it computes the date "as the game does", but it can't see the difference. const time_t expireTime = ip->lExpireTime; ExpireTime = localtime(&expireTime); fixes it.
There was a problem hiding this comment.
Fixed in 3d3e7ec, in the builder: const time_t expireTime = ip->lExpireTime; ExpireTime = localtime(&expireTime);. The test's timed item now comes from a CItemMng (CreateItemByParameters), so it has RefCount 1 like a game item. I put the old cast back to check: the date line then fails.
| // transformation rings), the Jewel of Harmony and item add | ||
| // option tables, and the buff system's time texts. Run in a ClientDataScope, which makes the paths relative to the | ||
| // client. | ||
| class TooltipDataScope |
There was a problem hiding this comment.
Non-blocking. The scope restores the harmony and add-option pointers, the buff system and the skill table it made, and the language. But the item sets (g_csItemOption), the socket items (g_SocketItemMgr) and the monster names stay loaded after the test. When another test had already set SkillAttribute, the skills are also loaded into that test's array.
Later tests then see data they didn't load; for example, the usage test's comment "No set file is loaded here, so the set numbers are all 0" no longer holds after these tests. It passed in my runs, but leftovers like these caused the order-dependent failures earlier in the stack (#847, #854). Restoring them, or noting in the comment that these tests load them for good, keeps it visible.
There was a problem hiding this comment.
In 3d3e7ec I went with the comment: the scope's comment now lists what it restores and says the item sets, socket items and monster names stay loaded, since the game has no way to unload them and each load replaces what was there (with e9491df, the monster names too). I reworded the usage test's comment so it no longer assumes no set file is loaded; the Kris is in no ancient set either way.
| #include <vector> | ||
|
|
||
| // An item as the server would send it: the fields not set are 0, the durability is full. | ||
| ItemCreationParams FullItemParameters(int group, int number); |
There was a problem hiding this comment.
Non-blocking. These are free functions extracted from ControlCommandsQuery.cpp into a new file. CODING_RULES.md, "Namespaces and file names for extracted free functions (new files only)", asks for them to be wrapped in a <Layer>::<Concern> namespace, e.g. Engine::Items::TooltipSnapshot.
There was a problem hiding this comment.
Done in 3d3e7ec: FullItemParameters, TooltipOfParameters, WriteItemTooltipSnapshot and the new VisitItemTooltipSnapshots are in Engine::Items::TooltipSnapshot; the control commands and the tests call them qualified.
| SetItemTooltipTextMeasure(&FixedWidthMeasure); | ||
|
|
||
| std::map<std::string, std::string> hashes; | ||
| std::map<std::string, std::string> tooltips; |
There was a problem hiding this comment.
Non-blocking. tooltips keeps the full text of every item's tooltips for both heroes: 2 × 911 × 11 × 16 = 320,672 tooltips. Each hero's ostringstream holds them once more, and all of that is only to print up to five changed items on failure. Hashing each item as it is written, and building the text again only for the items that changed, would keep the memory flat. The test takes 16 to 20 s alone in a Debug build.
There was a problem hiding this comment.
Done in 3d3e7ec: VisitItemTooltipSnapshots hands out one item's text at a time (WriteItemTooltipSnapshot is built on it). The test loads the recorded hashes first, hashes and compares each item as it comes, and keeps the text only for the first five changed items, so it holds one item's tooltips at a time.
| // no tooltip or the pet manager draws its own (the Dark Lord's pets). | ||
| // How the tooltip measures a description piece for wrapping: the game's font (g_pRenderText), unless a test sets | ||
| // a measure of its own, which needs no font; nullptr puts the font back. | ||
| void SetItemTooltipTextMeasure(ItemTooltipTextMeasure measure); |
There was a problem hiding this comment.
Non-blocking. This declaration was inserted between BuildItemTooltipLines' comment (line 235) and its declaration. That comment now reads as a second comment of SetItemTooltipTextMeasure, and BuildItemTooltipLines has none. In the .cpp, RenderItemInfo's comment "(sx, sy) in the screen's 640x480 stretch" also sits orphaned above s_itemTooltipTextMeasure.
There was a problem hiding this comment.
| case 2: mu_swprintf(Text, L"%ls", ChaosEventName[p->Durability]); break; | ||
| case 2: | ||
| // The prize by the item's durability; none past the list, so the caller's fallback names the item. | ||
| if (p->Durability < std::size(ChaosEventName)) |
There was a problem hiding this comment.
Non-blocking. For a prize past ChaosEventName, nothing is written now, and the comment counts on "the caller's fallback". Only the std::wstring overload has one, though. The raw-buffer callers (WSclient.cpp, MixMgr.cpp, QuestMng.cpp, NPCQuest.cpp) keep whatever their buffer held. The tooltip writes p->Name in the same case, and doing the same here keeps the two alike. The shipped 14/12 has durability 0, so this is only reachable with custom data.
There was a problem hiding this comment.
Done in 3d3e7ec: past the list it writes p->Name, as the tooltip does, so the raw-buffer callers get the item's name too.
- The Talisman of Mobility shows its saved position only when one is saved, as the Talisman of Resurrection does; with none, GetPortalPositionText asserted in Debug and left another tooltip's row in Release. 13/70's hash is recorded again (both heroes; nothing else changed). - A timed item's end date copies the expiry time into a time_t instead of reading it through a cast, which on Windows took the item's reference count as the time's high half. The test's timed item now comes from the item manager, so it has that reference count, and fails with the old cast. - The snapshot helpers are in Engine::Items::TooltipSnapshot, and VisitItemTooltipSnapshots hands out one item at a time: the test hashes and compares each item as it is written and keeps only the first changed ones. - A prize past ChaosEventName gets the item's own name, as the tooltip shows. - The tooltip test data scope says what stays loaded after it; the comments of SetItemTooltipTextMeasure and BuildItemTooltipLines are back in place.
|
Thanks for the thorough review. Pushed 3d3e7ec:
|
…ip-context # Conflicts: # src/source/Engine/Object/ZzzInfomation.cpp # src/source/Engine/Object/ZzzInventory.cpp
|
Merged main again (
|
This step's own changes: compare with #821. The diff of this PR also holds every earlier step of #815, since a PR from a fork can only target this repository's branches.
Summary
Step 5b-1, continued, of #815 (data-driven item tooltips; design). Stacked on #821 (→ #820 → … → #813): only the last commit,
test(items): every item's tooltip in a test, against recorded hashes, is this step.#821 records every item's tooltip in the game through the control socket. This PR does the same in a test, so CI checks every tooltip before the stat lines move into data (5b-3).
What the test needed
I ran the tooltip builder in a test and gave it what it reached for. It needed less than a full context:
CharacterAttribute/Hero. A test points them at its own hero, astest_item_stats.cppalready does.FillItemFromParametersis the part of the item manager'sCreateItemByParametersthat needs no item list or key, and the manager calls it.SetItemTooltipTextMeasurelets a test use a fixed width instead.The test
"Every item's tooltip is as recorded" builds the 60,736 tooltips of
tooltip-snapshotfor two heroes:It compares one hash per item with
tests/data/item_tooltip_snapshot.txt(1,898 lines, 66 KB). When an item changes, the test prints its new lines:(That's the Jewel of Bless's description changed by hand to check the test; the change isn't part of this PR.) After a change that's meant,
MU_TOOLTIP_SNAPSHOT_UPDATE=1records the new hashes, and the PR's diff of the file shows which items changed. The snapshot code is shared withtooltip-snapshot(Engine/Object/ItemTooltipSnapshot), so the game's lines are one request away for comparison.Checked against the game
For the same hero (test0Dw on a local OpenMU), the test's snapshot equals the game's, apart from the wrapped description lines (the font). That comparison found one bug:
SommonTablehas the six monsters of +0 to +5, and a higher level read past its end. The name was then whatever memory held there: "Bull Fighter" at +7 and +13 in the game, and other monsters in the test.IsTransformationRingMonsterchecks the level. Such a ring has no monster, so its name is empty, as it already was at the other levels above +5.Apart from that fix, the game's snapshots of both heroes before and after this PR are identical.
Related issues
Step 5b-1 of #815. Builds on #821, #820, #819, #818, #817, #816 and #813.
Checklist
docs/CODING_RULES.md.docs/build/README.md).