if FirstLoad then g_LipSyncData = {} end --- --- Gets the filename for a voice sample based on the given translation ID. --- --- @param T table The translation object containing the ID. --- @param seed number An optional seed value for randomizing the voice variation. --- @return string The filename of the voice sample. --- function GetVoiceFilename(T, seed) if not T then return end local id = TGetID(T) if not id then return end if g_VoiceVariations then local all_vars = g_VoiceVariations[id] or g_VoiceVariations[tostring(id)] if all_vars and #all_vars > 0 then local var = 1 + (seed and BraidRandom(seed, #all_vars) or AsyncRand(#all_vars)) return all_vars[var] end end local not_robot_voice = "CurrentLanguage/Voices/" .. id -- if not (Platform.developer and Platform.pc and config.RunUnpacked) then if not config.VoicesTTS or not GetAccountStorageOptionValue("VoicesTTS") then return not_robot_voice end if GetSoundDuration(not_robot_voice) then return not_robot_voice end local robo_voice = "CurrentLanguage/VoicesTTS/" .. id if io.exists(robo_voice .. ".opus") then return robo_voice end end -- choose the sound by the translation id of the given text --- --- Gets the filename for a voice sample based on the given translation ID and actor. --- --- @param T table The translation object containing the ID. --- @param actor string The actor for the voice sample (e.g. "male", "female"). --- @param pathName string An optional path name for the voice sample. --- @return string The filename of the voice sample. --- function VoiceSampleByText(T, actor, pathName) local id = TGetID(T) if not id then return false end local sample if actor == "male" then sample = pathName or "CurrentLanguage/Voices/Male/" .. id elseif actor == "female" then sample = pathName or "CurrentLanguage/Voices/Female/" .. id elseif actor and actor ~= "" and actor ~= "narrator" then sample = string.concat(pathName or "CurrentLanguage/Voices/", not pathName and actor or "", "/", id) else sample = pathName or "CurrentLanguage/Voices/" .. id end -- so we can play robo_voices in conversations if not io.exists(sample .. ".opus") then sample = GetVoiceFilename(T) end return sample end --- --- Generates a voice context string based on the provided field, annotation, voiced property, and annotation property. --- --- @param field string|table The field(s) to check for voice data. --- @param annotation string An optional annotation to include in the voice context. --- @param voiced_prop string An optional property to check if the object has voice data. --- @param annotation_prop string An optional property to check for additional annotation data. --- @return function A function that generates the voice context string for a given object. --- function VoicedContextFromField(field, annotation, voiced_prop, annotation_prop) return function(obj, prop_meta, parent) if type(field) == "table" then for i = 1, #field do if obj[field[i]] and obj[field[i]] ~= "" then field = field[i] break end end end local extra_annotation = annotation_prop and obj[annotation_prop] if IsT(extra_annotation) then extra_annotation = _InternalTranslate(extra_annotation) end if (not voiced_prop or obj[voiced_prop]) and obj[field] then if annotation and extra_annotation then return string.format("%s %s voice:%s", annotation, extra_annotation, obj[field]) elseif annotation or extra_annotation then return string.format("%s voice:%s", annotation or extra_annotation, obj[field]) else return string.format("voice:%s", obj[field]) end else return annotation and extra_annotation and (annotation .. " " .. extra_annotation) or annotation or extra_annotation or "" end end end --- --- Generates localization IDs for the properties of the given object. --- --- @param obj table The object to generate localization IDs for. --- @return table The modified object with localization IDs. --- function GenerateLocalizationIDs(obj) if not obj then return end if PropObjHasMember(obj, "GetProperties") then local props = obj:GetProperties() for i = 1, #props do local prop = props[i] local id = prop.id if prop.translate then local value = obj:GetProperty(id) if IsT(value) and not obj:IsDefaultPropertyValue(id, prop, value) then assert(not THasArgs(value)) obj:SetProperty(id, T {RandomLocId(), TDevModeGetEnglishText(value)}) end end local editor = prop.editor if editor == "T_list" and not obj:IsDefaultPropertyValue(id, prop, value) then local tlist = obj:GetProperty(id) for idx, text in ipairs(tlist) do tlist[idx] = T {RandomLocId(), TDevModeGetEnglishText(text)} end elseif editor == "nested_obj" then GenerateLocalizationIDs(obj:GetProperty(id)) elseif editor == "nested_list" then for _, subobj in ipairs(obj:GetProperty(id)) do GenerateLocalizationIDs(subobj) end end end end for i = 1, #obj do GenerateLocalizationIDs(obj[i], true) end return obj end g_TestUIPlatform = false -- win32 uI -- g_TestUIPlatform = "ps4" -- to test ps4 UI interface -- g_TestUIPlatform = "ps5" -- to test ps5 UI interface -- g_TestUIPlatform = "xbox" -- to test xbox UI interface -- g_TestUIPlatform = "xbox_one" -- to test xbox one UI interface -- g_TestUIPlatform = "xbox_series" -- to test xbox series UI interface -- g_TestUIPlatform = "switch" -- to test Switch UI interface --- button images remap for PlayStation --- A mapping of Xbox button names to their PlayStation equivalents. --- --- This table is used to translate button names between the Xbox and PlayStation --- controller layouts. The keys in this table are the Xbox button names, and the --- values are the corresponding PlayStation button names. --- --- For example, the Xbox "ButtonA" button maps to the PlayStation "Circle" button --- (or "Cross" button if `const.PlayStationEnterBtnCircle` is false). --- --- @field ButtonA string The PlayStation button that corresponds to the Xbox "ButtonA" button. --- @field ButtonB string The PlayStation button that corresponds to the Xbox "ButtonB" button. --- @field ButtonY string The PlayStation button that corresponds to the Xbox "ButtonY" button. --- @field ButtonX string The PlayStation button that corresponds to the Xbox "ButtonX" button. --- @field LT string The PlayStation button that corresponds to the Xbox "LT" button. --- @field RT string The PlayStation button that corresponds to the Xbox "RT" button. --- @field LS string The PlayStation button that corresponds to the Xbox "LS" button. --- @field RS string The PlayStation button that corresponds to the Xbox "RS" button. --- @field LSPress string The PlayStation button that corresponds to the Xbox "LSPress" button. --- @field RSPress string The PlayStation button that corresponds to the Xbox "RSPress" button. --- @field LB string The PlayStation button that corresponds to the Xbox "LB" button. --- @field RB string The PlayStation button that corresponds to the Xbox "RB" button. --- @field Start string The PlayStation button that corresponds to the Xbox "Start" button. --- @field Back string The PlayStation button that corresponds to the Xbox "Back" button. const.XboxToPlayStationButtons = {ButtonA=const.PlayStationEnterBtnCircle and "Circle" or "Cross", ButtonB=const.PlayStationEnterBtnCircle and "Cross" or "Circle", ButtonY="Triangle", ButtonX="Square", LT="L2", RT="R2", LS="L", RS="R", LSPress="L3", RSPress="R3", LB="L1", RB="R1", Start="Options", Back="TouchPad"} const.ShortenedButtonNames = { LeftTrigger = "LT", RightTrigger = "RT", LeftShoulder = "LB", RightShoulder = "RB", RightThumbClick = "RSPress", LeftThumbClick = "LSPress", } local TagToImageName = { ["ButtonA"] = "ButtonA", ["ButtonB"] = "ButtonB", ["ButtonX"] = "ButtonX", ["ButtonY"] = "ButtonY", ["DPad"] = "DPad", ["DPadUp"] = "DPadUp", ["DPadDown"] = "DPadDown", ["DPadLeft"] = "DPadLeft", ["DPadRight"] = "DPadRight", ["DPadUpDown"] = "DPad_Up_Down", ["DPadLeftRight"] = "DPad_Left_Right", ["LT"] = "LT", ["RT"] = "RT", ["LeftTrigger"] = "LT", ["RightTrigger"] = "RT", ["LS"] = "LS", ["RS"] = "RS", ["LSPress"] = "LSPress", ["RSPress"] = "RSPress", ["LB"] = "LB", ["RB"] = "RB", ["Start"] = "Start", ["Back"] = "Back", ["lsupdown"] = "ls_up_down", ["lsright"] = "lsright", ["lsleft"] = "lsleft", ["lsup"] = "lsup", ["lsdown"] = "lsdown", ["rsupdown"] = "rs_up_down", ["rsright"] = "rsright", ["rsleft"] = "rsleft", ["rsup"] = "rsup", ["rsdown"] = "rsdown", ["RightThumbUp"] = "rsup", ["RightThumbDown"] = "rsdown", ["Controller"] = "Controller", } -- these get auto-replaced in translated text, e.g. is the same as --- --- Recreates the lookup table that maps button tags to their corresponding platform-specific image names. --- This function is called when the active controller type changes, to ensure the correct button images are displayed. --- --- @function RecreateButtonsTagLookupTable --- @return nil function RecreateButtonsTagLookupTable() for tag, img in pairs(TagToImageName) do const.TagLookupTable[tag] = GetPlatformSpecificImageTag(img) end end --- --- Handles updating the active controller type for the PC platform. --- This function is called when the XInput controller is initialized or the active controller is updated. --- It updates the `g_PCActiveControllerType` global variable and triggers the `OnControllerTypeChanged` message if the controller type has changed. --- --- @function UpdateActiveControllerType --- @return nil function UpdateActiveControllerType() end --- --- Returns the current active controller type for the PC platform. --- --- @function GetPCActiveControllerType --- @return string|boolean the active controller type, or `false` if no controller is connected function GetPCActiveControllerType() end if Platform.pc then function UpdateActiveControllerType() if not rawget(_G, "XInput") or not IsXInputControllerConnected() or not ActiveController then return end local previous = g_PCActiveControllerType g_PCActiveControllerType = XInput.GetControllerType(ActiveController) or false if g_PCActiveControllerType ~= previous then RecreateButtonsTagLookupTable() Msg("OnControllerTypeChanged", g_PCActiveControllerType) end end OnMsg.XInputInitialized = UpdateActiveControllerType OnMsg.ActiveControllerUpdated = UpdateActiveControllerType if FirstLoad then g_PCActiveControllerType = false end function GetPCActiveControllerType() return g_PCActiveControllerType end else function GetPCActiveControllerType() end end function ShouldShowPS4Images() return Platform.ps4 or g_TestUIPlatform == "ps4" or (not g_TestUIPlatform and GetPCActiveControllerType() == "ps4") end function ShouldShowPS5Images() return Platform.ps5 or g_TestUIPlatform == "ps5" or (not g_TestUIPlatform and GetPCActiveControllerType() == "ps5") end --- --- Returns the platform-specific image name for the given button. --- --- @param btn string the button name --- @param platform string the platform name --- @return string the platform-specific image name function GetPlatformSpecificImageName(btn, platform) btn = const.ShortenedButtonNames[btn] or btn local path = "UI/DesktopGamepad/" local ext = ".tga" local btnimg = btn if platform == "ps4" then path = "UI/PS4/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif platform == "ps5" then path = "UI/PS5/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif platform == "xbox_one" then path = "UI/XboxOne/" elseif platform == "xbox_series" then path = "UI/XboxSeries/" elseif platform == "switch" then path = "UI/Switch/" else -- win32 end return path .. btnimg .. ext end --- --- Collects the resource IDs for all platform-specific button images used in the game. --- --- @param resIds table a table to store the resource IDs and their associated DLC function ReportButtonImageResources(resIds) local function AddResourcesForPlatform(platform, dlc) dlc = dlc or platform for _, btn in pairs(TagToImageName) do local name = GetPlatformSpecificImageName(btn, platform) local resId = ResourceManager.GetResourceID(name) if ResourceManager.GetResourceIDAsString(resId) ~= "" then resIds[resId] = dlc end end end AddResourcesForPlatform("win32") AddResourcesForPlatform("ps4") AddResourcesForPlatform("ps5") AddResourcesForPlatform("xbox_one", "xbox") AddResourcesForPlatform("switch") end --- Returns the platform-specific image path and scale for the given button. --- --- @param btn string the button name --- @return string the platform-specific image path --- @return number the scale factor for the image function GetPlatformSpecificImagePath(btn) btn = const.ShortenedButtonNames[btn] or btn local path = "UI/DesktopGamepad/" local ext = ".tga" local btnimg = btn if ShouldShowPS4Images() then path = "UI/PS4/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif ShouldShowPS5Images() then path = "UI/PS5/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif Platform.xbox_one then path = "UI/XboxOne/" elseif Platform.xbox_series then path = "UI/XboxSeries/" elseif Platform.switch or g_TestUIPlatform == "switch" or GetPCActiveControllerType() == "switch" then path = "UI/Switch/" else -- win32 end return path .. btnimg .. ext, 500 end --- --- Returns the platform-specific image tag for the given button. --- --- @param btn string the button name --- @param scale number (optional) the scale factor for the image --- @return string the platform-specific image tag --- function GetPlatformSpecificImageTag(btn, scale) btn = const.ShortenedButtonNames[btn] or btn local path = "UI/DesktopGamepad/" local btnimg = btn if ShouldShowPS4Images() then path = "UI/PS4/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif ShouldShowPS5Images() then path = "UI/PS5/" btnimg = (const.XboxToPlayStationButtons[btn] or btn) elseif Platform.xbox_one then path = "UI/XboxOne/" elseif Platform.xbox_series then path = "UI/XboxSeries/" elseif Platform.switch or g_TestUIPlatform == "switch" or GetPCActiveControllerType() == "switch" then path = "UI/Switch/" else -- win32 end if scale then return string.format("", path, btnimg, tonumber(scale) or 1000) else return string.format("", path, btnimg) end end OnMsg.XInputInitialized = RecreateButtonsTagLookupTable RecreateButtonsTagLookupTable() -- lookup tags with Untranslated() const.TagLookupTable["tm"] = Untranslated("™") -- ( tm TM trademark trade mark ) symbol const.TagLookupTable["copyright"] = Untranslated("©") -- ( copyright (c) (C) copy right ) symbol const.TagLookupTable["registered"] = Untranslated("®") -- ( registered (r) (R) ) symbol const.TagLookupTable["nbsp"] = Untranslated(" ") -- ( non-breaking space nonbreaking space non breaking space ) symbol local replace_map = { ["`"] = "'", ["‘"] = "'", ["’"] = "'", ["“"] = "\"", ["”"] = "\"", ["–"] = "-", ["—"] = "-", ["−"] = "-", ["…"] = "...", } --- Replaces non-standard characters in the input string with their standard counterparts. --- --- @param s string The input string to replace non-standard characters in. --- @return string The input string with non-standard characters replaced. function ReplaceNonStandardCharacters(s) for k, v in pairs(replace_map) do s = s:gsub(k, v) end return s end changed = {} --- --- Fixes up preset text properties by replacing non-standard characters. --- --- This function iterates through all presets in the `Presets` table, and for each text property --- that has a non-standard character, it replaces the character with its standard counterpart. --- It then saves all modified presets to disk. --- --- @param none --- @return none --- function FixupPresetTs() local count = 0 local validation_start = GetPreciseTicks() PauseInfiniteLoopDetection("FixupPresetTs") local eval = prop_eval local dirty = {} for class_name, presets in pairs(Presets) do for _, group in ipairs(presets) do for _, preset in ipairs(group) do preset:ForEachSubObject(function(obj) for _, prop in ipairs(obj:GetProperties()) do if prop.editor == "text" and eval(prop.translate, obj, prop) then local t = obj:GetProperty(prop.id) if t and t ~= "" then local id, text = TGetID(t) or RandomLocId(), TDevModeGetEnglishText(t) local new_text = ReplaceNonStandardCharacters(text) if text ~= new_text then obj:SetProperty(prop.id, T(id, new_text)) table.insert(changed, preset.class .. " " .. new_text) dirty[class_name] = true count = count + 1 end end end end end) end end end for class_name in pairs(dirty) do _G[class_name]:SaveAll("force save all") end ResumeInfiniteLoopDetection("FixupPresetTs") CreateMessageBox(nil, Untranslated("Fixup Ts"), Untranslated( string.format("Changed a total of %d texts for %d ms", count, GetPreciseTicks() - validation_start))) end local diacritics_map = { ["À"] = "A", ["Á"] = "A", ["Â"] = "A", ["Ã"] = "A", ["Ä"] = "A", ["Å"] = "A", ["Æ"] = "AE", ["Ç"] = "C", ["È"] = "E", ["É"] = "E", ["Ê"] = "E", ["Ë"] = "E", ["Ì"] = "I", ["Í"] = "I", ["Î"] = "I", ["Ï"] = "I", ["Ð"] = "D", ["Ñ"] = "N", ["Ò"] = "O", ["Ó"] = "O", ["Ô"] = "O", ["Õ"] = "O", ["Ö"] = "O", ["Ø"] = "O", ["Ù"] = "U", ["Ú"] = "U", ["Û"] = "U", ["Ü"] = "U", ["Ý"] = "Y", ["Þ"] = "P", ["ß"] = "s", ["à"] = "a", ["á"] = "a", ["â"] = "a", ["ã"] = "a", ["ä"] = "a", ["å"] = "a", ["æ"] = "ae", ["ç"] = "c", ["è"] = "e", ["é"] = "e", ["ê"] = "e", ["ë"] = "e", ["ì"] = "i", ["í"] = "i", ["î"] = "i", ["ï"] = "i", ["ð"] = "eth", ["ñ"] = "n", ["ò"] = "o", ["ó"] = "o", ["ô"] = "o", ["õ"] = "o", ["ö"] = "o", ["ø"] = "o", ["ù"] = "u", ["ú"] = "u", ["û"] = "u", ["ü"] = "u", ["ý"] = "y", ["þ"] = "p", ["ÿ"] = "y", } --- --- Removes diacritical marks (accents, umlauts, etc.) from the given string. --- --- @param s string The input string to remove diacritical marks from. --- @return string The input string with diacritical marks removed. function RemoveDiacritics(s) return s:gsub("[%z\1-\127\194-\244][\128-\191]*", diacritics_map) end -------------------- Tokenization ------------------------- tag_processors = {} DefineClass.XTextToken = { __parents = { "PropertyObject" }, text = false, type = false, args = false, } -- Text -> Tokens --- --- Tokenizes the given input text into a stream of tokens. --- --- @param input_text string The input text to tokenize. --- @param token_func function (optional) A custom token processing function. If not provided, a default function will be used. --- @param stream table (optional) The output stream to append tokens to. If not provided, a new stream will be created. --- @return table The stream of tokens. function XTextTokenize(input_text, token_func, stream) if not token_func then stream = stream or {} token_func = function(stream, ttype, args, text) if text == "" then return end local next_token = XTextToken:new({type=ttype, text=text, args=args}) table.insert(stream, next_token) end end if type(input_text) ~= "string" or not utf8.IsValidString(input_text) then token_func(stream, "text", false, "Not a valid UTF-8 string:" .. string.gsub(input_text, "[^a-zA-Z0-9\n <%>-%:%(%)\\/]", ".")) return stream end local byte_idx = 1 local input_text_bytes_len = #input_text local tags_on = true while byte_idx <= input_text_bytes_len do local start_byte_idx, end_byte_idx = string.find(input_text, "][^>]*>", byte_idx) if not start_byte_idx then start_byte_idx = input_text_bytes_len + 1 end token_func(stream, "text", false, string.sub(input_text, byte_idx, start_byte_idx - 1)) byte_idx = start_byte_idx + 1 -- grab any available non text tokens if end_byte_idx then -- we're done byte_idx = end_byte_idx + 1 local token = string.sub(input_text, start_byte_idx + 1, end_byte_idx - 1) local elements if token:find("%s") then elements = {} for part in string.gmatch(token, "[^%s]+") do table.insert(elements, part) end -- collapse quoted strings local i = 1 while i <= #elements do if elements[i]:starts_with("'") then if i < #elements and not elements[i]:ends_with("'") then elements[i] = string.format("%s %s", elements[i], elements[i + 1]) table.remove(elements, i + 1) else if elements[i]:ends_with("'") then elements[i] = elements[i]:sub(2, -2) end i = i + 1 end else i = i + 1 end end end local tag = elements and elements[1] or token if tag == "tags" then tags_on = elements and elements[2] == "on" elseif tag == "literal" and tonumber(elements and elements[2]) then local offset = tonumber(elements[2]) offset = Min(Max(0, offset), input_text_bytes_len) byte_idx = Max(end_byte_idx + 1, Min(input_text_bytes_len + 1, end_byte_idx + offset + 1)) token_func(stream, "text", false, string.sub(input_text, end_byte_idx + 1, byte_idx - 1)) elseif token == "" or not tag_processors[tag] or tag == "text" or not tags_on then token_func(stream, "text", false, "<" .. token .. ">") else if elements then table.remove(elements, 1) end token_func(stream, tag, elements, token) end end end return stream end local starts_with = string.starts_with local string_gmatch = string.gmatch --- --- Counts the number of words in the given line of text. --- --- @param line string The input line of text to count words in. --- @return integer The number of words in the input line. function CountWords(line) local count = 0 for _, span in ipairs(XTextTokenize(line)) do if span.type == "text" then local text = span.text if not starts_with(text, "<") then for word in string_gmatch(text, "[^%s]+") do count = count + 1 end end end end return count end