[OTS] Lists of unique numbers kept in ‘player storage’

Friend asked me about code to keep ‘auto loot lists’ in storage. So player can configure few lists of items and then just switch between them, when he goes to other spawn with different loot.

-- config
local listsStartStorage = 550000000
local maximumListsCount = 10
local maximumElementsPerList = 100

-- variables for calculations
local listOffset = maximumElementsPerList + 1

function lists_getLists(player)
    local result = {}
    for i = 1, maximumListsCount do
        result[i] = lists_getListElementsCount(player, i)
    end

    return result
end

function lists_getListElementsCount(player, listId)
    local startStorage = listsStartStorage + listOffset * listId
    return math.max(0, player:getStorage(startStorage))
end

function lists_getListElements(player, listId)
    local result = {}
    local startStorage = listsStartStorage + listOffset * listId
    local listElementsCount = lists_getListElementsCount(player, listId)
    for i = startStorage + 1, startStorage + listElementsCount do
        table.insert(result, player:getStorage(i))
    end

    return result
end

function lists_removeAllListElements(player, listId)
    local startStorage = listsStartStorage + listOffset * listId
    local listElementsCount = lists_getListElementsCount(player, listId)
    for i = startStorage + 1, startStorage + listElementsCount + 1 do
        player:setStorage(i, -1)
    end
    player:setStorage(startStorage, -1)
end

function lists_getListElement(player, listId, elementKey)
    local elementStorage = listsStartStorage + listOffset * listId + elementKey
    return player:getStorage(elementStorage)
end

local function lists_assertListId(listId)
    assert(listId >= 1, 'minimum list id is 1')
    assert(listId <= maximumListsCount, 'maximum list id is 1' .. maximumListsCount)
end

local function lists_assertElementKey(elementKey)
    assert(elementKey >= 1, 'minimum list element id is 1')
    assert(elementKey <= maximumElementsPerList, 'maximum list element id is ' .. maximumElementsPerList)
end

-- this is for internal use only, should not be used by user
local function lists_setListElement(player, listId, elementKey, elementValue)
    lists_assertListId(listId)
    lists_assertElementKey(elementKey)
    local elementStorage = listsStartStorage + listOffset * listId + elementKey
    player:setStorage(elementStorage, elementValue)
end

-- this is for internal use only, should not be used by user
local function lists_setListElementsCount(player, listId, count)
    lists_assertListId(listId)
    lists_assertElementKey(count)
    local startStorage = listsStartStorage + listOffset * listId
    player:setStorage(startStorage, count)
end

function lists_addListElement(player, listId, elementValue)
    local elements = lists_getListElements(player, listId)
    for k, v in pairs(elements) do
        if v == elementValue then
            return
        end
    end

    local elementsCount = lists_getListElementsCount(player, listId)
    lists_setListElement(player, listId, elementsCount + 1, elementValue)
    lists_setListElementsCount(player, listId, elementsCount + 1)
end

function lists_removeListElement(player, listId, elementValue)
    local elements = lists_getListElements(player, listId)
    local elementToRemovePosition = nil
    for k, v in pairs(elements) do
        if v == elementValue then
            elementToRemovePosition = k
            break
        end
    end

    if elementToRemovePosition then
        local elementsCount = lists_getListElementsCount(player, listId)
        for i = elementToRemovePosition + 1, elementsCount  do
            local nextElementValue = lists_getListElement(player, listId, i)
            lists_setListElement(player, listId, i - 1, nextElementValue)
        end
        lists_setListElement(player, listId, elementsCount, -1)
        lists_setListElementsCount(player, listId, elementsCount - 1)
    end
end

TESTS:

Player = {}
function Player:new()
    o = {storages = {}}
    setmetatable(o, self)
    self.__index = self
    return o
end

function Player.getStorage(self, k)
    if (self.storages[k]) then
        return self.storages[k]
    end
    return -1
end

function Player.setStorage(self, k, v)
    if (v == -1) then
        v = nil
    end
    self.storages[k] = v
end

function Player.dumpStorages(self)
    local storages = 'storages: '
    for k, v in pairs(self.storages) do
        storages = storages .. k .. '=' .. v .. ', '
    end
    print(storages)
end

local function printPlayerListsElementsCount(player)
    local playerLists = lists_getLists(player)
    for k, v in pairs(playerLists) do
        local listElementsString = ''
        for k2, v2 in pairs(lists_getListElements(player, k)) do
            listElementsString = listElementsString .. ' ' .. k2 .. '=' .. v2 .. ', '
        end
        print ('list', k, 'elements', v, 'values', listElementsString)
    end
end

player = Player:new()

print()
player:dumpStorages()
print('start - empty list')
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_addListElement(player, 2, 2001)
lists_addListElement(player, 2, 2002)
print('added 2 elements to list 2')
player:dumpStorages()
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_addListElement(player, 1, 1001)
lists_addListElement(player, 1, 1002)
lists_addListElement(player, 1, 1003)
lists_addListElement(player, 1, 1004)
lists_addListElement(player, 1, 1005)
print('added 5 elements to list 1')
player:dumpStorages()
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_removeListElement(player, 1, 1001)
print('removed first element from list 1')
player:dumpStorages()
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_removeListElement(player, 1, 1005)
print('removed last element from list 1')
player:dumpStorages()
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_removeListElement(player, 1, 1003)
print('removed middle element from list 1')
player:dumpStorages()
printPlayerListsElementsCount(player)

print()
player:dumpStorages()
lists_removeAllListElements(player, 1)
print('removed all elements from list 1')
player:dumpStorages()

You can paste code and tests to online tool like: https://www.tutorialspoint.com/execute_lua_online.php

[TFS 1.x+] How to NOT write LUA scripts or how to crash server by LUA script

There are many tutorials about writing scripts for TFS 1.x, so I decided to write short tutorial how to NOT write scripts for TFS 1.x.
In this tutorial I will describe two common mistakes that result in server crash!

First thing that everyone notice when they change engine from TFS 0.x to 1.x are objects in LUA.
There are many of them, but most popular are: Player, Monster, Npc, Creature, Tile, Item, Container, Position

Thing that most scripters don’t know, is that, these objects are directly bind to address in RAM memory (to C++ objects).
If you try to use C++ object (ex. get name of player) that was deleted, it results in server crash. Bad thing about it is: you cannot check, if object was deleted in C++!
C++ programmers are used to take care of not using deleted objects. LUA scripters in all old engines always get nice error message, when they use variable that does not exist (ex. Creature not found).

There are 2 common mistakes that result in crash. Worst thing about them is that, they don’t crash server always. You can write code like that, test it, put it on your server, many players will use it.. and after week you get random crash.

PROBLEM 1

Passing object to addEvent – addEvent is function that execute given function with some delay. Example – print text after 5 seconds:

function executeMeLater()
    print('printed after 5 seconds!')
end

addEvent(executeMeLater, 5000)

You can pass arguments to function executed with delay:

function executeMeLater(param1, param2)
    print(param1)
    print(param2)
end

addEvent(executeMeLater, 5000, 'test string', 12345)

It will print after 5 seconds:

test string
12345

What is a problem with passing object to it? Object – in RAM memory – can be already deleted!
Example talkaction that crash server:

function buggedEvent1(player)
   print('Execute bugged event 1')
   print(player)
end

function buggedEvent2(hiddenPlayerObject)
   print('Execute bugged event 2')
   print(hiddenPlayerObject.myPlayerVariable:getName())
end

function onSay(player, words, param)
   print('onSay, player name: ' .. player:getName())
-- TRY 1
   addEvent(buggedEvent1, 4000, player)

-- TRY 2
   local hiddenPlayerObject = { myPlayerVariable = player }
   addEvent(buggedEvent2, 5000, hiddenPlayerObject)
   return true
end

What it does, if player stays online for 5 seconds after executing this talkaction:

onSay, player name: Gesior

Lua Script Error: [TalkAction Interface]
data/talkactions/scripts/crashAddEvent.lua:onSay
luaAddEvent(). Argument #3 is unsafe
stack traceback:
    [C]: in function 'addEvent'
    data/talkactions/scripts/crashAddEvent.lua:14: in function <data/talkactions/scripts/crashAddEvent.lua:11>
Execute bugged event 1
268435457
Execute bugged event 2
Gesior

As you can see, there is a simple protection against passing objects as arguments to addEvent. Passing it directly as argument, results in changing object Player to old ‘cid’ version (player temporary ID number).
In second event example I put object Player inside table to bypass protection code. That way I could use ‘:getName()’ on object, as it was not converted to ‘cid’ number.

What it does, if player relog within 5 seconds after executing this talkaction:

onSay, player name: Gesior

Lua Script Error: [TalkAction Interface]
data/talkactions/scripts/crashAddEvent.lua:onSay
luaAddEvent(). Argument #3 is unsafe
stack traceback:
    [C]: in function 'addEvent'
    data/talkactions/scripts/crashAddEvent.lua:14: in function <data/talkactions/scripts/crashAddEvent.lua:11>
Gesior has logged out.
Execute bugged event 1
268435456
Execute bugged event 2
Segmentation fault (core dumped)

SERVER CRASHED!
It tried to load passed Player object (use ‘:getName()’ on it). Player did relog, so given instance of Player was removed in C++.
gdb report after crash:

#0  0x0000000000000000 in ?? ()
No symbol table info available.
#1  0x000055d4fed50615 in LuaScriptInterface::luaCreatureGetName(lua_State*) ()
No symbol table info available.
#2  0x00007f8865d55e37 in ?? () from /usr/lib/x86_64-linux-gnu/libluajit-5.1.so.2
No symbol table info available.
#3  0x00007f8865da327c in lua_pcall () from /usr/lib/x86_64-linux-gnu/libluajit-5.1.so.2
No symbol table info available.
#4  0x000055d4fedc9274 in LuaScriptInterface::protectedCall(lua_State*, int, int) ()
No symbol table info available.
#5  0x000055d4fedca368 in LuaScriptInterface::callFunction(int) ()
No symbol table info available.
#6  0x000055d4fed39af2 in LuaEnvironment::executeTimerEvent(unsigned int) ()
No symbol table info available.
#7  0x000055d4fed1eb6e in void std::__invoke_impl<void, void (LuaEnvironment::*&)(unsigned int), LuaEnvironment*&, unsigned int&>(std::__invoke_memfun_deref, void (LuaEnvironment::*&)(unsigned int), LuaEnvironment*&, unsigned int&) ()
No symbol table info available.
#8  0x000055d4fed1bcac in std::__invoke_result<void (LuaEnvironment::*&)(unsigned int), LuaEnvironment*&, unsigned int&>::type std::__invoke<void (LuaEnvironment::*&)(unsigned int), LuaEnvironment*&, unsigned int&>(void (LuaEnvironment::*&)(unsigned int), LuaEnvironment*&, unsigned int&) ()
No symbol table info available.
#9  0x000055d4fed16be2 in void std::_Bind<void (LuaEnvironment::*(LuaEnvironment*, unsigned int))(unsigned int)>::__call<void, , 0ul, 1ul>(std::tuple<>&&, std::_Index_tuple<0ul, 1ul>) ()
No symbol table info available.
#10 0x000055d4fed1048d in void std::_Bind<void (LuaEnvironment::*(LuaEnvironment*, unsigned int))(unsigned int)>::operator()<, void>() ()
No symbol table info available.
#11 0x000055d4fed09916 in std::_Function_handler<void (), std::_Bind<void (LuaEnvironment::*(LuaEnvironment*, unsigned int))(unsigned int)> >::_M_invoke(std::_Any_data const&) ()
No symbol table info available.
#12 0x000055d4febfaec8 in std::function<void ()>::operator()() const ()
No symbol table info available.
#13 0x000055d4febfa9fe in Task::operator()() ()
No symbol table info available.
#14 0x000055d4febfac2c in Dispatcher::threadMain() ()

PROBLEM 2

Keeping object in variable for future use in script.
There is example of common usage in script – keeping last player who executed script (who went into some room, who used some lever):

local lastPlayer = nil

function onSay(player, words, param)
   print('onSay, player name: ' .. player:getName())
   if lastPlayer ~= nil then
      print('onSay, last player name: ' .. lastPlayer:getName())
   end
   lastPlayer = player
   return true
end

You can execute this code as many times you want and it will work fine.. until you relog or player that used it as last, logout and someone else execute it.
gdb report after crash:

#0  0x00007ff55a471d20 in ?? ()
No symbol table info available.
#1  0x000055dffa717615 in LuaScriptInterface::luaCreatureGetName(lua_State*) ()
No symbol table info available.
#2  0x00007ff560fd0e37 in ?? () from /usr/lib/x86_64-linux-gnu/libluajit-5.1.so.2
No symbol table info available.
#3  0x00007ff56101e27c in lua_pcall () from /usr/lib/x86_64-linux-gnu/libluajit-5.1.so.2
No symbol table info available.
#4  0x000055dffa790274 in LuaScriptInterface::protectedCall(lua_State*, int, int) ()
No symbol table info available.
#5  0x000055dffa791368 in LuaScriptInterface::callFunction(int) ()
No symbol table info available.
#6  0x000055dffa5f8554 in TalkAction::executeSay(Player*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, SpeakClasses) const ()
No symbol table info available.
#7  0x000055dffa5f81c1 in TalkActions::playerSaySpell(Player*, SpeakClasses, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) const ()
No symbol table info available.
#8  0x000055dffa827c4f in Game::playerSaySpell(Player*, SpeakClasses, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) ()
No symbol table info available.
#9  0x000055dffa827910 in Game::playerSay(unsigned int, unsigned short, SpeakClasses, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) ()
No symbol table info available.

How to make scripts safe? Pass cid/guid/itemid/position, not object.
Problem 1 script – safe version:

function buggedEvent1(player)
   print('Execute bugged event 1')
   print(player)
end

function buggedEvent2(hiddenPlayerObject)
   print('Execute bugged event 2')
   -- FIX: TRY TO CREATE OBJECT 'PLAYER' FROM IT'S CID
   local myPlayer = Player(hiddenPlayerObject.myPlayerVariable)
   -- FIX: IT WILL CREATE OBJECT 'PLAYER' OR RETURN 'nil'
   if myPlayer then
      -- FIX: 'if' CHECKED THAT 'myPlayer' IS NOT 'nil',
      -- SO CREATING OBJECT 'PLAYER' WORKED
      -- WE CAN USE 'getName'
      print(myPlayer:getName())
   end
end

function onSay(player, words, param)
   print('onSay, player name: ' .. player:getName())
   -- TRY 1
   addEvent(buggedEvent1, 4000, player)

   -- TRY 2
   -- FIX: PASS ID (cid) OF PLAYER, NOT OBJECT
   local hiddenPlayerObject = { myPlayerVariable = player:getId() }
   addEvent(buggedEvent2, 5000, hiddenPlayerObject)
   return true
end

Problem 2 script – safe version:
Version with player ID (in old scripts called ‘cid’). It’s temporary player ID. It changes when player relog/die:

local lastPlayerCid = nil

function onSay(player, words, param)
   print('onSay, player name: ' .. player:getName())
   -- FIX: TRY TO CREATE OBJECT 'PLAYER' FROM IT'S CID
   local lastPlayer = Player(lastPlayerCid)
   -- FIX: IT WILL CREATE OBJECT 'PLAYER' OR RETURN 'nil'
   if lastPlayer then
      print('onSay, last player name: ' .. lastPlayer:getName())
   end
   -- FIX: STORE ID OF PLAYER (cid), NOT OBJECT
   lastPlayerCid = player:getId()
   return true
end

Version with player GUID. It’s player ID from database. It never changes.

local lastPlayerGuid = nil

function onSay(player, words, param)
   print('onSay, player name: ' .. player:getName())
   -- FIX: TRY TO CREATE OBJECT 'PLAYER' FROM IT'S GUID
   local lastPlayer = Player(lastPlayerGuid)
   -- FIX: IT WILL CREATE OBJECT 'PLAYER' OR RETURN 'nil'
   if lastPlayer then
      print('onSay, last player name: ' .. lastPlayer:getName())
   end
   -- FIX: STORE ID OF PLAYER (cid), NOT OBJECT
   lastPlayerGuid = player:getGuid()
   return true
end

There are 2 main reasons for using ID version:

  • It’s sometimes useful to know that player did relog/die (change ID). If he went to no-logout zone and his ID changed, it means he died.
  • It also works for Monster, Npc and Creature objects. You can store ID of monster/npc/player and load it same way – some LUA scripts work for monsters (ex. movements).

There is one reason for using GUID:

  • Some scripts need to know if player is online, even if he did relog.

[TFS 1.2+] Give item on level advance

Someone asked me on Discord about script that gives configurable rewards on level advance.
It’s onAdvance event. Rewards are easy configurable and can be limited by vocations and level.

local rookVocations = { 0 }
local sorcererVocations = { 1, 5 }
local druidVocations = { 2, 6 }
local paladinVocations = { 3, 7 }
local knightVocations = { 4, 8 }
local mainVocations = { 1, 2, 3, 4, 5, 6, 7, 8 }
local allVocations = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }

local rewardsConfig = {
    -- 2cc and Magic Sword for every 20 level on main
    {
        level = 20,
        vocations = mainVocations,
        storage = 25100,
        items = {
            { 2160, 2 },
            { 2400, 1 },
        }
    },
    -- wand of inferno for 33 sorc
    {
        level = 33,
        vocations = sorcererVocations,
        storage = 25101,
        items = {
            { 2187, 1 },
        }
    }
}

function onAdvance(player, skill, oldLevel, newLevel)
    if skill ~= SKILL_LEVEL then
        return true
    end

    for i1, rewardConfig in pairs(rewardsConfig) do
        if newLevel == rewardConfig.level then
            if table.contains(rewardConfig.vocations, player:getVocation():getId()) then
                if player:getStorageValue(rewardConfig.storage) < 1 then
                    player:setStorageValue(rewardConfig.storage, os.time())
                    for i2, item in pairs(rewardConfig.items) do
                        player:addItem(item[1], item[2])
                    end
                    player:getPosition():sendMagicEffect(CONST_ME_CRAPS)
                    player:sendTextMessage(MESSAGE_INFO_DESCR, "You received reward for getting " .. rewardConfig.level .. " level.")
                end
            end
        end
    end

    return true
end

TFS – Jak zrobić skrypt Loop, żeby wykonywał się co sekundę przez określoną w skrypcie ilość razy?

W językach skryptowych nie ma znanego z innych języków polecenia ‘sleep’, żeby poczekać np. sekundę – to zatrzymało by działanie całego serwera gry.
W takich językach najczęściej dostępne są funkcje do ‘planowania zdarzeń’ – to znaczy specjalna lista gdzie są spisane funkcje i czas po jakim mają być wykonane.

Przykład w LUA w serwerze gry TFS 1.3 (funkcja do tego służąca: addEvent ).
Do funkcji addEvent można przekazywać parametry, ale nie mogą to być obiekty zdefiniowane w silniku TFS jak Player, Creature, Item itp. – może to doprowadzić do crasha serwera i każde takie użycie generuje komunikat w konsoli.

Ta funkcja (akcja do dodania w actions) wykona się 3 sekundy po użyciu przedmiotu i wyświetli animacje ‘POFF’ na pozycji gracza.
Zamiast przekazywać jako parametr gracza (obiekt Player), przekazujemy jego ID (numer), a w momencie wykonania ‘zdarzenia’ zamieniamy go ponownie na obiekt Player.

function wyslijAnimacjeNaPozycjeGracza(playerId)
   -- zamieniamy liczbę playerId w obiekt player
   local player = Player(playerId)
   -- UWAGA: gracz moze w tym czasie zginac/wylogowac sie, wiec zawsze sprawdzamy czy udalo sie utworzyc obiekt 'player'
   if player then
      player:getPosition():sendMagicEffect(CONST_ME_POFF)
   end
end

function onUse(player, item, fromPosition, target, toPosition, isHotkey)
   addEvent(wyslijAnimacjeNaPozycjeGraczaZaSekunde, 3000, player:getId())
   return true
end

Ta funkcja zostanie wykonana 2 razy w odstępach sekundy:

function twojaFunkcjaDoWykonaniaKilkaRazy(iloscPowtorzen, czasMiedzyPowtorzeniami)
    -- kod odpowiadajacy za powtarzanie
    iloscPowtorzen = iloscPowtorzen - 1
    if (iloscPowtorzen > 0 ) then
        -- parametry przekazywane do funkcji 'addEvent' to kolejno:
        -- 1: nazwa funkcji ktora ma sie wykonac
        -- 2: czas po jakim ma sie wykonac, 1000 = 1 sekunda
        -- 3 i kolejne - sa opcjonalne - wszystkie kolejne parametry zostana przekazane do wykonywanej funkcji w takiej kolejnosci jak zostaly podane
        addEvent(twojaFunkcjaDoWykonaniaKilkaRazy, czasMiedzyPowtorzeniami, iloscPowtorzen, czasMiedzyPowtorzeniami)
    end

    -- kod ktory ma sie wykonywac (twoj wlasny):
    print('zostalo powtorzen:', iloscPowtorzen)
end

twojaFunkcjaDoWykonaniaKilkaRazy(2, 1000, 'testowy test', 12345)

Przykład z tą samą funkcją, ale teraz z przekazaniem parametrów. W funkcji wykonywanej ‘później’ nie masz dostępu do zmiennych które są dostępne w ‘aktualnie’ wykonywanym skrypcie (tym który ‘planuje’ wykonanie funkcji), więc trzeba je przekazać.

function twojaFunkcjaDoWykonaniaKilkaRazyZParametrami(iloscPowtorzen, czasMiedzyPowtorzeniami, parametr1, parametr2)
   -- kod odpowiadajacy za powtarzanie
   iloscPowtorzen = iloscPowtorzen - 1
   if (iloscPowtorzen > 0 ) then
      addEvent(twojaFunkcjaDoWykonaniaKilkaRazyZParametrami, czasMiedzyPowtorzeniami, iloscPowtorzen, czasMiedzyPowtorzeniami, parametr1, parametr2)
   end

   -- kod ktory ma sie wykonywac:
   print('parametr 1 ma wartosc:', parametr1)
   print('parametr 2 ma wartosc:', parametr2)
end

twojaFunkcjaDoWykonaniaKilkaRazyZParametrami(5, 1000, 'testowy test', 12345)

LUA BASICS for other language programmers

What do you need to know to start scripting in LUA?

1: 0 is true!

local v = 0
if v then
    print('YES! ZERO IS TRUE!')
    print('That is why many functions return "nil" (which is false)')
end

2: elseif != else if

if something then
    print(1)
else if somethingOther then
    print(2)
end

ERROR?! WHAT?! YES!
In LUA else if is one word elseif.
As there are no one-line ifs without end, you need to write:

if something then
    print(1)
elseif somethingOther then
    print(2)
end

or:

if something then<
    print(1)
else
    if somethingOther then
        print(2)
    end
end

Multi-line formatting is ignored, whole LUA program can be in 1 line!

3: Tables are indexed from 1!

Tables/arrays are indexed from 1, not 0!

local table = {1, 2, 3, 4, 5}
for k, v in pairs(table) do
    print(k .. ' = ' .. v)
end
print(table[0])

Result:

1 = 1
2 = 2
3 = 3
4 = 4
5 = 5
nil -- reading not assigned index returns magic "nil" from point 1

OTC Module – Debug UI

When you create new module, you always got problems with OTUI design. Why X element is on that position? What am I clicking?

I can’t solve all these problems, but my module can help you debug your UI without milion prints to console.

With this module you can easily view tree of elements on given position (under mouse cursor):

It also works before login:

modules/client_ui_debug/client_ui_debug.otmod

Module
  name: client_ui_debug
  description: Draws tree of widgets on mouse position
  author: Gesior.pl
  reloadable: false
  sandboxed: true
  scripts: [ client_ui_debug ]
  @onLoad: init()
  @onUnload: terminate()

modules/client_ui_debug/client_ui_debug.otui

HighlightWidget < Panel
  id: highlightWidget
  background-color: #AA000099
  focusable: false
  phantom: true

Panel
  id: clientUiDebug
  anchors.top: parent.top
  anchors.left: parent.left
  anchors.right: parent.right
  height: 20
  margin-top: 1
  focusable: false

  UILabel
    id: clientUiDebugLabel
    color: #FF7777
    background-color: #00000099
    anchors.fill: parent
    text-align: left
    text-auto-resize: false
    padding: 2
    font: verdana-11px-antialised

modules/client_ui_debug/client_ui_debug.lua

local clientUiDebug
local clientUiDebugLabel
local clientUiDebugHighlightWidget

function onClientUiDebuggerMouseMove(mouseBindWidget, mousePos, mouseMove)
    local widgets = rootWidget:recursiveGetChildrenByPos(mousePos)

    local smallestWidget
    for _, widget in pairs(widgets) do
        if (widget:getId() ~= 'highlightWidget' and widget:getId() ~= 'toolTip') then
            if (not smallestWidget or
                    (widget:getSize().width <= smallestWidget:getSize().width and widget:getSize().height <= smallestWidget:getSize().height)
            ) then
                smallestWidget = widget
            end
        end
    end
    if smallestWidget then
        clientUiDebugHighlightWidget:setPosition(smallestWidget:getPosition())
        clientUiDebugHighlightWidget:setSize(smallestWidget:getSize())
        clientUiDebugHighlightWidget:raise()
    end

    local widgetNames = {}
    for wi = #widgets, 1, -1 do
        local widget = widgets[wi]
        if (widget:getId() ~= 'highlightWidget') then
            table.insert(widgetNames, widget:getClassName() .. '#' .. widget:getId())
        end
    end
    clientUiDebugLabel:setText(table.concat(widgetNames, " -> "))
end

function init()
    connect(rootWidget, {
        onMouseMove = onClientUiDebuggerMouseMove,
    })
    clientUiDebug = g_ui.displayUI('client_ui_debug')
    clientUiDebugLabel = clientUiDebug:getChildById('clientUiDebugLabel')
    clientUiDebugHighlightWidget = g_ui.createWidget('HighlightWidget', rootWidget)
end

function terminate()
    disconnect(rootWidget, {
        onMouseMove = onClientUiDebuggerMouseMove,
    })
    clientUiDebug:destroy()
    clientUiDebugHighlightWidget:destroy()
end

at end of modules list in modules/client/client.otmod add:

- client_ui_debug