_addon.name = 'ws_link' _addon.author = 'OpenAI' _addon.version = '5.0.0-roles' _addon.commands = {'wsl', 'wslink'} local config = require('config') local packets = require('packets') local res = require('resources') pcall(require, 'ws_magic_dict') local WS_DICT = _G.WS_DICT or {} local MAGIC_DICT = _G.MAGIC_DICT or {} local PET_DICT = _G.PET_DICT or {} local ABILITY_DICT = _G.ABILITY_DICT or {} --[[ v5.0.0 changes: - Scenes no longer reference real character names directly. Every "who" field is a role id (roleA, roleB, ...). Real names live only in the "characters" table inside ws_link_config.json (or set locally via //wsl role set), so the addon and its default config work for anyone out of the box and never ship with any specific player's name in them. - Matching is now precomputed into an index at load time (see build_index), so the per-action-event hot path is a couple of table lookups instead of scanning every scene and re-translating every trigger name on every swing/spell/JA in the zone. This is the main load-reduction change. ]] local defaults = { enabled = true, debug = false, default_target = '', block_seconds = 1.2, } local settings = config.load(defaults) local scenes = {} local characters = {} -- [role_id_lower] = { label = '...', name = '...' } local index = {} -- [actor_name_lower] = { {scene=scene, kind=kind, names={[alias]=true}}, ... } local entry_alias_cache = {} -- ['category:id'] = { alias, alias, ... } -- windower.add_to_chat expects Shift-JIS, not UTF-8 -- everything else in this -- addon (the Lua source, the JSON config) is UTF-8, so Japanese text has to be -- converted right before it's handed to the chat log or it displays as mojibake. -- pcall guards against windower.to_shift_jis not being available or erroring on -- unusual input; if that happens we fall back to the original string rather -- than losing the message entirely. local function to_sjis(s) if not s or s == '' then return s end local ok, converted = pcall(windower.to_shift_jis, s) if ok and converted and converted ~= '' then return converted end return s end local function msg(s, c) windower.add_to_chat(c or 207, to_sjis('[ws_link] ' .. tostring(s))) end local function dbg(s) if settings.debug then msg(s, 8) end end local function trim(s) return (tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '')) end local function lower(s) return trim(s):lower() end local function json_path() return windower.addon_path .. 'ws_link_config.json' end local function read_file(path) local f = io.open(path, 'rb') if not f then return nil end local data = f:read('*a') f:close() return data end local function write_file(path, data) local f = io.open(path, 'wb') if not f then return false end f:write(data) f:close() return true end -- ===================== minimal JSON parser ===================== local function parse_json_value(str, i) local function skip_ws(idx) while true do local ch = str:sub(idx, idx) if ch == ' ' or ch == '\n' or ch == '\r' or ch == '\t' then idx = idx + 1 else return idx end end end local function parse_string(idx) idx = idx + 1 local out = {} while idx <= #str do local ch = str:sub(idx, idx) if ch == '"' then return table.concat(out), idx + 1 elseif ch == '\\' then local n = str:sub(idx + 1, idx + 1) local map = {['"']='"', ['\\']='\\', ['/']='/', b='\b', f='\f', n='\n', r='\r', t='\t'} if map[n] then out[#out+1] = map[n] idx = idx + 2 else out[#out+1] = n idx = idx + 2 end else out[#out+1] = ch idx = idx + 1 end end return nil, idx end local parse_value local function parse_array(idx) idx = idx + 1 local arr = {} idx = skip_ws(idx) if str:sub(idx, idx) == ']' then return arr, idx + 1 end while idx <= #str do local v v, idx = parse_value(idx) arr[#arr+1] = v idx = skip_ws(idx) local ch = str:sub(idx, idx) if ch == ']' then return arr, idx + 1 elseif ch == ',' then idx = skip_ws(idx + 1) else return nil, idx end end return nil, idx end local function parse_object(idx) idx = idx + 1 local obj = {} idx = skip_ws(idx) if str:sub(idx, idx) == '}' then return obj, idx + 1 end while idx <= #str do local key if str:sub(idx, idx) ~= '"' then return nil, idx end key, idx = parse_string(idx) idx = skip_ws(idx) if str:sub(idx, idx) ~= ':' then return nil, idx end idx = skip_ws(idx + 1) local v v, idx = parse_value(idx) obj[key] = v idx = skip_ws(idx) local ch = str:sub(idx, idx) if ch == '}' then return obj, idx + 1 elseif ch == ',' then idx = skip_ws(idx + 1) else return nil, idx end end return nil, idx end function parse_value(idx) idx = skip_ws(idx) local ch = str:sub(idx, idx) if ch == '"' then return parse_string(idx) elseif ch == '{' then return parse_object(idx) elseif ch == '[' then return parse_array(idx) elseif ch == 't' and str:sub(idx, idx+3) == 'true' then return true, idx + 4 elseif ch == 'f' and str:sub(idx, idx+4) == 'false' then return false, idx + 5 elseif ch == 'n' and str:sub(idx, idx+3) == 'null' then return nil, idx + 4 else local num = str:match('^-?%d+%.?%d*[eE]?[+-]?%d*', idx) if num and #num > 0 then return tonumber(num), idx + #num end end return nil, idx end return parse_value(i or 1) end -- ===================== minimal JSON encoder (for saving characters) ==== local function json_escape(s) s = tostring(s) s = s:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') return s end local function is_array(t) local n = 0 for _ in pairs(t) do n = n + 1 end if n == 0 then return true end -- empty table serializes as [] for i = 1, n do if t[i] == nil then return false end end return true end local encode_value local function encode_array(t, indent) local pad = string.rep(' ', indent) local pad2 = string.rep(' ', indent + 1) if #t == 0 then return '[]' end local parts = {} for i = 1, #t do parts[#parts+1] = pad2 .. encode_value(t[i], indent + 1) end return '[\n' .. table.concat(parts, ',\n') .. '\n' .. pad .. ']' end local function encode_object(t, indent) local pad = string.rep(' ', indent) local pad2 = string.rep(' ', indent + 1) local keys = {} for k in pairs(t) do keys[#keys+1] = k end table.sort(keys) if #keys == 0 then return '{}' end local parts = {} for _, k in ipairs(keys) do parts[#parts+1] = pad2 .. '"' .. json_escape(k) .. '": ' .. encode_value(t[k], indent + 1) end return '{\n' .. table.concat(parts, ',\n') .. '\n' .. pad .. '}' end function encode_value(v, indent) local t = type(v) if t == 'string' then return '"' .. json_escape(v) .. '"' elseif t == 'number' then return tostring(v) elseif t == 'boolean' then return v and 'true' or 'false' elseif t == 'table' then if is_array(v) then return encode_array(v, indent) else return encode_object(v, indent) end elseif v == nil then return 'null' end return '""' end -- ===================== translation helpers ===================== local function translate_name(kind, name) local raw = trim(name) if raw == '' then return '' end if kind == 'ws' and WS_DICT[raw] then return WS_DICT[raw] end if kind == 'ma' and MAGIC_DICT[raw] then return MAGIC_DICT[raw] end if kind == 'pet' and PET_DICT[raw] then return PET_DICT[raw] end if kind == 'ja' and ABILITY_DICT[raw] then return ABILITY_DICT[raw] end return raw end -- Windower resource tables (res.weapon_skills / res.job_abilities / res.spells) -- sometimes store their Japanese fields (ja / jal / japanese) in Shift-JIS while -- everything else in this addon -- the Lua source, the JSON config, the picker -- -- is UTF-8. Left unconverted, that mismatch shows up as mojibake in chat AND -- silently breaks name matching (a Shift-JIS string will never equal a UTF-8 -- string typed in the config, even if they "look the same"). Convert defensively; -- pcall guards against fields that are already valid UTF-8 (or nil). -- Byte-level UTF-8 validity check (no external library needed). Used to decide -- whether a string actually needs Shift-JIS conversion, and to sanity-check the -- config file itself so we can warn instead of silently displaying mojibake. local function looks_like_valid_utf8(s) if not s or s == '' then return true end local i, len = 1, #s while i <= len do local b = s:byte(i) if b < 0x80 then i = i + 1 elseif b >= 0xC2 and b <= 0xDF then local b2 = s:byte(i + 1) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end i = i + 2 elseif b >= 0xE0 and b <= 0xEF then local b2, b3 = s:byte(i + 1), s:byte(i + 2) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end if not b3 or b3 < 0x80 or b3 > 0xBF then return false end i = i + 3 elseif b >= 0xF0 and b <= 0xF4 then local b2, b3, b4 = s:byte(i + 1), s:byte(i + 2), s:byte(i + 3) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end if not b3 or b3 < 0x80 or b3 > 0xBF then return false end if not b4 or b4 < 0x80 or b4 > 0xBF then return false end i = i + 4 else return false end end return true end -- Only attempt Shift-JIS -> UTF-8 conversion when the string does NOT already -- look like valid UTF-8, and only accept the converted result if IT looks like -- valid UTF-8. This avoids the failure mode of the previous version, where a -- field that was already fine could get mangled by an unnecessary conversion. local function safe_from_sjis(s) if not s or s == '' then return s end if looks_like_valid_utf8(s) then return s end local ok, converted = pcall(windower.from_shift_jis, s) if ok and converted and converted ~= '' and looks_like_valid_utf8(converted) then return converted end return s end local function aliases(entry) local t = {} local seen = {} local function add(v) v = lower(v) if v ~= '' and not seen[v] then seen[v] = true t[#t+1] = v end end if entry then add(entry.name) add(entry.en) add(entry.enl) add(safe_from_sjis(entry.ja)) add(safe_from_sjis(entry.jal)) add(entry.english) add(safe_from_sjis(entry.japanese)) end return t end -- cache entry aliases per (category, param) pair -- the same WS/spell/JA id -- comes up over and over in a fight, no need to rebuild its alias list every time. local function cached_aliases(category, id, entry) local key = tostring(category) .. ':' .. tostring(id) local hit = entry_alias_cache[key] if hit then return hit end local a = aliases(entry) entry_alias_cache[key] = a return a end -- ===================== characters / roles ===================== local function normalize_characters(raw) local out = {} if type(raw) == 'table' then for k, v in pairs(raw) do local key = lower(k) if type(v) == 'table' then out[key] = {label = trim(v.label or k), name = trim(v.name or '')} else out[key] = {label = trim(k), name = trim(v or '')} end end end return out end -- Resolve a "who" field (a role id like "roleA") to a real in-game name. -- Returns nil if it's a known role with no name assigned yet. -- Falls back to treating the value as a literal name (back-compat with -- older configs that used real names directly). local function resolve_who(who) local w = trim(who) if w == '' then return nil end local key = lower(w) local ch = characters[key] if ch then if ch.name ~= '' then return ch.name end return nil end return w end -- ===================== index build (perf-critical) ===================== -- Rebuilds the actor-name -> scene lookup used by the action event handler. -- Runs once per config/role change, never inside the hot path. local function build_index() index = {} entry_alias_cache = {} local missing_roles = {} for _, scene in ipairs(scenes) do if type(scene) == 'table' and type(scene.trigger) == 'table' then local actor = resolve_who(scene.trigger.who) if not actor then if scene.enabled ~= false then missing_roles[lower(scene.trigger.who or '?')] = true end else local actor_key = lower(actor) for _, kind in ipairs({'ws', 'ma', 'ja', 'pet'}) do local list = scene.trigger[kind] if type(list) == 'table' and #list > 0 then local nameset = {} for _, want in ipairs(list) do local a = lower(want) local b = lower(translate_name(kind, want)) if a ~= '' then nameset[a] = true end if b ~= '' then nameset[b] = true end end index[actor_key] = index[actor_key] or {} table.insert(index[actor_key], {scene = scene, kind = kind, names = nameset}) end end end end end for role in pairs(missing_roles) do msg(('warning: scene(s) reference role "%s" but it has no character name set yet (use //wsl role set %s )'):format(role, role), 123) end end -- ===================== config load/save ===================== local function load_json() local raw = read_file(json_path()) if not raw then scenes = {} characters = {} msg('config not found: ws_link_config.json', 123) return false end raw = raw:gsub('^\239\187\191', '') if not looks_like_valid_utf8(raw) then msg('WARNING: ws_link_config.json does not look like valid UTF-8. This is almost certainly why names show as garbled text (mojibake) and scenes fail to match. Re-save the file using the editor\'s "JSON save" button and overwrite it directly -- do not re-save it through Notepad or another text editor.', 123) end local data = select(1, parse_json_value(raw, 1)) if type(data) ~= 'table' or type(data.scenes) ~= 'table' then scenes = {} characters = {} msg('config load failed', 123) return false end scenes = data.scenes characters = normalize_characters(data.characters) build_index() msg('config loaded: scenes=' .. tostring(#scenes) .. ' characters=' .. tostring((function() local n=0 for _ in pairs(characters) do n=n+1 end return n end)())) return true end local function save_json() local out = { characters = {}, scenes = scenes, } for k, v in pairs(characters) do out.characters[k] = {label = v.label, name = v.name} end local ok = write_file(json_path(), encode_value(out, 0) .. '\n') if ok then dbg('config saved to ws_link_config.json') else msg('failed to save ws_link_config.json (file locked or path invalid?)', 123) end return ok end -- ===================== action matching / execution ===================== local function get_actor_name(id) local mob = windower.ffxi.get_mob_by_id(id) return mob and mob.name or nil end local function event_info(act) if not act or not act.actor_id then return nil end local actor = get_actor_name(act.actor_id) if not actor then return nil end if act.category == 3 then return {kind='ws', actor=actor, entry=res.weapon_skills[act.param], id=act.param, category=act.category} elseif act.category == 6 then return {kind='ja', actor=actor, entry=res.job_abilities[act.param], id=act.param, category=act.category} elseif act.category == 4 or act.category == 7 or act.category == 8 or act.category == 11 then return {kind='ma', actor=actor, entry=res.spells[act.param], id=act.param, category=act.category} elseif act.category == 13 then -- Pet TP moves: avatar Blood Pacts (Rage/Ward) and charmed/jugged Beastmaster pet -- special attacks. These are indexed in res.monster_abilities, NOT res.spells -- -- category 13 used to be lumped in with the magic branch above and looked up in -- the wrong table, so it never actually matched anything correctly. return {kind='pet', actor=actor, entry=res.monster_abilities and res.monster_abilities[act.param], id=act.param, category=act.category} end return nil end local recent = {} local function recent_key(info) return table.concat({lower(info.actor), tostring(info.category), tostring(info.id)}, '|') end -- Some scenes legitimately use the same technique as both their trigger and -- one of their own steps (e.g. a solo chain that reuses a buff partway -- through). Without a guard, that step would re-fire the whole scene from -- the top before the current run has finished, and the runs pile up on top -- of each other. Track when each scene last fired and how long its own -- longest-delayed step is, and ignore a re-trigger until that long enough -- for the current run to have finished (plus a little slack). local scene_last_fired = setmetatable({}, {__mode = 'k'}) local function scene_cycle_seconds(scene) local longest = 0 for _, step in ipairs(scene.steps or {}) do local d = tonumber(step.delay or 0) or 0 if d > longest then longest = d end end return longest + 1 -- a little slack after the last scheduled step end local function build_cmd(step) local who = resolve_who(step.who) local kind = lower(step.kind) local raw_name = trim(step.cmd_name or '') if raw_name == '' then raw_name = trim(step.name) end local name = translate_name(kind, raw_name) -- An empty string is truthy in Lua, so "step.target or default or ''" -- would silently accept a blank target field instead of falling back -- -- and a blank target produces a malformed command (e.g. '/ja "..." ' with -- nothing after it), which can crash the receiving client. Check for an -- actually-empty value (after trimming whitespace) at each fallback step. local target = trim(step.target or '') if target == '' then target = trim(settings.default_target or '') end if target == '' then target = '' end if not who or kind == '' or name == '' then return nil end if type(step.command) == 'string' and trim(step.command) ~= '' then return ('send %s %s'):format(who, trim(step.command)) end if kind == 'ws' then return ('send %s input /ws "%s" %s'):format(who, name, target) elseif kind == 'ma' then return ('send %s input /ma "%s" %s'):format(who, name, target) elseif kind == 'ja' then return ('send %s input /ja "%s" %s'):format(who, name, target) elseif kind == 'pet' then return ('send %s input /pet "%s" %s'):format(who, name, target) end return nil end local function run_scene(scene, info) local now = os.clock() local last = scene_last_fired[scene] if last and (now - last) < scene_cycle_seconds(scene) then dbg(('skip (still running): %s'):format(tostring(scene.name or 'scene'))) return end scene_last_fired[scene] = now msg('fire: ' .. tostring(scene.name or 'scene')) for _, step in ipairs(scene.steps or {}) do local cmd = build_cmd(step) if cmd then local delay = tonumber(step.delay or 0) or 0 dbg(('queue %.1fs: %s'):format(delay, cmd)) coroutine.schedule(function() windower.send_command(cmd) end, delay) end end end windower.register_event('action', function(act) if not settings.enabled then return end -- category 3/4/6/7/8/11/13 are the only ones we ever care about; bail -- out before touching mob lookups or the index for anything else. local cat = act and act.category if cat ~= 3 and cat ~= 4 and cat ~= 6 and cat ~= 7 and cat ~= 8 and cat ~= 11 and cat ~= 13 then return end local info = event_info(act) if not info or not info.entry then return end -- fast bail: nobody we're tracking used this action local candidates = index[lower(info.actor)] if not candidates then return end local k = recent_key(info) local now = os.clock() if recent[k] and (now - recent[k]) < (settings.block_seconds or 1.2) then return end recent[k] = now local entry_names = cached_aliases(info.category, info.id, info.entry) dbg(('seen actor=%s kind=%s name=%s / %s'):format( tostring(info.actor), tostring(info.kind), tostring(info.entry.en or info.entry.name or '?'), tostring(safe_from_sjis(info.entry.ja) or '?') )) for _, c in ipairs(candidates) do if c.kind == info.kind and c.scene.enabled ~= false then local matched = false for _, a in ipairs(entry_names) do if c.names[a] then matched = true break end end if matched then run_scene(c.scene, info) end end end end) -- ===================== status / commands ===================== local function role_label(key) local ch = characters[key] if not ch then return key end if ch.label ~= '' and ch.label ~= key then return ('%s (%s)'):format(ch.label, key) end return key end local function status() msg(('enabled=%s debug=%s default_target=%s scenes=%d'):format( settings.enabled and 'ON' or 'OFF', settings.debug and 'ON' or 'OFF', tostring(settings.default_target), #scenes )) for i, scene in ipairs(scenes) do msg(('[%d] %s = %s'):format(i, tostring(scene.name or ('scene_' .. i)), scene.enabled ~= false and 'ON' or 'OFF')) end end local function role_list() local keys = {} for k in pairs(characters) do keys[#keys+1] = k end table.sort(keys) if #keys == 0 then msg('no roles defined yet in ws_link_config.json') return end for _, k in ipairs(keys) do local ch = characters[k] msg(('%s = %s'):format(role_label(k), ch.name ~= '' and ch.name or '(未設定)')) end end windower.register_event('addon command', function(...) local args = {...} local cmd = lower(args[1]) if cmd == '' or cmd == 'help' then msg('on | off | toggle | debug on|off | target t|bt | status | scene list | scene on N | scene off N | scene toggle N | role list | role set | role clear | json reload | json save | reload') return elseif cmd == 'on' then settings.enabled = true config.save(settings) msg('enabled ON') return elseif cmd == 'off' then settings.enabled = false config.save(settings) msg('enabled OFF') return elseif cmd == 'toggle' then settings.enabled = not settings.enabled config.save(settings) msg('enabled ' .. (settings.enabled and 'ON' or 'OFF')) return elseif cmd == 'debug' then local v = lower(args[2]) settings.debug = (v == 'on' or v == '1' or v == 'true') config.save(settings) msg('debug ' .. (settings.debug and 'ON' or 'OFF')) return elseif cmd == 'target' then local v = lower(args[2]) if v == 't' then settings.default_target = '' config.save(settings) msg('default_target=') elseif v == 'bt' then settings.default_target = '' config.save(settings) msg('default_target=') else msg('target must be t or bt', 123) end return elseif cmd == 'status' then status() return elseif cmd == 'reload' then windower.send_command('lua r ws_link') return elseif cmd == 'json' then local sub = lower(args[2]) if sub == 'reload' then load_json() return elseif sub == 'save' then save_json() return end elseif cmd == 'role' then local sub = lower(args[2]) if sub == 'list' then role_list() return elseif sub == 'set' then local role = lower(args[3]) local name = trim(table.concat(args, ' ', 4)) if role == '' or name == '' then msg('usage: //wsl role set ', 123) return end characters[role] = characters[role] or {label = role, name = ''} characters[role].name = name build_index() save_json() msg(('role %s -> %s'):format(role_label(role), name)) return elseif sub == 'clear' then local role = lower(args[3]) if role == '' or not characters[role] then msg('unknown role', 123) return end characters[role].name = '' build_index() save_json() msg(('role %s cleared'):format(role_label(role))) return else msg('usage: //wsl role list | role set | role clear ', 123) return end elseif cmd == 'scene' then local sub = lower(args[2]) if sub == 'list' then status() return end local idx = tonumber(args[3] or '') if not idx or not scenes[idx] then msg('invalid scene index', 123) return end if sub == 'on' then scenes[idx].enabled = true build_index() msg(('scene %d ON'):format(idx)) return elseif sub == 'off' then scenes[idx].enabled = false build_index() msg(('scene %d OFF'):format(idx)) return elseif sub == 'toggle' then scenes[idx].enabled = not (scenes[idx].enabled ~= false) build_index() msg(('scene %d %s'):format(idx, scenes[idx].enabled and 'ON' or 'OFF')) return end end msg('unknown command', 123) end) windower.register_event('load', function() load_json() msg('loaded v' .. _addon.version .. ' -- new here? try: //wsl role list') end)