135 Commits
Author SHA1 Message Date
Dylan DeverillandClaude Sonnet 5.5 3948bd9ada Let Print Screen and the Menu key be bound as key outputs (#123)
Fixes #119. Print Screen had no entry in the key map and Windows only reports it on keyup; the Menu key was missing from the map too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 13:39:09 -07:00
Dylan DeverillandClaude Sonnet 5.5 1de3f7a40e Bump version to 1.25
The stick deadzone fix (#117) is confirmed by the reporter and ships as 1.25
rather than a patch release, superseding the 1.24.1 bump before it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:44:20 -07:00
Dylan DeverillandClaude Sonnet 5.5 39faaaaa03 Bump version to 1.24.1
The stick deadzone fix (#117) ships as a patch release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:44:20 -07:00
Dylan DeverillandClaude Sonnet 5.5 1409bb131e Add a blanket stick deadzone (#117)
A hand-off stick does not read exactly zero, and the virtual pad passed
whatever it read straight through. XInput games mostly hide that behind a
deadzone of their own; an emulator waiting for "press the input to bind", or
a gamepad tester, takes the pad's word for it and sees the sticks moving all
the time, so a bind prompt latches onto them before a button can be pressed.

StickRestProbe measured what a deadzone has to clear, hands off, on two
units. The reading is a steady offset from zero rather than noise: jitter
about the mean is under 0.5%, while the offset reaches 5.9% of the range on
one stick and wanders by over a point from one settle to the next. So the
deadzone is 8%, about 1.4 times the worst resting reading, rather than a
number that clears the measurement by a hair.

Radial rather than per-axis, so a direction is left alone, and rescaled so
the output does not jump the moment the stick leaves the deadzone. The scale
is applied per axis and each axis clamped on its own, so a diagonal pushed
into a stick's corner still reaches full on both axes as it did before.

Applied in one place, ReadStick, which both the Xbox and DualShock 4 reports
read their sticks through, so the two cannot disagree about what rest is.
Fixed rather than a setting: a stick does not need tuning per game the way a
trigger threshold does.

Also invert the DualShock 4 Y axis before quantising it rather than after.
255 - u8 turned a centred 128 into 127, so a stick at rest sat one step off
on Y and nowhere near it on X, which a gamepad tester showed as a constant
nonzero value.

StickDeadzoneProbe checks the maths without a controller, including that the
worst measured resting position reads as nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:44:20 -07:00
Dylan DeverillandClaude Sonnet 5.5 0c0e3ea2b2 Drop the legacy shadow from the customize window
The window class set CS_DROPSHADOW on top of the DWM shadow WS_THICKFRAME
already provides. The legacy shadow is sized to the whole window rect,
including the invisible resize frame, and offset right and down, so it left
a glassy band along the right and bottom edges only.

Measured on a recreation of the window's class and style flags over a solid
backdrop: with the flag the edge steps from 223 to 125 in one pixel at the
window rect; without it the falloff is smooth. Window rect, DWM extended
bounds and client rect are identical either way, so none of the DPI sizing
or hit-testing is affected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:28:00 -07:00
Dylan DeverillandClaude Sonnet 5.5 8e87e7a930 Turn the controller off with Steam + Y (#106)
Steam reserves Steam + Y to power a wireless controller down. Do the same:
the firmware accepts feature command 0x9F with the payload "off!", confirmed
on a puck slot with PowerOffProbe, so SteamController::PowerOff() sends it
and the read loop fires it on the press of the chord.

On by default with no setting. The chord is consumed, so the game never sees
the Guide press or the Y, and the alerts for the silence and disconnect that
follow are suppressed because the user asked for them. A controller still
reporting three seconds later ignored the command (or woke straight back up)
and gets its input back. Skipped on a cable: the pad is powered by it and
would only ignore the command.

PowerOffProbe is the tool that found the command: it tries the candidate
payloads and reports whether the pad then goes quiet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 22:57:28 -07:00
Dylan DeverillandClaude Sonnet 5.5 fea79a946e Add the trigger probes behind dual-stage triggers (#75)
TriggerProbe measures what the triggers actually report and which haptic
reads as a click, which is how the 95/80 defaults and the pad-click-plus-
grip-buzz feedback were chosen rather than guessed. A guided calibration
records rest noise, full travel, the speed of a fast pull, the wobble of a
held half-pull and where the firmware's full-pull bit fires and drops, and
a bit scan found the left trigger's bit. A haptic bench fires each click
candidate on a threshold crossing and times how long the host takes to get
it out.

TriggerDetectorProbe is a console check for the detector with no
controller needed: the hysteresis, a mode switched off mid-hold, the
clamps on persisted thresholds, and that profiles written before the
feature read back as a plain analog trigger.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 22:26:51 -07:00
Dylan DeverillandClaude Sonnet 5.5 e599201683 Add simulated dual-stage triggers (#75)
The 2026 controller's triggers are smooth analog travel with no physical
second stage, so a trigger set to Dual Stage holds a full-press binding
once it is pulled past a threshold. The analog value still reaches the game
untouched, so a game sees the pull and the extra input together.

The threshold is software on the analog value, with hysteresis: it presses
at 95% and lets go at 80% by default, which is what measurement showed the
firmware's own full-pull bit does. Both triggers share one code path and
the point is adjustable. The left trigger's firmware bit turned up at
buf[5] bit 3 and is named BTN_LT_FULL; nothing here reads it, but it is
now documented where the right one is.

The binding is an ordinary BackButtonBinding, so it can be a gamepad
button, a key combo or a mouse button, and goes out through the same
paddle dispatch and virtual-pad paths. It is released if the mode is
switched off mid-hold.

There is no actuator in a trigger, so the click is a pad click plus a short
grip buzz on the trigger's side. It is queued to the rumble thread rather
than written from the read loop, because a Bluetooth write can block for
seconds. The buzz merges with whatever rumble a game is already asking for
instead of cancelling it, and a click is dropped if the previous one on
that side is still playing.

Customize Controls gains a group per trigger: a mode select, a live meter
with draggable press and release markers, a Full Press rebind row and a
click-feel select. Settings persist per profile, and profiles written
before this existed read back as a plain analog trigger.

The settings page's script and styles each go in a literal of their own,
because the neighbouring ones are within a kilobyte of the compiler's
limit on a single string.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 22:26:51 -07:00
Dylan DeverillandClaude Sonnet 5.5 781fe3624a Let F13-F24 be bound as key-combo outputs
Add the keys to the KeyboardEvent.code table so the remap window can capture
them, and name them explicitly because GetKeyNameText has no label for their
scan codes and every one would otherwise read as "Key".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:25:55 -07:00
Dylan DeverillandClaude Opus 5.5 26c5b4ab6e Bump version to 1.24
The first release built and signed through SignPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v1.24
2026-09-22 23:17:41 -07:00
Dylan DeverillandClaude Opus 5.5 1a1253a384 Build and sign releases through SignPath on GitHub Actions
SignPath Foundation signs only what a trusted build system built from the
repository, so releases move from local builds to a workflow. A tag push
builds, signs the three executables, compiles the installer around the
signed copies, signs the installer, and opens a draft release. A manual run
does the same under test-signing, without the release.

The README gains the code signing policy the Foundation requires, including
a privacy note for the Google Fonts request the remap window makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:57:33 -07:00
Dylan DeverillandClaude Sonnet 5 19e5735504 Let Manual mode enable itself on launch, off by default for upgrades (#107)
People kept updating control mappings that did nothing, because Steamless
mode was off and nothing said so until the in-window banner landed. The
banner helps once you're looking at the window, but the fix people actually
asked for is not needing to remember to flip the tray toggle every session.

Manual mode now has its own "Enable on Launch" item, checked by default only
for fresh installs — LoadSettings already returns before touching any member
when the registry key doesn't exist at all, so that fork was free. An
existing install keeps today's off-until-toggled behavior unless the user
opts in, since flipping it under them wasn't part of the ask. Hidden outside
Manual mode: the auto modes never make an "at launch" decision, they
resolve control from Steam/game state continuously, so there is nothing for
the item to mean there.

Routes through EnableFromUser() at the end of Init(), after the profile is
pushed — the same path the tray toggle and the remap window's banner button
already share, so a fresh install's first acquire carries the right bindings
and goes through the same elevated-helper registration as a manual click.

Bumped to 1.23; this is the last change queued before packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v1.23
2026-09-17 00:03:51 -07:00
Dylan DeverillandClaude Sonnet 5 09ca4c30b4 Let Escape be captured as a key-combo binding (#105)
Escape used to be intercepted globally as the remap window's cancel
affordance, so pressing it while listening just exited capture instead
of binding anything. Now it only dismisses the unsaved-changes modal
or the game picker; while a row is listening, Escape falls through to
the normal capture path (alone or as part of a combo), and the VK
table gained the missing entry so it resolves to a real binding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:41:48 -07:00
Dylan DeverillandClaude Sonnet 5 f56559fe35 Share one is-this-dock-held check across ClaimDocks, AdoptDock, and UnheldDockPaths
The three each repeated the same any_of over m_docks. Pulled it into
IsDockHeld so they can't drift on what "held" means later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00
Arvin GandClaude Opus 5 527ee65257 Take the dock in a cycle of its own, after the controller
Riding the dock along in the acquire cycle had three costs. The helper
reports one verdict per run, so a dock that cycled cleanly stood in for a
slot that was vetoed and hid the checks that stop us cycling against our own
handle. Later attempts grew to the docks plus every slot, outrunning the 4 s
gap before "claiming anyway" reopens slots the helper had not reached yet.
And a dock that could not be claimed after the takeover was only logged.

The acquire cycle is slots only again, exactly as before. Once the
controller is ours, a dock Steam still holds is cycled on its own after
500 ms, when the helper run that won the slot has exited, and taken back by
a dock-only pounce, at most twice per takeover. Cycling the dock does not
interrupt the controller. The self-held release keeps held docks, since no
slot cycle includes them.

Tested on a puck: slot cycle and takeover as before, dock claimed on the
first dock cycle, no prompt when docking, Steam gets both back on exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00
Arvin GandClaude Opus 5 6e553a9609 Hold the puck's dock interface so Steam cannot offer to re-pair
With Steamless mode on and Steam running, setting the controller on the puck
raised Steam's "Pair Steam Controller to your Puck?" prompt. Answering Yes
re-paired the controller onto a slot Steam still held, leaving the virtual pad
up on a slot that had gone silent beside a controller Steam was now driving
(#102).

The puck reports a docked controller through a dock interface of its own
(usage page 0xFF00, usage 0x0002, MI_06), separate from the slot interfaces.
Steam answers that event with the prompt because it cannot see the controller
is paired while we hold the slot. Game mode now holds the dock interface the
same way it holds the slot, so the event never reaches Steam:

- Acquire cycles list unheld docks ahead of the slot and the pounce takes
  them back; docks already held stay held through the cycle.
- Release and exit cycle any held dock so Steam reopens it.
- Cycle progress is tracked per interface. The helper cycles a request one
  interface after another, and judging the set as a whole ended the cycle
  between the first returning and the next going down, so the resync
  reopened the next interface ahead of its turn and vetoed it.

Tested on a puck: no prompt when docking with Steamless mode on, Steam gets
the dock and controller back on release, and no pairing is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00
Dylan DeverillandClaude Opus 5 50114dff5e Release 1.22
The intermediate 1.21.1 through 1.21.4 existed only so test builds could be
told apart in a log, which this issue needed repeatedly. What ships is one
release with two user-facing additions — a per-pad scroll speed setting and
the banner saying when Steamless mode is off — so it takes a minor version
rather than the last of the test patches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.22
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 6a2494161f Bump to 1.21.4 so the banner build identifies itself
1.21.3 has already run on this machine and written logs under that version.
Rebuilding it with a visible new feature would leave those logs unable to
say which build produced them, which is the ambiguity this issue has
already lost several rounds to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 d2b29cd883 Say in the window when Steamless mode is off (#95)
Every setting in Customize Controls is inert while Steamless mode is off —
trackpad modes, scroll speed, paddles, all of it. Nothing on screen said
so. The event log has said so since the PADS line landed, which turned out
to be no help at all: people are looking at the window while they test, not
at a log.

That has now eaten three test sessions on this issue alone. The reporter
changed settings twice with it off and reported the feature broken; the
maintainer then did the same and reported scrolling regressed. In every
case the settings were saved correctly and simply were not driving
anything, and working that out took a round trip each time.

So the window says it, and offers to fix it. In manual mode the banner
carries an Enable button; in the auto modes it explains that the mode
decides instead, because a button there would either lie or be undone a
moment later.

The button routes back out to TrayApp::EnableFromUser — extracted from the
tray toggle so both are literally the same path. A shortcut of its own
would have skipped EnsureCycleTaskRegistered and moved the one-time UAC
prompt to the first device cycle, which is precisely where it ambushes
people.

Clicking Enable does not hide the banner. Enabling can fail — no ViGEm, or
a device another process will not let go — and a banner that vanished on
click would claim a success nothing had verified. The state is pushed from
UpdateTrayIcon, so the banner clears when control is actually taken, and
comes back if it is lost.

That push is posted through WM_CONTROLSTATE rather than made directly: the
controller notifies from its read thread and WebView2 must only be touched
on the UI thread. The state is read in the handler rather than carried in
the message, so what the window gets is the current answer rather than the
one that prompted the post.

Two things about the wording, both learned by writing it wrong first.

It says nothing about whether settings are saved. An earlier draft ended
"they are saved either way", which reads as though the page saves on its
own — it does not, Apply does. A banner about one thing being off is the
wrong place to imply something else is automatic.

And its punctuation is ASCII. GetHtml widens the embedded page a byte at a
time, so a literal em dash arrives as two garbage units; the static markup
has always sidestepped this by writing &#8212; instead. Every other
non-ASCII character in the page sits in a comment, where it never reaches
a reader, and this string was the first displayed text to trip over it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 7d1508484b Bump to 1.21.3 so the new slider identifies itself
The reporter already has a 1.21.2 that he reports working. Sending him a
build with a different slider under the same version would leave his next
log unable to say which one produced it, which is the ambiguity the last
several rounds were spent on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 dbb5edb641 Report scroll speed as what it actually feels like, and reach lower (#95)
Two things, both found by working out what the log was really saying.

The PADS line reported speed x correction as a "net % of calibrated". That
is the multiplier on the wheel units we send, not the distance anything
moves — the machine's lines-per-notch multiplies them straight back up. The
two cancel exactly:

  L = D x SENSITIVITY x (3/linesPerNotch) x speed/100 / WHEEL_DELTA
        x linesPerNotch
    = D x SENSITIVITY x 3 x speed/100 / WHEEL_DELTA

which is the whole point of correcting for it. So the speed setting alone
says how fast a pad scrolls, and the log said 8% for a pad that was in fact
scrolling at 25% of calibrated. The reporter's log is the one being read to
decide what to change next, so a figure that misstates it by 3.3x is worse
than no figure. The correction is still printed — it explains why two
machines at the same speed send different wheel numbers — but it is no
longer presented as something to multiply by. TrackpadDirectionProbe now
checks the cancellation directly at 1, 3, 10, 20 and 100 lines per notch.

The floor drops from 25% to 10%, because the reporter settled on exactly
25% and asked to go lower — he was sitting on the end of the range.

The slider now offers stops instead of a continuous percentage:

  0.1x 0.15x 0.25x 0.35x 0.5x 0.75x 1x 1.5x 2x 3x 4x

Roughly a constant ratio per step, which a linear slider cannot be: over
10..400 linear, three quarters of the track sits above 1x where nobody has
asked to go, and the region people use is squeezed into the first quarter.
The stops also have names, and "set it to 0.5x" ends a support round trip
that "set it to 47%" starts another one of — which on this issue is not a
small consideration.

25 and 100 are both stops on purpose, so neither the reporter's setting nor
the default is rounded out from under someone. Values between stops remain
valid and stored — the C++ side still accepts anything in range, and an
older build wrote continuous ones — they simply show as the nearest stop
and only become it if the user applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 a29cf4000e Bump to 1.21.2 so the new diagnostics identify themselves
The reporter already has a build calling itself 1.21.1. Sending him a
second one that logs differently under the same version would put us back
where the last two rounds were spent — unable to tell from a log which
build wrote it.

The installer's staleness guard does not cover this: it compares the
packaged binary's version against MyAppVersion, which catches a stale exe
but not a same-version rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 36b94872c1 Record what the pads are set to, not just when control was taken (#95)
Two rounds of #95 diagnostics produced logs that could not answer the
question they were collected for, and both times the reason was the same:
the only line describing the trackpad setup came from EnableGameMode, and
it was gated on a pad already being claimed for the desktop at that
moment.

That gate assumes configuration precedes enabling. It usually does not —
the settings window is how you configure a pad, and people open it after
turning the app on. The reporter's last log never enabled at all; the
maintainer's next one enabled first and configured five seconds later.
Neither produced the wheel line, so neither could say whether the pad
under test was even in scroll mode.

So the pad setup is now logged where it is applied rather than only where
control is taken, once per change:

  PADS: left=scroll (natural, speed 25%, wheel correction x0.30, net 8%
        of calibrated) right=pointer

The net figure is the point. A scroll-feels-wrong report has two
independent multipliers behind it — the user's speed setting and the
machine's lines-per-notch — and neither alone explains the feel, so the
line carries both and their product. The correction is dropped on a
machine left at the Windows default, where saying it three times adds
nothing.

The line also says when game mode is off, because settings changed then do
nothing whatsoever and until now nothing told anybody. That is the exact
shape of the reporter's "no change even at 25%".

The enable-time environment line loses its gate as well. Withholding it
when no pad is claimed hid it from the report most likely to need it — a
trackpad doing nothing at all, which deserves a line saying so rather than
silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 341d3e3ae0 Refuse to package a stale executable (#95)
The Visual Studio generator is multi-config, so CMAKE_BUILD_TYPE does
nothing and "cmake --build build/release" quietly produces DEBUG. The
installer packages build\release\Release\, which then keeps whatever was
last built there — and stamps MyAppVersion on the result regardless.

That shipped: a 1.21.1 installer carrying the 1.21 executable went to the
#95 reporter. Every visible signal agreed it was fine. Setup succeeded,
Add/Remove Programs said 1.21.1, and only the app's own startup banner and
the missing feature gave it away.

Nothing about the failure was loud, so the fix is to make it loud. The
script now reads the FileVersion out of the binary it is about to package
and aborts the compile when it disagrees with MyAppVersion, naming both
versions and the command to run. Verified in both directions: matching
compiles, mismatched aborts.

The release build preset gains the two helper binaries the installer also
packages, so "cmake --build --preset release" — the command the error
points at — is actually sufficient to produce an installer. It previously
built only SteamlessController, which on a clean tree would have left the
other two missing or stale, the same failure by a different route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 45a731bb82 Bump to 1.21.1 so the test build identifies itself
The scroll changes go to the #95 reporter as a private build before they go
to anybody else, and the last round cost a round trip precisely because the
logs could not be pinned to a build: the version banner was trimmed out of
the paste, and every line that survived was one 1.20 already wrote.

A distinct version makes the startup banner answer that on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 e8c269bf0e Add a per-pad scroll speed setting (#95)
Normalising the wheel setting removes the difference between machines; it
does not settle what the shared feel should be. Steam has a scroll speed
slider and we had a compile-time constant, so a pad that scrolled too fast
for someone left them nothing to do about it but stop using scroll mode.

Per pad rather than global, because the two pads are already configured
independently and either can be the one scrolling. Shown only in scroll
mode, alongside the scroll direction it belongs with.

Applied after the wheel correction so the percentage means the same thing
everywhere: 100% is the calibrated feel, not "whatever this machine
happens to do". Bounded 25-400%.

Zero is the value the registry yields for every profile written before
this existed, so it has to read as "unset" and land on the default rather
than as "do not scroll". ScrollSpeedFromDword owns that on the C++ side
and clampSpeed owns it in the page, each the single place its side bounds
the value — the page previously clamped only on render, which let a stored
out-of-range value reach savedProfile unbounded and show up as an unsaved
change the user never made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 b477e9a4b7 Scroll the same distance whatever the machine's wheel setting is (#95)
Scroll is injected as wheel events, and Windows multiplies every notch by
the machine's lines-per-notch setting before anything moves. That setting
is not ours, is not the same everywhere, and sits downstream of everything
we do — so identical injection scrolls 3.3x further on a machine set to 10
than on one left at the default 3. It is why a scroll-is-far-too-fast
report can be entirely real and still not reproduce on a developer's
machine running the same build.

Dividing it back out makes a given swipe cover the same ground everywhere,
which is what a gesture-to-scroll mapping should do in the first place.

Anchored at the Windows default rather than at 1, so the correction is
exactly 1.0 on a machine nobody has touched: this changes nothing for the
great majority of installs and only moves the ones that were already out
of step. WheelCorrection is kept pure and separate from the setting that
feeds it so that property is checked by TrackpadDirectionProbe rather than
merely asserted in a comment.

The reporter's logs ruled out the alternative before this was written: no
TRACKPAD jump lines across roughly a minute of pad activity on either
transport, so their pad is not dropping reports or lying about the touch
bit, and the amplification is not in our arithmetic. The enablement log
now carries the correction beside the setting, so the next report says
both what the machine does and what we did about it.

Also lands the scroll speed the scale multiplies by, so the two corrections
live together. Nothing sets it yet — it is stored, clamped and defaulted
here, and reaches the UI in the commit that follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan Deverill 7bf69ca4de Update README.md 2026-09-06 23:44:49 -07:00
Dylan DeverillandClaude Opus 5 dcf37f5c6f Release 1.21
Everything since 1.20: the trackpad directional pad and single button
modes, and the pad type now staying put while a game runs rather than
being rebuilt on every alt-tab.

1.20.1 existed only so private test builds could identify themselves in
their own log header, which is the one place it mattered. Nothing shipped
under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.21
2026-09-06 23:41:03 -07:00
Dylan DeverillandClaude Opus 5 5796177be9 Track the pad-type hold by process, not by profile selection
Two fixes to the hold, both found by forcing paths that do not occur on
this machine.

The liveness fallback never released. It asked whether the pid could be
opened and read success as proof of life, but a pid stays valid for as
long as anything holds a handle to the process object — and a launcher or
an anti-cheat service generally does. Measured against Fortnite with the
fallback forced: the game exited, the pid stayed openable, and the hold
stood forever, leaving a PlayStation pad that never went back to Xbox.
It now asks whether the process has exited rather than whether it can be
opened. Both remaining ways to fool it — an exit code of 259, or pid
reuse between two checks — leave a hold standing too long, which costs
the deferred rebuild rather than causing a wrong one.

The hold was also gated on the profile selection changing, having been
written behind the early return that skips a foreground change selecting
the profile already selected. But a hold follows a process and a
selection follows a profile, and those do not change together: a loader
handing off to the game it launched is the same profile wearing a new
pid, so the selection does not change, the return fires, and the hold
stays on the old process. When that one exits the pad rebuilds under a
game that has only just started.

Both liveness paths are now confirmed on hardware — Marathon and Fortnite
by handle, Fortnite by pid with the fallback forced — holding across
alt-tabs and releasing within the poll interval of the game closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 39fa42563c Bump to 1.20.1 so test builds identify themselves
Both diagnostics on this branch are meant to reach a reporter as a
private build before the branch merges, and a build stamped 1.20 is
indistinguishable from the release in the one place it matters — the
log's own header line, which is what comes back attached to every report.

Verified on the binary rather than the source: FileVersion and
ProductVersion both read 1.20.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 491537ffc6 Report trackpad frames that moved further than a finger can (#95)
The wheel-lines diagnostic did its job and ruled itself out: the reporter
was on 10 lines per notch, which really was scrolling them 3.3x faster
than the default, but setting it back to 3 changed nothing. Report id and
length, screen size and now the wheel setting all match a machine where
scrolling is fine. The transport does not — theirs is the dongle.

Movement is integrated from position deltas, so dropped reports are
harmless by themselves: the displacement adds up the same whether it
arrives over ten frames or one. The case that is not harmless is a finger
lifting and landing somewhere else without the touch bit ever being seen
false, because then the gap between two unrelated points is applied as a
single enormous swipe. A wireless transport dropping the odd report is
exactly how those frames go missing.

So this logs any single frame that moves further than a finger plausibly
can, with the milliseconds since the previous touched frame attached.
That number is what separates the two: a gap at the report rate means the
sensor jumped and the touch bit is lying about a finger that left, while
a long gap means reports went missing during real movement.

No clamp yet, deliberately. A threshold picked before knowing which of
those is happening would clip fast flicks on a transport that simply
reports less often, and the right value is the one the logs describe. The
haptic tick path has distrusted jumps like this for a while
(TRACKPAD_TICK_MAX_STEP); movement never learned to, and this is the
evidence for what it should learn.

Also logs the applied profile when it changes, which says something the
existing "switched to" line cannot: a profile set to follow the default
matches, is reported as switched to, and then supplies none of its own
bindings. That is the shape of "my custom bindings do nothing until I
open customize controls", and nothing in a log could show it before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 e4f3b51d33 Hold the virtual pad type for as long as its game is running (#92)
Alt-tabbing out of a game switched the profile back to the default
immediately, and a profile that changes platform tears the virtual pad
down and builds it again. That is the Windows device connect/disconnect
sound the report describes, twice per round trip — and worse than the
noise, a game polling XInput sees its controller unplugged and replugged
while it is still running.

The delay that should have covered this never ran. RELEASE_GRACE_MS
guards handing the physical controller back, and in Manual mode
WantControlNow keeps answering yes when the foreground changes, so the
profile was pushed with nothing in the way. Exposing that delay as a
setting, which was the plan, would have made a number configurable that
this path never consults.

The unit being switched was also wrong. A platform is only meaningful to
a game: desktop bindings are keyboard and mouse going out through
SendInput, which never touches the virtual pad, so nothing on the desktop
cares what type it is. Bindings are the opposite — they describe what the
user is doing right now, and when the desktop is in front the desktop's
bindings are the ones wanted.

So the two are split. Bindings still follow the foreground, exactly as
before. The pad type follows whichever profiled game is running: claimed
when a game comes to the front, kept across alt-tabs, and released when
the process exits — the one moment the rebuild costs nothing, because
the game that needed it has gone.

Liveness is a wait on a process handle, falling back to re-opening by pid
where anti-cheat refuses one, since being denied access to a process is
proof it exists. Polled every two seconds rather than watched: nothing is
looking at the pad in the moment a game exits.

Deliberately does nothing for AutoMode::OffUnlessProfile, which releases
the physical controller the moment a profiled game is not in front and
destroys the pad outright — leaving no pad to hold a type for. That mode
exists to be restrictive and stays that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 a46db6cc1e Log the machine's wheel-lines setting alongside injection (#95)
Scroll is injected as wheel events, 120 units to a notch, and Windows
multiplies each notch by the user's lines-per-notch before anything
moves. Identical injection therefore scrolls wildly different amounts on
two machines, and a "scroll is far too fast" report gives no way to tell
that apart from a bug in our own arithmetic.

The default is 3 lines. WHEEL_PAGESCROLL makes a notch a whole page,
which turns the gentlest flick into a page jump — "I barely touch the pad
and it scrolls all the way down a page" describes that setting exactly,
and it is not something a sensitivity control could tune away, since with
a page as the smallest unit the granularity is the problem rather than
the scale.

Recorded in the injection environment line, which already fires at game
mode takeover whenever a pad is driving the desktop, so it costs nothing
and arrives attached to every future report of this kind.

The reporter's log and ours are otherwise identical — both id=0x42
len=54 — so the report layout is ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.20
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 71a50df221 Actuate the DS4 touchpad at the same force as everything else (#96)
The DS4 touchpad press was the last thing still reading the firmware's
click bit, which reports fewer than half of a thumb's presses and wants
more force the more heavily the pad is already being rested on. That is
what "having to press pretty hard to activate the trackpad" is, and why
it reproduces for one person and not another: the threshold is force
based, so thumb size and resting weight decide how hard is hard enough.

It now takes the same resolved press every other binding uses, so every
press in the app actuates at one threshold rather than the touchpad
keeping its own, much heavier one.

No sensitivity setting, which is what the report asked for. The pad
already actuates about as lightly as it can without a resting thumb
tripping it, so there would be nothing useful to turn down. If it still
reads heavy after this, that is evidence kPadPressArea itself is too high
for everyone rather than a reason to add a knob most people would never
find.

Also fixes a second reader of the same bit, found while checking for
others: TapState asked it whether a contact had become a press, so a
gentle press the firmware missed left the contact looking like a tap and
fired the tap binding on lift, on top of the direction the press had
already sent. With a tap bound to a mouse button that is a stray click on
every soft press.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 10f3315877 Remove the press calibration that produced a wrong threshold
This mode measured a resting thumb and swept a threshold across the gap
to the presses, and the number it produced put kPadPressArea inside the
resting band. Its resting stage only sampled frames where the touch bit
was set, which does not reliably catch a thumb that is genuinely
resting: the run that set the constant reported a median of 0 against a
real resting level of 259..381. A pad with a thumb lying on it then read
as a held press, and consecutive presses merged into one.

Deleted rather than repaired. TrackpadDirectionProbe --live already does
this better, by correlating against the firmware's own haptic
notification — a per-event ground truth rather than a distribution to
reason about — and reporting outright the two numbers a threshold has to
live between. A tool that quietly produced a wrong answer once is worth
less than nothing next to one that did not, and the header now says so
rather than leaving the next person to rediscover it.

The ring-boundary calibration and the tap session are untouched; both
produced numbers that held up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 0dc1d2855d Bump to 1.20 and note the new trackpad modes
Version in resource.h and the installer script, which the header's own
comment asks to be kept in step. Verified on the built binary rather than
in the source: FileVersion and ProductVersion both read 1.20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 3580436cb8 Stop every left-pad haptic from buzzing both pads
The side byte on the haptic output reports indexes the actuator as
0 = left, 1 = right, 2 = both, 3 = nothing. Left was set to 2, so every
haptic meant for the left pad asked for both actuators and fired the
right one as well. Pressing the left pad buzzed both; pressing the right
buzzed only the right, which is why it read as the right pad reacting to
the left rather than as anything wrong with the left.

Each value was fired on hardware with HapticTester and reported by where
it was felt. A tick on side 2 buzzes both pads exactly as a click does,
so this is the actuator index rather than a per-command quirk or a
bitmask.

The old value came from reading the byte as "the 1-based form of the
original controller's 0 = right / 1 = left". The reversal was real but it
is not a shift: this firmware is 0 = left / 1 = right. That put right on
1 by luck and left on the both-pads value, and the two-thumb test
recorded in the old comment would have confirmed the right pad without
ever isolating the left.

Predates the v1.20 trackpad work — every left-pad haptic since these
constants were written has done this. A directional pad on the right and
a scroll wheel on the left is simply the first configuration that
presses the left pad often enough, and watches the right one closely
enough, to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 02fea1fff3 Raise the press threshold to 1800, measured against real resting
The 250 this replaces was calibrated against a thumb held OFF the pad.
TrackpadZoneProbe's resting stage only samples frames where the touch bit
is set, and the run that produced it reported a median of 0 and a maximum
of 33 — numbers no thumb resting on a pad produces. A thumb actually
resting reads 259..381, so 250 sat inside the resting band: a pad with a
thumb lying on it read as a held press, the area never fell back through
the threshold, and consecutive presses merged into one. The same
swallowing the old click haptic suffered, reached from a threshold too
low rather than one too high.

Traced live against the firmware's own click bit, four bands, none
overlapping:

  thumb resting or gliding      259..381
  presses the firmware misses  2840..3921
  presses it calls a click     4026..4427   (releases again around 2200)
  a press being held           4671..14784

1800 sits in the middle of the empty span between a thumb that is merely
down and one that is pressing — about five times a resting thumb, and
still far below the presses the click bit drops, which is the whole
reason the area is read at all.

The dip that 250's comment also rested on is not in the traces: while a
press is held the area only climbs. The apparent mid-press dips were
separate press attempts merged into one span by that same low threshold.

TrackpadDirectionProbe gains a live mode for this. The firmware plays its
own haptic at the moment it judges a press happened and announces it to
the host in a 0x40 report, which turns the haptics being felt into a
per-event ground truth: each one is timestamped against our area-based
detection, the click bit and direction changes, with the deltas
summarised on exit. It judges presses with the detector the app ships, so
what it measures is what a user feels. It also reports the two numbers a
threshold has to live between — the lowest area while a click is held,
and the floor between consecutive clicks — and says outright when they
overlap, since no threshold works if they do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 377aa0b18e Detect a trackpad press from contact area, not the firmware's click bit
Measured with TrackpadZoneProbe: pressing the pad twenty times with a
thumb that stays on it produced nine rising edges on BTN_TP_RT_CLICK. The
presses were all in the report — a press flattens the fingertip and the
contact area spikes for every one — so they are recovered from the area
instead, and the click bit is kept only as a corroborating signal for the
firm presses it does report.

One threshold, not two. Above kPadPressArea is pressed, below it is not,
and everything below counts as backed off however heavily a particular
thumb rests. A separate release level would be a second per-person number
with nothing behind it, and one guessed too high latches the detector and
swallows every press after the first — which is precisely the bug the
click haptic had, where the release waited on an area a planted thumb
never reaches.

250 because a resting thumb reports almost nothing: over 1250 resting
samples the median was 0 and the highest 33, against presses peaking past
6000. Deliberately near the bottom of that range rather than the middle.
The area dips and recovers within a single press, so a threshold up in
the press range splits one press into several — the sweep counted 24
presses at 1092 where twenty were made — while one just above resting
does not. Two frames of confirmation on each side, which is not a
per-person number but a description of the sensor's noise, and is what a
single threshold needs in place of the second level it does without.
Three frames starts merging genuinely separate presses.

One answer now drives everything a press feeds: the directions, the click
binding on both delivery paths, and the click haptic, which was firing
for fewer than half the presses a thumb made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 3865120f77 Decide a trackpad tap on the lift instead of after a fixed wait
The confirmation window added in the previous commit does not work, and
the measurement that sized it says why. Over 148 presses the finger is
down for a median of 59 frames before the click and sometimes thousands:
a thumb is planted and then pressed. Suppressing a tap on every press
would need a wait longer than the longest of those, so any window short
enough for a tap to feel responsive still fires before nearly every
press. Twelve frames would have caught well under a fifth of them.

A tap is knowable exactly, just not at the moment of contact. It is a
contact that ended without a click, was brief, and did not travel — all
three known at the lift. So that is where it is decided now, with no
window to tune and no latency to trade off. It reports as a short pulse
rather than a hold, because the event is over by the time it is known to
have happened; the row is labelled Trackpad Tap, which is what it now is.

The same session validates the click haptic fix from two commits ago:
the shortest gap held the click bit low for 7 frames against a 4-frame
release window, so all 148 presses fire. The probe had been replaying the
old area-based latch and reporting 132 of 148, which described code that
no longer exists — its replay now mirrors the current constants, and its
touch-to-click section explains the lift rather than recommending a
window that cannot work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 fbdc10a815 Report a trackpad tap only when the contact was not a press
A press sets the report's touch bit on its way down — the finger lands,
force builds, and only then does the click bit follow — so a touch
binding dispatched off the raw bit fired on every press, before the press
itself. Pressing a direction triggered whatever the touch was bound to
first. Not specific to the directional pad: clicking a pointer pad did
it too, it was just never somewhere a thumb presses constantly.

Touch is not an independent signal. A click always implies one, so the
raw bit cannot be what a binding fires on. What makes a touch its own
event is a contact that does NOT become a press, which is why the row is
now labelled Trackpad Tap.

A finger must therefore be down for twelve frames without clicking before
a tap is reported, and a contact that clicks first reports none — nor any
later in that same contact, having stopped being a tap. Nothing else
separates the two: contact area does not (a thumb loading for a press
sits around 2500, above a light rest and overlapping a grinding one), and
the click that would settle it has not happened yet. Only waiting does.

Both delivery paths read the confirmed answer off PadDigital rather than
re-deriving it, as they already do for directions and the click's zone.

TrackpadZoneProbe's tap mode now also reports how long a finger is down
before the click, which is the ceiling on the confirmation window: twelve
is a starting value, and the shortest real press is what it has to stay
under. That session also echoes each tap as it happens and reports the
stream rate, after a run captured two presses out of many with no way to
tell a dropped report from a press that never happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 f955c9d239 Expose the directional pad and single button modes in the remap window
Phase 5 of the v1.20 trackpad work, and the point at which either mode
can be chosen without editing the registry.

Movement Mode gains Directional Pad and Single Button. Each pad grows ten
rows — four directions and a touch, per pad — which need no machinery of
their own: they are bindings like any other and go through the same
rebind, capture and reset paths the paddles already use. What each mode
shows is a table rather than a chain of conditionals, because the answer
is now six modes wide and the old two-branch form had no room for the
distinction that matters: a mode either resolves directions or it does
not, while touch rides along with anything using the pad at all.

A directional pad also gets a Diagonals dropdown and one line saying how
its surface is split, which is the only thing about these rows that
cannot be read off their labels.

Changing mode now cancels a capture on any row the new mode hides,
rather than only on the DS4 case. Switching a pad from Directional Pad to
Mouse Pointer while rebinding Up would otherwise leave the row listening
behind hidden UI, and the next button pressed would bind to something the
user could not see. A row the new mode keeps — touch, in every mode that
has it — goes on listening.

Key labels are now gathered from every binding a profile holds rather
than just the clicks, so a direction bound to a key still has a name
ready when its mode is switched back on.

Verified by extracting the page and driving it in a browser: all six
modes show exactly the rows intended, a profile round-trips through
save and load with every new key, one written before these existed reads
back as the documented defaults, and the capture-cancel rule holds in
both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 536536950e Tick a directional pad on direction changes, not on distance
Phase 4 of the v1.20 trackpad work.

The movement haptic ticks once per 6500 units of travel, which is the
right feedback for a pad steering a pointer and the wrong one for a pad
being used as a d-pad: distance means nothing there, so sliding around
inside a single direction buzzes continuously for no event, while rolling
from one direction to the next — the only thing that actually happens —
gets no acknowledgement at all.

A directional pad now ticks once per direction change instead, which is
the detent a d-pad gives. Only between two directions: arriving from
nothing is the press and going back to nothing is the release, and both
already fire their own click haptic.

That tick sits outside the distance gates rather than among them. Every
one of those would suppress it — directions only change while the click
is held, and the gates exist precisely to stop a press bleeding a tick
into the click haptic.

A single button reports no movement at all, so it ticks for nothing. The
other three modes are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 a4c6475e84 Fire the click haptic on every press, not just the first of a burst
The click haptic latch re-armed only once contact area fell to an
absolute idle level (<=1000) for eight frames. Contact area only gets
that low when the thumb leaves the pad, so tapping a pad the way you walk
down a menu — thumb planted, pressure modulating — left the latch waiting
for a release that never came, and every press after the first went
unfelt. Lifting clear of the pad was the only way to feel the next one.

Measured rather than reasoned: TrackpadZoneProbe's tap mode replays the
real latch over recorded presses, and a genuine two-tap burst had the gap
between them bottom out at 1236 against a threshold of 1000, with the
second press duly swallowed. A thumb resting between taps sits around
2500.

Release now confirms on the click bit holding false for four frames,
matching how the press edge already works. The original reason for
distrusting the bit was chatter around the firmware's force threshold,
which a confirmation window answers the same way it answers it on press.
Area was not just unnecessary but backwards for this: grinding a hard
press dips it to 1000-2000, LOWER than the planted thumb between taps
that has to count as a release, so no absolute threshold separates the
two. Duration does — the same probe measured a real gap holding the bit
low for 18 frames, well clear of four.

Predates the directional pad work; a pad clicked like a mouse button is
released by lifting off, so nothing before now tapped one fast enough to
notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 e14c0a799d Dispatch trackpad directions, touches and zoned clicks
Phase 3 of the v1.20 trackpad work, and the point at which a directional
pad does something: its bindings now reach both the virtual gamepad and
SendInput. Neither mode is selectable from the UI yet, so this is only
reachable by setting LeftPadMode or RightPadMode to 4 by hand.

The edge table grows from 6 bindings to 16 — the four paddles, then each
pad's click, touch and four directions. Every entry still has the same
shape, so auto-repeat and the release-on-teardown sweep pick the new ones
up for free, both being sized off kBindableCount.

Directions are the first edge here that is not a bit in the report. They
are resolved from a position, so the previous report cannot be re-read
for them and the slot carries the answer instead. That write sits outside
the hasPrev guard, alongside the report copy it is the equivalent of: the
first frame of a read loop has to establish a baseline rather than
dispatch against a stale one, and skipping it on any frame would stand a
direction up or down for good.

A directional pad's click only reaches its click binding when the press
landed inside the centre circle. Out in the ring the same press is a
direction, and delivering both is the one-press-two-meanings collision
the zone split exists to prevent. Every other mode answers "centre", so
they gate to exactly what they did before.

The virtual controller is now updated after the pads rather than before.
It is handed what they resolved, so the old order would have reported
last frame's directions — a frame of lag on every press, and a direction
still held for a frame after release. Both report paths walk the pad
bindings through one shared helper rather than a copy each; on the DS4
side that walk happens before the hat is computed, so a direction bound
to the d-pad folds into it and diagonals come out as diagonals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 93ff4a0f52 Resolve directional pad directions in TrackpadInput
Phase 2 of the v1.20 trackpad work. Nothing dispatches these yet — the
directions are resolved and handed out, and Phase 3 wires them to the
virtual pad and to SendInput.

TrackpadMouse is renamed TrackpadInput. The name stopped being true once
the class resolves d-pad directions as well as driving the mouse; the
registry value L"TrackpadMouse" keeps its name, being the retired toggle
this has nothing to do with.

Directions come from the click rather than from touch, so a resting thumb
presses nothing, and the pad is split by where the click lands: inside
kPadRingRadius the press is the pad's click binding, outside it is the
direction under the thumb. The zone is decided once on the rising edge
and never revisited — sliding from ring to centre mid-press would
otherwise release a direction and fire the click, which is one physical
press meaning two things and the collision the split exists to stop.
Within a ring press the direction does keep following the thumb, so a
roll from up to right registers as both in turn.

Eight-way presses two directions on a diagonal; four-way rounds to the
nearer cardinal. A sector is held until the thumb clears its boundary by
8 degrees, because directions are edge-dispatched and a thumb parked on a
boundary would otherwise emit a stream of key down/up pairs rather than
merely flickering.

TrackpadDirectionProbe drives all of this through synthesized state
reports and checks 50 cases: sector bearings including the wrap through
0/360, both diagonal modes, the ring boundary either side, the zone
latching in both directions, release clearing state, hysteresis both
holding and letting go, every other mode resolving nothing, and a short
report being ignored. Feeding whole reports rather than calling the
resolver also covers the byte offsets, which is where a left-pad reader
ends up reading right-pad coordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 234eb308b1 Add the directional pad data model and its persistence
Phase 1 of the v1.20 trackpad work: the settings a Directional Pad and a
Single Button pad need, and reading and writing them. Nothing resolves a
direction yet and neither mode is selectable, so behaviour is unchanged.

TrackpadMode gains DirectionalPad and SingleButton, appended so a build
that predates them reads a profile naming one as None through the guard
that was already there rather than as some other mode.

TrackpadSettings gains four direction bindings, a touch binding and a
DiagonalMode. Directions default to the gamepad d-pad so choosing the
mode does the obvious thing before anything is rebound. Touch is a
binding on every mode but None and DS4Touchpad, not just the two new
ones: it cannot collide with a direction because directions resolve from
the click, and a thumb crosses zones constantly while sliding, so zoning
it would only make it flicker.

kPadRingRadius carries the centre/ring boundary at 12000 raw units. Not
a fraction of the pad: the coordinate space is square, both axes
saturate at 32767, so the corner sits 1.41x further out than an edge and
no single fraction describes one circle. The value comes from
TrackpadZoneProbe against real presses — centre presses reached at most
6035 and the loosest direction press was 17731, eyes-free.

Both registry sites read and write per pad through one prefixed helper
rather than two copies of twelve names, which is where a left-pad name
ends up reading a right-pad value. The six settings that already existed
keep their exact names, so 1.19 profiles load untouched, and the six new
ones per pad default to the struct's own values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 3f2e311437 Re-split the remap page literals under the C2026 limit
The page is built from adjacent raw string literals because MSVC caps one
at 16380 bytes. The three pieces had drifted to roughly 17.5KB each,
which the old toolchain accepted and VS 2026 does not — main stopped
compiling with three C2026s the moment the generator moved, and nothing
warns on the way there.

Six pieces now, largest 12.7KB. The splits fall after </style>, between
the WebView bridge and the actions it drives, and between the picker and
the row rendering, so each one lands where two complete constructs meet.
Adjacent literals are concatenated by the compiler, so this is invisible
at runtime: the extracted page is line-for-line identical, bar one blank
line at each new seam.

The comment that governs this also drifted — it advised keeping pieces
near 16KB, which is the cap itself rather than a budget under it, and
told the next person to rebalance the halves rather than add a split.
Both are why the pieces grew past the limit. It now names a 12KB budget
and says to add a seam instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 dd61941d3b Add TrackpadZoneProbe to calibrate the d-pad ring boundary
v1.20 splits a trackpad by location in Directional Pad mode: a click near
the centre is the Trackpad Click binding, a click out in the ring is the
direction under the thumb. That boundary needs to be a measurement, not a
guess, and nothing in the tree reported where a thumb actually lands.

Reads only — opens shared, never sends a report, never touches lizard
mode, so Steam can keep running alongside it. "cal" walks nine stages
(centre, four cardinals, four diagonals) and reports the centre and
direction radius distributions, whether they are separable at all, a
recommended boundary, per-direction sector accuracy, and how far the
centroid drifts between touch-down and the click bit going high.

First results settle two design questions:

  - The coordinate space is square, not circular. Max observed radius
    46338 is sqrt(2) * 32767 — both axes saturating at once — so the
    boundary belongs in absolute report units rather than as a fraction
    of a "usable radius" that means the corner.

  - Centroid drift at the click edge is 117 units at one frame back and
    ~350 at three, against a separation gap of 11700. The pre-press
    lookback this tool was built to size turns out not to be needed at
    all; the zone can resolve from the click frame itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00