Persist per-mount wrist and head placement after drag

This commit is contained in:
baketnk committed 2026-09-26 23:41:13 -04:00
1 parent 8f4c1877f7
commit 4b0b29700d
6 files changed
+293 -15

No files matched your search

+4 -1
View File
@@ -58,7 +58,7 @@ target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION
FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}" FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}"
FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}") FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}")
if(CMAKE_SYSTEM_NAME STREQUAL "Linux") if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp) add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp src/placement.cpp)
target_include_directories(frameyap_mount PUBLIC src) target_include_directories(frameyap_mount PUBLIC src)
add_library(frameyap_worker src/worker.cpp) add_library(frameyap_worker src/worker.cpp)
target_include_directories(frameyap_worker PUBLIC src) target_include_directories(frameyap_worker PUBLIC src)
@@ -167,6 +167,9 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
add_executable(frameyap_mount_test tests/mount_test.cpp) add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount) target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
add_test(NAME frameyap.mount COMMAND frameyap_mount_test) add_test(NAME frameyap.mount COMMAND frameyap_mount_test)
add_executable(frameyap_placement_test tests/placement_test.cpp)
target_link_libraries(frameyap_placement_test PRIVATE frameyap_mount)
add_test(NAME frameyap.placement COMMAND frameyap_placement_test)
add_executable(frameyap_angle_fade_test tests/angle_fade_test.cpp) add_executable(frameyap_angle_fade_test tests/angle_fade_test.cpp)
target_include_directories(frameyap_angle_fade_test PRIVATE src) target_include_directories(frameyap_angle_fade_test PRIVATE src)
target_compile_options(frameyap_angle_fade_test PRIVATE -UNDEBUG) target_compile_options(frameyap_angle_fade_test PRIVATE -UNDEBUG)
+18 -9
View File
@@ -359,8 +359,12 @@ panel with the controller, including depth, pitch, yaw and roll. Grab captures
`inverse(controller_down) * panel_down` and applies that unchanged relative pose `inverse(controller_down) * panel_down` and applies that unchanged relative pose
to each controller pose, so grabbing does not snap or reset the panel orientation. to each controller pose, so grabbing does not snap or reset the panel orientation.
Release leaves the last pose in the chosen mount frame; head/wrist mounts continue Release leaves the last pose in the chosen mount frame; head/wrist mounts continue
following that anchor afterward. Mount changes/recenter reset the pose, while following that anchor afterward. A completed grab or scale on Head, Left wrist or
retaining the size factor. Restart restores configured position/size defaults. Right wrist saves the full device-relative canvas position, rotation and scale
separately for that mount. Mount changes restore each saved relative placement;
restarting does too. A cancelled drag does not update the saved placement.
World-space placement is resampled on startup/recenter rather than restored
against a possibly different tracking origin.
Drag the corner bracket to scale between half and twice the configured width. Drag the corner bracket to scale between half and twice the configured width.
Scaling freezes its initial plane and calibrates a ray from the source controller Scaling freezes its initial plane and calibrates a ray from the source controller
@@ -402,9 +406,10 @@ fade feel and interaction at the threshold still need live acceptance.
To tune the selected wrist, set `wrist` in `config.json` as in the example above: To tune the selected wrist, set `wrist` in `config.json` as in the example above:
`x`, `y`, `z` are controller-local meters (each -0.3 to 0.3), `width` is panel `x`, `y`, `z` are controller-local meters (each -0.3 to 0.3), `width` is panel
width in meters (0.15 to 0.6), and `roll_degrees` rotates about controller -Z width in meters (0.15 to 0.6), and `roll_degrees` rotates about controller -Z
(-180 to 180) before applying the offset. These settings apply to both wrists, (-180 to 180) before applying the offset. These settings are initial defaults
are read at startup, and do not alter world/head placement. Invalid values for both wrists; a saved per-wrist pose takes precedence. They are read at
fail direct native startup; the installer backs up and repairs invalid entries. startup and do not alter world/head placement. Invalid values fail direct native
startup; the installer backs up and repairs invalid entries.
A missing/untracked selected wrist temporarily falls back to world space, A missing/untracked selected wrist temporarily falls back to world space,
with a visible explanation in Settings, then reattaches when tracking returns. with a visible explanation in Settings, then reattaches when tracking returns.
Use Settings → **Wrist world fallback** to switch between the default Use Settings → **Wrist world fallback** to switch between the default
@@ -416,10 +421,14 @@ pointer controls cannot be used.
The saved mount preference is not replaced by the fallback. These offsets and sizes are The saved mount preference is not replaced by the fallback. These offsets and sizes are
initial choices, **not headset-comfort acceptance**. initial choices, **not headset-comfort acceptance**.
A selection saves only the mount token to `$XDG_CONFIG_HOME/frameyap/mount` A selection saves the mount token to `$XDG_CONFIG_HOME/frameyap/mount`
(or `$HOME/.config/frameyap/mount`), using an atomic replacement. No pose, audio (or `$HOME/.config/frameyap/mount`). Completed relative adjustments save to
or transcript is saved. Missing/invalid settings default to world; write failure `placement-left-wrist`, `placement-right-wrist`, or `placement-head` beside that
keeps the selection for the session and displays a warning. `--mount file. Remove one placement file while FrameYap is closed to restore that mount's
default. These are bounded, versioned device-relative transforms with owner-only
atomic writes; invalid files are ignored. World-space poses, audio and transcripts
are not saved. Missing/invalid mount settings default to World; write failure
keeps the placement for the session and displays a warning. `--mount
world|left-wrist|right-wrist|head` overrides the saved choice for one launch without world|left-wrist|right-wrist|head` overrides the saved choice for one launch without
writing it; `--head` remains an alias for `--mount head`. writing it; `--head` remains an alias for `--mount head`.
+34 -5
View File
@@ -3,6 +3,7 @@
#include "angle_fade.hpp" #include "angle_fade.hpp"
#include "gestures.hpp" #include "gestures.hpp"
#include "laser_setting.hpp" #include "laser_setting.hpp"
#include "placement.hpp"
#include "panel_surface.hpp" #include "panel_surface.hpp"
#include <openvr.h> #include <openvr.h>
@@ -85,6 +86,7 @@ struct Overlay::Impl {
std::filesystem::path settings_path, laser_settings_path; std::filesystem::path settings_path, laser_settings_path;
bool save_failed = false, debug_save_failed = false, auto_save_failed = false; bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
bool layout_save_failed = false, mic_save_failed = false, laser_change_failed = false; bool layout_save_failed = false, mic_save_failed = false, laser_change_failed = false;
bool placement_save_failed = false;
bool persist_mount = true; bool persist_mount = true;
} persistence; } persistence;
Mount mount; Mount mount;
@@ -100,6 +102,7 @@ struct Overlay::Impl {
float size_scale = 1.f; float size_scale = 1.f;
bool placement_dirty = false; bool placement_dirty = false;
Matrix34 canvas_pose{}; Matrix34 canvas_pose{};
std::array<std::optional<RelativePlacement>, 4> saved_placements{};
struct DragState { struct DragState {
Matrix34 canvas{}; Matrix34 canvas{};
PanelDrag panel; PanelDrag panel;
@@ -136,6 +139,9 @@ struct Overlay::Impl {
config(load_config(default_config_path())), config(load_config(default_config_path())),
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) { surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) {
persistence.persist_mount = persist; persistence.persist_mount = persist;
for (auto selected : {Mount::LeftWrist, Mount::RightWrist, Mount::Head})
saved_placements[static_cast<size_t>(selected)] =
load_relative_placement(default_placement_path(selected), selected);
const auto action_path = absolute_file(action_manifest(assets, config)); const auto action_path = absolute_file(action_manifest(assets, config));
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json"); absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
try { try {
@@ -273,7 +279,8 @@ struct Overlay::Impl {
const Mount effective = *selected; const Mount effective = *selected;
if (effective == Mount::World && applied_mount && *applied_mount != Mount::World) if (effective == Mount::World && applied_mount && *applied_mount != Mount::World)
world_ready = false; // a fresh world fallback near the wearer, not an old room location world_ready = false; // a fresh world fallback near the wearer, not an old room location
std::string note = persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." : std::string note = persistence.placement_save_failed ? "Placement not saved; using it only for this session." :
persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." :
persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." : persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." :
persistence.auto_save_failed ? "Auto insert preference not saved; using it only for this session." : persistence.auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." : persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." :
@@ -302,6 +309,13 @@ struct Overlay::Impl {
const float base_width = mount_width(effective, config.wrist); const float base_width = mount_width(effective, config.wrist);
if (relocated) { if (relocated) {
surface.reset_pointers(); dragging.panel.reset(); surface.reset_pointers(); dragging.panel.reset();
const auto& remembered = saved_placements[static_cast<size_t>(effective)];
if (effective != Mount::World && remembered) {
// Saved canvas coordinates already include transparent margins.
size_scale = remembered->scale;
canvas_pose = remembered->canvas_pose;
} else {
if (effective != Mount::World) size_scale = 1.f;
auto pose = effective == Mount::World ? matrix(world_transform) : relative_mount_pose(effective, config.wrist); auto pose = effective == Mount::World ? matrix(world_transform) : relative_mount_pose(effective, config.wrist);
pose = resized_mount_pose(pose, base_width, size_scale, float(CH) / CW); pose = resized_mount_pose(pose, base_width, size_scale, float(CH) / CW);
// Preserve main-panel dimensions when adding transparent margins. // Preserve main-panel dimensions when adding transparent margins.
@@ -311,6 +325,7 @@ struct Overlay::Impl {
for (int r = 0; r < 3; ++r) pose[r][3] += pose[r][0] * dx + pose[r][1] * dy; for (int r = 0; r < 3; ++r) pose[r][3] += pose[r][0] * dx + pose[r][1] * dy;
canvas_pose = pose; canvas_pose = pose;
} }
}
// After a grab, keep the full released pose. Never rebuild it from // After a grab, keep the full released pose. Never rebuild it from
// a planar offset or configured orientation on the next poll. // a planar offset or configured orientation on the next poll.
vr::HmdMatrix34_t transform{}; vr::HmdMatrix34_t transform{};
@@ -369,6 +384,18 @@ struct Overlay::Impl {
diagnostics.last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") + diagnostics.last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") +
" device=" + std::to_string(dragging.device) + " trigger-watch=" + (dragging.trigger_observed ? "Y" : "N"); " device=" + std::to_string(dragging.device) + " trigger-watch=" + (dragging.trigger_observed ? "Y" : "N");
} }
void finish_drag(bool released) {
if (released && applied_mount && *applied_mount == mount && mount != Mount::World &&
(size_scale != dragging.scale || canvas_pose != dragging.canvas)) {
const RelativePlacement current{canvas_pose, size_scale};
if (valid_relative_placement(current)) {
saved_placements[static_cast<size_t>(mount)] = current;
persistence.placement_save_failed = persistence.persist_mount &&
!save_relative_placement(default_placement_path(mount), mount, current);
} else persistence.placement_save_failed = true;
}
surface.reset_pointers(); dragging.panel.reset();
}
void update_drag() { void update_drag() {
if (!dragging.panel.active()) return; if (!dragging.panel.active()) return;
const auto source = drag_source(); const auto source = drag_source();
@@ -379,12 +406,14 @@ struct Overlay::Impl {
// Without a readable release watchdog, never keep manipulating after // Without a readable release watchdog, never keep manipulating after
// the pointer leaves our hit region: an outside MouseUp is not assured. // the pointer leaves our hit region: an outside MouseUp is not assured.
const bool lost_unwatched_pointer = !dragging.trigger_observed && !overlay->IsHoverTargetOverlay(handle); const bool lost_unwatched_pointer = !dragging.trigger_observed && !overlay->IsHoverTargetOverlay(handle);
if (!surface.dragging(dragging.cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer || const bool timed_out = std::chrono::steady_clock::now() - dragging.started > std::chrono::seconds(15);
std::chrono::steady_clock::now() - dragging.started > std::chrono::seconds(15)) { if (!surface.dragging(dragging.cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer || timed_out) {
surface.reset_pointers(); dragging.panel.reset(); return; finish_drag((!surface.dragging(dragging.cursor) || trigger_released) &&
shown && focus && bool(source) && !lost_unwatched_pointer && !timed_out);
return;
} }
const auto change = dragging.panel.update(*source); const auto change = dragging.panel.update(*source);
if (!change) { surface.reset_pointers(); dragging.panel.reset(); return; } if (!change) { finish_drag(false); return; }
const float scale = dragging.kind == PanelDragKind::Scale ? std::clamp(dragging.scale * change->factor, .5f, 2.f) : size_scale; const float scale = dragging.kind == PanelDragKind::Scale ? std::clamp(dragging.scale * change->factor, .5f, 2.f) : size_scale;
const auto pose = dragging.kind == PanelDragKind::Grab ? change->pose : const auto pose = dragging.kind == PanelDragKind::Grab ? change->pose :
resized_mount_pose(dragging.canvas, mount_width(*applied_mount, config.wrist) * dragging.scale * W / CW, resized_mount_pose(dragging.canvas, mount_width(*applied_mount, config.wrist) * dragging.scale * W / CW,
+115
View File
@@ -0,0 +1,115 @@
#include "placement.hpp"
#include "panel_drag.hpp"
#include <atomic>
#include <cerrno>
#include <cmath>
#include <fstream>
#include <iomanip>
#include <limits>
#include <locale>
#include <sstream>
#include <string>
#include <fcntl.h>
#include <unistd.h>
namespace frameyap {
namespace {
constexpr std::size_t max_placement_bytes = 512;
std::atomic<unsigned long> next_temp{0};
bool relative_mount(Mount mount) {
return mount == Mount::LeftWrist || mount == Mount::RightWrist || mount == Mount::Head;
}
} // namespace
std::filesystem::path default_placement_path(Mount mount) {
if (!relative_mount(mount)) return {};
const auto preference = default_mount_settings_path();
if (preference.empty()) return {};
return preference.parent_path() / ("placement-" + std::string(mount_name(mount)));
}
bool valid_relative_placement(const RelativePlacement& placement) {
if (!panel_drag_detail::rigid(placement.canvas_pose) ||
!std::isfinite(placement.scale) || placement.scale < .5f || placement.scale > 2.f) return false;
for (int row = 0; row < 3; ++row)
if (std::abs(placement.canvas_pose[row][3]) > 5.f) return false;
return true;
}
std::optional<RelativePlacement> load_relative_placement(const std::filesystem::path& path, Mount mount) {
try {
if (!relative_mount(mount) || path.empty() || std::filesystem::is_symlink(path) ||
!std::filesystem::is_regular_file(path)) return {};
std::ifstream file(path, std::ios::binary);
if (!file) return {};
char bytes[max_placement_bytes + 1];
file.read(bytes, sizeof(bytes));
const auto count = file.gcount();
if (count <= 0 || count > static_cast<std::streamsize>(max_placement_bytes)) return {};
std::istringstream input(std::string(bytes, static_cast<std::size_t>(count)));
input.imbue(std::locale::classic());
std::string magic, name;
RelativePlacement placement{};
if (!(input >> magic >> name >> placement.scale) || magic != "frameyap-placement-v1" ||
name != mount_name(mount)) return {};
for (auto& row : placement.canvas_pose)
for (auto& value : row)
if (!(input >> value)) return {};
input >> std::ws;
if (!input.eof() || !valid_relative_placement(placement)) return {};
return placement;
} catch (...) { return {}; }
}
bool save_relative_placement(const std::filesystem::path& path, Mount mount,
const RelativePlacement& placement) {
if (!relative_mount(mount) || path.empty() || !path.is_absolute() ||
!valid_relative_placement(placement)) return false;
std::filesystem::path temporary;
int fd = -1;
try {
const auto dir = path.parent_path();
if (std::filesystem::is_symlink(dir) || std::filesystem::is_symlink(path) ||
(std::filesystem::exists(path) && !std::filesystem::is_regular_file(path))) return false;
std::ostringstream out;
out.imbue(std::locale::classic());
out << "frameyap-placement-v1 " << mount_name(mount) << ' ' <<
std::setprecision(std::numeric_limits<float>::max_digits10) << placement.scale;
for (const auto& row : placement.canvas_pose)
for (float value : row) out << ' ' << value;
out << '\n';
const auto content = out.str();
if (content.size() > max_placement_bytes) return false;
std::filesystem::create_directories(dir);
if (std::filesystem::is_symlink(dir)) return false;
for (int attempt = 0; attempt < 16; ++attempt) {
temporary = dir / (path.filename().string() + ".tmp." + std::to_string(::getpid()) +
"." + std::to_string(next_temp.fetch_add(1)));
fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
if (fd >= 0) break;
if (errno != EEXIST) return false;
}
if (fd < 0) return false;
std::size_t written = 0;
while (written < content.size()) {
const auto n = ::write(fd, content.data() + written, content.size() - written);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) { ::close(fd); fd = -1; std::filesystem::remove(temporary); return false; }
written += static_cast<std::size_t>(n);
}
const bool synced = ::fsync(fd) == 0;
const bool closed = ::close(fd) == 0;
fd = -1;
if (!synced || !closed) { std::filesystem::remove(temporary); return false; }
std::filesystem::rename(temporary, path);
return true;
} catch (...) {
if (fd >= 0) ::close(fd);
std::error_code ignored;
if (!temporary.empty()) std::filesystem::remove(temporary, ignored);
return false;
}
}
} // namespace frameyap
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "mount.hpp"
#include <filesystem>
#include <optional>
namespace frameyap {
// The exact OpenVR canvas transform in the selected tracked device's local
// coordinate frame, including the transparent grab/scale margins.
struct RelativePlacement {
Matrix34 canvas_pose;
float scale = 1.f;
};
// World-space placement is deliberately not persisted across tracking origins.
std::filesystem::path default_placement_path(Mount mount);
bool valid_relative_placement(const RelativePlacement& placement);
std::optional<RelativePlacement> load_relative_placement(const std::filesystem::path& path, Mount mount);
// Atomic owner-only write; does not follow a symlink at path or its parent.
bool save_relative_placement(const std::filesystem::path& path, Mount mount,
const RelativePlacement& placement);
} // namespace frameyap
+100
View File
@@ -0,0 +1,100 @@
#include "placement.hpp"
#include "panel_drag.hpp"
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <limits>
#include <string>
#include <unistd.h>
using namespace frameyap;
namespace fs = std::filesystem;
#define CHECK(x) do { if (!(x)) { std::cerr << "line " << __LINE__ << ": " #x "\n"; std::exit(1); } } while (0)
namespace {
constexpr Matrix34 identity{{{{1.f, 0.f, 0.f, 0.f}},
{{0.f, 1.f, 0.f, 0.f}},
{{0.f, 0.f, 1.f, 0.f}}}};
void put(const fs::path& path, const std::string& bytes) {
std::ofstream file(path, std::ios::binary | std::ios::trunc);
CHECK(bool(file)); file << bytes; CHECK(bool(file));
}
std::string get(const fs::path& path) {
std::ifstream file(path, std::ios::binary);
return {std::istreambuf_iterator<char>(file), {}};
}
bool near(float a, float b) { return std::abs(a - b) < 1e-6f; }
} // namespace
int main() {
std::string pattern = (fs::temp_directory_path() / "frameyap-placement-test-XXXXXX").string();
CHECK(::mkdtemp(pattern.data()) != nullptr);
const fs::path dir = pattern;
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
const auto left = default_placement_path(Mount::LeftWrist);
const auto right = default_placement_path(Mount::RightWrist);
const auto head = default_placement_path(Mount::Head);
CHECK(left == dir / "frameyap/placement-left-wrist");
CHECK(right == dir / "frameyap/placement-right-wrist");
CHECK(head == dir / "frameyap/placement-head");
CHECK(default_placement_path(Mount::World).empty());
CHECK(!load_relative_placement(left, Mount::LeftWrist));
RelativePlacement placed{relative_mount_pose(Mount::LeftWrist), 1.37f};
// A freely rotated canvas with both depth and lateral offsets, rather than
// only the editable roll angle in config.json.
const auto turn = relative_mount_pose(Mount::RightWrist);
placed.canvas_pose = compose_pose(placed.canvas_pose, turn);
placed.canvas_pose[0][3] += .31f;
placed.canvas_pose[1][3] -= .14f;
placed.canvas_pose[2][3] += .22f;
CHECK(valid_relative_placement(placed));
CHECK(save_relative_placement(left, Mount::LeftWrist, placed));
CHECK((fs::status(left).permissions() & fs::perms::group_read) == fs::perms::none);
auto restored = load_relative_placement(left, Mount::LeftWrist);
CHECK(restored && near(restored->scale, placed.scale));
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c)
CHECK(near(restored->canvas_pose[r][c], placed.canvas_pose[r][c]));
CHECK(!load_relative_placement(left, Mount::RightWrist));
CHECK(!load_relative_placement(right, Mount::RightWrist));
CHECK(save_relative_placement(right, Mount::RightWrist, RelativePlacement{relative_mount_pose(Mount::RightWrist), .7f}));
CHECK(load_relative_placement(right, Mount::RightWrist)->scale == .7f);
CHECK(load_relative_placement(left, Mount::LeftWrist)->scale == placed.scale);
CHECK(save_relative_placement(head, Mount::Head, RelativePlacement{relative_mount_pose(Mount::Head), 2.f}));
CHECK(load_relative_placement(head, Mount::Head)->scale == 2.f);
CHECK(!save_relative_placement(left, Mount::World, placed));
const auto good = get(left);
for (const auto& bad : {std::string("frameyap-placement-v2 left-wrist 1\n"),
good + "junk", std::string(513, 'x')}) {
put(left, bad);
CHECK(!load_relative_placement(left, Mount::LeftWrist));
}
put(left, good);
auto invalid = placed;
invalid.scale = 0.f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.scale = std::numeric_limits<float>::quiet_NaN();
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.canvas_pose[0][0] *= 1.1f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.canvas_pose[2][3] = 6.f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
CHECK(get(left) == good); // invalid writes cannot corrupt a prior placement
const auto linked = dir / "linked-placement";
fs::create_symlink(left, linked);
CHECK(!load_relative_placement(linked, Mount::LeftWrist));
CHECK(!save_relative_placement(linked, Mount::LeftWrist, placed));
CHECK(get(left) == good);
fs::create_directory(dir / "directory-placement");
CHECK(!save_relative_placement(dir / "directory-placement", Mount::Head, placed));
fs::create_directory_symlink(left.parent_path(), dir / "linked-directory");
CHECK(!save_relative_placement(dir / "linked-directory/placement-left-wrist", Mount::LeftWrist, placed));
fs::remove_all(dir);
std::cout << "relative placement persistence checks passed\n";
}