• Stars
    star
    1,853
  • Rank 25,024 (Top 0.5 %)
  • Language
    C#
  • License
    MIT License
  • Created over 6 years ago
  • Updated 2 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

XUnity Auto Translator

Index

Introduction

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.

Plugin Frameworks

The mod can be installed without any external dependencies or as a plugin to the following Plugin Managers/Mod Loaders:

Installation instructions for all methods can be found below.

Installation

The plugin can be installed in following ways:

Standalone Installation (ReiPatcher)

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.

  1. Read the VERY IMPORTANT NOTE above.
  2. Download XUnity.AutoTranslator-ReiPatcher-{VERSION}.zip from releases.
  3. Extract directly into the game directory, such that "SetupReiPatcherAndAutoTranslator.exe" is placed alongside other exe files.
  4. Execute "SetupReiPatcherAndAutoTranslator.exe". This will setup up ReiPatcher correctly.
  5. Execute the shortcut {GameExeName} (Patch and Run).lnk that was created besides existing executables. This will patch and launch the game.
  6. From now on you can launch the game from the {GameExeName}.exe instead.
  7. 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.

BepInEx Plugin

REQUIRES: BepInEx plugin manager (follow its installation instructions first!).

  1. Download XUnity.AutoTranslator-BepInEx-{VERSION}.zip from releases.
  2. Extract directly into the game directory, such that the plugin dlls are placed in BepInEx folder.
  3. Launch the game.
  4. 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!)

BepInEx IL2CPP 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.

MelonLoader Plugin

REQUIRES: Melon Loader (follow its installation instructions first!).

  1. Download XUnity.AutoTranslator-MelonMod-{VERSION}.zip from releases.
  2. Extract directly into the game directory, such that the plugin dlls are placed in Mods and UserLibs folders.
  3. Launch the game.
  4. 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!)

MelonLoader IL2CPP Plugin

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.

IPA Plugin

REQUIRES: IPA plugin manager (follow its installation instructions first!).

  1. Download XUnity.AutoTranslator-IPA-{VERSION}.zip from releases.
  2. Extract directly into the game directory, such that the plugin dlls are placed in Plugins folder.
  3. Launch the game.
  4. 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!)

UnityInjector Plugin

REQUIRES: UnityInjector (follow its installation instructions first!).

  1. Download XUnity.AutoTranslator-UnityInjector-{VERSION}.zip from releases.
  2. Extract directly into the game directory, such that the plugin dlls are placed in UnityInjector folder. This may not be game root directory!
  3. Launch the game.
  4. 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.

Key Mapping

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

Translators

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.
    • Not sure on quotas on this one.
  • 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.
    • Not sure on quotas on this one.
  • 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: 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.

About Authenticated Translators

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.

Spam Prevention

The plugin employs the following spam prevention mechanisms:

  1. 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.
  2. It will never send out more than 8000 requests (max 200 characters each (configurable)) during a single game session.
  3. It will never send out more than 1 request at a time (no concurrency!).
  4. If it detects an increasing number of queued translations (4000), the plugin will shutdown.
  5. If the service returns no result for five consecutive requests, the plugin will shutdown.
  6. If the plugin detects that the game queues translations every frame, the plugin will shutdown after 90 frames.
  7. 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)
  8. If the plugin consistently queues translations every second for more than 60 seconds, the plugin will shutdown.
  9. For the supported languages, each translatable line must pass a symbol check that detects if the line includes characters from the source language.
  10. It will never attempt a translation for a text that is already considered a translation for something else.
  11. 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.
  12. 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.
  13. Some endpoints support batching of translations so far fewer requests are sent. This does not increase the total number of translations per session (2).
  14. All translation results are cached in memory and stored on disk to prevent making the same translation request twice.
  15. 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).
  16. 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.

Text Frameworks

The following text frameworks are supported.

Configuration

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)
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

Behaviour Configuration Explanation

Whitespace Handling

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 than MinDialogueChars, 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.

Text post/pre-processing

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.

UI Resizing

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 of EnableUIResizing and ForceUIResizing. UGUI only.
  • OverrideFontTextMeshPro: Consider using FallbackFontTextMeshPro instead. Changes the font of all text components regardless of EnableUIResizing and ForceUIResizing. 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 or Fonts & 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.

Reducing Translation Requests

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

Romaji 'translation'

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 text
  • ReplaceMacronWithCircumflex: 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 characters
  • ReplaceHtmlEntities: 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

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.

Other Options

  • 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 the OutputFile 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

IL2CPP Support

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

Frequently Asked Questions

Q: How do I disable auto translations?
A: Select the empty endpoint when you press ALT+0 or set the configuration parameter Endpoint= 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 parameter TextGetterCompatibilityMode=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.

Translating Mods

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.

Manual Translations

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.

Plugin-specific Manual Translations

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 );
}

Substitutions

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.

Regex Usage

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

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:'.

Splitter Regex

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.

Regex Post Processing

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.

UI Font Resizing

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 font
    • IgnoreFontSize(): 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):
  • Commands to control text alignment (TMP only):

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.

Translation Scoping

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 collisions
  • exe scopes can be used both to avoid translation collisions and to enhance performance

Text Lookup and Whitespace Handling

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):

  1. 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 「こう見えて怒っているんですよ?……失礼しますね」
「こう見えて怒っているんですよ?……失礼しますね」
  1. 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.

Resource Redirection

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.

Regarding Redistribution

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.
  • 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.

Texture Translation

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 the FromImageData option causes performance issues. If this is used, it is recommended to enable EnableTextureScanOnSceneLoad 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 or DetectDuplicateTextureNames=true
  • Only redistribute the mod with TextureHashGenerationStrategy=FromImageData enabled if absolutely required by the game.

Technical details about Hash Generation in file names

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. If FromImageName 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.

Integrating with Auto Translator

NOTE: Everything below this point requires programming knowledge!

Implementing a plugin that can query translations

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!

Implementing a component that the Auto Translator should not interfere with

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!

Implementing a Translator

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.

Important Notes on Implementing a Translator based on an Online Service

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'.

How-To

Follow these steps:

  1. Download XUnity.AutoTranslator-Developer-{VERSION}.zip from releases
  2. 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.
  3. Add a reference to the XUnity.AutoTranslator.Plugin.Core.dll that you downloaded in step 1
  4. 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!)
  5. 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

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 calls context.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 the XUnityWebRequest object that will be sent to the external endpoint. The call to context.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.

Implementing a Resource Redirector

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.

Asset Loading/Loaded Methods

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.

Asset Loading Methods

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 the Parameters property is LoadByType or LoadNamedWithSubAssets, 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.

Asset Loaded Methods

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 the Parameters property is LoadByType or LoadNamedWithSubAssets, 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.

Resource Load Methods

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.

Resource Loaded Methods

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 the Parameters property is LoadByType, 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.

AssetBundle Load Methods

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].

AssetBundle Synchrous Load Methods

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.

AssetBundle Asynchrounous Load Methods

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.

About Recursion

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.

Implementing an Asset Redirector

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 );
        }
    }
}

Implemting an AssetBundle Redirector

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;
    }
}

Implementing an Asset/Resource Handler (Auto Translator)

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.