Skip to content

Keep the output switch working, and split game from chat - #2

Merged
cunningorb merged 5 commits into
mainfrom
fix/game-chat-slider-direction
Aug 19, 2026
Merged

cunningorb merged 5 commits into
mainfrom
fix/game-chat-slider-direction

Conversation

@cunningorb

Copy link
Copy Markdown
Owner

Five commits, all on the tray. The last one is the substantial change; the four
before it are the alpha.2 work this branch already carried.

The output switch had stopped working

The record of where the sound came from doubles as the "a move back is owed"
flag, and its mere presence was read as "already switched". Endpoint ids are
durable but not permanent — a reinstalled driver or a re-enumerated dongle
retires one — so the moment that record named an endpoint the machine no longer
had, it could never be discharged (the endpoint was gone) and never be replaced
(its presence blocked the switch). Every later power-off and power-on did
nothing at all, for good, until somebody edited the registry.

A debt now counts only while the machine still has the endpoint it names, and
one that never reappears is given up on after about ten seconds rather than kept
forever. The rule moved into headset-tray/src/output.rs, which touches
nothing: it is handed facts and answers with an Action. The alternative is a
rule whose only test is powering a headset off and watching a machine's audio
move.

Split game and chat

Windows keeps three defaults — console, multimedia, communications — and the
headset presents two endpoints, which is the entire reason a chat channel
exists. A restore can only name the one endpoint it moved away from, and writing
that into all three roles overwrote a communications default pointed at the chat
channel, so the voices came out of the game channel.

So: a new Split game and chat toggle, off by default, with Game channel
and Chat channel pickers beneath it. Turn it on, pick both, and a headset
coming back gets ordinary playback on the first and calls on the second.

  • Opt-in. A headset with two channels is not a reason to assume somebody
    wants their calls moved.
  • Independent of the move-when-off switch. Wanting calls on their own
    channel and wanting your sound moved when the headset dies are different
    wishes.
  • It beats a plain restore, deliberately. Restoring is the thing that breaks
    it, and the sound lands on the headset either way, so the debt is discharged
    rather than left to drag the sound back off. It also overrides "put it back
    where it came from": a power-on always lands on the chosen game channel, even
    if you had moved to speakers by hand. That is the trade for roles that are
    deterministic rather than inferred.
  • Both rules share one retry budget, because a wireless link comes up seconds
    before its audio endpoints do. "The endpoint I want is not there" is the
    ordinary first answer, not a failure.

The three device choices are one Slot enum rather than three parallel settings
and three picker views — they ask the same question and differ only in what the
answer is for — so there is one store, one picker, and one place a fourth would
go. The two channel rows appear only once the split is on, unlike "Play
through", because the toggle directly above them is what reveals them.

Failures say so

A balloon the first time a problem appears, and the problem persisted so the
settings row can still explain itself hours later. Stored by name rather than by
its wording, so a complaint lands on the row of the feature that earned it and
re-phrasing a message cannot move it; a record written by an older build reads
as no complaint. Acting on a settings change immediately is held back in exactly
one case — picking the game channel and being told off for not having picked the
chat channel is a complaint about the step you are visibly in the middle of.

headset-tray.exe --explain-output prints what would happen for a powered-off
and a powered-on headset, and why, without moving anything.

Verification

125 tests pass, clippy is clean at -D warnings, the settings panel renders
without the title collision its neighbouring comment warns about, and a release
build installs and runs.

Not verified: the power-off and power-on round trip with the split on, which
needs the power button held. That is being tested by hand now.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AVDi4cWtmLALKXVmy1pxo5

micmancg and others added 5 commits August 4, 2026 23:12
The panel labelled low values CHAT and high values GAME, so dragging
toward GAME moved the mix toward chat. Raw 0x00 is full game and 0x14
full chat, which the captures never established -- they record the range
and the clamps, not which end is which -- so it went in backwards and
stayed that way until it was heard.

active_end_label needs no change: it returns a positional index into the
label array, so value 0 still highlights the left label, now GAME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Holding the power button is what turns the headset off, and there is no
power-button event in the protocol to hang this on -- so the trigger is
parameter 0x20, the link state the dongle already pushes. The link carries
no reason, so an auto-sleep or walking out of range is indistinguishable
from a deliberate power-off; the two-second debounce is what stops a
momentary dropout bouncing the whole machine's audio to the speakers and
straight back.

The way back is a persisted debt rather than a remembered device. The
endpoint the sound was moved *from* is written to the registry, and its
presence is the state: it means a move back is owed. Holding it in memory
would strand somebody on the speakers if the tray were closed, updated, or
killed while the headset was off. It is recorded only after a switch
actually succeeds, so a failed switch cannot cause a later unearned one.
Remembering the endpoint rather than "the headset" also matters because
the dongle presents two render endpoints, Game and Chat, and only the user
knows which one they were on.

Setting the default output has no documented Windows API at all --
IMMDeviceEnumerator reads the default and has never had a setter. This is
the workspace's one undocumented interface, confined to its own module and
recorded in docs/undocumented-apis.md with the reasoning for why it is a
different category from the HID rules in CONTRIBUTING.md: a local Windows
setting the user can change back by hand, not a guessed byte sent to
firmware nobody here can inspect. The vtable is declared only as far as
the one method called, with the ten preceding slots deliberately unnamed
and untyped; a unit test pins the offset, because a stray field there
would silently call a different method with these arguments.

The picker is an in-panel view rather than a native dropdown: the panel is
a layered window that hides on losing activation, so a menu owned by
another window would close the panel the menu belongs to.

Verified on the machine: the undocumented interface accepts a no-op
SetDefaultEndpoint against the live audio stack (an ignored test, one role
only so nothing moves), and the picker renders real device names. A layout
bug was caught by rendering rather than by a test -- the settings title
wrapped and collided with its own description, the exact hazard the
neighbouring comment warns about. Not verified: the full power-off and
power-on round trip, which needs the button pressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79
Building this workspace needs MinGW-w64 on PATH and a GNU *default host*,
and neither is discoverable from the error it produces.

`rustup target add x86_64-pc-windows-gnu` is not enough on its own: build
scripts and proc macros compile for the host triple, so a stock MSVC-host
rustup dies with `linker 'link.exe' not found` before reaching any of this
project's code. That error names Visual Studio, which is the wrong fix for
a workspace that pins the GNU target deliberately.

CI never hits either problem, which is why neither was written down:
GitHub's Windows runners ship MinGW-w64 already, and the workflow passes
--target explicitly while running on a host that has both linkers.

Found by setting the project up from scratch on a machine that had only
rustup installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79
The workspace manifest is the single source of truth for the version: Inno
takes the same value on its command line via build-installer.ps1, and
Cargo.lock follows from the build.

Also records the game/chat slider fix (5938736) under Fixed, which that
commit never added to the changelog. It is the user-visible reason this
build exists, so release notes that omitted it would be a poor record.

docs/history/ still names 0.1.0-alpha.1 in places and is deliberately left
alone -- it is a record of what was known at the time, not documentation
that tracks the current version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79
Two changes to the same feature, which is why they arrive together: the
switch had stopped working at all, and what it does when it works was
wrong for a headset that presents two channels.

The switch died silently. The record of where the sound came from doubles
as the "a move back is owed" flag, and its mere presence was read as
"already switched". Endpoint ids are durable but not permanent -- a
reinstalled driver or a re-enumerated dongle retires one -- so the moment
that record named an endpoint the machine no longer had, it could never be
discharged (the endpoint was gone) and never be replaced (its presence
blocked the switch). Every later power-off and power-on did nothing, for
good, until somebody edited the registry. A debt now counts only while the
machine still has the endpoint it names, and one that never reappears is
given up on rather than kept forever. The rule moved into its own module,
handed facts and answering with an Action, because the alternative is a
rule whose only test is powering a headset off and watching a machine's
audio move.

Restoring the sound was actively destructive on this hardware. Windows
keeps three defaults -- console, multimedia, communications -- and the
headset presents two endpoints, which is the entire reason a chat channel
exists. A restore can only name the one endpoint it moved away from, and
writing that into all three roles overwrote a communications default the
user had pointed at the chat channel, so the voices came out of the game
channel. Hence "Split game and chat": pick a Game channel and a Chat
channel, and a headset coming back gets ordinary playback on the first and
calls on the second.

It is opt-in and off by default. A headset with two channels is not a
reason to assume somebody wants their calls moved, and the setting is
independent of the move-when-off switch because wanting calls on their own
channel and wanting your sound moved when the headset dies are different
wishes. When it is on it beats a plain restore, deliberately: restoring is
the thing that breaks it, and the sound lands on the headset either way, so
the debt is discharged rather than left to drag the sound back off. It also
overrides "put it back where it came from" -- a power-on always lands on
the chosen game channel, even if the user had moved to speakers by hand.
That is the trade for roles that are deterministic rather than inferred.

Both rules share one retry budget, because a wireless link comes up seconds
before its audio endpoints do: "the endpoint I want is not there" is the
ordinary first answer, not a failure.

The three device choices are one Slot enum rather than three parallel
settings and three picker views -- they ask the same question and differ
only in what the answer is for -- so there is one store, one picker, and
one place a fourth would go. The two channel rows appear only once the
split is on, unlike "Play through", because the toggle directly above them
is what reveals them and three device rows with two inert is a settings
page that looks like it is asking for more than it is.

Failures are surfaced rather than logged into a subscriber nobody enabled:
a balloon the first time, and the problem persisted so the settings row can
still explain itself hours later. Stored by name rather than by its
wording, so the panel can put a complaint on the row of the feature that
earned it and re-phrasing a message cannot move it. A record written by an
older build reads as no complaint. Acting on a settings change immediately
is held back in exactly one case: picking the game channel and being told
off for not having picked the chat channel is a complaint about the step
the user is visibly in the middle of.

Also adds --explain-output, which prints what would happen for a
powered-off and a powered-on headset, and why, without moving anything. The
switch acts on a device event nobody can stage on demand and on facts the
user cannot see; before this, telling those candidates apart meant reading
the registry by hand.

Verified: 125 tests pass, clippy clean at -D warnings, the settings panel
renders without the title collision its neighbouring comment warns about,
and a release build installs and runs. Not verified: the power-off and
power-on round trip with the split on, which needs the button held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVDi4cWtmLALKXVmy1pxo5
@cunningorb
cunningorb merged commit 394f836 into main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant