Status: v1, enforced by the hub's upload scanner (hub/src/lib/api/scan.ts).
A .mjolnir file is a plain zip with a manifest at the root and IoStore
override containers under content/:
mjolnir.json the manifest (schema below)
changes.json the declared change list (schema below)
signature.json optional; the author's signature (see below)
content/
MyMod_P.utoc override container index
MyMod_P.ucas override container data
docs/README.md optional; rendered on the mod page
LICENSE optional
Only content releases travel this path — inert data the engine parses.
Anything executable (Lua, DLLs, scripts of any kind) is rejected by the
scanner; those mods ship from the mjolnir-core repository's reviewed and
signed CI releases instead (docs/hub_architecture.md §2).
mjolnir.json
{
"schema_version": 1,
"name": "My Texture Pack",
"version": "1.0.0",
"type": "content",
"summary": "Optional one-liner shown on cards.",
"compat": { "min_build": "2026.06.26.1097863.1" },
"deps": [{ "slug": "some-framework-mod", "range": ">=2.0.0" }]
}
versionmust equal the version of the hub release it is uploaded to; the scanner rejects a mismatch.compatdeclares the game builds the release was made against. Chunk IDs are per-build, so a container built against one build may point at moved or vanished chunks after a game patch.
Map archives
A converted map is a content archive with "type": "map" and a map block,
and only a map archive carries one. The layout and the install are in
map_distribution.md; the block itself:
"map": {
"code": "BGL",
"title": "Blood Gulch",
"modes": ["slayer", "ctf"],
"size": "large",
"players": { "min": 4, "max": 16 },
"vehicles": true,
"origin": "custom_edition"
}
| Field | Required | Meaning |
|---|---|---|
code | yes | The three-character scenario codename (A-Z, 0-9), unique across the hub. |
title | yes | Up to 80 characters. |
modes | yes | The game types the multiplayer menu offers, 1 to 8 of them. |
size | no | small, medium or large. |
players | no | The player counts the map suits, min and max from 1 to 16, min no greater than max. |
vehicles | no | Whether the map has vehicles. |
origin | no | custom_edition for a converted Halo Custom Edition map, original for one made for Campaign Evolved. classic marks the converted stock maps; moderators set it, and a manifest that claims it is rejected (bad_manifest). |
The optional fields fill the map's catalog listing, which players filter by.
A release that leaves one out keeps what the listing already says, so they
need not be repeated in every release; the map's owner can also change them
on the hub without a release (PATCH /api/v1/maps/{code}).
changes.json
The transparency file: what the mod does, declared as data, so the hub and
the launcher can show players the actual edits instead of asking them to
trust a description. The tag editor writes it at export from the same
resolved view its Changes panel shows — the recipe's (group, tag, field)
keys with the shipped value beside the modded one:
{
"schema_version": 1,
"tags": [
{
"group": "weapon",
"tag": "objects/weapons/pistol/pistol",
"fields": [
{ "field": "magazines[0].rounds loaded maximum", "before": "12", "value": "24" }
]
}
],
"textures": [{ "path": "characters/weapons/assaultrifle/T_ar_default_D", "bytes": 481203 }],
"scripts": [{ "group": "scenario", "tag": "levels/a10/a10" }],
"new_tags": [
{ "group": "weapon", "tag": "objects/weapons/pistol/pistol_mk2", "from": "objects/weapons/pistol/pistol" }
]
}
new_tags lists the tags the mod adds, each cloned from a shipped one. The hub's
validator (hub/src/lib/api/changes.ts) does not know the list yet and drops it, so
the archive validates but neither the mod page nor the launcher shows anything for
new tags.
It is a declaration, not a proof: the containers remain the bytes that
ship. The hub stores the list at scan time and renders it beside the
measured chunk count from the containers themselves, so a release
declaring one tweak while overriding hundreds of chunks is visibly odd.
Absent is a warning (no_changes — older editors predate the format);
present-and-invalid is an error (bad_changes), because the file is
machine-written and a broken one means tampering or a bug.
signature.json
An archive may carry an author signature: an envelope whose base64 payload
is a statement naming the mod's slug, version, author, signing-key
fingerprint, and the sha256 of every other member of the archive, signed
Ed25519 over "MJOLNIR-MOD-STATEMENT-V1\n" + payload with a per-device key
registered to the author's hub account. The verifier requires the archive's
member set to exactly equal the statement's — nothing added, nothing removed,
nothing changed — so anyone holding the archive can prove who bundled exactly
these bytes, without asking the hub.
The tag editor writes this automatically, and mjolnir map pack --sign and
mjolnir map runtime --sign sign the archives the CLI writes (map packs and
the CE runtime pack). The hub
verifies it at upload: mjolnircore.com requires a signature
(REQUIRE_SIGNED_UPLOADS), so a missing one is an error (unsigned), as are
bad_signature and foreign_signature. The launcher verifies it again at
install, refusing an archive whose signature is present and wrong. Full
design, threat model and rollout:
mod_signing_design.md.
What the scanner checks
| Check | Failure |
|---|---|
| Valid zip, decompressed total ≤ 768 MiB | bad_zip |
| The stored archive is the one uploaded | hash_mismatch |
No .., absolute, or drive-letter paths | path_traversal |
mjolnir.json present and valid | no_manifest, bad_manifest |
| Manifest version matches the release | version_mismatch |
Only allowed file kinds (utoc ucas json md txt png jpg jpeg webp, plus bin under map/) | forbidden_file |
Every .utoc parses, unencrypted, paired with its .ucas | bad_container, encrypted_container, orphan_utoc, orphan_ucas |
At least one container under content/ | no_content |
changes.json, when present, valid and ≤ 512 KiB | bad_changes (absent is the no_changes warning) |
A map archive carries map/level.json and map/registration.json, both valid JSON | map_file_missing, bad_map_file |
A map's code is not already held by another mod | map_code_taken |
Warnings never fail a scan: no_summary (the manifest has no summary),
stray_container (a .utoc outside content/), empty_container,
foreign_container (a map archive's container not named -<CODE>_P), and
no_changes, which a map archive never raises.
On a passing scan the hub records every chunk ID the containers claim into
the conflict index (release_chunks), and the release — plus its mod, if
this was the first release — goes live. The exception is a map release from
an author below trust level 2 (moderators excepted): it passes with an
awaiting_review warning and stays pending until a moderator approves or
rejects it. On a failing scan the findings are stored and returned; fix the
archive and re-upload to the same release.
Publishing flow
POST /api/v1/mods {"slug":"my-pack","name":"My Pack", ...}
POST /api/v1/mods/my-pack/releases {"version":"1.0.0"}
PUT /api/v1/releases/{id}/archive <raw .mjolnir bytes> (≤ 90 MiB)
POST /api/v1/releases/{id}/complete → published | pending | rejected + findings
An archive over 90 MiB, up to 400 MiB, goes up in parts instead of the single
PUT: POST /api/v1/releases/{id}/archive/multipart returns an upload_id;
each part (5 to 64 MiB, every part but the last the same size, numbered from 1)
goes to PUT …/archive/multipart/{upload_id}/{n};
POST …/archive/multipart/{upload_id}/complete with the parts' etags joins
them; then /complete as above. DELETE …/archive/multipart/{upload_id}
abandons an upload.
Each of these needs a signed-in session (Discord OAuth) or an API key with the
mods:write scope, which is how the tag editor publishes. Third-party tools can
watch GET /api/v1/releases/{id} for status and query
POST /api/v1/conflicts/check before installing a set of releases together.
Building a container
mjolnir pack (crates/blam-cli) composes an override container from edited
tags; docs/iostore_packaging.md documents the format. Since the
perfect-hash fix (2026-08-01) containers may hold any number of chunks.