Skip to content

Wii layouts, per-player mapping and a sideways Wii Remote mode - #21

Merged
JoeGeC merged 16 commits into
mainfrom
wii-layouts-and-sideways-remote
Sep 23, 2026
Merged

JoeGeC merged 16 commits into
mainfrom
wii-layouts-and-sideways-remote

Conversation

@JoeGeC

@JoeGeC JoeGeC commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Wii layouts (Wii, Joy-Con, Mario Kart Wheel, Mario Kart Nunchuck), with bindings that take several sources
  • Buttons mapped per player, with layouts you can save and apply to all players
  • A per-player Sideways Wii Remote switch: turns motion and the d-pad for games that steer by tilt, and sets which way is up on a lone Joy-Con's stick (towards the rail when on, towards L/R when off)
  • Mario Kart tricks and wheelies from a wrist flick
  • User-facing strings moved out of the domain; comments trimmed and detail moved to docs/

Why is this PR so big? I was working on a branch for a while and just wanted to merge, Claude over compensated with a PR - for future reference, please don't submit PRs this big if you want them reviewed :D

JoeGeC and others added 16 commits September 21, 2026 23:16
Three things the Wii mapping editor gained, and the Dolphin motion fix
that prompted them.

Layouts. A console now starts from a named layout rather than one set of
defaults: Wii (as before), Joy-Con (the remote's B and 2 swapped, so the
Joy-Con's own B is the remote's B) and Mario Kart (a sideways Joy-Con
laid out as Mario Kart 8 uses one). Picking one resets that console's
customizations, since an override only means anything against the layout
it was made on. The per-console tables move out of DefaultControllerMappings
into a preset/ package, one layout per file.

Several sources per target. A binding holds every source bound to it and
any of them fires it, stored as their ids joined by '|', so a value from
an older build still reads as one source. Dolphin ORs them into one
expression; Eden binds one input per key, so it keeps the first its body
can emit. The editor's rows become multi-select.

Sideways Wii Remote. A lone Joy-Con can stand in for a Wii Remote held
sideways: its motion turns onto that remote's frame and its four D-pad
bindings turn a quarter with it, since the player's up is a sideways
remote's right. A layout seeds the switch and the user has the last word.

The motion itself was wrong for a right Joy-Con in a wheel game, which is
what started this. Sideways, that body rotates clockwise into its grip, so
its top edge points where a sideways remote's tail does — half a turn out,
and Mario Kart Wii steered backwards. A left Joy-Con rotates the other way
and already matched, which is why only P1 was affected. Pointing wants the
opposite half turn (the nose on the shoulder edge you aim), and no Dolphin
option bridges them: GetOrientation() turns a quarter and never reaches the
IR cursor. Hence the switch rather than a fix.

Measured with tools/dsu_client and a replay of Dolphin's EmulateIMUCursor
against a captured aiming session: restoring the body gives 114 degrees of
cursor travel where leaving the grip frame alone gives 36, because aiming
turns the body about its rail, which is a roll the cursor discards. The
pointer's yaw clamp also goes from Dolphin's 25 degrees to 60, which a
hand-held aim overruns constantly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mapping editor showed one set of bindings per console, so two people
holding the same body had to share them. It now shows a card per connected
player: P1 and its controllers on the left, the layout that mapping amounts
to on the right, opening onto the bindings themselves. Everything is keyed
by PlayerBody -- a player plus the body they hold -- from the repositories
down through the four emulator-config generators, which take a lookup per
body rather than per side.

A layout is no longer a choice remembered against a body, only a name for a
set of bindings. Applying one copies out everything it says, and the name is
read back by matching what a body is bound to against every layout the app
ships and every one the user saved. That is what lets a deleted layout take
away its name and nothing else -- everyone keeps their buttons, and those
same buttons answer to the layout again the day an identical one is saved
back. No match is "Custom". Saving is offered only when there is no name yet
and suggests the first free "Custom N", so no two layouts can compete to
name the same bindings.

All players, at the top, sets everyone at once and saves the session under a
name. A saved set carries every player's bindings in full rather than a
layout id, so it restores what it saved whatever has happened to the layouts
since; it also carries the bodies it was saved from, which is why it is
greyed out -- and says which players it wants -- until they are back.

Sideways Wii Remote was a console-wide switch and is now one per player,
since the players at a table need not agree.

A flick of a sideways Joy-Con is now amplified into Mario Kart Wii's tricks
through the accelerometer rather than synthesised from the gyroscope. The
game has no MotionPlus and picks between its four tricks by the direction of
the flick, which a Shake group cannot carry -- it is one axis and symmetric
-- so the real jerk has to arrive big enough instead. Subtracting a slew
limiter leaves what gravity is not, and adding that back doubles the
transient while leaving the gravity the wheel steers by untouched: measured
2026-09, a flick carries 1.6 to 3.6 g against 0.35 for the sharpest steering.

Mappings stored per console are handed to every player by a DataStore
migration, which is what they already meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The switch described a right Joy-Con to both of them. A left Joy-Con's own
body already is a Wii Remote held sideways -- a sideways remote's nose
points left, just as its L/ZL edge does -- so there is no steering for the
switch to correct there, and it only turns the d-pad and amplifies flicks.
Only a right Joy-Con gives up its own body, and with it the R edge it aims
down, so only it carries that caveat.

The caveat now reads as one: SettingSwitch takes an optional warning, drawn
under the description behind an amber caution icon in the colour WarningBox
already uses, and announced as a warning to a screen reader rather than
arriving as one more sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A trick is a flick, and a flick of something Joy-Con sized is mostly
rotation: captured ones peak past 1200 deg/s summed while carrying barely a
g of linear jerk, where jerking a real Wii Wheel throws the whole thing.
Mario Kart Wii has no MotionPlus and reads only the accelerometer, so the
flick never arrives -- on hardware it wouldn't either. The gyroscope fires
the trick instead, which hardware could not do.

Two ways of delivering it were ruled out on the way, and both are written
down so they are not tried a third time. Amplifying the accelerometer's own
transient buys nothing: an emulated remote saturates around +3.9/-4.9 g
(ACCEL_ZERO_G 0x80, ACCEL_ONE_G 0x9A over 8 bits), so a bigger number only
clips sooner. Dolphin's Shake group does nothing either -- bound straight to
a key in Dolphin's own config, a full 7 g oscillation of it never once
landed a trick -- even though its acceleration is added unconditionally and
plainly reaches the game.

What did land one by hand was shaking a Joy-Con hard for about a second, so
the synthetic trick copies that shape rather than a push: an oscillation
held for 0.6 s at about 6.7 Hz across every IMUAccelerometer input, the
opposites carrying half a turn of phase so the remote is thrown back and
forth instead of leaned on. The accelerometer is the one path known to reach
the game, since steering is read from it.

A rate alone cannot tell a flick from a turn, because steering a lone
Joy-Con held as a wheel *is* rotation; a pair steers from the Nunchuk's
stick with its remote hand still, which is why only single Joy-Cons suffered
for it. The trigger subtracts a slew limiter, leaving only what climbs
faster than the limiter can follow: it catches the sharpest measured
steering (6.5 rad/s) within a seventh of a second and leaves nothing behind,
while a flick's 40 ms rise to 21 outruns it almost untouched. Until it did,
a firm turn fired a trick and dumped the shake onto the very accelerometer
the wheel is read from, which made steering lurch and swallowed the next
flick with it.

Shake is a mapping target of its own, so any body can trick from a button,
which was a pair's only route before it learned to flick. It sits below the
sticks in the editor, being a motion of the remote rather than a button on
it. Mario Kart takes it on SR for a lone Joy-Con -- the shoulder that
already hops, so the finger that jumps is the finger that tricks -- and on R
for a pair, whose index fingers move onto the shoulders their hands'
controllers keep a trigger on.

Reset to defaults goes with all this: picking the console's own layout has
done the same job since layouts arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trick fired about half the time, and tuning it by feel was never going
to work: the threshold was built on a flick of 21 rad/s recorded in June,
and a capture of the hand actually playing peaks at 11 to 16. It was sitting
squarely inside the range it was meant to be under. Of four flicks leaving
5.6, 6.1, 8.3 and 9.1 rad/s behind the limiter, the old bar at 6.0 cleared
two, cleared one by two percent, and missed the fourth.

It now fires at 2.5, which is 2.1x above the worst of 25 s of hard steering
(3.6 rad/s peak, 1.2 residual) and 2.2x below the weakest flick -- as evenly
as two sparsely sampled distributions can be split. Erring low is right
regardless: a trick fired by accident costs nothing, since the game only
tricks a kart already airborne, while one fired mid-corner drops the shake
onto the accelerometer the wheel is read from.

The limiter stays at 0.01. A slower one lifts a flick's residual but lifts
steering's faster, and the ratio between them -- all that matters -- falls
from 4.7 to 3.8 at 0.02 and 2.0 at 0.04. Worth writing down, since a slower
limiter is exactly what it looks like you should reach for.

flick_stats.py is how any of this was known. It reads a dsu_client capture,
reconstructs what Dolphin's expression leaves after the limiter, and reports
it per event, so the constant can come from a hand rather than a guess. The
stream itself came out healthy -- 15 ms, mean and median -- so sampling was
never the problem, though three to five samples across a flick is why two of
the same strength can read several rad/s apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measurements, byte layouts and emulator archaeology had been accumulating
as comment blocks at the top of whichever class happened to need them, and
more than half of it had quietly become a second copy of something already
in docs/. Two places to keep in sync, and one of them was already wrong:
the trick expression in dsu-motion.md still carried a deadzone() that came
out of the code when we measured it.

1024 comment lines to 864. The ratio barely moves, 8% to 7%, because that
was never the problem -- the distribution was. The longest block anywhere
is now 12 lines rather than 31, and nothing sits above 35% of its file.

MotionConverter was the sharpest case, at 65% comment: dsu-motion.md said
"the MotionConverter KDoc records the frames and signs", so the doc was
deferring to the code. That is inverted now, with both frames as a table,
the 2026-09 discovery that raw x had been documented mirrored, and the note
that yaw is the one sign no static pose can pin.

Three sections carry what had nowhere to go: the SPI reply layout and why
the accent colour rather than the body colour (protocol.md), Eden's
cemuhook guid/port/pad addressing (dsu-motion.md), and the Dolphin GC pad's
device qualifier and per-direction stick inputs (virtual-gamepad.md). The
rest was already written down; those classes now point at it.

What stays in the code is what a reader needs at that line and cannot
reconstruct from it: the per-byte labels on the HID descriptor, that
Nintendo's VID/PID makes hid-nintendo intercept the device, that Source = 1
forces a Wii Remote slot to Emulated. One-liners, where opening a doc would
cost more than it gave.

Every cross-reference was checked to resolve, in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule that let them grow was the one saying physical-world comments
"ARE welcome and can be detailed". They cannot be derived from code, which
is true, but that is an argument for writing them down properly rather than
for putting them at the top of whichever class happened to need them first.

So: three lines is the ceiling, and past it the tell is that the comment has
stopped explaining the line in front of it and started explaining the
subject. Those go to docs/, which is named per kind of fact so the next one
has a destination rather than a judgement call.

One home per fact is the lesson from this branch specifically -- dsu-motion.md
described the trick expression with a deadzone() for two commits after it
came out of the code, because the fact had two homes and only one was
updated.

The last bullet is what stops the rest reading as "no comments": what a
reader needs at that line and cannot reconstruct from it stays, which is why
the HID descriptor still labels its bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mario Kart's pair now gives each hand both its shoulders: the remote's B and
the Nunchuk's Z on the upper pair, 1 and 2 on the lower. It also settles two
overlaps by accident and by design. The Joy-Con's own B had been firing the
remote's 2 as well as its B, inherited from the Wii layout; 2 moving away
leaves B to hop and nothing else. And 1 and 2 passed through X on the way
here, which collided with the Nunchuk's C, so the lower shoulders were the
better landing spot -- they were free precisely because B and Z had moved up.

The one source still driving two targets is R, which hops and tricks, and
that is the point of it.

A target with several sources also reads "SR, Stick Up" now rather than
"SR + Stick Up". The plus looked like both at once when it means either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wheelie is a state an up-flick starts and a down-flick drops, where a
trick takes any direction and only one per jump. The shake was a symmetric
oscillation, so it started and cancelled a wheelie four times a second --
the "on and off" -- and the flick meant to drop one was answered by the same
oscillation restarting it. Three measured changes, from captures of flicks,
of hard steering, and of an actual race, read back by tools/flick_stats.py.

The trigger reads pitch, and only pitch. A flick is 59 to 89% pitch on both
bodies -- a lone sideways Joy-Con and a pair alike, despite a lone one being
rotated into its grip before it reaches the wire -- while steering a wheel is
roll and never passed 2.8 rad/s of pitch against a flick's 7 to 17. That
separates them by axis rather than by rate, which no slew limiter could: the
down-flick is a slow gesture, and a limiter quick enough to reject a turn ate
all but 0.9 rad/s of it. So the limiter is gone and the bar is 4.5 rad/s.

The wave is half rectified, so the jerks all go the way the flick did.

Each direction locks the other out for 0.4 s, which the captures are what
found: every flick rebounds the opposite way 0.12 to 0.32 s later, and a
rebound is routinely stronger than a genuine flick elsewhere in the same run
-- 8.5 against 7.0 -- so nothing but order can tell them apart. The gate sits
on the pulse's input rather than its output, so a jerk already running
finishes. Simulated against 45 s of real racing it turns 31 raw peaks into 11
alternating fires, rebounds included.

Untested on device: the expression is simulated against real motion, not yet
played. The one thing still assumed rather than measured is that Dolphin's
`Gyro Pitch Up` is the positive half of wire pitch; if a wheelie starts on a
down-flick, swap the pair in TRICK_AXES.

flick_stats.py reports each gesture's axis and direction now, which is how
any of this was knowable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one Mario Kart layout was really two: a lone Joy-Con held sideways as a
wheel, steering by tilt, and a pair split across the hands as a remote and
nunchuk, steering by stick. They are now named for what they are -- Mario
Kart Wheel and Mario Kart Nunchuck -- and a lone Joy-Con can have either,
playing both halves of the nunchuk scheme itself with its own stick standing
in for the Nunchuk's and its rails carrying C and Z.

A preset says which bodies it is offered to, because a pair cannot be held
as a wheel and should not be shown one. It also says which family it belongs
to, which does two jobs: setting a grip globally gives every other body the
same game's other grip rather than dropping it to the console default, and a
table each on their own grip still names itself -- "Mario Kart" -- instead of
reading Custom the moment it is set.

The lone layout says None for the d-pad, 1, 2 and the remote's minus rather
than leaving them out. A layout lies over the console's default, so a target
it never mentions keeps whatever that default bound: the d-pad kept the stick
the Nunchuk now wants, and the minus button fired the remote's plus and minus
together. A test now asserts that no button on a lone body fires two targets,
bar the shoulder that hops and tricks, since that is the general shape of it
and it is invisible until someone plays.

Left and right agree under the sideways rotation, which the first draft of
the left hand did not: Down, Left and Right emit what X, A and Y do on the
other body, so the same thumb position does the same job in either hand.
That too has a test, against emittedFor rather than against the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu stays open while sources are ticked off, since a target can hold
several. None empties the row, so there is nothing left to tick and waiting
there only asks to be tapped away.

It keys off the contract the component already had -- callers put their
"none" row first, which is what the trigger falls back to when nothing is
selected -- rather than taking a parameter for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A binding's menu was a pill of bold 10sp text over an unstyled Material menu,
where the emulator and layout pickers are an accented value over a bordered
panel. Same job, two appearances.

Rather than copy the styling across, the pieces are now shared: DropdownTrigger
is the accented value every dropdown opens from, and PanelDropdownMenu takes an
optional set of ticked ids -- a menu of a set rather than a choice, where every
row keeps room for its tick so the labels do not shift as one is chosen. The
pickers that pass no set are untouched. Each dropdown keeps its own behaviour
on top: one closes when something is picked, the other stays open until None.

The binding menu is the one that sits at least its trigger's width rather than
exactly it. A source's name runs far longer than an emulator's and its trigger
is half a card wide, so pinning it would wrap every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A layout now carries a line saying what picking it does, in the same dim
sub-label the saved sets use for their player list -- so it shows in a
player's dropdown rows, under the selected name on that dropdown, and in the
All players rows. The two Standard presets have none: they are the only
layout their console offers, so there is nothing to tell apart.

The Joy-Con layout puts the remote's 1 and 2 on the shoulders, where a thumb
need not leave the stick to reach them, and the left hand's A, B and plus
move with them. It was literally "Wii with B and 2 exchanged" and is now its
own arrangement stated over the Wii base, which takes swappingSources with
it -- that swap was its only caller.

Its left hand shuffles four sources at once, so a test asserts no button
fires two of its targets, the way the Nunchuk layout already does. The
sideways switch's own copy is the author's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A domain module is pure Kotlin and cannot see R.string, so every name in one
was a name that could never be translated or reworded without touching logic.
They are all gone: Console, JoyconSide, StickDirection, StickSource and the
six target vocabularies are bare enums now, and GlobalLayout no longer builds
"P1 L, P2 R" -- that is a sentence, and sentences belong where sentences are
built.

Presentation names all of it, through an exhaustive `when` over each type
rather than a map, so a target added without a word does not build. That is
worth more here than brevity: the alternative fails at runtime, on a screen,
with an enum constant where a label should be.

Two things that look like copy are not, and stay where they are. Dolphin's
ini spelling of a stick direction was riding on StickDirection.displayName,
the editor's "Up" and the key "Main Stick/Up" being the same word by
coincidence; it moves to DolphinControls beside EdenControls, shared by the
two generators that write one. And JoyconButton.label -- "ZL", "+", "A" --
is the marking printed on the hardware, drawn onto the controller picture. It
is part of the drawing rather than prose, and reads the same in any language.

Both exceptions are written into CLAUDE.md with the rule, so the line is
recorded rather than argued again.

One domain test asserted "P1 L, P2 R, P3 L/R", which is exactly the smell
being removed; it now asserts the bodies and their order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments were reading as prose and repeating the docs. Measurements, byte
layouts and emulator internals now live in docs/ with one-line pointers,
and name-restating KDoc is gone. CLAUDE.md gains rules on comment voice,
sibling duplication and a Docs section.

Fixes stale docs along the way: the Joy-Con layout row, the trick
expression in dsu-motion.md, CONTRIBUTING's comment policy, PRODUCT's
battery note, and a nonexistent ADB shell backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the left Joy-Con's Wii layout, A moves to Right and + to Up, with
1 and 2 on Left and Down where a right Joy-Con keeps them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JoeGeC
JoeGeC merged commit ac5a699 into main Sep 23, 2026
@JoeGeC
JoeGeC deleted the wii-layouts-and-sideways-remote branch September 23, 2026 17:11
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