Wii layouts, per-player mapping and a sideways Wii Remote mode - #21
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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