Develop

Language Packs

Updated: Aug 26, 2026
Language packs enable you to provide additional languages with your app without increasing the initial download size.
These files are uploaded to the Meta Horizon Store when you upload your app package.
On Link PC-VR devices, providing language packs as downloadable assets will decrease initial download size. The Meta Horizon Link app for Link PC-VR will allow users to select which language pack they want to use from the Details page.
The following image shows an example of how this looks.

For Meta VR devices

On Meta VR devices, providing language packs as downloadable assets will decrease initial download size.
Developers will have to implement their own language picker in an app. Then, users will be able to select a language to use within that application, which will download the appropriate language pack.
Check for language packs in app code has more information that will be helpful for creating a language picker.
For Meta VR devices to correctly recognize your language pack you should name it with a language code per BCP47 format⁠, with a suffix of “lang”. For example, en-us.lang and de.lang would be valid language pack names.

Upload a binary with language packs to the Meta Horizon Store

You must use the Meta Horizon platform Command Line Utility to upload a binary with assets or language packs.
For language packs, use the --language_packs_dir parameter to specify the directory that contains the language packs.
When you upload new apps that have accompanying asset files, make sure the asset files have the same name as previously uploaded versions of the same file.
Here is a sample command to upload a Rift package with a language pack:
ovr-platform-util upload-rift-build -a 12345 -s 1234 -d path/to/mygame.zip  --language_packs_dir /path/to/myGame/language-packs -c ALPHA

For Meta VR devices

Here is a sample command to upload a Quest package with a language pack:
ovr-platform-util upload-quest-build -a 12345 -s 1234 -d path/to/mygame.zip  --language_packs_dir /path/to/myGame/language-packs -c ALPHA

View language packs on the Dashboard

Once you successfully upload your items, you can view and manage them on the developer dashboard. To do so, follow these steps.
  1. In the top-right corner of the Meta Horizon Dashboard, select your org.
  2. Select your app.
  3. In the left-side navigation, select Distribution > Builds. A list of all builds for your app appears.
  4. In the Build column, click on a build.
  5. Select the Expansion Files tab.
  6. Find the Expansion Files column for the build you selected and then select View Expansion Files. The different kinds of assets will display.
  7. Look for Language Packs.
    The following image shows an example of language packs.
    Asset File Dashboard

Check for language packs in your app

Use the following steps to check for language packs in your app code.
  1. Use the Platform.AssetFile.GetList() function to get a list of all assets.
  2. Check for an asset type via the Platform.Models.AssetDetails.AssetType property. A language pack has the asset type language_pack.
  3. Read the language of the asset from the nullable Platform.Models.AssetDetails.LanguageOptional property, whose Tag field holds the BCP47 language code.

Download and apply a language pack at runtime

The runtime flow uses two language pack calls, get-current and set-current. Listing packs, tracking download progress, and confirming an install come from the asset file API rather than from a language pack API.
Work through the sections in order. Each one hands state to the next: get-current returns the tag that is applied now, progress observation has to be running before you apply a pack, set-current returns the asset ID that the status call takes, and the status response returns the filepath you load from.

Before your first request

Initialize the platform core before you call LanguagePack or AssetFile. Each request method returns null while the core is not initialized, so hold the returned request in a variable and test it for null before you attach OnComplete().

Get the current language pack

The get-current call returns the asset details of the pack that is applied now. Read the BCP47 language tag from the language info, and read the on-disk location of the pack from the filepath.
Call LanguagePack.GetCurrent(). AssetDetails.LanguageOptional is nullable, so check it before you read the tag.
using Oculus.Platform;
using Oculus.Platform.Models;

Request<AssetDetails> request = LanguagePack.GetCurrent();
if (request == null) { return; }
request.OnComplete(message =>
{
    if (message.IsError) { return; }
    AssetDetails details = message.Data;
    if (details == null || details.LanguageOptional == null) { return; }
    string tag = details.LanguageOptional.Tag;
    string path = details.Filepath;
});

Track download progress

The asset file API reports download progress. Set up progress observation before you apply a pack, because an update that arrives before you are listening is lost.
Each update carries the bytes transferred and the total bytes. The bytes transferred value is -1 before the download starts, so check for a negative transferred count and a zero total before you compute a percentage.
Register a callback with AssetFile.SetDownloadUpdateNotificationCallback().
AssetFile.SetDownloadUpdateNotificationCallback(message =>
{
    if (message.IsError) { return; }
    AssetFileDownloadUpdate update = message.Data;
    if (update == null) { return; }
    if (update.BytesTransferred < 0 || update.BytesTotal == 0) { return; }
    float progress = (float)update.BytesTransferred / update.BytesTotal;
});

Apply or switch a language pack

Pass the BCP47 tag of the pack to the set-current call. The call downloads the pack automatically when the pack is not installed on the device.
The tag carries no .lang suffix. A pack file named de.lang has the tag de.
The result of the call carries the asset ID of the pack. Store that asset ID in state your code owns, such as a field on the object that drives your picker, because the confirm step needs it after the callback returns. The result also carries a filepath. Do not read content from it here: the pack is ready only once the status call reports installed.
// languagePackAssetId is a ulong field on your picker, not a local in the callback.
Request<AssetFileDownloadResult> request = LanguagePack.SetCurrent("de");
if (request == null) { return; }
request.OnComplete(message =>
{
    if (message.IsError) { return; }
    AssetFileDownloadResult result = message.Data;
    if (result == null) { return; }
    languagePackAssetId = result.AssetId;
});

Confirm the install

A progress update whose completed flag is true means the download finished. It does not mean the pack is installed. Request the asset status repeatedly until the download status changes from available to installed, and read the filepath after that.
Your code owns that polling loop. Build it with all four of these:
  • A delay between requests, so the loop does not spin.
  • A bound, either a maximum number of attempts or a timeout, so a pack that never reaches installed does not hold the loop open.
  • A cancellation path, so leaving the picker ends the loop.
  • Error handling on every response, so a failed request retries or ends the loop rather than reading as a status that is not installed.
Each snippet below is the body of one iteration of that loop, not the whole loop.
One iteration, using the asset ID you stored from set-current:
// installedPath is a string field on your picker.
Request<AssetDetails> request = AssetFile.StatusById(languagePackAssetId);
if (request == null) { return; }
request.OnComplete(message =>
{
    if (message.IsError) { return; }
    AssetDetails details = message.Data;
    if (details == null) { return; }
    if (details.DownloadStatus == "installed")
    {
        installedPath = details.Filepath;
    }
});

Load your localized content

Load your localized content from the filepath that the SDK returns. Never hardcode the path to a pack.
Take the filepath from the get-current response, or from a status response whose download status is installed. Do not load from the filepath on the set-current result, because at that point the pack is not confirmed installed.
Read the filepath from AssetDetails.Filepath on the get-current or status result.

Populate language picker entries

The samples in this section populate the entries of a picker: they list the packs and build one label per entry. They do not run the switch. Combine them with the sections above for the whole flow.
These are the six steps end to end, and the state each one hands to the next:
  1. List every asset file, as described in Check for language packs in your app. Keep the entries whose asset type is the string language_pack. The list call takes no filter parameter, so filter the results in your app code. This gives you the entry list.
  2. Label each entry from the names on its language info: the English name, for a list that stays readable to a user who selected the wrong language, and the native name, for the spelling used by that language. This gives you the display text.
  3. Call get-current and pre-select the entry whose tag matches the tag it returns. This gives you the starting selection.
  4. Start progress observation, then call set-current with the tag of the entry the user selects. Store the asset ID from the result in your picker state. This gives you the asset ID.
  5. Request the asset status in your bounded loop until the download status is installed. Store the filepath from that response. This gives you the filepath.
  6. Reload your localized content from the stored filepath, then end the progress observation.
Request<AssetDetailsList> request = AssetFile.GetList();
if (request == null) { return; }
request.OnComplete(message =>
{
    if (message.IsError) { return; }
    AssetDetailsList assets = message.Data;
    if (assets == null) { return; }
    foreach (AssetDetails asset in assets)
    {
        if (asset.AssetType != "language_pack" || asset.LanguageOptional == null) { continue; }
        string label = $"{asset.LanguageOptional.EnglishName} ({asset.LanguageOptional.NativeName})";
    }
});
The Unity list is paged, and this sample processes one page. To cover every asset, repeat the filter and label step for each page: while assets.HasNextPage is true, call AssetFile.GetNextAssetDetailsListPage(assets) and run the same step on the page it returns. That call returns null when there is no next page.