- Introduction
- Plugin Frameworks
- Installation
- Key Mapping
- Translators
- Text Frameworks
- Configuration
- IL2CPP Support
- Frequently Asked Questions
- Translating Mods
- Manual Translations
- Regarding Redistribution
- Texture Translation
- Integrating with Auto Translator
- Implementing a Translator
- Implementing a Resource Redirector
This is an advanced translator plugin that can be used to translate Unity-based games automatically and also provides the tools required to translate games manually.
It does (obviously) go to the internet, in order to provide the automated translation, so if you are not comfortable with that, don't use it.
If you intend on redistributing this plugin as part of a translation suite for a game, please read this section and the section regarding manual translations so you understand how the plugin operates.
The mod can be installed without any external dependencies or as a plugin to the following Plugin Managers/Mod Loaders:
- BepInEx (recommended)
- MelonLoader
- IPA
- UnityInjector
Installation instructions for all methods can be found below.
The plugin can be installed in following ways:
REQUIRES: Nothing, ReiPatcher is provided by this download.
VERY IMPORTANT NOTE: Using this method is a certain way to get the plugin working in most Unity games with two simple clicks. Do note that if one of the supported Plugin Managers is used, this installation method should be avoided as it will cause problems.
- Read the
VERY IMPORTANT NOTE
above. - Download XUnity.AutoTranslator-ReiPatcher-{VERSION}.zip from releases.
- Extract directly into the game directory, such that "SetupReiPatcherAndAutoTranslator.exe" is placed alongside other exe files.
- Execute "SetupReiPatcherAndAutoTranslator.exe". This will setup up ReiPatcher correctly.
- Execute the shortcut {GameExeName} (Patch and Run).lnk that was created besides existing executables. This will patch and launch the game.
- From now on you can launch the game from the {GameExeName}.exe instead.
- Due to various considerations, not all text hooks are enabled by default, so if you find that the game or parts of the game are not being properly translated it may be worth going into the configuration file and enable some of the disabled text frameworks! The configuration file is created when the game is launched.
The file structure should like like this
{GameDirectory}/ReiPatcher/Patches/XUnity.AutoTranslator.Patcher.dll
{GameDirectory}/ReiPatcher/ExIni.dll
{GameDirectory}/ReiPatcher/Mono.Cecil.dll
{GameDirectory}/ReiPatcher/Mono.Cecil.Inject.dll
{GameDirectory}/ReiPatcher/Mono.Cecil.Mdb.dll
{GameDirectory}/ReiPatcher/Mono.Cecil.Pdb.dll
{GameDirectory}/ReiPatcher/Mono.Cecil.Rocks.dll
{GameDirectory}/ReiPatcher/ReiPatcher.exe
{GameDirectory}/{GameExeName}_Data/Managed/ReiPatcher.exe
{GameDirectory}/{GameExeName}_Data/Managed/XUnity.Common.dll
{GameDirectory}/{GameExeName}_Data/Managed/XUnity.ResourceRedirector.dll
{GameDirectory}/{GameExeName}_Data/Managed/XUnity.AutoTranslator.Plugin.Core.dll
{GameDirectory}/{GameExeName}_Data/Managed/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
{GameDirectory}/{GameExeName}_Data/Managed/MonoMod.RuntimeDetour.dll
{GameDirectory}/{GameExeName}_Data/Managed/MonoMod.Utils.dll
{GameDirectory}/{GameExeName}_Data/Managed/Mono.Cecil.dll
{GameDirectory}/{GameExeName}_Data/Managed/0Harmony.dll
{GameDirectory}/{GameExeName}_Data/Managed/ExIni.dll
{GameDirectory}/{GameExeName}_Data/Managed/Translators/{Translator}.dll
{GameDirectory}/AutoTranslator/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
NOTE: The Mono.Cecil.dll
file placed in the ReiPatcher directory is not the same file as is placed in the Managed directory.
REQUIRES: BepInEx plugin manager (follow its installation instructions first!).
- Download XUnity.AutoTranslator-BepInEx-{VERSION}.zip from releases.
- Extract directly into the game directory, such that the plugin dlls are placed in BepInEx folder.
- Launch the game.
- Due to various considerations, not all text hooks are enabled by default, so if you find that the game or parts of the game are not being properly translated it may be worth going into the configuration file and enable some of the disabled text frameworks! The configuration file is created when the game is launched.
The file structure should like like this:
{GameDirectory}/BepInEx/core/XUnity.Common.dll
{GameDirectory}/BepInEx/plugins/XUnity.ResourceRedirector/XUnity.ResourceRedirector.dll
{GameDirectory}/BepInEx/plugins/XUnity.ResourceRedirector/XUnity.ResourceRedirector.BepInEx.dll
{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.Core.dll
{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.BepInEx.dll
{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/ExIni.dll
{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/Translators/{Translator}.dll
{GameDirectory}/BepInEx/core/MonoMod.RuntimeDetour.dll
{GameDirectory}/BepInEx/core/MonoMod.Utils.dll
{GameDirectory}/BepInEx/core/Mono.Cecil.dll
{GameDirectory}/BepInEx/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
The instructions for installation for IL2CPP are the same as for the standard version except that you must install BepInEx 6 for IL2CPP, which as of this writing are only available as bleeding edge builds right here and you must use the BepInEx-IL2CPP
package of this plugin instead.
The current version (5.3.0) was built against bleeding edge build 672.
REQUIRES: Melon Loader (follow its installation instructions first!).
- Download XUnity.AutoTranslator-MelonMod-{VERSION}.zip from releases.
- Extract directly into the game directory, such that the plugin dlls are placed in Mods and UserLibs folders.
- Launch the game.
- Due to various considerations, not all text hooks are enabled by default, so if you find that the game or parts of the game are not being properly translated it may be worth going into the configuration file and enable some of the disabled text frameworks! The configuration file is created when the game is launched.
The file structure should like like this:
{GameDirectory}/Mods/XUnity.AutoTranslator.Plugin.MelonMod.dll
{GameDirectory}/UserLibs/XUnity.Common.dll
{GameDirectory}/UserLibs/XUnity.ResourceRedirector.dll
{GameDirectory}/UserLibs/XUnity.AutoTranslator.Plugin.Core.dll
{GameDirectory}/UserLibs/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
{GameDirectory}/UserLibs/ExIni.dll
{GameDirectory}/UserLibs/Translators/{Translator}.dll
{GameDirectory}/AutoTranslator/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
The current version (5.3.0) was built against v0.6.1 Open-Beta.
The instructions for installation for IL2CPP are the same as for the standard version except that you must use the MelonMod-IL2CPP
package of this plugin instead.
REQUIRES: IPA plugin manager (follow its installation instructions first!).
- Download XUnity.AutoTranslator-IPA-{VERSION}.zip from releases.
- Extract directly into the game directory, such that the plugin dlls are placed in Plugins folder.
- Launch the game.
- Due to various considerations, not all text hooks are enabled by default, so if you find that the game or parts of the game are not being properly translated it may be worth going into the configuration file and enable some of the disabled text frameworks! The configuration file is created when the game is launched.
The file structure should like like this
{GameDirectory}/Plugins/XUnity.Common.dll
{GameDirectory}/Plugins/XUnity.ResourceRedirector.dll
{GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.Core.dll
{GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.IPA.dll
{GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
{GameDirectory}/Plugins/MonoMod.RuntimeDetour.dll
{GameDirectory}/Plugins/MonoMod.Utils.dll
{GameDirectory}/Plugins/Mono.Cecil.dll
{GameDirectory}/Plugins/0Harmony.dll
{GameDirectory}/Plugins/ExIni.dll
{GameDirectory}/Plugins/Translators/{Translator}.dll
{GameDirectory}/Plugins/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
REQUIRES: UnityInjector (follow its installation instructions first!).
- Download XUnity.AutoTranslator-UnityInjector-{VERSION}.zip from releases.
- Extract directly into the game directory, such that the plugin dlls are placed in UnityInjector folder. This may not be game root directory!
- Launch the game.
- Due to various considerations, not all text hooks are enabled by default, so if you find that the game or parts of the game are not being properly translated it may be worth going into the configuration file and enable some of the disabled text frameworks! The configuration file is created when the game is launched.
The file structure should like like this
{GameDirectory}/UnityInjector/XUnity.Common.dll
{GameDirectory}/UnityInjector/XUnity.ResourceRedirector.dll
{GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.Core.dll
{GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.UnityInjector.dll
{GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
{GameDirectory}/UnityInjector/0Harmony.dll
{GameDirectory}/UnityInjector/Translators/{Translator}.dll
{GameDirectory}/UnityInjector/Config/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
NOTE: MonoMod hooks are not supported with this installation method because an outdated version of Mono.Cecil.dll
is being used with Sybaris.
The following key inputs are mapped:
- ALT + 0: Toggle XUnity AutoTranslator UI. (That's a zero, not an O)
- ALT + 1: Toggle Translation Aggregator UI.
- ALT + T: Alternate between translated and untranslated versions of all texts provided by this plugin.
- ALT + R: Reload translation files. Useful if you change the text and texture files on the fly. Not guaranteed to work for all textures.
- ALT + U: Manual hooking. The default hooks wont always pick up texts. This will attempt to make lookups manually. Will not hook text components from frameworks not enabled.
- ALT + F: If OverrideFont is configured, will toggle between overridden and default font.
- ALT + Q: Reboot the plugin if it was shutdown. This will only work if the plugin was shut down due to consecutive errors towards the translation endpoint. Should only be used if you have reason to believe you have remedied the problem (such as changed VPN endpoint etc.) otherwise it will just shut down again.
Debugging-only keys:
- CTRL + ALT + NP9: Simulate synchronous errors
- CTRL + ALT + NP8: Simulate asynchronous errors delayed by one second
- CTRL + ALT + NP7: Print out loaded scene names and ids to console
- CTRL + ALT + NP6: Print out entire GameObject hierarchy to file
hierarchy.txt
The supported translators are:
- GoogleTranslate, based on the online Google translation service. Does not require authentication.
- No limitations, but unstable.
- GoogleTranslateV2, based on the online Google translation service. Does not require authentication.
- No limitations, but unstable. Currently being tested. May replace original version in future since that API is no longer used on their official translator web.
- GoogleTranslateCompat, same as the above, except requests are served out-of-process which is needed in some versions of Unity/Mono.
- No limitations, but unstable.
- GoogleTranslateLegitimate, based on the Google cloud translation API. Requires an API key.
- Provides trial period of 1 year with $300 credits. Enough for 15 million characters translations.
- BingTranslate, based on the online Bing translation service. Does not require authentication.
- No limitations, but unstable.
- BingTranslateLegitimate, based on the Azure text translation. Requires an API key.
- Free up to 2 million characters per month.
- DeepLTranslate, based on the online DeepL translation service. Does not require authentication.
- No limitations, but unstable. Remarkable quality.
- DeepLTranslateLegitimate, based on the online DeepL translation service. Requires an API Key.
- $4.99 per month and $20 per million characters translated that month.
- Free up to 0.5 million characters per month.
- For now, you must subscribe to DeepL API (for Developers). - DOES NOT WORK WITH DeepL Pro (Starter, Advanced and Ultimate)
- PapagoTranslate, based on the online Papago translation service. Does not require authentication.
- No limitations, but unstable.
- BaiduTranslate, based on Baidu translation service. Requires AppId and AppSecret.
- After registration, the first 50,000 characters per month are free (QPS=1), and 49 yuan/million characters are charged after that. If you have passed the free identity authentication, then the first 1 million characters per month are free (QPS=10), and the excess is charged at 49 yuan/million characters. The longest single request is 6000 characters.
- YandexTranslate, based on the Yandex translation service. Requires an API key.
- Free up to 1 million characters per day, but max 10 million characters per month.
- WatsonTranslate, based on IBM's Watson. Requires a URL and an API key.
- Free up to 1 million characters per month.
- LecPowerTranslator15, based on LEC's Power Translator. Does not require authentication, but does require the software installed.
- No limitations.
- ezTrans XP, based on Changsinsoft's japanese-korean translator ezTrans XP. Does not require authentication, but does require the software and Ehnd installed.
- No limitations.
- Sugoi Translator, currently requires external translator plugin.
- No limitations. Remarkable quality.
- LingoCloudTranslate, based on the online LingoCloud translation service. Translation is only supported in Chinese and two other languages: Japanese and English.
- After registration and free certification, the first 1 million characters per month are free, and the excess will be charged at 20 yuan/million characters.The official test token is
3975l6lr5pcbvidl6jl2
, you can try it before registering.
- After registration and free certification, the first 1 million characters per month are free, and the excess will be charged at 20 yuan/million characters.The official test token is
- CustomTranslate. Alternatively you can also specify any custom HTTP url that can be used as a translation endpoint (GET request). This must use the query parameters "from", "to" and "text" and return only a string with the result (try HTTP without SSL first, as unity-mono often has issues with SSL).
- NOTE: This is a developer-centric option. You cannot simply specify "CustomTranslate" and expect it to work with any arbitrary translation service you find online. See FAQ
- Example Configuration:
- Endpoint=CustomTranslate
- [Custom]
- Url=http://my-custom-translation-service.net/translate
- Example Request: GET http://my-custom-translation-service.net/translate?from=ja&to=en&text=こんにちは
- Example Response (only body): Hello
- Known implementations that can be used with CustomTranslate:
NOTE: If you use any of the online translators that does not require some form of authentication, that this plugin may break at any time.
Since 3.0.0, you can also implement your own translators. To do so, follow the instruction here.
If you decide to use an authenticated service do not ever share your key or secret. If you do so by accident, you should revoke it immediately. Most, if not all services provides an option for this.
If you want to use a paid option, remember to check if that plugin supports the language you want to translate from and to before paying. Also, while the plugin does attempt to keep the amount of requests sent to the translation endpoint to a minimum, there are no guarantees about how much it will ask the endpoint to translate, and the author/owner of this repository takes no responsibility for any charges you may receive from your selected translation provider as a result of using this plugin.
How the plugin attempts to minmize the number of requests it sends out is outlined here.
The plugin employs the following spam prevention mechanisms:
- When it sees a new text, it will always wait one second before it queues a translation request, to check if that same text changes. It will not send out any request until the text has not changed for 1 second.
- It will never send out more than 8000 requests (max 200 characters each (configurable)) during a single game session.
- It will never send out more than 1 request at a time (no concurrency!).
- If it detects an increasing number of queued translations (4000), the plugin will shutdown.
- If the service returns no result for five consecutive requests, the plugin will shutdown.
- If the plugin detects that the game queues translations every frame, the plugin will shutdown after 90 frames.
- If the plugin detects text that "scrolls" into place, the plugin will shutdown. This is detected by inspecting all requests that are queued for translation. ((1) will genenerally prevent this from happening)
- If the plugin consistently queues translations every second for more than 60 seconds, the plugin will shutdown.
- For the supported languages, each translatable line must pass a symbol check that detects if the line includes characters from the source language.
- It will never attempt a translation for a text that is already considered a translation for something else.
- All queued translations are kept track of. If two different components that require the same translation and both are queued for translation at the same time, only a single request is sent.
- It employs an internal dictionary of manual translations (~2000 in total) for commonly used phrases (Japanese-to-English only) to prevent sending out translation requests for these.
- Some endpoints support batching of translations so far fewer requests are sent. This does not increase the total number of translations per session (2).
- All translation results are cached in memory and stored on disk to prevent making the same translation request twice.
- Due to its spammy nature, any text that comes from an IMGUI component has any numbers found in it templated away (and substituted back in upon translation) to prevent issues in relation to (6).
- The plugin will keep a single TCP connection alive towards the translation endpoint. This connection will be gracefully closed if it is not used for 50 seconds.
The following text frameworks are supported.
- UGUI
- NGUI
- IMGUI (disabled by default)
- TextMeshPro
- TextMesh (disabled by default, often text float in 3D space)
- FairyGUI for Unity
- Utage (VN Game Engine)
The default configuration file, looks as such:
[Service]
Endpoint=GoogleTranslate ;Endpoint to use. See the [translators section](#translators) for valid values.
FallbackEndpoint= ;Endpoint to automatically fallback to if the primary endpoint fails for a specific translation.
[General]
Language=en ;The language to translate into
FromLanguage=ja ;The original language of the game. "auto" is also supported for some endpoints, but it is generally not recommended
[Files]
Directory=Translation\{Lang}\Text ;Directory to search for cached translation files. Can use placeholder: {GameExeName}, {Lang}
OutputFile=Translation\{Lang}\Text\_AutoGeneratedTranslations.txt ;File to insert generated translations into. Can use placeholders: {GameExeName}, {Lang}
SubstitutionFile=Translation\{Lang}\Text\_Substitutions.txt ;File that contains substitution applied before translations. Can use placeholders: {GameExeName}, {Lang}
PreprocessorsFile=Translation\{Lang}\Text\_Preprocessors.txt ;File that contains preprocessors to be applied before sending a text to a translator. Can use placeholders: {GameExeName}, {Lang}
PostprocessorsFile=Translation\{Lang}\Text\_Postprocessors.txt ;File that contains postprocessors to be applied after receiving a text from a translator. Can use placeholders: {GameExeName}, {Lang}
[TextFrameworks]
EnableUGUI=True ;Enable or disable UGUI translation
EnableNGUI=True ;Enable or disable NGUI translation
EnableTextMeshPro=True ;Enable or disable TextMeshPro translation
EnableTextMesh=False ;Enable or disable TextMesh translation
EnableIMGUI=False ;Enable or disable IMGUI translation
[Behaviour]
MaxCharactersPerTranslation=200 ;Max characters per text to translate. Max 2500.
IgnoreWhitespaceInDialogue=True ;Whether or not to ignore whitespace, including newlines, in dialogue keys
IgnoreWhitespaceInNGUI=True ;Whether or not to ignore whitespace, including newlines, in NGUI
MinDialogueChars=20 ;The length of the text for it to be considered a dialogue
ForceSplitTextAfterCharacters=0 ;Split text into multiple lines once the translated text exceeds this number of characters
CopyToClipboard=False ;Whether or not to copy hooked texts to clipboard
MaxClipboardCopyCharacters=450 ;Max number of characters to hook to clipboard at a time
ClipboardDebounceTime=1.25 ;The number of seconds it takes for hooked text to reach the clipboard. Minimum is 0.1
EnableUIResizing=True ;Whether or not the plugin should provide a "best attempt" at resizing UI components upon translation
EnableBatching=True ;Indicates whether batching of translations should be enabled for supported endpoints
UseStaticTranslations=True ;Indicates whether or not to use translations from the included static translation cache
OverrideFont= ;Overrides the fonts used for texts when updating text components. NOTE: Only works for UGUI
OverrideFontTextMeshPro= ;Consider using FallbackFontTextMeshPro instead. Overrides the fonts used for texts when updating text components. NOTE: Only works for TextMeshPro
FallbackFontTextMeshPro= ;Adds a fallback font for TextMeshPro in case a specific character is not supported. This is recommended over OverrideFontTextMeshPro
ResizeUILineSpacingScale= ;A decimal value that the default line spacing should be scaled by during UI resizing, for example: 0.80. NOTE: Only works for UGUI
ForceUIResizing=True ;Indicates whether the UI resize behavior should be applied to all UI components regardless of them being translated.
IgnoreTextStartingWith=\u180e; ;Indicates that the plugin should ignore any strings starting with certain characters. This is a list seperated by ';'.
TextGetterCompatibilityMode=False ;Indicates whether or not to enable "Text Getter Compatibility Mode". Should only be enabled if required by the game.
GameLogTextPaths= ;Indicates specific paths for game objects that the game uses as "log components", where it continuously appends or prepends text to. Requires expert knowledge to setup. This is a list seperated by ';'.
RomajiPostProcessing=ReplaceMacronWithCircumflex;RemoveApostrophes;ReplaceHtmlEntities ;Indicates what type of post processing to do on 'translated' romaji texts. This can be important in certain games because the font used does not support various diacritics properly. This is a list seperated by ';'. Possible values: ["RemoveAllDiacritics", "ReplaceMacronWithCircumflex", "RemoveApostrophes", "ReplaceHtmlEntities"]
TranslationPostProcessing=ReplaceMacronWithCircumflex;ReplaceHtmlEntities ;Indicates what type of post processing to do on translated texts (not romaji). Possible values: ["RemoveAllDiacritics", "ReplaceMacronWithCircumflex", "RemoveApostrophes", "ReplaceWideCharacters", "ReplaceHtmlEntities"]
RegexPostProcessing=None ;Indicates what type of post processing to perform on the capture groups of regexes. Possible values: ["RemoveAllDiacritics", "ReplaceMacronWithCircumflex", "RemoveApostrophes", "ReplaceWideCharacters", "ReplaceHtmlEntities"]
CacheRegexLookups=False ;Indicates whether or not results of regex lookups should be output to the specified OutputFile
CacheWhitespaceDifferences=False ;Indicates whether or not whitespace differences should be output to the specified OutputFile
CacheRegexPatternResults=False ;Indicates whether or not the complete result of regex-splitted translations should be output to the specified OutputFile
GenerateStaticSubstitutionTranslations=False ;Indicates that the plugin should generate translations without variables when using substitutions
GeneratePartialTranslations=False ;Indicates that the plugin should generate partial translations to support text translations as it is "scrolling in"
EnableTranslationScoping=False ;Indicates the plugin should parse 'TARC' directives and scope translations based on these
EnableSilentMode=False ;Indicates the plugin should not print out success messages in relation to translations
BlacklistedIMGUIPlugins= ;If an IMGUI window assembly/class/method name contains any of the strings in this list (case insensitive) that UI will not be translated. Requires MonoMod hooks. This is a list seperated by ';'
OutputUntranslatableText=False ;Indicates if texts that are considered by the plugin to be untranslatable should be output to the specified OutputFile
IgnoreVirtualTextSetterCallingRules=False; Indicates that rules for virtual method calls should be ignored when trying to set the text of a text component. May in some cases help setting the text of stubborn components
MaxTextParserRecursion=1 ;Indicates how many levels of recursion are allowed when text is parsed so it can be translated in different parts. This can be used with splitter-regexes in advanced scenarios. The default value of one essentially means that recursion is disabled.
HtmlEntityPreprocessing=True ;Will preprocess and decode html entities before they are send for translation. Some translators will fail when html entities are sent.
HandleRichText=True ;Will enable automated handling of rich text (text with markup)
PersistRichTextMode=Final ;Indicates how parsed rich text should be persisted. Either 'Fragment' to store the the text piecemeal or 'Final' to store the entire translated string (does not support substitutions!)
EnableTranslationHelper=False ;Indicates if translator-related helpful log messages should be enabled. May be useful when tranlating based on redirected resources
ForceMonoModHooks=False ;Indicates that the plugin must use MonoMod hooks instead of harmony hooks
InitializeHarmonyDetourBridge=False ;Indicates the plugin should initial harmony detour bridge which allows harmony hooks to work in an environment where System.Reflection.Emit does not exist (usually such settings are handled by plugin managers, so don't use when using a plugin manager)
RedirectedResourceDetectionStrategy=AppendMongolianVowelSeparatorAndRemoveAll ;Indicates if and how the plugin should attempt to recognize redirected resources in order to prevent double translations. Can be ["None", "AppendMongolianVowelSeparator", "AppendMongolianVowelSeparatorAndRemoveAppended", "AppendMongolianVowelSeparatorAndRemoveAll"]
OutputTooLongText=False ;Indicates if the plugin should output text that exceeds 'MaxCharactersPerTranslation' without translating it
[Texture]
TextureDirectory=Translation\{Lang}\Texture ;Directory to dump textures to, and root of directories to load images from. Can use placeholder: {GameExeName}, {Lang}
EnableTextureTranslation=False ;Indicates whether the plugin will attempt to replace in-game images with those from the TextureDirectory directory
EnableTextureDumping=False ;Indicates whether the plugin will dump texture it is capable of replacing to the TextureDirectory. Has significant performance impact
EnableTextureToggling=False ;Indicates whether or not toggling the translation with the ALT+T hotkey will also affect textures. Not guaranteed to work for all textures. Has significant performance impact
EnableTextureScanOnSceneLoad=False ;Indicates whether or not the plugin should scan for textures on scene load. This enables the plugin to find and (possibly) replace more texture
EnableSpriteRendererHooking=False ;Indicates whether or not the plugin should attempt to hook SpriteRenderer. This is a seperate option because SpriteRenderer can't actually be hooked properly and the implemented workaround could have a theoretical impact on performance in certain situations
LoadUnmodifiedTextures=False ;Indicates whether or not unmodified textures should be loaded. Modifications are determined based on the hash in the file name. Only enable this for debugging purposes as it is likely to cause oddities
TextureHashGenerationStrategy=FromImageName ;Indicates how the mod identifies pictures through hashes. Can be ["FromImageName", "FromImageData", "FromImageNameAndScene"]
DuplicateTextureNames= ;Indicates specific texture names that are duplicated in the game. List is separated by ';'.
DetectDuplicateTextureNames=False;Indicates if the plugin should detect duplicate texture names.
EnableLegacyTextureLoading=False ;Indicates the plugin should use a different strategy to load images, that may be relevant if the game engine is old
CacheTexturesInMemory=True ;Indicates that all textures loaded should be kept in memory for optimal performance. Disable to decrease memory usage
[ResourceRedirector]
PreferredStoragePath=Translation\{Lang}\RedirectedResources ;Indicates the preferred storage for redirected resources in relation to the Auto Translator. Can use placeholder: {GameExeName}, {Lang}
EnableTextAssetRedirector=False ;Indicates if TextAssets should be redirected
LogAllLoadedResources=False ;Indicates if the plugin should log to the console all loaded assets. Useful to determine what can be hooked
EnableDumping=False ;Indicates if translatable resources that are found should be dumped
CacheMetadataForAllFiles=True ;When files are in ZIP files in the PreferredStoragePath, these files are indexed in memory to avoid performing file check IO when loading them. Enabling this option will do the same for physical files
[Http]
UserAgent= ;Override the user agent used by APIs requiring a user agent
DisableCertificateValidation=False ;Indiciates whether certificate validations for the .NET API should be disabled
[TranslationAggregator]
Width=400 ;The total width of the translation aggregator window.
Height=100 ;The width (per translator) of the translation aggregator window.
EnabledTranslators= ;The id's of the translation endpoints that has been enabled in the translation aggregator window. List is separated by ';'.
[Google]
ServiceUrl= ;OPTIONAL, can be used to direct google API request to a different URL. Can be used to circumvent GFWoC
[GoogleLegitimate]
GoogleAPIKey= ;OPTIONAL, needed if GoogleTranslateLegitimate is configured
[BingLegitimate]
OcpApimSubscriptionKey= ;OPTIONAL, needed if BingTranslateLegitimate is configured
[Baidu]
BaiduAppId= ;OPTIONAL, needed if BaiduTranslate is configured
BaiduAppSecret= ;OPTIONAL, needed if BaiduTranslate is configured
[Yandex]
YandexAPIKey= ;OPTIONAL, needed if YandexTranslate is configured
[Watson]
Url= ;OPTIONAL, needed if WatsonTranslate is configured
Key= ;OPTIONAL, needed if WatsonTranslate is configured
[DeepL]
MinDelay=2 ;OPTIONAL, used for throttling DeepL
MaxDelay=7 ;OPTIONAL, used for throttling DeepL
[DeepLLegitimate]
ApiKey= ;OPTIONAL, required if DeepLLegitimate is configured
Free=False ;OPTIONAL, required if DeepLLegitimate is configured
[Custom]
Url= ;Optional, needed if CustomTranslated is configured
[LecPowerTranslator15]
InstallationPath= ;Optional, needed if LecPowerTranslator15 is configured
[LingoCloud]
LingoCloudToken= ;Optional, needed if LingoCloudTranslate is configured
[Debug]
EnableConsole=False ;Enables the console. Do not enable if other plugins (managers) handles this
EnableLog=False ;Enables extra logging for debugging purposes
[Migrations]
Enable=True ;Used to enable automatic migrations of this configuration file
Tag=4.15.0 ;Tag representing the last version this plugin was executed under. Do not edit
This section describes configuration parameters that has an effect on whitespace handling before and after performing a translation. None of these settings have an impact on the 'untranslated texts' that are placed in the auto generated translations file.
When it comes to automated translations, proper whitespace handling can really make or break the translation. The parameters that control whitespace handling are:
IgnoreWhitespaceInDialogue
IgnoreWhitespaceInNGUI
MinDialogueChars
ForceSplitTextAfterCharacters
The plugin first determines whether or not it should perform a special whitespace removal operation. It determines whether or not to perform this operation based on the parameters IgnoreWhitespaceInDialogue
, IgnoreWhitespaceInNGUI
and MinDialogueChars
:
IgnoreWhitespaceInDialogue
: If the text is longer thanMinDialogueChars
, whitespace is removed.IgnoreWhitespaceInNGUI
: If the text comes from an NGUI component, whitespace is removed.
After the text has been translated by the configured service, ForceSplitTextAfterCharacters
is used to determine if the plugin should force the result into multiple lines after a certain number of characters.
The main reason that this type of handling can make or break a translation really comes down to whether or not whitespace is removed from the source text before sending it to the endpoint. Most endpoints (such as GoogleTranslate) consider text on multiple lines seperately, which can often result in terrible translation if an unnecessary newline is included.
While proper whitespace handling goes a long way in ensuring better translations, it is not always enough.
The PreprocessorsFile
allows defining entries that modifies the text just before it is sent to the translator.
The PostprocessorsFile
allows defining entries that modifies the translated text just after it is received from the translator.
Often when performing a translation on a text component, the resulting text is larger than the original. This often means that there is not enough room in the text component for the result. This section describes ways to remedy that by changing important parameters of the text components.
By default, the plugin will attempt some basic auto-resizing behaviour, which are controlled by the following parameters: EnableUIResizing
, ResizeUILineSpacingScale
, ForceUIResizing
, OverrideFont
and OverrideFontTextMeshPro
.
EnableUIResizing
: Resizes the components when a translation is performed.ForceUIResizing
: Resizes all components at all times, period.ResizeUILineSpacingScale
: Changes the line spacing of resized components. UGUI only.OverrideFont
: Changes the font of all text components regardless ofEnableUIResizing
andForceUIResizing
. UGUI only.OverrideFontTextMeshPro
: Consider usingFallbackFontTextMeshPro
instead. Changes the font of all text components regardless ofEnableUIResizing
andForceUIResizing
. TextMeshPro only. This option is able to load a font in two different ways. If the specified string indicates a path within the game folder, then that file will be attempted to be loaded as an asset bundle (requires Unity 2018 or greater (or alternatively a custom asset bundle built specifically for the targeted game)). If not, it will be attempted to be loaded through the Resources API. Default resources that are often distributed with TextMeshPro are:Fonts & Materials/LiberationSans SDF
orFonts & Materials/ARIAL SDF
.FallbackFontTextMeshPro
: Adds a fallback font that TextMesh Pro can use in case a specific character is not supported.
An additional note on changing the font of TextMeshPro: You can download some pre-built asset bundles for Unity 2018 and 2019 in the release tab, but for now, they are not particularly well tested. If you want to try them out, simply download the .zip folder and put one of the font assets into the game folder. Then configure it up by writing the name of the file in the configuration file in OverrideFontTextMeshPro
.
Resizing of a UI component does not refer to changing of it's dimensions, but rather how the component handles overflow. The plugin changes the overflow parameters such that text is more likely to be displayed.
The configuratiaon EnableUIResizing
and ForceUIResizing
also control whether or not manual UI resize behaviour is enabled. See this section for more information.
The following aims at reducing the number of requests send to the translation endpoint:
EnableBatching
: Batches several translation requests into a single with supported endpoints.UseStaticTranslations
: Enables usage of internal lookup dictionary of various english-to-japanese terms.MaxCharactersPerTranslation
: Specifies the maximum length of a text to translate. Any texts longer than this is ignored by the plugin. Cannot be greater than 1000. Never redistribute this mod with this value greater than 400
One of the possible values as output Language
is 'romaji'. If you choose this as language, you will find that games often has problems showing the translations because the font does not understand the special characters used, for example the macron diacritic.
To rememdy this, post processing can be applied to translations when 'romaji' is chosen as Language
. This is done through the option RomajiPostProcessing
. This option is a ';'-seperated list of values:
RemoveAllDiacritics
: Remove all diacritics from the translated textReplaceMacronWithCircumflex
: Replaces the macron diacritic with a circumflex.RemoveApostrophes
: Some translators might decide to include apostrophes after the 'n'-character. Applying this option removes those.ReplaceWideCharacters
: Replaces wide-width japanese characters with standard ASCII charactersReplaceHtmlEntities
: Replaces all html entities with their unescaped character
This type of post processing is also applied to normal translations, but instead uses the option TranslationPostProcessing
, which can use the same values.
MonoMod hooks are hooks are created at runtime, but not through the Harmony dependency. Harmony has two primary problems that these hooks attempt to solve:
- Harmony cannot hook methods with no body.
- Harmony cannot hook methods under the
netstandard2.0
API surface, which later versions of Unity can be build under.
MonoMod solves both of these problems. In order to use MonoMod hooks the libraries MonoMod.RuntimeDetours.dll
, MonoMod.Utils.dll
and Mono.Cecil.dll
must be available to the plugin. These are optional dependencies.
These are only available in the following packages:
XUnity.AutoTranslator-BepInEx-{VERSION}.zip
(because all dependencies are distributed with BepInEx 5.x)XUnity.AutoTranslator-IPA-{VERSION}.zip
(because all dependencies are included in the package)XUnity.AutoTranslator-ReiPatcher-{VERSION}.zip
(because all dependencies are included in the package)
They are not distributed in the BepInEx 4.x because of the potential for conflicts in mod packages for various games.
The following configuration controls the MonoMod hooks:
ForceMonoModHooks
: Forces the plugin to use MonoMod hooks over Harmony hooks.
If MonoMod hooks are not forced they are only used if available and a given method cannot be hooked through Harmony for one of the two reasons mentioned above.
TextGetterCompatibilityMode
: This mode fools the game into thinking that the text displayed is not translated. This is required if the game uses text displayed to the user to determine what logic to execute. You can easily determine if this is required if you can see the functionality works fine if you toggle the translation off (hotkey: ALT+T).IgnoreTextStartingWith
: Disable translation for any texts starting with values in this ';-separated' setting. The default value is an invisible character that takes up no space.CopyToClipboard
: Copy text to translate to the clipboard to support tools such as Translation Aggregator.ClipboardDebounceTime
: The delay between hooking a text and it being copied to clipboard. This is to avoid spamming the clipboard. If multiple texts appear in this period they will be concatenated.EnableSilentMode
: Indicates the plugin should not print out success messages in relation to translations.BlacklistedIMGUIPlugins
: If an IMGUI window assembly/class/method name contains any of the strings in this list (case insensitive) that UI will not be translated. Requires MonoMod hooks. This is a list seperated by ';'.OutputUntranslatableText
: Indicates if texts that are considered by the plugin to be untranslatable should be output to the specified OutputFile. Enabling this may also output a lot of garbage to theOutputFile
that should be deleted before potential redistribution. Never redistribute the mod with this enabled.IgnoreVirtualTextSetterCallingRules
: Indicates that rules for virtual method calls should be ignored when trying to set the text of a text component. May in some cases help setting the text of stubborn components.RedirectedResourceDetectionStrategy
: Indicates if and how the plugin should attempt to recognize redirected resources in order to prevent double translations. Can be ["None", "AppendMongolianVowelSeparator", "AppendMongolianVowelSeparatorAndRemoveAppended", "AppendMongolianVowelSeparatorAndRemoveAll"]OutputTooLongText
: Indicates if the plugin should output text that exceeds 'MaxCharactersPerTranslation' without translating it
While this plugin offers some level of IL2CPP support, it is by no means complete. The following differences can be observed/features are missing:
- Subpar text hooking capabilities
- TextGetterCompatibilityMode is not supported
- Plugin-specific translations are not supported (yet)
- IMGUI translations are not supported (yet)
- Many other features are completely unproven
Q: How do I disable auto translations?
A: Select the empty endpoint when you press ALT+0 or set the configuration parameterEndpoint=
to empty.
Q: How do I disable the plugin entirely?
A: You can do so by deleting the "XUnity.AutoTranslator" directory in the "{GameDirectory}\BepInEx\plugins" directory. Avoid deleting the "XUnity.ResourceRedirector" directory as other plugins may depend on it.
Q: The game stops working when this plugin applies translations.
A: Try setting the following configuration parameterTextGetterCompatibilityMode=True
.
Q: Can this plugin translate other plugins/mods?
A: Likely yes, see here.
Q: How do I use CustomTranslate?
A: If you have to ask, you probably can't. CustomTranslate is intended for developers of a translation service. They would be able to expose an API that conforms to CustomTranslate's API specification without needing to implement a custom ITranslateEndpoint in this plugin as well.
Q: Please provide support for translation service X.
A: For now, additional support for services that does not require some form of authentication is unlikely. Do note though, that it is possible to implement custom translators independently of this plugin. And it takes remarkably little code to do so.
Often other mods UI are implemented through IMGUI. As you can see above, this is disabled by default. By changing the "EnableIMGUI" value to "True", it will start translating IMGUI as well, which likely means that other mods UI will be translated.
It is also possible to provide plugin-specific translations. See next section.
When you use this plugin, you can always go to the file Translation\{Lang}\Text\_AutoGeneratedTranslations.txt
(OutputFile) to edit any auto generated translations and they will show up the next time you run the game. Or you can press (ALT+R) to reload the translation immediately.
It is also worth noting that this plugin will read all text files (*.txt) in the Translation
(Directory), so if you want to provide a manual translation, you can simply cut out texts from the Translation\_AutoGeneratedTranslations.{lang}.txt
(OutputFile) and place them in new text files in order to replace them with a manual translation. These text files can also be placed in standard .zip archives.
In this context, the Translation\{Lang}\Text\_AutoGeneratedTranslations.txt
(OutputFile) will always have the lowest priority when reading translations. So if the same translation is present in two places, it will not be the one from the (OutputFile) that is used.
In some ADV engines text 'scrolls' into place slowly. Different techniques are used for this and in some instances if you want the translated text to be scrolling in instead of the untranslated text, you may need to set GeneratePartialTranslations=True
. This should not be turned on unless required by the game.
Often you may want to provide translations for other plugins that are not naturally translated. This is obviously also possible with this plugin as described in the previous section. But what if you want to provide translations that should be specific to that plugin because such translation would conflict with a different plugin/generic translation?
In order to add plugin-specific translations, simply create a Plugins
directory in the text translation Directory
. In this directory you can create a new directory for each plugin you want to provide plugin-specific translations for. The name of the directory should be the same as the dll name without the extension (.dll).
Within this directory you can create translations files as you normally would. In addition you can add the following directive in these files:
#enable fallback
This will allow the plugin-specific translations to fallback to the generic/automated translations provided by the plugin. It does not matter which translation file this directive is placed it and it only need to be added once.
As a plugin author it is also possible to embed these translation files in your plugin and register them through code with the following API:
/// <summary>
/// Entry point for manipulating translations that have been loaded by the plugin.
///
/// Methods on this interface should be called during plugin initialization. Preferably during the Start callback.
/// </summary>
public static class TranslationRegistry
{
/// <summary>
/// Obtains the translations registry instance.
/// </summary>
public static ITranslationRegistry Default { get; }
}
/// <summary>
/// Interface for manipulating translation that have been loaded by the plugin.
/// </summary>
public interface ITranslationRegistry
{
/// <summary>
/// Registers and loads the specified translation package.
/// </summary>
/// <param name="assembly">The assembly that the behaviour should be applied to.</param>
/// <param name="package">Package containing translations.</param>
void RegisterPluginSpecificTranslations( Assembly assembly, StreamTranslationPackage package );
/// <summary>
/// Registers and loads the specified translation package.
/// </summary>
/// <param name="assembly">The assembly that the behaviour should be applied to.</param>
/// <param name="package">Package containing translations.</param>
void RegisterPluginSpecificTranslations( Assembly assembly, KeyValuePairTranslationPackage package );
/// <summary>
/// Allow plugin-specific translation to fallback to generic translations.
/// </summary>
/// <param name="assembly">The assembly that the behaviour should be applied to.</param>
void EnablePluginTranslationFallback( Assembly assembly );
}
It is also possible to add substitutions that are applied to found texts before translations are created. This is controlled through the SubstitutionFile
, which uses the same format as normal translation text files, although things like regexes are not supported.
This is useful for replacing names that are often translated incorrectly, etc.
When using substitutions, the found occurrences will be parameterized in the generated translations, like so:
私は{{A}}=I am {{A}}
Alternatively, if the configuration GenerateStaticSubstitutionTranslations=True
is used the translations will not be parameterized.
When creating manual translations, use this file as sparingly as you would use regexes, as it can have an effect on performance.
NOTE: If the text to be translated includes rich text, it cannot currently be parameterized.
Text translation files support regexes as well. Always remember to use regexes as sparing as possible and scope them as much as possible to avoid performance issues.
Regexes can be applied to translations in two different ways. The following two sections describes these two ways:
Standard regex translation are simply regexes that applied directly onto a translatable text, if no direct lookup can be found.
r:"^シンプルリング ([0-9]+)$"=Simple Ring $1
These are identified by the untranslated text starting with 'r:'.
Sometimes games likes to combine texts before displaying them on screen. This means that it can sometimes be hard to know what text to add to the translation file because it appears in a number of different ways.
This section explores a solution to this by applying a regex to split the text to be translated into individual pieces before trying to make lookups for the specified texts.
For example, let's say an accessory (Simple Ring) would be translated with the following line シンプルリング=Simple Ring
. Now lets say this appears in multiple textboxes throughout the game like 01 シンプルリング
and 02 シンプルリング
. Providing a standard regex in a translation file to handle this is not going to work because you would need a regex for each accessory and this would not be performant at all.
However, if we split the translation before trying to make lookups it will allow us to only have a single simple translation in our file, like this: シンプルリング=Simple Ring
.
Simply place the following regex in a translation file:
sr:"^([0-9]{2}) ([\S\s]+)$"=$1 $2
This will split up the text to be translated into two parts, translate them individually and put them back together.
These are identified by the untranslated text starting with 'sr:'.
It is also worth noting that this methodology can be used recursively, if configured. This means that it allows the individual strings that were split for translations by a regex, to flow into another splitter regex, and so on.
In addition to identifying each group by index, they can also be identified by a name, which allows groups to be completely additional. Let's take a look at an example that combines all of these things:
sr:"^\[(?<stat>[\w\s]+)(?<num_i>[\+\-]{1}[0-9]+)?\](?<after>[\s\S]+)?$"="[${stat}${num_i}]${after}"
In this example there are 3 named groups, two of which are optional (standard regex syntax). The replacement pattern identifies these named group by surrounding the name with ${}
.
If the identifier name ends in _i
it means that the string will not be attempted to be translated, but rather transfered as is. Generally this is not really needed as the plugin is smart enough to determine if something should be translated or not.
So what would this regex split? It would split strings like this:
[DEF+14][ATK+64][DEX+34][AGI]
The group(s) (?<stat>[\w]+)(?<num_i>[\+\-]{1}[0-9]+)?
matches the text inside the []
. As you can see there are two groups. The first is requried and represents the text. The second is optional and represents the plus-/minus sign and number that comes after.
The group (?<after>[\s\S]+)
matches whatever comes after. Because of this, it will attempt to translate that text like any other, and that may flow directly back into this splitter regex.
Using the configuration option RegexPostProcessing
, it is also possible to apply post processing the to the groups of a regex. For sr:
regexes they are only applied to groups where the identifier name ends in _i
.
It is also possible to manually control the font size of text components. This is useful when the translated text uses more space than the untranslated text.
You can control this in files that end in resizer.txt
placed in the translation Directory
. This file takes a simply syntax like this:
CharaCustom/CustomControl/CanvasDraw=ChangeFontSizeByPercentage(0.5)
In these files, the left-hand side of the equals sign represents a (partial) path to the components that must have their fonts resized. The right-hand sized represent a ';'-separated list of the command to perform on those texts.
In the shown example it will reduce the font size of all texts below the specified path to 50%.
Like any other translation file, these files also support translation scoping, as decribed in this section.
The following types of commands exists:
- Commands that change the font size to a static size:
ChangeFontSizeByPercentage(double percentage)
: Where the percentage is the percentage of the original font size to reduce it to.ChangeFontSize(int size)
: Where the size if the new size of the fontIgnoreFontSize()
: This can be used to reset font resize behavior that was set on a very 'non-specific' path.
- Commands that control auto-resizing:
AutoResize(bool enabled, minSize, maxSize)
: Where enabled control if auto-resize behaviour should be enabled. The two last parameters are optional.- minSize, maxSize possible values: [keep, none, any number]
- Commands that control the line spacing (UGUI only):
UGUI_ChangeLineSpacingByPercentage(float percentage)
UGUI_ChangeLineSpacing(float lineSpacing)
- Commands that control horizontal overflow (UGUI only):
UGUI_HorizontalOverflow(string mode)
- possible values: [wrap, overflow]
- Commands that control vertical overflow (UGUI only):
UGUI_VerticalOverflow(string mode)
- possible values: [truncate, overflow]
- Commands to control overflow (TMP only):
TMP_Overflow(string mode)
- possible values
- Commands to control text alignment (TMP only):
TMP_Alignment(string mode)
- possible values
But stop you say! How would I determine the path to use? This plugin provides no way to easily determine this, but there are other plugins that will allow you to do this.
There's two ways, and you will likely need to use both of them:
- Using Runtime Unity Editor to determine these.
- Enabling the option
[Behaviour] EnableTextPathLogging=True
, which will log out the path to all text components that text are changed on.
The following two options are available when it comes to scoping translations to only part of the game:
The translation files support the following directives:
#set level 1,2,3
tells the plugin that translations following this line in this file may only be applied in scenes with ID 1, 2 or 3.#unset level 1,2,3
tells the plugin that translations following this line in this file should not be applied in scenes with ID 1, 2 or 3. If no levels are set, all specified translations are global.#set exe game1,game2
tells the plugin that translations following this line in this file may only be applied when the game is run through an executable with the name game1 or game2.#unset exe game1,game2
tells the plugin that translations following this line in this file should not be applied when the game is run through an executable with the name game1 or game2. If no exes are set, all specified translations are global.#set required-resolution height > 1280 && width > 720
tells the plugin that translations following this line in this file should only be applied if the resolution is greater than specified. Current implementation only handles the resolution used by the game at startup.#unset required-resolution
tells the plugin to ignore previously specified#set required-resolution
directive.
For this to work, the following configuration option must be True
:
[Behaviour]
EnableTranslationScoping=True
Also, this behaviour is not available in the OutputFile
.
You can always see which levels are loaded by using the hotkey CTRL+ALT+NP7.
Another way of scoping translations are through file names. It is possible to tell the plugin where to look for translation files. It is possible to parameterize these paths with the variable {GameExeName}.
Example configuration that seperates translations for each executable:
[Files]
Directory=Translation\{GameExeName}\{Lang}\Text
Directory=Translation\{GameExeName}\{Lang}\Text\_AutoGeneratedTranslations.txt
Directory=Translation\{GameExeName}\{Lang}\Text\_Substitutions.txt
So when should use scope your translations? Well that depends on the type of scope:
level
scopes should really only be used to avoid translation collisionsexe
scopes can be used both to avoid translation collisions and to enhance performance
This section is provided to give the translator an understanding of how this plugin looks up texts and provides translations.
In the simplest form, the way the plugin works is as a dictionary of untranslated text strings. When plugin sees a text that it considers untranslated, it will attempt to look up the text string in the dictionary and if it finds a result, it will display the found translation instead.
The world, however, is not always that simple. Depending on the engine/text framework used by a game, an untranslated text string may be slightly different when used in different contexts. For example for a VN it may not be the exact same text string that appears in the "ADV history"-view as it was when it was being initially displayed to the user.
Example:
「こう見えて怒っているんですよ?……失礼しますね」
「こう見えて怒っているんですよ?\n ……失礼しますね」
These text strings are not the same and it would be annoying having to translate the same text multiple times if the final translation is supposed to be the same.
In fact, only one of these translations are needed. Here's why: (still very much simplified):
- When the plugin sees an untranslated text, it will actually make four lookups, not one. These are, in order:
- Based on the untouched original text
- Based on the original text but without leading/trailing whitespace. If found the leading/trailing whitespace is added to the resulting translation
- Based on the original text but without internal non-repeating whitespace surrounding a newline
- Based on the original text but without leading/trailing whitespace and internal non-repeating whitespace surrounding a newline. If found the leading/trailing whitespace is added to the resulting translation
This means that for the following string \n 「こう見えて怒っているんですよ?\n ……失礼しますね」
the plugin will make the following lookups:
\n 「こう見えて怒っているんですよ?\n ……失礼しますね」
「こう見えて怒っているんですよ?\n ……失礼しますね」
\n 「こう見えて怒っているんですよ?……失礼しますね」
「こう見えて怒っているんですよ?……失礼しますね」
- When the plugin loads the (manual/automatic) translation it will not make one dictionary entry, but three. These are:
- Based on the untouched original text and original translation
- Based on the original text (without leading/trailing whitespace) and original translation (without leading/trailing whitespace)
- Based on the original text (without leading/trailing whitespace and internal non-repeating whitespace surrounding a newline) and original translation (without leading/trailing whitespace and internal non-repeating whitespace surrounding a newline)
This means that for the following string \n 「こう見えて怒っているんですよ?\n ……失礼しますね」
the plugin will make the following entries:
\n 「こう見えて怒っているんですよ?\n ……失礼しますね」
「こう見えて怒っているんですよ?\n ……失礼しますね」
「こう見えて怒っているんですよ?……失礼しますね」
This means you can get away with providing a single translation for both of these cases. Which you think is better is up to you.
Another thing to note is that the plugin will always output the original text without modifications in the translation file. But if it sees another text afterwards that is "compatible" with that text-string (due to the above mentioned text modifications) it will not output this new text by default.
This is controlled by the configuration option CacheWhitespaceDifferences=False
. You can change this to true, and it will output a new entry for each unique text, even if the only differences are whitespace. Obviously, translations-pairs actually appearing in the translation file will always that precendent over translations-pairs that are generated based on an exinsting translation-pair.
NOTE: Whitespace differences in relation to level-scoped translations will never be output regardless of this setting.
Sometimes it's easier to provide a translation to a game by directly overriding the game resource files. However, directly overriding the game resource files is also problematic because that means the modification will likely only work for one version of the game.
To overcome this problem, and allow for modification of resource files, this plugin also has a resource redirector module that allows redirecting any kind of resource loaded by the game.
Before we get into the details of this module, it is worth mentioning that it is:
- It is not a plugin. Rather it is just a library that is not beholden to any plugin manager (it does come with a plugin-compatible BepInEx DLL but this is only to manage configuration).
- It is game independent.
- And while it may be redistributed with the Auto Translator, it is completely independent from it and it can be used without having the Auto Translator installed.
The DLLs required for the Resource Redirector to work are XUnity.Common.dll
and XUnity.ResourceRedirector.dll
. By themselves, these libraries do nothing.
By default the Auto Translator plugin comes with one resource redirector for TextAsset
, which basically outputs the raw text assets to the file system allowing them to be individually overridden.
More redirectors can be implemented for specific games, though this does require programming knowledge, see this section for more information.
The Auto Translator has the following Resource Redirector-specific configuration:
PreferredStoragePath
: Indicates where the Auto Translator should store redirected resources.EnableTextAssetRedirector
: Indicates if the TextAsset redirector is enabled.LogAllLoadedResources
: Indicates if Resource Redirector should log all resources to the console (can also be controlled through Resource Redirector API surface).EnableDumping
: Indicates if resources redirected to the Auto Translator should be dumped for overwriting if possible.CacheMetadataForAllFiles
: When files are in ZIP files in the PreferredStoragePath, these files are indexed in memory to avoid performing file check IO when loading them. Enabling this option will do the same for physical files
ZIP files that are placed in the PreferredStoragePath
will be indexed during startup, allowing redirected resources to be compressed and zipped. When files are placed in a zip file, the zip file is simply treated as not existing during file lookup.
Redistributing this plugin for various games is absolutely encouraged. However, if you do so, please keep the following in mind:
- Distribute the _AutoGeneratedTranslations.txt file along with the redistribution with as many translations as possible to ensure the online translator is hit as little as possible.
- Test your redistribution with logging/console enabled to ensure the game does not exhibit undesirable behaviour such as spamming the endpoints.
- Do not redistribute the plugin with a non-default translation endpoint configured which comes from this repository. This means:
- Don't set
Endpoint=DeepLTranslate
and then redistribute. - However, if you implemented your own endpoint or the endpoint is not a part of this repository, you can go ahead and redistribute it with that as the default endpoint.
- Don't set
- Ensure you keep the plugin up-to-date, as much as reasonably possible.
- If you use image loading feature, make sure you read this section.
From version 2.16.0+ this mod provides basic capabilities to replace images. It is a feature that is disabled by default. There is no automatic translation of these images though.
This feature is primarily meant for games with little to no mod support to enable full translations without needing to modify resource files.
It is controlled by the following configuration:
[Texture]
TextureDirectory=Translation\Texture
EnableTextureTranslation=False
EnableTextureDumping=False
EnableTextureToggling=False
EnableTextureScanOnSceneLoad=False
EnableSpriteRendererHooking=False
LoadUnmodifiedTextures=False
TextureHashGenerationStrategy=FromImageName
DuplicateTextureNames=
DetectDuplicateTextureNames=False
EnableLegacyTextureLoading=False
CacheTexturesInMemory=True
TextureDirectory
specifies the directory where textures are dumped to and loaded from. Loading will happen from all subdirectories of the specified directory as well, so you can move dumped images to whatever folder structure you desire.
EnableTextureTranslation
enables texture translation. This basically means that textures will be loaded from the TextureDirectory
and it's subsdirectories. These images will replace the in-game images used by the game.
EnableTextureDumping
enables texture dumping. This means that the mod will dump any images it has not already dumped to the TextureDirectory
. When dumping textures, it may also be worth enabling EnableTextureScanOnSceneLoad
to more quickly find all textures that require translating. Never redistribute the mod with this enabled.
EnableTextureScanOnSceneLoad
allows the plugin to scan for texture objects on the sceneLoad event. This enables the plugin to find more texture at a tiny performance cost during scene load (which is often during loading screens, etc.). However, because of the way Unity works not all of these are guaranteed to be replacable. If you find an image that is dumped but cannot be translated, please report it. However, please recognize this mod is primarily intended for replacing UI textures, not textures for 3D meshes.
EnableSpriteRendererHooking
allows the plugin to attempt to hook SpriteRenderer. This is a seperate option because SpriteRenderer can't actually be hooked properly and the implemented workaround could have a theoretical impact on performance in certain situations.
LoadUnmodifiedTextures
enables whether or not the plugin should load textures that has not been modified. This is only useful for debugging, and likely to cause various visual glitches, especially if EnableTextureScanOnSceneLoad
is also enabled. Never redistribute the mod with this enabled.
EnableTextureToggling
enables whether the ALT+T hotkey will also toggle textures. This is by no means guaranteed to work, especially if EnableTextureScanOnSceneLoad
is also enabled. Never redistribute the mod with this enabled.
DuplicateTextureNames
specifies different textures in the game that are used under the same resource name. The plugin will fallback to the 'FromImageData' for image identification for these images.
DetectDuplicateTextureNames
specifies that the plugin should identify which image names are duplicated and update the configuration with these names automatically. Never redistribute the mod with this enabled.
EnableLegacyTextureLoading
specifies that the plugin should use attempt to load images differently, which may be relevant if the unity engine is old (verified with versions less than 5.3). This should not be used unless the images that are loaded are not the ones that you expected.
CacheTexturesInMemory
specifies that all translation textures should be kept in memory to optimize performance. Can be disabled to reduce memory usage.
TextureHashGenerationStrategy
specifies how images are identified. When images are stored, the game will need some way of associating them with the image that it has to replace.
This is done through a hash-value that is stored in square brackets in each image file name, like this: file_name [0223B639A2-6E698E9272].png
. This configuration specifies how these hash-values are generated:
FromImageName
means that the hash is generated from the internal resource name that the game uses for the image, which may not exist for all images or even be unique. However, it is generally fairly reliable. If an image has no resource name, it will not be dumped.FromImageData
means that the hash is generated from the data stored in the image, which is guaranteed to exist for all images. However, generating the hash comes at a performance cost, that will also be incurred by the end-users.FromImageNameAndScene
means that it should use the name and scene to generate a hash. The name is still required for this to work. When using this option, there is a chance the same texture could be dumped with different hashes, which is undesirable, but it could be required for some games, if the name itself is not unique and theFromImageData
option causes performance issues. If this is used, it is recommended to enableEnableTextureScanOnSceneLoad
as well.
There's an important catch you need to be aware when dealing with these options and that is if ANY of these options exists: EnableTextureDumping=True
, EnableTextureToggling=True
, TextureHashGenerationStrategy=FromImageData
, then the game will need to read the raw data from all images it finds in game in order to replace the image and this is an expensive operation.
It is therefore recommended to use TextureHashGenerationStrategy=FromImageName
. Most likely, images without a resource name won't be interesting to translate anyway.
If you redistribute this mod with translated images, it is recommended you delete all images you either have no intention of translating or are not translated at all.
You can also change the file name to whatever you desire, as long as you keep the hash appended to the end of the file name.
If you take anything away from this section, it should be these two points:
- Never redistribute the mod with
EnableTextureDumping=True
,EnableTextureToggling=True
,LoadUnmodifiedTextures=True
orDetectDuplicateTextureNames=true
- Only redistribute the mod with
TextureHashGenerationStrategy=FromImageData
enabled if absolutely required by the game.
There are actually two hashes in the generated file name, separated by a dash (-):
- The first hash is a SHA1 (only first 5 bytes) based on the
TextureHashGenerationStrategy
used. IfFromImageName
is specified, then it is based on the UTF8 (without BOM) representation. - The second hash is a SHA1 (only first 5 bytes) based on the data in the image. This is used to determine whether or not the image has been modified, so images that has not been edited are not loaded. Unless
LoadUnmodifiedTextures
is specified.
If TextureHashGenerationStrategy=FromImageData
is specified, only a single hash will appear in each file name, as that single hash can be used both to identify the image and to determine whether or not it has been edited.
NOTE: Everything below this point requires programming knowledge!
As a mod author, you may want to query translations from the plugin. This easily done, take a look at the example below.
public class MyPlugin : XPluginBase
{
public void Start()
{
var untranslatedText = "お前はもう死んでいる!";
// EXAMPLE 1) Query cache, and if it cannot be found in cache query the user-selected translation endpoint
AutoTranslator.Default.TranslateAsync( untranslatedText, result =>
{
if( result.Succeeded )
{
var translatedText = result.TranslatedText;
}
else
{
var errorMessage = result.ErrorMessage;
}
} );
// EXAMPLE 2) Query cache only
if( AutoTranslator.Default.TryTranslate( untranslatedText, out string translation ) )
{
// success
}
else
{
// failure
}
}
}
This requires version 3.7.0 or later!
As a mod author, you might not want the Auto Translator to interfere with your mods UI. If this is the case there's two ways to tell Auto Translator not to perform any translation:
- If your UI is based on GameObjects, you can simply name your GameObjects containing the text element (for example Text class) to something that contains the string "XUAIGNORE". The Auto Translator will check for this and ignore components that contains the string.
- If your UI is based on IMGUI, the above approach is not possible, because there are no GameObject. In that case you can do the following instead:
public class MyPlugin : XPluginBase
{
private GameObject _xua;
private bool _lookedForXua;
public void OnGUI()
{
// make sure we only do this lookup once, as it otherwise may be detrimental to performance!
// also: do not attempt to do this in the Awake method or similar of your plugin, as your plugin may be instantiated before the auto translator!
if(!_lookedForXua)
{
_lookedForXua = true;
_xua = GameObject.Find( "___XUnityAutoTranslator" );
}
// try-finally block is important to make sure you re-enable the plugin
try
{
_xua?.SendMessage("DisableAutoTranslator");
// do your GUI things here
GUILayout.Button( "こんにちは!" );
}
finally
{
_xua?.SendMessage("EnableAutoTranslator");
}
}
}
This requires version 2.15.0 or later!
Since version 3.0.0, you can now also implement your own translators.
In order to do so, all you have to do is implement the following interface, build the assembly and place the generated DLL in the Translators
folder.
/// <summary>
/// The interface that must be implemented by a translator.
/// </summary>
public interface ITranslateEndpoint
{
/// <summary>
/// Gets the id of the ITranslateEndpoint that is used as a configuration parameter.
/// </summary>
string Id { get; }
/// <summary>
/// Gets a friendly name that can be displayed to the user representing the plugin.
/// </summary>
string FriendlyName { get; }
/// <summary>
/// Gets the maximum concurrency for the endpoint. This specifies how many times "Translate"
/// can be called before it returns.
/// </summary>
int MaxConcurrency { get; }
/// <summary>
/// Gets the maximum number of translations that can be served per translation request.
/// </summary>
int MaxTranslationsPerRequest { get; }
/// <summary>
/// Called during initialization. Use this to initialize plugin or throw exception if impossible.
/// </summary>
void Initialize( IInitializationContext context );
/// <summary>
/// Attempt to translated the provided untranslated text. Will be used in a "coroutine",
/// so it can be implemented in an asynchronous fashion.
/// </summary>
IEnumerator Translate( ITranslationContext context );
}
Often an implementation of this interface will access an external web service. If this is the case, you do not need to implement the entire interface yourself. Instead you can rely on a base class in the XUnity.AutoTranslator.Plugin.Core
assembly. But more on this later.
Whenever you implement a translator based on an online service, it is important to not use it in an abusive way. For example by:
- Establishing a large number of connections to it
- Performing web scraping instead of using an available API
- Making concurrent requests towards it
- This is especially important if the service is not authenticated
With that in mind, consider the following:
- The
WWW
class in Unity establishes a new TCP connection on each request you make, making it extremely poor at this kind of job. Especially if SSL (https) is involved because it has to do the entire handshake procedure each time. Yuck. - The
UnityWebRequest
class in Unity does not exist in most games, because the authors use an old engine, so it is not a good choice either. - The
WebClient
class from .NET is capable of using persistent connections (it does so by default), but has its own problems with SSL. The version of Mono used in most Unity games rejects all certificates by default making all HTTPS connections fail. This, however, can be remedied during the initialization phase of the translator (see examples below). Another shortcoming of this API is the fact that the runtime will never release the TCP connections it has used until the process ends. The API also integrates terribly with Unity because callbacks return on a background thread. - The
WebRequest
class from .NET is essentially the same as WebClient. - The
HttpClient
class from .NET is also unlikely to exist in most Unity games.
None of these are therefore an ideal solution.
To remedy this, the plugin implements a class XUnityWebClient
, which is based on Mono's version of WebClient. However, it adds the following features:
- Enables integration with Unity by returning result classes that can be 'yielded'.
- Properly closes connections that has not been used for 50 seconds.
I recommend using this class, or in case that cannot be used, falling back to the .NET 'WebClient'.
Follow these steps:
- Download XUnity.AutoTranslator-Developer-{VERSION}.zip from releases
- Start a new project (.NET 3.5) in Visual Studio 2017 or later. I recommend using the same name for your assembly/project as the "Id" you are going to use in your interface implementation. This makes it easier for users to know how to configure your translator
- I recommend using the "Class Library (.NET Standard)" and simply editing the generated .csproj file to use 'net35' instead of 'netstandard2.0'. This generates much cleaner .csproj files.
- Add a reference to the XUnity.AutoTranslator.Plugin.Core.dll that you downloaded in step 1
- You do not need to directly reference the UnityEngine.dll assembly. This is good, because you do not need to worry about which version of Unity is used.
- If you do need a reference to this assembly (because you need functionality from it) consider using an old version of it (if
UnityEngine.CoreModule.dll
exists in the Managed folder, it is not an old version!)
- If you do need a reference to this assembly (because you need functionality from it) consider using an old version of it (if
- Create a new class that either:
- Implements the
ITranslateEndpoint
interface - Inherits from the
HttpEndpoint
class - Inherits from the
WwwEndpoint
class - Inherits from the
ExtProtocolEndpoint
class
- Implements the
Here's an example that simply reverses the text and also reads some configuration from the configuration file the plugin uses:
public class ReverseTranslatorEndpoint : ITranslateEndpoint
{
private bool _myConfig;
public string Id => "ReverseTranslator";
public string FriendlyName => "Reverser";
public int MaxConcurrency => 50;
public int MaxTranslationsPerRequest => 1;
public void Initialize( IInitializationContext context )
{
_myConfig = context.GetOrCreateSetting( "Reverser", "MyConfig", true );
}
public IEnumerator Translate( ITranslationContext context )
{
var reversedText = new string( context.UntranslatedText.Reverse().ToArray() );
context.Complete( reversedText );
return null;
}
}
Arguably, this is not a particularly interesting example, but it illustrates the basic principles of what must be done in order to implement a Translator.
Let's take a look at a more advanced example that accesses the web:
internal class YandexTranslateEndpoint : HttpEndpoint
{
private static readonly HashSet<string> SupportedLanguages = new HashSet<string> { "az", "sq", "am", "en", "ar", "hy", "af", "eu", "ba", "be", "bn", "my", "bg", "bs", "cy", "hu", "vi", "ht", "gl", "nl", "mrj", "el", "ka", "gu", "da", "he", "yi", "id", "ga", "it", "is", "es", "kk", "kn", "ca", "ky", "zh", "ko", "xh", "km", "lo", "la", "lv", "lt", "lb", "mg", "ms", "ml", "mt", "mk", "mi", "mr", "mhr", "mn", "de", "ne", "no", "pa", "pap", "fa", "pl", "pt", "ro", "ru", "ceb", "sr", "si", "sk", "sl", "sw", "su", "tg", "th", "tl", "ta", "tt", "te", "tr", "udm", "uz", "uk", "ur", "fi", "fr", "hi", "hr", "cs", "sv", "gd", "et", "eo", "jv", "ja" };
private static readonly string HttpsServicePointTemplateUrl = "https://translate.yandex.net/api/v1.5/tr.json/translate?key={3}&text={2}&lang={0}-{1}&format=plain";
private string _key;
public override string Id => "YandexTranslate";
public override string FriendlyName => "Yandex Translate";
public override void Initialize( IInitializationContext context )
{
_key = context.GetOrCreateSetting( "Yandex", "YandexAPIKey", "" );
context.DisableCertificateChecksFor( "translate.yandex.net" );
// if the plugin cannot be enabled, simply throw so the user cannot select the plugin
if( string.IsNullOrEmpty( _key ) ) throw new Exception( "The YandexTranslate endpoint requires an API key which has not been provided." );
if( !SupportedLanguages.Contains( context.SourceLanguage ) ) throw new Exception( $"The source language '{context.SourceLanguage}' is not supported." );
if( !SupportedLanguages.Contains( context.DestinationLanguage ) ) throw new Exception( $"The destination language '{context.DestinationLanguage}' is not supported." );
}
public override void OnCreateRequest( IHttpRequestCreationContext context )
{
var request = new XUnityWebRequest(
string.Format(
HttpsServicePointTemplateUrl,
context.SourceLanguage,
context.DestinationLanguage,
WwwHelper.EscapeUrl( context.UntranslatedText ),
_key ) );
request.Headers[ HttpRequestHeader.Accept ] = "*/*";
request.Headers[ HttpRequestHeader.AcceptCharset ] = "UTF-8";
context.Complete( request );
}
public override void OnExtractTranslation( IHttpTranslationExtractionContext context )
{
var data = context.Response.Data;
var obj = JSON.Parse( data );
var code = obj.AsObject[ "code" ].ToString();
if( code != "200" ) context.Fail( "Received bad response code: " + code );
var token = obj.AsObject[ "text" ].ToString();
var translation = JsonHelper.Unescape( token.Substring( 2, token.Length - 4 ) );
if( string.IsNullOrEmpty( translation ) ) context.Fail( "Received no translation." );
context.Complete( translation );
}
}
This plugin extends from HttpEndpoint
. Let's look at the three methods it overrides:
Initialize
is used to read the API key the user has configured. In addition it callscontext.DisableCertificateChecksFor( "translate.yandex.net" )
in order to disable the certificate check for this specific hostname. If this is neglected, SSL will fail in most versions of Unity. Finally, it throws an exception if the plugin cannot be used with the specified configuration.OnCreateRequest
is used to construct theXUnityWebRequest
object that will be sent to the external endpoint. The call tocontext.Complete( request )
specifies the request to use.OnExtractTranslation
is used to extract the text from the response returned from the web server.
As you can see, the XUnityWebClient
class is not even used. We simply specify a request object that the HttpEndpoint
will use internally to perform the request.
After implementing the class, simply build the project and place the generated DLL file in the "Translators" directory of the plugin folder. That's it.
For more examples of implementations, you can simply take a look at this projects source code.
NOTE: If you implement a class based on the HttpEndpoint
and you get an error where the web request is never completed, then it is likely due to the web server requiring Tls1.2. Unity-mono has issues with this spec and it will cause the request to lock up forever. The only solutions to this for now are:
- Disable SSL, if you can. There are many situations where it is simply not possible to do this because the web server will simply redirect back to the HTTPS endoint.
- Use the
WwwEndpoint
instead. I highly advice against this though, unless it is an authenticated endpoint.
Another way to implement a translator is to implement the ExtProtocolEndpoint
class. This can be used to delegate the actual translation logic to an external process. Currently there is no documentation on this, but you can take a look at the LEC implementation, which uses it.
If instead, you use the interface directly, it is also possible to extend from MonoBehaviour to get access to all the normal lifecycle callbacks of Unity components.
The resource director allows you to modify resources loaded through the Resources
and AssetBundle
API as they are being loaded by the game.
The following API surface is made available by the Resource Redirector:
/// <summary>
/// Entrypoint to the resource redirection API.
/// </summary>
public static class ResourceRedirection
{
/// <summary>
/// Gets or sets a bool indicating if the resource redirector
/// should log all loaded resources/assets to the console.
/// </summary>
public static bool LogAllLoadedResources { get; set; }
/// <summary>
/// Register an AssetLoading hook (prefix to loading an asset from an asset bundle).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAssetLoadingHook( int priority, Action<AssetLoadingContext> action );
/// <summary>
/// Unregister an AssetLoading hook (prefix to loading an asset from an asset bundle).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAssetLoadingHook( Action<AssetLoadingContext> action );
/// <summary>
/// Register an AsyncAssetLoading hook (prefix to loading an asset from an asset bundle asynchronously).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAsyncAssetLoadingHook( int priority, Action<AsyncAssetLoadingContext> action );
/// <summary>
/// Unregister an AsyncAssetLoading hook (prefix to loading an asset from an asset bundle asynchronously).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAsyncAssetLoadingHook( Action<AsyncAssetLoadingContext> action );
/// <summary>
/// Register an AsyncAssetLoading hook and AssetLoading hook (prefix to loading an asset from an asset bundle synchronously/asynchronously).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAsyncAndSyncAssetLoadingHook( int priority, Action<IAssetLoadingContext> action );
/// <summary>
/// Unregister an AsyncAssetLoading hook and AssetLoading hook (prefix to loading an asset from an asset bundle synchronously/asynchronously).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAsyncAndSyncAssetLoadingHook( Action<IAssetLoadingContext> action );
/// <summary>
/// Register an AssetLoaded hook (postfix to loading an asset from an asset bundle (both synchronous and asynchronous)).
/// </summary>
/// <param name="behaviour">The behaviour of the callback.</param>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAssetLoadedHook( HookBehaviour behaviour, int priority, Action<AssetLoadedContext> action );
/// <summary>
/// Unregister an AssetLoaded hook (postfix to loading an asset from an asset bundle (both synchronous and asynchronous)).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAssetLoadedHook( Action<AssetLoadedContext> action );
/// <summary>
/// Register an AssetBundleLoading hook (prefix to loading an asset bundle synchronously).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAssetBundleLoadingHook( int priority, Action<AssetBundleLoadingContext> action );
/// <summary>
/// Unregister an AssetBundleLoading hook (prefix to loading an asset bundle synchronously).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAssetBundleLoadingHook( Action<AssetBundleLoadingContext> action );
/// <summary>
/// Register an AsyncAssetBundleLoading hook (prefix to loading an asset bundle asynchronously).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAsyncAssetBundleLoadingHook( int priority, Action<AsyncAssetBundleLoadingContext> action );
/// <summary>
/// Unregister an AsyncAssetBundleLoading hook (prefix to loading an asset bundle asynchronously).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAsyncAssetBundleLoadingHook( Action<AsyncAssetBundleLoadingContext> action );
/// <summary>
/// Register an AsyncAssetBundleLoading hook and AssetBundleLoading hook (prefix to loading an asset bundle synchronously/asynchronously).
/// </summary>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterAsyncAndSyncAssetBundleLoadingHook( int priority, Action<IAssetBundleLoadingContext> action );
/// <summary>
/// Unregister an AsyncAssetBundleLoading hook and AssetBundleLoading hook (prefix to loading an asset bundle synchronously/asynchronously).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterAsyncAndSyncAssetBundleLoadingHook( Action<IAssetBundleLoadingContext> action );
/// <summary>
/// Register a ReourceLoaded hook (postfix to loading a resource from the Resources API (both synchronous and asynchronous)).
/// </summary>
/// <param name="behaviour">The behaviour of the callback.</param>
/// <param name="priority">The priority of the callback, the higher the sooner it will be called.</param>
/// <param name="action">The callback.</param>
public static void RegisterResourceLoadedHook( HookBehaviour behaviour, int priority, Action<ResourceLoadedContext> action );
/// <summary>
/// Unregister a ReourceLoaded hook (postfix to loading a resource from the Resources API (both synchronous and asynchronous)).
/// </summary>
/// <param name="action">The callback.</param>
public static void UnregisterResourceLoadedHook( Action<ResourceLoadedContext> action );
/// <summary>
/// Enables experimental hooks that allows returning an Asset instead of a Request from async prefix
/// asset load operations.
/// </summary>
public static void EnableSyncOverAsyncAssetLoads();
/// <summary>
/// Disables all recursive behaviour in the plugin. This means that trying to load an asset
/// using the hooked APIs will not trigger a callback back into the callback chain. This makes
/// setting the correct priorities on callbacks much more important.
///
/// This method should not be called lightly. It should not be something a single plugin randomly
/// decides to call, but rather decision for how to use the ResourceRedirection API on a game-wide basis.
/// </summary>
public static void DisableRecursionPermanently();
/// <summary>
/// Creates an asset bundle hook that attempts to load asset bundles in the emulation directory
/// over the default asset bundles if they exist.
/// </summary>
/// <param name="hookPriority">Priority of the hook.</param>
/// <param name="emulationDirectory">The directory to look for the asset bundles in.</param>
public static void EnableEmulateAssetBundles( int hookPriority, string emulationDirectory );
/// <summary>
/// Disable a previously enabled asset bundle emulation.
/// </summary>
public static void DisableEmulateAssetBundles();
/// <summary>
/// Creates an asset bundle hook that redirects asset bundles loads to an empty
/// asset bundle if the file that is being loaded does not exist.
/// </summary>
/// <param name="hookPriority">Priority of the hook.</param>
public static void EnableRedirectMissingAssetBundlesToEmptyAssetBundle( int hookPriority );
/// <summary>
/// Disable a previously enabled redirect missing asset bundles to empty asset bundle.
/// </summary>
public static void DisableRedirectMissingAssetBundlesToEmptyAssetBundle();
}
Let's attach some comments to this API.
The resource redirector comes with both postfix and prefix callbacks when loading an asset from the AssetBundle
API.
The event callback chain looks like this [AssetLoading / AsyncAssetLoading hooks] => [Original Method] => [AssetLoaded hooks]
. The AssetLoaded
event handles postfixes for both synchronous and asynchronous loading of assets.
The methods RegisterAssetLoadingHook( HookBehaviour behaviour, Action<AssetLoadingContext> action )
and RegisterAsyncAssetLoadingHook( int priority, Action<AsyncAssetLoadingContext> action )
hooks into the AssetBundle
API when loading assets.
These methods registers prefix callbacks, which means the assets themselves wont be loaded yet when they are called.
The callbacks take the types AssetLoadingContext
and AsyncAssetLoadingContext
as an argument, respectively. Let's take a look at their definitions:
/// <summary>
/// The operation context surrounding the AssetLoading hook (synchronous).
/// </summary>
public class AssetLoadingContext : IAssetLoadingContext
{
/// <summary>
/// Gets the original path the asset bundle was loaded with.
/// </summary>
/// <returns>The unmodified, original path the asset bundle was loaded with.</returns>
public string GetAssetBundlePath();
/// <summary>
/// Gets the normalized path to the asset bundle that is:
/// * Relative to the current directory
/// * Lower-casing
/// * Uses '\' as separators.
/// </summary>
/// <returns></returns>
public string GetNormalizedAssetBundlePath();
/// <summary>
/// Indicate your work is done and if any other hooks to this asset/resource load should be called.
/// </summary>
/// <param name="skipRemainingPrefixes">Indicate if the remaining prefixes should be skipped.</param>
/// <param name="skipOriginalCall">Indicate if the original call should be skipped. If you set the asset, you likely want to set this to true.</param>
/// <param name="skipAllPostfixes">Indicate if the postfixes should be skipped.</param>
public void Complete( bool skipRemainingPrefixes = true, bool? skipOriginalCall = true, bool? skipAllPostfixes = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the original parameters the asset load call was called with.
/// </summary>
public AssetLoadingParameters Parameters { get; }
/// <summary>
/// Gets the AssetBundle associated with the loaded assets.
/// </summary>
public AssetBundle Bundle { get; }
/// <summary>
/// Gets or sets the loaded assets.
///
/// Consider using this if the load type is 'LoadByType' or 'LoadNamedWithSubAssets'.
/// </summary>
public UnityEngine.Object[] Assets { get; set; }
/// <summary>
/// Gets or sets the loaded assets. This is simply equal to the first index of the Assets property, with some
/// additional null guards to prevent NullReferenceExceptions when using it.
/// </summary>
public UnityEngine.Object Asset { get; set; }
}
/// <summary>
/// The operation context surrounding the AsyncAssetLoading hook (asynchronous).
/// </summary>
public class AsyncAssetLoadingContext : IAssetLoadingContext
{
/// <summary>
/// Gets the original path the asset bundle was loaded with.
/// </summary>
/// <returns>The unmodified, original path the asset bundle was loaded with.</returns>
public string GetAssetBundlePath();
/// <summary>
/// Gets the normalized path to the asset bundle that is:
/// * Relative to the current directory
/// * Lower-casing
/// * Uses '\' as separators.
/// </summary>
/// <returns></returns>
public string GetNormalizedAssetBundlePath();
/// <summary>
/// Indicate your work is done and if any other hooks to this asset/resource load should be called.
/// </summary>
/// <param name="skipRemainingPrefixes">Indicate if the remaining prefixes should be skipped.</param>
/// <param name="skipOriginalCall">Indicate if the original call should be skipped. If you set the asset, you likely want to set this to true.</param>
/// <param name="skipAllPostfixes">Indicate if the postfixes should be skipped.</param>
public void Complete( bool skipRemainingPrefixes = true, bool? skipOriginalCall = true, bool? skipAllPostfixes = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the original parameters the asset load call was called with.
/// </summary>
public AssetLoadingParameters Parameters { get; }
/// <summary>
/// Gets the AssetBundle associated with the loaded assets.
/// </summary>
public AssetBundle Bundle { get; }
/// <summary>
/// Gets or sets the AssetBundleRequest used to load assets.
/// </summary>
public AssetBundleRequest Request { get; set; }
/// <summary>
/// Gets or sets the loaded assets.
///
/// Consider using this if the load type is 'LoadByType' or 'LoadNamedWithSubAssets'.
/// </summary>
UnityEngine.Object[] Assets { get; set; }
/// <summary>
/// Gets or sets the loaded assets. This is simply equal to the first index of the Assets property, with some
/// additional null guards to prevent NullReferenceExceptions when using it.
/// </summary>
UnityEngine.Object Asset { get; set; }
/// <summary>
/// Gets or sets how this load operation should be resolved.
/// Setting the Asset/Assets/Request property will automatically update this value.
/// </summary>
public AsyncAssetLoadingResolve ResolveType { get; set; }
}
The only difference between these two contexts is that one has an Asset/Assets
property you can set, while the other has a Request
property you can set.
Now if you actually paid attention to what you were reading(!?), you would notice that both of the above context objects has an Asset/Assets
property that can be set.
Under normal circumstances, however, you cannot use the Assets/Asset
property on the the AsyncAssetLoadingContext
. In order to be able to use these, you must first call ResourceRedirection.EnableSyncOverAsyncAssetLoads
once during your initialization logic. This will allow you to set the asset directly so you don't have to go through the standard AssetBundle
API to obtain a request object.
It is, however, recommended that if you can that you set the Request
property instead of the Assets/Asset
property as that will keep the operation asynchronous and not block the game while the asset is being loaded.
If you can handle the loading of the asset remember to call the Complete
method to indicate your intentions regarding:
- Whether the rest of the prefixes registered should be skipped.
- Whether the original method should be skipped.
- Whether all the postfixes should be skipped.
An important points to make here, is that there is both an Asset
and an Assets
property on the context object. These can be used interchangably, but an array will only ever be used if the following condition apply:
- The
LoadType
in theParameters
property isLoadByType
orLoadNamedWithSubAssets
, which are the only types of resource loading that may return multiple resources.
Finally, if we take a look at the Parameters
property of the context object, we will find the following definition:
/// <summary>
/// Class representing the original parameters of the load call.
/// </summary>
public class AssetLoadingParameters
{
/// <summary>
/// Gets or sets the name of the asset being loaded. Will be null if loaded through 'LoadMainAsset' or 'LoadByType'.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the type that passed to the asset load call.
/// </summary>
public Type Type { get; set; }
/// <summary>
/// Gets the type of call that loaded this asset. If 'LoadByType' or 'LoadNamedWithSubAssets' is specified
/// multiple assets may be returned if subscribed as 'OneCallbackPerLoadCall'.
/// </summary>
public AssetLoadType LoadType { get; }
}
/// <summary>
/// Enum representing the different ways an asset may be loaded.
/// </summary>
public enum AssetLoadType
{
/// <summary>
/// Indicates that this asset has been loaded as the 'mainAsset' in the AssetBundle API.
/// </summary>
LoadMainAsset,
/// <summary>
/// Indicates that this call is loading all assets of a specific type in an AssetBundle API.
/// </summary>
LoadByType,
/// <summary>
/// Indicates that this call is loading a specific named asset in the AssetBundle API.
/// </summary>
LoadNamed,
/// <summary>
/// Indicates that this call is loading a specific named asset and all those below it in the AssetBundle API.
/// </summary>
LoadNamedWithSubAssets
}
Another way to change the result of the asset load operation is to change the value of the Name
and Type
properties in the Parameters
property. If you do this, you likely will not want to call the Complete method, as you will want the original method to still be called.
An important additional way to subscribe to the prefix asset loading operations are through the method RegisterAsyncAndSyncAssetLoadingHook( int priority, Action<IAssetLoadingContext> action )
. This method will handle both async and sync asset loading operations. The IAssetLoadingContext
is an interface implemented by both the AssetLoadingContext
and AsyncAssetLoadingContext
.
Do note, that if you want to use this method you must first call the method EnableSyncOverAsyncAssetLoads()
to enable the hooks required for this to work.
The method RegisterAssetLoadedHook( HookBehaviour behaviour, Action<AssetLoadedContext> action )
hooks into the AssetBundle
API in the UnityEngine. Any time an asset is loaded through this API a callback is sent to these hooks.
This API is a postfix hook to the AssetBundle
API, which means that it is first called once the original asset has already been loaded, but is still replacable.
The AssetLoadedContext
class has the following definition:
/// <summary>
/// The operation context surrounding the AssetLoaded hook.
/// </summary>
public class AssetLoadedContext : IAssetOrResourceLoadedContext
{
/// <summary>
/// Gets a bool indicating if this resource has already been redirected before.
/// </summary>
public bool HasReferenceBeenRedirectedBefore( UnityEngine.Object asset );
/// <summary>
/// Gets a file system path for the specfic asset that should be unique.
/// </summary>
/// <param name="asset"></param>
/// <returns></returns>
public string GetUniqueFileSystemAssetPath( UnityEngine.Object asset );
/// <summary>
/// Gets the original path the asset bundle was loaded with.
/// </summary>
/// <returns>The unmodified, original path the asset bundle was loaded with.</returns>
public string GetAssetBundlePath();
/// <summary>
/// Gets the normalized path to the asset bundle that is:
/// * Relative to the current directory
/// * Lower-casing
/// * Uses '\' as separators.
/// </summary>
/// <returns></returns>
public string GetNormalizedAssetBundlePath();
/// <summary>
/// Indicate your work is done and if any other hooks to this asset/resource load should be called.
/// </summary>
/// <param name="skipRemainingPostfixes">Indicate if any other hooks should be skipped.</param>
public void Complete( bool skipRemainingPostfixes = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the original parameters the asset load call was called with.
/// </summary>
public AssetLoadedParameters Parameters { get; }
/// <summary>
/// Gets the AssetBundle associated with the loaded assets.
/// </summary>
public AssetBundle Bundle { get; }
/// <summary>
/// Gets the loaded assets. Override individual indices to change the asset reference that will be loaded.
///
/// Consider using this if the load type is 'LoadByType' or 'LoadNamedWithSubAssets' and you subscribed with 'OneCallbackPerLoadCall'.
/// </summary>
public UnityEngine.Object[] Assets { get; set; }
/// <summary>
/// Gets the loaded asset. This is simply equal to the first index of the Assets property, with some
/// additional null guards to prevent NullReferenceExceptions when using it.
/// </summary>
public UnityEngine.Object Asset { get; set; }
}
The HookBehaviour is an enum with the following definition:
/// <summary>
/// Enum indicating how the resource redirector should treat the callback.
/// </summary>
public enum HookBehaviour
{
/// <summary>
/// Specifies that exactly one callback should be received per call to asset/resource load method.
/// </summary>
OneCallbackPerLoadCall = 1,
/// <summary>
/// Specifies that exactly one callback should be received per loaded asset/resources. This means
/// that the 'Asset' property should be used over the 'Assets' property on the context object.
/// Do note that when using this option, if no resources are returned by a load call, no callbacks
/// will be received.
/// </summary>
OneCallbackPerResourceLoaded = 2
}
An important points to make here, is that there is both an Asset
and an Assets
property on the context object. These can be used interchangably, but an array will only ever be used if the following two conditions apply:
- You've subscribed with
OneCallbackPerLoadCall
. - The
LoadType
in theParameters
property isLoadByType
orLoadNamedWithSubAssets
, which are the only types of resource loading that may return multiple resources.
In relation to this, it is worth mentioning that if a call to load assets returns 0 assets, you will not receive any callbacks if you subscribe through OneCallbackPerResourceLoaded
where as if you subscribe through OneCallbackPerLoadCall
you would still get your one callback.
If you update or replace the asset being loaded remember to call to Complete
method to indicate your intentions regarding:
- Whether the remaining postfixes should be called.
In addition, if we take a look at the Parameters
property of the context object, we will find the following definition:
/// <summary>
/// Class representing the original parameters of the load call.
/// </summary>
public class AssetLoadedParameters
{
/// <summary>
/// Gets the name of the asset being loaded. Will be null if loaded through 'LoadMainAsset' or 'LoadByType'.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the type that passed to the asset load call.
/// </summary>
public Type Type { get; }
/// <summary>
/// Gets the type of call that loaded this asset. If 'LoadByType' or 'LoadNamedWithSubAssets' is specified
/// multiple assets may be returned if subscribed as 'OneCallbackPerLoadCall'.
/// </summary>
public AssetLoadType LoadType { get; }
}
/// <summary>
/// Enum representing the different ways an asset may be loaded.
/// </summary>
public enum AssetLoadType
{
/// <summary>
/// Indicates that this asset has been loaded as the 'mainAsset' in the AssetBundle API.
/// </summary>
LoadMainAsset,
/// <summary>
/// Indicates that this call is loading all assets of a specific type in an AssetBundle API.
/// </summary>
LoadByType,
/// <summary>
/// Indicates that this call is loading a specific named asset in the AssetBundle API.
/// </summary>
LoadNamed,
/// <summary>
/// Indicates that this call is loading a specific named asset and all those below it in the AssetBundle API.
/// </summary>
LoadNamedWithSubAssets
}
It is also worth mentioning that these hooks handles both synchronous and asynchronous loading of assets.
Hooks subscribed through hook behaviour OneCallbackPerLoadCall
will be called before hooks with the behaviour OneCallbackPerResourceLoaded
.
The resource redirector comes only with postfix callbacks when loading an asset from the Resources
API.
The event callback chain looks like this [Original Method] => [ResourceLoaded hooks]
. The ResourceLoaded
event handles postfixes for both synchronous and asynchronous loading of assets.
The method RegisterResourceLoadedHook( HookBehaviour behaviour, Action<ResourceLoadedContext> action )
hooks into the Resources
API in the UnityEngine. Any time a resource is loaded through this API a callback is sent to these hooks.
This API is a postfix hook to the Resources
API, which means that it is first called once the original asset has already been loaded, but is still replacable.
The ResourceLoadedContext
class has the following definition:
/// <summary>
/// The operation context surrounding the ResourceLoaded hook.
/// </summary>
public class ResourceLoadedContext : IAssetOrResourceLoadedContext
{
/// <summary>
/// Gets a bool indicating if this resource has already been redirected before.
/// </summary>
public bool HasReferenceBeenRedirectedBefore( UnityEngine.Object asset );
/// <summary>
/// Gets a file system path for the specfic asset that should be unique.
/// </summary>
/// <param name="asset"></param>
/// <returns></returns>
public string GetUniqueFileSystemAssetPath( UnityEngine.Object asset );
/// <summary>
/// Indicate your work is done and if any other hooks to this asset/resource load should be called.
/// </summary>
/// <param name="skipRemainingPostfixes">Indicate if any other hooks should be skipped.</param>
public void Complete( bool skipRemainingPostfixes = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the original parameters the asset load call was called with.
/// </summary>
public ResourceLoadParameters Parameters { get; }
/// <summary>
/// Gets the loaded assets. Override individual indices to change the asset reference that will be loaded.
///
/// Consider using this if the load type is 'LoadByType' and you subscribed with 'OneCallbackPerLoadCall'.
/// </summary>
public UnityEngine.Object[] Assets { get; set; }
/// <summary>
/// Gets the loaded asset. This is simply equal to the first index of the Assets property, with some
/// additional null guards to prevent NullReferenceExceptions when using it.
/// </summary>
public UnityEngine.Object Asset { get; set; }
}
The HookBehaviour is an enum with the following definition:
/// <summary>
/// Enum indicating how the resource redirector should treat the callback.
/// </summary>
public enum HookBehaviour
{
/// <summary>
/// Specifies that exactly one callback should be received per call to asset/resource load method.
/// </summary>
OneCallbackPerLoadCall = 1,
/// <summary>
/// Specifies that exactly one callback should be received per loaded asset/resources. This means
/// that the 'Asset' property should be used over the 'Assets' property on the context object.
/// Do note that when using this option, if no resources are returned by a load call, no callbacks
/// will be received.
/// </summary>
OneCallbackPerResourceLoaded = 2
}
An important points to make here, is that there is both an Asset
and an Assets
property on the context object. These can be used interchangably, but an array will only ever be used if the following two conditions apply:
- You've subscribed with
OneCallbackPerLoadCall
. - The
LoadType
in theParameters
property isLoadByType
, which is the only type of resource loading that may return multiple resources.
In relation to this, it is worth mentioning that if a call to load assets returns 0 assets, you will not receive any callbacks if you subscribe through OneCallbackPerResourceLoaded
where as if you subscribe through OneCallbackPerLoadCall
you would still get your one callback.
If you update or replace the asset being loaded remember to call to Complete
method to indicate your intentions regarding:
- Whether the remaining postfixes should be called.
In addition, if we take a look at the Parameters
property of the context object, we will find the following definition:
/// <summary>
/// Class representing the original parameters of the load call.
/// </summary>
public class ResourceLoadedParameters
{
/// <summary>
/// Gets the name of the resource being loaded. Will not be the complete resource path if 'LoadByType' is used.
/// </summary>
public string Path { get; set; }
/// <summary>
/// Gets the type that passed to the resource load call.
/// </summary>
public Type Type { get; set; }
/// <summary>
/// Gets the type of call that loaded this asset. If 'LoadByType' is specified
/// multiple assets may be returned if subscribed as 'OneCallbackPerLoadCall'.
/// </summary>
public ResourceLoadType LoadType { get; }
}
/// <summary>
/// Enum representing the different ways a resource may be loaded.
/// </summary>
public enum ResourceLoadType
{
/// <summary>
/// Indicates that this call is loading all assets of a specific type (below a specific path) in the Resources API.
/// </summary>
LoadByType,
/// <summary>
/// Indicates that this call is loading a single named asset in the Resources API.
/// </summary>
LoadNamed,
/// <summary>
/// Indicates that this call is loading a single named built-in asset in the Resources API.
/// </summary>
LoadNamedBuiltIn
}
It is also worth mentioning that these hooks handles both synchronous and asynchronous loading of resources.
Hooks subscribed through hook behaviour OneCallbackPerLoadCall
will be called before hooks with the behaviour OneCallbackPerResourceLoaded
.
It is also possible to hook the loading of AssetBundles
themselves. Only prefix hooks are supported when loading an asset bundle.
The event callback chain looks like this [AssetBundleLoading/AsyncAssetBundleLoading hooks] => [Original Method]
.
The method RegisterAssetBundleLoadingHook( Action<AssetBundleLoadingContext> action )
is used to hook the synchronous AssetBundle load methods.
This API is a prefix to the AssetBundle
API, which means that it is called before the AssetBundle is loaded.
The AssetBundleLoadingContext
class has the following definition:
/// <summary>
/// The operation context surrounding the AssetBundleLoading hook (synchronous).
/// </summary>
public class AssetBundleLoadingContext : IAssetBundleLoadingContext
{
/// <summary>
/// Gets a normalized path to the asset bundle that is:
/// * Relative to the current directory
/// * Lower-casing
/// * Uses '\' as separators.
/// </summary>
/// <returns></returns>
public string GetNormalizedPath();
/// <summary>
/// Indicate your work is done and if any other hooks to this asset bundle load should be called.
/// </summary>
/// <param name="skipRemainingPrefixes">Indicate if the remaining prefixes should be skipped.</param>
/// <param name="skipOriginalCall">Indicate if the original call should be skipped. If you set the asset bundle, you likely want to set this to true.</param>
public void Complete( bool skipRemainingPrefixes = true, bool? skipOriginalCall = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the parameters of the call.
/// </summary>
public AssetBundleLoadingParameters Parameters { get; }
/// <summary>
/// Gets or sets the AssetBundle being loaded.
/// </summary>
public AssetBundle Bundle { get; set; }
}
Because this is a prefix API, the Bundle
property will be null when the method is called and it is up to you to set it to a different value if you can handle the specified path.
If you update the Bundle
property, remember to call the Complete
to indicate your intentions regarding:
- Whether or not the remaining prefixes should be skipped.
- Whether or not the original method should be skipped.
In addition, if we take a look at the Parameters
property of the context object, we will find the following definition:
/// <summary>
/// Class representing the original parameters of the load call.
/// </summary>
public class AssetBundleLoadingParameters
{
/// <summary>
/// Gets or sets the loaded path. Only relevant for 'LoadFromFile'.
/// </summary>
public string Path { get; }
/// <summary>
/// Gets or sets the crc. Only relevant for 'LoadFromFile'.
/// </summary>
public uint Crc { get; }
/// <summary>
/// Gets or sets the offset. Only relevant for 'LoadFromFile'.
/// </summary>
public ulong Offset { get; }
/// <summary>
/// Gets the type of call that is loading this asset bundle.
/// </summary>
public AssetBundleLoadType LoadType { get; }
}
/// <summary>
/// Enum representing the different ways an asset bundle may be loaded.
/// </summary>
public enum AssetBundleLoadType
{
/// <summary>
/// Indicates that the asset bundle is being loaded through a call to 'LoadFromFile' or 'LoadFromFileAsync'.
/// </summary>
LoadFromFile,
}
As can be seen, the current implementation only hooks the LoadFromFile/LoadFromFileAsync ways of loading AssetBundles, but this may be expanded in the future.
It may also be worth looking at the GetNormalizedPath()
method instead of the Path
property of the original call parameters. This is because the path passed to the method can take literally any form:
- Absolute path
- Relative path
- Include a stray '..' in the middle of the path
Another way to change the result of the asset bundle load operation is to change the value of the Path
, Crc
and Offset
properties in the Parameters
property. If you do this, you likely will not want to call the Complete method, as you will want the original method to still be called.
The method RegisterAsyncAssetBundleLoadingHook( Action<AsyncAssetBundleLoadingContext> action )
is used to hook the asynchronous AssetBundle load methods.
This API is a prefix to the AssetBundle
API, which means that it is called before the AssetBundleCreateRequest
is created.
The AsyncAssetBundleLoadingContext
class has the following definition:
/// <summary>
/// The operation context surrounding the AsyncAssetBundleLoading hook (asynchronous).
/// </summary>
public class AsyncAssetBundleLoadingContext : IAssetBundleLoadingContext
{
/// <summary>
/// Gets a normalized path to the asset bundle that is:
/// * Relative to the current directory
/// * Lower-casing
/// * Uses '\' as separators.
/// </summary>
/// <returns></returns>
public string GetNormalizedPath();
/// <summary>
/// Indicate your work is done and if any other hooks to this asset bundle load should be called.
/// </summary>
/// <param name="skipRemainingPrefixes">Indicate if the remaining prefixes should be skipped.</param>
/// <param name="skipOriginalCall">Indicate if the original call should be skipped. If you set the request, you likely want to set this to true.</param>
public void Complete( bool skipRemainingPrefixes = true, bool? skipOriginalCall = true );
/// <summary>
/// Disables recursive calls if you make an asset/asset bundle load call
/// from within your callback. If you want to prevent recursion this should
/// be called before you load the asset/asset bundle.
/// </summary>
public void DisableRecursion();
/// <summary>
/// Gets the parameters of the call.
/// </summary>
public AssetBundleLoadingParameters Parameters { get; }
/// <summary>
/// Gets or sets the AssetBundleCreateRequest being used to load the AssetBundle.
/// </summary>
public AssetBundleCreateRequest Request { get; set; }
/// <summary>
/// Gets or sets the AssetBundle being loaded.
/// </summary>
public AssetBundle Bundle { get; set; }
/// <summary>
/// Gets or sets how this load operation should be resolved.
/// Setting the Bundle/Request property will automatically update this value.
/// </summary>
public AsyncAssetBundleLoadingResolve ResolveType { get; set; }
}
Because this is a prefix API, the Request
property will be null when the method is called and it is up to you to set it to a different value if you can handle the specified path.
As you can see there is actually also a Bundle
property available on the context object. Under normal circumstances, however, you cannot use the Bundle
property on the the AsyncAssetBundleLoadingContext
. In order to be able to use these, you must first call ResourceRedirection.EnableSyncOverAsyncAssetLoads
once during your initialization logic. This will allow you to set the bundle directly so you don't have to go through the standard AssetBundle
API to obtain a request object.
It is, however, recommended that if you can that you set the Request
property instead of the Bundle
property as that will keep the operation asynchronous and not block the game while the asset is being loaded.
If you update the Request
property, remember to call the Complete
to indicate your intentions regarding:
- Whether or not the remaining prefixes should be skipped.
- Whether or not the original method should be skipped.
In addition, if we take a look at the Parameters
property of the context object, we will find the following definition:
/// <summary>
/// Class representing the original parameters of the load call.
/// </summary>
public class AssetBundleLoadingParameters
{
/// <summary>
/// Gets the loaded path. Only relevant for 'LoadFromFile'.
/// </summary>
public string Path { get; }
/// <summary>
/// Gets the crc. Only relevant for 'LoadFromFile'.
/// </summary>
public uint Crc { get; }
/// <summary>
/// Gets the offset. Only relevant for 'LoadFromFile'.
/// </summary>
public ulong Offset { get; }
/// <summary>
/// Gets the type of call that is loading this asset bundle.
/// </summary>
public AssetBundleLoadType LoadType { get; }
}
/// <summary>
/// Enum representing the different ways an asset bundle may be loaded.
/// </summary>
public enum AssetBundleLoadType
{
/// <summary>
/// Indicates that the asset bundle is being loaded through a call to 'LoadFromFile' or 'LoadFromFileAsync'.
/// </summary>
LoadFromFile,
}
As can be see, the current implementation only hooks the LoadFromFile/LoadFromFileAsync ways of loading AssetBundles, but this may be expanded in the future.
It may also be worth looking at the GetNormalizedPath()
method instead of the Path
property of the original call parameters. This is because the path passed to the method can take literally any form:
- Absolute path
- Relative path
- Include a stray '..' in the middle of the path
Another way to change the result of the asset bundle load operation is to change the value of the Path
, Crc
and Offset
properties in the Parameters
property. If you do this, you likely will not want to call the Complete method, as you will want the original method to still be called.
An important additional way to subscribe to the prefix asset bundle loading operations are through the method RegisterAsyncAndSyncAssetBundleLoadingHook( int priority, Action<IAssetBundleLoadingContext> action )
. This method will handle both async and sync asset bundle loading operations. The IAssetLoadingContext
is an interface implemented by both the AssetBundleLoadingContext
and AsyncAssetBundleLoadingContext
.
Do note, that if you want to use this method you must first call the method EnableSyncOverAsyncAssetLoads()
to enable the hooks required for this to work.
As you may have noticed, all of the context classes shown in the previous sections had a method called DisableRecursion
and that there is a method called DisableRecursionPermanently
directly on the ResourceRedirection
class.
The purpose of these method is, as it name states, to disable recursion. That only leaves the question, when does recursion occur?
Recursion will happen anytime you try to load an asset/resource/asset bundle from within your callback using the AssetBundle
or Resources
API. Essentially, what it means is that all callbacks (except the one loading the resource) will get a chance to modify the resource that is being loaded by your callback.
This may not always be desirable, so if you call the method DisableRecursion
before you load your resource, this recursive behaviour is disabled. In many other cases, this behaviour is very desirable because it means that it is less important to set the correct priority, whatever that may be.
Recursion has an important side effect for other prefix/postfix callbacks, and that is that they will always be called if you make a recursive load call in your callback, even if you indicate through the Complete
method that they should not be called. So if, in your scenario, it is important to avoid this, you must disable recursion.
DisableRecursionPermanently
disables recursion permanently for all subscribers to the ResourceRedirection API no matter who calls it. This is a game-wide setting that should be decided upon between plugin developers rather than by the hand of the individual.
These options exists only because it is not currently known whether or not having recursion enabled gives the best experience to plugin developers.
Here's an example of how a resource redirection may be implemented to hook all Texture2D
objects loaded through the AssetBundle
API:
class TextureReplacementPlugin
{
void Awake()
{
ResourceRedirection.RegisterAssetLoadedHook(
behaviour: HookBehaviour.OneCallbackPerResourceLoaded,
priority: 0,
action: AssetLoaded );
}
public void AssetLoaded( AssetLoadedContext context )
{
if( context.Asset is Texture2D texture2d ) // also acts as a null check
{
// TODO: Modify, replace or dump the texture
context.Asset = texture2d; // only need to update the reference if you created a new texture
context.Complete(
skipRemainingPostfixes: true );
}
}
}
Here's an example of how a resource redirection may be implemented to redirect non-existing resources to a seperate 'mods' directory.
class AssetBundleRedirectorPlugin
{
void Awake()
{
ResourceRedirection.RegisterAssetBundleLoadingHook(
priority: 1000,
action: AssetBundleLoading );
ResourceRedirection.RegisterAsyncAssetBundleLoadingHook(
priority: 1000,
action: AsyncAssetBundleLoading );
}
public void AssetBundleLoading( AssetBundleLoadingContext context )
{
if( !File.Exists( context.Parameters.Path ) )
{
// the game is trying to load a path that does not exist, lets redirect to our own resources
// obtain different resource path
var normalizedPath = context.GetNormalizedPath();
var modFolderPath = Path.Combine( "mods", normalizedPath );
// if the path exists, let's load that instead
if( File.Exists( modFolderPath ) )
{
var bundle = AssetBundle.LoadFromFile( modFolderPath );
context.Bundle = bundle;
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
}
}
}
public void AsyncAssetBundleLoading( AsyncAssetBundleLoadingContext context )
{
if( !File.Exists( context.Parameters.Path ) )
{
// the game is trying to load a path that does not exist, lets redirect to our own resources
// obtain different resource path
var normalizedPath = context.GetNormalizedPath();
var modFolderPath = Path.Combine( "mods", normalizedPath );
// if the path exists, let's load that instead
if( File.Exists( modFolderPath ) )
{
var request = AssetBundle.LoadFromFileAsync( modFolderPath );
context.Request = request;
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
}
}
}
}
Here's a smart way to implement the same thing, by having a single method that hooks both the synchronous and asynchronous method at the same time:
class AssetBundleRedirectorSyncOverAsyncPlugin
{
void Awake()
{
ResourceRedirection.EnableSyncOverAsyncAssetLoads();
ResourceRedirection.RegisterAsyncAndSyncAssetBundleLoadingHook(
priority: 1000,
action: AssetBundleLoading );
}
public void AssetBundleLoading( IAssetBundleLoadingContext context )
{
if( !File.Exists( context.Parameters.Path ) )
{
// the game is trying to load a path that does not exist, lets redirect to our own resources
// obtain different resource path
var normalizedPath = context.GetNormalizedPath();
var modFolderPath = Path.Combine( "mods", normalizedPath );
// if the path exists, let's load that instead
if( File.Exists( modFolderPath ) )
{
var bundle = AssetBundle.LoadFromFile( modFolderPath );
context.Bundle = bundle;
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
}
}
}
}
While this is clean it causes all asset bundles to be loaded synchronously, potentially locking up the game causing FPS lag. Another approach that also handles that could look like this:
class SmartAssetBundleRedirectorSyncOverAsyncPlugin
{
void Awake()
{
ResourceRedirection.EnableSyncOverAsyncAssetLoads();
ResourceRedirection.RegisterAsyncAndSyncAssetBundleLoadingHook(
priority: 1000,
action: AssetBundleLoading );
}
public void AssetBundleLoading( IAssetBundleLoadingContext context )
{
if( !File.Exists( context.Parameters.Path ) )
{
// the game is trying to load a path that does not exist, lets redirect to our own resources
// obtain different resource path
var normalizedPath = context.GetNormalizedPath();
var modFolderPath = Path.Combine( "mods", normalizedPath );
// if the path exists, let's load that instead
if( File.Exists( modFolderPath ) )
{
if( context is AsyncAssetBundleLoadingContext asyncContext )
{
var request = AssetBundle.LoadFromFileAsync( modFolderPath );
asyncContext.Request = request;
}
else
{
var bundle = AssetBundle.LoadFromFile( modFolderPath );
context.Bundle = bundle;
}
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
}
}
}
}
Here's the redirector that is activated if the EmulateAssetBundles
option is enabled in the plugin, which allows loading asset bundles from a different location than the game is requesting the bundle from:
/// <summary>
/// Creates an asset bundle hook that attempts to load asset bundles in the emulation directory
/// over the default asset bundles if they exist.
/// </summary>
/// <param name="hookPriority">Priority of the hook.</param>
/// <param name="emulationDirectory">The directory to look for the asset bundles in.</param>
public static void EnableEmulateAssetBundles( int hookPriority, string emulationDirectory )
{
RegisterAssetBundleLoadingHook( hookPriority, ctx => HandleAssetBundleEmulation( ctx, SetBundle ) );
RegisterAsyncAssetBundleLoadingHook( hookPriority, ctx => HandleAssetBundleEmulation( ctx, SetRequest ) );
// define base callback
void HandleAssetBundleEmulation<T>( T context, Action<T, string> changeBundle )
where T : IAssetBundleLoadingContext
{
if( context.Parameters.LoadType == AssetBundleLoadType.LoadFromFile )
{
var normalizedPath = context.GetNormalizedPath();
var emulatedPath = Path.Combine( emulationDirectory, normalizedPath );
if( File.Exists( emulatedPath ) )
{
changeBundle( context, emulatedPath );
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
}
}
}
// synchronous specific code
void SetBundle( AssetBundleLoadingContext context, string path )
{
context.Bundle = AssetBundle.LoadFromFile( path, context.Parameters.Crc, context.Parameters.Offset );
}
// asynchronous specific code
void SetRequest( AsyncAssetBundleLoadingContext context, string path )
{
context.Request = AssetBundle.LoadFromFileAsync( path, context.Parameters.Crc, context.Parameters.Offset );
}
}
Here's the redirector that is activated if the RedirectMissingAssetBundles
option is enabled in the plugin, which essentially simply loads an empty asset bundle if an asset bundle cannot be found:
/// <summary>
/// Creates an asset bundle hook that redirects asset bundles loads to an empty
/// asset bundle if the file that is being loaded does not exist.
/// </summary>
/// <param name="hookPriority">Priority of the hook.</param>
public static void EnableRedirectMissingAssetBundlesToEmptyAssetBundle( int hookPriority )
{
RegisterAssetBundleLoadingHook( hookPriority, ctx => HandleMissingBundle( ctx, SetBundle ) );
RegisterAsyncAssetBundleLoadingHook( hookPriority, ctx => HandleMissingBundle( ctx, SetRequest ) );
// define base callback
void HandleMissingBundle<TContext>( TContext context, Action<TContext, byte[]> changeBundle )
where TContext : IAssetBundleLoadingContext
{
if( context.Parameters.LoadType == AssetBundleLoadType.LoadFromFile
&& !File.Exists( context.Parameters.Path ) )
{
var buffer = Properties.Resources.empty;
CabHelper.RandomizeCab( buffer );
changeBundle( context, buffer );
context.Complete(
skipRemainingPrefixes: true,
skipOriginalCall: true );
XuaLogger.ResourceRedirector.Warn( "Tried to load non-existing asset bundle: " + context.Parameters.Path );
}
}
// synchronous specific code
void SetBundle( AssetBundleLoadingContext context, byte[] assetBundleData )
{
var bundle = AssetBundle.LoadFromMemory( assetBundleData );
context.Bundle = bundle;
}
// asynchronous specific code
void SetRequest( AsyncAssetBundleLoadingContext context, byte[] assetBundleData )
{
var request = AssetBundle.LoadFromMemoryAsync( assetBundleData );
context.Request = request;
}
}
This section shows how to implement an asset/resource redirector that respects the Auto Translator configuration.
The XUnity.AutoTranslator.Core.Plugin.dll
assembly has a base class that can be used to implement a plugin that dumps resources for the purposes of translation.
This class simply hooks the postfix to the load of assets from the AssetBundle
and the Resources
API. Here's how the base class looks:
/// <summary>
/// Base implementation of resource redirect handler that takes care of the plumming for a
/// resource redirector that is interested in either updating or dumping redirected resources.
/// </summary>
/// <typeparam name="TAsset">The type of asset being redirected.</typeparam>
public abstract class AssetLoadedHandlerBaseV2<TAsset>
where TAsset : UnityEngine.Object
{
/// <summary>
/// Method invoked when an asset should be updated or replaced.
/// </summary>
/// <param name="calculatedModificationPath">This is the modification path calculated in the CalculateModificationFilePath method.</param>
/// <param name="asset">The asset to be updated or replaced.</param>
/// <param name="context">This is the context containing all relevant information for the resource redirection event.</param>
/// <returns>A bool indicating if the event should be considered handled.</returns>
protected abstract bool ReplaceOrUpdateAsset( string calculatedModificationPath, ref TAsset asset, IAssetOrResourceLoadedContext context );
/// <summary>
/// Method invoked when an asset should be dumped.
/// </summary>
/// <param name="calculatedModificationPath">This is the modification path calculated in the CalculateModificationFilePath method.</param>
/// <param name="asset">The asset to be updated or replaced.</param>
/// <param name="context">This is the context containing all relevant information for the resource redirection event.</param>
/// <returns>A bool indicating if the event should be considered handled.</returns>
protected abstract bool DumpAsset( string calculatedModificationPath, TAsset asset, IAssetOrResourceLoadedContext context );
/// <summary>
/// Method invoked when a new resource event is fired to calculate a unique path for the resource.
/// </summary>
/// <param name="asset">The asset to be updated or replaced.</param>
/// <param name="context">This is the context containing all relevant information for the resource redirection event.</param>
/// <returns>A string uniquely representing a path for the redirected resource.</returns>
protected abstract string CalculateModificationFilePath( TAsset asset, IAssetOrResourceLoadedContext context );
/// <summary>
/// Method to be invoked to indicate if the asset should be handled or not.
/// </summary>
/// <param name="asset">The asset to be updated or replaced.</param>
/// <param name="context">This is the context containing all relevant information for the resource redirection event.</param>
/// <returns>A bool indicating if the asset should be handled.</returns>
protected abstract bool ShouldHandleAsset( TAsset asset, IAssetOrResourceLoadedContext context );
}
The Auto Translation includes one default implementation of this class for TextAssets. It looks like this:
internal class TextAssetLoadedHandler : AssetLoadedHandlerBaseV2<TextAsset>
{
protected override string CalculateModificationFilePath( TextAsset asset, IAssetOrResourceLoadedContext context )
{
return context.GetPreferredFilePath( asset, ".txt" );
}
protected override bool DumpAsset( string calculatedModificationPath, TextAsset asset, IAssetOrResourceLoadedContext context )
{
Directory.CreateDirectory( new FileInfo( calculatedModificationPath ).Directory.FullName );
File.WriteAllBytes( calculatedModificationPath, asset.bytes );
return true;
}
protected override bool ReplaceOrUpdateAsset( string calculatedModificationPath, ref TextAsset asset, IAssetOrResourceLoadedContext context )
{
RedirectedResource file;
var files = RedirectedDirectory.GetFile( calculatedModificationPath ).ToList();
if( files.Count == 0 )
{
return false;
}
else
{
if( files.Count > 1 )
{
XuaLogger.AutoTranslator.Warn( "Found more than one resource file in the same path: " + calculatedModificationPath );
}
file = files.FirstOrDefault();
}
if( file != null )
{
using( var stream = file.OpenStream() )
{
var data = stream.ReadFully( (int)stream.Length );
var text = Encoding.UTF8.GetString( data );
var ext = asset.GetOrCreateExtensionData<TextAssetExtensionData>();
ext.Data = data;
ext.Text = text;
return true;
}
}
return false;
}
protected override bool ShouldHandleAsset( TextAsset asset, IAssetOrResourceLoadedContext context )
{
return !context.HasReferenceBeenRedirectedBefore( asset );
}
}
Note that when accessing the resource file, we do not use the standard file API to obtain a stream to get the data in the file. Instead we use the RedirectedDirectory facade. This will also look in ZIP files and simply treat a ZIP file as a directory when making the lookup.
Another examples of an implementation of this class would be for Koikatsu that enables replacing its custom resources:
public class ScenarioDataResourceRedirector : AssetLoadedHandlerBaseV2<ScenarioData>
{
public ScenarioDataResourceRedirector()
{
CheckDirectory = true;
}
protected override string CalculateModificationFilePath( ScenarioData asset, IAssetOrResourceLoadedContext context )
{
return context.GetPreferredFilePathWithCustomFileName( asset, null )
.Replace( ".unity3d", "" );
}
protected override bool DumpAsset( string calculatedModificationPath, ScenarioData asset, IAssetOrResourceLoadedContext context )
{
var defaultTranslationFile = Path.Combine( calculatedModificationPath, "translation.txt" );
var cache = new SimpleTextTranslationCache(
file: defaultTranslationFile,
loadTranslationsInFile: false );
foreach( var param in asset.list )
{
if( param.Command == Command.Text )
{
for( int i = 0; i < param.Args.Length; i++ )
{
var key = param.Args[ i ];
if( !string.IsNullOrEmpty( key ) && LanguageHelper.IsTranslatable( key ) )
{
cache.AddTranslationToCache( key, key );
}
}
}
}
return true;
}
protected override bool ReplaceOrUpdateAsset( string calculatedModificationPath, ref ScenarioData asset, IAssetOrResourceLoadedContext context )
{
var defaultTranslationFile = Path.Combine( calculatedModificationPath, "translation.txt" );
var redirectedResources = RedirectedDirectory.GetFilesInDirectory( calculatedModificationPath, ".txt" );
var streams = redirectedResources.Select( x => x.OpenStream() );
var cache = new SimpleTextTranslationCache(
outputFile: defaultTranslationFile,
inputStreams: streams,
allowTranslationOverride: false,
closeStreams: true );
foreach( var param in asset.list )
{
if( param.Command == Command.Text )
{
for( int i = 0; i < param.Args.Length; i++ )
{
var key = param.Args[ i ];
if( !string.IsNullOrEmpty( key ) )
{
if( cache.TryGetTranslation( key, true, out var translated ) )
{
param.Args[ i ] = translated;
}
else if( AutoTranslatorSettings.IsDumpingRedirectedResourcesEnabled && LanguageHelper.IsTranslatable( key ) )
{
cache.AddTranslationToCache( key, key );
}
}
}
}
}
return true;
}
protected override bool ShouldHandleAsset( ScenarioData asset, IAssetOrResourceLoadedContext context )
{
return !context.HasReferenceBeenRedirectedBefore( asset );
}
}
Note that this implementation uses a SimpleTextTranslationCache
to lookup translations. Using this class for translation lookups have the following benefits:
- Whitespace doesn't have to match exactly.
- It respects the
RedirectedResourceDetectionStrategy
configuration. If this is not respected the plugin may double translate certain texts. - When loading text translation files, it supports the same text format that is otherwise used by the plugin.
Once you have implemented one of these classes, you just need to instantiate it and it will do it's magic.