--Copyright (c) 2014~2020, Byrthnoth --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 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 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 = 'Translate' _addon.version = '2.0.0.0' _addon.author = 'Byrth' _addon.commands = {'trans','translate'} language = 'english' trans_list = {} res = require 'resources' require 'sets' require 'lists' require 'pack' require 'strings' require 'katakana_to_romanji' search_comment = {ts = 0, reg = L{}, translated = false} handled_resources = S{ 'ability_recasts', 'auto_translates', 'buffs', 'days', 'elements', 'items', 'job_abilities', 'job_traits', 'jobs', 'key_items', 'monster_abilities', 'monstrosity', 'moon_phases', 'races', 'regions', 'skills', 'spells', 'titles', 'weapon_skills', 'weather', 'zones' } green_open = string.char(0xEF,0x27) red_close = string.char(0xEF,0x28) green_col = ''--string.char(0x1E,2) rcol = ''--string.char(0x1E,1) local temp_str function to_a_code(num) local first_byte,second_byte = math.floor(num/256),num%256 if first_byte == 0 or second_byte == 0 then return nil end return string.char(0xFD,2,2,first_byte,second_byte,0xFD):escape() -- 0xFD,2,2,8,37,0xFD :: 37 = % end function to_item_code(id) local first_byte,second_byte = math.floor(id/256),id%256 local t = 0x07 if first_byte == 0 then t = 0x09 first_byte = 0xFF elseif second_byte == 0 then t = 0x0A second_byte = 0xFF end return string.char(0xFD,t,2,first_byte,second_byte,0xFD):escape() end function to_ki_code(id) local first_byte,second_byte = math.floor(id/256),id%256 local t = 0x13 if first_byte == 0 then t = 0x15 first_byte = 0xFF elseif second_byte == 0 then t = 0x16 second_byte = 0xFF end return string.char(0xFD,t,2,first_byte,second_byte,0xFD):escape() end function sanity_check(ja) return (ja and string.len(ja) > 0 and ja ~= '%.') end function load_dict(dict) for _,res_line in pairs(dict) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() local jps = windower.to_shift_jis(res_line.jas or ''):escape() if sanity_check(jp) and sanity_check(res_line.en) and jp~= res_line.en:escape() then trans_list[jp] = green_col..res_line.en..rcol end if sanity_check(jps) and sanity_check(res_line.ens) and jps ~= res_line.ens:escape() then trans_list[jps] = green_col..res_line.ens..rcol end end end load_dict(katakana_to_romanji) for res_name in pairs(handled_resources) do local resource = res[res_name] or {} if res_name == 'auto_translates' then for autotranslate_code,res_line in pairs(resource) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() if sanity_check(jp) and not trans_list[jp] and jp ~= res_line.en:escape() then trans_list[jp] = to_a_code(autotranslate_code) end end elseif res_name == 'items' then for id,res_line in pairs(resource) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() if sanity_check(jp) and not trans_list[jp] and jp ~= res_line.en:escape() then trans_list[jp] = to_item_code(id) end end elseif res_name == 'key_items' then for id,res_line in pairs(resource) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() if sanity_check(jp) and not trans_list[jp] and jp ~= res_line.en:escape() then trans_list[jp] = to_ki_code(id) end end elseif res_name == 'jobs' then for _,res_line in pairs(resource) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() local jps = windower.to_shift_jis(res_line.jas or ''):escape() if sanity_check(jp) and sanity_check(res_line.en) and jp ~= res_line.en:escape() then trans_list[jp] = green_col..res_line.en..rcol:escape() end if sanity_check(jps) and sanity_check(res_line.ens) and jp ~= res_line.en:escape() and jps ~= res_line.ens:escape() and res_line.ens ~= 'PUP' then trans_list[jps] = green_col..res_line.ens..rcol:escape() end end else for _,res_line in pairs(resource) do local jp = windower.to_shift_jis(res_line.ja or ''):escape() local jps = windower.to_shift_jis(res_line.jas or ''):escape() if sanity_check(jp) and not trans_list[jp] and sanity_check(res_line.en) and jp ~= res_line.en:escape() then trans_list[jp] = green_col..res_line.en..rcol:escape() end if sanity_check(jps) and not trans_list[jps] and sanity_check(res_line.ens) and jp ~= res_line.en:escape() and jps ~= res_line.ens:escape() then trans_list[jps] = green_col..res_line.ens..rcol:escape() end end end end local custom_dict_names = S(windower.get_dir(windower.addon_path..'dicts/')):filter(string.endswith-{'.lua'}):map(string.sub-{1, -5}) for dict_name in pairs(custom_dict_names) do local dict = dofile(windower.addon_path..'dicts/'..dict_name..'.lua') if dict then load_dict(dict) end end function print_bytes(str) local c = '' local i = 1 while i <= #str do c = c..' '..str:byte(i) i = i + 1 end return c end trans_list[string.char(0x46)] = nil trans_list['\.'] = nil windower.register_event('incoming chunk',function(id,orgi,modi,is_injected,is_blocked) if id == 0x17 and not is_injected and not is_blocked then local out_text = modi:unpack('z',0x18) out_text = translate_phrase(out_text) if not out_text then return end if show_original then windower.add_to_chat(8,modi:sub(9,0x17):unpack('z',1)..'[Original]: '..modi:unpack('z',0x18)) end while #out_text > 0 do local boundary = get_boundary_length(out_text,151) local len = math.ceil((boundary+1+23)/2) -- Make sure there is at least one nul after the string local out_pack = string.char(0x17,len)..modi:sub(3,0x17)..out_text:sub(1,boundary) -- zero pad it while #out_pack < len*2 do out_pack = out_pack..string.char(0) end windower.packets.inject_incoming(0x17,out_pack) out_text = out_text:sub(boundary+1) end return true end end) function translate_phrase(out_text) local matches,match_bool = {},false local function make_matches(catch) -- build a table of matches indexed by their length local esc = catch:escape() if not sanity_check(esc) then return end if not matches[#catch] then matches[#catch] = {} end matches[#catch][#matches[#catch]+1] = esc match_bool = true end for jp,en in pairs(trans_list) do out_text:gsub(jp,make_matches) end if not match_bool then return end local order = {} for len,_ in pairs(matches) do local c,found = 1,false while c <= #order do if len > order[c] then table.insert(order,c,len) found = true break end c = c + 1 end if c > #order then order[c] = len end end for _,ind in ipairs(order) do for _,option in ipairs(matches[ind]) do out_text = sjis_gsub(out_text,unescape(option),unescape(trans_list[option])) end end return out_text end function get_boundary_length(str,limit) -- If it is already short enough, return it if #str <= limit then return #str end local lim = 0 for i= 1,#str do local c_byte = str:byte(i) if c_byte == 0xFD then i = i + 6 elseif ( (c_byte > 0x7F and c_byte <= 0xA0) or c_byte >= 0xE0) then i = i + 2 else i = i + 1 end if i > limit then return lim else lim = i end end -- Otherwise, try to pick a spot to split that will not interfere with command codes and such --[[ local boundary = limit for i=limit-5,limit do local c_byte = str:byte(i) if c_byte ==0xFD then -- 0xFD: Autotranslate code, 6 bytes boundary = i-1 break elseif c_byte == 0xEF and str:byte(i+1) == 0x27 then -- Opening green ( boundary = i-1 break elseif i == limit and ( (c_byte > 0x7F and c_byte <= 0xA0) or c_byte >= 0xE0) then -- Double-byte shift_JIS character boundary = i-1 break end end return boundary]] end windower.register_event('addon command', function(...) local commands = {...} if not commands[1] then return end if commands[1]:lower() == 'show' then if commands[2] and commands[2]:lower() == 'original' then show_original=not show_original if show_original then print('Translate: Showing the original text line.') else print('Translate: Hiding the original text line.') end end elseif commands[1]:lower() == 'eval' and commands[2] then table.remove(commands,1) assert(loadstring(table.concat(commands, ' ')))() end end) windower.register_event('incoming text',function(org,mod,ocol,mcol,blk) if not blk and ocol == 204 then local ret = translate_phrase(mod) temp_str = ret or org if ret then if show_original then windower.add_to_chat:schedule(0.3, 8,'[Original]: '..org) end mod = ret if org == temp_str then blk = true temp_str = '' end return blk and blk or mod end end end) function unescape(str) return (str:gsub('%%([%%%%^%$%*%(%)%.%+%?%-%]%[])','%1')) end -- Two problems with how I currently do this: -- 1: It is possible to have something like 0x94, (0x92, 0x8B,) 0xE1, which are two JP characters that contain a third. -- 2: It is possible to have a gsub replace something with an autotranslate code, which then causes a later dictionary -- option to match part of the replacement. -- If I solve #1, will #2 be an issue? No, it should not be. function sjis_gsub(str,pattern,rep) if not (type(rep) == 'function' or type(rep) == 'string') then return str end local str_len,pat_len,ret_str = string.len(str),string.len(pattern),str local i = 1 while i<=str_len-pat_len+1 do local c_byte = str:byte(i) if str:sub(i,i+pat_len-1) == pattern then if type(rep) == 'function' then ret_str = rep(pattern) or str -- No recursion for functions at the moment, because this addon doesn't need it return elseif type(rep) == 'string' then if i ~= 1 then -- Not the beginning ret_str = str:sub(1,i-1)..rep else -- The beginning ret_str = rep end if i+pat_len <= str_len-pat_len+1 then -- i == 13, pat_len == 2, str_len == 16 -- Match is characters 13 and 14. Could conceivably match again to characters 15 and 16. -- Send the remainder of the string back through recursively. return ret_str..sjis_gsub(str:sub(i+pat_len),pattern,rep) elseif i+pat_len <= str_len then -- i == 14, pat_len == 2, str_len == 16 -- Match is characters 14 and 15, so 16 can't possibly be a match but needs to be stuck on there return ret_str..str:sub(i+pat_len) else -- i == 15, pat_len == 2, str_len == 16 -- Match is characters 15 and 16, so no further addition is necessary return ret_str end end elseif c_byte == 0xFD then i = i + 6 elseif ( (c_byte > 0x7F and c_byte <= 0xA0) or c_byte >= 0xE0) then i = i + 2 else i = i + 1 end end return ret_str end