diff --git a/pressure-vessel/pressure-vessel-test-ui b/pressure-vessel/pressure-vessel-test-ui
index 2e212023502ad99f74a8f1550f44d4f7d7bea906..61f9e5c2079c2e02be9586b35ab3f8cd7a6b6445 100755
--- a/pressure-vessel/pressure-vessel-test-ui
+++ b/pressure-vessel/pressure-vessel-test-ui
@@ -81,6 +81,151 @@ def boolean_environment(name, default):
     return default
 
 
+class ContainerRuntime:
+    def __init__(
+        self,
+        path,           # type: str
+        home,           # type: str
+    ):  # type: (...) -> None
+        self.home = home
+        self.path = path
+        self.description = ''
+
+
+class DirectoryRuntime(ContainerRuntime):
+    def __init__(
+        self,
+        path,           # type: str
+        home,           # type: str
+    ):  # type: (...) -> None
+        super().__init__(path, home=home)
+
+        self.description = self.__describe_runtime(path)
+
+    def __describe_runtime(
+        self,
+        path        # type: str
+    ):
+        # type: (...) -> str
+
+        description = path
+        files = os.path.join(path, 'files')
+        metadata = os.path.join(path, 'metadata')
+
+        if os.path.islink(files):
+            description = os.path.realpath(files)
+
+        if description.startswith(self.home + '/'):
+            description = '~' + description[len(self.home):]
+
+        name = None             # type: typing.Optional[str]
+        pretty_name = None      # type: typing.Optional[str]
+        build_id = None         # type: typing.Optional[str]
+        variant = None          # type: typing.Optional[str]
+
+        try:
+            keyfile = GLib.KeyFile.new()
+            keyfile.load_from_file(
+                metadata, GLib.KeyFileFlags.NONE)
+            try:
+                build_id = keyfile.get_string('Runtime', 'x-flatdeb-build-id')
+            except GLib.Error:
+                pass
+
+            try:
+                name = keyfile.get_string('Runtime', 'runtime')
+            except GLib.Error:
+                pass
+            else:
+                assert name is not None
+                variant = name.split('.')[-1]
+        except GLib.Error:
+            pass
+
+        try:
+            with open(
+                os.path.join(files, 'lib', 'os-release')
+            ) as reader:
+                for line in reader:
+                    if line.startswith('PRETTY_NAME='):
+                        pretty_name = line.split('=', 1)[1].strip()
+                        pretty_name = GLib.shell_unquote(pretty_name)
+                    elif line.startswith('BUILD_ID='):
+                        build_id = line.split('=', 1)[1].strip()
+                        build_id = GLib.shell_unquote(build_id)
+                    elif line.startswith('VARIANT='):
+                        variant = line.split('=', 1)[1].strip()
+                        variant = GLib.shell_unquote(variant)
+        except (GLib.Error, EnvironmentError):
+            pass
+
+        if pretty_name is None:
+            pretty_name = name
+
+        if pretty_name is None:
+            pretty_name = os.path.basename(path)
+
+        if build_id is None:
+            build_id = ''
+        else:
+            build_id = ' build {}'.format(build_id)
+
+        if variant is None:
+            variant = ''
+        else:
+            variant = ' {}'.format(variant)
+
+        description = '{}{}{}\n({})'.format(
+            pretty_name,
+            variant,
+            build_id,
+            description,
+        )
+
+        return description
+
+
+class ArchiveRuntime(ContainerRuntime):
+    def __init__(
+        self,
+        path,           # type: str
+        buildid_file,   # type: str
+        home,           # type: str
+    ):  # type: (...) -> None
+        super().__init__(path, home=home)
+
+        if path.startswith(self.home + '/'):
+            path = '~' + path[len(self.home):]
+
+        description = os.path.basename(path)
+        sdk_suffix = ''
+
+        if description.startswith('com.valvesoftware.SteamRuntime.'):
+            description = description[len('com.valvesoftware.SteamRuntime.'):]
+
+        if description.startswith('Platform-'):
+            description = description[len('Platform-'):]
+
+        if description.startswith('Sdk-'):
+            sdk_suffix = '-sdk'
+            description = description[len('Sdk-'):]
+
+        if description.startswith('amd64,i386-'):
+            description = description[len('amd64,i386-'):]
+
+        if description.endswith('.tar.gz'):
+            description = description[:-len('.tar.gz')]
+
+        if description.endswith('-runtime'):
+            description = description[:-len('-runtime')]
+
+        with open(buildid_file) as reader:
+            build = reader.read().strip()
+
+        self.deploy_id = '{}{}_{}'.format(description, sdk_suffix, build)
+        self.description = '{} build {}\n({})'.format(description, build, path)
+
+
 class Gui:
     def __init__(self):
         # type: (...) -> None
@@ -88,7 +233,8 @@ class Gui:
         self.failed = False
         self.home = GLib.get_home_dir()
 
-        self.container_runtimes = {}    # type: typing.Dict[str, str]
+        self.container_runtimes = {
+        }    # type: typing.Dict[str, ContainerRuntime]
 
         for search in (
             os.getenv('PRESSURE_VESSEL_RUNTIME_BASE'),
@@ -115,8 +261,25 @@ class Gui:
                 files = os.path.join(path, 'files')
 
                 if os.path.isdir(files):
-                    description = self._describe_runtime(path)
-                    self.container_runtimes[path] = description
+                    self.container_runtimes[path] = DirectoryRuntime(
+                        path,
+                        home=self.home,
+                    )
+                    continue
+
+                if member.endswith(('-runtime.tar.gz', '-sysroot.tar.gz')):
+                    # runtime and sysroot happen to be the same length!
+                    buildid_file = os.path.join(
+                        source_of_runtimes,
+                        member[:-len('-runtime.tar.gz')] + '-buildid.txt',
+                    )
+
+                    if os.path.exists(buildid_file):
+                        self.container_runtimes[path] = ArchiveRuntime(
+                            path,
+                            buildid_file=buildid_file,
+                            home=self.home,
+                        )
 
         self.window = Gtk.Window()
         self.window.set_default_size(600, 300)
@@ -156,8 +319,8 @@ class Gui:
             'None (use host system and traditional LD_LIBRARY_PATH runtime)'
         )
 
-        for path, description in sorted(self.container_runtimes.items()):
-            self.container_runtime_combo.append(path, description)
+        for path, runtime in sorted(self.container_runtimes.items()):
+            self.container_runtime_combo.append(path, runtime.description)
 
         if self.container_runtimes:
             self.container_runtime_combo.set_active(1)
@@ -172,10 +335,13 @@ class Gui:
 
         if var_path is None:
             value = False
-            var_path = os.getenv('PRESSURE_VESSEL_VARIABLE_DIR', None)
+            var_path = None
         else:
             value = True
 
+        var_path = os.getenv('PRESSURE_VESSEL_VARIABLE_DIR', var_path)
+        value = boolean_environment('PRESSURE_VESSEL_COPY_RUNTIME', value)
+
         if var_path is None:
             var_path = os.getenv(
                 'PRESSURE_VESSEL_RUNTIME_BASE',
@@ -184,11 +350,34 @@ class Gui:
             assert var_path is not None
 
         self.var_path = os.path.realpath(var_path)
-        self.copy_runtime_into_check = Gtk.CheckButton.new_with_label(
+
+        self.gc_runtimes_check = Gtk.CheckButton.new_with_label(
+            'Clean up old runtimes'
+        )
+        # Deliberately ignoring PRESSURE_VESSEL_GC_RUNTIMES: a lot of the
+        # point of this test UI is the ability to switch between runtimes,
+        # but if we GC non-current runtimes, that'll be really slow.
+        self.gc_runtimes_check.set_active(False)
+        self.grid.attach(self.gc_runtimes_check, 1, row, 1, 1)
+        row += 1
+
+        label = Gtk.Label.new('')
+        label.set_markup(
+            '<small><i>'
+            'Normally this is enabled, but this test UI disables it by '
+            'default for quicker switching between multiple runtimes.'
+            '</i></small>'
+        )
+        label.set_halign(Gtk.Align.START)
+        label.set_line_wrap(True)
+        self.grid.attach(label, 1, row, 1, 1)
+        row += 1
+
+        self.copy_runtime_check = Gtk.CheckButton.new_with_label(
             'Create temporary runtime copy on disk'
         )
-        self.copy_runtime_into_check.set_active(value)
-        self.grid.attach(self.copy_runtime_into_check, 1, row, 1, 1)
+        self.copy_runtime_check.set_active(value)
+        self.grid.attach(self.copy_runtime_check, 1, row, 1, 1)
         row += 1
 
         label = Gtk.Label.new('')
@@ -411,94 +600,14 @@ class Gui:
 
     def _container_runtime_changed(self, combo):
         if combo.get_active_id() == '/':
-            self.copy_runtime_into_check.set_sensitive(False)
+            self.copy_runtime_check.set_sensitive(False)
+            self.gc_runtimes_check.set_sensitive(False)
             self.host_graphics_check.set_sensitive(False)
         else:
-            self.copy_runtime_into_check.set_sensitive(True)
+            self.copy_runtime_check.set_sensitive(True)
+            self.gc_runtimes_check.set_sensitive(True)
             self.host_graphics_check.set_sensitive(True)
 
-    def _describe_runtime(
-        self,
-        path        # type: str
-    ):
-        # type: (...) -> str
-
-        description = path
-        files = os.path.join(path, 'files')
-        metadata = os.path.join(path, 'metadata')
-
-        if os.path.islink(files):
-            description = os.path.realpath(files)
-
-        if description.startswith(self.home + '/'):
-            description = '~' + description[len(self.home):]
-
-        name = None             # type: typing.Optional[str]
-        pretty_name = None      # type: typing.Optional[str]
-        build_id = None         # type: typing.Optional[str]
-        variant = None          # type: typing.Optional[str]
-
-        try:
-            keyfile = GLib.KeyFile.new()
-            keyfile.load_from_file(
-                metadata, GLib.KeyFileFlags.NONE)
-            try:
-                build_id = keyfile.get_string('Runtime', 'x-flatdeb-build-id')
-            except GLib.Error:
-                pass
-
-            try:
-                name = keyfile.get_string('Runtime', 'runtime')
-            except GLib.Error:
-                pass
-            else:
-                assert name is not None
-                variant = name.split('.')[-1]
-        except GLib.Error:
-            pass
-
-        try:
-            with open(
-                os.path.join(files, 'lib', 'os-release')
-            ) as reader:
-                for line in reader:
-                    if line.startswith('PRETTY_NAME='):
-                        pretty_name = line.split('=', 1)[1].strip()
-                        pretty_name = GLib.shell_unquote(pretty_name)
-                    elif line.startswith('BUILD_ID='):
-                        build_id = line.split('=', 1)[1].strip()
-                        build_id = GLib.shell_unquote(build_id)
-                    elif line.startswith('VARIANT='):
-                        variant = line.split('=', 1)[1].strip()
-                        variant = GLib.shell_unquote(variant)
-        except (GLib.Error, EnvironmentError):
-            pass
-
-        if pretty_name is None:
-            pretty_name = name
-
-        if pretty_name is None:
-            pretty_name = os.path.basename(path)
-
-        if build_id is None:
-            build_id = ''
-        else:
-            build_id = ' build {}'.format(build_id)
-
-        if variant is None:
-            variant = ''
-        else:
-            variant = ' {}'.format(variant)
-
-        description = '{}{}{}\n({})'.format(
-            pretty_name,
-            variant,
-            build_id,
-            description,
-        )
-
-        return description
-
     def run_cb(self, _ignored=None):
         # type: (typing.Any) -> None
 
@@ -516,7 +625,13 @@ class Gui:
         elif id == '/':
             argv.append('--runtime=')
         else:
-            argv.append('--runtime=' + id)
+            runtime = self.container_runtimes[id]
+
+            if isinstance(runtime, ArchiveRuntime):
+                argv.append('--runtime-archive=' + id)
+                argv.append('--runtime-id=' + runtime.deploy_id)
+            else:
+                argv.append('--runtime=' + id)
 
         if self.host_graphics_check.get_active():
             if os.path.isdir('/run/host'):
@@ -526,11 +641,17 @@ class Gui:
         else:
             argv.append('--graphics-provider=')
 
-        if self.copy_runtime_into_check.get_active():
+        if self.copy_runtime_check.get_active():
             os.makedirs(self.var_path, mode=0o755, exist_ok=True)
-            argv.append('--copy-runtime-into=' + self.var_path)
+            argv.append('--copy-runtime')
+            argv.append('--variable-dir=' + self.var_path)
+        else:
+            argv.append('--no-copy-runtime')
+
+        if self.gc_runtimes_check.get_active():
+            argv.append('--gc-runtimes')
         else:
-            argv.append('--copy-runtime-into=')
+            argv.append('--no-gc-runtimes')
 
         if self.unshare_home_check.get_active():
             argv.append('--unshare-home')
diff --git a/pressure-vessel/runtime.c b/pressure-vessel/runtime.c
index 90ed82324f594c9be3c87923bf9fe09c1faedd1e..fb9b0fca9a35f2a1327b8785346c62cbbda6561e 100644
--- a/pressure-vessel/runtime.c
+++ b/pressure-vessel/runtime.c
@@ -54,6 +54,8 @@ struct _PvRuntime
   GObject parent;
 
   gchar *bubblewrap;
+  gchar *source;
+  gchar *id;
   gchar *deployment;
   gchar *source_files;          /* either deployment or that + "/files" */
   gchar *tools_dir;
@@ -61,7 +63,7 @@ struct _PvRuntime
   GStrv original_environ;
 
   gchar *libcapsule_knowledge;
-  gchar *mutable_parent;
+  gchar *variable_dir;
   gchar *mutable_sysroot;
   gchar *tmpdir;
   gchar *overrides;
@@ -78,7 +80,7 @@ struct _PvRuntime
   const gchar *host_in_current_namespace;
 
   PvRuntimeFlags flags;
-  int mutable_parent_fd;
+  int variable_dir_fd;
   int mutable_sysroot_fd;
   int provider_fd;
   gboolean any_libc_from_provider;
@@ -96,10 +98,11 @@ struct _PvRuntimeClass
 enum {
   PROP_0,
   PROP_BUBBLEWRAP,
-  PROP_DEPLOYMENT,
+  PROP_SOURCE,
   PROP_ORIGINAL_ENVIRON,
   PROP_FLAGS,
-  PROP_MUTABLE_PARENT,
+  PROP_ID,
+  PROP_VARIABLE_DIR,
   PROP_PROVIDER_IN_CURRENT_NAMESPACE,
   PROP_PROVIDER_IN_CONTAINER_NAMESPACE,
   PROP_TOOLS_DIRECTORY,
@@ -373,7 +376,7 @@ pv_runtime_init (PvRuntime *self)
 {
   self->any_libc_from_provider = FALSE;
   self->all_libc_from_provider = FALSE;
-  self->mutable_parent_fd = -1;
+  self->variable_dir_fd = -1;
   self->mutable_sysroot_fd = -1;
 }
 
@@ -399,8 +402,12 @@ pv_runtime_get_property (GObject *object,
         g_value_set_flags (value, self->flags);
         break;
 
-      case PROP_MUTABLE_PARENT:
-        g_value_set_string (value, self->mutable_parent);
+      case PROP_ID:
+        g_value_set_string (value, self->id);
+        break;
+
+      case PROP_VARIABLE_DIR:
+        g_value_set_string (value, self->variable_dir);
         break;
 
       case PROP_PROVIDER_IN_CURRENT_NAMESPACE:
@@ -411,8 +418,8 @@ pv_runtime_get_property (GObject *object,
         g_value_set_string (value, self->provider_in_container_namespace);
         break;
 
-      case PROP_DEPLOYMENT:
-        g_value_set_string (value, self->deployment);
+      case PROP_SOURCE:
+        g_value_set_string (value, self->source);
         break;
 
       case PROP_TOOLS_DIRECTORY:
@@ -452,25 +459,31 @@ pv_runtime_set_property (GObject *object,
         self->flags = g_value_get_flags (value);
         break;
 
-      case PROP_MUTABLE_PARENT:
+      case PROP_VARIABLE_DIR:
         /* Construct-only */
-        g_return_if_fail (self->mutable_parent == NULL);
+        g_return_if_fail (self->variable_dir == NULL);
         path = g_value_get_string (value);
 
         if (path != NULL)
           {
-            self->mutable_parent = realpath (path, NULL);
+            self->variable_dir = realpath (path, NULL);
 
-            if (self->mutable_parent == NULL)
+            if (self->variable_dir == NULL)
               {
                 /* It doesn't exist. Keep the non-canonical path so we
                  * can warn about it later */
-                self->mutable_parent = g_strdup (path);
+                self->variable_dir = g_strdup (path);
               }
           }
 
         break;
 
+      case PROP_ID:
+        /* Construct-only */
+        g_return_if_fail (self->id == NULL);
+        self->id = g_value_dup_string (value);
+        break;
+
       case PROP_PROVIDER_IN_CURRENT_NAMESPACE:
         /* Construct-only */
         g_return_if_fail (self->provider_in_current_namespace == NULL);
@@ -483,20 +496,20 @@ pv_runtime_set_property (GObject *object,
         self->provider_in_container_namespace = g_value_dup_string (value);
         break;
 
-      case PROP_DEPLOYMENT:
+      case PROP_SOURCE:
         /* Construct-only */
-        g_return_if_fail (self->deployment == NULL);
+        g_return_if_fail (self->source == NULL);
         path = g_value_get_string (value);
 
         if (path != NULL)
           {
-            self->deployment = realpath (path, NULL);
+            self->source = realpath (path, NULL);
 
-            if (self->deployment == NULL)
+            if (self->source == NULL)
               {
                 /* It doesn't exist. Keep the non-canonical path so we
                  * can warn about it later */
-                self->deployment = g_strdup (path);
+                self->source = g_strdup (path);
               }
           }
 
@@ -524,36 +537,238 @@ pv_runtime_constructed (GObject *object)
   g_return_if_fail (self->original_environ != NULL);
   g_return_if_fail (self->provider_in_current_namespace != NULL);
   g_return_if_fail (self->provider_in_container_namespace != NULL);
-  g_return_if_fail (self->deployment != NULL);
+  g_return_if_fail (self->source != NULL);
   g_return_if_fail (self->tools_dir != NULL);
 }
 
+static void
+pv_runtime_maybe_garbage_collect_subdir (const char *description,
+                                         const char *parent,
+                                         int parent_fd,
+                                         const char *member)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(PvBwrapLock) temp_lock = NULL;
+  g_autofree gchar *keep = NULL;
+  g_autofree gchar *ref = NULL;
+  struct stat ignore;
+
+  g_return_if_fail (parent != NULL);
+  g_return_if_fail (parent_fd >= 0);
+  g_return_if_fail (member != NULL);
+
+  g_debug ("Found %s %s/%s, considering whether to delete it...",
+           description, parent, member);
+
+  keep = g_build_filename (member, "keep", NULL);
+
+  if (glnx_fstatat (parent_fd, keep, &ignore,
+                    AT_SYMLINK_NOFOLLOW, &local_error))
+    {
+      g_debug ("Not deleting \"%s/%s\": ./keep exists",
+               parent, member);
+      return;
+    }
+  else if (!g_error_matches (local_error, G_IO_ERROR,
+                             G_IO_ERROR_NOT_FOUND))
+    {
+      /* EACCES or something? Give it the benefit of the doubt */
+      g_warning ("Not deleting \"%s/%s\": unable to stat ./keep: %s",
+               parent, member, local_error->message);
+      return;
+    }
+
+  g_clear_error (&local_error);
+
+  ref = g_build_filename (member, ".ref", NULL);
+  temp_lock = pv_bwrap_lock_new (parent_fd, ref,
+                                 (PV_BWRAP_LOCK_FLAGS_CREATE |
+                                  PV_BWRAP_LOCK_FLAGS_WRITE),
+                                 &local_error);
+
+  if (temp_lock == NULL)
+    {
+      g_info ("Not deleting \"%s/%s\": unable to get lock: %s",
+              parent, member, local_error->message);
+      return;
+    }
+
+  g_debug ("Deleting \"%s/%s\"...", parent, member);
+
+  /* We have the lock, which would not have happened if someone was
+   * still using the runtime, so we can safely delete it. */
+  if (!glnx_shutil_rm_rf_at (parent_fd, member, NULL, &local_error))
+    {
+      g_debug ("Unable to delete %s/%s: %s",
+               parent, member, local_error->message);
+    }
+}
+
+static gboolean
+is_old_runtime_deployment (const char *name)
+{
+  if (g_str_has_prefix (name, "scout_before_"))
+    return TRUE;
+
+  if (g_str_has_prefix (name, "soldier_before_"))
+    return TRUE;
+
+  if (g_str_has_prefix (name, "scout_0."))
+    return TRUE;
+
+  if (g_str_has_prefix (name, "soldier_0."))
+    return TRUE;
+
+  if (g_str_has_prefix (name, ".scout_")
+      && g_str_has_suffix (name, "_unpack-temp"))
+    return TRUE;
+
+  if (g_str_has_prefix (name, ".soldier_")
+      && g_str_has_suffix (name, "_unpack-temp"))
+    return TRUE;
+
+  return FALSE;
+}
+
+gboolean
+pv_runtime_garbage_collect_legacy (const char *variable_dir,
+                                   const char *runtime_base,
+                                   GError **error)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(PvBwrapLock) variable_lock = NULL;
+  g_autoptr(PvBwrapLock) base_lock = NULL;
+  g_auto(GLnxDirFdIterator) variable_dir_iter = { FALSE };
+  g_auto(GLnxDirFdIterator) runtime_base_iter = { FALSE };
+  glnx_autofd int variable_dir_fd = -1;
+  glnx_autofd int runtime_base_fd = -1;
+  struct
+  {
+    const char *path;
+    GLnxDirFdIterator *iter;
+  } iters[] = {
+    { variable_dir, &variable_dir_iter },
+    { runtime_base, &runtime_base_iter },
+  };
+  gsize i;
+
+  g_return_val_if_fail (variable_dir != NULL, FALSE);
+  g_return_val_if_fail (runtime_base != NULL, FALSE);
+  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+  if (!glnx_opendirat (AT_FDCWD, variable_dir, TRUE,
+                       &variable_dir_fd, error))
+    return FALSE;
+
+  if (!glnx_opendirat (AT_FDCWD, runtime_base, TRUE,
+                       &runtime_base_fd, error))
+    return FALSE;
+
+  variable_lock = pv_bwrap_lock_new (variable_dir_fd, ".ref",
+                                     (PV_BWRAP_LOCK_FLAGS_CREATE
+                                      | PV_BWRAP_LOCK_FLAGS_WRITE),
+                                     &local_error);
+
+  /* If we can't take the lock immediately, just don't do GC */
+  if (variable_lock == NULL)
+    return TRUE;
+
+  /* We take out locks on both the variable directory and the base
+   * directory, because historically in the shell scripts we only
+   * locked the base directory, and we later moved to locking only the
+   * variable directory. Now that we're in C code it seems safest to
+   * lock both. */
+  base_lock = pv_bwrap_lock_new (runtime_base_fd, ".ref",
+                                 (PV_BWRAP_LOCK_FLAGS_CREATE
+                                  | PV_BWRAP_LOCK_FLAGS_WRITE),
+                                 &local_error);
+
+  /* Same here */
+  if (base_lock == NULL)
+    return TRUE;
+
+  for (i = 0; i < G_N_ELEMENTS (iters); i++)
+    {
+      const char * const symlinks[] = { "scout", "soldier" };
+      gsize j;
+
+      if (!glnx_dirfd_iterator_init_at (AT_FDCWD, iters[i].path,
+                                        TRUE, iters[i].iter, error))
+        return FALSE;
+
+      g_debug ("Cleaning up old subdirectories in %s...",
+               iters[i].path);
+
+      while (TRUE)
+        {
+          struct dirent *dent;
+
+          if (!glnx_dirfd_iterator_next_dent_ensure_dtype (iters[i].iter,
+                                                           &dent, NULL, error))
+            return FALSE;
+
+          if (dent == NULL)
+            break;
+
+          switch (dent->d_type)
+            {
+              case DT_DIR:
+                break;
+
+              case DT_BLK:
+              case DT_CHR:
+              case DT_FIFO:
+              case DT_LNK:
+              case DT_REG:
+              case DT_SOCK:
+              case DT_UNKNOWN:
+              default:
+                g_debug ("Ignoring %s/%s: not a directory",
+                         iters[i].path, dent->d_name);
+                continue;
+            }
+
+          if (!is_old_runtime_deployment (dent->d_name))
+            continue;
+
+          pv_runtime_maybe_garbage_collect_subdir ("legacy runtime",
+                                                   iters[i].path,
+                                                   iters[i].iter->fd,
+                                                   dent->d_name);
+        }
+
+      g_debug ("Cleaning up old symlinks in %s...",
+               iters[i].path);
+
+      for (j = 0; j < G_N_ELEMENTS (symlinks); j++)
+        pv_delete_dangling_symlink (iters[i].iter->fd, iters[i].path,
+                                    symlinks[j]);
+    }
+
+  return TRUE;
+}
+
 static gboolean
 pv_runtime_garbage_collect (PvRuntime *self,
-                            PvBwrapLock *mutable_parent_lock,
+                            PvBwrapLock *variable_dir_lock,
                             GError **error)
 {
   g_auto(GLnxDirFdIterator) iter = { FALSE };
 
   g_return_val_if_fail (PV_IS_RUNTIME (self), FALSE);
-  g_return_val_if_fail (self->mutable_parent != NULL, FALSE);
+  g_return_val_if_fail (self->variable_dir != NULL, FALSE);
   g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
   /* We don't actually *use* this: it just acts as an assertion that
    * we are holding the lock on the parent directory. */
-  g_return_val_if_fail (mutable_parent_lock != NULL, FALSE);
+  g_return_val_if_fail (variable_dir_lock != NULL, FALSE);
 
-  if (!glnx_dirfd_iterator_init_at (AT_FDCWD, self->mutable_parent,
+  if (!glnx_dirfd_iterator_init_at (AT_FDCWD, self->variable_dir,
                                     TRUE, &iter, error))
     return FALSE;
 
   while (TRUE)
     {
-      g_autoptr(GError) local_error = NULL;
-      g_autoptr(PvBwrapLock) temp_lock = NULL;
-      g_autofree gchar *keep = NULL;
-      g_autofree gchar *ref = NULL;
       struct dirent *dent;
-      struct stat ignore;
 
       if (!glnx_dirfd_iterator_next_dent_ensure_dtype (&iter, &dent,
                                                        NULL, error))
@@ -576,126 +791,89 @@ pv_runtime_garbage_collect (PvRuntime *self,
           case DT_UNKNOWN:
           default:
             g_debug ("Ignoring %s/%s: not a directory",
-                     self->mutable_parent, dent->d_name);
+                     self->variable_dir, dent->d_name);
             continue;
         }
 
-      if (!g_str_has_prefix (dent->d_name, "tmp-"))
+      if (g_str_has_prefix (dent->d_name, "deploy-"))
         {
-          g_debug ("Ignoring %s/%s: not tmp-*",
-                   self->mutable_parent, dent->d_name);
-          continue;
-        }
-
-      g_debug ("Found temporary runtime %s/%s, considering whether to "
-               "delete it...",
-               self->mutable_parent, dent->d_name);
-
-      keep = g_build_filename (dent->d_name, "keep", NULL);
+          /* Don't GC old deployments unless we know which one is current
+           * and therefore should not be deleted */
+          if (self->id == NULL)
+            {
+              g_debug ("Ignoring %s/deploy-*: current ID not known",
+                       self->variable_dir);
+              continue;
+            }
 
-      if (glnx_fstatat (self->mutable_parent_fd, keep, &ignore,
-                        AT_SYMLINK_NOFOLLOW, &local_error))
-        {
-          g_debug ("Not deleting \"%s/%s\": ./keep exists",
-                   self->mutable_parent, dent->d_name);
-          continue;
+          /* Don't GC the current deployment */
+          if (strcmp (dent->d_name + strlen ("deploy-"), self->id) == 0)
+            {
+              g_debug ("Ignoring %s/%s: is the current version",
+                       self->variable_dir, dent->d_name);
+              continue;
+            }
         }
-      else if (!g_error_matches (local_error, G_IO_ERROR,
-                                 G_IO_ERROR_NOT_FOUND))
+      else if (!g_str_has_prefix (dent->d_name, "tmp-"))
         {
-          /* EACCES or something? Give it the benefit of the doubt */
-          g_warning ("Not deleting \"%s/%s\": unable to stat ./keep: %s",
-                   self->mutable_parent, dent->d_name, local_error->message);
-          g_clear_error (&local_error);
+          g_debug ("Ignoring %s/%s: not tmp-*",
+                   self->variable_dir, dent->d_name);
           continue;
         }
 
-      g_clear_error (&local_error);
+      pv_runtime_maybe_garbage_collect_subdir ("temporary runtime",
+                                               self->variable_dir,
+                                               self->variable_dir_fd,
+                                               dent->d_name);
+    }
 
-      ref = g_build_filename (dent->d_name, ".ref", NULL);
-      temp_lock = pv_bwrap_lock_new (self->mutable_parent_fd, ref,
-                                     (PV_BWRAP_LOCK_FLAGS_CREATE |
-                                      PV_BWRAP_LOCK_FLAGS_WRITE),
-                                     &local_error);
+  return TRUE;
+}
 
-      if (temp_lock == NULL)
-        {
-          g_info ("Not deleting \"%s/%s\": unable to get lock: %s",
-                  self->mutable_parent, dent->d_name,
-                  local_error->message);
-          g_clear_error (&local_error);
-          continue;
-        }
+static gboolean
+pv_runtime_init_variable_dir (PvRuntime *self,
+                              GError **error)
+{
+  /* Nothing to do in this case */
+  if (self->variable_dir == NULL)
+    return TRUE;
 
-      g_debug ("Deleting \"%s/%s\"...", self->mutable_parent, dent->d_name);
+  if (g_mkdir_with_parents (self->variable_dir, 0700) != 0)
+    return glnx_throw_errno_prefix (error, "Unable to create %s",
+                                    self->variable_dir);
 
-      /* We have the lock, which would not have happened if someone was
-       * still using the runtime, so we can safely delete it. */
-      if (!glnx_shutil_rm_rf_at (self->mutable_parent_fd, dent->d_name,
-                                 NULL, &local_error))
-        {
-          g_debug ("Unable to delete %s/%s: %s",
-                   self->mutable_parent, dent->d_name, local_error->message);
-          g_clear_error (&local_error);
-          continue;
-        }
-    }
+  if (!glnx_opendirat (AT_FDCWD, self->variable_dir, TRUE,
+                       &self->variable_dir_fd, error))
+    return FALSE;
 
   return TRUE;
 }
 
 static gboolean
-pv_runtime_init_mutable (PvRuntime *self,
-                         GError **error)
+pv_runtime_create_copy (PvRuntime *self,
+                        PvBwrapLock *variable_dir_lock,
+                        GError **error)
 {
   g_autofree gchar *dest_usr = NULL;
   g_autofree gchar *source_usr_subdir = NULL;
   g_autofree gchar *temp_dir = NULL;
   g_autoptr(GDir) dir = NULL;
   g_autoptr(PvBwrapLock) copy_lock = NULL;
-  g_autoptr(PvBwrapLock) mutable_lock = NULL;
   g_autoptr(PvBwrapLock) source_lock = NULL;
   const char *member;
   const char *source_usr;
   glnx_autofd int temp_dir_fd = -1;
   gboolean is_just_usr;
 
-  /* Nothing to do in this case */
-  if (self->mutable_parent == NULL)
-    return TRUE;
-
-  if (g_mkdir_with_parents (self->mutable_parent, 0700) != 0)
-    return glnx_throw_errno_prefix (error, "Unable to create %s",
-                                    self->mutable_parent);
-
-  if (!glnx_opendirat (AT_FDCWD, self->mutable_parent, TRUE,
-                       &self->mutable_parent_fd, error))
-    return FALSE;
-
-  /* Lock the parent directory. Anything that directly manipulates the
-   * temporary runtimes is expected to do the same, so that
-   * it cannot be deleting temporary runtimes at the same time we're
-   * creating them.
-   *
-   * This is a read-mode lock: it's OK to create more than one temporary
-   * runtime in parallel, as long as nothing is deleting them
-   * concurrently. */
-  mutable_lock = pv_bwrap_lock_new (self->mutable_parent_fd, ".ref",
-                                    PV_BWRAP_LOCK_FLAGS_CREATE,
-                                    error);
-
-  if (mutable_lock == NULL)
-    return glnx_prefix_error (error, "Unable to lock \"%s/%s\"",
-                              self->mutable_parent, ".ref");
-
-  /* GC old runtimes (if they have become unused) before we create a
-   * new one. This means we should only ever have one temporary runtime
-   * copy per game that is run concurrently. */
-  if ((self->flags & PV_RUNTIME_FLAGS_GC_RUNTIMES) != 0 &&
-      !pv_runtime_garbage_collect (self, mutable_lock, error))
-    return FALSE;
+  g_return_val_if_fail (PV_IS_RUNTIME (self), FALSE);
+  g_return_val_if_fail (self->variable_dir != NULL, FALSE);
+  g_return_val_if_fail (self->flags & PV_RUNTIME_FLAGS_COPY_RUNTIME, FALSE);
+  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+  /* We don't actually *use* this: it just acts as an assertion that
+   * we are holding the lock on the parent directory. */
+  g_return_val_if_fail (variable_dir_lock != NULL, FALSE);
 
-  temp_dir = g_build_filename (self->mutable_parent, "tmp-XXXXXX", NULL);
+  temp_dir = g_build_filename (self->variable_dir, "tmp-XXXXXX", NULL);
 
   if (g_mkdtemp (temp_dir) == NULL)
     return glnx_throw_errno_prefix (error,
@@ -828,14 +1006,210 @@ pv_runtime_init_mutable (PvRuntime *self,
   return TRUE;
 }
 
+static gboolean
+gstring_replace_suffix (GString *s,
+                        const char *suffix,
+                        const char *replacement)
+{
+  gsize len = strlen (suffix);
+
+  if (s->len >= len
+      && strcmp (&s->str[s->len - len], suffix) == 0)
+    {
+      g_string_truncate (s, s->len - len);
+      g_string_append (s, replacement);
+      return TRUE;
+    }
+
+  return FALSE;
+}
+
+/*
+ * mutable_lock: (out) (not optional):
+ */
+static gboolean
+pv_runtime_unpack (PvRuntime *self,
+                   PvBwrapLock **mutable_lock,
+                   GError **error)
+{
+  g_autoptr(GString) debug_tarball = NULL;
+  g_autofree gchar *deploy_basename = NULL;
+  g_autofree gchar *unpack_dir = NULL;
+
+  g_return_val_if_fail (PV_IS_RUNTIME (self), FALSE);
+  g_return_val_if_fail (mutable_lock != NULL, FALSE);
+  g_return_val_if_fail (*mutable_lock == NULL, FALSE);
+  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+  g_return_val_if_fail (self->source != NULL, FALSE);
+  g_return_val_if_fail (self->variable_dir != NULL, FALSE);
+  g_return_val_if_fail (self->variable_dir_fd >= 0, FALSE);
+  g_return_val_if_fail (self->deployment == NULL, FALSE);
+
+  if (!g_file_test (self->source, G_FILE_TEST_IS_REGULAR))
+    return glnx_throw (error, "\"%s\" is not a regular file", self->source);
+
+  if (!g_str_has_suffix (self->source, ".tar.gz"))
+    return glnx_throw (error, "\"%s\" is not a .tar.gz file", self->source);
+
+  if (self->id == NULL)
+    {
+      g_autoptr(GString) build_id_file = g_string_new (self->source);
+      g_autofree char *id = NULL;
+      gsize len;
+      gsize i;
+
+      if (gstring_replace_suffix (build_id_file, "-runtime.tar.gz",
+                                  "-buildid.txt")
+          || gstring_replace_suffix (build_id_file, "-sysroot.tar.gz",
+                                     "-buildid.txt"))
+        {
+          if (!g_file_get_contents (build_id_file->str, &id, &len, error))
+            {
+              g_prefix_error (error, "Unable to determine build ID from \"%s\": ",
+                              build_id_file->str);
+              return FALSE;
+            }
+
+          if (len == 0)
+            return glnx_throw (error, "Build ID in \"%s\" is empty",
+                               build_id_file->str);
+
+          for (i = 0; i < len; i++)
+            {
+              /* Ignore a trailing newline */
+              if (i + 1 == len && id[i] == '\n')
+                {
+                  id[i] = '\0';
+                  break;
+                }
+
+              /* Allow dot, dash or underscore, but not at the beginning */
+              if (i > 0 && strchr (".-_", id[i]) != NULL)
+                continue;
+
+              if (!g_ascii_isalnum (id[i]))
+                return glnx_throw (error, "Build ID in \"%s\" is invalid",
+                                   build_id_file->str);
+            }
+
+          self->id = g_steal_pointer (&id);
+        }
+    }
+
+  if (self->id == NULL)
+    return glnx_throw (error, "Cannot unpack archive without unique ID");
+
+  deploy_basename = g_strdup_printf ("deploy-%s", self->id);
+  self->deployment = g_build_filename (self->variable_dir,
+                                       deploy_basename, NULL);
+
+  /* Fast path: if we already unpacked it, nothing more to do! */
+  if (g_file_test (self->deployment, G_FILE_TEST_IS_DIR))
+    return TRUE;
+
+  /* Lock the parent directory. Anything that directly manipulates the
+   * unpacked runtimes is expected to do the same, so that
+   * it cannot be deleting unpacked runtimes at the same time we're
+   * creating them.
+   *
+   * This is an exclusive lock, to avoid two concurrent processes trying
+   * to unpack the same runtime. */
+  *mutable_lock = pv_bwrap_lock_new (self->variable_dir_fd, ".ref",
+                                     (PV_BWRAP_LOCK_FLAGS_CREATE
+                                      | PV_BWRAP_LOCK_FLAGS_WAIT),
+                                     error);
+
+  if (*mutable_lock == NULL)
+    return FALSE;
+
+  /* Slow path: we need to do this the hard way. */
+  unpack_dir = g_build_filename (self->variable_dir, "tmp-XXXXXX", NULL);
+
+  if (g_mkdtemp (unpack_dir) == NULL)
+    return glnx_throw_errno_prefix (error,
+                                    "Cannot create temporary directory \"%s\"",
+                                    unpack_dir);
+
+  g_info ("Unpacking \"%s\" into \"%s\"...", self->source, unpack_dir);
+
+    {
+      g_autoptr(FlatpakBwrap) tar = flatpak_bwrap_new (NULL);
+
+      flatpak_bwrap_add_args (tar,
+                              "tar",
+                              "--force-local",
+                              "-C", unpack_dir,
+                              NULL);
+
+      if (self->flags & PV_RUNTIME_FLAGS_VERBOSE)
+        flatpak_bwrap_add_arg (tar, "-v");
+
+      flatpak_bwrap_add_args (tar,
+                              "-xf", self->source,
+                              NULL);
+      flatpak_bwrap_finish (tar);
+
+      if (!pv_bwrap_run_sync (tar, NULL, error))
+        {
+          glnx_shutil_rm_rf_at (-1, unpack_dir, NULL, NULL);
+          return FALSE;
+        }
+    }
+
+  debug_tarball = g_string_new (self->source);
+
+  if (gstring_replace_suffix (debug_tarball, "-runtime.tar.gz",
+                              "-debug.tar.gz")
+      && g_file_test (debug_tarball->str, G_FILE_TEST_EXISTS))
+    {
+      g_autoptr(FlatpakBwrap) tar = flatpak_bwrap_new (NULL);
+      g_autoptr(GError) local_error = NULL;
+      g_autofree char *files_lib_debug = NULL;
+
+      files_lib_debug = g_build_filename (unpack_dir, "files", "lib",
+                                          "debug", NULL);
+
+      flatpak_bwrap_add_args (tar,
+                              "tar",
+                              "--force-local",
+                              "-C", files_lib_debug,
+                              NULL);
+
+      if (self->flags & PV_RUNTIME_FLAGS_VERBOSE)
+        flatpak_bwrap_add_arg (tar, "-v");
+
+      flatpak_bwrap_add_args (tar,
+                              "-xf", debug_tarball->str,
+                              "files/",
+                              NULL);
+      flatpak_bwrap_finish (tar);
+
+      if (!pv_bwrap_run_sync (tar, NULL, &local_error))
+        g_debug ("Ignoring error unpacking detached debug symbols: %s",
+                 local_error->message);
+    }
+
+  g_info ("Renaming \"%s\" to \"%s\"...", unpack_dir, deploy_basename);
+
+  if (!glnx_renameat (self->variable_dir_fd, unpack_dir,
+                      self->variable_dir_fd, deploy_basename,
+                      error))
+    {
+      glnx_shutil_rm_rf_at (-1, unpack_dir, NULL, NULL);
+      return FALSE;
+    }
+
+  return TRUE;
+}
+
 static gboolean
 pv_runtime_initable_init (GInitable *initable,
                           GCancellable *cancellable G_GNUC_UNUSED,
                           GError **error)
 {
   PvRuntime *self = PV_RUNTIME (initable);
+  g_autoptr(PvBwrapLock) mutable_lock = NULL;
   g_autofree gchar *contents = NULL;
-  g_autofree gchar *files_ref = NULL;
   g_autofree gchar *os_release = NULL;
   gsize len;
 
@@ -850,11 +1224,24 @@ pv_runtime_initable_init (GInitable *initable,
                          self->bubblewrap);
     }
 
-  if (self->mutable_parent != NULL
-      && !g_file_test (self->mutable_parent, G_FILE_TEST_IS_DIR))
+  if (!pv_runtime_init_variable_dir (self, error))
+    return FALSE;
+
+  if (self->flags & PV_RUNTIME_FLAGS_UNPACK_ARCHIVE)
     {
-      return glnx_throw (error, "\"%s\" is not a directory",
-                         self->mutable_parent);
+      if (self->variable_dir_fd < 0)
+        return glnx_throw (error,
+                           "Cannot unpack archive without variable directory");
+
+      if (!pv_runtime_unpack (self, &mutable_lock, error))
+        return FALSE;
+
+      /* Set by pv_runtime_unpack */
+      g_assert (self->deployment != NULL);
+    }
+  else
+    {
+      self->deployment = g_strdup (self->source);
     }
 
   if (!g_file_test (self->deployment, G_FILE_TEST_IS_DIR))
@@ -893,17 +1280,66 @@ pv_runtime_initable_init (GInitable *initable,
    * continue to be locked until all processes in the container exit.
    * If we make a temporary mutable copy, we only hold this lock until
    * setup has finished. */
-  files_ref = g_build_filename (self->source_files, ".ref", NULL);
-  self->runtime_lock = pv_bwrap_lock_new (AT_FDCWD, files_ref,
-                                          PV_BWRAP_LOCK_FLAGS_CREATE,
-                                          error);
+  if (self->runtime_lock == NULL)
+    {
+      g_autofree gchar *files_ref = NULL;
+
+      files_ref = g_build_filename (self->source_files, ".ref", NULL);
+      self->runtime_lock = pv_bwrap_lock_new (AT_FDCWD, files_ref,
+                                              PV_BWRAP_LOCK_FLAGS_CREATE,
+                                              error);
+    }
 
   /* If the runtime is being deleted, ... don't use it, I suppose? */
   if (self->runtime_lock == NULL)
     return FALSE;
 
-  if (!pv_runtime_init_mutable (self, error))
-    return FALSE;
+  /* GC old runtimes (if they have become unused) before we create a
+   * new one. This means we should only ever have one temporary runtime
+   * copy per game that is run concurrently. */
+  if (self->variable_dir_fd >= 0
+      && (self->flags & PV_RUNTIME_FLAGS_GC_RUNTIMES))
+    {
+      g_autoptr(GError) local_error = NULL;
+
+      /* Take out an exclusive lock for GC so that we will not conflict
+       * with other concurrent processes that are halfway through
+       * deploying or unpacking a runtime. */
+      if (mutable_lock == NULL)
+        mutable_lock = pv_bwrap_lock_new (self->variable_dir_fd, ".ref",
+                                          (PV_BWRAP_LOCK_FLAGS_CREATE
+                                           | PV_BWRAP_LOCK_FLAGS_WRITE),
+                                          &local_error);
+
+      if (mutable_lock == NULL)
+        g_debug ("Unable to take an exclusive lock, skipping GC: %s",
+                 local_error->message);
+      else if (!pv_runtime_garbage_collect (self, mutable_lock, error))
+        return FALSE;
+    }
+
+  if (self->flags & PV_RUNTIME_FLAGS_COPY_RUNTIME)
+    {
+      if (self->variable_dir_fd < 0)
+        return glnx_throw (error,
+                           "Cannot copy runtime without variable directory");
+
+      /* This time take out a non-exclusive lock: any number of processes
+       * can safely be creating their own temporary copy at the same
+       * time. If another process is doing GC, wait for it to finish,
+       * then take our lock. */
+      if (mutable_lock == NULL)
+        mutable_lock = pv_bwrap_lock_new (self->variable_dir_fd, ".ref",
+                                          (PV_BWRAP_LOCK_FLAGS_CREATE
+                                           | PV_BWRAP_LOCK_FLAGS_WAIT),
+                                          error);
+
+      if (mutable_lock == NULL)
+        return FALSE;
+
+      if (!pv_runtime_create_copy (self, mutable_lock, error))
+        return FALSE;
+    }
 
   if (self->mutable_sysroot != NULL)
     {
@@ -1043,8 +1479,8 @@ pv_runtime_finalize (GObject *object)
   g_free (self->bubblewrap);
   g_strfreev (self->original_environ);
   g_free (self->libcapsule_knowledge);
-  glnx_close_fd (&self->mutable_parent_fd);
-  g_free (self->mutable_parent);
+  glnx_close_fd (&self->variable_dir_fd);
+  g_free (self->variable_dir);
   glnx_close_fd (&self->mutable_sysroot_fd);
   g_free (self->mutable_sysroot);
   glnx_close_fd (&self->provider_fd);
@@ -1053,6 +1489,7 @@ pv_runtime_finalize (GObject *object)
   g_free (self->provider_in_container_namespace);
   g_free (self->runtime_files_on_host);
   g_free (self->runtime_usr);
+  g_free (self->source);
   g_free (self->source_files);
   g_free (self->deployment);
   g_free (self->tools_dir);
@@ -1094,10 +1531,16 @@ pv_runtime_class_init (PvRuntimeClass *cls)
                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
                          G_PARAM_STATIC_STRINGS));
 
-  properties[PROP_MUTABLE_PARENT] =
-    g_param_spec_string ("mutable-parent", "Mutable parent",
-                         ("Path to a directory in which to create a "
-                          "mutable copy of source-files, or NULL"),
+  properties[PROP_ID] =
+    g_param_spec_string ("id", "ID",
+                         "Unique identifier of runtime to be unpacked",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_VARIABLE_DIR] =
+    g_param_spec_string ("variable-dir", "Variable directory",
+                         ("Path to directory for temporary files, or NULL"),
                          NULL,
                          (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS));
@@ -1120,10 +1563,10 @@ pv_runtime_class_init (PvRuntimeClass *cls)
                          (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS));
 
-  properties[PROP_DEPLOYMENT] =
-    g_param_spec_string ("deployment", "Deployment",
+  properties[PROP_SOURCE] =
+    g_param_spec_string ("source", "Source",
                          ("Path to read-only runtime files (merged-/usr "
-                          "or sysroot) in current namespace"),
+                          "or sysroot) or archive, in current namespace"),
                          NULL,
                          (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS));
@@ -1139,8 +1582,9 @@ pv_runtime_class_init (PvRuntimeClass *cls)
 }
 
 PvRuntime *
-pv_runtime_new (const char *deployment,
-                const char *mutable_parent,
+pv_runtime_new (const char *source,
+                const char *id,
+                const char *variable_dir,
                 const char *bubblewrap,
                 const char *tools_dir,
                 const char *provider_in_current_namespace,
@@ -1149,7 +1593,7 @@ pv_runtime_new (const char *deployment,
                 PvRuntimeFlags flags,
                 GError **error)
 {
-  g_return_val_if_fail (deployment != NULL, NULL);
+  g_return_val_if_fail (source != NULL, NULL);
   g_return_val_if_fail (bubblewrap != NULL, NULL);
   g_return_val_if_fail (tools_dir != NULL, NULL);
   g_return_val_if_fail ((flags & ~(PV_RUNTIME_FLAGS_MASK)) == 0, NULL);
@@ -1159,8 +1603,9 @@ pv_runtime_new (const char *deployment,
                          error,
                          "bubblewrap", bubblewrap,
                          "original-environ", original_environ,
-                         "mutable-parent", mutable_parent,
-                         "deployment", deployment,
+                         "variable-dir", variable_dir,
+                         "source", source,
+                         "id", id,
                          "tools-directory", tools_dir,
                          "provider-in-current-namespace",
                            provider_in_current_namespace,
diff --git a/pressure-vessel/runtime.h b/pressure-vessel/runtime.h
index b0223bad6fd7652bca605e164d9acbf9afc33252..3669a67cbd906041cb62e5c97882896bab97b2b6 100644
--- a/pressure-vessel/runtime.h
+++ b/pressure-vessel/runtime.h
@@ -38,6 +38,8 @@
  * @PV_RUNTIME_FLAGS_GC_RUNTIMES: Garbage-collect old temporary runtimes
  * @PV_RUNTIME_FLAGS_VERBOSE: Be more verbose
  * @PV_RUNTIME_FLAGS_IMPORT_VULKAN_LAYERS: Include host Vulkan layers
+ * @PV_RUNTIME_FLAGS_COPY_RUNTIME: Copy the runtime and modify the copy
+ * @PV_RUNTIME_FLAGS_UNPACK_ARCHIVE: Source is an archive, not a deployment
  * @PV_RUNTIME_FLAGS_NONE: None of the above
  *
  * Flags affecting how we set up the runtime.
@@ -49,6 +51,8 @@ typedef enum
   PV_RUNTIME_FLAGS_GC_RUNTIMES = (1 << 2),
   PV_RUNTIME_FLAGS_VERBOSE = (1 << 3),
   PV_RUNTIME_FLAGS_IMPORT_VULKAN_LAYERS = (1 << 4),
+  PV_RUNTIME_FLAGS_COPY_RUNTIME = (1 << 5),
+  PV_RUNTIME_FLAGS_UNPACK_ARCHIVE = (1 << 6),
   PV_RUNTIME_FLAGS_NONE = 0
 } PvRuntimeFlags;
 
@@ -58,6 +62,8 @@ typedef enum
    | PV_RUNTIME_FLAGS_GC_RUNTIMES \
    | PV_RUNTIME_FLAGS_VERBOSE \
    | PV_RUNTIME_FLAGS_IMPORT_VULKAN_LAYERS \
+   | PV_RUNTIME_FLAGS_COPY_RUNTIME \
+   | PV_RUNTIME_FLAGS_UNPACK_ARCHIVE \
    )
 
 typedef struct _PvRuntime PvRuntime;
@@ -71,8 +77,9 @@ typedef struct _PvRuntimeClass PvRuntimeClass;
 #define PV_RUNTIME_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS((obj), PV_TYPE_RUNTIME, PvRuntimeClass)
 GType pv_runtime_get_type (void);
 
-PvRuntime *pv_runtime_new (const char *deployment,
-                           const char *mutable_parent,
+PvRuntime *pv_runtime_new (const char *source,
+                           const char *id,
+                           const char *variable_dir,
                            const char *bubblewrap,
                            const char *tools_dir,
                            const char *provider_in_current_namespace,
@@ -90,4 +97,8 @@ gboolean pv_runtime_bind (PvRuntime *self,
                           GError **error);
 void pv_runtime_cleanup (PvRuntime *self);
 
+gboolean pv_runtime_garbage_collect_legacy (const char *variable_dir,
+                                            const char *runtime_base,
+                                            GError **error);
+
 G_DEFINE_AUTOPTR_CLEANUP_FUNC (PvRuntime, g_object_unref)
diff --git a/pressure-vessel/utils.c b/pressure-vessel/utils.c
index 5cb4950d666a27aa21d0f30a3ae0c9229a415895..af84f80484f2e2b5aa94bb2dda78a773d50ddf95 100644
--- a/pressure-vessel/utils.c
+++ b/pressure-vessel/utils.c
@@ -977,3 +977,72 @@ pv_set_up_logging (gboolean opt_verbose)
                      opt_timestamp ? pv_log_to_stderr_with_timestamp : pv_log_to_stderr,
                      NULL);
 }
+
+/**
+ * pv_delete_dangling_symlink:
+ * @dirfd: An open file descriptor for a directory
+ * @debug_path: Path to directory represented by @dirfd, used in debug messages
+ * @name: A filename in @dirfd that is thought to be a symbolic link
+ *
+ * If @name exists in @dirfd and is a symbolic link whose target does not
+ * exist, delete it.
+ */
+void
+pv_delete_dangling_symlink (int dirfd,
+                            const char *debug_path,
+                            const char *name)
+{
+  struct stat stat_buf, lstat_buf;
+
+  g_return_if_fail (dirfd >= 0);
+  g_return_if_fail (name != NULL);
+  g_return_if_fail (strcmp (name, "") != 0);
+  g_return_if_fail (strcmp (name, ".") != 0);
+  g_return_if_fail (strcmp (name, "..") != 0);
+
+  if (fstatat (dirfd, name, &lstat_buf, AT_SYMLINK_NOFOLLOW) == 0)
+    {
+      if (!S_ISLNK (lstat_buf.st_mode))
+        {
+          g_debug ("Ignoring %s/%s: not a symlink",
+                   debug_path, name);
+        }
+      else if (fstatat (dirfd, name, &stat_buf, 0) == 0)
+        {
+          g_debug ("Ignoring %s/%s: symlink target still exists",
+                   debug_path, name);
+        }
+      else if (errno != ENOENT)
+        {
+          int saved_errno = errno;
+
+          g_debug ("Ignoring %s/%s: fstatat(!NOFOLLOW): %s",
+                   debug_path, name, g_strerror (saved_errno));
+        }
+      else
+        {
+          g_debug ("Target of %s/%s no longer exists, deleting it",
+                   debug_path, name);
+
+          if (unlinkat (dirfd, name, 0) != 0)
+            {
+              int saved_errno = errno;
+
+              g_debug ("Could not delete %s/%s: unlinkat: %s",
+                       debug_path, name, g_strerror (saved_errno));
+            }
+        }
+    }
+  else if (errno == ENOENT)
+    {
+      /* Silently ignore: symlink doesn't exist so we don't need to
+       * delete it */
+    }
+  else
+    {
+      int saved_errno = errno;
+
+      g_debug ("Ignoring %s/%s: fstatat(NOFOLLOW): %s",
+               debug_path, name, g_strerror (saved_errno));
+    }
+}
diff --git a/pressure-vessel/utils.h b/pressure-vessel/utils.h
index 5d647afa0e79652e3ac90594aacc132a561e6daa..5920b7b6ee4a30b1e2163f1e753fa0cf14795e23 100644
--- a/pressure-vessel/utils.h
+++ b/pressure-vessel/utils.h
@@ -76,3 +76,7 @@ const char *pv_get_path_after (const char *str,
                                const char *prefix);
 
 void pv_set_up_logging (gboolean opt_verbose);
+
+void pv_delete_dangling_symlink (int dirfd,
+                                 const char *debug_path,
+                                 const char *name);
diff --git a/pressure-vessel/wrap.1.md b/pressure-vessel/wrap.1.md
index 25c5b00a5950cc796c80f5dc1b9725eab4112dfc..e682891fdbe0243696c8aaca34916f07318bc0a4 100644
--- a/pressure-vessel/wrap.1.md
+++ b/pressure-vessel/wrap.1.md
@@ -28,10 +28,18 @@ pressure-vessel-wrap - run programs in a bubblewrap container
 :   Disable all interactivity and redirection: ignore `--shell`,
     all `--shell-` options, `--terminal`, `--tty` and `--xterm`.
 
+`--copy-runtime`, `--no-copy-runtime`
+:   If a `--runtime` is active, copy it into a subdirectory of the
+    `--variable-dir`, edit the copy in-place, and mount the copy read-only
+    in the container, instead of setting up elaborate bind-mount structures.
+    This option requires the `--variable-dir` option to be used.
+
+    `--no-copy-runtime` disables this behaviour and is currently
+    the default.
+
 `--copy-runtime-into` *DIR*
-:   If a `--runtime` is active, copy it into a subdirectory of *DIR*,
-    edit the copy in-place, and mount the copy read-only in the container,
-    instead of setting up elaborate bind-mount structures.
+:   If *DIR* is an empty string, equivalent to `--no-copy-runtime`.
+    Otherwise, equivalent to `--copy-runtime --variable-dir=DIR`.
 
 `--env-if-host` *VAR=VAL*
 :   If *COMMAND* is run with `/usr` from the host system, set
@@ -46,7 +54,7 @@ pressure-vessel-wrap - run programs in a bubblewrap container
     freedesktop.org app *ID* would use.
 
 `--gc-runtimes`, `--no-gc-runtimes`
-:   If using `--copy-runtime-into`, garbage-collect old temporary
+:   If using `--variable-dir`, garbage-collect old temporary
     runtimes that are left over from a previous **pressure-vessel-wrap**.
     This is the default. `--no-gc-runtimes` disables this behaviour.
 
@@ -81,8 +89,8 @@ pressure-vessel-wrap - run programs in a bubblewrap container
 
 `--only-prepare`
 :   Prepare the runtime, but do not actually run *COMMAND*.
-    With `--copy-runtime-into`, the prepared runtime will appear in
-    a subdirectory of *DIR*.
+    With `--copy-runtime`, the prepared runtime will appear in
+    a subdirectory of the `--variable-dir`.
 
 `--pass-fd` *FD*
 :   Pass the file descriptor *FD* (specified as a small positive integer)
@@ -107,9 +115,38 @@ pressure-vessel-wrap - run programs in a bubblewrap container
     (containing bin/sh, bin/env and many other OS files).
     For example, a Flatpak runtime is a suitable value for *PATH*.
 
+`--runtime-archive` *ARCHIVE*
+:   Unpack *ARCHIVE* and use it to provide /usr in the container, similar
+    to `--runtime`. The `--runtime-id` option is also required, unless
+    the filename of the *ARCHIVE* ends with a supported suffix
+    (`-runtime.tar.gz` or `-sysroot.tar.gz`) and it is accompanied by a
+    `-buildid.txt` file.
+
+    If this option is used, then `--variable-dir`
+    (or its environment variable equivalent) is also required.
+    This option and `--runtime` cannot both be used.
+
+    The archive will be unpacked into a subdirectory of the `--variable-dir`.
+    Any other subdirectories of the `--variable-dir` that appear to be
+    different runtimes will be deleted, unless they contain a file
+    at the top level named `keep` or are currently in use.
+
+    The archive must currently be a gzipped tar file whose name ends
+    with `.tar.gz`. Other formats might be allowed in future.
+
 `--runtime-base` *PATH*
-:   If `--runtime` specifies a relative path, look for it relative
-    to *PATH*.
+:   If `--runtime` or `--runtime-archive` is specified as a relative path,
+    look for it relative to *PATH*.
+
+`--runtime-id` *ID*
+:   Use *ID* to construct a directory into which the `--runtime-archive`
+    will be unpacked, overriding an accompanying `-buildid.txt` file
+    if present.
+
+    If the *ID* is the same as in a previous run of pressure-vessel-wrap,
+    the content of the `--runtime-archive` will be assumed to be the
+    same as in that previous run, resulting in the previous runtime
+    being reused.
 
 `--share-home`, `--unshare-home`
 :   If `--unshare-home` is specified, use the home directory given
@@ -168,6 +205,10 @@ pressure-vessel-wrap - run programs in a bubblewrap container
 :   Perform a smoke-test to determine whether **pressure-vessel-wrap**
     can work, and exit. Exit with status 0 if it can or 1 if it cannot.
 
+`--variable-dir` *PATH*
+:   Use *PATH* as a cache directory for files that are temporarily
+    unpacked or copied. It will be created automatically if necessary.
+
 `--verbose`
 :   Be more verbose.
 
@@ -211,9 +252,14 @@ The following environment variables (among others) are read by
 :   If set to `1`, equivalent to `--batch`.
     If set to `0`, no effect.
 
+`PRESSURE_VESSEL_COPY_RUNTIME` (boolean)
+:   If set to `1`, equivalent to `--copy-runtime`.
+    If set to `0`, equivalent to `--no-copy-runtime`.
+
 `PRESSURE_VESSEL_COPY_RUNTIME_INTO` (path or empty string)
-:   Equivalent to
-    `--copy-runtime-into="$PRESSURE_VESSEL_COPY_RUNTIME_INTO"`.
+:   If the string is empty, equivalent to `--no-copy-runtime`.
+    Otherwise, equivalent to
+    `--copy-runtime --variable-dir="$PRESSURE_VESSEL_COPY_RUNTIME_INTO"`.
 
 `PRESSURE_VESSEL_FILESYSTEMS_RO` (`:`-separated list of paths)
 :   Make these paths available read-only inside the container if they
@@ -281,6 +327,9 @@ The following environment variables (among others) are read by
 `PRESSURE_VESSEL_TERMINAL` (`none`, `auto`, `tty` or `xterm`)
 :   Equivalent to `--terminal="$PRESSURE_VESSEL_TERMINAL"`.
 
+`PRESSURE_VESSEL_VARIABLE_DIR` (path)
+:   Equivalent to `--variable-dir="$PRESSURE_VESSEL_VARIABLE_DIR"`.
+
 `PRESSURE_VESSEL_VERBOSE` (boolean)
 :   If set to `1`, equivalent to `--verbose`.
 
diff --git a/pressure-vessel/wrap.c b/pressure-vessel/wrap.c
index 964b1b3ceb010a2305ecfd103771fa83e551717a..fbe9436dc4600042d1c62f6016704cfef84a769f 100644
--- a/pressure-vessel/wrap.c
+++ b/pressure-vessel/wrap.c
@@ -706,12 +706,13 @@ typedef enum
 } Tristate;
 
 static gboolean opt_batch = FALSE;
-static char *opt_copy_runtime_into = NULL;
+static gboolean opt_copy_runtime = FALSE;
 static char **opt_env_if_host = NULL;
 static char *opt_fake_home = NULL;
 static char **opt_filesystems = NULL;
 static char *opt_freedesktop_app_id = NULL;
 static char *opt_steam_app_id = NULL;
+static gboolean opt_gc_legacy_runtimes = FALSE;
 static gboolean opt_gc_runtimes = TRUE;
 static gboolean opt_generate_locales = TRUE;
 static char *opt_home = NULL;
@@ -724,12 +725,15 @@ static gboolean opt_import_vulkan_layers = TRUE;
 static PvShell opt_shell = PV_SHELL_NONE;
 static GPtrArray *opt_ld_preload = NULL;
 static GArray *opt_pass_fds = NULL;
-static char *opt_runtime_base = NULL;
 static char *opt_runtime = NULL;
+static char *opt_runtime_archive = NULL;
+static char *opt_runtime_base = NULL;
+static char *opt_runtime_id = NULL;
 static Tristate opt_share_home = TRISTATE_MAYBE;
 static gboolean opt_share_pid = TRUE;
 static double opt_terminate_idle_timeout = 0.0;
 static double opt_terminate_timeout = -1.0;
+static char *opt_variable_dir = NULL;
 static gboolean opt_verbose = FALSE;
 static gboolean opt_version = FALSE;
 static gboolean opt_version_only = FALSE;
@@ -753,6 +757,35 @@ opt_host_ld_preload_cb (const gchar *option_name,
   return TRUE;
 }
 
+static gboolean
+opt_copy_runtime_into_cb (const gchar *option_name,
+                          const gchar *value,
+                          gpointer data,
+                          GError **error)
+{
+  if (value == NULL)
+    {
+      opt_copy_runtime = FALSE;
+    }
+  else if (value[0] == '\0')
+    {
+      g_warning ("%s is deprecated, disable with --no-copy-runtime instead",
+                 option_name);
+      opt_copy_runtime = FALSE;
+    }
+  else
+    {
+      g_warning ("%s is deprecated, use --copy-runtime and "
+                 "--variable-dir instead",
+                 option_name);
+      opt_copy_runtime = TRUE;
+      g_free (opt_variable_dir);
+      opt_variable_dir = g_strdup (value);
+    }
+
+  return TRUE;
+}
+
 static gboolean
 opt_pass_fd_cb (const char *name,
                 const char *value,
@@ -966,11 +999,20 @@ static GOptionEntry options[] =
     G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_batch,
     "Disable all interactivity and redirection: ignore --shell*, "
     "--terminal, --xterm, --tty. [Default: if $PRESSURE_VESSEL_BATCH]", NULL },
+  { "copy-runtime", '\0',
+    G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_copy_runtime,
+    "If a --runtime is used, copy it into --variable-dir and edit the "
+    "copy in-place.",
+    NULL },
+  { "no-copy-runtime", '\0',
+    G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, &opt_copy_runtime,
+    "Don't behave as described for --copy-runtime. "
+    "[Default unless $PRESSURE_VESSEL_COPY_RUNTIME is 1 or running in Flatpak]",
+    NULL },
   { "copy-runtime-into", '\0',
-    G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, &opt_copy_runtime_into,
-    "If a --runtime is used, copy it into DIR and edit the copy in-place. "
-    "[Default: $PRESSURE_VESSEL_COPY_RUNTIME_INTO or empty]",
-    "DIR" },
+    G_OPTION_FLAG_FILENAME|G_OPTION_FLAG_HIDDEN, G_OPTION_ARG_CALLBACK,
+    &opt_copy_runtime_into_cb,
+    "Deprecated alias for --copy-runtime and --variable-dir", "DIR" },
   { "env-if-host", '\0',
     G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME_ARRAY, &opt_env_if_host,
     "Set VAR=VAL if COMMAND is run with /usr from the host system, "
@@ -991,14 +1033,23 @@ static GOptionEntry options[] =
     "Make --unshare-home use ~/.var/app/com.steampowered.AppN "
     "as home directory. [Default: $STEAM_COMPAT_APP_ID or $SteamAppId]",
     "N" },
+  { "gc-legacy-runtimes", '\0',
+    G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_gc_legacy_runtimes,
+    "Garbage-collect old unpacked runtimes in $PRESSURE_VESSEL_RUNTIME_BASE.",
+    NULL },
+  { "no-gc-legacy-runtimes", '\0',
+    G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, &opt_gc_legacy_runtimes,
+    "Don't garbage-collect old unpacked runtimes in "
+    "$PRESSURE_VESSEL_RUNTIME_BASE [default].",
+    NULL },
   { "gc-runtimes", '\0',
     G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_gc_runtimes,
-    "If using --copy-runtime-into, garbage-collect old temporary "
+    "If using --variable-dir, garbage-collect old temporary "
     "runtimes. [Default, unless $PRESSURE_VESSEL_GC_RUNTIMES is 0]",
     NULL },
   { "no-gc-runtimes", '\0',
     G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, &opt_gc_runtimes,
-    "If using --copy-runtime-into, don't garbage-collect old "
+    "If using --variable-dir, don't garbage-collect old "
     "temporary runtimes.", NULL },
   { "generate-locales", '\0',
     G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_generate_locales,
@@ -1066,11 +1117,22 @@ static GOptionEntry options[] =
     "it with the provider's graphics stack. The empty string "
     "means don't use a runtime. [Default: $PRESSURE_VESSEL_RUNTIME or '']",
     "RUNTIME" },
+  { "runtime-archive", '\0',
+    G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, &opt_runtime_archive,
+    "Unpack the ARCHIVE and use it as the runtime, using --runtime-id to "
+    "avoid repeatedly unpacking the same archive. "
+    "[Default: $PRESSURE_VESSEL_RUNTIME_ARCHIVE]",
+    "ARCHIVE" },
   { "runtime-base", '\0',
     G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, &opt_runtime_base,
-    "If a --runtime is a relative path, look for it relative to BASE. "
+    "If a --runtime or --runtime-archive is a relative path, look for "
+    "it relative to BASE. "
     "[Default: $PRESSURE_VESSEL_RUNTIME_BASE or '.']",
     "BASE" },
+  { "runtime-id", '\0',
+    G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, &opt_runtime_id,
+    "Reuse a previously-unpacked --runtime-archive if its ID matched this",
+    "ID" },
   { "share-home", '\0',
     G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, opt_share_home_cb,
     "Use the real home directory. "
@@ -1141,6 +1203,10 @@ static GOptionEntry options[] =
     "skip SIGTERM and use SIGKILL immediately. Implies --subreaper. "
     "[Default: -1.0, meaning don't signal].",
     "SECONDS" },
+  { "variable-dir", '\0',
+    G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, &opt_variable_dir,
+    "If a runtime needs to be unpacked or copied, put it in DIR.",
+    "DIR" },
   { "verbose", '\0',
     G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &opt_verbose,
     "Be more verbose.", NULL },
@@ -1246,8 +1312,29 @@ main (int argc,
 
   original_environ = g_get_environ ();
 
+  if (is_flatpak_env)
+    opt_copy_runtime = TRUE;
+
   /* Set defaults */
   opt_batch = pv_boolean_environment ("PRESSURE_VESSEL_BATCH", FALSE);
+  /* Process COPY_RUNTIME_INFO first so that COPY_RUNTIME and VARIABLE_DIR
+   * can override it */
+  opt_copy_runtime_into_cb ("$PRESSURE_VESSEL_COPY_RUNTIME_INTO",
+                            g_getenv ("PRESSURE_VESSEL_COPY_RUNTIME_INTO"),
+                            NULL, NULL);
+  opt_copy_runtime = pv_boolean_environment ("PRESSURE_VESSEL_COPY_RUNTIME",
+                                             opt_copy_runtime);
+  opt_runtime_id = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME_ID"));
+
+    {
+      const char *value = g_getenv ("PRESSURE_VESSEL_VARIABLE_DIR");
+
+      if (value != NULL)
+        {
+          g_free (opt_variable_dir);
+          opt_variable_dir = g_strdup (value);
+        }
+    }
 
   opt_freedesktop_app_id = g_strdup (g_getenv ("PRESSURE_VESSEL_FDO_APP_ID"));
 
@@ -1265,6 +1352,7 @@ main (int argc,
                                                      TRUE);
 
   opt_share_home = tristate_environment ("PRESSURE_VESSEL_SHARE_HOME");
+  opt_gc_legacy_runtimes = pv_boolean_environment ("PRESSURE_VESSEL_GC_LEGACY_RUNTIMES", FALSE);
   opt_gc_runtimes = pv_boolean_environment ("PRESSURE_VESSEL_GC_RUNTIMES", TRUE);
   opt_generate_locales = pv_boolean_environment ("PRESSURE_VESSEL_GENERATE_LOCALES", TRUE);
 
@@ -1290,29 +1378,52 @@ main (int argc,
   if (opt_verbose)
     pv_set_up_logging (opt_verbose);
 
-  if (opt_runtime == NULL)
-    opt_runtime = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME"));
+  /* Specifying either one of these mutually-exclusive options as a
+   * command-line option disables use of the environment variable for
+   * the other one */
+  if (opt_runtime == NULL && opt_runtime_archive == NULL)
+    {
+      opt_runtime = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME"));
 
-  if (opt_runtime_base == NULL)
-    opt_runtime_base = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME_BASE"));
+      /* Normalize empty string to NULL to simplify later code */
+      if (opt_runtime != NULL && opt_runtime[0] == '\0')
+        g_clear_pointer (&opt_runtime, g_free);
 
-  if (opt_runtime != NULL
-      && opt_runtime[0] != '\0'
-      && !g_path_is_absolute (opt_runtime)
-      && opt_runtime_base != NULL
-      && opt_runtime_base[0] != '\0')
+      opt_runtime_archive = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME_ARCHIVE"));
+
+      if (opt_runtime_archive != NULL && opt_runtime_archive[0] == '\0')
+        g_clear_pointer (&opt_runtime_archive, g_free);
+    }
+
+  if (opt_runtime_id != NULL)
     {
-      g_autofree gchar *tmp = g_steal_pointer (&opt_runtime);
+      const char *p;
 
-      opt_runtime = g_build_filename (opt_runtime_base, tmp, NULL);
+      if (opt_runtime_id[0] == '-' || opt_runtime_id[0] == '.')
+        {
+          g_warning ("--runtime-id must not start with dash or dot");
+          goto out;
+        }
+
+      for (p = opt_runtime_id; *p != '\0'; p++)
+        {
+          if (!g_ascii_isalnum (*p) && *p != '_' && *p != '-' && *p != '.')
+            {
+              g_warning ("--runtime-id may only contain "
+                         "alphanumerics, underscore, dash or dot");
+              goto out;
+            }
+        }
     }
 
-  if (opt_copy_runtime_into == NULL)
-    opt_copy_runtime_into = g_strdup (g_getenv ("PRESSURE_VESSEL_COPY_RUNTIME_INTO"));
+  if (opt_runtime_base == NULL)
+    opt_runtime_base = g_strdup (g_getenv ("PRESSURE_VESSEL_RUNTIME_BASE"));
 
-  if (opt_copy_runtime_into != NULL
-      && opt_copy_runtime_into[0] == '\0')
-    opt_copy_runtime_into = NULL;
+  if (opt_runtime != NULL && opt_runtime_archive != NULL)
+    {
+      g_warning ("--runtime and --runtime-archive cannot both be used");
+      goto out;
+    }
 
   if (opt_graphics_provider == NULL)
     opt_graphics_provider = g_strdup (g_getenv ("PRESSURE_VESSEL_GRAPHICS_PROVIDER"));
@@ -1493,6 +1604,12 @@ main (int argc,
         }
     }
 
+  if (opt_copy_runtime && opt_variable_dir == NULL)
+    {
+      g_warning ("--copy-runtime requires --variable-dir");
+      goto out;
+    }
+
   /* Finished parsing arguments, so any subsequent failures will make
    * us exit 1. */
   ret = 1;
@@ -1644,9 +1761,26 @@ main (int argc,
                               NULL);
     }
 
-  if (opt_runtime != NULL && opt_runtime[0] != '\0')
+  if (opt_gc_legacy_runtimes
+      && opt_runtime_base != NULL
+      && opt_runtime_base[0] != '\0'
+      && opt_variable_dir != NULL)
+    {
+      if (!pv_runtime_garbage_collect_legacy (opt_variable_dir,
+                                              opt_runtime_base,
+                                              &local_error))
+        {
+          g_warning ("Unable to clean up old runtimes: %s",
+                     local_error->message);
+          g_clear_error (&local_error);
+        }
+    }
+
+  if (opt_runtime != NULL || opt_runtime_archive != NULL)
     {
       PvRuntimeFlags flags = PV_RUNTIME_FLAGS_NONE;
+      g_autofree gchar *runtime_resolved = NULL;
+      const char *runtime_path = NULL;
 
       if (opt_gc_runtimes)
         flags |= PV_RUNTIME_FLAGS_GC_RUNTIMES;
@@ -1663,9 +1797,33 @@ main (int argc,
       if (opt_import_vulkan_layers)
         flags |= PV_RUNTIME_FLAGS_IMPORT_VULKAN_LAYERS;
 
-      g_debug ("Configuring runtime %s...", opt_runtime);
+      if (opt_copy_runtime)
+        flags |= PV_RUNTIME_FLAGS_COPY_RUNTIME;
+
+      if (opt_runtime != NULL)
+        {
+          /* already checked for mutually exclusive options */
+          g_assert (opt_runtime_archive == NULL);
+          runtime_path = opt_runtime;
+        }
+      else
+        {
+          flags |= PV_RUNTIME_FLAGS_UNPACK_ARCHIVE;
+          runtime_path = opt_runtime_archive;
+        }
+
+      if (!g_path_is_absolute (runtime_path)
+          && opt_runtime_base != NULL
+          && opt_runtime_base[0] != '\0')
+        {
+          runtime_resolved = g_build_filename (opt_runtime_base,
+                                               runtime_path, NULL);
+          runtime_path = runtime_resolved;
+        }
+
+      g_debug ("Configuring runtime %s...", runtime_path);
 
-      if (is_flatpak_env && opt_copy_runtime_into == NULL)
+      if (is_flatpak_env && !opt_copy_runtime)
         {
           glnx_throw (error,
                       "Cannot set up a runtime inside Flatpak without "
@@ -1673,8 +1831,9 @@ main (int argc,
           goto out;
         }
 
-      runtime = pv_runtime_new (opt_runtime,
-                                opt_copy_runtime_into,
+      runtime = pv_runtime_new (runtime_path,
+                                opt_runtime_id,
+                                opt_variable_dir,
                                 bwrap_executable,
                                 tools_dir,
                                 opt_graphics_provider,
@@ -2539,9 +2698,12 @@ out:
   g_clear_pointer (&opt_steam_app_id, g_free);
   g_clear_pointer (&opt_home, g_free);
   g_clear_pointer (&opt_fake_home, g_free);
-  g_clear_pointer (&opt_runtime_base, g_free);
   g_clear_pointer (&opt_runtime, g_free);
+  g_clear_pointer (&opt_runtime_archive, g_free);
+  g_clear_pointer (&opt_runtime_base, g_free);
+  g_clear_pointer (&opt_runtime_id, g_free);
   g_clear_pointer (&opt_pass_fds, g_array_unref);
+  g_clear_pointer (&opt_variable_dir, g_free);
 
   g_debug ("Exiting with status %d", ret);
   return ret;
diff --git a/tests/pressure-vessel/containers.py b/tests/pressure-vessel/containers.py
index 549366f692a14b25e65fe8d8f8e1086b6a2f945d..6e07123436a21f03d1566e2bd1eef847f62de3dc 100755
--- a/tests/pressure-vessel/containers.py
+++ b/tests/pressure-vessel/containers.py
@@ -482,6 +482,7 @@ class TestContainers(BaseTest):
         *,
         copy: bool = False,
         gc: bool = True,
+        gc_legacy: bool = True,
         locales: bool = False,
         only_prepare: bool = False
     ) -> None:
@@ -490,6 +491,7 @@ class TestContainers(BaseTest):
             runtime,
             copy=copy,
             gc=gc,
+            gc_legacy=gc_legacy,
             is_scout=True,
             locales=locales,
             only_prepare=only_prepare,
@@ -502,6 +504,7 @@ class TestContainers(BaseTest):
         *,
         copy: bool = False,
         gc: bool = True,
+        gc_legacy: bool = True,
         locales: bool = False,
         only_prepare: bool = False
     ) -> None:
@@ -510,6 +513,7 @@ class TestContainers(BaseTest):
             runtime,
             copy=copy,
             gc=gc,
+            gc_legacy=gc_legacy,
             is_soldier=True,
             locales=locales,
             only_prepare=only_prepare,
@@ -520,8 +524,11 @@ class TestContainers(BaseTest):
         test_name: str,
         runtime: str,
         *,
+        archive: str = '',
         copy: bool = False,
+        fast_path: bool = False,
         gc: bool = True,
+        gc_legacy: bool = True,
         is_scout: bool = False,
         is_soldier: bool = False,
         locales: bool = False,
@@ -530,8 +537,15 @@ class TestContainers(BaseTest):
         if self.bwrap is None and not only_prepare:
             self.skipTest('Unable to run bwrap (in a container?)')
 
-        if not os.path.isdir(runtime):
-            self.skipTest('{} not found'.format(runtime))
+        if archive:
+            if not os.path.isfile(archive):
+                self.skipTest('{} not found'.format(archive))
+
+            if fast_path and not os.path.isdir(runtime):
+                self.skipTest('{} not found'.format(runtime))
+        else:
+            if not os.path.isdir(runtime):
+                self.skipTest('{} not found'.format(runtime))
 
         artifacts = os.path.join(
             self.artifacts,
@@ -543,23 +557,74 @@ class TestContainers(BaseTest):
 
         argv = [
             self.pv_wrap,
-            '--runtime', runtime,
             '--verbose',
             '--write-final-argv', final_argv_temp.name
         ]
 
-        var = os.path.join(self.containers_dir, 'var')
-        os.makedirs(var, exist_ok=True)
+        if archive and (fast_path or copy):
+            argv.extend([
+                '--runtime-archive', archive,
+                # For simplicity, we rely on this below
+                '--runtime-id', 'myruntime_0.1.2',
+            ])
+        elif archive:
+            # Assume the archive is accompanied by a -buildid.txt file
+            argv.extend([
+                '--runtime-archive', archive,
+            ])
+        else:
+            argv.extend(['--runtime', runtime])
+
+        var_dir = os.path.join(self.containers_dir, 'var')
+        os.makedirs(var_dir, exist_ok=True)
 
         if not locales:
             argv.append('--no-generate-locales')
 
-        with tempfile.TemporaryDirectory(prefix='test-', dir=var) as temp:
+        with tempfile.TemporaryDirectory(
+            prefix='test-', dir=var_dir
+        ) as temp, tempfile.TemporaryDirectory(
+            prefix='test-mock-base-', dir=var_dir
+        ) as mock_base:
+            argv.extend(['--runtime-base', mock_base])
+            argv.extend(['--variable-dir', temp])
+
+            if fast_path:
+                # Pretend we had already run this runtime. Rather than
+                # using the real runtime's name, for simplicity this is
+                # a fake name.
+                old_dir = os.path.join(temp, 'deploy-myruntime_0.1.2')
+
+                run_subprocess(
+                    ['cp', '-al', runtime, old_dir],
+                    check=True,
+                )
+
+                if os.path.isdir(os.path.join(old_dir, 'files')):
+                    old_dir = os.path.join(old_dir, 'files')
+
+                if os.path.isdir(os.path.join(old_dir, 'usr')):
+                    old_dir = os.path.join(old_dir, 'usr')
+
+                # Touch a flag file so we can detect that we reused the
+                # old deployment
+                with open(os.path.join(old_dir, 'OLD-DEPLOYMENT'), 'w'):
+                    pass
+
             if copy:
-                argv.extend(['--copy-runtime-into', temp])
+                argv.append('--copy-runtime')
+            else:
+                argv.append('--no-copy-runtime')
 
-                if not gc:
-                    argv.append('--no-gc-runtimes')
+            if gc:
+                argv.append('--gc-runtimes')
+            else:
+                argv.append('--no-gc-runtimes')
+
+            if gc_legacy:
+                argv.append('--gc-legacy-runtimes')
+            else:
+                argv.append('--no-gc-legacy-runtimes')
 
             if is_scout:
                 python = 'python3.5'
@@ -595,7 +660,7 @@ class TestContainers(BaseTest):
             os.makedirs(os.path.join(temp, 'tmp-deleteme'), exist_ok=True)
             # Delete, and assert that it is recursive
             os.makedirs(
-                os.path.join(temp, 'tmp-deleteme2', 'usr', 'lib'),
+                os.path.join(temp, 'deploy-deleteme', 'usr', 'lib'),
                 exist_ok=True,
             )
             # Do not delete because it has ./keep
@@ -605,6 +670,34 @@ class TestContainers(BaseTest):
             # Do not delete because we will write-lock .ref
             os.makedirs(os.path.join(temp, 'tmp-wlock'), exist_ok=True)
 
+            for d in [mock_base, temp]:
+                os.makedirs(
+                    os.path.join(d, 'scout_before_0.20200101.0'),
+                    exist_ok=True,
+                )
+                os.makedirs(
+                    os.path.join(d, 'soldier_0.20200101.0'),
+                    exist_ok=True,
+                )
+                os.makedirs(
+                    os.path.join(d, '.scout_0.20200202.0_unpack-temp'),
+                    exist_ok=True,
+                )
+                os.makedirs(
+                    os.path.join(d, '.soldier_dontdelete'),
+                    exist_ok=True,
+                )
+                os.makedirs(
+                    os.path.join(d, 'scout_dontdelete'),
+                    exist_ok=True,
+                )
+                os.makedirs(
+                    os.path.join(d, 'soldier_0.20200101.0_keep', 'keep'),
+                    exist_ok=True,
+                )
+                os.symlink('soldier_0.20200101.0', os.path.join(d, 'soldier'))
+                os.symlink('scout_dontdelete', os.path.join(d, 'scout'))
+
             with open(
                 os.path.join(temp, 'tmp-rlock', '.ref'), 'w+'
             ) as rlock_writer, open(
@@ -655,40 +748,82 @@ class TestContainers(BaseTest):
 
             final_argv_temp.close()
 
+            for d in [mock_base, temp]:
+                members = set(os.listdir(d))
+
+                if gc_legacy:
+                    self.assertNotIn('scout_before_0.20200101.0', members)
+                    self.assertNotIn('soldier', members)
+                    self.assertNotIn('soldier_0.20200101.0', members)
+                    self.assertNotIn(
+                        '.scout_0.20200202.0_unpack-temp', members,
+                    )
+                else:
+                    self.assertIn('.scout_0.20200202.0_unpack-temp', members)
+                    self.assertIn('scout_before_0.20200101.0', members)
+                    self.assertIn('soldier', members)
+                    self.assertIn('soldier_0.20200101.0', members)
+
+                self.assertIn('.soldier_dontdelete', members)
+                self.assertIn('scout', members)
+                self.assertIn('scout_dontdelete', members)
+                self.assertIn('soldier_0.20200101.0_keep', members)
+
             if copy:
                 members = set(os.listdir(temp))
 
                 self.assertIn('.ref', members)
+
+                if fast_path:
+                    self.assertIn('deploy-myruntime_0.1.2', members)
+
                 self.assertIn('donotdelete', members)
                 self.assertIn('tmp-keep', members)
                 self.assertIn('tmp-rlock', members)
                 self.assertIn('tmp-wlock', members)
                 if gc:
+                    if archive:
+                        self.assertNotIn('deploy-deleteme', members)
+
                     self.assertNotIn('tmp-deleteme', members)
-                    self.assertNotIn('tmp-deleteme2', members)
                 else:
                     # These would have been deleted if not for --no-gc-runtimes
+                    self.assertIn('deploy-deleteme', members)
                     self.assertIn('tmp-deleteme', members)
-                    self.assertIn('tmp-deleteme2', members)
 
                 members.discard('.ref')
+                members.discard('.scout_0.20200202.0_unpack-temp')
+                members.discard('.soldier_dontdelete')
                 members.discard('donotdelete')
+                members.discard('scout')
+                members.discard('scout_before_0.20200101.0')
+                members.discard('scout_dontdelete')
+                members.discard('soldier')
+                members.discard('soldier_0.20200101.0')
+                members.discard('soldier_0.20200101.0_keep')
                 members.discard('tmp-deleteme')
-                members.discard('tmp-deleteme2')
                 members.discard('tmp-keep')
                 members.discard('tmp-rlock')
                 members.discard('tmp-wlock')
+                members.discard('deploy-deleteme')
+                members.discard('deploy-myruntime_0.1.2')
                 # After discarding those, there should be exactly one left:
                 # the one we just created
                 self.assertEqual(len(members), 1)
                 tree = os.path.join(temp, members.pop())
 
+                if fast_path:
+                    require_flag_file = 'OLD-DEPLOYMENT'
+                else:
+                    require_flag_file = ''
+
                 with self.subTest('mutable sysroot'):
                     self._assert_mutable_sysroot(
                         tree,
                         artifacts,
                         is_scout=is_scout,
                         is_soldier=is_soldier,
+                        require_flag_file=require_flag_file,
                     )
 
     def _assert_mutable_sysroot(
@@ -697,7 +832,8 @@ class TestContainers(BaseTest):
         artifacts: str,
         *,
         is_scout: bool = False,
-        is_soldier: bool = False
+        is_soldier: bool = False,
+        require_flag_file: str = ''
     ) -> None:
         with open(
             os.path.join(artifacts, 'contents.txt'),
@@ -718,6 +854,11 @@ class TestContainers(BaseTest):
         )
         self.assertTrue(os.path.isdir(os.path.join(tree, 'sbin')))
 
+        if require_flag_file:
+            self.assertTrue(
+                os.path.exists(os.path.join(tree, 'usr', require_flag_file)),
+            )
+
         target = os.readlink(os.path.join(tree, 'overrides'))
         self.assertEqual(target, 'usr/lib/pressure-vessel/overrides')
         self.assertTrue(
@@ -1088,7 +1229,7 @@ class TestContainers(BaseTest):
         with self.subTest('copy'):
             self._test_scout(
                 'scout_sysroot_copy_usrmerge', scout,
-                copy=True, gc=False,
+                copy=True, gc=False, gc_legacy=False,
             )
 
         with self.subTest('transient'):
@@ -1106,6 +1247,38 @@ class TestContainers(BaseTest):
         with self.subTest('transient'):
             self._test_scout('scout', scout)
 
+    def test_unpack(self) -> None:
+        scout = os.path.join(
+            self.containers_dir,
+            'scout',
+        )
+        archive = os.path.join(
+            self.containers_dir,
+            ('com.valvesoftware.SteamRuntime.Platform-amd64,i386-'
+             'scout-runtime.tar.gz'),
+        )
+
+        self._test_container(
+            'scout_unpack',
+            scout,
+            archive=archive,
+            copy=True,
+            is_scout=True,
+            locales=False,
+            only_prepare=True,
+        )
+
+        self._test_container(
+            'scout_skip_unpack',
+            scout,
+            archive=archive,
+            copy=True,
+            fast_path=True,
+            is_scout=True,
+            locales=False,
+            only_prepare=True,
+        )
+
     def test_soldier_sysroot(self) -> None:
         soldier = os.path.join(self.containers_dir, 'soldier_sysroot')
 
diff --git a/tests/pressure-vessel/utils.c b/tests/pressure-vessel/utils.c
index 5508fb04f3dd20cd1f6c1ba99873eca932aaeed4..9fa84bb99e0e26d7134acaf3b2d6b9e00c014c9c 100644
--- a/tests/pressure-vessel/utils.c
+++ b/tests/pressure-vessel/utils.c
@@ -38,7 +38,7 @@
 
 typedef struct
 {
-  int unused;
+  TestsOpenFdSet old_fds;
 } Fixture;
 
 typedef struct
@@ -51,6 +51,8 @@ setup (Fixture *f,
        gconstpointer context)
 {
   G_GNUC_UNUSED const Config *config = context;
+
+  f->old_fds = tests_check_fd_leaks_enter ();
 }
 
 static void
@@ -58,6 +60,8 @@ teardown (Fixture *f,
           gconstpointer context)
 {
   G_GNUC_UNUSED const Config *config = context;
+
+  tests_check_fd_leaks_leave (f->old_fds);
 }
 
 static void
@@ -162,6 +166,68 @@ test_capture_output (Fixture *f,
   g_clear_pointer (&output, g_free);
 }
 
+static void
+test_delete_dangling_symlink (Fixture *f,
+                              gconstpointer context)
+{
+  g_autoptr(GError) error = NULL;
+  g_auto(GLnxTmpDir) tmpdir = { FALSE };
+  struct stat stat_buf;
+
+  glnx_mkdtemp ("test-XXXXXX", 0700, &tmpdir, &error);
+  g_assert_no_error (error);
+
+  glnx_file_replace_contents_at (tmpdir.fd, "exists", (const guint8 *) "",
+                                 0, 0, NULL, &error);
+  g_assert_no_error (error);
+
+  g_assert_no_errno (mkdirat (tmpdir.fd, "subdir", 0755));
+  g_assert_no_errno (symlinkat ("exists", tmpdir.fd, "target-exists"));
+  g_assert_no_errno (symlinkat ("does-not-exist", tmpdir.fd,
+                                "target-does-not-exist"));
+  g_assert_no_errno (symlinkat ("/etc/ssl/private/nope", tmpdir.fd,
+                                "cannot-stat-target"));
+
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "cannot-stat-target");
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "does-not-exist");
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "exists");
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "subdir");
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "target-does-not-exist");
+  pv_delete_dangling_symlink (tmpdir.fd, tmpdir.path, "target-exists");
+
+  /* We cannot tell whether ./cannot-stat-target is dangling or not
+   * (assuming we're not root) so we give it the benefit of the doubt
+   * and do not delete it */
+  if (G_LIKELY (stat ("/etc/ssl/private/nope", &stat_buf) < 0
+      && errno == EACCES))
+    {
+      g_assert_no_errno (fstatat (tmpdir.fd, "cannot-stat-target", &stat_buf,
+                                  AT_SYMLINK_NOFOLLOW));
+    }
+
+  /* ./does-not-exist never existed */
+  g_assert_cmpint (fstatat (tmpdir.fd, "does-not-exist", &stat_buf,
+                            AT_SYMLINK_NOFOLLOW) == 0 ? 0 : errno,
+                   ==, ENOENT);
+
+  /* ./exists is not a symlink and so was not deleted */
+  g_assert_no_errno (fstatat (tmpdir.fd, "exists", &stat_buf,
+                              AT_SYMLINK_NOFOLLOW));
+
+  /* ./subdir is not a symlink and so was not deleted */
+  g_assert_no_errno (fstatat (tmpdir.fd, "subdir", &stat_buf,
+                              AT_SYMLINK_NOFOLLOW));
+
+  /* ./target-does-not-exist is a dangling symlink and so was deleted */
+  g_assert_cmpint (fstatat (tmpdir.fd, "target-does-not-exist", &stat_buf,
+                            AT_SYMLINK_NOFOLLOW) == 0 ? 0 : errno,
+                   ==, ENOENT);
+
+  /* ./target-exists is a non-dangling symlink and so was not deleted */
+  g_assert_no_errno (fstatat (tmpdir.fd, "target-exists", &stat_buf,
+                              AT_SYMLINK_NOFOLLOW));
+}
+
 static void
 test_envp_cmp (Fixture *f,
                gconstpointer context)
@@ -307,6 +373,8 @@ main (int argc,
               setup, test_arbitrary_key, teardown);
   g_test_add ("/capture-output", Fixture, NULL,
               setup, test_capture_output, teardown);
+  g_test_add ("/delete-dangling-symlink", Fixture, NULL,
+              setup, test_delete_dangling_symlink, teardown);
   g_test_add ("/envp-cmp", Fixture, NULL, setup, test_envp_cmp, teardown);
   g_test_add ("/get-path-after", Fixture, NULL,
               setup, test_get_path_after, teardown);
diff --git a/tests/test-utils.h b/tests/test-utils.h
index 3bcc6840e8a659f620d2fe1fa0c0aeb8c1c95154..dc38143944a5904b919de499afb9bd7c34ab6763 100644
--- a/tests/test-utils.h
+++ b/tests/test-utils.h
@@ -56,6 +56,11 @@
 #define g_assert_cmpstr(a, op, b) g_assert (g_strcmp0 ((a), (b)) op 0)
 #endif
 
+#ifndef g_assert_no_errno
+#define g_assert_no_errno(expr) \
+  g_assert_cmpstr ((expr) >= 0 ? NULL : g_strerror (errno), ==, NULL)
+#endif
+
 /*
  * Other assorted test helpers.
  */