diff --git a/README.md b/README.md
index 082c99a..e1761df 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ very largest homes may not fit.
- Shape the walkable area: auto-generated surfaces, drawn floors, spawns, seats, hotspots, wall spots, gravity zones.
- Add a background sound and a picture; set and calculate per-world settings.
- Saves baked `.fenv` files and leaves out what can't be seen; sends them to the headset in one click.
+- Packs several environments into one `.fbundle` to share or install together ([bundles](docs/bundles.md)).
- Debug views (wireframe, meshes, GPU cost, overdraw), a home report, and the headset's settings and debug page
from your desk.
diff --git a/docs/README.md b/docs/README.md
index df6907d..b16b3e2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -9,6 +9,6 @@
| [Environments and .fenv](environments-and-fenv.md) | The `.fenv` format, converting homes with `fenv-tool`, per-world settings and tuning |
| [Performance](performance.md) | How Frame Home reaches 120 fps, checking it, fixing stutter, Studio's own pacing |
| [Frame Studio guide](studio-guide.md) | The Studio window, tools and panels |
-| [Bundles](bundles.md) | Several environments in one `.fbundle` file (being written) |
+| [Bundles](bundles.md) | Several environments in one `.fbundle` file |
| [Building from source](building.md) | Building on each system, the CI, the headset side, the repository layout |
| [Troubleshooting](troubleshooting.md) | Symptoms and fixes, logs, the self-test |
diff --git a/docs/bundles.md b/docs/bundles.md
index e62872b..070a228 100644
--- a/docs/bundles.md
+++ b/docs/bundles.md
@@ -1,5 +1,101 @@
# Bundles
-This guide is being written. It will explain bundles: several `.fenv` environments packed into one `.fbundle` file,
-so a whole collection can be shared and installed in one step. Until it is ready, see
-[environments-and-fenv.md](environments-and-fenv.md) for single environments.
+A **bundle** (`.fbundle`) is several environments in one file: a collection to share, or to install on the headset
+in one go. Each environment in it stays its own environment. On the headset, a bundle is unpacked into the `.fenv`
+files it holds, and the Environments panel lists each one separately.
+
+## Making a bundle in Frame Studio
+
+Everything is in the **Bundle** menu and on the Inspector's **Bundle** tab.
+
+1. **Bundle > New Bundle** starts an empty one, and the **Bundle** tab opens.
+2. Give it a **Name**. It's the bundle's own name; each environment keeps its name.
+3. Add environments:
+ - **Add the Open Environment**: the environment you have open, as it was last saved. Save first (Ctrl+S) to
+ include your latest changes. A home that has never been saved can't be added yet.
+ - **My Environments ▾**: anything you've saved.
+ - **Add .fenv...**: any `.fenv` file. You can pick several at once.
+
+ Each row shows the environment's picture, name and size. **▲** moves it up: the order is kept in the file.
+ **✕** takes it out of the bundle; the `.fenv` file itself stays where it is. Double-click a row to open that
+ environment.
+4. **Save Bundle...** writes the `.fbundle`. The environments are stored as they are, not packed a second time, so
+ saving is quick, and the bundle is about as big as its environments together.
+
+## Opening a bundle
+
+**Bundle > Open Bundle...**, **File > Open Home...**, or dropping a `.fbundle` on the window opens the bundle on the
+Bundle tab. Its environments are listed in place; nothing is unpacked yet.
+
+- Double-click an environment to open it. It is unpacked into a scratch folder first.
+- Add, remove or reorder environments, then **Save Bundle** (the same file) or **Save Bundle As...** (a new one).
+- **Unpack into My Environments** copies every environment out of the bundle into My Environments. An identical
+ environment already there is skipped. A different one with the same name is kept, and the bundle's copy is added
+ as "name 2".
+
+## Installing a bundle on the Steam Frame
+
+- **From Studio**: with the bundle saved, choose **Install on Steam Frame...** on the Bundle tab or in the Bundle
+ menu. The bundle is added to the **Headset** tab's install list. Then choose **Install Frame Home** there. The
+ Headset tab's **Add .fenv or .fbundle...** button adds bundles too.
+- **Without Studio**: give the bundle to the stand-alone installer like any environment
+ (see [install-frame-home.md](install-frame-home.md)):
+
+ ```sh
+ sh install-frame-home.sh --host steamos@frame my-collection.fbundle
+ ```
+
+ On Windows, drop it onto `Install Frame Home.bat`, or run
+ `powershell -ExecutionPolicy Bypass -File install-frame-home.ps1 my-collection.fbundle`.
+- **By hand**: copy the `.fbundle` into `~/frame-home/envs` on the headset.
+
+On the headset, Frame Home's Environments panel unpacks any bundle in `~/frame-home/envs` in the background. Each
+environment becomes its own `.fenv`, and the bundle is then deleted to save space. An environment already there is
+kept, as above. A bundle that's damaged, or still being copied, is left alone and tried again once it changes.
+
+A bundle never changes which environment is shown. When you install `.fenv` files and bundles together, the first
+`.fenv` is the one shown.
+
+## From the command line
+
+`fenv-tool` (built with Frame Home, see [building.md](building.md)) makes and reads bundles:
+
+```sh
+fenv-tool bundle "My collection.fbundle" --name "My collection" calm.fenv beach.fenv night.fenv
+fenv-tool info "My collection.fbundle" # each environment: name, size, picture, damaged or not
+fenv-tool unbundle "My collection.fbundle" out-folder # the .fenv files, one each
+```
+
+## What's inside
+
+A `.fbundle` is a ZIP archive:
+
+```
+my-collection.fbundle
+├── bundle.json the bundle's name and its environments, in order
+└── environments/
+ ├── calm.fenv each environment, stored as it is
+ ├── beach.fenv
+ └── night.fenv
+```
+
+```json
+{
+ "format": "fbundle",
+ "version": 1,
+ "name": "My collection",
+ "environments": [
+ { "file": "environments/calm.fenv", "name": "Calm", "size": 94371840 }
+ ]
+}
+```
+
+- The `.fenv` files are *stored*, not compressed again: they're already compressed inside, so packing and unpacking
+ are just copies, and each one can be read in place (Studio reads names and pictures without unpacking).
+- Bundles over 4 GB use ZIP64. Any ZIP tool can open a bundle.
+- Two environments with the same file name get "name 2"; names are compared without regard to case, so they unpack
+ separately on Windows too.
+- Every member's checksum is verified when it's unpacked: a damaged bundle stops without leaving half a file.
+
+The format is in `src/fenv_bundle.h`; `.fenv` itself is described in
+[environments-and-fenv.md](environments-and-fenv.md).
diff --git a/docs/studio-guide.md b/docs/studio-guide.md
index e05d932..e7fff8a 100644
--- a/docs/studio-guide.md
+++ b/docs/studio-guide.md
@@ -9,9 +9,9 @@ the headset. This guide walks through the window and the everyday tasks. To inst
```
┌───────────────────────────────────────────────────────────────────────────┐
-│ File Edit View World Headset Debug Help menu bar │
+│ File Edit View World Bundle Headset Debug Help menu bar │
├────┬──────────────┬──────────────────────────────┬────────────────────────┤
-│ ✥ │ EXPLORER │ │ Selected World Envir… │
+│ ✥ │ EXPLORER │ │ Selected World Env… … │
│ ⟳ │ Filter… │ │ ───────────────────── │
│ ⤢ │ ▾ Root │ 3D VIEW │ INSPECTOR │
│ ✎ │ ▾ folder │ │ (the chosen tab's │
@@ -30,7 +30,7 @@ the headset. This guide walks through the window and the everyday tasks. To inst
| **Tool strip** | left edge | the transform tools, Draw Floor, and how the gizmo and the camera behave |
| **Explorer** | left | everything in the home: its objects (in the file's own folders, or grouped by kind) and its world items |
| **3D view** | middle | the home as the headset draws it, with the gizmo and the walkable area on top |
-| **Inspector** | right | settings, in tabs: **Selected**, **World**, **Environment**, **Sound**, **Headset** |
+| **Inspector** | right | settings, in tabs: **Selected**, **World**, **Environment**, **Bundle**, **Headset** |
| **Timeline** | bottom | plays the home's animations and sound; drag to scrub through the loop |
| **Status bar** | very bottom | the home's name and format, what's selected, the frame rate, and whether the headset has it |
@@ -109,11 +109,14 @@ removes them all.
## The environment: name, picture, sound
-- **Environment** tab: the name it shows on the headset, its picture (**Choose...**, or a view taken when you
- save), what it's made of, and its own headset settings: resolution, foveation, MSAA and frame rate.
+The **Environment** tab, top to bottom:
+
+- **Environment**: the name it shows on the headset, its picture (**Choose...**, or a view taken when you save), and
+ what it's made of.
+- **Sound**: the home's own ambience and a background sound of your own (MP3, Ogg, Opus, WAV or FLAC), with their
+ volumes. Drop a sound file on the window to add it. **Save Its Sound...** keeps the home's ambience as an Ogg file.
+- **World settings**: its own headset settings: resolution, foveation, MSAA and frame rate.
**Calculate Best Settings** walks it on the headset and keeps the sharpest settings that hold 120 fps.
-- **Sound** tab: the home's own ambience and a background sound of your own (MP3, Ogg, Opus, WAV or FLAC). Drop a
- sound file on the window to add it. **Save Its Sound...** keeps the home's ambience as an Ogg file.
## Saving
@@ -129,12 +132,19 @@ removes them all.
Saved environments are *baked*: everything the headset needs, already in its own format. See
[environments-and-fenv.md](environments-and-fenv.md).
+## Bundles
+
+A bundle (`.fbundle`) packs several saved environments into one file, to share or install together. Use the
+**Bundle** menu or the **Bundle** tab: **New Bundle**, add environments (the open one, from My Environments, or any
+`.fenv`), **Save Bundle...**, and **Install on Steam Frame...**. Opening a `.fbundle` lists what's in it; double-click
+one to open it, or **Unpack into My Environments**. See [bundles.md](bundles.md).
+
## Sending it to the headset
The **Headset** tab (or the **Headset** menu):
- **Send to Frame** makes the open environment the headset's current one, saved or not.
-- **Install Frame Home** puts Frame Home itself on the headset, with the environments you list under
+- **Install Frame Home** puts Frame Home itself on the headset, with the environments and bundles you list under
**Environments to install**. **Restore SteamVR's Environment** takes it off again.
- The **Headset** field is the headset's address: an SSH alias such as `frame`, or `steamos@
`.
See [headset-setup.md](headset-setup.md) and [install-frame-home.md](install-frame-home.md).
diff --git a/src/hsl_renderer.cpp b/src/hsl_renderer.cpp
index 0aea9d7..6092e23 100644
--- a/src/hsl_renderer.cpp
+++ b/src/hsl_renderer.cpp
@@ -337,6 +337,18 @@ void HslRenderer::destroy(Image& i) {
i = Image{};
}
+VkImageView HslRenderer::createPicture(const u8* rgba, uint32_t w, uint32_t h) {
+ Image i = createTexture(rgba, w, h, false);
+ if (!i.view) return VK_NULL_HANDLE;
+ pictures_.push_back(i);
+ return i.view;
+}
+
+void HslRenderer::releasePicture(VkImageView view) {
+ for (size_t k = 0; k < pictures_.size(); ++k)
+ if (pictures_[k].view == view) { destroy(pictures_[k]); pictures_.erase(pictures_.begin() + static_cast(k)); return; }
+}
+
template void HslRenderer::oneShot(F&& record) {
if (recordInto_) { // streaming: ride along in the frame's command buffer, no stall
record(recordInto_);
@@ -4266,6 +4278,8 @@ void HslRenderer::shutdown() {
destroyGuide();
destroyDebug();
for (VkImageView v : layerViews_) if (v) vkDestroyImageView(device_, v, nullptr);
+ for (Image& i : pictures_) destroy(i);
+ pictures_.clear();
for (Image* i : {&colorMs_, &depth_, &resolve_, &fdm_, &sub_, &white_, &black_, &nullTex_, &noMetal_, &flatNormal_, &whiteCube_, &white3D_, &shadowMap_}) destroy(*i);
if (fovFb_) vkDestroyFramebuffer(device_, fovFb_, nullptr);
if (fovPipe_) vkDestroyPipeline(device_, fovPipe_, nullptr);
diff --git a/src/hsl_renderer.h b/src/hsl_renderer.h
index 80f44f3..563ca1c 100644
--- a/src/hsl_renderer.h
+++ b/src/hsl_renderer.h
@@ -61,6 +61,10 @@ public:
void resize(uint32_t eyeWidth, uint32_t eyeHeight);
// UNORM 2D view of one eye layer: the frame's sRGB bytes as-is, for display in a UI.
VkImageView layerView(uint32_t layer) const { return layerViews_[layer]; }
+ // A picture for the UI (an environment's thumbnail): its RGBA8 bytes as-is, like layerView. Kept until
+ // releasePicture (once no frame in flight still draws it) or shutdown.
+ VkImageView createPicture(const u8* rgba, uint32_t w, uint32_t h);
+ void releasePicture(VkImageView view);
// Texture streaming: deferred (still-compressed) textures are uploaded as ASTC when the GPU samples it,
// otherwise decoded on background threads and swapped in a few per frame (render() does this).
@@ -271,6 +275,7 @@ private:
Buffer createDeviceBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage);
void destroy(Buffer& b);
void destroy(Image& i);
+ std::vector pictures_; // createPicture's
void retire(Buffer& b);
VkCommandBuffer recordInto_ = VK_NULL_HANDLE; // set while uploads ride in the frame's commands
std::vector frameStaging_; // their staging, freed after that frame
diff --git a/studio/studio.cpp b/studio/studio.cpp
index 004235d..79229e0 100644
--- a/studio/studio.cpp
+++ b/studio/studio.cpp
@@ -19,6 +19,7 @@
#include "world_gen.h"
#include "surface_mesh.h"
#include "fenv.h"
+#include "fenv_bundle.h"
#include "scene_bake.h"
#include "anim_bake.h"
#include "visibility_bake.h"
@@ -42,7 +43,9 @@ extern "C" void stbi_image_free(void* retval_from_stbi_load);
#include
#include
#else
+#ifndef GLFW_INCLUDE_NONE // (CMake defines it too)
#define GLFW_INCLUDE_NONE
+#endif
#include
#include
#include
@@ -1200,6 +1203,7 @@ struct Studio {
}
void open(std::vector paths, bool pair = true) {
+ if (paths.size() == 1 && isFenvBundlePath(paths[0])) { openBundle(paths[0]); return; } // a bundle: its environments, listed
if (paths.empty() || loadState == 1 || saving) return;
if (pair) carry = Carry(); // another environment: nothing carries over
if (pair && !pairVista(paths)) return; // asked which vista: opens from the picker
@@ -1313,9 +1317,9 @@ struct Studio {
// Pick the footprint, or the footprint and its vista together (Ctrl-click both).
void openDialog() {
- const std::vector picked = platform::openFiles("Open a home (footprint + vista together), an environment (.fenv) or models",
- {{"Homes, environments and models", {"*.apk", "*.fenv", "*.glb", "*.gltf", "*.fbx", "*.obj"}}, {"Meta home", {"*.apk"}},
- {"Frame environment", {"*.fenv"}}, {"Model from Blender / Unity", {"*.glb", "*.gltf", "*.fbx", "*.obj"}}}, true);
+ const std::vector picked = platform::openFiles("Open a home (footprint + vista together), an environment (.fenv), a bundle or models",
+ {{"Homes, environments and models", {"*.apk", "*.fenv", "*.fbundle", "*.glb", "*.gltf", "*.fbx", "*.obj"}}, {"Meta home", {"*.apk"}},
+ {"Frame environment", {"*.fenv"}}, {"Environment bundle", {"*.fbundle"}}, {"Model from Blender / Unity", {"*.glb", "*.gltf", "*.fbx", "*.obj"}}}, true);
if (picked.empty()) return;
open(picked);
}
@@ -1363,6 +1367,7 @@ struct Studio {
if (saveFailed.exchange(false)) { dirty = true; updateTitle(); }
}
if (!saving && saveQueued && scene && !uploading) { saveQueued = false; saveToLibrary(); } // a Save asked for mid auto-save
+ if (!bundleBusy && bundler.joinable()) { bundler.join(); bundleFinished(); }
if (scene && dirty && !saving && !uploading && loadState == 0 && std::chrono::steady_clock::now() - autosavedAt > std::chrono::minutes(5)) {
autosaveVersion();
autosavedAt = std::chrono::steady_clock::now();
@@ -4091,6 +4096,20 @@ struct Studio {
ImGui::TextDisabled("%s", walkSaved() ? "Walking: the walkable area saved with the home" : "Walking: the walkable area Frame Home works out at load");
ImGui::EndMenu();
}
+ if (ImGui::BeginMenu("Bundle")) {
+ if (ImGui::MenuItem("New Bundle", nullptr, false, !bundleBusy)) newBundle();
+ if (ImGui::MenuItem("Open Bundle\u2026", nullptr, false, !bundleBusy)) openBundleDialog();
+ ImGui::Separator();
+ if (ImGui::MenuItem("Add the Open Environment", nullptr, false, !openEnvFile().empty() && !bundleBusy)) addToBundle({openEnvFile()});
+ if (ImGui::MenuItem("Add Environments\u2026", nullptr, false, !bundleBusy)) addToBundleDialog();
+ ImGui::Separator();
+ if (ImGui::MenuItem("Save Bundle", nullptr, false, !bundleMembers.empty() && !bundleBusy)) { if (bundleFile.empty()) saveBundleDialog(); else saveBundle(bundleFile); }
+ if (ImGui::MenuItem("Save Bundle As\u2026", nullptr, false, !bundleMembers.empty() && !bundleBusy)) saveBundleDialog();
+ if (ImGui::MenuItem("Install on Steam Frame\u2026", nullptr, false, !bundleFile.empty() && !bundleDirty)) installBundle();
+ ImGui::Separator();
+ if (ImGui::MenuItem("Bundle Panel")) showInspectorTab(InspectorTab::Bundle);
+ ImGui::EndMenu();
+ }
if (ImGui::BeginMenu("Headset")) {
if (ImGui::MenuItem("Send to Frame", nullptr, false, scene != nullptr && !sending)) sendToFrame();
ImGui::Separator();
@@ -4976,7 +4995,7 @@ struct Studio {
}
// ── The Inspector (right): the selection's and the environment's settings, one tab each ──
- enum class InspectorTab { Selection, World, Environment, Sound, Headset };
+ enum class InspectorTab { Selection, World, Environment, Bundle, Headset };
InspectorTab inspectorTab = InspectorTab::Selection;
int lastSelected = -1, lastWorldKind = -1, lastWorldIndex = -1; // selecting something new shows its tab
@@ -4996,15 +5015,24 @@ struct Studio {
static const struct { InspectorTab tab; const char* name; const char* tip; } kTabs[] = {
{InspectorTab::Selection, "Selected", "The object or world item you selected"},
{InspectorTab::World, "World", "Where you can walk: the walkable area and its settings"},
- {InspectorTab::Environment, "Environment", "Its name, picture, what it's made of, and its headset settings"},
- {InspectorTab::Sound, "Sound", "Its ambience, and a background sound of your own"},
+ {InspectorTab::Environment, "Environment", "Its name, picture, sound, what it's made of, and its headset settings"},
+ {InspectorTab::Bundle, "Bundle", "Several environments packed into one file, to share or install together"},
{InspectorTab::Headset, "Headset", "Send it to the Steam Frame, or install Frame Home there"},
};
ImGui::BeginChild("inspector", ImVec2(0, height), ImGuiChildFlags_AlwaysUseWindowPadding);
- if (ImGui::BeginTabBar("##inspectorTabs", ImGuiTabBarFlags_FittingPolicyResizeDown)) {
+ // Snug tabs, so all five names fit the Inspector's default width. ImGui sizes a tab with the padding at its
+ // BeginTabItem but draws its label with the padding at BeginTabBar: both get the same.
+ const ImVec2 tabPadding(4 * scale, ImGui::GetStyle().FramePadding.y);
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, tabPadding);
+ const bool tabBar = ImGui::BeginTabBar("##inspectorTabs", ImGuiTabBarFlags_FittingPolicyResizeDown);
+ ImGui::PopStyleVar();
+ if (tabBar) {
for (const auto& t : kTabs) {
const bool force = inspectorTab == t.tab && forceInspectorTab;
- if (ImGui::BeginTabItem(t.name, nullptr, force ? ImGuiTabItemFlags_SetSelected : 0)) {
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, tabPadding);
+ const bool open = ImGui::BeginTabItem(t.name, nullptr, force ? ImGuiTabItemFlags_SetSelected : 0);
+ ImGui::PopStyleVar();
+ if (open) {
if (!forceInspectorTab) inspectorTab = t.tab;
ImGui::EndTabItem();
}
@@ -5018,7 +5046,7 @@ struct Studio {
case InspectorTab::Selection: selectionTab(); break;
case InspectorTab::World: worldTab(); break;
case InspectorTab::Environment: environmentTab(); break;
- case InspectorTab::Sound: soundTab(); break;
+ case InspectorTab::Bundle: bundleTab(); break;
case InspectorTab::Headset: headsetTab(); break;
}
ImGui::EndChild();
@@ -5348,6 +5376,7 @@ struct Studio {
ImGui::TextDisabled("Or drop an .apk on the window.");
}
+ soundSection();
if (scene) {
section("WORLD SETTINGS");
{
@@ -5427,9 +5456,9 @@ struct Studio {
}
- // Sound: the home's own ambience and a background sound added to it.
- void soundTab() {
- if (!scene) { ImGui::TextDisabled("Open a home to start."); return; }
+ // The Environment tab's sound: the home's own ambience and a background sound added to it.
+ void soundSection() {
+ if (!scene) return;
section("SOUND");
fieldLabel("Sound");
ImGui::Text("%s", scene->ambientSource.empty() ? "none of its own" : scene->ambientSource == "FMOD bank" ? "FMOD bank (ambience bed)" : "Ogg loop");
@@ -6424,7 +6453,8 @@ struct Studio {
bool openHomeIsFenv() const { return homePaths.size() == 1 && isFenvPath(homePaths[0]); }
void addInstallEnvDialog() {
- const std::vector picked = platform::openFiles("Environments to install on the Steam Frame", {{"Frame environments", {"*.fenv"}}}, true);
+ const std::vector picked = platform::openFiles("Environments to install on the Steam Frame",
+ {{"Environments and bundles", {"*.fenv", "*.fbundle"}}, {"Frame environment", {"*.fenv"}}, {"Environment bundle", {"*.fbundle"}}}, true);
for (const auto& f : picked)
if (std::find(installEnvs.begin(), installEnvs.end(), f) == installEnvs.end()) installEnvs.push_back(f);
}
@@ -6507,9 +6537,11 @@ struct Studio {
if (const size_t at = out.rfind("frame-install: "); at != std::string::npos)
msg = lastLine(out.substr(at + 15, out.find('\n', at) == std::string::npos ? std::string::npos : out.find('\n', at) - at - 15));
if (rc != 0) return done("Failed: " + msg);
- if (!uninstall && !envs.empty()) { // the first environment is the one shown
+ // The first environment is the one shown (a bundle's are named once the headset unpacks it).
+ const auto shown = std::find_if(envs.begin(), envs.end(), [](const std::string& e) { return !isFenvBundlePath(e); });
+ if (!uninstall && shown != envs.end()) {
out.clear();
- if (run(ssh + L"\"printf '" + widen(frameName(envs.front())) + L"\\n' > frame-home/current\"", L"", out) != 0)
+ if (run(ssh + L"\"printf '" + widen(frameName(*shown)) + L"\\n' > frame-home/current\"", L"", out) != 0)
return done("Installed, but couldn't choose the first environment: " + lastLine(out));
}
if (msg.empty()) msg = uninstall ? "SteamVR's environment restored" : "Installed";
@@ -6545,7 +6577,7 @@ struct Studio {
}
if (remove >= 0) installEnvs.erase(installEnvs.begin() + remove);
if (installEnvs.empty() && !scene) ImGui::TextDisabled("None yet: Frame Home alone (it keeps the environments already on the headset).");
- if (ImGui::Button("Add .fenv\u2026")) addInstallEnvDialog();
+ if (ImGui::Button("Add .fenv or .fbundle\u2026")) addInstallEnvDialog();
ImGui::Spacing();
ImGui::Separator();
pushPrimary();
@@ -6558,6 +6590,414 @@ struct Studio {
ImGui::PopTextWrapPos();
}
+ // ── Bundles (Inspector › Bundle, the Bundle menu): several environments packed into one .fbundle ──
+ // Each member stays its own environment; on the headset Frame Home unpacks a bundle into them. A member is a .fenv
+ // on disk, or one inside the bundle that was opened (unpacked when it's needed: opened, saved into another bundle).
+ struct BundleMember {
+ std::string path; // the .fenv, or the opened bundle
+ std::string member; // inside that bundle: its file there; empty for a .fenv on disk
+ std::string name; // what the headset calls it
+ uint64_t size = 0;
+ VkImageView thumbView = VK_NULL_HANDLE;
+ VkDescriptorSet thumb = VK_NULL_HANDLE;
+ float thumbAspect = 0; // its picture's height / width; 0: not read yet, < 0: it has none
+ };
+ std::vector bundleMembers;
+ std::string bundleFile; // the .fbundle opened or last saved; empty for a new one
+ std::string bundleName;
+ bool bundleDirty = false;
+ std::thread bundler; // saving and unpacking, off the UI thread
+ std::atomic bundleBusy{false};
+ std::string bundleStatus; // (statusMutex)
+ std::vector bundleOpenLater; // a member unpacked to open: opened by the UI thread (statusMutex)
+
+ void setBundleStatus(const std::string& s) { std::lock_guard l(statusMutex); bundleStatus = s; }
+
+ // Thumbnails are GPU pictures: freed together, once the frames that drew them are done.
+ void releaseBundleThumbs(std::vector& members) {
+ bool any = false;
+ for (const auto& m : members) any |= m.thumbView != VK_NULL_HANDLE;
+ if (!any) return;
+ vkDeviceWaitIdle(renderer.device());
+ for (auto& m : members) {
+ if (m.thumb) ImGui_ImplVulkan_RemoveTexture(m.thumb);
+ if (m.thumbView) renderer.releasePicture(m.thumbView);
+ m.thumb = VK_NULL_HANDLE;
+ m.thumbView = VK_NULL_HANDLE;
+ m.thumbAspect = 0;
+ }
+ }
+
+ // Its picture, read from the .fenv (or from inside the bundle) the first time the row is drawn, shrunk to 160 px.
+ void loadBundleThumb(BundleMember& m) {
+ m.thumbAspect = -1;
+ FenvManifest fm;
+ std::vector png;
+ const bool ok = m.member.empty() ? fenvReadManifest(m.path, fm, &png) : fenvBundleReadEnvironment(m.path, m.member, fm, &png);
+ if (!ok || png.empty()) return;
+ int w = 0, h = 0, c = 0;
+ unsigned char* px = stbi_load_from_memory(png.data(), static_cast(png.size()), &w, &h, &c, 4);
+ if (!px) return;
+ const int tw = std::min(160, w), th = std::max(1, h * tw / std::max(1, w));
+ std::vector small(static_cast(tw) * th * 4);
+ for (int y = 0; y < th; ++y)
+ for (int x = 0; x < tw; ++x) { // box filter over the source pixels each covers
+ const int x0 = x * w / tw, x1 = std::max(x0 + 1, (x + 1) * w / tw), y0 = y * h / th, y1 = std::max(y0 + 1, (y + 1) * h / th);
+ uint32_t acc[4] = {}, n = 0;
+ for (int yy = y0; yy < y1; ++yy)
+ for (int xx = x0; xx < x1; ++xx, ++n)
+ for (int k = 0; k < 4; ++k) acc[k] += px[(static_cast(yy) * w + xx) * 4 + k];
+ for (int k = 0; k < 4; ++k) small[(static_cast(y) * tw + x) * 4 + k] = static_cast(k == 3 ? 255 : acc[k] / n);
+ }
+ stbi_image_free(px);
+ m.thumbView = renderer.createPicture(small.data(), static_cast(tw), static_cast(th));
+ if (!m.thumbView) return;
+ m.thumb = ImGui_ImplVulkan_AddTexture(viewSampler, m.thumbView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
+ m.thumbAspect = static_cast(th) / static_cast(tw);
+ }
+
+ // Unsaved changes to the bundle: true when it's fine to drop them.
+ bool confirmBundleDiscard() {
+ if (!bundleDirty || bundleMembers.empty()) return true;
+ const platform::Answer a = platform::ask("Frame Studio", "Save the bundle \"" + shownBundleName() + "\" first?", true);
+ if (a == platform::Answer::Cancel) return false;
+ if (a == platform::Answer::Yes) { saveBundleDialog(); return false; } // saving runs in the background: try again after
+ return true;
+ }
+
+ std::string shownBundleName() const { return bundleName.empty() ? std::string("New bundle") : bundleName; }
+
+ void newBundle() {
+ if (bundleBusy || !confirmBundleDiscard()) return;
+ releaseBundleThumbs(bundleMembers);
+ bundleMembers.clear();
+ bundleFile.clear();
+ bundleName.clear();
+ bundleDirty = false;
+ setBundleStatus("");
+ showInspectorTab(InspectorTab::Bundle);
+ }
+
+ void openBundle(const std::string& path) {
+ if (bundleBusy || !confirmBundleDiscard()) return;
+ FenvBundleManifest bm;
+ std::string err;
+ if (!fenvBundleReadManifest(path, bm, &err)) {
+ platform::message("Frame Studio", "Couldn't open the bundle " + narrow(fs::path(widen(path)).filename().wstring()) + (err.empty() ? "" : ": " + err),
+ platform::Icon::Warning);
+ return;
+ }
+ releaseBundleThumbs(bundleMembers);
+ bundleMembers.clear();
+ for (const auto& e : bm.environments) {
+ BundleMember m;
+ m.path = path;
+ m.member = e.file;
+ m.name = e.name;
+ m.size = e.size;
+ bundleMembers.push_back(std::move(m));
+ }
+ bundleFile = path;
+ bundleName = bm.name.empty() ? narrow(fs::path(widen(path)).stem().wstring()) : bm.name;
+ bundleDirty = false;
+ setBundleStatus("");
+ showInspectorTab(InspectorTab::Bundle);
+ }
+
+ void openBundleDialog() {
+ const std::vector picked = platform::openFiles("Open an environment bundle", {{"Environment bundle", {"*.fbundle"}}}, false);
+ if (!picked.empty()) openBundle(picked.front());
+ }
+
+ // Adds .fenv files (one already in the bundle isn't added twice).
+ void addToBundle(const std::vector& paths) {
+ for (const auto& p : paths) {
+ if (!isFenvPath(p)) continue;
+ bool there = false;
+ for (const auto& m : bundleMembers) there |= m.member.empty() && fs::path(widen(m.path)) == fs::path(widen(p));
+ if (there) continue;
+ FenvManifest fm;
+ if (!fenvReadManifest(p, fm)) {
+ platform::message("Frame Studio", "This isn't an environment Frame Studio can read: " + narrow(fs::path(widen(p)).filename().wstring()), platform::Icon::Warning);
+ continue;
+ }
+ BundleMember m;
+ m.path = p;
+ m.name = fm.name.empty() ? narrow(fs::path(widen(p)).stem().wstring()) : fm.name;
+ std::error_code ec;
+ m.size = fs::file_size(fs::path(widen(p)), ec);
+ bundleMembers.push_back(std::move(m));
+ bundleDirty = true;
+ }
+ showInspectorTab(InspectorTab::Bundle);
+ }
+
+ void addToBundleDialog() {
+ addToBundle(platform::openFiles("Environments to add to the bundle", {{"Frame environments", {"*.fenv"}}}, true));
+ }
+
+ // The open environment as a .fenv: the file it was opened from or last saved to.
+ std::string openEnvFile() const { return openHomeIsFenv() ? homePaths[0] : envFile; }
+
+ void removeFromBundle(size_t i) {
+ std::vector gone(bundleMembers.begin() + static_cast(i), bundleMembers.begin() + static_cast(i) + 1);
+ releaseBundleThumbs(gone);
+ bundleMembers.erase(bundleMembers.begin() + static_cast(i));
+ bundleDirty = true;
+ }
+
+ // Members inside a bundle are unpacked here when they're needed (a scratch folder, emptied each time).
+ fs::path bundleScratch() const { return dataDir() / L"bundle-unpacked"; }
+
+ // Opens one member: a .fenv straight away, one inside the bundle once it's unpacked.
+ void openBundleMember(size_t i) {
+ const BundleMember m = bundleMembers[i];
+ if (m.member.empty()) { open({m.path}); return; }
+ if (bundleBusy) return;
+ if (bundler.joinable()) bundler.join();
+ bundleBusy = true;
+ setBundleStatus("Unpacking " + m.name + "\u2026");
+ const fs::path dir = bundleScratch() / L"open";
+ bundler = std::thread([this, m, dir] {
+ std::error_code ec;
+ fs::remove_all(dir, ec);
+ fs::create_directories(dir, ec);
+ std::vector out;
+ std::string err;
+ if (!fenvBundleExtract(m.path, narrow(dir.wstring()), out, err, m.member) || out.empty()) setBundleStatus("Couldn't unpack " + m.name + ": " + err);
+ else {
+ std::lock_guard l(statusMutex);
+ bundleStatus.clear();
+ bundleOpenLater = out;
+ }
+ bundleBusy = false;
+ });
+ }
+
+ void saveBundleDialog() {
+ if (bundleBusy || bundleMembers.empty()) return;
+ std::string name = shownBundleName();
+ for (char& c : name) if (c == '+' || c == '/' || c == '\\' || c == ':') c = '-';
+ const std::string path = platform::saveFile("Save the bundle", name + ".fbundle", {{"Environment bundle", {"*.fbundle"}}}, "fbundle");
+ if (!path.empty()) saveBundle(path);
+ }
+
+ // Packs the members into `out` in the background. Members from inside a bundle are unpacked first (the bundle
+ // being replaced may be the one they're in).
+ void saveBundle(const std::string& out) {
+ if (bundleBusy || bundleMembers.empty()) return;
+ if (bundler.joinable()) bundler.join();
+ bundleBusy = true;
+ const std::vector members = bundleMembers;
+ const std::string name = shownBundleName();
+ const fs::path scratch = bundleScratch() / L"save";
+ bundler = std::thread([this, members, name, out, scratch] {
+ std::error_code ec;
+ fs::remove_all(scratch, ec);
+ std::vector files;
+ std::string err;
+ for (const auto& m : members) {
+ if (m.member.empty()) { files.push_back(m.path); continue; }
+ setBundleStatus("Unpacking " + m.name + "\u2026");
+ const fs::path dir = scratch / std::to_wstring(files.size()); // one folder each: same-named members stay apart
+ fs::create_directories(dir, ec);
+ std::vector got;
+ if (!fenvBundleExtract(m.path, narrow(dir.wstring()), got, err, m.member) || got.empty()) {
+ setBundleStatus("Couldn't save the bundle: unpacking " + m.name + " failed: " + err);
+ bundleBusy = false;
+ return;
+ }
+ files.push_back(got.front());
+ }
+ const bool ok = fenvBundleWrite(out, files, name, err, [this](uint64_t done, uint64_t total) {
+ char msg[96];
+ std::snprintf(msg, sizeof msg, "Saving the bundle\u2026 %.0f%%", total ? 100.0 * static_cast(done) / static_cast(total) : 100.0);
+ setBundleStatus(msg);
+ });
+ fs::remove_all(scratch, ec);
+ std::error_code sec;
+ const double mb = static_cast(fs::file_size(fs::path(widen(out)), sec)) / 1048576.0;
+ char msg[512];
+ if (ok) std::snprintf(msg, sizeof msg, "Saved %s (%zu environments, %.0f MB)", narrow(fs::path(widen(out)).filename().wstring()).c_str(), files.size(), mb);
+ {
+ std::lock_guard l(statusMutex);
+ bundleStatus = ok ? std::string(msg) : "Couldn't save the bundle: " + err;
+ if (ok) bundleSavedTo = out;
+ }
+ bundleBusy = false;
+ });
+ }
+ std::string bundleSavedTo; // set by the saver (statusMutex); the UI thread then makes it the bundle's file
+
+ // Unpacks the opened bundle into My Environments.
+ void unpackBundle() {
+ if (bundleBusy || bundleFile.empty() || bundleDirty) return;
+ if (bundler.joinable()) bundler.join();
+ bundleBusy = true;
+ const std::string file = bundleFile;
+ const fs::path dir = libraryDir();
+ bundler = std::thread([this, file, dir] {
+ std::vector out;
+ std::string err;
+ const bool ok = fenvBundleExtract(file, narrow(dir.wstring()), out, err, {}, [this](uint64_t done, uint64_t total) {
+ char msg[96];
+ std::snprintf(msg, sizeof msg, "Unpacking\u2026 %.0f%%", total ? 100.0 * static_cast(done) / static_cast(total) : 100.0);
+ setBundleStatus(msg);
+ });
+ setBundleStatus(ok ? "Unpacked into My Environments (" + std::to_string(out.size()) + (out.size() == 1 ? " environment)" : " environments)")
+ : "Couldn't unpack the bundle: " + err);
+ bundleBusy = false;
+ });
+ }
+
+ // The saved bundle goes onto the install list, and the Headset tab opens to install it.
+ void installBundle() {
+ if (bundleFile.empty() || bundleDirty) return;
+ seedInstallEnvs();
+ if (std::find(installEnvs.begin(), installEnvs.end(), bundleFile) == installEnvs.end()) installEnvs.push_back(bundleFile);
+ showInspectorTab(InspectorTab::Headset);
+ }
+
+ // What the background work left for the UI thread: a member to open, a bundle file saved.
+ void bundleFinished() {
+ std::vector openNow;
+ std::string saved;
+ {
+ std::lock_guard l(statusMutex);
+ openNow.swap(bundleOpenLater);
+ saved.swap(bundleSavedTo);
+ }
+ if (!saved.empty()) {
+ bundleFile = saved;
+ bundleDirty = false;
+ if (bundleName.empty()) bundleName = narrow(fs::path(widen(saved)).stem().wstring());
+ }
+ if (!openNow.empty()) open(openNow);
+ }
+
+ // Inspector › Bundle.
+ void bundleTab() {
+ section("BUNDLE");
+ ImGui::PushTextWrapPos(0);
+ ImGui::TextDisabled("Several environments in one file, to share or install together. Each stays its own environment on the headset.");
+ ImGui::PopTextWrapPos();
+ ImGui::Spacing();
+ ImGui::BeginDisabled(bundleBusy);
+ fieldLabel("Name");
+ {
+ char nb[128];
+ std::snprintf(nb, sizeof nb, "%s", bundleName.c_str());
+ if (ImGui::InputTextWithHint("##bundlename", "New bundle", nb, sizeof nb)) { bundleName = nb; bundleDirty = true; }
+ }
+ if (!bundleFile.empty()) {
+ ImGui::TextDisabled("%s%s", narrow(fs::path(widen(bundleFile)).filename().wstring()).c_str(), bundleDirty ? " (changed)" : "");
+ if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", bundleFile.c_str());
+ }
+
+ ImGui::SeparatorText("Environments");
+ if (bundleMembers.empty()) {
+ ImGui::PushTextWrapPos(0);
+ ImGui::TextDisabled("None yet. Add the open environment, environments from My Environments, or any .fenv.");
+ ImGui::PopTextWrapPos();
+ }
+ const float thumbW = 64 * scale, rowH = std::max(thumbW * 9 / 16, ImGui::GetTextLineHeight() * 2 + 4 * scale);
+ int remove = -1, openIt = -1, moveUp = -1;
+ uint64_t total = 0;
+ for (size_t i = 0; i < bundleMembers.size(); ++i) {
+ BundleMember& m = bundleMembers[i];
+ total += m.size;
+ if (m.thumbAspect == 0) loadBundleThumb(m);
+ ImGui::PushID(static_cast(i));
+ const ImVec2 a = ImGui::GetCursorScreenPos();
+ ImDrawList* dl = ImGui::GetWindowDrawList();
+ const ImVec2 t1(a.x + thumbW, a.y + rowH);
+ if (m.thumb) { // fitted into the slot, centred
+ const float h = std::min(rowH, thumbW * m.thumbAspect), w = h / m.thumbAspect;
+ const ImVec2 p((a.x + t1.x - w) * 0.5f, (a.y + t1.y - h) * 0.5f);
+ dl->AddImage((ImTextureID)m.thumb, p, ImVec2(p.x + w, p.y + h));
+ } else dl->AddRectFilled(a, t1, col(0x3f9a5c, 0.22f), 4 * scale);
+ const float tx = t1.x + 8 * scale, lh = ImGui::GetTextLineHeight();
+ const float right = ImGui::GetContentRegionMax().x + ImGui::GetWindowPos().x;
+ dl->PushClipRect(ImVec2(tx, a.y), ImVec2(right - 52 * scale, a.y + rowH), true);
+ dl->AddText(ImVec2(tx, a.y + rowH * 0.5f - lh - 1 * scale), col(kText), m.name.c_str());
+ char sub[64];
+ std::snprintf(sub, sizeof sub, "%.0f MB%s", static_cast(m.size) / 1048576.0, m.member.empty() ? "" : " \u00b7 in the bundle");
+ dl->AddText(ImVec2(tx, a.y + rowH * 0.5f + 1 * scale), col(kDim), sub);
+ dl->PopClipRect();
+ if (ImGui::InvisibleButton("##row", ImVec2(std::max(1.0f, right - a.x - 52 * scale), rowH)) ) {}
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip("%s\n%s\nDouble-click to open it", m.name.c_str(), m.member.empty() ? m.path.c_str() : (m.path + " \u203a " + m.member).c_str());
+ if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openIt = static_cast(i);
+ }
+ ImGui::SameLine(0, 4 * scale);
+ ImGui::BeginGroup();
+ ImGui::BeginDisabled(i == 0);
+ if (ImGui::SmallButton("\u25b2")) moveUp = static_cast(i);
+ ImGui::EndDisabled();
+ if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Move up: the first one is the one shown after installing");
+ if (ImGui::SmallButton("\u2715")) remove = static_cast(i);
+ if (ImGui::IsItemHovered()) ImGui::SetTooltip("Take it out of the bundle (the file itself stays)");
+ ImGui::EndGroup();
+ ImGui::PopID();
+ ImGui::Dummy(ImVec2(0, 2 * scale));
+ }
+ if (moveUp > 0) { std::swap(bundleMembers[static_cast(moveUp)], bundleMembers[static_cast(moveUp) - 1]); bundleDirty = true; }
+ if (remove >= 0) removeFromBundle(static_cast(remove));
+ if (openIt >= 0) openBundleMember(static_cast(openIt));
+ if (!bundleMembers.empty()) ImGui::TextDisabled("%zu environment%s \u00b7 %.0f MB", bundleMembers.size(), bundleMembers.size() == 1 ? "" : "s", static_cast(total) / 1048576.0);
+
+ ImGui::Spacing();
+ const std::string here = openEnvFile();
+ ImGui::BeginDisabled(here.empty());
+ if (ImGui::Button("Add the Open Environment", ImVec2(-1, 0))) addToBundle({here});
+ ImGui::EndDisabled();
+ if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
+ ImGui::SetTooltip("%s", here.empty() ? "Save it first (Ctrl+S): a bundle holds saved environments"
+ : dirty ? "As it was last saved: save first (Ctrl+S) to include your latest changes" : here.c_str());
+ const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
+ if (ImGui::Button("My Environments \u25be", ImVec2(half, 0))) ImGui::OpenPopup("##mine");
+ if (ImGui::BeginPopup("##mine")) {
+ const std::vector mine = libraryEnvs();
+ if (mine.empty()) ImGui::TextDisabled("None yet: Save (Ctrl+S) puts what you make here");
+ for (size_t r = 0; r < mine.size(); ++r) {
+ ImGui::PushID(static_cast(r));
+ if (ImGui::MenuItem(recentDetails({mine[r]}).title.c_str())) addToBundle({mine[r]});
+ ImGui::PopID();
+ }
+ ImGui::EndPopup();
+ }
+ ImGui::SameLine();
+ if (ImGui::Button("Add .fenv\u2026", ImVec2(-1, 0))) addToBundleDialog();
+
+ ImGui::Spacing();
+ ImGui::Separator();
+ ImGui::BeginDisabled(bundleMembers.empty());
+ pushPrimary();
+ if (ImGui::Button(bundleFile.empty() ? "Save Bundle\u2026" : "Save Bundle", ImVec2(-1, ImGui::GetFrameHeight() * 1.4f))) {
+ if (bundleFile.empty()) saveBundleDialog(); else saveBundle(bundleFile);
+ }
+ ImGui::PopStyleColor(3);
+ if (ImGui::Button("Save Bundle As\u2026", ImVec2(-1, 0))) saveBundleDialog();
+ ImGui::EndDisabled();
+ const bool saved = !bundleFile.empty() && !bundleDirty;
+ ImGui::BeginDisabled(!saved);
+ if (ImGui::Button("Install on Steam Frame\u2026", ImVec2(-1, 0))) installBundle();
+ if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
+ ImGui::SetTooltip("%s", saved ? "Adds it to the Headset tab's install list: Frame Home unpacks it there into its environments"
+ : "Save the bundle first");
+ if (ImGui::Button("Unpack into My Environments", ImVec2(-1, 0))) unpackBundle();
+ ImGui::EndDisabled();
+ ImGui::EndDisabled();
+ std::string st;
+ { std::lock_guard l(statusMutex); st = bundleStatus; }
+ if (!st.empty()) {
+ ImGui::Spacing();
+ ImGui::PushTextWrapPos(0);
+ ImGui::TextUnformatted(st.c_str());
+ ImGui::PopTextWrapPos();
+ }
+ }
+
// A world point on the viewport (false behind the camera).
bool screenOf(const float* p, ImVec2& out) const {
const float ty = std::tan(kFovY * 0.5f), tx = ty * std::max(1.0f, viewSize.x) / std::max(1.0f, viewSize.y);
@@ -7632,6 +8072,48 @@ struct SelfTest {
a.sound = SoundState();
return 1;
});
+ // ── Bundles: the saved environment packed into a .fbundle, opened again and unpacked
+ stages.push_back([this] {
+ if (a.saving) return -1;
+ bundleFile = dataDir() / L"selftest" / L"selftest.fbundle";
+ std::error_code ec;
+ fs::remove(bundleFile, ec);
+ a.newBundle();
+ libraryBefore = a.libraryEnvs().size();
+ a.addToBundle({a.envFile, a.envFile});
+ check(a.bundleMembers.size() == 1 && a.bundleDirty && a.inspectorTab == Studio::InspectorTab::Bundle,
+ "Bundle \u203a Add the Open Environment lists it once", std::to_string(a.bundleMembers.size()) + " listed");
+ a.bundleName = "Self-test bundle";
+ return 3; // the tab draws it, reading its picture
+ });
+ stages.push_back([this] {
+ check(a.bundleMembers.size() == 1 && a.bundleMembers[0].thumbAspect > 0, "...with its picture");
+ screenshot("09_bundle");
+ a.saveBundle(narrow(bundleFile.wstring()));
+ return 2;
+ });
+ stages.push_back([this] {
+ if (a.bundleBusy || a.bundler.joinable()) return -1;
+ std::string err;
+ check(a.bundleFile == narrow(bundleFile.wstring()) && !a.bundleDirty && fenvBundleValidate(a.bundleFile, err), "Save Bundle writes a valid .fbundle",
+ err.empty() ? narrow(bundleFile.filename().wstring()) : err);
+ a.openBundle(narrow(bundleFile.wstring()));
+ check(a.bundleMembers.size() == 1 && !a.bundleMembers[0].member.empty() && a.bundleName == "Self-test bundle", "Open Bundle lists what's in it",
+ a.bundleMembers.empty() ? "" : a.bundleMembers[0].member);
+ a.unpackBundle();
+ return 2;
+ });
+ stages.push_back([this] {
+ if (a.bundleBusy || a.bundler.joinable()) return -1;
+ std::string st;
+ { std::lock_guard l(a.statusMutex); st = a.bundleStatus; }
+ check(st.rfind("Unpacked into My Environments (1 ", 0) == 0 && a.libraryEnvs().size() == libraryBefore, "Unpack puts it in My Environments (the same file isn't copied twice)", st);
+ a.bundleDirty = false;
+ a.newBundle();
+ std::error_code ec;
+ fs::remove(bundleFile, ec);
+ return 2;
+ });
// ── Edit > Settings: this PC's options, and the headset's read over ssh (nothing is written there)
stages.push_back([this] { a.showSettings = true; a.headsetSettings(false); return 5; });
stages.push_back([this] {
@@ -7658,6 +8140,8 @@ struct SelfTest {
bool planetMade = false;
fs::path soundWav, soundSaved, soundFenv;
fs::path imageFenv;
+ fs::path bundleFile;
+ size_t libraryBefore = 0; // My Environments' count before the bundle is unpacked into it
std::string titleWas, imageWas;
std::array genCounts{}; // Auto-generate's walls, objects, wall spots, seats + hotspots (first run)
size_t collidersAfter = 0;
@@ -7779,6 +8263,9 @@ bool allowClose() {
if (!g_app->confirmDiscard()) return false;
if (g_app->installing)
platform::message("Frame Studio", "Frame Home is still being installed on the headset. Frame Studio will close when the copy finishes.");
+ if (!g_app->confirmBundleDiscard()) return false;
+ if (g_app->bundleBusy)
+ platform::message("Frame Studio", "A bundle is still being written. Frame Studio will close when it's done.");
if (g_app->saving) // closing waits for it (an unfinished .fenv would be lost)
platform::message("Frame Studio", "An environment is still being saved. Frame Studio will close as soon as it's written.");
return true;
@@ -7970,7 +8457,7 @@ int runStudio(StudioWindow& window, const std::vector& args) {
if (const char* tab = std::getenv("FS_INSPECTOR_TAB")) { // diagnostics / screenshots: start on this Inspector tab
const std::string t = tab;
app->showInspectorTab(t == "world" ? Studio::InspectorTab::World : t == "environment" ? Studio::InspectorTab::Environment
- : t == "sound" ? Studio::InspectorTab::Sound : t == "headset" ? Studio::InspectorTab::Headset
+ : t == "sound" ? Studio::InspectorTab::Environment : t == "bundle" ? Studio::InspectorTab::Bundle : t == "headset" ? Studio::InspectorTab::Headset
: Studio::InspectorTab::Selection);
}
std::unique_ptr selftest;
@@ -8075,6 +8562,7 @@ int runStudio(StudioWindow& window, const std::vector& args) {
if (app->tuner.joinable()) app->tuner.join();
if (app->filesReader.joinable()) app->filesReader.join();
if (app->installer.joinable()) app->installer.join();
+ if (app->bundler.joinable()) app->bundler.join();
if (app->headsetThread.joinable()) app->headsetThread.join();
if (app->headsetDebugThread.joinable()) app->headsetDebugThread.join();
if (app->loadAbandoned) { // a stopped loader may still be running and would touch the app as it's torn down
@@ -8439,7 +8927,7 @@ bool loadVulkan() {
}
int main(int argc, char** argv) {
- std::freopen((dataDir() / "studio.log").c_str(), "w", stderr);
+ if (!std::freopen((dataDir() / "studio.log").c_str(), "w", stderr)) std::perror("studio.log"); // (logs to the terminal then)
std::vector args(argv + 1, argv + argc);
#ifdef __APPLE__
// Started from Finder, macOS may add a "-psn_..." process serial number argument: not a home.