Add opt-in experimental overlay input priority

This commit is contained in:
baketnk committed 2026-09-24 14:31:00 -04:00
1 parent 46b3c37a8a
commit 69f5de5a09
14 files changed
+131 -49

No files matched your search

+1
View File
@@ -117,6 +117,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_executable(frameyap_config_test tests/config_test.cpp)
target_link_libraries(frameyap_config_test PRIVATE frameyap_config)
target_compile_options(frameyap_config_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.config COMMAND frameyap_config_test "${CMAKE_CURRENT_SOURCE_DIR}/assets")
add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
+5
View File
@@ -34,6 +34,11 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
panel colors, a font path and Frame controller button mappings; missing fonts
fall back to bundled Inconsolata. The installer creates/checks this file and
backs it up before repairs. See [overlay configuration](docs/overlay.md#user-theme-and-controller-configuration).
- **Experimental input priority:** set `"input_priority": "experimental"` in
that config and enable SteamVR's Developer option **Enable global input from
overlays**. FrameYap then requests priority for its bound controller sources.
This may consume controls used by games or the dashboard; coexistence on Frame
is under test. The default is `"normal"`; restart FrameYap after changing it.
- **Review-first:** focus your destination, then press Insert. No automatic insertion
or submission. Maximum clip 20 seconds; accidental taps under 200 ms are discarded.
- While the native app is Ready, it keeps the mic device open and discards idle
+1
View File
@@ -1,5 +1,6 @@
{
"font": "",
"input_priority": "normal",
"theme": {
"background": "#0c101b",
"card": "#141c2b",
+5 -3
View File
@@ -112,9 +112,11 @@ silently takes a game's button away. Check action activity and neutral rearm.
OpenVR 2.15.6 documents experimental overlay action-set priorities
`0x01000000..0x01FFFFFF`, gated by SteamVR's **Experimental overlay input overrides**
setting. This can selectively override scene input, but is not guaranteed enabled
or usable on this Frame. Do not toggle it automatically. Begin with overlay mic
controls; validate a global PTT binding separately with a scene active, dashboard
setting. FrameYap now requests the minimum experimental priority when its config
has `"input_priority": "experimental"`; the default is `"normal"` (priority zero).
This can selectively override scene input, but delivered input and dashboard
coexistence still need testing on Frame. FrameYap only reads the SteamVR permission
setting and never toggles it automatically. Validate bindings with a scene active, dashboard
open/closed, lost tracking, and reconnection. Overlay interactivity/input ownership
is distinct from OS keyboard focus.
@@ -51,3 +51,11 @@ not establish SteamVR texture acceptance, orientation, click behavior or a
flicker fix. No microphone, inference or text/Enter delivery was exercised.
The next human headset check should compare both static action clicks and
content-changing tabs using the installed Vulkan build's `--check-controls`.
## Subsequent wearer report — 2026-09-24
After testing the installed Vulkan build, the user confirmed that the flicker
is fixed. They separately reported controller shortcuts becoming unavailable
in system laser/dashboard interaction states while pointer clicks work. This
is wearer confirmation of the visual fix and a distinct input-routing issue;
it does not establish microphone, transcription or text-delivery acceptance.
+38 -6
View File
@@ -73,6 +73,7 @@ installer creates one with defaults on first install. Copy the shipped
```json
{
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"input_priority": "normal",
"theme": {
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
@@ -101,8 +102,36 @@ the exact prior bytes under `config.json.backup-*` before a repair and refuses
symlink/oversized config paths; valid customizations remain intact. The installed
launcher no longer pins `--font`, so this selection takes effect. Direct native
launches with bad JSON, colors or button mappings fail startup rather than
silently changing input behavior. This does not enable experimental SteamVR
action overrides or prove delivery in games.
silently changing input behavior.
### Experimental controller input priority
There are two independent gates:
1. SteamVR's Developer setting **Enable global input from overlays** (called
**Experimental overlay input overrides** in the SDK documentation) permits
global action priority. FrameYap reads it and never changes it.
2. FrameYap config `"input_priority": "experimental"` requests
`k_nActionSetOverlayGlobalPriorityMin` (`0x01000000`) for its existing action
set. `"normal"` or an omitted field requests priority zero. Restart FrameYap
after changing the config. Invalid values fail native startup; the installer
repairs them to normal and backs up the prior bytes.
This applies to all controller sources bound to FrameYap actions, including
custom SteamVR bindings. It does not replace the action manifest or change the
physical button mappings. Bound sources can take input away from games or the
dashboard. The request stays the same with the dashboard open/closed and Lasers
anytime on/off so the experiment can compare those modes. Successful API calls
do not prove that controller actions arrive or that dashboard interaction works.
Native startup logs the requested priority and SteamVR permission separately.
The no-audio/no-delivery `--check-controls` probe also logs dashboard state, the
Lasers anytime flag, `IsInputAvailable`, panel visibility/focus, and activity,
press state and pose/role acceptance for all six actions. The laser flag is our
request, not a detector for every system laser. Compare the same bindings in
each dashboard/laser state; check pointer clicks and press/release through mode
transitions too. Return `input_priority` to `normal` and relaunch to end the
FrameYap experiment.
### Placement settings
@@ -139,7 +168,10 @@ missing, symlinked or invalid files mean off. A failed save keeps the new
choice only for the running session and shows a warning. To turn it back on
after disabling it with the dashboard closed, open the dashboard to use its
laser on the Settings button. Controls-only checks can toggle it temporarily
but do not save the preference. This has not yet been accepted in a headset.
but do not save the preference. The wearer reports that pointer clicks work,
while normal-priority controller actions become unavailable in system laser
mode, including when this preference is enabled. Experimental-priority
coexistence remains unverified.
### Hardware-free UI checks
@@ -199,9 +231,9 @@ action explicitly begins on down and ends on up. On tracking-pose invalidity,
action inactivity or overlay focus loss, a held capture emits Cancel, and
reconnection requires a neutral observation before any new press. PTT and left
Enter require an enabled panel; clickable Record remains available for retry
after an error and Cancel is always available. The action set has normal priority: global input
while a scene is active is not guaranteed, and experimental overlay overrides
are not switched on automatically.
after an error and Cancel is always available. The action set defaults to normal
priority; the experimental config request is described above. Neither priority
guarantees delivery while a game or dashboard owns input.
The UI cannot itself guarantee a capture started when a BeginRecord action
arrives: the owning runtime checks worker readiness. `Cancel` invalidates
+3
View File
@@ -33,6 +33,7 @@ VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = {
"font": "",
"input_priority": "normal",
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
@@ -87,6 +88,8 @@ def normalized_config(data):
if not isinstance(data, dict):
data = {}
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
priority = data.get("input_priority", "normal")
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
for section in ("theme", "buttons"):
source = data.get(section)
source = source if isinstance(source, dict) else {}
+3
View File
@@ -22,6 +22,7 @@ VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = {
"font": "",
"input_priority": "normal",
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
@@ -76,6 +77,8 @@ def normalized_config(data):
if not isinstance(data, dict):
data = {}
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
priority = data.get("input_priority", "normal")
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
for section in ("theme", "buttons"):
source = data.get(section)
source = source if isinstance(source, dict) else {}
+4
View File
@@ -173,6 +173,10 @@ Config load_config(const std::filesystem::path& path) {
if (key == "font") {
if (!value.is_string) throw std::runtime_error("Config font must be a path string");
config.font = value.value;
} else if (key == "input_priority") {
if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
throw std::runtime_error("Config input_priority must be normal or experimental");
config.experimental_input_priority = value.value == "experimental";
} else if (key == "theme") {
if (!value.is_object) throw std::runtime_error("Config theme must be an object");
for (const auto& [name, v] : value.object) {
+2
View File
@@ -15,6 +15,8 @@ struct Theme {
struct Config {
Theme theme;
std::string font; // absolute TTF/OTF path; empty uses the bundled face
// Requests OpenVR's experimental global action priority; SteamVR must allow it too.
bool experimental_input_priority = false;
// OpenVR action name -> physical Frame controller input path; empty disables it.
std::map<std::string, std::string> buttons;
};
+2
View File
@@ -27,6 +27,8 @@ void help() {
"Theme, font and Frame button mappings: $XDG_CONFIG_HOME/frameyap/config.json\n"
"(or ~/.config/frameyap/config.json). CLI --font overrides config; missing fonts\n"
"fall back to bundled Inconsolata, then a system DejaVu face.\n"
"Config input_priority: normal (default) or experimental; also requires\n"
"SteamVR Developer setting Enable global input from overlays.\n"
"Right X: hold to speak, release to review (default Frame binding).\n"
"Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Left grip: double-tap for explicit Enter when active.\n"
+41 -39
View File
@@ -34,6 +34,7 @@ void configure_registry() {
::setenv("VR_PATHREG_OVERRIDE", registry.c_str(), 0);
}
constexpr int W = PanelSurface::width, H = PanelSurface::height;
constexpr std::array<const char*, 6> action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}};
void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) {
if (err != vr::VROverlayError_None)
throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err));
@@ -136,15 +137,13 @@ struct Overlay::Impl {
throw std::runtime_error("Could not set OpenVR action manifest path");
if (input->GetActionSetHandle("/actions/frameyap", &action_set) != vr::VRInputError_None)
throw std::runtime_error("Could not find FrameYap action set");
constexpr std::array<const char*, 6> names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}};
for (size_t i = 0; i < names.size(); ++i)
if (input->GetActionHandle((std::string("/actions/frameyap/in/") + names[i]).c_str(), &actions[i]) != vr::VRInputError_None)
throw std::runtime_error(std::string("Could not find action ") + names[i]);
for (size_t i = 0; i < action_names.size(); ++i)
if (input->GetActionHandle((std::string("/actions/frameyap/in/") + action_names[i]).c_str(), &actions[i]) != vr::VRInputError_None)
throw std::runtime_error(std::string("Could not find action ") + action_names[i]);
overlay_check(overlay->CreateOverlay("local.frameyap.overlay.panel", "FrameYap", &handle), overlay, "CreateOverlay");
overlay_check(overlay->SetOverlayWidthInMeters(handle, 0.85f), overlay, "SetOverlayWidthInMeters");
overlay_check(overlay->SetOverlayInputMethod(handle, vr::VROverlayInputMethod_Mouse), overlay, "SetOverlayInputMethod");
// Normal priority actions stay unchanged. This separate, explicit
// preference requests system-wide laser mouse mode only while the
// This separate preference requests system-wide laser mouse mode only while the
// panel is visible; it may affect interaction with a running game.
if (lasers_anytime && overlay->SetOverlayFlag(handle, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true) != vr::VROverlayError_None) {
lasers_anytime = false;
@@ -157,6 +156,7 @@ struct Overlay::Impl {
system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size()));
place();
draw(Panel{"Disabled", "", "Record to start local worker", false, false});
std::cout << input_mode_status() << std::endl;
} catch (...) { cleanup(); throw; }
}
~Impl() { cleanup(); }
@@ -250,6 +250,28 @@ struct Overlay::Impl {
for (size_t i = 1; i < edges.size(); ++i) edges[i].reset();
surface.reset_pointers();
}
int32_t action_priority() const {
return config.experimental_input_priority ? vr::k_nActionSetOverlayGlobalPriorityMin : 0;
}
std::string input_mode_status() const {
// Read the runtime permission separately: requesting a priority does not
// enable SteamVR's global setting or prove delivery of controller input.
vr::EVRSettingsError settings_error = vr::VRSettingsError_None;
auto* settings = vr::VRSettings();
const bool allowed = settings && settings->GetBool(vr::k_pch_SteamVR_Section,
vr::k_pch_SteamVR_AllowGlobalActionSetPriority, &settings_error);
const char* permission = !settings || settings_error != vr::VRSettingsError_None ? "unavailable" :
allowed ? "enabled" : "disabled";
bool laser_flag = false;
const auto laser_error = overlay->GetOverlayFlag(handle,
vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, &laser_flag);
return std::string("Input priority-request=") + (config.experimental_input_priority ? "experimental" : "normal") +
" (" + std::to_string(action_priority()) + ") SteamVR-global-input=" + permission +
"\nMode dashboard=" + (overlay->IsDashboardVisible() ? "Y" : "N") +
" lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a") +
" system-input-available=" + (system->IsInputAvailable() ? "Y" : "N") +
" panel-shown=" + (shown ? "Y" : "N") + " focus-gate=" + (focus ? "Y" : "N");
}
// Bound actions are accepted only with a connected tracked source. A held input
// following loss of activity must return to neutral before generating an edge.
std::pair<bool, bool> digital(size_t index) {
@@ -350,7 +372,10 @@ struct Overlay::Impl {
vr::VRActiveActionSet_t set{};
set.ulActionSet = action_set;
set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle;
set.nPriority = 0; // normal priority; no experimental scene-input overrides
// Explicit opt-in affects only sources bound to our existing action set.
// Keep requesting it across dashboard/laser states: those are what the
// experiment compares. SteamVR's separate permission gate is never changed here.
set.nPriority = action_priority();
action_update_error = input->UpdateActionState(&set, sizeof(set), 1);
if (action_update_error != vr::VRInputError_None) {
reset_input(result); return result;
@@ -392,42 +417,19 @@ Overlay::~Overlay() = default;
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
std::string Overlay::controls_status() {
// Diagnostic only: distinguish SteamVR binding/activity from our stricter
// pose/role gate. Raw legacy state is read-only and may be unavailable.
bool laser_flag = false;
const auto laser_error = impl_->overlay->GetOverlayFlag(impl_->handle,
vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, &laser_flag);
std::string result = "SteamVR update=" + std::to_string(int(impl_->action_update_error)) +
" dashboard=" + (impl_->overlay->IsDashboardVisible() ? "Y" : "N") +
" lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a");
for (size_t i = 0; i < 2; ++i) {
const auto role = i == 0 ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand;
const auto device = impl_->system->GetTrackedDeviceIndexForControllerRole(role);
const bool tracked = device < impl_->poses.size() && impl_->poses[device].bPoseIsValid &&
impl_->system->IsTrackedDeviceConnected(device);
// Compare the same actions across modes, before and after our pose/role gate.
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
std::string result = impl_->input_mode_status() +
" update=" + std::to_string(int(impl_->action_update_error));
for (size_t i = 0; i < action_names.size(); ++i) {
vr::InputDigitalActionData_t data{};
const auto error = impl_->input->GetDigitalActionData(impl_->actions[i], &data, sizeof(data), vr::k_ulInvalidInputValueHandle);
vr::InputOriginInfo_t origin{};
const bool origin_ok = error == vr::VRInputError_None && data.activeOrigin != vr::k_ulInvalidInputValueHandle &&
impl_->input->GetOriginTrackedDeviceInfo(data.activeOrigin, &origin, sizeof(origin)) == vr::VRInputError_None &&
origin.trackedDeviceIndex == device;
vr::VRControllerState_t raw{};
const bool raw_ok = device < impl_->poses.size() && impl_->system->GetControllerState(device, &raw, sizeof(raw));
result += std::string(i == 0 ? "\nLeft:" : "\nRight:") +
const auto [accepted, down] = impl_->digital(i);
result += std::string("\nAction ") + action_names[i] +
" err=" + std::to_string(int(error)) + " active=" + (data.bActive ? "Y" : "N") +
" down=" + (data.bState ? "Y" : "N") + " pose=" + (tracked ? "Y" : "N") +
" origin=" + (origin_ok ? "Y" : "N") +
" raw=" + (raw_ok ? ((raw.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_Grip)) ? "down" : "up") : "n/a");
" down=" + (data.bState ? "Y" : "N") +
" pose-role-accepted=" + (accepted ? "Y" : "N") + " gated-down=" + (down ? "Y" : "N");
}
// The default Frame binding maps right X to the named hold-to-talk action.
// Report the action's own activity separately from the stricter pose gate;
// remapped bindings may intentionally have a different origin.
vr::InputDigitalActionData_t ptt{};
const auto ptt_error = impl_->input->GetDigitalActionData(impl_->actions[2], &ptt, sizeof(ptt), vr::k_ulInvalidInputValueHandle);
const auto [accepted, down] = impl_->digital(2);
result += "\nPTT (default right X): err=" + std::to_string(int(ptt_error)) +
" active=" + (ptt.bActive ? "Y" : "N") + " down=" + (ptt.bState ? "Y" : "N") +
" accepted=" + (accepted ? "Y" : "N") + " gated-down=" + (down ? "Y" : "N");
return result;
}
std::string Overlay::pointer_status() const {
+14
View File
@@ -27,10 +27,24 @@ int main(int argc, char** argv) {
assert(default_config_path().empty());
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
assert(load_config(path).buttons.empty());
assert(!load_config(path).experimental_input_priority);
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
assert(example.font.empty());
assert(!example.experimental_input_priority);
std::filesystem::create_directories(path.parent_path());
put(path, R"({"input_priority":"experimental"})");
auto experimental = load_config(path);
assert(experimental.experimental_input_priority);
// A priority request must preserve the user's existing action manifest/bindings.
assert(action_manifest(argv[1], experimental) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
put(path, R"({"input_priority":"normal"})");
assert(!load_config(path).experimental_input_priority);
for (const auto* invalid : {R"({"input_priority":true})", R"({"input_priority":16777216})",
R"({"input_priority":"highest"})", R"({"input_priority":null})"}) {
put(path, invalid);
fails([&] { load_config(path); });
}
put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})");
auto config = load_config(path);
assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255}));
+4 -1
View File
@@ -124,21 +124,24 @@ class InstallTests(unittest.TestCase):
self.assertEqual(fixed["buttons"]["ptt"], "/user/hand/left/input/y")
self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"])
self.assertEqual(fixed["buttons"]["cancel"], "")
self.assertEqual(fixed["input_priority"], "normal")
backups = list(config.parent.glob("config.json.backup-*"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), original)
fixed["input_priority"] = "experimental"
compact = json.dumps(fixed, separators=(",", ":")).encode()
config.write_bytes(compact)
self.install("v1", archive, digest)
self.assertEqual(config.read_bytes(), compact)
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
original = b'{"font":"/system/face.ttf","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
original = b'{"font":"/system/face.ttf","input_priority":"highest","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
config.write_bytes(original)
self.install("v1", archive, digest)
fixed = json.loads(config.read_text())
self.assertEqual(fixed["font"], "/system/face.ttf")
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
self.assertEqual(fixed["input_priority"], "normal")
self.assertNotIn("old_option", fixed)
self.assertEqual(sorted(p.read_bytes() for p in config.parent.glob("config.json.backup-*")), sorted([backups[0].read_bytes(), original]))
original = b'{"font":"one","font":"two"'