From a2476cfb72ee154355d7c4ddc90863f103961f0d Mon Sep 17 00:00:00 2001 From: coldmix <3292347+coldmix@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:10:55 +0800 Subject: [PATCH 1/3] Add gearstats addon Add gearstats addon to quickly add all the visible stats on gear for quick comparison. --- addons/gearstats/README.md | 153 ++++++++ addons/gearstats/gearstats.lua | 628 +++++++++++++++++++++++++++++++ addons/gearstats/json_helper.lua | 254 +++++++++++++ 3 files changed, 1035 insertions(+) create mode 100644 addons/gearstats/README.md create mode 100644 addons/gearstats/gearstats.lua create mode 100644 addons/gearstats/json_helper.lua diff --git a/addons/gearstats/README.md b/addons/gearstats/README.md new file mode 100644 index 000000000..8086c8e79 --- /dev/null +++ b/addons/gearstats/README.md @@ -0,0 +1,153 @@ +# gearstats + +## English + +Print the current equipment stats or save to file for simple comparison. +It just extract the stats for each piece of equipment from the item_descriptions.lua and extdata and add them up. +For information such as `Sets:`, it will assigned a number 1 to each appearance and add them up +Example: + + TP not depleted when weapon skill used:2 + Set - Increases Accuracy, Ranged Accuracy, and Magic Accuracy:1 + Set - Augments Double Attack:1 + +The "TP not depleted when weapon skill used" appears on Fotia Gorget and Belt, the number is 2 because it appears twice +For the "Set - Increases Accuracy, Ranged Accuracy, and Magic Accuracy:1", only 1 piece of AF3 was equipped so it count as 1. +Similarly "Set - Augments Double Attack:1" indicates only 1 piece of WAR Empy was equipped + +### Limitations + +It doesn't aggregate information not directly obtained via extdata. +These are encoded as Path and Rank in extdata, so there is no direct way to read the stats they provide. +Use `//gearstats debug` to see the full structure of information + + "waist" : { + "count" : 1, + "description" : ["Haste+9% \"Triple Attack\"+2%", "Unity Ranking: Attack+10~15"], + "extdata" : { + "augment_system" : 4, + "augments" : { + "1" : "Path: A" + }, + "path" : "A", + "rank" : 15, + "type" : "Augmented Equipment" + }, + "id" : 28428, + "name" : "Sailfi Belt +1", + "stats" : { + "Haste" : 9, + "Triple Attack" : 2, + "Unity Ranking" : { + "Attack" : 15 + } + } + } + +### Command + +- `//gearstats help` + +Print the help menu + +- `//gearstats print` + +Print the aggregate stats of the current equipped gear +Example: + + Generating Equipment Stats + DMG:336 Delay:480 DEF:717 HP:501 MP:53 Haste:26 + STR:203 DEX:131 AGI:115 VIT:201 INT:108 MND:138 CHR:157 + Acc:309 Atk:256 R.Acc:0 R.Atk:10 M.Acc:149 M.Dmg:155 + Eva:298 M.Eva:464 M.Def.Bn:30 DT:-36 PDT:-5 + Store TP:54 Double Atk.:43 Triple Atk.:2 Quad Atk.:3 + TP Bonus:500 PDL:6 + Subtle Blow:18 Subtle Blow II:5 + :Skill - Magic Accuracy:228 Great Axe:285 Parrying:269 + Weapon skill DEX:10 Regen:2 Upheaval:1 Crit.hit rate:4 Double Attack damage:20 + Berserk effect duration:15 Blood Rage effect duration:36 + Right ear - Double Atk.:8 Subtle Blow:6 + Aftermath - Ultimate Skillchain:1 Increases magic burst potency:1 + Aftermath - Increases skillchain potency:1 + Unity Ranking - Atk:15 + Set - Increases Accuracy, Ranged Accuracy, and Magic Accuracy:2 + Set - Augments Double Attack:1 + +- `//gearstats file ` + +This will generate the `file data/{player_name}_{player_main_job}.txt` and append the stats. +The arguments after the `file` command will be inserted into the header line. + + =====[ description_string ]====== + DMG:336 Delay:480 DEF:717 HP:501 MP:53 Haste:26 + STR:203 DEX:131 AGI:115 VIT:201 INT:108 MND:138 CHR:157 + Acc:309 Atk:256 R.Acc:0 R.Atk:10 M.Acc:149 M.Dmg:155 + Eva:298 M.Eva:464 M.Def.Bn:30 DT:-36 PDT:-5 + Store TP:54 Double Atk.:43 Triple Atk.:2 Quad Atk.:3 + TP Bonus:500 PDL:6 + Subtle Blow:18 Subtle Blow II:5 + :Skill - Magic Accuracy:228 Great Axe:285 Parrying:269 + Weapon skill DEX:10 Regen:2 Upheaval:1 Crit.hit rate:4 Double Attack damage:20 + Berserk effect duration:15 Blood Rage effect duration:36 + Right ear - Double Atk.:8 Subtle Blow:6 + Aftermath - Ultimate Skillchain:1 Increases magic burst potency:1 + Aftermath - Increases skillchain potency:1 + Unity Ranking - Atk:15 + Set - Increases Accuracy, Ranged Accuracy, and Magic Accuracy:2 + Set - Augments Double Attack:1 + +- `//gearstats merge_pet_stats < toggle | on | off>` + +Merge the Pet: statistics into Automation, Wyvern or Avatar and hide it + +- `//gearstats line_wrap < number >` + +Set the line wrap limit, defaults to 80 chars per line + +- `//gearstats debug` + +Output the internal structures into `data/debug.json` +Should invoke `//gearstats print` first to generate structures + +### Customization + +The output line order (priority_list) and printing name (mapping_table) of attributes is defined in data/settings.json. + +Can edit it to change the format based on your preferences. + + { + "line_wrap" : 80, + "mapping_table" : { + "Accuracy" : "Acc", + "Attack" : "Atk", + "Damage taken" : "DT", + "Double Attack" : "Double Atk.", + "Evasion" : "Eva", + "Magic Accuracy" : "M.Acc", + "Magic Atk. Bonus" : "M.Atk.Bn", + "Magic Damage" : "M.Dmg", + "Magic Def. Bonus" : "M.Def.Bn", + "Magic Evasion" : "M.Eva", + "Magic burst damage" : "MB.Dmg", + "Magical damage taken" : "MDT", + "Occ. quickens spellcasting" : "Quick Cast", + "Physical damage limit" : "PDL", + "Physical damage taken" : "PDT", + "Quad Attack" : "Quad Atk.", + "Ranged Accuracy" : "R.Acc", + "Ranged Attack" : "R.Atk", + "Skillchain Bonus" : "SC.Bonus", + "Spell interruption rate down" : "Interrupt Down Rate", + "Triple Attack" : "Triple Atk.", + "Weapon skill damage" : "WS.Dmg" + }, + "merge_pet_stats" : true, + "priority_list" : [["DMG", "Delay", "DEF", "HP", "MP", "Haste"], + ["STR", "DEX", "AGI", "VIT", "INT", "MND", "CHR"], + ["Accuracy", "Attack", "Ranged Accuracy", "Ranged Attack", "Magic Accuracy", "Magic Atk. Bonus", "Magic Damage", "Magic burst damage"], + ["Evasion", "Magic Evasion", "Magic Def. Bonus", "Damage taken", "Physical damage taken", "Magical damage taken"], + ["Regain", "Store TP", "Double Attack", "Triple Attack", "Quad Attack", "Dual Wield"], + ["Weapon skill damage", "TP Bonus", "Skillchain Bonus", "Physical damage limit"], + ["Enmity", "Counter", "Subtle Blow", "Subtle Blow II"], + ["Refresh", "Conserve MP", "Fast Cast", "Occ. quickens spellcasting", "Spell interruption rate down"]] + } diff --git a/addons/gearstats/gearstats.lua b/addons/gearstats/gearstats.lua new file mode 100644 index 000000000..e48b462ba --- /dev/null +++ b/addons/gearstats/gearstats.lua @@ -0,0 +1,628 @@ +--[[ +Copyright © 2025, Arakon +All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Dimmer nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.]] + +_addon.name = 'gearstats' +_addon.author = 'Arakon' +_addon.version = '1.0' +_addon.commands = {'gearstats'} + +require("lists") +require("tables") +local extdata = require("extdata") +local res = require('resources') +local files = require('files') +local jsonHelper = require("json_helper") + +defaults = { + priority_list = L{ + L{"DMG","Delay","DEF","HP","MP","Haste"}, + L{"STR","DEX","AGI","VIT","INT","MND","CHR"}, + L{"Accuracy","Attack","Ranged Accuracy","Ranged Attack","Magic Accuracy","Magic Atk. Bonus","Magic Damage", "Magic burst damage"}, + L{"Evasion","Magic Evasion","Magic Def. Bonus","Damage taken","Physical damage taken","Magical damage taken"}, + L{'Regain',"Store TP","Double Attack","Triple Attack","Quad Attack","Dual Wield"}, + L{'Weapon skill damage','TP Bonus','Skillchain Bonus','Physical damage limit'}, + L{'Enmity','Counter','Subtle Blow','Subtle Blow II'}, + L{'Refresh','Conserve MP','Fast Cast','Occ. quickens spellcasting', 'Spell interruption rate down'}, + }, + mapping_table = {}, + merge_pet_stats = true, + line_wrap = 80, +} + +defaults.mapping_table["Accuracy"] = "Acc" +defaults.mapping_table["Attack"] = "Atk" +defaults.mapping_table["Ranged Accuracy"] = "R.Acc" +defaults.mapping_table["Ranged Attack"] = "R.Atk" +defaults.mapping_table["Magic Accuracy"] = "M.Acc" +defaults.mapping_table["Magic Atk. Bonus"] = "M.Atk.Bn" +defaults.mapping_table["Magic Damage"] = "M.Dmg" +defaults.mapping_table["Magic burst damage"] = "MB.Dmg" +defaults.mapping_table["Evasion"] = "Eva" +defaults.mapping_table["Magic Evasion"] = "M.Eva" +defaults.mapping_table["Magic Def. Bonus"] = "M.Def.Bn" +defaults.mapping_table["Damage taken"] = "DT" +defaults.mapping_table["Physical damage taken"] = "PDT" +defaults.mapping_table["Magical damage taken"] = "MDT" +defaults.mapping_table["Double Attack"] = "Double Atk." +defaults.mapping_table["Triple Attack"] = "Triple Atk." +defaults.mapping_table["Quad Attack"] = "Quad Atk." +defaults.mapping_table['Weapon skill damage'] = "WS.Dmg" +defaults.mapping_table['Skillchain Bonus'] = "SC.Bonus" +defaults.mapping_table['Physical damage limit'] = "PDL" +defaults.mapping_table['Occ. quickens spellcasting'] = "Quick Cast" +defaults.mapping_table['Spell interruption rate down'] = "Interrupt Down Rate" + +settings = {} + +gameinfo = { + equipment = {}, + equipment_stats = {} +} + +--- Generic Functions + +function addon_console(...) + windower.console.write('%s: %s':format(_addon.name,table.concat({...},', '))) +end + +function addon_message(...) + windower.add_to_chat(7,'%s: %s':format(_addon.name,table.concat({...},', '))) +end + +function addon_trace(...) + if gameinfo.trace then + windower.add_to_chat(7,'%s: %s':format(_addon.name,table.concat({...},', '))) + end +end + +function flag_to_string(flag) + if (not flag) then + return '[Off]' + end + return '[On]' +end + +function table_clear(obj) + if type(obj) == 'table' then + for key in pairs(obj) do + rawset(obj, key, nil) + end + end +end + +function table_merge(to_obj, from_obj) + if type(to_obj) == 'table' and type(from_obj) == 'table' then + for key, value in pairs(from_obj) do + if to_obj[key] ~= nil then + if type(to_obj[key]) == 'table' and type(from_obj[key]) == 'table' then + table_merge(to_obj[key], from_obj[key]) + elseif type(to_obj[key]) =='number' and type(from_obj[key]) == 'number' then + to_obj[key] = to_obj[key] + from_obj[key] + elseif type(to_obj[key]) =='boolean' and type(from_obj[key]) == 'boolean' then + to_obj[key] = to_obj[key] and from_obj[key] + else + to_obj[key] = tostring(to_obj[key])..' '..tostring(from_obj[key]) + end + else + if type(from_obj[key]) == 'table' then + to_obj[key] = {} + table_merge(to_obj[key], from_obj[key]) + else + to_obj[key] = from_obj[key] + end + end + end + end + return to_obj +end + +function table_remove_keys(obj, list) + if type(obj) == 'table' then + for key in list:it() do + rawset(obj, key, nil) + end + end +end + +function remove_from_list(list, item) + if not list or not item then + return + end + list = list:filter(function(val) return (val ~= item) end) +end + +function from_json(data) + if type(data) ~= "string" then + return T{} + end + return jsonHelper:fromJson(data) +end + +function to_json(data) + if not data then + return "{}" + end + return jsonHelper:toJson(data) +end + +function matchWithDefault(current, default) + for key, value in pairs(default) do + if current[key] == nil or (type(current[key]) ~= type(value)) or (type(current[key]) == 'table' and class(current[key]) ~= class(value)) then + current[key] = value + end + end + for key, _ in pairs(current) do + if default[key] == nil then + current[key] = nil + end + end + return current +end + +function load_settings() + local filepath = "data/settings.json" + local file = files.new(filepath, true) + if not file:exists() then + matchWithDefault(settings, defaults) + save_settings() + end + local filecontent = file:read(filepath) + settings = from_json(filecontent) + settings = matchWithDefault(settings, defaults) +end + +function save_settings() + local filepath = "data/settings.json" + local file = files.new(filepath, true) + file:write(to_json(settings)) +end + +function print_debug(filepath) + local file = files.new(filepath, true) + local data = { + settings = setting, + gameinfo = gameinfo + } + file:write(to_json(data), true) +end +--- Stats Functions + +local function extract_stats_from_str(str, prefix) + local stats = {} + -- General string replacement + str = str:gsub('(Converts )([%d%.]+)(%%?[%a%s]+)','%1%3: %2'):gsub('%s%s',' ') + str = str:gsub('(%a)[%-](%a)','%1 %2') + local match = str:match('^\".*\"$') + if match then + match = match:gsub('\"','') + stats[match] = 1 + return stats, prefix + end + local name, op, value + for name, op, value in str:gmatch('(%u[^%d:%+%-]+)([:]?[%+%-]?)%s?([%d%.~]*)[%%]?') do + local is_prefix = false + if op == nil or op == '' then + op = '' + end + if value == nil or value == '' then + if op == ':' then + is_prefix = true + prefix = name + else + value = 1 + end + else + local offset = value:find('~') + if offset ~= nil then + value = value:match('~([%d]+)') + end + if op:find('-') ~= nil then + value = '-'.. value + end + value = tonumber(value) + end + if not is_prefix then + name = name:gsub('\"',''):gsub('^%s+',''):gsub('%s+$','') + local offset = name:find(':') + if (offset ~= nil) and (name:sub(offset+1, offset+1) ~= '+') then + prefix = name:sub(1, offset - 1):gsub('%s+$','') + name = name:sub(offset+1):gsub('^%s+','') + end + if prefix ~= nil then + if not stats[prefix] then + stats[prefix] = {} + end + if stats[prefix][name] then + stats[prefix][name] = stats[prefix][name] + value + else + stats[prefix][name] = value + end + else + if stats[name] then + stats[name] = stats[name] + value + else + stats[name] = value + end + end + end + end + return stats, prefix +end + +local function extract_stats(gear) + local stats = {} + local str_stats + if gear.description then + local prefix = nil + local has_stats = false + for str in gear.description:it() do + if not has_stats and str:match('^%a([^%:%+%-]*)[%.%l]$') then + -- if not stats['description'] then + -- stats['description'] = '' + -- end + -- stats['description'] = stats['description']..str + else + has_stats = true + str_stats, prefix = extract_stats_from_str(str, prefix) + table_merge(stats, str_stats) + end + end + end + if gear.extdata and gear.extdata.augment_system then + if gear.extdata.augment_system == 1 then + local prefix = nil + for key,str in pairs(gear.extdata.augments) do + if str:match('^System: %d') then + -- Skip useless string + else + str_stats, prefix = extract_stats_from_str(str, prefix) + table_merge(stats, str_stats) + end + end + end + end + return stats +end + +local function merge_stats(stats) + local merge_pair = {} + for key, value in pairs(stats) do + if type(value) == 'table' then + merge_stats(stats[key]) + elseif key:match('%.') then + local fuzzykey = key:gsub('(%a+)%.',function(word) return (word:gsub('(%w)','%1%%a*')..'%.?%s?') end) + fuzzykey = '^'..fuzzykey..'$' + addon_trace('Searching for %s using %s':format(key, fuzzykey)) + for x,y in pairs(stats) do + if x:match(fuzzykey) and x ~= key then + addon_trace('Found match for %s - %s':format(key, x)) + merge_pair[key] = x + break + end + end + end + end + for from_key, to_key in pairs(merge_pair) do + addon_trace('Merging %s to %s':format(from_key, to_key)) + stats[to_key] = (stats[to_key] or 0) + (stats[from_key] or 0) + stats[from_key] = nil + end +end + +function update_gear() + gameinfo.equipment_stats = {} + gameinfo.equipment = {} + local equipment = windower.ffxi.get_items('equipment') + local bags = L{0,8,10,11,12,13,14,15,16} + local slots = L{"main","sub","range","ammo","head","body","hands","legs","feet","neck","waist","back","left_ear","right_ear","left_ring","right_ring"} + for slot in slots:it() do + local index = equipment[slot] + local bag = equipment[slot.."_bag"] + local gear = {} + if index > 0 and bags:contains(bag) then + item = windower.ffxi.get_items(bag, index) + if item ~= nil and item.id ~= nil and res.items[item.id] then + gear.id = item.id + gear.name = res.items[item.id].name + gear.description = L{} + if res.item_descriptions[item.id] then + local desc = res.item_descriptions[item.id].english + local set_str = nil + local str = nil + for match in desc:gmatch("([^\n]+)") do + addon_trace("%s description length %d - str %s match %s":format(gear.name, gear.description:length(), tostring(str), match)) + if str and match:match("^%l") then + str = str..' '..match + elseif match:match('^Set: ') then + if str then + gear.description:append(str) + str = nil + end + set_str = match + elseif set_str ~= nil then + set_str = set_str..' '..match + else + if str then + gear.description:append(str) + end + str = match + end + end + if str then + gear.description:append(str) + end + if set_str then + gear.description:append(set_str) + end + end + gear.extdata = extdata.decode(item) + if (gear.extdata) then + gear.extdata.__raw = nil + end + gear.count = math.max(item.count, 1) + end + gear.stats = extract_stats(gear) + table_merge(gameinfo.equipment_stats, gear.stats) + end + gameinfo.equipment[slot] = gear + end + -- Merge the stats from Pet into the Individual pet types + if settings.merge_pet_stats and gameinfo.equipment_stats['Pet'] then + for key in L{'Automaton','Avatar','Wyvern'}:it() do + if gameinfo.equipment_stats[key] then + table_merge(gameinfo.equipment_stats[key], gameinfo.equipment_stats['Pet']) + end + end + gameinfo.equipment_stats['Pet'] = nil + end + merge_stats(gameinfo.equipment_stats) +end + +commands = T{} + +commands['trace'] = { +help = "Trace log - toggle | on | off ", +func = function(args) + if not args[1] then + gameinfo.trace = not gameinfo.trace + elseif args[1]:lower() == 'on' then + gameinfo.trace = true + elseif args[1]:lower() == 'off' then + gameinfo.trace = false + end + addon_message('Trace logs '..flag_to_string(gameinfo.trace)) +end} + +function print_stats_table(stats, prefix, priority_list, mapping_table, split) + local done_list = L{} + local output = L{} + local message = '' + for row in priority_list:it() do + for key in row:it() do + if stats[key] ~= nil then + local name = key + if mapping_table[name] ~= nil then + name = mapping_table[name] + end + local str = "%s:%d":format(name, stats[key]) + if (message:len() + str:len() > settings.line_wrap) then + output:append(message) + message = '' + end + if (message:len() == 0) and (prefix:len() > 0) then + message = prefix..' -' + end + message = message..' '..str + done_list:append(key) + end + end + if split and (message:len() > 0) then + output:append(message) + message = '' + end + end + if message:len() > 0 then + output:append(message) + message = '' + end + local sub_prefix = 'Skill' + if prefix:len() then + sub_prefix = prefix..':'..sub_prefix + end + for key, value in pairs(stats) do + if key:match('skill$') then + local str = '%s:%s':format(key:gsub('%s*skill$',''), tostring(value)) + if (message:len() + str:len() > settings.line_wrap) then + output:append(message) + message = '' + end + if (message:len() == 0) and (sub_prefix:len() > 0) then + message = sub_prefix..' -' + end + message = message..' '..str + done_list:append(key) + end + end + if (message:len() > 0) then + output:append(message) + message = '' + end + for key, value in pairs(stats) do + if (not done_list:contains(key)) and type(value) ~= 'table' then + local name = key + if mapping_table[name] ~= nil then + name = mapping_table[name] + end + local str = "%s:%s":format(name, tostring(value)) + if (message:len() + str:len() > settings.line_wrap) then + output:append(message) + message = '' + end + if (message:len() == 0) and (prefix:len() > 0) then + message = prefix..' -' + end + message = message..' '..str + done_list:append(key) + end + end + if message:len() > 0 then + output:append(message) + message = '' + end + return output:concat('\n') +end + +commands['print'] = { +help = "Print the stats of current gear", +func = function(args) + update_gear() + addon_message("Generating Equipment Stats") + output = print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) + for key, value in pairs(gameinfo.equipment_stats) do + if type(value) == 'table' then + output = output..'\n'..print_stats_table(value, key, settings.priority_list, settings.mapping_table, false) + end + end + for str in output:gmatch("([^\n]+)") do + addon_message(str) + end +end} + +commands['file'] = { +help = "Save the stats of current gear to file, arguments are added as header message", +func = function(args) + update_gear() + local player = windower.ffxi.get_player() + local filename = "data/"..player.name:lower().."_"..player.main_job..".txt" + addon_message("Generating Equipment Stats to %s":format(filename)) + local args_str = args:concat(' ') + local header = "=====[ %s ]======\n":format(args_str) + output = print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) + for key, value in pairs(gameinfo.equipment_stats) do + if type(value) == 'table' then + output = output..'\n'..print_stats_table(value, key, settings.priority_list, settings.mapping_table, false) + end + end + output = header..output.."\n" + local file = files.new(filename, true) + file:append(output, true) +end} + +commands['debug'] = { +help = "Print all structures to file", +func = function(args) + addon_message("Generating data/debug.json") + print_debug("data/debug.json") +end} + +commands['load'] = { +help = "Load settings", +func = function(args) + load_settings() + addon_message('settings loaded.') +end} + +commands['save'] = { +help = "Save settings", +func = function(args) + save_settings() + addon_message('settings saved.') +end} + +commands['help'] = { +help = "Command Help", +func = function(args) + addon_message("Commands") + for command, value in pairs(commands) do + if value.help then + addon_message("%s - %s":format(command, value.help)) + else + addon_message("%s":format(command)) + end + end + addon_message("Settings") + for command, value in pairs(settings) do + if commands[command] == nil then + if type(settings[command]) == 'boolean' then + addon_message("%s - boolean: default - toggle | on - enable | off - disable":format(command)) + elseif type(settings[command]) == 'number' then + addon_message("%s - ":format(command)) + elseif type(settings[command]) == 'string' then + addon_message("%s - ...":format(command)) + end + end + end +end} + +windower.register_event('addon command', function(command, ...) + local args = L{...} + command = command and command:lower() or 'help' + if commands:containskey(command) and commands[command].func ~= nil then + commands[command].func(args) + elseif settings[command] ~= nil then + if #args < 1 then + if type(settings[command]) == 'boolean' then + settings[command] = not settings[command] + addon_message(command..' is now set to '..flag_to_string(settings[command])) + elseif _static[command] ~= nil then + for i = 1, #_static[command] do + if _static[command][i] == settings[command] then + i = i + 1 + if i > #_static[command] then + i = 1 + end + settings[command] = _static[command][i] + break + end + end + addon_message(command..' is now set to '..settings[command]) + else + addon_message(command..' requires argument') + end + else + local arg = tonumber(args[1]) or args[1]:lower() + if type(settings[command]) == 'boolean' then + if arg == 'on' then + settings[command] = true + addon_message(command..' is now set to '..flag_to_string(settings[command])) + elseif arg == 'off' then + settings[command] = false + addon_message(command..' is now set to '..flag_to_string(settings[command])) + else + addon_message(command..' invalid option '..arg) + end + elseif type(settings[command]) == 'number' then + settings[command] = tonumber(args[1]) + addon_message(command..' is now set to '..settings[command]) + elseif type(settings[command]) == 'string' then + settings[command] = args.concat(' '):lower() + addon_message(command..' is now set to '..settings[command]) + end + end + else + commands['help'].func(args) + end +end) + +load_settings() diff --git a/addons/gearstats/json_helper.lua b/addons/gearstats/json_helper.lua new file mode 100644 index 000000000..fea2548a8 --- /dev/null +++ b/addons/gearstats/json_helper.lua @@ -0,0 +1,254 @@ +local JsonHelper = { +} + +function indentStr(count) + local str= '' + if (count > 0) then + for i = 1, count do + str = str..'\t' + end + end + return str +end + +function spairs(t, order) + -- collect the keys + local keys = {} + for k in pairs(t) do keys[#keys+1] = k end + + -- if order function given, sort by it by passing the table and keys a, b, + -- otherwise just sort the keys + if order then + table.sort(keys, function(a,b) return order(t, a, b) end) + else + table.sort(keys) + end + + -- return the iterator function + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + +function JsonHelper:decodeObject(str, index) + local obj = {} + local token, next_index, err + while index ~= nil do + token, next_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil then + return obj, next_index, err + end + if token == '}' then + -- End of object + break + elseif token == ',' then + -- comma separator, do nothing + index = next_index + elseif type(token) ~= 'string' then + return obj, next_index, "%d, Invalid token %s, expecting string key":format(index, tostring(token)) + else + local key = token + local value + index = next_index + token, next_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil then + break + end + if token ~= ':' then + return obj, next_index, "%d, Invalid token %s, expecting ':'":format(index, tostring(token)) + end + index = next_index + token, next_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil then + break + end + if token == '{' then + value, next_index, err = JsonHelper:decodeObject(str, next_index) + elseif token == '[' then + value, next_index, err = JsonHelper:decodeList(str, next_index) + else + value = token + end + if err ~= nil or value == nil then + break + end + obj[key] = value + index = next_index + end + end + return obj, next_index, err +end + +function JsonHelper:decodeList(str, index) + local list = L{} + local token, value, next_index, err + while index ~= nil do + token, next_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil then + return obj, next_index, err + end + if token == '{' then + value, next_index, err = JsonHelper:decodeObject(str, next_index) + if err ~= nil or value == nil then + break + end + list:append(value) + elseif token == ',' then + -- comma separator, skip + index = next_index + elseif token == ']' then + -- end of array + index = next_index + break + elseif L{'}','[',':'}:contains(token) then + err = "%d, unexpected token %s, expecting array element":format(index, token) + break + else + value, next_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil or value == nil then + break + end + list:append(value) + end + index = next_index + end + return list, next_index, err +end + +function JsonHelper:getNextToken(str, index) + local index = str:find('[^%s]', index) + local value, end_index + local char = str:sub(index, index) + -- print("getNextToken index %d byte %s":format(index, char)) + if L{'{','}','[',']',',',':'}:contains(char) then + value = char + end_index = (index < str:len()) and (index + 1) or nil + elseif L{'\'','"'}:contains(char) then + local start_quote = char + end_index = str:find(start_quote, index+1) + while (str:sub(end_index - 1, end_index - 1) == '\\') do + -- Continue searching if previous character is escape + end_index = str:find(start_quote, end_index+1) + end + if end_index == nil then + return nil, end_index, "%d: Cannot find string termination quote %s":format(index, start_quote) + end + if end_index > index + 1 then + value = str:sub(index + 1, end_index - 1) + end + -- Strip escape character + value = value:gsub('\\\"','\"') + end_index = (end_index < str:len()) and (end_index + 1) or nil + else + end_index = str:find('[%s,%{%}%:%[%]]', index) + value = str:sub(index, end_index - 1) + -- print("getNextToken index %d end_index %d value %s":format(index, end_index, tostring(value))) + if value:match('true') then + value = true + elseif value:match('false') then + value = false + else + local num = tonumber(value) + if (num == nil) then + return nil, end_index, "%d: Invalid number %s":format(index, value) + end + value = num + end + end + if end_index and end_index > str:len() then + end_index = nil + end + return value, end_index, nil +end + +function JsonHelper:decodeJson(str) + if type(str) ~= 'string' then + return nil, "decodeJson Invalid input type %s":format(type(str)) + end + local index = 1; + local obj, end_index, err; + local token, end_index, err = JsonHelper:getNextToken(str, index) + if err ~= nil then + return nil, err + end + if token == '{' then + obj, index, err = JsonHelper:decodeObject(str, end_index) + else + obj = nil + err = "%d, Invalid token %s encountered, expecting {":format(index, tostring(token)) + end + return obj, err +end + +function JsonHelper:fromJson(val) + if type(val) == 'string' then + -- return json.parse(val) + return JsonHelper:decodeJson(val) + end + return {} +end + +function JsonHelper:toJson(val, indent) + indent = indent or 0 + local str = T{} + if type(val) == 'table' and not (class(val) == 'List' or class(val) == 'Set') then + if (table.length(val) > 0) then + local list = T{} + for key, value in spairs(val) do + local json = JsonHelper:toJson(value, indent+1) + list:append("\"%s\" : %s":format(tostring(key):gsub('\"', '\\\"'), json)) + end + str:append('{') + str:append(indentStr(indent+1)..list:concat(',\n'..indentStr(indent+1))) + str:append(indentStr(indent)..'}') + else + str:append('{}') + end + elseif (class(val) == 'List') then + if (table.length(val) > 0) then + local list = T{} + local isObj = false + for key, value in pairs(val) do + if type(key) ~= 'string' or key ~= 'n' then + list:append("%s":format(JsonHelper:toJson(value, indent+1))) + if not isObj and type(value) == 'table' then + isObj = true + end + end + end + if isObj then + str:append('['..list:concat(',\n'..indentStr(indent+1))..']') + else + str:append('['..list:concat(', ')..']') + end + else + str:append('[]') + end + elseif (class(val) == 'Set') then + if (table.length(val) > 0) then + local list = T{} + for key, value in pairs(val) do + if (S{'number','boolean'}:contains(type(value))) then + list:append("%s":format(JsonHelper:toJson(key, indent+1))) + else + list:append("%s":format(JsonHelper:toJson(value, indent+1))) + end + end + table.sort(list) + str:append('['..list:concat(',')..']') + else + str:append('[]') + end + elseif (type(val) == 'string') then + str:append('"'..val:gsub('\"', '\\\"') ..'"') + else + str:append(val) + end + return str:concat('\n') +end + +return JsonHelper \ No newline at end of file From 08814958f51c92c2ce6e662e7828f10304614c42 Mon Sep 17 00:00:00 2001 From: coldmix <3292347+coldmix@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:34:28 +0800 Subject: [PATCH 2/3] Add diff and equip set printing Add command 'base' to set equipment set as base Add command 'diff' to compare current set with base Add command 'filediff' to output diff to file Add printing of equipment set and flag to enable/disable equip printing Add handling for "physical damage taken:" effects which clash with PDT number. --- addons/gearstats/README.md | 12 +++ addons/gearstats/gearstats.lua | 190 +++++++++++++++++++++++++++------ 2 files changed, 172 insertions(+), 30 deletions(-) diff --git a/addons/gearstats/README.md b/addons/gearstats/README.md index 8086c8e79..f06d33e95 100644 --- a/addons/gearstats/README.md +++ b/addons/gearstats/README.md @@ -96,6 +96,18 @@ The arguments after the `file` command will be inserted into the header line. Set - Increases Accuracy, Ranged Accuracy, and Magic Accuracy:2 Set - Augments Double Attack:1 +- `//gearstats base` + +Set the current gear stats as baseline for comparison + +- `//gearstats diff` + +Compare the current gear stats with baseline stats + +- `//gearstats diff` + +Compare the current gear stats with baseline stats and write to file + - `//gearstats merge_pet_stats < toggle | on | off>` Merge the Pet: statistics into Automation, Wyvern or Avatar and hide it diff --git a/addons/gearstats/gearstats.lua b/addons/gearstats/gearstats.lua index e48b462ba..ff1e9a78d 100644 --- a/addons/gearstats/gearstats.lua +++ b/addons/gearstats/gearstats.lua @@ -48,6 +48,7 @@ defaults = { mapping_table = {}, merge_pet_stats = true, line_wrap = 80, + print_gear_name = true, } defaults.mapping_table["Accuracy"] = "Acc" @@ -77,7 +78,13 @@ settings = {} gameinfo = { equipment = {}, - equipment_stats = {} + equipment_set = {}, + equipment_stats = {}, + base_set = {}, + base_stats = {}, + diff_set = {}, + diff_stats = {}, + slots = L{"main","sub","range","ammo","head","body","hands","legs","feet","neck","left_ear","right_ear","left_ring","right_ring","waist","back"}, } --- Generic Functions @@ -212,6 +219,7 @@ local function extract_stats_from_str(str, prefix) local stats = {} -- General string replacement str = str:gsub('(Converts )([%d%.]+)(%%?[%a%s]+)','%1%3: %2'):gsub('%s%s',' ') + str = str:gsub('(taken)(:)%s?([^%d])','%1 effect: %3'):gsub('%s%s',' ') str = str:gsub('(%a)[%-](%a)','%1 %2') local match = str:match('^\".*\"$') if match then @@ -333,10 +341,10 @@ end function update_gear() gameinfo.equipment_stats = {} gameinfo.equipment = {} + gameinfo.equipment_set = {} local equipment = windower.ffxi.get_items('equipment') local bags = L{0,8,10,11,12,13,14,15,16} - local slots = L{"main","sub","range","ammo","head","body","hands","legs","feet","neck","waist","back","left_ear","right_ear","left_ring","right_ring"} - for slot in slots:it() do + for slot in gameinfo.slots:it() do local index = equipment[slot] local bag = equipment[slot.."_bag"] local gear = {} @@ -386,6 +394,7 @@ function update_gear() table_merge(gameinfo.equipment_stats, gear.stats) end gameinfo.equipment[slot] = gear + gameinfo.equipment_set[slot] = gear.name or nil end -- Merge the stats from Pet into the Individual pet types if settings.merge_pet_stats and gameinfo.equipment_stats['Pet'] then @@ -399,20 +408,15 @@ function update_gear() merge_stats(gameinfo.equipment_stats) end -commands = T{} - -commands['trace'] = { -help = "Trace log - toggle | on | off ", -func = function(args) - if not args[1] then - gameinfo.trace = not gameinfo.trace - elseif args[1]:lower() == 'on' then - gameinfo.trace = true - elseif args[1]:lower() == 'off' then - gameinfo.trace = false - end - addon_message('Trace logs '..flag_to_string(gameinfo.trace)) -end} +function print_equip_set(equip_set) + local output = L{} + for slot in gameinfo.slots:it() do + if equip_set[slot] ~= nil then + output:append("%s=\"%s\"":format(slot, equip_set[slot])) + end + end + return output:concat(",") +end function print_stats_table(stats, prefix, priority_list, mapping_table, split) local done_list = L{} @@ -425,7 +429,7 @@ function print_stats_table(stats, prefix, priority_list, mapping_table, split) if mapping_table[name] ~= nil then name = mapping_table[name] end - local str = "%s:%d":format(name, stats[key]) + local str = "%s:%s":format(name, tostring(stats[key])) if (message:len() + str:len() > settings.line_wrap) then output:append(message) message = '' @@ -447,7 +451,7 @@ function print_stats_table(stats, prefix, priority_list, mapping_table, split) message = '' end local sub_prefix = 'Skill' - if prefix:len() then + if prefix:len() > 0 then sub_prefix = prefix..':'..sub_prefix end for key, value in pairs(stats) do @@ -490,25 +494,125 @@ function print_stats_table(stats, prefix, priority_list, mapping_table, split) output:append(message) message = '' end - return output:concat('\n') + local output_msg = output:concat('\n') + for key, value in pairs(stats) do + if type(value) == 'table' then + local str = print_stats_table(value, key, settings.priority_list, settings.mapping_table, false) + if str:len() > 0 then + output_msg = output_msg..'\n'..str + end + end + end + return output_msg +end + +function calc_diff_stats(base_stats, new_stats) + local merged_key = L{} + local diff_stats = {} + for key, value in pairs(new_stats) do + if base_stats[key] == nil then + diff_stats[key] = value + else + if type(value) == 'table' then + diff_stats[key] = calc_diff_stats(base_stats[key], new_stats[key]) + elseif type(value) == 'number' and type(base_stats[key]) == 'number' then + diff_stats[key] = value - base_stats[key] + if diff_stats[key] == 0 then + -- remove if no change + diff_stats[key] = nil + end + else + base_str = tostring(base_stats[key]) + new_str = tostring(value) + if (base_str ~= new_str) then + diff_stats[key] = new_str + end + end + end + merged_key:append(key) + end + -- Handle remaining entries, not found in new_stats + for key, value in pairs(base_stats) do + if not merged_key:contains(key) then + if type(value) == 'table' then + diff_stats[key] = calc_diff_stats(base_stats[key], {}) + elseif type(value) == 'number' then + diff_stats[key] = - value + else + diff_stats[key] = '' + end + merged_key:append(key) + end + end + return diff_stats end +commands = T{} + +commands['trace'] = { +help = "Trace log - toggle | on | off ", +func = function(args) + if not args[1] then + gameinfo.trace = not gameinfo.trace + elseif args[1]:lower() == 'on' then + gameinfo.trace = true + elseif args[1]:lower() == 'off' then + gameinfo.trace = false + end + addon_message('Trace logs '..flag_to_string(gameinfo.trace)) +end} + commands['print'] = { help = "Print the stats of current gear", func = function(args) + local output = '' update_gear() addon_message("Generating Equipment Stats") - output = print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) - for key, value in pairs(gameinfo.equipment_stats) do - if type(value) == 'table' then - output = output..'\n'..print_stats_table(value, key, settings.priority_list, settings.mapping_table, false) - end + if settings.print_gear_name then + output = print_equip_set(gameinfo.equipment_set) end + if output:len() > 0 then + output = output.."\n" + end + output = output .. print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) for str in output:gmatch("([^\n]+)") do addon_message(str) end end} +commands['base'] = { +help = "Set the current gear stats as baseline", +func = function(args) + update_gear() + gameinfo.base_stats = gameinfo.equipment_stats + gameinfo.base_set = gameinfo.equipment_set + addon_message("Setting Equipment Stats as baseline") +end} + +commands['diff'] = { +help = "Compare the current gear stats with baseline", +func = function(args) + local output = '' + update_gear() + addon_message("Comparing current equipment stats with baseline") + gameinfo.diff_set = calc_diff_stats(gameinfo.base_set, gameinfo.equipment_set) + gameinfo.diff_stats = calc_diff_stats(gameinfo.base_stats, gameinfo.equipment_stats) + if settings.print_gear_name then + output = print_equip_set(gameinfo.diff_set) + end + if output:len() > 0 then + output = output.."\n" + end + output = output .. print_stats_table(gameinfo.diff_stats, '', settings.priority_list, settings.mapping_table, true) + if output:len() == 0 then + addon_message("No change detected in stats") + else + for str in output:gmatch("([^\n]+)") do + addon_message(str) + end + end +end} + commands['file'] = { help = "Save the stats of current gear to file, arguments are added as header message", func = function(args) @@ -518,12 +622,38 @@ func = function(args) addon_message("Generating Equipment Stats to %s":format(filename)) local args_str = args:concat(' ') local header = "=====[ %s ]======\n":format(args_str) - output = print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) - for key, value in pairs(gameinfo.equipment_stats) do - if type(value) == 'table' then - output = output..'\n'..print_stats_table(value, key, settings.priority_list, settings.mapping_table, false) - end + local output = '' + if settings.print_gear_name then + output = print_equip_set(gameinfo.equipment_set) + end + if output:len() > 0 then + output = output.."\n" + end + output = output..print_stats_table(gameinfo.equipment_stats, '', settings.priority_list, settings.mapping_table, true) + output = header..output.."\n" + local file = files.new(filename, true) + file:append(output, true) +end} + +commands['filediff'] = { +help = "Save the diff of current gear to file, arguments are added as header message", +func = function(args) + update_gear() + local player = windower.ffxi.get_player() + local filename = "data/"..player.name:lower().."_"..player.main_job..".txt" + addon_message("Generating Equipment Stats to %s":format(filename)) + local args_str = args:concat(' ') + local header = "=====[ %s ]======\n":format(args_str) + local output = '' + gameinfo.diff_set = calc_diff_stats(gameinfo.base_set, gameinfo.equipment_set) + gameinfo.diff_stats = calc_diff_stats(gameinfo.base_stats, gameinfo.equipment_stats) + if settings.print_gear_name then + output = print_equip_set(gameinfo.diff_set) + end + if output:len() > 0 then + output = output.."\n" end + output = output..print_stats_table(gameinfo.diff_stats, '', settings.priority_list, settings.mapping_table, true) output = header..output.."\n" local file = files.new(filename, true) file:append(output, true) From 0bdb222739f24c87823ca6eef744415d50c73759 Mon Sep 17 00:00:00 2001 From: coldmix <3292347+coldmix@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:35:43 +0800 Subject: [PATCH 3/3] Update README.md Fix typo for command --- addons/gearstats/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/gearstats/README.md b/addons/gearstats/README.md index f06d33e95..47f40b73d 100644 --- a/addons/gearstats/README.md +++ b/addons/gearstats/README.md @@ -104,7 +104,7 @@ Set the current gear stats as baseline for comparison Compare the current gear stats with baseline stats -- `//gearstats diff` +- `//gearstats filediff` Compare the current gear stats with baseline stats and write to file