From 8b0c2aa5a33af9e3fdfe18d9757c3b44673f769f Mon Sep 17 00:00:00 2001 From: Toastconcern Date: Wed, 21 Oct 2026 12:44:57 -0500 Subject: [PATCH] Studio: bundles (Bundle menu and Inspector tab), Sound into the Environment tab, tab labels no longer clipped - Bundle tab: name, environments with their pictures (read in place), add the open one / My Environments / any .fenv, reorder, remove, double-click to open (unpacked first), Save / Save As, Install on Steam Frame (onto the Headset tab's list), Unpack into My Environments. Saving and unpacking run in the background. - .fbundle opens from File > Open, the Bundle menu or a drop; the installer takes bundles and shows the first .fenv. - HslRenderer::createPicture / releasePicture for UI thumbnails. - Inspector tabs: ImGui draws a tab's label with the padding at BeginTabBar, so the snug padding is pushed there too. - Self-test: bundle make, save, validate, reopen and unpack (93/93). - docs/bundles.md written; Studio guide and README follow. - Linux: no GLFW_INCLUDE_NONE redefinition, freopen's result checked. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/README.md | 2 +- docs/bundles.md | 102 ++++++++- docs/studio-guide.md | 26 ++- src/hsl_renderer.cpp | 14 ++ src/hsl_renderer.h | 5 + studio/studio.cpp | 524 +++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 644 insertions(+), 30 deletions(-) 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.