Publishing & Translations

Ship your pack: translations, zip-building, cosmetic and integration packs, and security

Translation packs

Translations are not an MMO Skill Tree custom content type. They use Hytale's native .lang format under Server/Languages/<bcp47>/, which the engine discovers and merges via its I18nModule without any MMO-specific asset code. A pack ships one mmoskilltree.langfile per locale:

MyTranslationPack.zip
├── manifest.json
└── Server/
    └── Languages/
        ├── es-ES/mmoskilltree.lang
        └── ko-KR/mmoskilltree.lang

.lang format is plain key = value text (UTF-8, no BOM, # for comments). Placeholders like {0} are kept verbatim from the English string.

# Server/Languages/it-IT/mmoskilltree.lang
ui.settings.title = Impostazioni
ui.viewxp.total_level = Livello Totale
ui.viewxp.level_prefix = Lv. {0}

The filename IS the key prefix

Hytale's loader prepends the filename (minus .lang) as a dot-separated prefix to every key in the file - the filename is the namespace a lookup resolves against. MMO Skill Tree's own keys resolve under the mmoskilltree. prefix, so a file carrying them must be named mmoskilltree.lang. Don't rename it.

A net-new domain of MMO keys is split into its own file named mmoskilltree.<domain>.lang (for example mmoskilltree.stations.lang), and its entries drop the segment the filename already supplies - the loader prepends it back, so a call site never spells it out. This is the convention, not an exception: the jar itself ships six files per locale for exactly this reason (client.lang, general.lang, items.lang, mmoskilltree.lang, npcs.lang, server.lang), each carrying only the keys its own prefix demands.

Name the file for the namespace whose keys it carries

A shelf heading or a contract band (shop.category.<id>, board.grade.<id>) is looked up by the shared economy library, not this mod, so a pack answering those ships ziggfreedcommon.commerce.lang beside its MMO file. Put a key in the wrong file and the screen shows the raw key instead of the translated text.

Which .lang file does my key go in?

FileKeys it carriesWho resolves themWorked key
mmoskilltree.langThis mod's default namespace: quest & achievement text, UI strings, gate reasonsMMO Skill Treequest.dragon_hunt.title
mmoskilltree.<domain>.langA net-new MMO namespace kept in its own file (entries drop the <domain>. segment; the filename supplies it)MMO Skill Treeline_1 in mmoskilltree.dialogue.lang resolves as dialogue.line_1
npcs.langPlaced-NPC display names and F-interact hintsThe NPC placement enginenpcs.Mmo_Hub.name
items.langMMO Skill Tree's own item display text (capes, tokens, and the like)MMO Skill Treeitems.Cape_Skill_Mining.name
server.langNative item names & descriptions for items authored under Server/Item/Items/Hytale's own item rendererserver.items.Example_Tool_Pickaxe_Fortune.name
client.langClient-rendered tooltip lines, including gear-stat tooltipsThe clientitemTooltip.stats.MMO_Defense
ziggfreedcommon.commerce.langShop category & board grade wordszc-commerceshop.category.tools

Sibling pages name their own keys against this table instead of restating the rule - see Dialogues for npcs.lang, Gear stats for server.lang and client.lang, and Quests for mmoskilltree.lang.

Rules & fallback

  • Empty values silently never ship. key = with nothing after is skipped as a malformed line (with a per-line warning; the rest of the file still parses), so the key simply never ships from that file - and a key no file anywhere carries renders as its raw id on screen. Omit the key entirely instead, and it falls back to English.
  • Fallback chain: the owner's override, then the pack or jar .lang for the player's locale, then the shipped en-US file for the same key. There are no in-code English defaults - the English a client resolves and the server's own fallback are the same shipped file, so they can never disagree. Missing keys never show a raw key to players.
  • Locale codes: the player's ISO code maps to a BCP 47 folder (itit-IT, ptpt-BR, etc.).

All 9 supported languages ship inside the mod jar, so no extra pack is needed for the bundled locales. Add a new locale with a pack that drops a single mmoskilltree.lang under Server/Languages/<bcp47>/ as shown above; the engine merges it through its I18nModule. See the in-game Localization page for the full key reference.

Building the zip

Three steps ship a pack:

  1. Author your content as a plain folder under Server/, exactly as shown throughout this section.
  2. Zip that folder with the script below.
  3. Drop the .zip into your server's mods directory.

manifest.json sits at the ZIP ROOT

manifest.json must sit beside Server/ at the top of the archive, never inside a wrapper folder named after the pack. The usual mistake is zipping the pack's parent directory, which nests everything one level too deep and the pack fails to load with no content found.

Hytale's asset loader also silently drops zip entries that use backslash separators (the default from PowerShell's Compress-Archive on Windows), and it needs explicit directory entries, not just file entries - without them Server/Languages/ in particular is skipped because Java's ZipFileSystem.isDirectory() returns false with no directory entry, and Hytale's I18nModule loads a pack's messages by walking directories. Build with the lower-level zip API using forward-slash relative paths and a real directory entry for every ancestor path, excluding top-level docs and dev files:

$pack = 'C:\path\to\MyMmoPack'
$version = (Get-Content (Join-Path $pack 'manifest.json') -Raw | ConvertFrom-Json).Version
if (-not $version) { throw 'manifest.json is missing a Version field' }
$PackName = 'MyMmoPack'
$zipPath = Join-Path $pack "$PackName-$version.zip"
Remove-Item $zipPath -ErrorAction SilentlyContinue
Add-Type -A 'System.IO.Compression.FileSystem'

$excludeNames = @('README.md', 'CURSEFORGE.md', 'CLAUDE.md', 'LICENSE', '.gitignore', 'build.ps1')
$excludeDirs  = @('.git', '.github', 'patch-notes')

$zip = [IO.Compression.ZipFile]::Open($zipPath, 'Create')
try {
    $files = Get-ChildItem -Path $pack -Recurse -File -Force | Where-Object {
        $rel = $_.FullName.Substring($pack.Length + 1).Replace('\', '/')
        $top = ($rel -split '/')[0]
        ($_.Name -notin $excludeNames) -and ($_.Extension -ne '.zip') -and ($top -notin $excludeDirs)
    }

    # A directory entry for every ancestor path (once), so the zip reports real
    # directories to Java's ZipFileSystem - files alone are not enough.
    $createdDirs = @{}
    foreach ($f in $files) {
        $rel = $f.FullName.Substring($pack.Length + 1).Replace('\', '/')
        $parts = $rel -split '/'
        for ($i = 1; $i -lt $parts.Length; $i++) {
            $dir = ($parts[0..($i - 1)] -join '/') + '/'
            if (-not $createdDirs.ContainsKey($dir)) {
                $zip.CreateEntry($dir, [IO.Compression.CompressionLevel]::NoCompression).Open().Close()
                $createdDirs[$dir] = $true
            }
        }
        $entry = $zip.CreateEntry($rel, [IO.Compression.CompressionLevel]::Optimal)
        $stream = $entry.Open()
        $bytes = [IO.File]::ReadAllBytes($f.FullName)
        $stream.Write($bytes, 0, $bytes.Length)
        $stream.Close()
    }
    Write-Host "Built $zipPath ($($files.Count) files, $($createdDirs.Count) dir entries)"
} finally {
    $zip.Dispose()
}

A pack's own build.ps1 (every shipped content pack carries one) runs exactly this algorithm already, driven by rebuild.ps1 at the monorepo root - copy that script rather than retyping this one.

For the fast edit loop, dropping the unzipped folder straight into the mods directory works identically - the engine loads a pack the same way whether it is a folder or a zip. The zip is what you ship.

Cosmetic / asset-only packs

Not every pack touches MMO content. The Capes Pack is a pure Hytale asset pack - it ships 20 cape items (19 skill capes plus the Master Cape), a custom rarity, models, textures, and per-locale names, with no gameplay logic. Grant such items through command rewards, the admin give command, or your own logic.

Coexistence: if a cosmetic pack's items are also bundled by the mod, don't install both - duplicate item ids produce warnings at server start.

Feature-gated integration packs

The Taming Pack is the pattern for content that depends on another mod: every quest, achievement, and XP map is gated on the taming feature, so the entire pack stays hidden unless the companion mod is installed. A pack can ship the skill itself too (see Custom Skills) as well as the content that hangs off one. See feature gating.

Ability & gear-stat packs (Since 1.6.0)

A third pattern targets combat tuning instead of quest content: ship new Abilities/, an AbilityMods/ improvement a quest or shop entry grants, or items whose Armor/ Weapon block carries MMO_* StatModifiers. No plugin code is needed for any of this - see Ability authoring and Gear stats for the field references and worked examples.

Installing & updating

Packs are discovered at server start. To add, update, or remove a pack, change the mods directory and restart the server. Run /mmopacks (or /mmopacks --action=list) to list loaded asset packs and how many entries each contributes per content type - see the Commands reference.

Keeping a pack in sync with the mod

A pack and the mod co-evolve. If a content type's structure changes (a new field, a new reward type referenced by your entries), re-emit the affected JSON and confirm against the version you target. Structural changes bump the relevant config SCHEMA_VERSION in the mod; additive content does not.

Security

Command rewards, quests, achievements, loot tables and conversations may run arbitrary console commands (the Command reward kind, a reward kind of the pack's own, a conversation's run action). Review packs from untrusted sources before installing them - a malicious pack could grant items, change permissions, or run any other console operation. The mod does not strip commands from packs; firing admin commands is the legitimate use case for the content type.