Skip to content

The bundle file ​

A bundle is how a manifest is stored: a zip with a fixed layout. This page is that layout, for anyone reading or writing one without opys.

For what a bundle is for, see The bundle.

Entries ​

EntryContains
opys.jsonThe head: what the bundle says about itself.
manifest.jsonThe manifest, whole.
blobs/<sha256>One entry per carried file.
sh
unzip -l game.opys
unzip -p game.opys opys.json
unzip -p game.opys manifest.json | jq '.launch'

Why two JSON files: they describe different things. The manifest describes the installation, and is megabytes, one entry per installed file. The head describes the bundle, and is small. It holds format, locale and options.

Find entries by name. Do not rely on their order.

format ​

json
{ "format": 1 }

The version of the format, in the head. It is 1.

A reader must:

  • Read format first.
  • Refuse the bundle if it is not the integer 1. Missing, "1" and 1.0 are all refused.
  • Do so before reading manifest.json.

Why first: another format may spell the manifest differently. A reader that guesses could install the wrong thing.

A reader ignores any other field of the head.

Any other number is refused, a lower one as much as a higher. opys reads one format. There is no migration and no compatibility mode. When the format changes, a bundle is rebuilt from its config with opys build.

For a launcher this means: update the runtime and republish the bundles together.

options ​

What whoever launches the bundle may choose. Optional, in the head.

json
{
  "format": 1,
  "options": [
    {
      "slider": "xmx",
      "title": "RAM",
      "min": 1024,
      "max": 16384,
      "step": 512,
      "default": 4096,
      "unit": "MB"
    },
    {
      "feature": "custom_java",
      "title": "Custom Java",
      "options": [{ "directory": "java_home", "title": "Java folder" }]
    }
  ]
}

A manifest reads variables and tests features. options says which of them are a player's to set, and how to ask for each. It is a schema only: the installer does not read it, and what a player chose reaches an install as plain vars and features.

An option is one of six kinds. The field that says the kind also holds the name:

FieldThe name isThe value is
slidera variablea number in a range
selecta variableone of a list
texta variablea line of text
filea variablethe path of a file
directorya variablethe path of a directory
featurea featureon or off

Every option has a title and may have a subtitle. The rest depends on the kind:

KindRequiredOptional
slidermin, max, step, defaultunit
selectchoices, default
textplaceholder, default
featuredefault, options
  • default is a number on a slider, a string on a select or a text, and a boolean on a feature. A feature with no default is off.
  • choices is a list of { "value", "label" }, in the order to show them.
  • A feature's options are the options that only matter while it is on. They may be features themselves, to any depth.
  • title, subtitle, unit, placeholder and a choice's label are text, which may be translated: see locale.

A reader refuses a head whose options:

  • name no kind, or more than one;
  • carry a field of another kind, such as min on a select;
  • name one variable twice, or one feature twice, anywhere in the tree;
  • have a slider with min not below max, a step not above zero, or a default outside the range;
  • have a select whose default is not the value of one of its choices;
  • have a text that breaks the rules of locale.

Why in the head: a launcher shows the settings before it installs anything, and the head is readable without the manifest.

locale ​

The language the options are written in. Optional, in the head.

json
{
  "format": 1,
  "locale": "en",
  "options": [
    {
      "slider": "xmx",
      "title": { "en": "RAM", "uk": "Пам'ять" },
      "min": 1024,
      "max": 16384,
      "step": 512,
      "default": 4096,
      "unit": "MB"
    }
  ]
}

locale is a language tag: a language of two or three letters, then subtags of one to eight letters or digits, joined by -. en, uk, pt-BR.

Every text a player reads is either:

  • a string, in the head's locale; or
  • an object from a language tag to the text in that language.

The texts are title, subtitle, unit, placeholder and a choice's label. A name, a choice's value and a default are never translated: they are what the manifest reads.

A reader refuses a head:

  • whose locale is not a language tag;
  • with a translated text and no locale;
  • with a translated text that does not hold the locale;
  • with a translated text that is empty, or has a key that is not a language tag.

Which language to show is the reader's choice. Match the player's language, then fall back to locale. A translated text always holds it, so there is always something to show.

Why inline: a text is read where it is shown. There is no table of keys beside the options, so no key can point at nothing.

Blobs ​

A blob is a file carried in the bundle. An artifact names it by the sha256 of its content:

json
{
  "path": "${game_directory}/config/pack.toml",
  "source": { "blob": "f727f7b2…" }
}

Its bytes are the entry blobs/f727f7b2…. The id is 64 lowercase hex digits.

A reader must:

  • Refuse a bundle that names a blob it does not contain, before installing anything.
  • Check each blob against its name when installing it.

A writer must not store bytes under a name they do not hash to. The same blob used by two artifacts is stored once.

What opys writes ​

These are how the reference writer behaves. A reader should not depend on them.

  • opys.json first and uncompressed, so it can be read from the front of the file. The rest is deflated.
  • Blobs in order of their id.
  • Fixed timestamps, so the same manifest always gives the same entries.
  • No other entries. A reader ignores any it does not know.

Reading one ​

text
open the zip
head = json(entry "opys.json")
if head.format != 1: refuse
manifest = json(entry "manifest.json")
for each artifact with source.blob:
    if no entry "blobs/" + id: refuse

Then, when installing an artifact with a blob: copy the entry to its path, and refuse it if its sha256 is not the id.

A reader of a bundle behind a URL need not download it. A zip's directory is at its end and says where every entry is, so the directory, then the head and manifest, then each blob the installation lacks can each be asked for as a range. Ask every range after the first with If-Range and the version the first response named, so a bundle replaced in the meantime is noticed instead of read as half of each.

The file name ​

By convention <name>.opys. Nothing in the format depends on it.