From 80bc71301edd9c7134e7bbf0028af16477eb9552 Mon Sep 17 00:00:00 2001 From: UserDerezzed Date: Sat, 14 Nov 2026 22:44:14 +0800 Subject: [PATCH 1/3] Switch 2 controllers: a Controller menu and panel in the game window test a stick's drift and save its calibration in the controller The game window's menu bar gets a Controller menu between Cheats and Window: Show Controller Window (Cmd-Shift-P) and a line for each connected Switch 2 controller. The panel shows a stick live, with markers where it comes to rest after each release and a tally of releases inside and past the dead zone. It shows the stick's dead zone (its own, read from the controller: main's s2_cal_apply), and has a guided calibration (let go, edges, review and save) that writes the stick's centre and travel into the controller as its user calibration, as the console's calibration does. While the panel is open the game gets no Switch 2 input. The write (switch2_ops_apple.m) backs the 4 KB user calibration sector up to a file first, erases, checks it reads blank, writes, reads back, and puts the backup back on a mismatch. Only the first stick slot is saved (a Pro Controller 2's left stick, a Joy-Con 2's stick): where the second slot's user calibration lives is not settled. A saved calibration applies to the game without a restart. DM_SW2_CALIBRATE=1 writes the released stick's rest at connect without the panel; DM_CONTROLLER_WINDOW=1 opens the panel at launch. --- dm/host/Makefile | 4 +- dm/host/front_mac.m | 63 +++- dm/host/pad_switch2_apple.h | 23 ++ dm/host/pad_switch2_apple.m | 204 ++++++++--- dm/host/sw2_panel_mac.h | 11 + dm/host/sw2_panel_mac.m | 661 ++++++++++++++++++++++++++++++++++ dm/host/switch2_ops_apple.h | 37 ++ dm/host/switch2_ops_apple.m | 158 ++++++++ dm/host/switch2_proto.h | 163 ++++++++- dm/host/tests/Makefile | 4 + dm/host/tests/t_switch2.c | 132 +++++++ dm/host/tests/t_switch2_ops.m | 289 +++++++++++++++ dm/tools/impact_map.tsv | 4 + docs/env_switches.tsv | 2 + docs/playing.md | 2 + docs/reference/services.md | 2 + docs/settings.md | 2 + 17 files changed, 1713 insertions(+), 48 deletions(-) create mode 100644 dm/host/sw2_panel_mac.h create mode 100644 dm/host/sw2_panel_mac.m create mode 100644 dm/host/switch2_ops_apple.h create mode 100644 dm/host/switch2_ops_apple.m create mode 100644 dm/host/tests/t_switch2_ops.m diff --git a/dm/host/Makefile b/dm/host/Makefile index 461132fb70..8072c8f974 100644 --- a/dm/host/Makefile +++ b/dm/host/Makefile @@ -19,7 +19,7 @@ include ../mac/min_os.mk # hle_bigstack.c's inline-assembly entry apart from its return path (SIGBUS in dm_bigstack_enter on Breath of the Wild, PR #556) CFLAGS=-O2 -g -Wall -Wno-unused-function -mmacosx-version-min=$(MACOS_MIN) # Service-layer takeover hle_*.c files are included by wildcard: adding a file does not touch this line (avoids merge conflicts) -SRC=main.c loader.c hle.c crash.c probe.c thread.c save_sync.c memstat.m haptics_apple.m pad_switch2_apple.m $(sort $(wildcard hle_*.c)) front_mac.m web_apple.m entry.S +SRC=main.c loader.c hle.c crash.c probe.c thread.c save_sync.c memstat.m haptics_apple.m pad_switch2_apple.m switch2_ops_apple.m $(sort $(wildcard hle_*.c)) front_mac.m sw2_panel_mac.m web_apple.m entry.S # NVN null device + nn::vi + the Vulkan layer (dm/gfx/) # Sources under dm/gfx, dm/gfx/nvn, dm/gfx/tex, dm/gfx/vk and dm/gfx/gl are included by wildcard (tests/ and metal/ do not go into the host): adding a file does not touch this line GFX=$(sort $(wildcard ../gfx/*.c ../gfx/nvn/*.c ../gfx/nvn/*.m ../gfx/nvn/*.S ../gfx/tex/*.c ../gfx/tex/*.m ../gfx/vk/*.m ../gfx/gl/*.m)) @@ -79,7 +79,7 @@ endif .PHONY: FORCE FORCE: # Makefile: a change to CFLAGS (or the compile line) rebuilds the host -dmhost: Makefile $(SRC) $(GFX) ../third_party/stb_vorbis/stb_vorbis.c dm.h hle.h romfs_image.h save_sync.h dm_input.h pad_slots.h hle_haptics.h haptics_apple.h pad_switch2_apple.h switch2_proto.h motion_fusion.h motion_apple.h motion_pro.h motion_pro_mac.h pad_apple.h pad_hid.h pad_hid_mac.h home_quit.h home_quit_mac.h ctrl_prompt.h front.h $(GFX_H) $(GAME_H) $(GAME_STAMP) $(VPX_LIB) $(DAV1D_LIB) $(MSL_H) +dmhost: Makefile $(SRC) $(GFX) ../third_party/stb_vorbis/stb_vorbis.c dm.h hle.h romfs_image.h save_sync.h dm_input.h pad_slots.h hle_haptics.h haptics_apple.h pad_switch2_apple.h sw2_panel_mac.h switch2_ops_apple.h switch2_proto.h motion_fusion.h motion_apple.h motion_pro.h motion_pro_mac.h pad_apple.h pad_hid.h pad_hid_mac.h home_quit.h home_quit_mac.h ctrl_prompt.h front.h $(GFX_H) $(GAME_H) $(GAME_STAMP) $(VPX_LIB) $(DAV1D_LIB) $(MSL_H) clang $(CFLAGS) -I../gfx/nvn/include -I$(dir $(MSL_H)) -I../third_party/vulkan-headers/include -I$(dir $(GAME_H)) -I$(VPX) -I$(DAV1D)/include -I../third_party/stb_vorbis -fobjc-arc -o $@ $(SRC) $(GFX) $(VPX_LIB) $(DAV1D_LIB) -framework Cocoa -framework WebKit -framework UserNotifications -framework QuartzCore -framework Metal -framework MetalFX -framework GameController -framework CoreBluetooth -framework IOBluetooth -framework CoreHaptics -framework IOKit -framework AudioToolbox -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework ImageIO -framework UniformTypeIdentifiers clean: # the host and everything make generates for it (gen/: the profile headers, the game stamp and the libvpx and dav1d builds; dmhost_* and *.dSYM beside them, as in .gitignore) rm -rf gen dmhost dmhost_* *.dSYM diff --git a/dm/host/front_mac.m b/dm/host/front_mac.m index 278bf27240..d554c759da 100644 --- a/dm/host/front_mac.m +++ b/dm/host/front_mac.m @@ -5,6 +5,8 @@ // Cheats (hle_cheats.c): the Cheats menu's rows toggle, reload (⌘R) and open the cheats folder; its first item "Show Cheats // Window" (⌘⇧C, or DM_CHEATS_WINDOW=1 at launch) opens a Cheats panel beside the game: sections, search, the master switch, // safe mode, values, details and the import of cheat files (see the code below). +// Controller (sw2_panel_mac.h): Show Controller Window (⌘⇧P, or DM_CONTROLLER_WINDOW=1 at launch) opens the Switch 2 stick tester beside the game, and the menu lists the +// connected Switch 2 controllers. While it is open the game gets no input from them; a quit waits for a calibration save it started (DmAppDelegate). // Key map (Nintendo layout): WASD left stick, arrow keys D-pad (with Shift held: the right stick, for the camera), J=A K=B U=X I=Y, Q=L E=R, Z=ZL C=ZR, F=press left stick G=press right stick, Return=+ Tab=−, Space=shake the controller. // Controllers: face buttons as on the iPhone, by position (right A, bottom B, top X, left Y) or on a Nintendo pad by its labels (pad_apple.h), shoulders L R, triggers ZL ZR, Menu + Options −, both sticks and their presses. // Each controller is a player of its own (local co-op): the first one connected is player 1, together with the keyboard, the next player 2, and so on up to 8 (pad_slots.h); @@ -24,6 +26,7 @@ #include "pad_apple.h" #include "pad_hid_mac.h" #include "pad_switch2_apple.h" +#include "sw2_panel_mac.h" static int s2pad_player(s2_pad *d); // the Switch 2 pads' player slots (below, beside the raw HID pads') static void s2pad_read(s2_pad *d); static int g_merge_s2_rumble; // DM_PAD_MERGE: player 1's rumble goes to the Switch 2 pads (merge_rumble_to_s2) @@ -399,10 +402,12 @@ static void merge_rumble_to_s2(int on) { if (!g_merge || g_merge_s2_rumble == on) return; g_merge_s2_rumble = on; dm_haptics_apple_set_sink(0, on ? dm_switch2_rumble : NULL); } -static int s2pad_blocked(void) { return !g_input_on || hq_blocks_input(g_hq.phase) || cp_blocks_input(g_cp.phase); } // no input to the game (focus lost, the Home or controller prompt up): its Joy-Con states are not sent (pad_switch2_apple.m) +// No input to the game (focus lost, the Home or controller prompt up, the Controller panel open): its Joy-Con states and sensors are not sent (pad_switch2_apple.m) +static int s2pad_blocked(void) { return !g_input_on || hq_blocks_input(g_hq.phase) || cp_blocks_input(g_cp.phase) || sw2_panel_open(); } static void s2pad_read(s2_pad *d) { hq_poll(); // Home and the prompt's A and B come from these pads too - if (!g_input_on || hq_blocks_input(g_hq.phase)) return; + // The Controller panel has the sticks: circles drawn there never move a character. The Joy-Con gestures (split a pair, join two) still act (pad_switch2_apple.m gesture:) + if (!g_input_on || hq_blocks_input(g_hq.phase) || sw2_panel_open()) return; int p = s2pad_player(d); uint64_t m; int32_t s[4]; if (!s2_pad_input(&d->st, &m, s)) return; if (p < 0) { // DM_PAD_PRESS_TO_JOIN=1: joins at its first button @@ -413,6 +418,16 @@ static void s2pad_read(s2_pad *d) { if (m) merge_rumble_to_s2(1); pad_set(p, m, s[0], s[1], s[2], s[3]); } +// The Controller panel opened: what the Switch 2 pads hold goes neutral once (buttons, sticks, each Joy-Con's own state and both sensors), as focus loss releases it; while the +// panel is open nothing more is sent, and once it closes the next report sends the pad as it is. With DM_PAD_MERGE player 1 is shared, so a GameController or HID pad on it +// reads neutral too until its next change, as after focus loss +static void s2pads_release(void) { + for (s2_pad *d = dm_switch2_pads(); d; d = d->next) { + int p = s2pad_player(d); if (p < 0) continue; + dm_pad_set_player(p, 0, 0, 0, 0, 0); dm_joy_set(p, 0, 0, 0, 0); dm_joy_set(p, 1, 0, 0, 0); + dm_motion_clear_hid(p); dm_motion_clear_joy(p, 0); dm_motion_clear_joy(p, 1); + } +} static int s2pad_attach(s2_pad *d) { // 1: kept (a player, or waiting for its first button press); 0: no free player (pad_switch2_apple.h) if (g_press_to_join && !g_merge) { LOG("controller '%s' (Bluetooth) connected: press a button to play (DM_PAD_PRESS_TO_JOIN)", d->name); cp_poll(); return 1; } int p = g_merge ? 0 : pad_slot_take(&g_slots, d, d->name); char nb[16]; @@ -646,11 +661,47 @@ - (void)setFrameSize:(NSSize)s { [super setFrameSize:s]; [self fitDrawable]; } - (void)viewDidChangeBackingProperties { [super viewDidChangeBackingProperties]; [self fitDrawable]; } @end +// A quit while a Switch 2 controller's calibration save runs waits for it to end, at most 30 s, since stopping between the erase and the write-back would leave the +// controller's calibration sector blank or half written (pad_switch2_apple.h, dm_switch2_saving): ⌘Q, the Home button's quit prompt and a logout (DmAppDelegate, through +// terminate:) and the game window's close button (DmWinDelegate). One wait serves them all and ends once: AppKit's pending reply when a terminate: is among them, else exit(0) +// as the close button does. It polls every 0.1 s on the main queue (served while AppKit waits for the reply) rather than through the session's hook, which has one observer, +// the Controller panel. The guest thread's own end, a lost GPU, the game's exit and SIGTERM, SIGINT and SIGHUP (a kill, a Ctrl-C in the terminal that started the app, a session end: +// the launcher forwards them and dm_host_shutdown ends the host within 1.5 s) still end the process at once, and a save then cut off leaves the sector blank or partly written, each block +// whole or blank, with the backup file on disk (the log names it) +enum { QUIT_REPLY = 1, QUIT_EXIT = 2 }; +static int g_quit_wait; // the QUIT_ bits of what waits for the save +static void quit_wait(double until) { + if (dm_switch2_saving() && CACurrentMediaTime() < until) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC / 10), dispatch_get_main_queue(), ^{ quit_wait(until); }); + return; + } + LOG("front end: %s", dm_switch2_saving() ? "the Switch 2 calibration save still runs after 30 s: quitting anyway" : "the Switch 2 calibration save ended: quitting"); + if (g_quit_wait & QUIT_REPLY) [NSApp replyToApplicationShouldTerminate:YES]; else exit(0); +} +static void quit_after_save(int how) { + int first = !g_quit_wait; g_quit_wait |= how; + if (!first) return; // a second quit while waiting: the first wait answers for both + LOG("front end: quitting waits for a Switch 2 controller's calibration save to end (at most 30 s)"); + quit_wait(CACurrentMediaTime() + 30); +} +@interface DmAppDelegate : NSObject @end +@implementation DmAppDelegate +- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)app { + (void)app; + if (!g_quit_wait && !dm_switch2_saving()) return NSTerminateNow; + quit_after_save(QUIT_REPLY); + return NSTerminateLater; +} +- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { (void)app; return YES; } // AppKit asks a delegate this and logs a warning without it; no window is restored +@end + // AppKit reports a refused fullscreen request only to the window delegate (there is no notification for it) static void (^g_fs_failed)(void); @interface DmWinDelegate : NSObject @end @implementation DmWinDelegate - (void)windowDidFailToEnterFullScreen:(NSWindow *)w { (void)w; if (g_fs_failed) g_fs_failed(); } +// The close button ends the process (NSWindowWillCloseNotification → exit(0)); during a Switch 2 calibration save the window stays and the process ends once the save has +- (BOOL)windowShouldClose:(NSWindow *)w { (void)w; if (!g_quit_wait && !dm_switch2_saving()) return YES; quit_after_save(QUIT_EXIT); return NO; } @end typedef struct { void (*fn)(void); } guest_arg; @@ -1561,14 +1612,20 @@ void front_run(void (*guest)(void)) { [[bar addItemWithTitle:@"Cheats" action:nil keyEquivalent:@""] setSubmenu:cm]; if (getenv("DM_CHEATS_WINDOW") && atoi(getenv("DM_CHEATS_WINDOW"))) // the Cheats panel open from the start (beside the game, not key) dispatch_async(dispatch_get_main_queue(), ^{ [g_cheats_menu showPanel:nil]; }); + // Controller (sw2_panel_mac.h): the Switch 2 stick tester and the controllers connected, between Cheats and Window + sw2_panel_setup(win, s2pads_release); + [[bar addItemWithTitle:@"Controller" action:nil keyEquivalent:@""] setSubmenu:sw2_panel_menu()]; + if (getenv("DM_CONTROLLER_WINDOW") && atoi(getenv("DM_CONTROLLER_WINDOW"))) // the Controller panel open from the start (beside the game, not key) + dispatch_async(dispatch_get_main_queue(), ^{ sw2_panel_show(); }); [[bar addItemWithTitle:@"Window" action:nil keyEquivalent:@""] setSubmenu:wm]; [wm addItemWithTitle:@"Enter Full Screen" action:@selector(toggleFullScreen:) keyEquivalent:@"f"].keyEquivalentModifierMask = NSEventModifierFlagControl | NSEventModifierFlagCommand; NSApp.mainMenu = bar; + static DmAppDelegate *ad; ad = [DmAppDelegate new]; NSApp.delegate = ad; // NSApp and the window keep their delegates weakly + static DmWinDelegate *wd; wd = [DmWinDelegate new]; win.delegate = wd; // the close button's wait for a save, and fullscreen's refusals below const char *fs = getenv("DM_FULLSCREEN"); // 1 (or any value but 0): start in fullscreen; 0, the settings window's Window, or unset: a window if (fs && strcmp(fs, "0")) { // A game opened from a fullscreen app starts while macOS leaves that Space. Wait for activation and for the window to reach the active Space before asking for fullscreen // (whichever happens last), and repeat a refused startup request once. Only a request made here owns a retry; a later player request must never restart startup handling - static DmWinDelegate *wd; wd = [DmWinDelegate new]; win.delegate = wd; __block int want = 1, retried = 0, entering = 0; void (^enter)(void) = ^{ if (!want || (win.styleMask & NSWindowStyleMaskFullScreen) || !NSApp.isActive || !win.isOnActiveSpace) return; diff --git a/dm/host/pad_switch2_apple.h b/dm/host/pad_switch2_apple.h index cf7b96d2b9..9d86c9293f 100644 --- a/dm/host/pad_switch2_apple.h +++ b/dm/host/pad_switch2_apple.h @@ -17,6 +17,7 @@ // DM_SW2_HOST=aa:bb:cc:dd:ee:ff names this Mac's Bluetooth address when it cannot be read; DM_SW2_VERBOSE=1 logs the first report of each second; DM_SW2_MAP=a,b,c,d,e,f and // DM_SW2_GYRO_DPS= adjust the motion axes and scale (switch2_proto.h s2_motion). On a Pro Controller 2, pairing, buttons, sticks and Home are checked on hardware; its motion map and scale are not measured. #pragma once +#import "switch2_ops_apple.h" // S2CalOutcome, NSString #include "switch2_proto.h" #include "hle_haptics.h" @@ -39,3 +40,25 @@ s2_pad *dm_switch2_pads(void); // the pads, a list thro // pad's controllers as s2_rumble_pick says: every value the game sets plays once, in its own 5 ms sample (switch2_proto.h, Rumble timing), three to a packet every 15 ms while they // buzz, then a few packets of silence (a write without response can be lost). void dm_switch2_rumble(const dm_haptic_state *state); + +// The stick tester's side (the Controller panel): what the controllers' sessions hold and the one write they allow. All calls come on the main queue. +typedef struct { + uint32_t id; // this connection's id: a reconnect gets a new one + uint16_t pid; // S2_PID_PRO, S2_PID_JOYCON_L, S2_PID_JOYCON_R + const char *kind; // "Pro Controller 2", "Joy-Con 2 (L)", "Joy-Con 2 (R)" + char serial[15]; + int player; // 0-based, -1 without one + s2_cal cal[2]; // what the game uses now: the user calibration, else the factory one + s2_cal factory[2]; // the factory blocks; a copy of cal[i] when the factory block did not read + uint16_t stick[2][2]; // the latest raw 12-bit x, y of each stick (index 1 only on a Pro Controller 2) + uint32_t clock_ms; // the controller's clock at that report (the s2_clock milliseconds the session already keeps) + int saving; // a save on it is running +} dm_sw2_info; +int dm_switch2_on(void); // dm_switch2_start returned 1 +int dm_switch2_controllers(dm_sw2_info *out, int max); // connected controllers whose handshake finished, oldest connection first, at most max; returns the count +// The tester's hooks (one observer; NULL blocks remove it): changed = a controller came, went, finished its handshake or a save began or ended; report = a new input report of controller id +void dm_switch2_observe(void (^changed)(void), void (^report)(uint32_t id)); +// cal as controller id's user calibration of slot (s2op_save_user_cal with s2op_backup_dir()). Refused at once (S2CalNoBackup, nil path, a log line) for slot != 0, an unknown id, a save already running on it, +// or an invalid cal. After any other outcome the session re-reads that slot's calibration (user, else factory) into the pad the game reads before done runs. done on the main queue, after this returns +void dm_switch2_save(uint32_t id, int slot, s2_cal cal, void (^done)(S2CalOutcome outcome, NSString *backupPath)); +int dm_switch2_saving(void); // some save is running (the quit wait) diff --git a/dm/host/pad_switch2_apple.m b/dm/host/pad_switch2_apple.m index 2e2cac0d10..f43a6649da 100644 --- a/dm/host/pad_switch2_apple.m +++ b/dm/host/pad_switch2_apple.m @@ -1,6 +1,7 @@ // CoreBluetooth transport for Switch 2 controllers, shared by the Mac and iOS front ends (pad_switch2_apple.h says what it does; switch2_proto.h is the protocol). // Everything runs on the main queue, where the front ends' own pad callbacks run. #import "pad_switch2_apple.h" +#import "switch2_ops_apple.h" #import #import #if TARGET_OS_OSX @@ -21,6 +22,13 @@ static int g_map_set; // DM_SW2_MAP overrides the per-layout maps static uint8_t g_host[6]; // this host's Bluetooth address, little endian, as a bonded controller's wake advertisement carries it static int g_host_known; +static int g_on; // dm_switch2_start returned 1 +static uint32_t g_next_id; // the id of the next connection (dm_sw2_info.id) +static int g_saving; // calibration saves running, on any controller (dm_switch2_saving) +static void (^g_changed)(void); // the tester's hooks (dm_switch2_observe) +static void (^g_report)(uint32_t); +static int g_calibrate; // DM_SW2_CALIBRATE: each controller's stick rest written as its user calibration, once a run +static NSMutableSet *g_calibrated; // the serials written (or tried) this run s2_pad *dm_switch2_pads(void) { return g_pads; } @@ -48,12 +56,14 @@ @interface S2Command : NSObject @implementation S2Command @end -@interface S2Session : NSObject +@interface S2Session : NSObject // S2Commander: the memory writes and bonding of switch2_ops_apple.m run through it @property (nonatomic, strong) CBPeripheral *peripheral; @property (nonatomic) uint16_t pid; @property (nonatomic) int use; // S2_ADV_PAIR, S2_ADV_TAKE or S2_ADV_WAKE: why it was connected @property (nonatomic) s2_pad *pad; @property (nonatomic) BOOL live; +@property (nonatomic, readonly) uint32_t cid; // this connection's id (dm_sw2_info.id) +@property (nonatomic, readonly) BOOL saving; // a calibration save is running on it @end static NSMutableDictionary *g_sessions; @@ -87,17 +97,31 @@ @implementation S2Session { CBCharacteristic *_wr, *_rsp, *_inp; NSMutableArray *_queue; BOOL _pending; uint8_t _pendingCmd; NSUInteger _seq; void (^_cb)(NSData *); - s2_cal _cal[2]; + s2_cal _cal[2], _factory[2]; BOOL _hasFactory[2]; // _cal: what the game uses (user, else factory); _factory: the factory block, _cal's copy while it did not read NSTimer *_tick; uint64_t _reports; BOOL _handshaken; s2_rest _rest; s2_clock _clk; uint64_t _lost; char _serial[15]; uint64_t _restSaved; // sample times from the controller's clock; the serial its learned gyro offset is kept under CBCharacteristic *_vib; uint8_t _vseq; int _offLeft; s2_rumble_cursor _rcur; // _rcur: where this controller is in its player's rumble log int _player; // the player at the last read (playerSync): a pad waiting for its first button press (DM_PAD_PRESS_TO_JOIN) gets one later int16_t _mouseX, _mouseY; BOOL _mouseSeen, _mouseOn; // _mouseOn: its mouse sensor is turned on (features) // the mouse sensor's counters at the last report s2_hold _hold; // the choosing gesture this Joy-Con holds now (s2_gesture) + int _calStep; s2_stick_rest _stickRest; // DM_SW2_CALIBRATE: 1 while it waits for the released stick's rest, 2 while it writes + uint32_t _cid; BOOL _saving; + BOOL _closed; // torn down: every command is answered nil at once, so an operation in progress (a calibration save) ends and says so } - (void)dealloc { [self saveRest]; } +- (uint32_t)cid { return _cid; } +- (BOOL)saving { return _saving; } +- (void)fill:(dm_sw2_info *)o { // dm_switch2_controllers' entry + memset(o, 0, sizeof *o); + const s2_dev *d = s2_dev_of(_pad, _pid); + o->id = _cid; o->pid = _pid; o->kind = s2_kind(_pid); memcpy(o->serial, _serial, sizeof o->serial); o->player = s2_player(_pad); + for (int i = 0; i < 2; i++) { o->cal[i] = _cal[i]; o->factory[i] = _factory[i]; } + o->stick[0][0] = _pid == S2_PID_JOYCON_R ? d->r.rx : d->r.lx; o->stick[0][1] = _pid == S2_PID_JOYCON_R ? d->r.ry : d->r.ly; // a Joy-Con's only stick is the one its side reports + if (_pid == S2_PID_PRO) { o->stick[1][0] = d->r.rx; o->stick[1][1] = d->r.ry; } + o->clock_ms = _clk.ms; o->saving = _saving; +} - (instancetype)initWith:(CBPeripheral *)p pid:(uint16_t)pid use:(int)use { - if ((self = [super init])) { _peripheral = p; _pid = pid; _use = use; p.delegate = self; _queue = [NSMutableArray new]; s2_cal_default(&_cal[0]); s2_cal_default(&_cal[1]); } + if ((self = [super init])) { _peripheral = p; _pid = pid; _use = use; p.delegate = self; _queue = [NSMutableArray new]; s2_cal_default(&_cal[0]); s2_cal_default(&_cal[1]); _factory[0] = _cal[0]; _factory[1] = _cal[1]; _cid = ++g_next_id; } return self; } - (void)begin { @@ -106,12 +130,16 @@ - (void)begin { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ S2Session *s = w; if (s && !s->_handshaken) [s fail:@"handshake timed out"]; }); } - (void)fail:(NSString *)why { + if (_closed) return; // already gone (a reply answered nil after the teardown): the peripheral may be another session's by now LOG("[sw2] %s: %s", s2_kind(_pid), why.UTF8String); [g_central cancelPeripheralConnection:_peripheral]; // didDisconnect cleans up; a session that never connected is dropped there too if (!_live) s2_session_closed(self); } - (void)teardown { [_tick invalidate]; _tick = nil; + _closed = YES; + if (_pending) { _pending = NO; void (^d)(NSData *) = _cb; _cb = nil; if (d) dispatch_async(dispatch_get_main_queue(), ^{ d(nil); }); } + [self pump]; if (_pad) { s2_pad *p = _pad; _pad = NULL; int player = s2_player(p); @@ -132,7 +160,9 @@ - (void)send:(uint8_t)cmd sub:(uint8_t)sub payload:(const uint8_t *)pl len:(size [_queue addObject:c]; [self pump]; } - (void)pump { - if (_pending || !_queue.count || !_wr) return; + if (_pending || !_queue.count) return; + if (_closed) { NSArray *q = [_queue copy]; [_queue removeAllObjects]; dispatch_async(dispatch_get_main_queue(), ^{ for (S2Command *c in q) if (c.done) c.done(nil); }); return; } + if (!_wr) return; S2Command *c = _queue.firstObject; [_queue removeObjectAtIndex:0]; uint8_t f[8 + 255]; size_t n = s2_command(f, sizeof f, c.cmd, c.sub, c.payload.bytes, c.payload.length); if (!n) { if (c.done) c.done(nil); [self pump]; return; } @@ -153,20 +183,86 @@ - (void)readMemory:(uint8_t)len at:(uint32_t)addr done:(void (^)(NSData *))done done(r.length >= S2_REPLY_DATA + len && ((const uint8_t *)r.bytes)[8] == len ? [r subdataWithRange:NSMakeRange(S2_REPLY_DATA, len)] : nil); }]; } -- (void)readCal:(int)slot done:(void (^)(void))done { // the stick's factory block (its parameters and calibration), then the user's calibration (s2_cal_from_flash) +// Slot's calibration from the controller, each block read verified (s2op_read): the user's when it parses, else the factory's, and the factory calibration kept apart (s2_cal_pick), +// as a travel is judged against it. The factory block is read whole (S2_STICK_BLOCK): its stick parameters give the stick's dead zone (s2_params_dead), which both carry. +// keep: the re-read after a save, where a block that does not answer leaves what the session holds (the stick's calibration is unknown, not blank) +- (void)readCal:(int)slot keep:(BOOL)keep done:(void (^)(void))done { __weak S2Session *w = self; uint32_t user = slot ? S2_ADDR_USER_STICK2 : S2_ADDR_USER_STICK1, factory = slot ? S2_ADDR_STICK2 : S2_ADDR_STICK1; - [self readMemory:S2_STICK_BLOCK at:factory done:^(NSData *f) { + // The handshake reads each block once (a bounded wait: 2 slots x 2 blocks x 2 s stays inside its 20 s timer); the re-read after a save retries (S2_CAL_TRIES). Both take a reply + // only when it echoes the request (a late reply to the user block's read is not the factory block) + void (^log)(NSString *) = [self opsLog]; + void (^rd)(S2Session *, uint32_t, NSUInteger, void (^)(NSData *)) = ^(S2Session *x, uint32_t a, NSUInteger n, void (^d)(NSData *)) { s2op_read(x, a, n, keep ? S2_CAL_TRIES : 1, log, d); }; + rd(self, user, 0x0B, ^(NSData *u) { S2Session *s = w; if (!s) return; - [s readMemory:0x0B at:user done:^(NSData *u) { - S2Session *s2 = w; if (!s2) return; - s2_cal *c = &s2->_cal[slot]; - int from = s2_cal_from_flash(f.bytes, f.length, u.bytes, u.length, c); - LOG("[sw2] %s: stick %d calibration from the %s, dead zone %.1f %% of its travel (%s)", s2_kind(s2->_pid), slot, from == 2 ? "user's" : from ? "factory's" : "typical values", - s2_cal_dead_share(c) * 100, s2_cal_dead_own(c) > 0 ? "its own" : "the default"); + if (!u && keep) { done(); return; } + rd(s, factory, S2_STICK_BLOCK, ^(NSData *f) { + S2Session *t = w; if (!t) return; + s2_cal uc, fc; s2_cal_default(&uc); s2_cal_default(&fc); + int hasU = u && s2_cal_parse(u.bytes, u.length, &uc), hasF = f && f.length >= S2_STICK_BLOCK && s2_cal_parse((const uint8_t *)f.bytes + S2_STICK_PARAMS, f.length - S2_STICK_PARAMS, &fc); + float dead = f ? s2_params_dead(f.bytes, f.length) : t->_factory[slot].dead; // 0: none read (s2_cal_dead_share's default) + if (!f && keep) { fc = t->_factory[slot]; hasF = t->_hasFactory[slot]; } // the factory block does not change: the one read before stands + if (!f && keep && !hasU && !hasF) { done(); return; } // neither the stick's user calibration nor its factory one is known: what the session holds stays + s2_cal_pick(&uc, hasU, &fc, hasF, &t->_cal[slot], &t->_factory[slot]); t->_hasFactory[slot] = hasF; + t->_cal[slot].dead = t->_factory[slot].dead = dead; + LOG("[sw2] %s: stick %d uses its %s calibration%s, dead zone %.1f %% of its travel (%s)", s2_kind(t->_pid), slot + 1, hasU ? "user" : hasF ? "factory" : "default (none reads)", + hasF ? "" : "; its factory block does not read", s2_cal_dead_share(&t->_cal[slot]) * 100, s2_cal_dead_own(&t->_cal[slot]) > 0 ? "its own" : "the default"); + if (t->_pad) s2_dev_of(t->_pad, t->_pid)->cal[slot] = t->_cal[slot]; done(); + }); + }); +} +// ---- DM_SW2_CALIBRATE: the released stick's rest written into the controller as its user calibration. Only the first stick slot (a Pro Controller 2's left stick, a Joy-Con +// 2's only one). The write itself (backup, write, read back, restore) is s2op_save_user_cal (switch2_ops_apple.m) ---- +- (NSUInteger)writeChunk { // data bytes a write carries: what one write without response takes less the two 8-byte headers, in 16s, 16 to 64 + NSInteger m = (NSInteger)[_peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse] - 16; + return (NSUInteger)MAX((NSInteger)16, MIN((NSInteger)64, m & ~(NSInteger)15)); +} +- (void (^)(NSString *))opsLog { // switch2_ops_apple.m's lines, as this controller's + uint16_t pid = _pid; + return ^(NSString *m) { LOG("[sw2] %s: %s", s2_kind(pid), m.UTF8String); }; +} +- (void)startCalibration { + if (!_serial[0] || [g_calibrated containsObject:@(_serial)]) return; + if (!_cal[0].valid) { LOG("[sw2] %s: not calibrated (DM_SW2_CALIBRATE): its stick's travel could not be read from the controller, and the calibration keeps it", s2_kind(_pid)); return; } + memset(&_stickRest, 0, sizeof _stickRest); _calStep = 1; + LOG("[sw2] %s: calibrating (DM_SW2_CALIBRATE): let go of the %sstick and leave it untouched for two seconds", s2_kind(_pid), _pid == S2_PID_PRO ? "left " : ""); +} +- (void)calibrateAt:(float)x y:(float)y { + s2_cal now = _cal[0], cal; + if (!s2_stick_rest_near(&now, x, y)) { // held: wait for it to be let go + LOG("[sw2] %s: the stick rests %.0f, %.0f counts from its centre, more than drift does: let go of it (DM_SW2_CALIBRATE)", s2_kind(_pid), x - now.cx, y - now.cy); + memset(&_stickRest, 0, sizeof _stickRest); return; + } + if (!s2_cal_recentre(&now, x, y, &cal)) { LOG("[sw2] %s: a centre of %.0f, %.0f leaves the stick too little travel: nothing written", s2_kind(_pid), x, y); _calStep = 0; return; } + _calStep = 2; [g_calibrated addObject:@(_serial)]; + LOG("[sw2] %s: the stick rests at %.0f, %.0f, its calibration's centre is %.0f, %.0f: writing the rest as its user calibration", s2_kind(_pid), x, y, now.cx, now.cy); + __weak S2Session *w = self; + [self saveCal:cal slot:0 done:^(S2CalOutcome o, NSString *path) { (void)o; (void)path; S2Session *s = w; if (s) s->_calStep = 0; }]; +} +// cal as the user calibration of slot 0 (s2op_save_user_cal), then the session's calibration of that slot read again from the controller whatever came of it, so the game and the +// tester use what the controller now holds (a failed restore may have left it blank). Refused at once when one is running, the link is gone or cal is no calibration. done after it returns +- (void)saveCal:(s2_cal)cal slot:(int)slot done:(void (^)(S2CalOutcome, NSString *))done { + NSString *why = slot != 0 ? @"only the first stick slot can be saved (where the second slot's user calibration lives is not settled)" : _closed || !_live ? @"the controller is no longer connected" + : _saving ? @"a save on it is still running" : !cal.valid ? @"that is not a calibration the controller would read" : nil; + if (why) { + LOG("[sw2] %s: nothing saved: %s", s2_kind(_pid), why.UTF8String); + dispatch_async(dispatch_get_main_queue(), ^{ if (done) done(S2CalNoBackup, nil); }); + return; + } + cal.dead = _factory[slot].dead; // the stick's dead zone stays its own whatever calibration it takes + _saving = YES; g_saving++; + LOG("[sw2] %s %s: saving centre %.0f, %.0f, travel %.0f, %.0f up and %.0f, %.0f down as its user calibration", s2_kind(_pid), _serial, cal.cx, cal.cy, cal.maxx, cal.maxy, cal.minx, cal.miny); + if (g_changed) g_changed(); + S2Session *me = self; // held until the save ends: the operation holds its commander, and a controller that went away still answers + s2op_save_user_cal(self, _serial, cal, s2op_backup_dir(), [self opsLog], ^(S2CalOutcome o, NSString *path) { + if (o == S2CalSaved) { me->_cal[slot] = cal; if (me->_pad) s2_dev_of(me->_pad, me->_pid)->cal[slot] = cal; } // what a re-read that gets no answer leaves + [me readCal:slot keep:YES done:^{ + me->_saving = NO; g_saving--; + if (g_changed) g_changed(); + if (done) done(o, path); }]; - }]; + }); } - (void)setLEDs:(void (^)(void))done { int p = s2_player(_pad); uint8_t pl[4] = {s2_led_pattern[p >= 0 && p < 8 ? p : 0], 0, 0, 0}; @@ -177,6 +273,7 @@ - (void)playerSync { int player = s2_player(_pad); if (player == _player) return; _player = player; if (player >= 0) { s2_sync_sides(_pad); [self setLEDs:nil]; } + if (g_changed) g_changed(); // the Controller menu names each controller's player } - (void)handshake { __weak S2Session *w = self; @@ -187,9 +284,9 @@ - (void)handshake { if (pid != s->_pid) LOG("[sw2] advertised 0x%04x but identifies as 0x%04x", s->_pid, pid); s->_pid = pid; memcpy(s->_serial, serial, sizeof s->_serial); void (^calibrate0)(void) = ^{ S2Session *a = w; if (!a) return; - [a readCal:0 done:^{ S2Session *b = w; if (!b) return; + [a readCal:0 keep:NO done:^{ S2Session *b = w; if (!b) return; void (^next)(void) = ^{ S2Session *c = w; if (!c) return; [c finish]; }; - if (b->_pid == S2_PID_PRO) [b readCal:1 done:next]; else next(); + if (b->_pid == S2_PID_PRO) [b readCal:1 keep:NO done:next]; else next(); }]; }; void (^calibrate)(void) = ^{ S2Session *a = w; if (!a) return; [a seedRest:calibrate0]; }; @@ -224,32 +321,10 @@ - (void)saveRest { if (!_serial[0] || !_rest.have) return; [NSUserDefaults.standardUserDefaults setObject:@[@(_rest.bias[0]), @(_rest.bias[1]), @(_rest.bias[2])] forKey:[NSString stringWithFormat:@"sw2.gyroOffset.%s", _serial]]; } -// Sync is held on this controller (its advertisement named no host): give it this host's address and fresh keys, as the console does (switch2_proto.h, Bonding) +// Sync is held on this controller (its advertisement named no host): give it this host's address and fresh keys, as the console does (s2op_bond). It plays either way - (void)bond:(void (^)(void))done { __weak S2Session *w = self; - uint8_t host[S2_PAIR_HOST_LEN], k1[16], k2[16], key1[S2_PAIR_KEY_LEN], key2[S2_PAIR_KEY_LEN], last = 0; - arc4random_buf(k1, sizeof k1); arc4random_buf(k2, sizeof k2); - s2_pair_host_payload(host, g_host); s2_pair_key_payload(key1, k1); s2_pair_key_payload(key2, k2); - NSData *z = [NSData dataWithBytes:&last length:1], *h = [NSData dataWithBytes:host length:sizeof host], *a = [NSData dataWithBytes:key1 length:sizeof key1], *b = [NSData dataWithBytes:key2 length:sizeof key2]; - void (^step)(NSData *, const char *, void (^)(void)) = ^(NSData *r, const char *what, void (^next)(void)) { - S2Session *s = w; if (!s) return; - if (!r) { LOG("[sw2] %s: pairing stopped at %s (no answer): it plays now, but needs Sync again next time", s2_kind(s->_pid), what); done(); return; } - next(); - }; - [self send:S2_CMD_PAIR sub:S2_SUB_PAIR_HOST payload:h.bytes len:h.length done:^(NSData *r1) { step(r1, "the host address", ^{ - S2Session *s = w; if (!s) return; - [s send:S2_CMD_PAIR sub:S2_SUB_PAIR_KEY1 payload:a.bytes len:a.length done:^(NSData *r2) { step(r2, "the first key", ^{ - S2Session *t = w; if (!t) return; - [t send:S2_CMD_PAIR sub:S2_SUB_PAIR_KEY2 payload:b.bytes len:b.length done:^(NSData *r3) { step(r3, "the second key", ^{ - S2Session *u = w; if (!u) return; - [u send:S2_CMD_PAIR sub:S2_SUB_PAIR_DONE payload:z.bytes len:z.length done:^(NSData *r4) { step(r4, "the last step", ^{ - S2Session *v = w; if (!v) return; - LOG("[sw2] %s paired with this Mac: from now on a press of any of its buttons connects it, no Sync needed", s2_kind(v->_pid)); - done(); - }); }]; - }); }]; - }); }]; - }); }]; + s2op_bond(self, g_host, [self opsLog], ^(BOOL ok) { (void)ok; S2Session *s = w; if (!s || s->_closed) return; done(); }); } // The features this controller reports: a Joy-Con 2's mouse sensor too once the game reads the sensors (dm_joy_mouse_wanted; the Pro Controller 2 has none), as the console // turns it on only for a game that uses it. Once on, it stays on while the controller is connected @@ -296,6 +371,7 @@ - (void)attachPad { _player = s2_player(p); if (_player < 0) LOG("[sw2] %s connected: a player once a button is pressed%s", s2_kind(_pid), fresh ? "" : " (joined the Joy-Con already there)"); else LOG("[sw2] %s connected: player %d%s", s2_kind(_pid), _player + 1, fresh ? "" : " (joined the Joy-Con already there)"); + if (g_changed) g_changed(); [self setLEDs:nil]; [_peripheral setNotifyValue:YES forCharacteristic:_inp]; __weak S2Session *w = self; // the link drops after about ten seconds without a write from the host, so the LEDs are written once a second @@ -306,6 +382,7 @@ - (void)attachPad { if (s->_wr && n) [s->_peripheral writeValue:[NSData dataWithBytes:f length:n] forCharacteristic:s->_wr type:CBCharacteristicWriteWithoutResponse]; if (!s->_mouseOn && [s features] != S2_FEATURES) [s mouseOn]; }]; + if (g_calibrate) [self startCalibration]; } // Every 15 ms: this controller's share of the player's motors (s2_rumble_pick) over its next three 5 ms slots (switch2_proto.h, Rumble timing). // A buzz ends with three packets of silence, since one write without response can be lost @@ -332,7 +409,12 @@ - (void)rumbleTick { - (void)report:(NSData *)d { s2_report r; if (!_pad || !s2_report_parse(d.bytes, d.length, &r)) return; s2_dev *dev = s2_dev_of(_pad, _pid); dev->r = r; _reports++; + if (_calStep == 1) { // a right Joy-Con's stick is in the second pair of the report, its calibration in the first slot + float rest[2]; + if (s2_stick_rest_update(&_stickRest, _pid == S2_PID_JOYCON_R ? r.rx : r.lx, _pid == S2_PID_JOYCON_R ? r.ry : r.ly, r.clock_ms, rest)) [self calibrateAt:rest[0] y:rest[1]]; + } unsigned lost; uint64_t ts = s2_clock_sample(&_clk, r.clock_ms, now_ns(), &lost); _lost += lost; + if (g_report) g_report(_cid); int had = _rest.have; float was[3] = {_rest.bias[0], _rest.bias[1], _rest.bias[2]}; s2_rest_update(&_rest, r.raw); if (_rest.have && (!had || was[0] != _rest.bias[0] || was[1] != _rest.bias[1] || was[2] != _rest.bias[2]) && ts - _restSaved > 30000000000ull) { [self saveRest]; _restSaved = ts; } @@ -440,7 +522,11 @@ - (void)peripheral:(CBPeripheral *)p didUpdateValueForCharacteristic:(CBCharacte - (void)peripheral:(CBPeripheral *)p didModifyServices:(NSArray *)invalidated { (void)invalidated; [self fail:@"the controller reset the link"]; } @end -static void s2_session_closed(S2Session *s) { [s teardown]; [g_sessions removeObjectForKey:s.peripheral.identifier]; s2_rumble_timer(); } +static void s2_session_closed(S2Session *s) { + BOOL listed = s.pad != NULL; // only a controller with a pad was ever in the tester's list + [s teardown]; [g_sessions removeObjectForKey:s.peripheral.identifier]; s2_rumble_timer(); + if (listed && g_changed) g_changed(); // a controller the tester may have listed is gone +} @interface S2Central : NSObject @end @@ -474,7 +560,10 @@ - (void)centralManagerDidUpdateState:(CBCentralManager *)c { #endif [self scan:c]; } else if (c.state == CBManagerStateUnauthorized) LOG("[sw2] Bluetooth is not allowed for this app (System Settings > Privacy & Security > Bluetooth)"); - else if (c.state == CBManagerStatePoweredOff) LOG("[sw2] Bluetooth is off"); + else if (c.state == CBManagerStatePoweredOff) { + LOG("[sw2] Bluetooth is off"); + for (S2Session *s in [g_sessions.allValues copy]) s2_session_closed(s); // no link is left: a save on one ends at once (not after every command's timeout) and the controller leaves the list + } } - (void)centralManager:(CBCentralManager *)c didDiscoverPeripheral:(CBPeripheral *)p advertisementData:(NSDictionary *)ad RSSI:(NSNumber *)rssi { NSData *d = ad[CBAdvertisementDataManufacturerDataKey]; @@ -540,14 +629,45 @@ static void s2_rumble_timer(void) { }]; } + +// ---- the stick tester's view (pad_switch2_apple.h): connected controllers whose handshake finished, in the order they connected ---- +int dm_switch2_on(void) { return g_on; } +void dm_switch2_observe(void (^changed)(void), void (^report)(uint32_t)) { g_changed = changed; g_report = report; } +int dm_switch2_saving(void) { return g_saving > 0; } +static S2Session *s2_session_by_id(uint32_t id) { + for (S2Session *s in g_sessions.allValues) if (s.cid == id && s.live && s.pad) return s; + return nil; +} +int dm_switch2_controllers(dm_sw2_info *out, int max) { + NSMutableArray *l = [NSMutableArray new]; + for (S2Session *s in g_sessions.allValues) if (s.live && s.pad) [l addObject:s]; // a torn-down session has no pad + [l sortUsingComparator:^NSComparisonResult(S2Session *a, S2Session *b) { return a.cid < b.cid ? NSOrderedAscending : a.cid > b.cid ? NSOrderedDescending : NSOrderedSame; }]; + int n = 0; + for (S2Session *s in l) { + if (!out || n >= max) break; + [s fill:&out[n++]]; + } + return n; +} +void dm_switch2_save(uint32_t id, int slot, s2_cal cal, void (^done)(S2CalOutcome, NSString *)) { + S2Session *s = s2_session_by_id(id); + if (!s) { + LOG("[sw2] nothing saved: controller %u is not connected", id); + dispatch_async(dispatch_get_main_queue(), ^{ if (done) done(S2CalNoBackup, nil); }); + return; + } + [s saveCal:cal slot:slot done:done]; +} + int dm_switch2_start(const dm_switch2_front *front) { const char *e = getenv("DM_SW2"); if (!e || !atoi(e)) return 0; // off by default: no Bluetooth client, so no permission prompt if (![NSBundle.mainBundle objectForInfoDictionaryKey:@"NSBluetoothAlwaysUsageDescription"]) { LOG("[sw2] Switch 2 controllers are off: this app's Info.plist has no NSBluetoothAlwaysUsageDescription (an app packed with a newer pack_app.py has it)"); return 0; } - g_front = *front; + g_front = *front; g_on = 1; e = getenv("DM_SW2_VERBOSE"); g_verbose = e && atoi(e); + e = getenv("DM_SW2_CALIBRATE"); g_calibrate = e && atoi(e); g_calibrated = [NSMutableSet new]; e = getenv("DM_PAD_MERGE"); g_joy = !(e && atoi(e)); // merged, every pad is player 1: no pad holds a game's Joy-Cons apart e = getenv("DM_SW2_MOTION"); g_motion = !e || atoi(e); e = getenv("DM_SW2_MOTION_SIDE"); g_motion_right = !(e && (e[0] == 'L' || e[0] == 'l')); diff --git a/dm/host/sw2_panel_mac.h b/dm/host/sw2_panel_mac.h new file mode 100644 index 0000000000..aed861d118 --- /dev/null +++ b/dm/host/sw2_panel_mac.h @@ -0,0 +1,11 @@ +// The game window's Controller menu and panel (Mac): the Switch 2 stick tester on the controller session the game already holds (pad_switch2_apple.h, the tester's side). +// sw2_panel_mac.m has the whole story; front_mac.m builds the menu bar, blocks the Switch 2 pads while the panel is open and makes a quit wait for a save. Main thread only. +#pragma once +#import + +// Once, before sw2_panel_menu: game is the game window (the panel's first place is beside it); release sets every Switch 2 player's buttons, sticks and sensors to neutral, +// called each time the panel opens (the game then gets nothing from those pads until it closes) +void sw2_panel_setup(NSWindow *game, void (*release)(void)); +NSMenu *sw2_panel_menu(void); // the Controller menu: filled now, so ⌘⇧P works before it is first opened, and rebuilt by its delegate on every open +void sw2_panel_show(void); // Show Controller Window: the panel is created on first use and kept, ordered front without taking the key window from the game +int sw2_panel_open(void); // the panel is open (shown and not closed; hidden with the app too): Switch 2 pads give the game nothing diff --git a/dm/host/sw2_panel_mac.m b/dm/host/sw2_panel_mac.m new file mode 100644 index 0000000000..3d2e3baab1 --- /dev/null +++ b/dm/host/sw2_panel_mac.m @@ -0,0 +1,661 @@ +// The game window's Controller menu and panel (Mac): the Switch 2 stick tester, on the controller session the game already holds (pad_switch2_apple.h, the tester's side), so +// a stick that makes a character creep can be checked and calibrated mid-game and the new calibration applies at once. Drawn with AppKit. +// The menu, between Cheats and Window: Show Controller Window (⌘⇧P, or DM_CONTROLLER_WINDOW=1 at launch), then one disabled line per connected controller, or why there is none. +// The panel is a utility NSPanel like the Cheats one (floating, over a fullscreen game too, never key for a click, closed only to hide, frame autosave "DmControllerPanel"). It +// works on one controller and stick at a time (a picker with two or more, Left / Right on a Pro Controller 2): the whole range, the centre zoomed 12x with the dead zone drawn as +// the round one s2_cal_apply applies (the stick's own, read from the controller), a marker where the stick came to rest after each release (orange inside the dead zone, red where +// the game would see it move), the readouts, the tally and the dead zone's size, and the guided calibration: let go (the rest becomes the centre), edges (two turns at the +// edge give the travel, judged against the factory calibration), review and save. Only the first stick slot is saved (a Pro Controller 2's right stick is test only), and a +// save's outcome stays on screen, naming the controller, whichever controller is shown or connected, until Done. While the panel is open the game gets nothing from Switch 2 +// controllers (front_mac.m asks sw2_panel_open), so circles drawn here never move a character +#import "sw2_panel_mac.h" +#include "dm.h" +#include "pad_switch2_apple.h" + +#define SW2_MAX 8 // controllers listed at most +#define SW2_MARKERS 20 // release markers kept: the oldest goes +#define SW2_ZOOM 12.f // the centre view shows the nominal travel over this, each way +#define SW2_TRAVEL 1200.f // a stick's nominal travel from its centre in raw counts (s2_cal_default's): what the centre view zooms + +static NSWindow *g_game; +static void (*g_release)(void); + +// ---- the stick views: what they draw is worked out by the panel ---- + +// Offsets in raw counts from the centre (y up), the travel each way, the dead zone as a share of the travel (s2_cal_dead_share), the markers and whether the game would see each one move +typedef struct { float x, y, right, left, up, down, dead; int n; float mx[SW2_MARKERS], my[SW2_MARKERS]; unsigned char past[SW2_MARKERS]; } sw2_picture; +// The edges step: the stick against the travel let go measured from (-1..1 each way, y up), the turns so far, the furthest reach each way (right, left, up, down) and the +// S2_REACH_SHORT_ bits of the ways still short of the factory travel +typedef struct { float x, y, turns, reach[4]; int shrt; } sw2_edge; +enum { SW2_VIEW_RANGE, SW2_VIEW_CENTRE, SW2_VIEW_EDGE }; + +static NSColor *col_dead(void) { return NSColor.systemGreenColor; } +static NSColor *col_live(void) { return NSColor.controlAccentColor; } +static NSColor *col_inside(void) { return NSColor.systemOrangeColor; } +static NSColor *col_past(void) { return NSColor.systemRedColor; } +static NSColor *col_line(void) { return [NSColor.secondaryLabelColor colorWithAlphaComponent:0.25]; } +static NSColor *col_edge(void) { return [NSColor.secondaryLabelColor colorWithAlphaComponent:0.6]; } +static NSColor *col_fill(void) { return [NSColor.secondaryLabelColor colorWithAlphaComponent:0.06]; } + +static void dot(NSPoint p, CGFloat r, NSColor *c) { [c setFill]; [[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(p.x - r, p.y - r, 2 * r, 2 * r)] fill]; } +static void crosshair(NSPoint c, CGFloat r) { + NSBezierPath *p = [NSBezierPath bezierPath]; + [p moveToPoint:NSMakePoint(c.x - r, c.y)]; [p lineToPoint:NSMakePoint(c.x + r, c.y)]; + [p moveToPoint:NSMakePoint(c.x, c.y - r)]; [p lineToPoint:NSMakePoint(c.x, c.y + r)]; + p.lineWidth = 1; [col_line() setStroke]; [p stroke]; +} +static CGFloat clampf(CGFloat v, CGFloat lo, CGFloat hi) { return v < lo ? lo : v > hi ? hi : v; } +// The dead zone around c: round in the stick's travel (s2_cal_apply), so in points each quarter is scaled by its own side's extent (right, left, up, down); each axis within lim +static void dead_zone(NSPoint c, CGFloat right, CGFloat left, CGFloat up, CGFloat down, CGFloat lim, CGFloat alpha, CGFloat width) { + NSBezierPath *b = [NSBezierPath bezierPath]; + for (int i = 0; i < 72; i++) { + CGFloat a = i * M_PI / 36, cx = cos(a), sy = sin(a); + NSPoint q = NSMakePoint(c.x + clampf(cx * (cx >= 0 ? right : left), -lim, lim), c.y + clampf(sy * (sy >= 0 ? up : down), -lim, lim)); + if (i) [b lineToPoint:q]; else [b moveToPoint:q]; + } + [b closePath]; + [[col_dead() colorWithAlphaComponent:alpha] setFill]; [b fill]; b.lineWidth = width; [col_dead() setStroke]; [b stroke]; +} + +@interface DmCtlStickView : NSView +@property (nonatomic) int kind; +@property (nonatomic) sw2_picture pic; +@property (nonatomic) sw2_edge edge; +@end +@implementation DmCtlStickView +- (void)drawRect:(NSRect)dirty { + (void)dirty; + NSSize s = self.bounds.size; NSPoint c = NSMakePoint(s.width / 2, s.height / 2); // y up: up on the stick is up on the screen + sw2_picture p = _pic; + if (_kind == SW2_VIEW_RANGE) { // the travel as a circle (each side scaled to its own travel), the dead zone, the stick as a dot + CGFloat r = fmin(s.width, s.height) / 2 - 6; + NSBezierPath *disc = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(c.x - r, c.y - r, 2 * r, 2 * r)]; + [col_fill() setFill]; [disc fill]; [col_edge() setStroke]; [disc stroke]; + crosshair(c, r); + dead_zone(c, r * p.dead, r * p.dead, r * p.dead, r * p.dead, r, 0.25, 0.7); // round here: each side is drawn over its own travel + CGFloat sx = p.x / (p.x >= 0 ? p.right : p.left), sy = p.y / (p.y >= 0 ? p.up : p.down); + dot(NSMakePoint(c.x + r * clampf(sx, -1.1, 1.1), c.y + r * clampf(sy, -1.1, 1.1)), 4.5, col_live()); + } else if (_kind == SW2_VIEW_CENTRE) { // the nominal travel over 12 each way: the dead zone, the rest markers, the stick; anything further out sits on the edge + CGFloat r = fmin(s.width, s.height) / 2 - 6, k = r / (SW2_TRAVEL / SW2_ZOOM); // points per count + NSRect frame = NSMakeRect(c.x - r, c.y - r, 2 * r, 2 * r); + [col_fill() setFill]; NSRectFillUsingOperation(frame, NSCompositingOperationSourceOver); + NSBezierPath *f = [NSBezierPath bezierPathWithRect:frame]; f.lineWidth = 1; [col_edge() setStroke]; [f stroke]; + dead_zone(c, p.dead * p.right * k, p.dead * p.left * k, p.dead * p.up * k, p.dead * p.down * k, r, 0.15, 1); // in counts: each side's share of its own travel + crosshair(c, r); + for (int i = 0; i < p.n; i++) dot(NSMakePoint(c.x + clampf(p.mx[i] * k, -r, r), c.y + clampf(p.my[i] * k, -r, r)), 3.2, p.past[i] ? col_past() : col_inside()); + dot(NSMakePoint(c.x + clampf(p.x * k, -r, r), c.y + clampf(p.y * k, -r, r)), 4.5, col_live()); + } else { // the edges step: the travel as a dashed circle, the turns as an arc (two: the whole circle), the reach each way as ticks (orange while short), the stick + sw2_edge e = _edge; CGFloat r = fmin(s.width, s.height) / 2 - 8; + NSBezierPath *ring = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(c.x - r, c.y - r, 2 * r, 2 * r)]; + CGFloat dash[2] = {3, 3}; [ring setLineDash:dash count:2 phase:0]; ring.lineWidth = 1; [col_edge() setStroke]; [ring stroke]; + if (e.turns > 0) { + NSBezierPath *arc = [NSBezierPath bezierPath]; + [arc appendBezierPathWithArcWithCenter:c radius:r - 2 startAngle:0 endAngle:-360 * fmin(e.turns, 2) / 2 clockwise:YES]; + arc.lineWidth = 3; [col_live() setStroke]; [arc stroke]; + } + const CGFloat dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1}; const int bit[4] = {S2_REACH_SHORT_RIGHT, S2_REACH_SHORT_LEFT, S2_REACH_SHORT_UP, S2_REACH_SHORT_DOWN}; + for (int i = 0; i < 4; i++) { + CGFloat v = fmin(e.reach[i], 1.2); NSPoint q = NSMakePoint(c.x + dx[i] * r * v, c.y + dy[i] * r * v), n = NSMakePoint(dy[i] * 5, dx[i] * 5); + NSBezierPath *t = [NSBezierPath bezierPath]; [t moveToPoint:NSMakePoint(q.x - n.x, q.y - n.y)]; [t lineToPoint:NSMakePoint(q.x + n.x, q.y + n.y)]; + t.lineWidth = 2; [((e.shrt & bit[i]) ? col_inside() : col_dead()) setStroke]; [t stroke]; + } + dot(NSMakePoint(c.x + r * clampf(e.x, -1.1, 1.1), c.y + r * clampf(e.y, -1.1, 1.1)), 4.5, col_live()); + } +} +@end + +// A rounded box behind each part; flipped, so the parts lay out from the top +@interface DmCtlBox : NSView @end +@implementation DmCtlBox +- (BOOL)isFlipped { return YES; } +- (void)drawRect:(NSRect)dirty { (void)dirty; [NSColor.controlBackgroundColor setFill]; [[NSBezierPath bezierPathWithRoundedRect:self.bounds xRadius:10 yRadius:10] fill]; } +@end +@interface DmCtlFlip : NSView @end +@implementation DmCtlFlip +- (BOOL)isFlipped { return YES; } +@end + +// ---- the panel ---- + +// The SHORT bits of s2_reach_travel in words +static NSString *short_words(int f) { + NSMutableArray *sides = [NSMutableArray new]; + if (f & S2_REACH_SHORT_RIGHT) [sides addObject:@"right"]; + if (f & S2_REACH_SHORT_LEFT) [sides addObject:@"left"]; + if (f & S2_REACH_SHORT_UP) [sides addObject:@"up"]; + if (f & S2_REACH_SHORT_DOWN) [sides addObject:@"down"]; + NSString *list = sides.count < 2 ? [sides componentsJoinedByString:@""] + : [NSString stringWithFormat:@"%@ and %@", [[sides subarrayWithRange:NSMakeRange(0, sides.count - 1)] componentsJoinedByString:@", "], sides.lastObject]; + return [NSString stringWithFormat:@"Not all the way out yet: push the stick further %@, as far as it goes, and keep turning.", list]; +} + +// An outcome in plain words (the session reads the stick's calibration again after any of them, so the game uses what the controller holds now) +static NSString *outcome_words(S2CalOutcome o) { + switch (o) { + case S2CalSaved: return @"Saved in the controller: the game uses the new calibration now."; + case S2CalUnchanged: return @"Nothing changed: the controller's calibration is as it was."; + case S2CalRestored: return @"The controller did not keep the new calibration, so the old one was restored from the backup. Nothing changed."; + case S2CalRestoreFailed: return @"The controller did not keep the new calibration, and the old one could not be restored. Its user calibration is blank, so it uses its factory calibration."; + case S2CalRestoreFailedNotBlank: return @"The controller did not keep the new calibration, and the old one could not be restored: the controller's user calibration may be neither the old nor the new one."; + case S2CalNoBackup: return @"Nothing changed: the controller's calibration could not be backed up (or the controller went away), so nothing was written."; + } + return @"The save ended in a way this window does not know."; +} + +typedef enum { ST_TEST, ST_LETGO, ST_EDGES, ST_REVIEW, ST_SAVING, ST_RESULT } sw2_step; + +@interface DmControllerPanel : NSObject +@property (readonly) BOOL open; +- (void)show; +@end +static DmControllerPanel *g_panel; // created on the first Show Controller Window, kept for the process's lifetime + +@implementation DmControllerPanel { + NSPanel *_panel; + DmCtlFlip *_content; + // the controllers (dm_switch2_controllers) and the one shown: its id, and its serial so the same controller back under a new id keeps its place + dm_sw2_info _list[SW2_MAX]; int _n; + BOOL _hasSel; uint32_t _sel; char _selSerial[15]; + int _stick; // 0: left (or a Joy-Con 2's only stick), 1: a Pro Controller 2's right stick + uint16_t _rawX, _rawY; + float _mx[SW2_MARKERS], _my[SW2_MARKERS]; int _nm; // where the stick came to rest after each release, raw counts, oldest first + sw2_step _step; + s2_cal _old, _new; // review: the calibration now and the one Save writes + NSString *_who; // saving and result: the controller saved to (kind and serial), captured at Save: it may be gone by the outcome + S2CalOutcome _outcome; NSString *_backup; + NSString *_hint; // the calibration's advice, or why it stopped + double _stillFor; // let go: the share of the rest time the stick has kept still + s2_release _release; s2_stick_rest _rest; s2_reach _reach; s2_turns _turns; + s2_cal _centred; // let go's result: the controller's calibration moved to the rest; the edges are measured from it + s2_cal _limits; // the factory calibration of the stick being calibrated: what its new travel is judged against (s2_reach_travel) + // views + NSPopUpButton *_picker; NSTextField *_kindLabel; NSSegmentedControl *_sticks; + DmCtlBox *_wait; NSTextField *_waitTitle, *_waitBody; + DmCtlBox *_live; NSTextField *_liveTitle, *_rangeCap, *_centreCap, *_readout, *_tally, *_deadName, *_deadValue; DmCtlStickView *_range, *_centre; + DmCtlBox *_cal; NSTextField *_calTitle, *_steps, *_whoLabel, *_big, *_text, *_spinText, *_grid, *_foot, *_small, *_hintLabel; NSProgressIndicator *_progress, *_spinner; + DmCtlStickView *_edge; NSButton *_btnL, *_btnR; + NSTextField *_note; + NSString *_pickerSig; // the controllers the picker's items were made from: rebuilt only when they change, not under an open popup at every report + CGFloat _height; // the content's height as update laid it out (fitHeight sizes the panel to it) +} + +// ---- the controller and stick shown ---- +- (const dm_sw2_info *)device { if (!_hasSel) return NULL; for (int i = 0; i < _n; i++) if (_list[i].id == _sel) return &_list[i]; return NULL; } +- (BOOL)twoSticks { const dm_sw2_info *d = [self device]; return d && d->pid == S2_PID_PRO; } +- (BOOL)rightStick { return [self twoSticks] && _stick == 1; } +// The stick's slot: its raw position, its calibration and its factory one are all at this index (a right Joy-Con 2's only stick is index 0 too: pad_switch2_apple.m fills it so) +- (int)slot { return [self rightStick] ? 1 : 0; } +// The calibration the game uses for this stick: the one read, or the defaults s2_cal_apply falls back to +- (s2_cal)calibration { const dm_sw2_info *d = [self device]; s2_cal c; if (d && d->cal[[self slot]].valid) c = d->cal[[self slot]]; else { s2_cal_default(&c); if (d) c.dead = d->cal[[self slot]].dead; } return c; } +- (BOOL)otherSaving { const dm_sw2_info *d = [self device]; return d && d->saving; } // a save on this controller runs that this panel did not start (DM_SW2_CALIBRATE's), and a second is refused +- (BOOL)canCalibrate { const dm_sw2_info *d = [self device]; return d && d->cal[[self slot]].valid && !d->saving; } +- (BOOL)canSave { return ![self rightStick] && ![self otherSaving]; } +- (BOOL)busy { return _step == ST_SAVING; } +- (BOOL)holdsOutcome { return _step == ST_SAVING || _step == ST_RESULT; } // stays on screen until Done, whichever controller is shown or connected +- (BOOL)calibrating { return _step == ST_LETGO || _step == ST_EDGES || _step == ST_REVIEW || _step == ST_SAVING; } +// Whether the game would see a stick resting at (x, y) move: s2_cal_apply with the stick's dead zone gives anything but zero +- (BOOL)past:(float)x y:(float)y { + s2_cal c = [self calibration]; float ox, oy; + s2_cal_apply(&c, (uint16_t)clampf(roundf(x), 0, 4095), (uint16_t)clampf(roundf(y), 0, 4095), &ox, &oy); + return ox != 0 || oy != 0; +} + +// ---- the session's news ---- +- (void)refresh { + _n = dm_switch2_controllers(_list, SW2_MAX); + if (![self device]) { + BOOL lost = _hasSel && [self calibrating] && ![self busy]; + int pick = -1; + for (int i = 0; i < _n && pick < 0; i++) if (_hasSel && !strcmp(_list[i].serial, _selSerial)) pick = i; // the same controller, reconnected + if (pick < 0 && _n) pick = 0; + _hasSel = pick >= 0; + if (_hasSel) { _sel = _list[pick].id; memcpy(_selSerial, _list[pick].serial, sizeof _selSerial); } + [self restart:lost ? @"The controller went away, so the calibration stopped." : nil]; + } + if (_stick == 1 && ![self twoSticks]) { _stick = 0; [self restart:nil]; } + [self takeRaw]; +} +// The stick's position as the session last had it: a controller or stick just picked shows where it is before its next report +- (void)takeRaw { const dm_sw2_info *d = [self device]; if (d) { _rawX = d->stick[[self slot]][0]; _rawY = d->stick[[self slot]][1]; } } + +// Back to the live view with no markers (another controller or stick, or the calibration given up). A save running or its outcome stays: only Done clears it +- (void)restart:(NSString *)why { + _nm = 0; memset(&_release, 0, sizeof _release); + if ([self holdsOutcome]) return; + _step = ST_TEST; _hint = why; +} + +- (void)report:(uint32_t)cid { + if (!_open || !_hasSel || cid != _sel) return; + _n = dm_switch2_controllers(_list, SW2_MAX); + const dm_sw2_info *d = [self device]; if (!d) return; + int s = [self slot]; _rawX = d->stick[s][0]; _rawY = d->stick[s][1]; + s2_cal c = [self calibration]; float out[2]; BOOL full = NO; + switch (_step) { + case ST_TEST: case ST_RESULT: + if (s2_release_update(&_release, &c, _rawX, _rawY, d->clock_ms, out)) { + if (_nm == SW2_MARKERS) { memmove(_mx, _mx + 1, sizeof _mx - sizeof *_mx); memmove(_my, _my + 1, sizeof _my - sizeof *_my); _nm--; } + _mx[_nm] = out[0]; _my[_nm] = out[1]; _nm++; full = YES; // the tally's text may wrap differently + } + break; + case ST_LETGO: full = [self letGo:d->clock_ms read:d->cal[s] factory:d->factory[s]]; break; + case ST_EDGES: full = [self edges]; break; + default: break; + } + if (full) [self update]; else [self updateLive]; +} + +// ---- the calibration ---- +- (void)calibrate:(id)sender { (void)sender; if (![self canCalibrate] || [self calibrating]) return; memset(&_rest, 0, sizeof _rest); _stillFor = 0; _hint = nil; _step = ST_LETGO; [self update]; } +- (void)cancel:(id)sender { (void)sender; if ([self busy]) return; [self restart:nil]; [self update]; } +- (void)edgesAgain { memset(&_reach, 0, sizeof _reach); memset(&_turns, 0, sizeof _turns); _hint = nil; } +- (void)startOver:(id)sender { (void)sender; [self edgesAgain]; [self update]; } + +// Let go: two seconds still, near the centre, and a centre that leaves travel each way; returns whether the panel's text changed +- (BOOL)letGo:(uint32_t)ms read:(s2_cal)read factory:(s2_cal)factory { + float out[2]; + BOOL done = s2_stick_rest_update(&_rest, _rawX, _rawY, ms, out) != 0; + _stillFor = _rest.n > 0 ? fmin(1, (double)(uint32_t)(ms - _rest.t0) / S2_STICK_REST_MS) : 0; + if (!done) return NO; + memset(&_rest, 0, sizeof _rest); _stillFor = 0; + if (!s2_stick_rest_near(&read, out[0], out[1])) { _hint = @"The stick came to rest far from its centre, as if held: let go of it completely."; return YES; } + s2_cal moved; + if (!s2_cal_recentre(&read, out[0], out[1], &moved)) { _hint = @"That centre would leave the stick too little travel on one side: let go of it and wait again."; return YES; } + _centred = moved; _limits = factory; [self edgesAgain]; _step = ST_EDGES; + return YES; +} + +- (sw2_edge)edgePicture { + sw2_edge e; s2_cal c = _centred; + e.x = s2_share((float)_rawX - c.cx, c.maxx, c.minx); e.y = s2_share((float)_rawY - c.cy, c.maxy, c.miny); e.turns = _turns.turns; + e.reach[0] = _reach.up[0] / fmaxf(c.maxx, 1); e.reach[1] = _reach.down[0] / fmaxf(c.minx, 1); e.reach[2] = _reach.up[1] / fmaxf(c.maxy, 1); e.reach[3] = _reach.down[1] / fmaxf(c.miny, 1); + e.shrt = s2_reach_short(&_reach, &_limits); + return e; +} + +// Edges: the reach each way and the turns; after two turns the travel, or what is wrong with it. Returns whether the panel's text changed +- (BOOL)edges { + s2_reach_update(&_reach, &_centred, _rawX, _rawY); + sw2_edge e = [self edgePicture]; float was = _turns.turns; + s2_turns_update(&_turns, e.x, e.y); + BOOL changed = (was < 1) != (_turns.turns < 1) || (was < S2_TURNS_WANTED) != (_turns.turns < S2_TURNS_WANTED); // the turn label's text + if (_turns.turns < S2_TURNS_WANTED) return changed; + s2_cal nc; int f = s2_reach_travel(&_reach, &_centred, &_limits, &nc); + const dm_sw2_info *d = [self device]; NSString *wasHint = _hint; + if (f == 0 && d) { _hint = nil; _old = d->cal[[self slot]]; _new = nc; _step = ST_REVIEW; return YES; } + if (f & S2_REACH_TOO_FAR) { [self edgesAgain]; _hint = @"The stick read further out than its edge can be, so the circles started over: rotate it slowly around its edge, twice."; return YES; } + if (f) _hint = short_words(f); + return changed || ![_hint isEqualToString:wasHint]; +} + +// Save: the controller is named now (kind and serial), since the outcome must say which one even after it went away (a disconnect mid-save fires the session's changed +// while the save still runs, with the controller already off the list) +- (void)save:(id)sender { + (void)sender; + const dm_sw2_info *d = [self device]; + if (_step != ST_REVIEW || ![self canSave] || !d) return; + _who = [NSString stringWithFormat:@"%s %s", d->kind, d->serial]; _step = ST_SAVING; _hint = nil; _backup = nil; + __weak DmControllerPanel *w = self; + dm_switch2_save(d->id, [self slot], _new, ^(S2CalOutcome o, NSString *path) { + DmControllerPanel *s = w; if (!s) return; + s->_step = ST_RESULT; s->_outcome = o; s->_backup = path; memset(&s->_release, 0, sizeof s->_release); + [s update]; + }); + [self update]; +} +// Done after a save: its outcome goes, and the markers with it +- (void)dismiss:(id)sender { (void)sender; if (_step != ST_RESULT) return; _nm = 0; memset(&_release, 0, sizeof _release); _step = ST_TEST; _hint = nil; _who = nil; _backup = nil; [self update]; } +- (void)showBackup:(id)sender { (void)sender; if (_backup) [NSWorkspace.sharedWorkspace activateFileViewerSelectingURLs:@[[NSURL fileURLWithPath:_backup]]]; } + +// ---- the picker and the stick switch ---- +- (void)picked:(NSPopUpButton *)p { + int i = (int)p.selectedTag; if (i < 0 || i >= _n || (_hasSel && _list[i].id == _sel)) return; + _hasSel = YES; _sel = _list[i].id; memcpy(_selSerial, _list[i].serial, sizeof _selSerial); + if (_stick == 1 && _list[i].pid != S2_PID_PRO) _stick = 0; + [self restart:nil]; [self takeRaw]; [self update]; +} +- (void)stickPicked:(NSSegmentedControl *)s { int v = (int)s.selectedSegment; if (v == _stick || v < 0) return; _stick = v; [self restart:nil]; [self takeRaw]; [self update]; } + +// ---- building ---- +static NSTextField *label(CGFloat size, NSFontWeight w, NSColor *c) { + NSTextField *l = [NSTextField wrappingLabelWithString:@""]; + l.font = [NSFont systemFontOfSize:size weight:w]; l.textColor = c; l.selectable = NO; // a selectable label would make the panel key on a click and keep the keyboard from the game + return l; +} +static NSButton *button(NSString *title, id target) { NSButton *b = [NSButton buttonWithTitle:title target:target action:nil]; return b; } + +- (void)makePanel { + _panel = [[NSPanel alloc] initWithContentRect:NSMakeRect(0, 0, 690, 520) styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskUtilityWindow + backing:NSBackingStoreBuffered defer:NO]; + _panel.title = @"Controller"; + _panel.releasedWhenClosed = NO; // the close button only hides (windowShouldClose) + _panel.floatingPanel = YES; // above the game window + _panel.becomesKeyOnlyIfNeeded = YES; // a click never takes the key window: the game keeps the keyboard + _panel.collectionBehavior = NSWindowCollectionBehaviorFullScreenAuxiliary; // shows over the fullscreen game too + _panel.delegate = self; + _content = [[DmCtlFlip alloc] initWithFrame:NSMakeRect(0, 0, 690, 520)]; + _panel.contentView = _content; + NSColor *pri = NSColor.labelColor, *sec = NSColor.secondaryLabelColor; + + _picker = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; _picker.target = self; _picker.action = @selector(picked:); + _kindLabel = [NSTextField labelWithString:@""]; _kindLabel.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + _sticks = [NSSegmentedControl segmentedControlWithLabels:@[@"Left stick", @"Right stick"] trackingMode:NSSegmentSwitchTrackingSelectOne target:self action:@selector(stickPicked:)]; + + _wait = [DmCtlBox new]; _waitTitle = label(15, NSFontWeightRegular, pri); _waitBody = label(13, NSFontWeightRegular, sec); + for (NSView *v in @[_waitTitle, _waitBody]) [_wait addSubview:v]; + + _live = [DmCtlBox new]; + _liveTitle = label(13, NSFontWeightSemibold, pri); _liveTitle.stringValue = @"Live"; + _range = [[DmCtlStickView alloc] initWithFrame:NSZeroRect]; _range.kind = SW2_VIEW_RANGE; + _centre = [[DmCtlStickView alloc] initWithFrame:NSZeroRect]; _centre.kind = SW2_VIEW_CENTRE; + _range.toolTip = @"The stick's whole range"; _centre.toolTip = @"The stick's centre, zoomed, with where it came to rest after each release"; + _rangeCap = label(10, NSFontWeightRegular, pri); _rangeCap.stringValue = @"Whole range"; _rangeCap.alignment = NSTextAlignmentCenter; + _centreCap = label(10, NSFontWeightRegular, pri); _centreCap.stringValue = @"Centre, zoomed 12×"; _centreCap.alignment = NSTextAlignmentCenter; + _readout = label(13, NSFontWeightRegular, pri); _readout.font = [NSFont monospacedDigitSystemFontOfSize:13 weight:NSFontWeightRegular]; + _tally = label(13, NSFontWeightRegular, sec); + _deadName = label(13, NSFontWeightRegular, pri); _deadName.stringValue = @"Dead zone"; + _deadValue = label(13, NSFontWeightRegular, sec); _deadValue.font = [NSFont monospacedDigitSystemFontOfSize:13 weight:NSFontWeightRegular]; _deadValue.alignment = NSTextAlignmentRight; + _deadValue.toolTip = @"How far the stick moves before the game sees it, as a share of its travel: the stick's own, read from the controller, or 10 % when it gives none"; + for (NSView *v in @[_liveTitle, _range, _centre, _rangeCap, _centreCap, _readout, _tally, _deadName, _deadValue]) [_live addSubview:v]; + + _cal = [DmCtlBox new]; + _calTitle = label(13, NSFontWeightSemibold, pri); _calTitle.stringValue = @"Calibrate"; + _steps = label(12, NSFontWeightRegular, pri); + _whoLabel = label(12, NSFontWeightRegular, sec); + _big = label(15, NSFontWeightRegular, pri); + _text = label(13, NSFontWeightRegular, pri); + _progress = [NSProgressIndicator new]; _progress.style = NSProgressIndicatorStyleBar; _progress.indeterminate = NO; _progress.minValue = 0; _progress.maxValue = 1; + _spinner = [NSProgressIndicator new]; _spinner.style = NSProgressIndicatorStyleSpinning; _spinner.controlSize = NSControlSizeSmall; + _spinText = label(13, NSFontWeightRegular, pri); _spinText.stringValue = @"Saving: backing up, writing, reading back…"; + _edge = [[DmCtlStickView alloc] initWithFrame:NSZeroRect]; _edge.kind = SW2_VIEW_EDGE; _edge.toolTip = @"Turns around the stick's edge"; + _grid = label(13, NSFontWeightRegular, pri); + _foot = label(11, NSFontWeightRegular, sec); + _small = label(13, NSFontWeightRegular, sec); + _hintLabel = label(13, NSFontWeightRegular, NSColor.systemOrangeColor); + _btnL = button(@"", self); _btnR = button(@"", self); + for (NSView *v in @[_calTitle, _steps, _whoLabel, _big, _text, _progress, _spinner, _spinText, _edge, _grid, _foot, _small, _hintLabel, _btnL, _btnR]) [_cal addSubview:v]; + + _note = label(11, NSFontWeightRegular, sec); + _note.stringValue = @"While this window is open the game gets no input from Switch 2 controllers: close it to play."; + for (NSView *v in @[_picker, _kindLabel, _sticks, _wait, _live, _cal, _note]) [_content addSubview:v]; + + __weak DmControllerPanel *w = self; + dm_switch2_observe(^{ DmControllerPanel *s = w; if (!s) return; [s refresh]; if (s->_open) [s update]; }, + ^(uint32_t cid) { [w report:cid]; }); + [self refresh]; + [self update]; // sizes the panel before the saved frame's place is applied + _panel.frameAutosaveName = @"DmControllerPanel"; + if (![_panel setFrameUsingName:@"DmControllerPanel"] && g_game) { // first use: beside the game window, below the Cheats panel's first place + NSRect g = g_game.frame; + [_panel setFrameOrigin:NSMakePoint(NSMaxX(g) + 8, NSMinY(g))]; + } + [self fitHeight]; +} + +// ---- filling and laying out (flipped: y down from the top) ---- + +// v placed at x, y, w wide and as tall as its text wraps to (or h); returns the y below it with gap +static CGFloat place(NSView *v, CGFloat x, CGFloat y, CGFloat w, CGFloat h, CGFloat gap) { + if ([v isKindOfClass:NSTextField.class] && h <= 0) h = ceil([((NSTextField *)v).cell cellSizeForBounds:NSMakeRect(0, 0, w, 10000)].height); + v.frame = NSMakeRect(x, y, w, h); v.hidden = NO; + return y + h + gap; +} +static void hide(NSArray *vs) { for (NSView *v in vs) v.hidden = YES; } + +- (sw2_picture)picture { + sw2_picture p; memset(&p, 0, sizeof p); s2_cal c = [self calibration]; + p.x = (float)_rawX - c.cx; p.y = (float)_rawY - c.cy; p.right = c.maxx; p.left = c.minx; p.up = c.maxy; p.down = c.miny; p.dead = s2_cal_dead_share(&c); + p.n = _nm; + for (int i = 0; i < _nm; i++) { p.mx[i] = _mx[i] - c.cx; p.my[i] = _my[i] - c.cy; p.past[i] = [self past:_mx[i] y:_my[i]]; } + return p; +} +- (NSAttributedString *)tallyText { + NSDictionary *sec = @{NSForegroundColorAttributeName: NSColor.secondaryLabelColor, NSFontAttributeName: [NSFont systemFontOfSize:13]}; + if (!_nm) return [[NSAttributedString alloc] initWithString:@"Push the stick and let it go: each release leaves a dot where it came to rest." attributes:sec]; + int past = 0; for (int i = 0; i < _nm; i++) past += [self past:_mx[i] y:_my[i]]; + NSMutableAttributedString *t = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"Last %d release%s came to rest: %d inside, ", _nm, _nm == 1 ? "" : "s", _nm - past] attributes:sec]; + NSMutableDictionary *p = [sec mutableCopy]; if (past) p[NSForegroundColorAttributeName] = col_past(); + [t appendAttributedString:[[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d past the dead zone", past] attributes:p]]; + return t; +} +- (NSAttributedString *)stepsText { + int place = _step == ST_LETGO ? 1 : _step == ST_EDGES ? 2 : _step == ST_REVIEW || _step == ST_SAVING ? 3 : 0; + NSArray *names = @[@"1 Let go", @"2 Edges", @"3 Review and save"]; + NSMutableAttributedString *t = [NSMutableAttributedString new]; + for (int i = 1; i <= 3; i++) { + if (i > 1) [t appendAttributedString:[[NSAttributedString alloc] initWithString:@" · " attributes:@{NSForegroundColorAttributeName: NSColor.secondaryLabelColor, NSFontAttributeName: [NSFont systemFontOfSize:12]}]]; + [t appendAttributedString:[[NSAttributedString alloc] initWithString:names[i - 1] attributes:@{NSForegroundColorAttributeName: i == place ? NSColor.labelColor : NSColor.secondaryLabelColor, + NSFontAttributeName: [NSFont systemFontOfSize:12 weight:i == place ? NSFontWeightSemibold : NSFontWeightRegular]}]]; + } + return t; +} +// Review: old against new, travel in counts from the centre, in columns +- (NSAttributedString *)gridText { + NSMutableParagraphStyle *ps = [NSMutableParagraphStyle new]; + ps.tabStops = @[[[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft location:64 options:@{}], [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft location:160 options:@{}]]; + ps.paragraphSpacing = 3; + NSFont *f = [NSFont monospacedDigitSystemFontOfSize:13 weight:NSFontWeightRegular]; + NSMutableAttributedString *t = [[NSMutableAttributedString alloc] initWithString:@"\tnow\tnew\n" attributes:@{NSFontAttributeName: f, NSParagraphStyleAttributeName: ps, NSForegroundColorAttributeName: NSColor.secondaryLabelColor}]; + s2_cal o = _old, n = _new; + NSString *rows = [NSString stringWithFormat:@"Centre\t%.0f, %.0f\t%.0f, %.0f\nRight\t%.0f\t%.0f\nLeft\t%.0f\t%.0f\nUp\t%.0f\t%.0f\nDown\t%.0f\t%.0f", + roundf(o.cx), roundf(o.cy), roundf(n.cx), roundf(n.cy), roundf(o.maxx), roundf(n.maxx), roundf(o.minx), roundf(n.minx), roundf(o.maxy), roundf(n.maxy), roundf(o.miny), roundf(n.miny)]; + [t appendAttributedString:[[NSAttributedString alloc] initWithString:rows attributes:@{NSFontAttributeName: f, NSParagraphStyleAttributeName: ps, NSForegroundColorAttributeName: NSColor.labelColor}]]; + return t; +} +// The stick's dead zone: its share of the travel, and whether it is the stick's own (s2_cal_dead_own) or the default +- (NSString *)deadLabel { s2_cal c = [self calibration]; return [NSString stringWithFormat:@"%.1f %% · %@", s2_cal_dead_share(&c) * 100, s2_cal_dead_own(&c) > 0 ? @"the stick's own" : @"the default"]; } + +// The parts that change with every report: the dots, the readout, let go's progress, the edges view and its turns +- (void)updateLive { + if (!_open) return; + s2_cal c = [self calibration]; + _range.pic = _centre.pic = [self picture]; _range.needsDisplay = _centre.needsDisplay = YES; + _readout.stringValue = [NSString stringWithFormat:@"raw %u, %u · off centre %d, %d", _rawX, _rawY, (int)lroundf((float)_rawX - c.cx), (int)lroundf((float)_rawY - c.cy)]; + if (_step == ST_LETGO) _progress.doubleValue = _stillFor; + if (_step == ST_EDGES) { + _edge.edge = [self edgePicture]; _edge.needsDisplay = YES; + _small.stringValue = _turns.turns < 1 ? @"First turn" : _turns.turns < S2_TURNS_WANTED ? @"Second turn" : @"Both turns done"; + } +} + +// Everything: texts, which parts show, their places, the panel's height +- (void)update { + if (!_panel) return; + const CGFloat W = 690, P = 14, LW = 354, CW = 294, IN = 12; + const dm_sw2_info *d = [self device]; + BOOL calibrating = [self calibrating]; + // the top row: the picker with two or more controllers, else the one's kind; Left / Right on a Pro Controller 2 + hide(@[_picker, _kindLabel, _sticks]); + CGFloat x = P; + if (_n > 1) { + NSMutableArray *titles = [NSMutableArray new]; + for (int i = 0; i < _n; i++) [titles addObject:[NSString stringWithFormat:@"%s · %s", _list[i].kind, _list[i].serial]]; + NSString *sig = [titles componentsJoinedByString:@"\n"]; + if (![sig isEqualToString:_pickerSig]) { + _pickerSig = sig; [_picker removeAllItems]; + for (int i = 0; i < _n; i++) { [_picker addItemWithTitle:titles[i]]; _picker.lastItem.tag = i; } // two Joy-Cons of one kind differ by serial, so no title repeats + } + for (int i = 0; i < _n; i++) if (d == &_list[i] && _picker.selectedTag != i) [_picker selectItemWithTag:i]; + [_picker sizeToFit]; place(_picker, x, P, _picker.frame.size.width, 25, 0); x += _picker.frame.size.width + 10; + } else if (d) { + _kindLabel.stringValue = @(d->kind); [_kindLabel sizeToFit]; place(_kindLabel, x, P + 4, _kindLabel.frame.size.width + 4, 0, 0); x += _kindLabel.frame.size.width + 10; + } + if ([self twoSticks]) { _sticks.selectedSegment = _stick; [_sticks sizeToFit]; place(_sticks, x, P + 1, _sticks.frame.size.width, 24, 0); } + _picker.enabled = _sticks.enabled = !calibrating; + CGFloat top = P + 26 + 10, bottom; + + if (!d && ![self holdsOutcome]) { // nothing to show: why, and how to connect one + hide(@[_live, _cal]); + [self fillWait]; + CGFloat y = place(_waitTitle, IN, IN, W - 2 * P - 2 * IN, 0, 6); y = place(_waitBody, IN, y, W - 2 * P - 2 * IN, 0, IN); + bottom = place(_wait, P, top, W - 2 * P, fmax(y, 200 + 2 * IN), 10); + } else { + CGFloat lh; + if (d) { hide(@[_wait]); lh = [self layoutLive:LW inner:IN]; } + else { hide(@[_live]); [self fillWait]; CGFloat y = place(_waitTitle, IN, IN, LW - 2 * IN, 0, 6); y = place(_waitBody, IN, y, LW - 2 * IN, 0, IN); lh = fmax(y, 200 + 2 * IN); } + CGFloat ch = [self layoutCal:CW inner:IN]; + CGFloat h = fmax(lh, ch); + place(d ? _live : _wait, P, top, LW, h, 0); + place(_cal, P + LW + 14, top, CW, h, 0); + [self placeButtons:CW inner:IN height:h]; + bottom = top + h + 10; + } + _height = place(_note, P, bottom, W - 2 * P, 0, P); + [self updateLive]; + [self fitHeight]; +} +- (void)fillWait { + if (!dm_switch2_on()) { + _waitTitle.stringValue = @"Switch 2 controllers are off"; + _waitBody.stringValue = @"Turn them on in the settings window (Controls) and start the game again."; + } else { + _waitTitle.stringValue = @"Press a button on your controller"; + _waitBody.stringValue = @"A controller paired with this Mac connects on a button press. To pair one, hold its small Sync button until the player lights sweep."; + } +} +// The Live box's parts; returns its height +- (CGFloat)layoutLive:(CGFloat)bw inner:(CGFloat)in { + CGFloat w = bw - 2 * in, y = place(_liveTitle, in, in, w, 0, 8); + place(_range, in, y, 150, 150, 0); place(_centre, in + 150 + 12, y, 150, 150, 0); y += 154; + place(_rangeCap, in, y, 150, 0, 0); y = place(_centreCap, in + 150 + 12, y, 150, 0, 8); + y = place(_readout, in, y, w, 0, 6); + _tally.attributedStringValue = [self tallyText]; y = place(_tally, in, y, w, 0, 10); + _deadValue.stringValue = [self deadLabel]; + place(_deadName, in, y, w / 2, 0, 0); y = place(_deadValue, in + w / 2, y, w / 2, 0, 0); + return y + in; +} +// The Calibrate box's parts above its buttons, for the step; returns the height it needs, buttons included +- (CGFloat)layoutCal:(CGFloat)bw inner:(CGFloat)in { + hide(@[_whoLabel, _big, _text, _progress, _spinner, _spinText, _edge, _grid, _foot, _small, _hintLabel, _btnL, _btnR]); + CGFloat w = bw - 2 * in, y = place(_calTitle, in, in, w, 0, 6); + _steps.attributedStringValue = [self stepsText]; y = place(_steps, in, y, w, 0, 10); + NSMutableArray *small = [NSMutableArray new]; + switch (_step) { + case ST_TEST: + _text.stringValue = @"Sets the stick's centre and how far it reaches, as the console's calibration does, and saves them in the controller."; + y = place(_text, in, y, w, 0, 8); + if ([self otherSaving]) [small addObject:@"A save on this controller is still running."]; + else if ([self device] && ![self canCalibrate]) [small addObject:@"This stick's calibration could not be read, so it cannot be calibrated here."]; + if ([self rightStick]) [small addObject:@"Right stick: test only for now"]; + break; + case ST_LETGO: + _big.stringValue = @"Let go of the stick and leave it still."; y = place(_big, in, y, w, 0, 8); + _progress.doubleValue = _stillFor; y = place(_progress, in, y, w, 12, 8); + [small addObject:@"Where it comes to rest becomes its centre."]; + break; + case ST_EDGES: + _big.stringValue = @"Rotate the stick slowly around its edge, twice"; y = place(_big, in, y, w, 0, 8); + y = place(_edge, in + (w - 130) / 2, y, 130, 130, 8); + [small addObject:_turns.turns < 1 ? @"First turn" : _turns.turns < S2_TURNS_WANTED ? @"Second turn" : @"Both turns done"]; + break; + case ST_REVIEW: + _grid.attributedStringValue = [self gridText]; y = place(_grid, in, y, w, 0, 6); _foot.toolTip = nil; + _foot.stringValue = @"Travel in counts from the centre. Save backs up the controller's calibration memory first, then writes and reads it back; if it does not read back as written, the backup goes back in."; + y = place(_foot, in, y, w, 0, 8); + if ([self otherSaving]) [small addObject:@"A save on this controller is still running."]; + if ([self rightStick]) [small addObject:@"Right stick: test only for now"]; + break; + case ST_SAVING: + _whoLabel.stringValue = _who ?: @""; y = place(_whoLabel, in, y, w, 0, 6); + place(_spinner, in, y + 1, 16, 16, 0); [_spinner startAnimation:nil]; y = place(_spinText, in + 22, y, w - 22, 0, 8); + break; + case ST_RESULT: + _whoLabel.stringValue = _who ?: @""; y = place(_whoLabel, in, y, w, 0, 6); + _text.stringValue = outcome_words(_outcome); y = place(_text, in, y, w, 0, 6); + if (_backup) { + BOOL failed = _outcome == S2CalRestoreFailed || _outcome == S2CalRestoreFailedNotBlank; + _foot.stringValue = [NSString stringWithFormat:failed ? @"The backup is at %@" : @"Backup: %@", _backup]; _foot.toolTip = _backup; + y = place(_foot, in, y, w, 0, 8); + } + break; + } + if (_step != ST_SAVING) [_spinner stopAnimation:nil]; + if (small.count) { _small.stringValue = [small componentsJoinedByString:@"\n"]; y = place(_small, in, y, w, 0, 8); } + if (_hint) { _hintLabel.stringValue = _hint; y = place(_hintLabel, in, y, w, 0, 8); } + return fmax(y + 32 + in, 330 + 2 * in); // the buttons' row below; at least 330 points, so the steps do not jump in height +} +// The step's buttons, at the bottom right of the Calibrate box +- (void)placeButtons:(CGFloat)bw inner:(CGFloat)in height:(CGFloat)h { + NSButton *l = _btnL, *r = _btnR; SEL ls = NULL, rs = NULL; NSString *lt = nil, *rt = nil; BOOL ron = YES; + switch (_step) { + case ST_TEST: rt = @"Calibrate…"; rs = @selector(calibrate:); ron = [self canCalibrate]; break; + case ST_LETGO: rt = @"Cancel"; rs = @selector(cancel:); break; + case ST_EDGES: lt = @"Cancel"; ls = @selector(cancel:); rt = @"Start over"; rs = @selector(startOver:); break; + case ST_REVIEW: lt = @"Cancel"; ls = @selector(cancel:); rt = @"Save"; rs = @selector(save:); ron = [self canSave]; break; + case ST_SAVING: break; + case ST_RESULT: if (_backup) { lt = @"Show Backup"; ls = @selector(showBackup:); } rt = @"Done"; rs = @selector(dismiss:); break; + } + CGFloat right = bw - in + 6; // a push button's frame reaches about 6 points past its bezel each side: frames side by side look 12 apart + if (rt) { r.title = rt; r.action = rs; r.enabled = ron; [r sizeToFit]; NSSize s = r.frame.size; place(r, right - s.width, h - in - s.height + 4, s.width, s.height, 0); right -= s.width; } + if (lt) { l.title = lt; l.action = ls; l.enabled = YES; [l sizeToFit]; NSSize s = l.frame.size; place(l, right - s.width, h - in - s.height + 4, s.width, s.height, 0); } +} +// The panel as tall as its parts, its top edge kept +- (void)fitHeight { + NSRect c = [_panel contentRectForFrameRect:_panel.frame]; const CGFloat w = 690; + if (fabs(c.size.height - _height) < 0.5 && fabs(c.size.width - w) < 0.5) return; + [_panel setFrame:[_panel frameRectForContentRect:NSMakeRect(c.origin.x, NSMaxY(c) - _height, w, _height)] display:YES]; +} + +// ---- shown and closed ---- + +// Shown: ordered front, not key, so the game keeps the keyboard. Opening it takes the Switch 2 controllers from the game: their state goes neutral once, then front_mac.m sends +// the game nothing from them while it is open (sw2_panel_open); the releases it marks start from the next push +- (void)show { + if (!_panel) [self makePanel]; + if (!_open) { + _open = YES; memset(&_release, 0, sizeof _release); + if (g_release) g_release(); + LOG("front end: Controller window open: the game gets no Switch 2 controller input until it closes"); + } + [self refresh]; + [self update]; + [_panel orderFront:self]; +} +// The close button: hide, not close. A calibration under way is given up (the stick goes back to the game); a save runs on, and its outcome waits for the next open +- (BOOL)windowShouldClose:(NSWindow *)w { + if (w != _panel) return YES; + if (_step == ST_LETGO || _step == ST_EDGES || _step == ST_REVIEW) [self restart:nil]; + _open = NO; + if (g_game && _panel.isKeyWindow) [g_game makeKeyWindow]; + [_panel orderOut:nil]; + LOG("front end: Controller window closed: Switch 2 controllers reach the game again"); + return NO; +} +// Became key anyway (Full Keyboard Access on a button, say): the keyboard goes back to the game a main-queue turn later, once the click has settled +- (void)windowDidBecomeKey:(NSNotification *)n { (void)n; dispatch_async(dispatch_get_main_queue(), ^{ if (g_game && self->_panel.isKeyWindow) [g_game makeKeyWindow]; }); } +@end + +// ---- the Controller menu: rebuilt on every open, so the list is the session's now ---- + +@interface DmControllerMenuDelegate : NSObject @end +@implementation DmControllerMenuDelegate +- (void)menuNeedsUpdate:(NSMenu *)menu { + [menu removeAllItems]; + NSMenuItem *sh = [menu addItemWithTitle:@"Show Controller Window" action:@selector(showPanel:) keyEquivalent:@"p"]; + sh.target = self; + sh.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagShift; // ⌘⇧P: no other menu item has it (⌘Q, ⌘⇧C, ⌘R, ⌃⌘F), and the key map never sees ⌘ + [menu addItem:NSMenuItem.separatorItem]; + if (!dm_switch2_on()) { [menu addItemWithTitle:@"Switch 2 controllers are off: turn them on in the settings window (Controls)" action:nil keyEquivalent:@""].enabled = NO; return; } + dm_sw2_info l[SW2_MAX]; int n = dm_switch2_controllers(l, SW2_MAX); + if (!n) [menu addItemWithTitle:@"No Switch 2 controller connected: press a button on one" action:nil keyEquivalent:@""].enabled = NO; + for (int i = 0; i < n; i++) { + size_t len = strlen(l[i].serial); const char *tail = l[i].serial + (len > 4 ? len - 4 : 0); + NSString *who = l[i].player >= 0 ? [NSString stringWithFormat:@"player %d", l[i].player + 1] : @"no player"; + [menu addItemWithTitle:[NSString stringWithFormat:@"%s · …%s · %@", l[i].kind, tail, who] action:nil keyEquivalent:@""].enabled = NO; + } +} +- (void)showPanel:(NSMenuItem *)it { (void)it; sw2_panel_show(); } +@end +static DmControllerMenuDelegate *g_menu; // NSMenu keeps its delegate unretained + +void sw2_panel_setup(NSWindow *game, void (*release)(void)) { g_game = game; g_release = release; } +NSMenu *sw2_panel_menu(void) { + g_menu = [DmControllerMenuDelegate new]; + NSMenu *m = [[NSMenu alloc] initWithTitle:@"Controller"]; + m.delegate = g_menu; + m.autoenablesItems = NO; // the delegate sets enabled on every row itself + [g_menu menuNeedsUpdate:m]; // AppKit matches ⌘⇧P only against items that exist, and fills a delegate's menu only when it is opened + return m; +} +void sw2_panel_show(void) { if (!g_panel) g_panel = [DmControllerPanel new]; [g_panel show]; } +int sw2_panel_open(void) { return g_panel && g_panel.open; } diff --git a/dm/host/switch2_ops_apple.h b/dm/host/switch2_ops_apple.h new file mode 100644 index 0000000000..2647ae1733 --- /dev/null +++ b/dm/host/switch2_ops_apple.h @@ -0,0 +1,37 @@ +// Operations on a Switch 2 controller's memory and bond, one implementation for every client that talks to a controller (the host's CoreBluetooth session in +// pad_switch2_apple.m, and t_switch2_ops.m's fake flash): verified reads, the user stick calibration's backup -> write -> read back -> restore, and bonding. The client sends the commands +// (S2Commander); everything here is Foundation and switch2_proto.h only. Replies and completions come on the queue the commander answers on (the main queue in the host). +// Each operation keeps its commander until it completes: a commander whose link is gone must answer nil to everything, so that a save in progress ends (and says so). +#pragma once +#import +#include "switch2_proto.h" + +#define S2_CAL_TRIES 4 // each read of a verified read is tried this often (a read times out while the game's start keeps the main queue busy) + +// A controller to talk to: one command in flight at a time, each answered once +@protocol S2Commander +// The command frame's reply (from its 8-byte header on); nil reply: no answer (timed out, or the link is gone) +- (void)send:(uint8_t)cmd sub:(uint8_t)sub payload:(const uint8_t *)pl len:(size_t)n done:(void (^)(NSData *reply))done; +- (NSUInteger)writeChunk; // data bytes one memory write carries (16..64, a multiple of 16) +@end + +typedef NS_ENUM(NSInteger, S2CalOutcome) { + S2CalSaved, // written and read back as written: the controller uses it from now on + S2CalUnchanged, // the memory reads as before: nothing changed + S2CalRestored, // it did not read as written; the backup was put back and reads back + S2CalRestoreFailed, // it did not read as written and the backup could not be put back (the file is at the backup path); the sector reads blank, so the + // controller uses its factory calibration + S2CalNoBackup, // the sector could not be read or the backup saved: nothing was written + S2CalRestoreFailedNotBlank, // as S2CalRestoreFailed, but the sector does not read blank (or could not be read): its user calibration may be neither the old nor the new one +}; + +NSURL *s2op_backup_dir(void); // Application Support//Switch 2 calibration +// n bytes from addr in reads of 0x40, each tried up to tries times (S2_CAL_TRIES for a save; 1 for the handshake's bounded wait) and taken only when the reply carries the read's length and address; nil when one never answers. log (may be nil) gets each refused reply and each read given up +void s2op_read(id c, uint32_t addr, NSUInteger n, int tries, void (^log)(NSString *), void (^done)(NSData *)); +// cal as the user calibration of the first stick slot (0x1FC040). The sector is backed up to a file in dir named after serial first; a blank block is written straight, +// otherwise the sector is erased and written back whole; it is then read back, and put back from the backup when it does not read as written. Nothing is written after an +// erase unless the sector then reads blank (an erase answered is not an erase done). log gets each step's line; done (once) the outcome and the backup's path (nil with +// S2CalNoBackup) +void s2op_save_user_cal(id c, const char *serial, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *backupPath)); +// Bonds the controller to host (its Bluetooth address, little endian) with fresh keys, as the console does (switch2_proto.h, Bonding); done(NO) when a step got no answer +void s2op_bond(id c, const uint8_t host[6], void (^log)(NSString *), void (^done)(BOOL)); diff --git a/dm/host/switch2_ops_apple.m b/dm/host/switch2_ops_apple.m new file mode 100644 index 0000000000..d19264f3b2 --- /dev/null +++ b/dm/host/switch2_ops_apple.m @@ -0,0 +1,158 @@ +// Operations on a Switch 2 controller's memory and bond (switch2_ops_apple.h). Every block holds its commander until the operation completes; a commander answers each +// command once, nil when there is no answer. +#import "switch2_ops_apple.h" + +static void s2op_log(void (^log)(NSString *), NSString *fmt, ...) NS_FORMAT_FUNCTION(2, 3); +static void s2op_log(void (^log)(NSString *), NSString *fmt, ...) { + if (!log) return; + va_list ap; va_start(ap, fmt); NSString *m = [[NSString alloc] initWithFormat:fmt arguments:ap]; va_end(ap); + log(m); +} + +// ---- reads ---- +// len bytes at addr, taken only when the reply carries this read's length and address (a late reply to a read that timed out is not another read's data). A reply refused +// logs its bytes 8-15, so a controller that lays them out otherwise shows in the log rather than as a calibration that "does not read" +static void s2op_read_exact(id c, uint8_t len, uint32_t addr, void (^log)(NSString *), void (^done)(NSData *)) { + uint8_t p[8]; s2_memory_payload(p, len, addr); + [c send:S2_CMD_MEMORY sub:S2_SUB_MEMORY_READ payload:p len:8 done:^(NSData *r) { + if (s2_memory_reply_for(r.bytes, r.length, len, addr)) { done([r subdataWithRange:NSMakeRange(S2_REPLY_DATA, len)]); return; } + if (r.length >= 16) { const uint8_t *b = r.bytes; + s2op_log(log, @"the read of 0x%x bytes at 0x%06x got a reply for another (%zu bytes, 8-15: %02x %02x %02x %02x %02x %02x %02x %02x)", len, addr, (size_t)r.length, b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); } + done(nil); + }]; +} +static void s2op_read_from(id c, uint32_t addr, NSUInteger n, NSMutableData *out, int attempt, int tries, void (^log)(NSString *), void (^done)(NSData *)) { + if (out.length >= n) { done([out copy]); return; } + uint8_t len = (uint8_t)MIN((NSUInteger)0x40, n - out.length); uint32_t at = addr + (uint32_t)out.length; + s2op_read_exact(c, len, at, log, ^(NSData *d) { + if (d) { [out appendData:d]; s2op_read_from(c, addr, n, out, 1, tries, log, done); return; } + if (attempt >= tries) { s2op_log(log, @"the read of 0x%x bytes at 0x%06x got no answer %d times", len, at, attempt); done(nil); return; } + s2op_read_from(c, addr, n, out, attempt + 1, tries, log, done); + }); +} +void s2op_read(id c, uint32_t addr, NSUInteger n, int tries, void (^log)(NSString *), void (^done)(NSData *)) { s2op_read_from(c, addr, n, [NSMutableData data], 1, tries, log, done); } + +// ---- write and erase ---- +// data written at addr from off; skip (after an erase): chunks that are all 0xFF are already so +static void s2op_write(id c, NSData *data, uint32_t addr, NSUInteger off, BOOL skip, void (^done)(BOOL)) { + NSUInteger chunk = [c writeChunk]; + if (!chunk) { done(NO); return; } + while (skip && off < data.length && s2_all_ff((const uint8_t *)data.bytes + off, MIN(chunk, data.length - off))) off += chunk; + if (off >= data.length) { done(YES); return; } + NSUInteger n = MIN(chunk, data.length - off); + uint8_t pl[8 + 0x80]; size_t pn = s2_memory_write_payload(pl, sizeof pl, addr + (uint32_t)off, (const uint8_t *)data.bytes + off, n); + if (!pn) { done(NO); return; } + [c send:S2_CMD_MEMORY sub:S2_SUB_MEMORY_WRITE payload:pl len:pn done:^(NSData *r) { + if (!r) { done(NO); return; } + s2op_write(c, data, addr, off + n, skip, done); + }]; +} +// The user calibration sector erased, then read back: done gets what it reads now (nil when it could not be read). An answer is no proof of the erase, and a write over +// bytes that were not erased leaves old & new (flash clears bits), a calibration that is neither: the caller writes only into a sector that reads blank +static void s2op_erase_sector(id c, void (^log)(NSString *), void (^done)(NSData *)) { + uint8_t pl[8]; s2_memory_erase_payload(pl, S2_ADDR_USER_CAL_SECTOR); + [c send:S2_CMD_MEMORY sub:S2_SUB_MEMORY_ERASE payload:pl len:8 done:^(NSData *r) { + if (!r) s2op_log(log, @"the erase got no answer (it may have happened all the same)"); + s2op_read_from(c, S2_ADDR_USER_CAL_SECTOR, S2_SECTOR_SIZE, [NSMutableData data], 1, S2_CAL_TRIES, log, done); + }]; +} +static BOOL s2op_blank(NSData *d) { return d && s2_all_ff(d.bytes, d.length); } + +// ---- the user stick calibration (switch2_proto.h, Writing a user stick calibration) ---- +NSURL *s2op_backup_dir(void) { + NSURL *base = [NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; + return [base URLByAppendingPathComponent:[NSString stringWithFormat:@"%@/Switch 2 calibration", NSBundle.mainBundle.bundleIdentifier ?: @"nxnative"] isDirectory:YES]; +} +// A file per save: the oldest is the controller as it came +static NSString *s2op_back_up(NSData *sector, NSURL *dir, NSString *serial) { + NSFileManager *fm = NSFileManager.defaultManager; + if (!dir || ![fm createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:nil]) return nil; + NSURL *f = [dir URLByAppendingPathComponent:[NSString stringWithFormat:@"%@-1FC000-%.0f.bin", serial, NSDate.date.timeIntervalSince1970]]; + return f && [sector writeToURL:f atomically:YES] ? f.path : nil; +} +// How a restore that did not bring the backup back left the sector: blank (the factory calibration is used: S2CalRestoreFailed), or anything else, including a sector that +// could not be read (S2CalRestoreFailedNotBlank) +static void s2op_restore_failed(NSData *now, NSString *path, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *)) { + if (s2op_blank(now)) { + s2op_log(log, @"the backup could not be put back; it is at %@. The user calibration is blank, so the controller uses its factory calibration", path); + done(S2CalRestoreFailed, path); return; + } + s2op_log(log, @"the backup could not be put back (%@): the controller's user calibration may be neither the old nor the new one; the backup is at %@", + now ? @"the sector reads otherwise" : @"the sector could not be read", path); + // No answer to the last read: the link went (or the controller stopped answering) after the memory may have been erased or written + if (!now) s2op_log(log, @"interrupted: the controller stopped answering during the save, its user calibration may be blank or half written, backup at %@", path); + done(S2CalRestoreFailedNotBlank, path); +} +// The sector read back: as written, the calibration is used from now on; as before, nothing changed; anything else, the backup is put back (erase, read blank, write, read +// again). A sector that does not read blank after the erase is not written over +static void s2op_settle(id c, NSData *want, NSData *old, NSString *path, s2_cal cal, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *)) { + s2op_read_from(c, S2_ADDR_USER_CAL_SECTOR, S2_SECTOR_SIZE, [NSMutableData data], 1, S2_CAL_TRIES, log, ^(NSData *now) { + if (now && [now isEqualToData:want]) { + s2op_log(log, @"user calibration written and read back: centre %.0f, %.0f, travel %.0f, %.0f up and %.0f, %.0f down. It stays in the controller until it is calibrated again", + cal.cx, cal.cy, cal.maxx, cal.maxy, cal.minx, cal.miny); + done(S2CalSaved, path); return; + } + if (now && [now isEqualToData:old]) { s2op_log(log, @"the controller's memory reads as before: nothing changed"); done(S2CalUnchanged, path); return; } + s2op_log(log, @"the user calibration does not read as written%@: putting the backup back", now ? @"" : @" (it could not be read)"); + s2op_erase_sector(c, log, ^(NSData *erased) { + if (!s2op_blank(erased)) { + s2op_log(log, @"the sector %@ after the erase: the backup is not written over it", erased ? @"does not read blank" : @"could not be read"); + s2op_restore_failed(erased, path, log, done); return; + } + s2op_write(c, old, S2_ADDR_USER_CAL_SECTOR, 0, YES, ^(BOOL ok) { + if (!ok) s2op_log(log, @"a write of the backup got no answer"); + s2op_read_from(c, S2_ADDR_USER_CAL_SECTOR, S2_SECTOR_SIZE, [NSMutableData data], 1, S2_CAL_TRIES, log, ^(NSData *again) { + if (again && [again isEqualToData:old]) { s2op_log(log, @"the backup is back: the controller is as it was"); done(S2CalRestored, path); return; } + s2op_restore_failed(again, path, log, done); + }); + }); + }); + }); +} +void s2op_save_user_cal(id c, const char *serial, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *)) { + void (^finish)(S2CalOutcome, NSString *) = ^(S2CalOutcome o, NSString *p) { if (done) done(o, p); }; + NSString *ser = serial && *serial ? @(serial) : @"unknown"; // copied: the caller's buffer need not outlive the call + uint8_t b[S2_USER_CAL_LEN]; s2_user_cal_block(&cal, b); + NSData *blk = [NSData dataWithBytes:b length:sizeof b]; + s2op_read_from(c, S2_ADDR_USER_CAL_SECTOR, S2_SECTOR_SIZE, [NSMutableData data], 1, S2_CAL_TRIES, log, ^(NSData *old) { + if (!old) { s2op_log(log, @"the user calibration could not be read for a backup: nothing written"); finish(S2CalNoBackup, nil); return; } + NSString *path = s2op_back_up(old, dir, ser); + if (!path) { s2op_log(log, @"the backup could not be saved: nothing written"); finish(S2CalNoBackup, nil); return; } + const uint8_t *o = old.bytes; + s2op_log(log, @"user calibration backed up to %@; marker B2 A1 at 0x1FC040 %s, 0x1FC060 %s, 0x1FC080 %s", path, + s2_user_cal_marked(o + 0x40) ? "yes" : "no", s2_user_cal_marked(o + 0x60) ? "yes" : "no", s2_user_cal_marked(o + 0x80) ? "yes" : "no"); + NSMutableData *want = [old mutableCopy]; [want replaceBytesInRange:NSMakeRange(S2_USER_STICK1_OFF, S2_USER_CAL_LEN) withBytes:blk.bytes]; + void (^check)(BOOL) = ^(BOOL ok) { if (!ok) s2op_log(log, @"a write got no answer"); s2op_settle(c, want, old, path, cal, log, finish); }; + if (s2_all_ff(o + S2_USER_STICK1_OFF, S2_USER_CAL_LEN)) s2op_write(c, blk, S2_ADDR_USER_CAL_SECTOR + S2_USER_STICK1_OFF, 0, NO, check); + else s2op_erase_sector(c, log, ^(NSData *erased) { + if (!s2op_blank(erased)) { // the final check says whether anything changed: nothing, when the sector still reads as the backup + s2op_log(log, @"the sector %@ after the erase: nothing written", erased ? @"does not read blank" : @"could not be read"); + s2op_settle(c, want, old, path, cal, log, finish); return; + } + s2op_write(c, want, S2_ADDR_USER_CAL_SECTOR, 0, YES, check); + }); + }); +} + +// ---- bonding: the host's address, two fresh keys, done ---- +void s2op_bond(id c, const uint8_t host[6], void (^log)(NSString *), void (^done)(BOOL)) { + uint8_t hp[S2_PAIR_HOST_LEN], k1[16], k2[16], key1[S2_PAIR_KEY_LEN], key2[S2_PAIR_KEY_LEN], last = 0; + arc4random_buf(k1, sizeof k1); arc4random_buf(k2, sizeof k2); + s2_pair_host_payload(hp, host); s2_pair_key_payload(key1, k1); s2_pair_key_payload(key2, k2); + NSData *z = [NSData dataWithBytes:&last length:1], *h = [NSData dataWithBytes:hp length:sizeof hp], *a = [NSData dataWithBytes:key1 length:sizeof key1], *b = [NSData dataWithBytes:key2 length:sizeof key2]; + void (^finish)(BOOL) = ^(BOOL ok) { if (done) done(ok); }; + void (^step)(NSData *, NSString *, void (^)(void)) = ^(NSData *r, NSString *what, void (^next)(void)) { + if (!r) { s2op_log(log, @"pairing stopped at %@ (no answer): it plays now, but needs Sync again next time", what); finish(NO); return; } + next(); + }; + [c send:S2_CMD_PAIR sub:S2_SUB_PAIR_HOST payload:h.bytes len:h.length done:^(NSData *r1) { step(r1, @"the host address", ^{ + [c send:S2_CMD_PAIR sub:S2_SUB_PAIR_KEY1 payload:a.bytes len:a.length done:^(NSData *r2) { step(r2, @"the first key", ^{ + [c send:S2_CMD_PAIR sub:S2_SUB_PAIR_KEY2 payload:b.bytes len:b.length done:^(NSData *r3) { step(r3, @"the second key", ^{ + [c send:S2_CMD_PAIR sub:S2_SUB_PAIR_DONE payload:z.bytes len:z.length done:^(NSData *r4) { step(r4, @"the last step", ^{ + s2op_log(log, @"paired with this Mac: from now on a press of any of its buttons connects it, no Sync needed"); + finish(YES); + }); }]; + }); }]; + }); }]; + }); }]; +} diff --git a/dm/host/switch2_proto.h b/dm/host/switch2_proto.h index 4e3415e790..d9de2675cc 100644 --- a/dm/host/switch2_proto.h +++ b/dm/host/switch2_proto.h @@ -77,12 +77,17 @@ static inline size_t s2_command(uint8_t *out, size_t cap, uint8_t cmd, uint8_t s if (n) memcpy(out + 8, payload, n); return 8 + n; } -// Memory read payload: length, 0x7E, 0, 0, address (little endian). The reply is the 8-byte frame header, the same 8 bytes echoed (length, 0x7E, 0, 0, address), then the data. +// Memory read payload: length, 0x7E, 0, 0, address (little endian). The reply is the 8-byte frame header, then 8 bytes starting with the length and ending with the address, then the data static inline void s2_memory_payload(uint8_t out[8], uint8_t len, uint32_t addr) { out[0] = len; out[1] = 0x7E; out[2] = out[3] = 0; out[4] = (uint8_t)addr; out[5] = (uint8_t)(addr >> 8); out[6] = (uint8_t)(addr >> 16); out[7] = (uint8_t)(addr >> 24); } #define S2_REPLY_DATA 16 // offset of the data in a memory-read reply +// Whether a memory-read reply answers the read of len bytes at addr: its length and address (bytes 8 and 12-15). A reply that comes after its command timed out would otherwise be +// taken for the next one's. Bytes 9-11 are not compared: a Pro Controller 2 does not echo the request's 0x7E there (comparing all 8 refused every read on hardware) +static inline int s2_memory_reply_for(const uint8_t *d, size_t n, uint8_t len, uint32_t addr) { + return d && n >= S2_REPLY_DATA + len && d[8] == len && s2_u32(d + 12) == addr; +} // A command reply is for command cmd when it starts with it and byte 1 is 1 (done) or 2 (status) static inline int s2_reply_is(const uint8_t *d, size_t n, uint8_t cmd) { return d && n >= 8 && d[0] == cmd && (d[1] == 1 || d[1] == 2); } @@ -192,6 +197,162 @@ static inline void s2_cal_apply(const s2_cal *c, uint16_t rawx, uint16_t rawy, f *x = fminf(fmaxf(nx, -1.f), 1.f); *y = fminf(fmaxf(ny, -1.f), 1.f); } +// The calibration a stick is read with from its two blocks: the user's when it parses, else the factory's, else the defaults (valid 0); *fac is the factory block, or a copy of *cal +// when that one did not read, so a travel is always judged against something. Returns cal->valid +static inline int s2_cal_pick(const s2_cal *user, int has_user, const s2_cal *factory, int has_factory, s2_cal *cal, s2_cal *fac) { + if (has_user && user) *cal = *user; else if (has_factory && factory) *cal = *factory; else s2_cal_default(cal); + if (has_factory && factory) *fac = *factory; else *fac = *cal; + return cal->valid; +} + +// ---- Writing a user stick calibration (DM_SW2_CALIBRATE: switch2_ops_apple.m) ---- +// The user calibration of the first stick slot is 11 bytes at 0x1FC040: the marker B2 A1 (Switch 1's user-calibration marker), then the 9 bytes s2_cal_parse reads, which is why +// the read starts at 0x1FC042 (SDL's SDL_hidapi_switch2.c and Linux's hid-nintendo.c check the marker the same way; both read the second slot at 0x1FC080, not 0x1FC062). Its +// sector 0x1FC000..0x1FCFFF holds only user calibration (ndeadly/switch2_controller_research memory_layout.md: motion at 0x1FC000, the sticks after it), all 0xFF until a +// calibration is written. Command 0x02: subcommand 0x05 writes (length, 0x7E, 0, 0, address, data; addresses below 0x1F5000 are refused), 0x03 erases the 0x1000-byte sector +// holding an address to 0xFF (0, 0, 0, 0, address). Whether a write over bytes that are not 0xFF needs the erase first is documented nowhere, so the host writes straight only +// into a blank block and otherwise erases the sector and writes it back whole (only once the sector reads blank: an answered erase is not proof of one), then reads +// everything back. +enum { S2_SUB_MEMORY_ERASE = 0x03, S2_SUB_MEMORY_WRITE = 0x05 }; +#define S2_ADDR_USER_CAL_SECTOR 0x001FC000u +#define S2_SECTOR_SIZE 0x1000u +#define S2_USER_STICK1_OFF 0x40u // the first stick slot's block in the sector (marker at 0x1FC040, calibration at S2_ADDR_USER_STICK1) +#define S2_USER_CAL_LEN 11 +static inline int s2_all_ff(const uint8_t *d, size_t n) { for (size_t i = 0; i < n; i++) if (d[i] != 0xFF) return 0; return 1; } +// A calibration as the 9 bytes s2_cal_parse reads: centre, travel above, travel below, each pair packed x | y << 12, little endian +static inline void s2_cal_pack(const s2_cal *c, uint8_t out[9]) { + float v[6] = {c->cx, c->cy, c->maxx, c->maxy, c->minx, c->miny}; + for (int i = 0; i < 3; i++) { + uint32_t x = (uint32_t)fminf(fmaxf(roundf(v[2 * i]), 0.f), 4095.f), y = (uint32_t)fminf(fmaxf(roundf(v[2 * i + 1]), 0.f), 4095.f), p = x | y << 12; + out[3 * i] = (uint8_t)p; out[3 * i + 1] = (uint8_t)(p >> 8); out[3 * i + 2] = (uint8_t)(p >> 16); + } +} +static inline void s2_user_cal_block(const s2_cal *c, uint8_t out[S2_USER_CAL_LEN]) { out[0] = 0xB2; out[1] = 0xA1; s2_cal_pack(c, out + 2); } +static inline int s2_user_cal_marked(const uint8_t *d) { return d[0] == 0xB2 && d[1] == 0xA1; } +// The calibration with its centre moved to (cx, cy) and the same end stops: travel is measured from the centre, so what the centre gains one side loses the other. +// 0 when c is not a calibration read from the controller (the defaults are no travel of this stick's) or the new travel would not be one (s2_cal_parse's rule) +static inline int s2_cal_recentre(const s2_cal *c, float cx, float cy, s2_cal *out) { + if (!c || !c->valid) return 0; + cx = roundf(cx); cy = roundf(cy); + float dx = cx - c->cx, dy = cy - c->cy; + out->cx = cx; out->cy = cy; out->dead = c->dead; // the dead zone is the stick's (s2_params_dead), not its calibration's + out->maxx = c->maxx - dx; out->minx = c->minx + dx; out->maxy = c->maxy - dy; out->miny = c->miny + dy; + out->valid = out->maxx > 100 && out->maxy > 100 && out->minx > 100 && out->miny > 100; + return out->valid; +} +// Where a released stick rests: S2_STICK_REST_MS of reports in which neither axis moved more than S2_STICK_REST_SPREAD counts (a touch starts it over), averaged. +// Returns 1 with the rest in out +#define S2_STICK_REST_MS 2000u +#define S2_STICK_REST_SPREAD 24 +#define S2_STICK_REST_MAX 300.f // a rest farther than this from the calibrated centre (a quarter of the travel) is a held stick, not drift: nothing is written +typedef struct { int n; uint32_t t0; uint16_t lo[2], hi[2]; uint64_t sum[2]; } s2_stick_rest; +// The same for hold_ms of reports (S2_STICK_REST_MS is the 2 s of the DM_SW2_CALIBRATE write) +static inline int s2_stick_rest_update_for(s2_stick_rest *r, uint16_t x, uint16_t y, uint32_t ms, uint32_t hold_ms, float out[2]) { + uint16_t v[2] = {x, y}; + if (r->n) for (int i = 0; i < 2; i++) { + uint16_t lo = v[i] < r->lo[i] ? v[i] : r->lo[i], hi = v[i] > r->hi[i] ? v[i] : r->hi[i]; + if (hi - lo > S2_STICK_REST_SPREAD) { r->n = 0; break; } + r->lo[i] = lo; r->hi[i] = hi; + } + if (!r->n) { r->t0 = ms; for (int i = 0; i < 2; i++) { r->lo[i] = r->hi[i] = v[i]; r->sum[i] = 0; } } + r->n++; r->sum[0] += x; r->sum[1] += y; + if (ms - r->t0 < hold_ms || r->n < 10) return 0; + out[0] = (float)r->sum[0] / (float)r->n; out[1] = (float)r->sum[1] / (float)r->n; + return 1; +} +static inline int s2_stick_rest_update(s2_stick_rest *r, uint16_t x, uint16_t y, uint32_t ms, float out[2]) { + return s2_stick_rest_update_for(r, x, y, ms, S2_STICK_REST_MS, out); +} +static inline int s2_stick_rest_near(const s2_cal *c, float x, float y) { return fabsf(x - c->cx) <= S2_STICK_REST_MAX && fabsf(y - c->cy) <= S2_STICK_REST_MAX; } +// A release for the stick tester: the stick goes more than S2_RELEASE_AWAY counts from c's centre on either axis, then rests closer than that for S2_RELEASE_HOLD_MS (milliseconds of the controller's clock). +// Returns 1 once per release with the averaged rest in out, then waits for the next push; while the stick is away its rest window starts over +#define S2_RELEASE_AWAY 300 +#define S2_RELEASE_HOLD_MS 300u +typedef struct { int away; s2_stick_rest rest; } s2_release; +static inline int s2_release_update(s2_release *r, const s2_cal *c, uint16_t x, uint16_t y, uint32_t ms, float out[2]) { + if (fabsf((float)x - c->cx) > S2_RELEASE_AWAY || fabsf((float)y - c->cy) > S2_RELEASE_AWAY) { r->away = 1; r->rest.n = 0; return 0; } + if (!r->away) { r->rest.n = 0; return 0; } + if (!s2_stick_rest_update_for(&r->rest, x, y, ms, S2_RELEASE_HOLD_MS, out)) return 0; + r->away = 0; r->rest.n = 0; + return 1; +} +// The edge of a stick for a new calibration: the furthest offset from c's centre in each direction, in counts (up: right and up, down: left and down; zero-initialised to start) +#define S2_REACH_MIN_FRAC 0.7f // a reach under this share of the limits' travel (the factory calibration's) in that direction is a stick not rotated all the way +#define S2_REACH_MAX_FRAC 1.25f // over this share of the limits' travel in any direction is not this stick's edge +#define S2_REACH_TRIM 0.97f // the new travel is this share of the reach, so the edge reads full tilt +#define S2_REACH_SHORT_RIGHT 1 +#define S2_REACH_SHORT_LEFT 2 +#define S2_REACH_SHORT_UP 4 +#define S2_REACH_SHORT_DOWN 8 +#define S2_REACH_TOO_FAR 16 +typedef struct { float up[2], down[2]; } s2_reach; +static inline void s2_reach_update(s2_reach *r, const s2_cal *c, uint16_t x, uint16_t y) { + float dx = (float)x - c->cx, dy = (float)y - c->cy; + if (dx > r->up[0]) r->up[0] = dx; + if (-dx > r->down[0]) r->down[0] = -dx; + if (dy > r->up[1]) r->up[1] = dy; + if (-dy > r->down[1]) r->down[1] = -dy; +} +// The S2_REACH_SHORT_ bits of the directions whose reach is under S2_REACH_MIN_FRAC of limits' travel that way (0: every way far enough) +static inline int s2_reach_short(const s2_reach *r, const s2_cal *limits) { + int f = 0; + if (r->up[0] < S2_REACH_MIN_FRAC * limits->maxx) f |= S2_REACH_SHORT_RIGHT; + if (r->down[0] < S2_REACH_MIN_FRAC * limits->minx) f |= S2_REACH_SHORT_LEFT; + if (r->up[1] < S2_REACH_MIN_FRAC * limits->maxy) f |= S2_REACH_SHORT_UP; + if (r->down[1] < S2_REACH_MIN_FRAC * limits->miny) f |= S2_REACH_SHORT_DOWN; + return f; +} +// The calibration with c's centre (the one the reach was measured from) and the reach, trimmed, as travel; out is written only when this returns 0. Otherwise the S2_REACH_ +// bits of what is wrong, judged against limits' travel: the factory calibration's, since a stored user calibration may be the short travel being replaced (all four SHORT +// bits when the result would not be a calibration by s2_cal_parse's rule) +static inline int s2_reach_travel(const s2_reach *r, const s2_cal *c, const s2_cal *limits, s2_cal *out) { + int f = s2_reach_short(r, limits); + if (r->up[0] > S2_REACH_MAX_FRAC * limits->maxx || r->down[0] > S2_REACH_MAX_FRAC * limits->minx || + r->up[1] > S2_REACH_MAX_FRAC * limits->maxy || r->down[1] > S2_REACH_MAX_FRAC * limits->miny) f |= S2_REACH_TOO_FAR; + if (f) return f; + s2_cal t; t.cx = c->cx; t.cy = c->cy; t.dead = c->dead; + t.maxx = r->up[0] * S2_REACH_TRIM; t.minx = r->down[0] * S2_REACH_TRIM; t.maxy = r->up[1] * S2_REACH_TRIM; t.miny = r->down[1] * S2_REACH_TRIM; + t.valid = t.maxx > 100 && t.maxy > 100 && t.minx > 100 && t.miny > 100; + if (!t.valid) return S2_REACH_SHORT_RIGHT | S2_REACH_SHORT_LEFT | S2_REACH_SHORT_UP | S2_REACH_SHORT_DOWN; + *out = t; + return 0; +} +// An offset from the centre as a share of the travel that way (d >= 0: up, else down; a travel under 1 count counts as 1) +static inline float s2_share(float d, float up, float down) { return d / fmaxf(d >= 0 ? up : down, 1.f); } +// The edges step's turns around the stick's edge: the angle swept while the stick is at least S2_TURN_SHARE of its travel out (x, y: s2_share each way, y up), signed so a +// still stick's jitter and a turn back cancel out; a stick pulled in starts the angle over. Returns the most turns reached so far, at most S2_TURNS_WANTED +#define S2_TURN_SHARE 0.6f +#define S2_TURNS_WANTED 2.f +typedef struct { int have; float last, winding, turns; } s2_turns; +static inline float s2_turns_update(s2_turns *t, float x, float y) { + const float pi = 3.14159265f; + if (hypotf(x, y) < S2_TURN_SHARE) { t->have = 0; return t->turns; } + float a = atan2f(y, x); + if (t->have) { + float d = a - t->last; + if (d > pi) d -= 2 * pi; else if (d < -pi) d += 2 * pi; + t->winding += d; + float n = fminf(S2_TURNS_WANTED, fabsf(t->winding) / (2 * pi)); + if (n > t->turns) t->turns = n; + } + t->last = a; t->have = 1; + return t->turns; +} +// Payloads of the write (returns its length, 0 when the data does not fit a command) and of the erase (1, or 0 when refused). Neither is built for an address below +// S2_WRITE_FLOOR: the controller refuses those, and the host never sends one +#define S2_WRITE_FLOOR 0x001F5000u +static inline size_t s2_memory_write_payload(uint8_t *out, size_t cap, uint32_t addr, const uint8_t *data, size_t n) { + if (!out || !data || n == 0 || n > 0x80 || cap < 8 + n || 8 + n > 0xFF || addr < S2_WRITE_FLOOR) return 0; + s2_memory_payload(out, (uint8_t)n, addr); memcpy(out + 8, data, n); + return 8 + n; +} +static inline int s2_memory_erase_payload(uint8_t out[8], uint32_t addr) { + if (!out || addr < S2_WRITE_FLOOR) return 0; + out[0] = out[1] = out[2] = out[3] = 0; + out[4] = (uint8_t)addr; out[5] = (uint8_t)(addr >> 8); out[6] = (uint8_t)(addr >> 16); out[7] = (uint8_t)(addr >> 24); + return 1; +} + // Player LED patterns of the console for players 1..8 static const uint8_t s2_led_pattern[8] = {0x01, 0x03, 0x07, 0x0F, 0x09, 0x05, 0x0D, 0x06}; diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile index 18cf424563..9479b3ddd0 100644 --- a/dm/host/tests/Makefile +++ b/dm/host/tests/Makefile @@ -74,6 +74,7 @@ run: $(B)/t_mii $(B)/t_net_eventfd $(B)/t_language $(B)/t_motion $(B)/t_heap_siz $(B)/t_pad_hid_mac $(B)/t_pad_pro $(B)/t_switch2 + $(B)/t_switch2_ops $(B)/t_haptics $(B)/t_haptics_apple TMPDIR="$(abspath $(B))" $(B)/t_cheats $(QUIET) @@ -415,6 +416,9 @@ $(B)/t_pad_pro: t_pad_pro.c ../pad_pro.h ../dm_input.h | $(B) $(CLANG) -O1 -g -Wall -Werror -Wno-unused-function -fsanitize=address,undefined -I.. -o $@ t_pad_pro.c $(B)/t_switch2: t_switch2.c ../switch2_proto.h ../dm_input.h | $(B) $(CLANG) -O1 -g -Wall -Werror -Wno-unused-function -fsanitize=address,undefined -I.. -o $@ t_switch2.c +run: $(B)/t_switch2_ops +$(B)/t_switch2_ops: t_switch2_ops.m ../switch2_ops_apple.m ../switch2_ops_apple.h ../switch2_proto.h ../dm_input.h | $(B) + $(CLANG) -O1 -g -fobjc-arc -Wall -Werror -Wno-unused-function -fsanitize=address,undefined -I.. -o $@ t_switch2_ops.m ../switch2_ops_apple.m -framework Foundation # Line coverage (../../sanitizers/coverage.mk): make coverage builds every test of run into $(B)/cov with COV_FLAGS, runs them and writes $(B)/cov/coverage.lcov. # It measures, it does not test: make -i runs every test even after one fails (the plain run reports that), so the coverage of the others is kept. diff --git a/dm/host/tests/t_switch2.c b/dm/host/tests/t_switch2.c index 0146757b6d..3453ef56f6 100644 --- a/dm/host/tests/t_switch2.c +++ b/dm/host/tests/t_switch2.c @@ -7,6 +7,9 @@ static int checks, fails; #define CHECK(c) do { checks++; if (!(c)) { fails++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); } } while (0) +static int same_cal(const s2_cal *a, const s2_cal *b) { // member by member: floats have no unique object representation for memcmp + return a->cx == b->cx && a->cy == b->cy && a->maxx == b->maxx && a->maxy == b->maxy && a->minx == b->minx && a->miny == b->miny && a->valid == b->valid; +} static size_t unhex(const char *h, uint8_t *out) { size_t n = 0; for (; h[0] && h[1]; h += 2) { unsigned v; sscanf(h, "%2x", &v); out[n++] = (uint8_t)v; } return n; } int main(void) { @@ -197,6 +200,105 @@ int main(void) { { s2_cal r = {2048, 2048, 1200, 1200, 1200, 1200, 1, 20}; CHECK(s2_cal_dead_own(&r) == 0 && s2_cal_dead_share(&r) == S2_DEAD_SHARE); // 1.7 % r.dead = 600; CHECK(s2_cal_dead_own(&r) == 0 && s2_cal_dead_share(&r) == S2_DEAD_SHARE); } // 50 % + // Writing a user calibration (DM_SW2_CALIBRATE): the recorded block packs back to its own bytes; the user block is the marker B2 A1 and those 9 bytes + { + n = unhex("2ff87c97944c9d9446ffff", b); s2_cal_parse(b, n, &c); + uint8_t p9[9]; s2_cal_pack(&c, p9); CHECK(!memcmp(p9, b, 9)); + uint8_t blk[S2_USER_CAL_LEN]; s2_user_cal_block(&c, blk); + CHECK(s2_user_cal_marked(blk) && !memcmp(blk + 2, b, 9)); + s2_cal back; CHECK(s2_cal_parse(blk + 2, 9, &back) && back.cx == c.cx && back.miny == c.miny); // what readCal reads at 0x1FC042 + CHECK(S2_ADDR_USER_CAL_SECTOR + S2_USER_STICK1_OFF + 2 == S2_ADDR_USER_STICK1); + // A new centre keeps the end stops (2095 + 1175 = 3270 and 2095 - 1181 = 914 still read full tilt) and the stick's own dead zone (a Pro Controller 2's 160 counts) + c.dead = 160; + s2_cal rc; CHECK(s2_cal_recentre(&c, 2300.4f, 1980.f, &rc) && rc.cx == 2300 && rc.cy == 1980 && rc.maxx == 970 && rc.minx == 1386 && rc.maxy == 1244 && rc.miny == 1110 && rc.dead == 160); + s2_cal_apply(&rc, 3270, 1980, &x, &y); CHECK(x == 1.f && y == 0.f); + s2_cal_apply(&rc, 914, 1980, &x, &y); CHECK(x == -1.f); + s2_cal_apply(&rc, 2300 + 30, 1980 - 30, &x, &y); CHECK(x == 0.f && y == 0.f); // the new rest is inside the dead zone + s2_cal_apply(&c, 2300, 1980, &x, &y); CHECK(x > 0.f); // and was past it before: the drift (205 counts, 17 % of the travel against 14 %) + CHECK(!s2_cal_recentre(&blank, 2100, 2100, &rc)); // no travel of its own: nothing to keep + CHECK(!s2_cal_recentre(&c, 2095 + 1100, 1999, &rc)); // travel above would be under 100 + CHECK(s2_stick_rest_near(&c, 2095 + 300, 1999 - 300) && !s2_stick_rest_near(&c, 2095 + 301, 1999)); + // Rest: two seconds of the controller's clock without a move past the spread, averaged; a touch starts it over + s2_stick_rest sr; memset(&sr, 0, sizeof sr); float rest[2]; int got = 0; uint32_t t = 1000; + for (int i = 0; i < 40 && !got; i++, t += 30) got = s2_stick_rest_update(&sr, (uint16_t)(2140 + (i & 1) * 4), 1980, t, rest); + CHECK(!got); // 1.2 s: not yet + CHECK(!s2_stick_rest_update(&sr, 2140 + 60, 1980, t, rest)); t += 30; // touched: over again + for (int i = 0; i < 67 && !got; i++, t += 30) got = s2_stick_rest_update(&sr, 2140, (uint16_t)(1980 - (i & 1) * 2), t, rest); + CHECK(!got); // the release starts it again too: 1.98 s since + got = s2_stick_rest_update(&sr, 2140, 1980, t, rest); + CHECK(got && sr.n == 68 && rest[0] == 2140.f && fabsf(rest[1] - (1980.f - 66.f / 68.f)) < 0.01f); // the touch is not in it; y averages 33 dips of 2 + // Release: the stick goes more than S2_RELEASE_AWAY from the centre, then rests closer than that for 300 ms: one report, then it waits for the next push + { + s2_release rl; memset(&rl, 0, sizeof rl); float rr[2]; int reports = 0; uint32_t u = 5000; + for (int i = 0; i < 34; i++, u += 30) reports += s2_release_update(&rl, &c, 2095, 1999, u, rr); + CHECK(reports == 0); // 1 s at rest: nothing was released + for (int i = 0; i < 7; i++, u += 30) reports += s2_release_update(&rl, &c, 2095 + 900, 1999, u, rr); + CHECK(reports == 0); // 200 ms away + for (int i = 0; i < 11; i++, u += 30) reports += s2_release_update(&rl, &c, 2140, 1980, u, rr); + CHECK(reports == 1 && fabsf(rr[0] - 2140.f) < 0.01f && fabsf(rr[1] - 1980.f) < 0.01f); // 330 ms back: reported once + for (int i = 0; i < 34; i++, u += 30) reports += s2_release_update(&rl, &c, 2140, 1980, u, rr); + CHECK(reports == 1); // holding there: no second report + } + // Reach: the furthest offset in each direction becomes the travel, trimmed, with the centre kept + { + s2_reach rc2; s2_cal nc; memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &c, 2095 + 1200, 1999); s2_reach_update(&rc2, &c, 2095 - 1180, 1999); + s2_reach_update(&rc2, &c, 2095, 1999 + 1230); s2_reach_update(&rc2, &c, 2095, 1999 - 1130); + CHECK(s2_reach_travel(&rc2, &c, &c, &nc) == 0 && nc.cx == 2095 && nc.cy == 1999 && nc.valid && nc.dead == c.dead); // the dead zone is the stick's, not the travel's + CHECK(fabsf(nc.maxx - 1164.f) < 0.01f && fabsf(nc.minx - 1144.6f) < 0.01f && fabsf(nc.maxy - 1193.1f) < 0.01f && fabsf(nc.miny - 1096.1f) < 0.01f); + memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &c, 2095 + 700, 1999); s2_reach_update(&rc2, &c, 2095 - 1180, 1999); + s2_reach_update(&rc2, &c, 2095, 1999 + 1230); s2_reach_update(&rc2, &c, 2095, 1999 - 1130); + int sh = s2_reach_travel(&rc2, &c, &c, &nc); + CHECK((sh & S2_REACH_SHORT_RIGHT) && sh == S2_REACH_SHORT_RIGHT); // only the right is short (700 < 822.5) + memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &c, 2095 + 1500, 1999); s2_reach_update(&rc2, &c, 2095 - 1180, 1999); + s2_reach_update(&rc2, &c, 2095, 1999 + 1230); s2_reach_update(&rc2, &c, 2095, 1999 - 1130); + CHECK((s2_reach_travel(&rc2, &c, &c, &nc) & S2_REACH_TOO_FAR)); // 1500 > 1468.75 + // A stick whose stored calibration has a short travel (900 each way, an old user calibration): the limits are the factory travel, so its real edge + // (1200, 1180, 1230, 1130) is taken, not too far; the travel is still measured from the stored calibration's centre + s2_cal shortc = c; shortc.maxx = shortc.minx = shortc.maxy = shortc.miny = 900; + memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &shortc, 2095 + 1200, 1999); s2_reach_update(&rc2, &shortc, 2095 - 1180, 1999); + s2_reach_update(&rc2, &shortc, 2095, 1999 + 1230); s2_reach_update(&rc2, &shortc, 2095, 1999 - 1130); + CHECK(s2_reach_travel(&rc2, &shortc, &c, &nc) == 0 && nc.cx == 2095 && nc.cy == 1999 && fabsf(nc.maxx - 1164.f) < 0.01f && fabsf(nc.miny - 1096.1f) < 0.01f); + CHECK(s2_reach_short(&rc2, &c) == 0); + // Short is measured against the factory travel too: 800 right is over 70 % of the stored 900 (630) but under 70 % of the factory 1175 (822.5) + memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &shortc, 2095 + 800, 1999); s2_reach_update(&rc2, &shortc, 2095 - 1180, 1999); + s2_reach_update(&rc2, &shortc, 2095, 1999 + 1230); s2_reach_update(&rc2, &shortc, 2095, 1999 - 1130); + CHECK(s2_reach_travel(&rc2, &shortc, &c, &nc) == S2_REACH_SHORT_RIGHT && s2_reach_short(&rc2, &c) == S2_REACH_SHORT_RIGHT); + // and too far against the factory travel: 1500 > 1.25 * 1175 + memset(&rc2, 0, sizeof rc2); + s2_reach_update(&rc2, &shortc, 2095 + 1500, 1999); s2_reach_update(&rc2, &shortc, 2095 - 1180, 1999); + s2_reach_update(&rc2, &shortc, 2095, 1999 + 1230); s2_reach_update(&rc2, &shortc, 2095, 1999 - 1130); + s2_cal keep; memset(&keep, 0x5A, sizeof keep); nc = keep; + CHECK(s2_reach_travel(&rc2, &shortc, &c, &nc) == S2_REACH_TOO_FAR && same_cal(&nc, &keep)); // out untouched when it is refused + memset(&rc2, 0, sizeof rc2); nc = keep; + CHECK(s2_reach_travel(&rc2, &shortc, &c, &nc) == (S2_REACH_SHORT_RIGHT | S2_REACH_SHORT_LEFT | S2_REACH_SHORT_UP | S2_REACH_SHORT_DOWN) && same_cal(&nc, &keep)); + } + // Payloads: the write (length, 0x7E, 0, 0, address, data) and the erase (four zeros, address) + uint8_t pl[8 + 0x80], f[8 + 255]; + size_t pn = s2_memory_write_payload(pl, sizeof pl, 0x001FC040u, blk, S2_USER_CAL_LEN); + CHECK(pn == 19 && pl[0] == 11 && pl[1] == 0x7E && pl[2] == 0 && pl[4] == 0x40 && pl[5] == 0xC0 && pl[6] == 0x1F && pl[7] == 0 && pl[8] == 0xB2 && pl[18] == blk[10]); + CHECK(s2_command(f, sizeof f, S2_CMD_MEMORY, S2_SUB_MEMORY_WRITE, pl, pn) == 27 && f[0] == 0x02 && f[3] == 0x05 && f[5] == 19); + CHECK(!s2_memory_write_payload(pl, sizeof pl, 0x001FC040u, blk, 0) && !s2_memory_write_payload(pl, 10, 0x001FC040u, blk, 11)); + CHECK(s2_memory_erase_payload(pl, 0x001FC040u) && !pl[0] && !pl[3] && pl[4] == 0x40 && pl[5] == 0xC0 && pl[6] == 0x1F && !pl[7]); + // Nothing below S2_WRITE_FLOOR is written or erased: the controller refuses it, and the host never sends it + CHECK(S2_WRITE_FLOOR == 0x001F5000u && s2_memory_write_payload(pl, sizeof pl, S2_WRITE_FLOOR, blk, S2_USER_CAL_LEN) == 19); + CHECK(!s2_memory_write_payload(pl, sizeof pl, S2_WRITE_FLOOR - 1, blk, S2_USER_CAL_LEN) && !s2_memory_write_payload(pl, sizeof pl, S2_ADDR_INFO, blk, S2_USER_CAL_LEN)); + CHECK(s2_memory_erase_payload(pl, S2_WRITE_FLOOR) && !s2_memory_erase_payload(pl, S2_WRITE_FLOOR - 1) && !s2_memory_erase_payload(pl, S2_ADDR_STICK1)); + uint8_t ff[11]; memset(ff, 0xFF, sizeof ff); CHECK(s2_all_ff(ff, 11) && !s2_all_ff(blk, 11)); + // A read's reply is taken only when it echoes the request: a late reply to the read before is not this one's + uint8_t rp[S2_REPLY_DATA + 0x40]; memset(rp, 0, sizeof rp); rp[0] = S2_CMD_MEMORY; rp[1] = 1; + s2_memory_payload(rp + 8, 0x40, 0x001FC040u); + CHECK(s2_memory_reply_for(rp, sizeof rp, 0x40, 0x001FC040u) && !s2_memory_reply_for(rp, sizeof rp, 0x40, 0x001FC080u) && !s2_memory_reply_for(rp, sizeof rp, 0x20, 0x001FC040u)); + CHECK(!s2_memory_reply_for(rp, sizeof rp - 1, 0x40, 0x001FC040u) && !s2_memory_reply_for(NULL, 0, 0x40, 0x001FC040u)); + // A Pro Controller 2 does not echo the request's 0x7E (nor bytes 10-11): only the length and the address are its own read's + rp[9] = 0x00; rp[10] = 0x5A; rp[11] = 0xA5; + CHECK(s2_memory_reply_for(rp, sizeof rp, 0x40, 0x001FC040u) && !s2_memory_reply_for(rp, sizeof rp, 0x40, 0x001FC080u)); + } + // Pads s2_pad_state pad; uint64_t m; int32_t st[4]; memset(&pad, 0, sizeof pad); CHECK(!s2_pad_input(&pad, &m, st) && !s2_pad_motion_dev(&pad, 1)); @@ -328,6 +430,36 @@ int main(void) { pad.right.r.buttons = 0xE0000000u; pad.left.r.buttons = 0xE0000000u | S2_HOME; ha = hb = 0; s2_pad_ab(&pad, &ha, &hb); CHECK(s2_pad_home(&pad) && !ha && !hb); memset(&pad, 0, sizeof pad); CHECK(!s2_pad_home(&pad)); + { // which calibration a stick is read with: the user's, else the factory's, else the defaults; the factory block stands in for itself with a copy of the stick's when it did not read + s2_cal u, f, c, fa; s2_cal_default(&u); s2_cal_default(&f); u.cx = 2000; u.maxx = u.maxy = u.minx = u.miny = 900; u.valid = 1; f.cx = 2100; f.maxx = f.maxy = f.minx = f.miny = 1200; f.valid = 1; + CHECK(s2_cal_pick(&u, 1, &f, 1, &c, &fa) && c.cx == 2000 && fa.cx == 2100 && fa.maxx == 1200); + CHECK(s2_cal_pick(&u, 1, NULL, 0, &c, &fa) && c.cx == 2000 && fa.cx == 2000 && fa.maxx == 900); + CHECK(s2_cal_pick(NULL, 0, &f, 1, &c, &fa) && c.cx == 2100 && fa.cx == 2100); + CHECK(!s2_cal_pick(NULL, 0, NULL, 0, &c, &fa) && !c.valid && c.cx == 2048 && !fa.valid && fa.cx == 2048); + } + // A share of the travel: each side its own, a travel under one count as one + CHECK(s2_share(600, 1200, 800) == 0.5f && s2_share(-400, 1200, 800) == -0.5f && s2_share(5, 0, 0) == 5.f); + { // The edges step's turns: two full turns at the edge in 10 degree steps make two, at most two; a turn and back again, jitter, or circles inside the share make none + s2_turns t; memset(&t, 0, sizeof t); float n = 0; + for (int i = 0; i <= 36 * 3; i++) n = s2_turns_update(&t, 0.9f * cosf(i * 0.17453293f), 0.9f * sinf(i * 0.17453293f)); + CHECK(n == S2_TURNS_WANTED && t.turns == S2_TURNS_WANTED); + memset(&t, 0, sizeof t); + for (int i = 0; i <= 18; i++) n = s2_turns_update(&t, cosf(i * 0.17453293f), sinf(i * 0.17453293f)); + CHECK(fabsf(n - 0.5f) < 0.01f); // half a turn anticlockwise + for (int i = 18; i >= 0; i--) n = s2_turns_update(&t, cosf(i * 0.17453293f), sinf(i * 0.17453293f)); + CHECK(fabsf(n - 0.5f) < 0.01f && fabsf(t.winding) < 0.01f); // back again: the most stays, the angle is undone + memset(&t, 0, sizeof t); + for (int i = 0; i < 200; i++) n = s2_turns_update(&t, 0.95f, (i & 1) ? 0.02f : -0.02f); + CHECK(n < 0.01f); // held at the edge, jittering + memset(&t, 0, sizeof t); + for (int i = 0; i <= 72; i++) n = s2_turns_update(&t, 0.5f * cosf(i * 0.17453293f), 0.5f * sinf(i * 0.17453293f)); + CHECK(n == 0.f && !t.have); // two turns inside 0.6 of the travel + memset(&t, 0, sizeof t); + for (int i = 0; i <= 9; i++) s2_turns_update(&t, -cosf(i * 0.17453293f), -sinf(i * 0.17453293f)); // a quarter anticlockwise, from the left to the bottom + s2_turns_update(&t, 0.1f, 0.1f); // pulled in: the next angle starts over + for (int i = 0; i <= 9; i++) n = s2_turns_update(&t, cosf(i * 0.17453293f), sinf(i * 0.17453293f)); + CHECK(fabsf(n - 0.5f) < 0.01f && fabsf(t.winding - 3.14159265f) < 0.01f); // the jump from the bottom to the right is no turn: two quarters, half a turn + } printf("t_switch2: %d checks, %d failed\n", checks, fails); return fails != 0; } diff --git a/dm/host/tests/t_switch2_ops.m b/dm/host/tests/t_switch2_ops.m new file mode 100644 index 0000000000..3f581e4eef --- /dev/null +++ b/dm/host/tests/t_switch2_ops.m @@ -0,0 +1,289 @@ +// The shared controller-memory operations (switch2_ops_apple.m) against a fake controller: the 4 KB user-calibration sector at 0x1FC000, which answers reads with the echoed +// request, writes as flash does (a write only clears bits) and erases to 0xFF, with knobs for the ways a real link fails. No Bluetooth; replies come on the main queue. +#import +#include +#include +#include "../switch2_ops_apple.h" + +static int checks, fails; +#define CHECK(c) do { checks++; if (!(c)) { fails++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); } } while (0) + +#define BASE S2_ADDR_USER_CAL_SECTOR +@interface FakeFlash : NSObject +@property BOOL ignoreWrites; // writes and erases are answered but change nothing +@property BOOL ignoreErase; // erases are answered but erase nothing (writes still clear bits) +@property int erasesBeforeIgnore; // with ignoreErase: the first N erases are done all the same +@property int deafAfterWrite; // the first N reads sent after the first write get no reply (the read back that follows a write times out) +@property int failReadsBeforeAnswer; // the first N reads of each address get no reply +@property BOOL staleReplies; // the first read of each address gets no reply; that read's reply is handed to the next read sent +@property BOOL staleChain; // every read's reply comes one read late: each read is answered with the reply of the read before it +@property BOOL stopAfterErase; // an erase is done and answered, then nothing is answered (or done) any more: the link dropped +@property int dropWrites; // the first N writes are answered but not done +@property int pairAnswers; // the pairing commands answered before the controller goes quiet +@property NSUInteger chunk; +@property int reads, writes, erases, bad, refusable; // bad: a write or erase outside the sector or below S2_WRITE_FLOOR; refusable: replies handed to a read of another address +@property int writesAfterIgnoredErase; // writes sent after an erase that was answered but not done +@property NSMutableArray *cmds; // every command: cmd, sub, payload +- (uint8_t *)mem; // the sector's bytes +- (NSData *)sector; // a copy of them +@end +@implementation FakeFlash { + uint8_t _mem[S2_SECTOR_SIZE]; + NSCountedSet *_seen; + NSData *_stale; + BOOL _silent, _ignoredErase, _wrote; + int _deaf; +} +- (instancetype)init { + if ((self = [super init])) { memset(_mem, 0xFF, sizeof _mem); _seen = [NSCountedSet new]; _cmds = [NSMutableArray new]; _chunk = 64; _pairAnswers = 1000; } + return self; +} +- (uint8_t *)mem { return _mem; } +- (NSData *)sector { return [NSData dataWithBytes:_mem length:sizeof _mem]; } +- (NSUInteger)writeChunk { return _chunk; } +- (void)send:(uint8_t)cmd sub:(uint8_t)sub payload:(const uint8_t *)pl len:(size_t)n done:(void (^)(NSData *))done { + uint8_t head[2] = {cmd, sub}; NSMutableData *rec = [NSMutableData dataWithBytes:head length:2]; [rec appendBytes:pl length:n]; [_cmds addObject:rec]; + NSData *reply = [self answer:cmd sub:sub payload:pl len:n]; + dispatch_async(dispatch_get_main_queue(), ^{ done(reply); }); +} +- (NSMutableData *)frame:(uint8_t)cmd sub:(uint8_t)sub { uint8_t h[8] = {cmd, 0x01, 0x01, sub, 0, 0, 0, 0}; return [NSMutableData dataWithBytes:h length:8]; } +- (BOOL)inSector:(uint32_t)addr len:(size_t)len { return addr >= S2_WRITE_FLOOR && addr >= BASE && addr + len <= BASE + S2_SECTOR_SIZE; } +- (NSData *)answer:(uint8_t)cmd sub:(uint8_t)sub payload:(const uint8_t *)pl len:(size_t)n { + if (_silent) return nil; + if (cmd == S2_CMD_PAIR) return _pairAnswers-- > 0 ? [self frame:cmd sub:sub] : nil; + if (cmd != S2_CMD_MEMORY || n < 8) return nil; + uint32_t addr = s2_u32(pl + 4); uint8_t len = pl[0]; + if (sub == S2_SUB_MEMORY_READ) { + _reads++; + if (_deaf > 0) { _deaf--; return nil; } + NSMutableData *r = [self frame:cmd sub:sub]; [r appendBytes:pl length:8]; + for (uint8_t i = 0; i < len; i++) { uint32_t a = addr + i; uint8_t v = a >= BASE && a < BASE + S2_SECTOR_SIZE ? _mem[a - BASE] : 0; [r appendBytes:&v length:1]; } + [_seen addObject:@(addr)]; NSUInteger seen = [_seen countForObject:@(addr)]; + if (_failReadsBeforeAnswer && seen <= (NSUInteger)_failReadsBeforeAnswer) return nil; + if (_staleReplies) { + if (_stale) { NSData *s = _stale; _stale = nil; [self note:s for:pl]; return s; } + if (seen == 1) { _stale = r; return nil; } + return r; + } + if (_staleChain) { NSData *s = _stale; _stale = r; if (s) [self note:s for:pl]; return s; } + return r; + } + if (sub == S2_SUB_MEMORY_WRITE) { + _writes++; + if (n != 8u + len || ![self inSector:addr len:len]) { _bad++; return nil; } + if (_ignoredErase) _writesAfterIgnoredErase++; + if (!_wrote) { _wrote = YES; _deaf = _deafAfterWrite; } + if (_dropWrites > 0) _dropWrites--; + else if (!_ignoreWrites) for (uint8_t i = 0; i < len; i++) _mem[addr - BASE + i] &= pl[8 + i]; + return [self frame:cmd sub:sub]; + } + if (sub == S2_SUB_MEMORY_ERASE) { + _erases++; + if (![self inSector:addr len:1]) { _bad++; return nil; } + BOOL ignored = _ignoreWrites || (_ignoreErase && _erases > _erasesBeforeIgnore); + if (!ignored) memset(_mem, 0xFF, sizeof _mem); + _ignoredErase = _ignoredErase || ignored; + if (_stopAfterErase) _silent = YES; + return [self frame:cmd sub:sub]; + } + return nil; +} +- (void)note:(NSData *)reply for:(const uint8_t *)pl { if (memcmp((const uint8_t *)reply.bytes + 8, pl, 8)) _refusable++; } +@end + +// Runs the main run loop until *flag is set; a hang fails after 30 s instead of blocking +static BOOL run_until(const BOOL *flag) { + NSDate *limit = [NSDate dateWithTimeIntervalSinceNow:30]; + while (!*flag && limit.timeIntervalSinceNow > 0) [NSRunLoop.mainRunLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + return *flag; +} + +static s2_cal g_cal; // the calibration saved: a recentred factory one +static void cal_block(const s2_cal *c, uint8_t out[S2_USER_CAL_LEN]) { s2_user_cal_block(c, out); } + +static NSData *with_block(NSData *before); +typedef struct { BOOL finished; S2CalOutcome outcome; int calls; } save_result; +static int g_scenario; +#define CHECK_IN(name, c) do { checks++; if (!(c)) { fails++; printf("FAIL %s:%d %s: %s\n", __FILE__, __LINE__, name, #c); } } while (0) +// One save into f (into dir, or a fresh backup folder when dir is nil); the outcome, the backup path and the log lines. Every save is held to the same rules: done comes +// exactly once, nothing is written or erased outside the sector, and the sector ends as it was, as written, or blank (the factory calibration): never anything else +static save_result save_in(FakeFlash *f, const char *name, NSURL *dir, NSString **path, NSArray **lines, NSURL **dirOut) { + if (!dir) { + dir = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"t_switch2_ops-%d-%d", getpid(), ++g_scenario]] isDirectory:YES]; + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + NSData *before = f.sector; + NSMutableArray *log = [NSMutableArray new]; + __block save_result res = {NO, S2CalNoBackup, 0}; __block NSString *p = nil; + s2op_save_user_cal(f, "HCW1234567890X", g_cal, dir, ^(NSString *m) { [log addObject:m]; }, + ^(S2CalOutcome o, NSString *backup) { res.outcome = o; p = backup; res.finished = YES; res.calls++; }); + BOOL done = run_until(&res.finished); + if (!done) printf("FAIL %s: the save never finished\n", name); + [NSRunLoop.mainRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; // a second done would have come by now + if (getenv("T_VERBOSE")) for (NSString *m in log) printf(" %s: %s\n", name, m.UTF8String); + NSData *now = f.sector; + CHECK_IN(name, res.calls == 1); + CHECK_IN(name, f.bad == 0); + CHECK_IN(name, [now isEqualToData:before] || [now isEqualToData:with_block(before)] || s2_all_ff(now.bytes, now.length)); + *path = p; *lines = log; *dirOut = dir; + return res; +} +static save_result save(FakeFlash *f, const char *name, NSString **path, NSArray **lines, NSURL **dirOut) { return save_in(f, name, nil, path, lines, dirOut); } +static BOOL logged(NSArray *lines, NSString *part) { for (NSString *m in lines) if ([m containsString:part]) return YES; return NO; } +static BOOL backup_is(NSString *path, NSURL *dir, NSData *want) { + NSString *d = dir.path; if (!path || !d) return NO; + NSData *got = [NSData dataWithContentsOfFile:path]; + return got && [path hasPrefix:d] && [got isEqualToData:want]; +} +// A sector with motion calibration at 0x00 and an older stick block at 0x40 +static void older(FakeFlash *f) { + for (int i = 0; i < 0x20; i++) f.mem[i] = (uint8_t)(i * 7 + 3); + s2_cal o = g_cal; o.cx -= 30; o.cy += 12; o.maxx += 30; o.minx -= 30; o.maxy -= 12; o.miny += 12; + uint8_t b[S2_USER_CAL_LEN]; cal_block(&o, b); memcpy(f.mem + S2_USER_STICK1_OFF, b, sizeof b); +} +static NSData *with_block(NSData *before) { + NSMutableData *m = [before mutableCopy]; uint8_t b[S2_USER_CAL_LEN]; cal_block(&g_cal, b); + [m replaceBytesInRange:NSMakeRange(S2_USER_STICK1_OFF, S2_USER_CAL_LEN) withBytes:b]; + return m; +} + +int main(void) { + @autoreleasepool { + s2_cal factory = {2095, 1999, 1175, 1225, 1181, 1129, 1}; + CHECK(s2_cal_recentre(&factory, 2110, 1990, &g_cal)); + NSString *path; NSArray *lines; NSURL *dir; save_result r; + + { // A blank sector: the block is written straight, no erase + FakeFlash *f = [FakeFlash new]; NSData *before = f.sector; + r = save(f, "blank", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved); + CHECK([f.sector isEqualToData:with_block(before)] && f.erases == 0 && f.writes == 1 && f.bad == 0); + CHECK(backup_is(path, dir, before) && [path.lastPathComponent hasPrefix:@"HCW1234567890X-1FC000-"]); + CHECK(logged(lines, @"user calibration backed up to") && logged(lines, @"written and read back")); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // An older block and motion bytes: the sector is erased and written back whole, the motion bytes kept + FakeFlash *f = [FakeFlash new]; f.chunk = 48; older(f); NSData *before = f.sector; + r = save(f, "older", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved); + CHECK([f.sector isEqualToData:with_block(before)] && !memcmp(f.mem, before.bytes, 0x20) && f.erases == 1 && f.bad == 0); + CHECK(backup_is(path, dir, before)); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // Writes and erases answered but not done: the memory reads as before + FakeFlash *f = [FakeFlash new]; older(f); f.ignoreWrites = YES; NSData *before = f.sector; + r = save(f, "ignoreWrites", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalUnchanged && [f.sector isEqualToData:before] && logged(lines, @"reads as before: nothing changed")); + CHECK(f.erases == 1 && f.writes == 0); // the erase did not take, so nothing was written over the sector + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // The link drops right after the erase: the sector is left blank (the factory calibration is used), the backup is on disk and the log names it + FakeFlash *f = [FakeFlash new]; older(f); f.stopAfterErase = YES; NSData *before = f.sector; + r = save(f, "stopAfterErase", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalRestoreFailedNotBlank && f.erases == 1 && f.writes == 0); // never read blank again: not known to be blank + CHECK(backup_is(path, dir, before) && s2_all_ff(f.mem, S2_SECTOR_SIZE)); + BOOL named = NO; for (NSString *m in lines) if ([m hasPrefix:@"interrupted: "] && [m containsString:[NSString stringWithFormat:@"backup at %@", path]]) named = YES; + CHECK(named); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // Reads that time out twice before answering: retried + FakeFlash *f = [FakeFlash new]; older(f); f.failReadsBeforeAnswer = 2; NSData *before = f.sector; + r = save(f, "failReads2", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && backup_is(path, dir, before)); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // A read that never answers within S2_CAL_TRIES: no backup, nothing written + FakeFlash *f = [FakeFlash new]; older(f); f.failReadsBeforeAnswer = S2_CAL_TRIES; NSData *before = f.sector; + r = save(f, "failReads4", &path, &lines, &dir); + CHECK(S2_CAL_TRIES == 4 && r.finished && r.outcome == S2CalNoBackup && !path && f.writes == 0 && f.erases == 0 && [f.sector isEqualToData:before]); + CHECK(logged(lines, @"got no answer 4 times") && logged(lines, @"could not be read for a backup: nothing written")); + NSString *dp = dir.path; + CHECK(dp && (![NSFileManager.defaultManager fileExistsAtPath:dp] || ![NSFileManager.defaultManager contentsOfDirectoryAtPath:dp error:nil].count)); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // The first read of each address times out and its reply comes with the next read + FakeFlash *f = [FakeFlash new]; older(f); f.staleReplies = YES; NSData *before = f.sector; + r = save(f, "staleReplies", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && backup_is(path, dir, before)); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // Every reply one read late, so most reads first get the reply for another address: refused by the echo check, retried + FakeFlash *f = [FakeFlash new]; older(f); f.staleChain = YES; NSData *before = f.sector; + r = save(f, "staleChain", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && backup_is(path, dir, before) && f.refusable > 64); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // A write lost on the way: the sector does not read as written, the backup is put back + FakeFlash *f = [FakeFlash new]; older(f); f.dropWrites = 1; NSData *before = f.sector; + r = save(f, "dropWrites", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalRestored && [f.sector isEqualToData:before] && f.erases == 2 && logged(lines, @"the backup is back")); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // An erase answered but not done (writes still clear bits): nothing is written over the old block, which would leave old & new, a calibration that is neither + FakeFlash *f = [FakeFlash new]; older(f); f.ignoreErase = YES; NSData *before = f.sector; + r = save(f, "ignoreErase", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalUnchanged && [f.sector isEqualToData:before] && f.erases == 1 && f.writes == 0 && f.writesAfterIgnoredErase == 0); + CHECK(logged(lines, @"does not read blank after the erase: nothing written") && logged(lines, @"reads as before: nothing changed") && backup_is(path, dir, before)); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // The new block is written, its read back times out, and the restore's erase is answered but not done: the backup is not written over it, and the outcome says + // the sector is not blank (here it holds the new block) + FakeFlash *f = [FakeFlash new]; older(f); f.ignoreErase = YES; f.erasesBeforeIgnore = 1; f.deafAfterWrite = S2_CAL_TRIES; NSData *before = f.sector; + r = save(f, "ignoreRestoreErase", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalRestoreFailedNotBlank && f.erases == 2 && f.writesAfterIgnoredErase == 0 && [f.sector isEqualToData:with_block(before)]); + CHECK(logged(lines, @"the backup is not written over it") && logged(lines, @"may be neither the old nor the new one") && backup_is(path, dir, before)); + CHECK(!logged(lines, @"factory")); // nothing claims the factory calibration is in use + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // Every write lost, the restore's too: the sector is left blank, and the outcome says so (the factory calibration is used) + FakeFlash *f = [FakeFlash new]; older(f); f.dropWrites = 1000; NSData *before = f.sector; + r = save(f, "dropAllWrites", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalRestoreFailed && s2_all_ff(f.mem, S2_SECTOR_SIZE) && f.erases == 2 && backup_is(path, dir, before)); + CHECK(logged(lines, @"blank, so the controller uses its factory calibration")); + [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // A backup folder that cannot be made: nothing written + FakeFlash *f = [FakeFlash new]; older(f); NSData *before = f.sector; + NSString *file = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"t_switch2_ops-%d-file", getpid()]]; + [@"x" writeToFile:file atomically:YES encoding:NSUTF8StringEncoding error:nil]; + r = save_in(f, "noFolder", [NSURL fileURLWithPath:[file stringByAppendingPathComponent:@"sub"]], &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalNoBackup && !path && f.writes == 0 && f.erases == 0 && [f.sector isEqualToData:before] && logged(lines, @"the backup could not be saved")); + [NSFileManager.defaultManager removeItemAtPath:file error:nil]; + } + { // s2op_read: the bytes asked for, in reads of 0x40; nil when a read never answers + FakeFlash *f = [FakeFlash new]; older(f); + __block BOOL got = NO; __block NSData *d = nil; + s2op_read(f, BASE, 0x90, S2_CAL_TRIES, nil, ^(NSData *x) { d = x; got = YES; }); + CHECK(run_until(&got) && d.length == 0x90 && !memcmp(d.bytes, f.mem, 0x90) && f.reads == 3); + FakeFlash *g = [FakeFlash new]; g.failReadsBeforeAnswer = S2_CAL_TRIES; got = NO; d = [NSData data]; + s2op_read(g, BASE, 0x40, S2_CAL_TRIES, nil, ^(NSData *x) { d = x; got = YES; }); + CHECK(run_until(&got) && !d && g.reads == S2_CAL_TRIES); + FakeFlash *h = [FakeFlash new]; h.failReadsBeforeAnswer = 1; got = NO; d = [NSData data]; // tries 1 (the handshake): one read, no retry + s2op_read(h, BASE, 0x0B, 1, nil, ^(NSData *x) { d = x; got = YES; }); + CHECK(run_until(&got) && !d && h.reads == 1); + } + { // Bonding: the host address, two keys, done, in that order + FakeFlash *f = [FakeFlash new]; const uint8_t host[6] = {1, 2, 3, 4, 5, 6}; + NSMutableArray *log = [NSMutableArray new]; __block BOOL got = NO, ok = NO; + s2op_bond(f, host, ^(NSString *m) { [log addObject:m]; }, ^(BOOL b) { ok = b; got = YES; }); + CHECK(run_until(&got) && ok && f.cmds.count == 4 && logged(log, @"paired with this Mac")); + uint8_t hp[S2_PAIR_HOST_LEN]; s2_pair_host_payload(hp, host); + if (f.cmds.count == 4) { + const uint8_t *c0 = f.cmds[0].bytes; + CHECK(c0[0] == S2_CMD_PAIR && c0[1] == S2_SUB_PAIR_HOST && f.cmds[0].length == 2 + S2_PAIR_HOST_LEN && !memcmp(c0 + 2, hp, sizeof hp)); + CHECK(((const uint8_t *)f.cmds[1].bytes)[1] == S2_SUB_PAIR_KEY1 && f.cmds[1].length == 2 + S2_PAIR_KEY_LEN && ((const uint8_t *)f.cmds[2].bytes)[1] == S2_SUB_PAIR_KEY2); + CHECK(((const uint8_t *)f.cmds[3].bytes)[1] == S2_SUB_PAIR_DONE && f.cmds[3].length == 3 && ((const uint8_t *)f.cmds[3].bytes)[2] == 0); + CHECK(memcmp((const uint8_t *)f.cmds[1].bytes + 3, (const uint8_t *)f.cmds[2].bytes + 3, 16)); // two different keys + } + FakeFlash *g = [FakeFlash new]; g.pairAnswers = 1; [log removeAllObjects]; got = NO; ok = YES; + s2op_bond(g, host, ^(NSString *m) { [log addObject:m]; }, ^(BOOL b) { ok = b; got = YES; }); + CHECK(run_until(&got) && !ok && g.cmds.count == 2 && logged(log, @"pairing stopped at the first key (no answer)")); + } + // Where the backups go: Application Support//Switch 2 calibration + NSURL *bd = s2op_backup_dir(); + CHECK([bd.lastPathComponent isEqualToString:@"Switch 2 calibration"] && [bd.path containsString:@"Application Support/"]); + CHECK([bd.URLByDeletingLastPathComponent.lastPathComponent isEqualToString:NSBundle.mainBundle.bundleIdentifier ?: @"nxnative"]); + } + printf("t_switch2_ops: %d checks, %d failed\n", checks, fails); + return fails ? 1 : 0; +} diff --git a/dm/tools/impact_map.tsv b/dm/tools/impact_map.tsv index 91eddde65f..8418dee28a 100644 --- a/dm/tools/impact_map.tsv +++ b/dm/tools/impact_map.tsv @@ -149,6 +149,10 @@ dm/host/motion_pro_mac.h hle:*SixAxisSensor* Mac Pro Controller raw motion trans dm/host/switch2_proto.h hle:*hid* Switch 2 controller protocol: reports, sticks, buttons, motion maps dm/host/pad_switch2_apple.h hle:*hid* Switch 2 controllers over Bluetooth LE (off unless DM_SW2=1) dm/host/pad_switch2_apple.m hle:*hid* Switch 2 controllers over Bluetooth LE (off unless DM_SW2=1) +dm/host/sw2_panel_mac.h hle:*hid* the Mac Controller menu and panel (the Switch 2 stick tester) +dm/host/sw2_panel_mac.m hle:*hid* the Mac Controller menu and panel (the Switch 2 stick tester) +dm/host/switch2_ops_apple.h hle:*hid* Switch 2 controller memory writes and bonding (off unless DM_SW2=1) +dm/host/switch2_ops_apple.m hle:*hid* Switch 2 controller memory writes and bonding (off unless DM_SW2=1) dm/host/front.h present:* the front end dm/ios/* *:* the iOS app dm/third_party/ryujinx/src/Ryujinx.Graphics.Shader/Translation/ResourceReservations.cs shader:stage:gs geometry compute varying slots diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 80ca355bf2..bd8da628e7 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -19,6 +19,7 @@ DM_CHEATS_ANY_BUILD switch `1` loads a cheat file written for another build of t DM_CHEATS_DEBUG diagnostic Logs every memory write a cheat makes and the cheat file context the menu's "another version" rows come from DM_CHEATS_ON switch Comma-separated cheat names turned on at launch, besides the saved per-build enabled states DM_CHEATS_WINDOW switch `1` opens the Mac Cheats panel beside the game from the start +DM_CONTROLLER_WINDOW switch `1` opens the Mac Controller panel (the Switch 2 stick tester) beside the game from the start; the game gets no Switch 2 controller input until it is closed DM_CONTROL_NACP path The game's control data, `control.nacp` (default: `control.nacp` beside the RomFS, as in the NSP script's extraction folder and the Mac app's Resources); the iOS app sets it to its `dm/control.nacp`. The host answers `fs::GetCacheStorageMax` from it DM_COPY_GAME_RESOURCES switch `NO` skips the Xcode build phase that copies the game's resources into the app (`stage_resources.sh`, on by default); `build_ipa.sh` sets it for its unsigned build, which packs them itself DM_COPY_ROMFS switch `NO` keeps the game files out of the app, in the Xcode build phase (`stage_resources.sh`) and in the unsigned IPA of `build_ipa.sh` (on by default): `main.m` takes the validated `Documents/romfs.img` copied by `scripts/push_romfs.sh`, then a `Documents/romfs` folder; the build writes the image's identity as `romfs.id` into the app, and `main.m` uses only a matching image (`--romfs separate` of the NSP script sets this) @@ -384,6 +385,7 @@ DM_SPIN_LIST diagnostic `=1` makes `patch_nso.py` print every guest poll loop it DM_SPIN_POLL switch `=<µs>`: how long a spin-gate wait polls before it sleeps with the yield back-off (default 2000; `0` yields on every pass) DM_SVC diagnostic `=1` logs memory SVCs (`SetHeapSize`, `MapMemory`, `Map`/`UnmapPhysicalMemory`) with their addresses and sizes DM_SW2 player `=1` turns on Switch 2 controllers over Bluetooth LE (Joy-Con 2, Pro Controller 2): the settings window's Switch 2 controllers. Off, no Bluetooth client is created and macOS asks nothing. The host also stays off when the app's Info.plist has no NSBluetoothAlwaysUsageDescription, without which macOS ends the process at the first Bluetooth scan +DM_SW2_CALIBRATE switch `=1` writes each Switch 2 controller's stick rest into the controller as its user calibration, once a run, for a stick that drifts: let go of the stick (a Pro Controller 2's left one, a Joy-Con 2's only one) for two seconds after it connects (left on, it overrides a calibration saved from the Controller panel at every start). The user calibration sector is backed up to Application Support//Switch 2 calibration/ first and put back when it does not read back as written; the log says each step DM_SW2_GYRO_DPS diagnostic `=` is the Switch 2 controllers' gyro scale (default 0.0607, measured on a left and a right Joy-Con 2; the Switch family's nominal 0.070 turned a 90 degree tilt into 104) DM_SW2_HOST switch `=aa:bb:cc:dd:ee:ff` names this Mac's Bluetooth address when the Mac's Bluetooth host controller does not give it (IOBluetooth): a paired Switch 2 controller wakes with an advertisement addressed to it, and pairing gives it this address DM_SW2_MAP diagnostic `=a,b,c,d,e,f` replaces the built-in map from a Switch 2 controller's raw sensor channels (1 to 6: accelerometer x y z, gyro x y z, a minus flips the sign) to the Switch frame's acceleration x y z and rotation x y z, e.g. `-1,-2,-3,4,5,6` for a Joy-Con 2 held upright diff --git a/docs/playing.md b/docs/playing.md index 99998d4578..3a8a512a37 100644 --- a/docs/playing.md +++ b/docs/playing.md @@ -68,6 +68,8 @@ Joy-Con 2 and the Pro Controller 2 do not appear in macOS's Bluetooth settings: **Pairs and singles.** A left and a right Joy-Con 2 that connect play together as one pair. To make one of a pair a player of its own, held sideways, hold its **SL and SR** (the small rail buttons) for three seconds; to pair two single Joy-Cons again, hold **L** on the left one and **R** on the right one together for three seconds. The player lights show the new player numbers. A game that asks for more players than there are controllers splits a pair itself, as on a Switch (Super Mario Bros. Wonder's two-player screen). The game sees Joy-Cons as Joy-Cons: a pair is a dual controller with a sensor in each Joy-Con, a single one a sideways Joy-Con with its own sensor and rail buttons; a game that knows no Joy-Cons reads them as a Pro Controller. The gyro works, and so does rumble: each Joy-Con of a pair plays its side, a Joy-Con alone plays both, and **Game rumble**, **Rumble strength** and **Rumble cutoff** apply. Hold **Home** for two seconds for the quit prompt, as on other controllers. Each game app asks for Bluetooth access once. An app packed again asks again: macOS ties the answer to the app's signature, and the NSP script signs each build anew, so the same bundle ID does not carry it over. Not yet: the Joy-Con 2's mouse sensor. On a Pro Controller 2, pairing, every button, the sticks and Home are checked; its rumble is untested, and its gyro scale and axes are borrowed from the Joy-Con 2 and the Pro Controller, not measured on it. +**A character creeps with the stick let go?** In the game window, **Controller** › **Show Controller Window** (⌘⇧P) shows where the stick rests and calibrates it ([Switch 2 controllers](settings.md#switch-2-controllers)). + The mouse pointer hides when you press a key or a controller button, or move a stick, while it is over the game picture. Moving the mouse shows it again, as does switching to another window or app. ## Shaders on first sight diff --git a/docs/reference/services.md b/docs/reference/services.md index 9bf64814ca..5a8cdf23c6 100644 --- a/docs/reference/services.md +++ b/docs/reference/services.md @@ -423,6 +423,8 @@ Most service calls can be found before they crash, before the first boot or the - **Fix:** `switch2_proto.h` (pure C: the advertisement and what the host does with it, command framing, bonding, the input report, stick calibration, the motion maps) and `pad_switch2_apple.m` (a CoreBluetooth central the Mac and iOS front ends share, behind the off-by-default `DM_SW2`). A controller held in Sync advertises no host: on the Mac the host bonds it with the console's `0x15` commands (this Mac's Bluetooth address, then two keys, which may be fresh random ones), and from then on a button press makes it advertise toward this Mac and the host connects. A controller advertising another host is never written to. After connecting, the host runs the handshake (identity read, stick calibration, player lights, features) and takes the 63-byte reports; a player light write once a second keeps the link up. - **Report layout (a left and a right Joy-Con 2, macOS 27):** the controller's own clock in milliseconds at offset 0 (four bytes; +30 a report on a Mac, +60 or +90 when one was lost), buttons at offset 4 (the same bits as the Pro Controller 2; the right Joy-Con's rail buttons are `0x10` / `0x20`, the left's `0x100000` / `0x200000`), the left Joy-Con's stick in the first stick field and the right one's in the second, both calibrated from the first slot (`0x130A8`); accelerometer at `0x30`, gyro at `0x36`. A Joy-Con held upright reports raw x right, y forward, z up, and its gyro channels are right-handed rotations about the same axes, which is the Switch frame, so its map is the identity for rotation and the negated reading for gravity. The gyro scale is 0.0607 degrees per second per count, measured: 14 tilts from lying on an edge to standing against a wall (90 degrees by the accelerometer) integrated to 1.153 times as much at the Switch family's nominal 0.070, on both Joy-Cons. Each sample is timed by the controller's clock, not by when its notification arrived (they come in bursts, and a lost report leaves a gap only the clock shows). At rest the gyro reads up to about 1.5 degrees per second off, a fixed offset per controller: it is there from the first sample, the one learned for that controller before (kept under its serial number) or, for a right Joy-Con 2, its factory offset (16 bytes at `0x13040`: a temperature and the offset in radians per second, which matches its rest offset); about a second of nearly constant samples refines it. - **Rumble:** each controller lists its own vibration channel (the right Joy-Con 2's `FA19B0FB-CD1F-46A7-84A1-BBB09E00C149`, the left one's `289326CB-A471-485D-A8F4-240C14F18241`). The report is a zero byte and a 16-byte block per actuator: `0x40 | samples << 4 | sequence`, then up to three five-byte samples, played in turn. A sample is two bands, low then high, each a 10-bit frequency code and a 10-bit amplitude. The frequency code is logarithmic, 96 steps an octave, with 333 at 110 Hz (about 9.9 Hz to 16 kHz); the amplitude is linear, 1023 at full; both bands together are limited to 0.98. Hertz and amplitude are rounded as the console rounds them: truncate, then the nearer of that code and the next. Checked against the Switch 2's own encoder on 200,000 random values: no byte differed. An earlier reading (hertz as the code, a single sample per block) played effects 3 to 5 times too low in pitch and dropped two samples of three. A game sets its vibration about every 5 ms, and each value plays once in its own 5 ms sample, three to a packet every 15 ms (`switch2_proto.h`, Rumble timing), as the console plays them: a one-step pulse stays one sample long. Before that, a value played until the next packet, so short pulses lasted two to three times as long; replayed against a recording of Nintendo Switch 2 Welcome Tour's rumble exhibits, their energy came to 1.8 to 2.2 times the console's, and now matches it. The game's two full-key motors reach the pad through `haptics_apple.m` (`dm_haptics_apple_set_sink`, with Rumble strength and cutoff applied): each Joy-Con of a pair plays its side, a Joy-Con alone the stronger of the two, a Pro Controller 2 one per grip (its channel from the notes, untested). The console also corrects the amplitude by frequency with tables of its own per controller model; that is not done here. +- **Stick calibration written (`DM_SW2_CALIBRATE=1`, untested on hardware):** for a stick that drifts with no Switch 2 at hand to calibrate it. Once a run per controller the host waits for two seconds of the released stick not moving more than 24 counts, refuses a rest more than 300 counts from the calibrated centre (a held stick), and writes the rest as the user calibration of the first stick slot: the marker `B2 A1` and the 9 bytes the host reads at `0x1FC042`, the centre moved and the travel changed so the end stops stay where they were. The write is command `0x02` subcommand `0x05` and the erase `0x03` (ndeadly/switch2_controller_research commands.md); no driver writes this memory, and whether a write over bytes that are not `0xFF` needs the erase first is not known. So the host first saves the whole user calibration sector (`0x1FC000`, 4 KB) to a file, writes straight into a blank block and otherwise erases the sector and writes it back with the new block, then reads it all back: as written, the new centre is used at once; otherwise it erases and writes the backup back. An answer to an erase is not taken as the erase done: the sector is read after every erase and written only when it reads blank (a write over bytes that were not erased would leave the old and new bytes ANDed, a calibration that is neither); when the restore cannot put the backup back, the log says whether the sector is blank (the factory calibration is used) or may hold neither calibration. The backup line logs which of `0x1FC040`, `0x1FC060` and `0x1FC080` carry the marker: SDL and Linux read the second stick's user calibration at `0x1FC080`, where this host reads `0x1FC062`. When the controller stops answering in the middle (the link dropped after the erase), the log says so in a line starting `interrupted:` that names the backup file. Nothing below `0x1F5000` is ever written or erased (`dm/host/switch2_ops_apple.m`). +- **Controller menu and panel (game window, not yet run on hardware):** the Controller menu (between Cheats and Window; Show Controller Window, ⌘⇧P, or `DM_CONTROLLER_WINDOW=1` at launch) opens a panel in the game window, on the controller session the game already holds (`dm_switch2_controllers`, `dm_switch2_save` in `pad_switch2_apple.m`; the panel is `dm/host/sw2_panel_mac.m`), so a stick that makes a character creep is checked and calibrated mid-game and a saved calibration applies at once (the session re-reads it after any save). It shows one controller's stick live: the whole range, and the centre zoomed 12 times with the dead zone drawn as the round one the game applies (`s2_cal_apply`: the stick's own, read from the controller). Each time the stick is let go it leaves a marker where it came to rest (the last 20): orange inside the dead zone, red past it, where the game would see the stick move. Below them the panel gives the dead zone's size as a share of the travel, the stick's own or the 10 % default ([A Switch 2 controller's stick at rest reads as in use](#a-switch-2-controllers-stick-at-rest-reads-as-in-use)); it is not a setting. Its guided calibration runs as the console's does: let go (two seconds still: the new centre), rotate the stick around its edge twice (the furthest reach each way, trimmed, becomes the travel; a side short of 70 % of the factory travel is named so the player pushes further, a reach over 125 % of it starts the circles over: the factory calibration, not the stored one, which may be the short travel being replaced), then old against new, and Save. Save is the same backup, write, read back and restore as `DM_SW2_CALIBRATE` (`switch2_ops_apple.m`, one implementation for both), and the panel says how it ended, naming the controller, until Done (also when the controller disconnects or another one is shown): saved; nothing changed; restored from the backup; or could not restore, with the backup's path, and either the user calibration is blank (the factory one is used) or it may be neither the old nor the new one. The right stick of a Pro Controller 2 is test only: where its user calibration lives (`0x1FC062` or `0x1FC080`) is not settled. While the panel is open the game gets no input from Switch 2 controllers (so circles never move a character; Home still opens the quit prompt). A save in progress is waited for: ⌘Q, the Home prompt and the window's close button wait for it, 30 seconds at most. A kill, a Ctrl-C in the terminal that started the app or a session end (SIGTERM, SIGINT, SIGHUP) does not wait: the host ends within 1.5 seconds, and a save cut off between the erase and the write-back leaves the calibration blank (the factory one is used) or partly written, each block whole or blank, with the backup file on disk. `DM_SW2_CALIBRATE` left on re-centres the stick at every game start, so it overrides a calibration saved from the panel: turn it off once the panel is used. - **Bonding (a left and a right Joy-Con 2):** each bonded with fresh random keys, then woke toward the Mac on a button press and reconnected with no Sync. The answer to the first key was the same bytes on both Joy-Cons and with any key; the answer to the second changes with the key. - **Permission:** macOS asks once for each game app, at the first scan. A rebuilt app with the same bundle ID asked again (a fresh ad-hoc signature); a second start of the same app did not. - **Found on:** a left and a right Joy-Con 2 on an M1 Pro Mac, macOS 27.2: [Metroid Dread](../../dm/games/010093801237C000/README.md) (both woke and played as one pair, pairing by Sync during the game, the Home quit prompt; a Joy-Con paired back to a Switch 2 woke for it and was left alone, `woke for another host`, until Sync paired it with the Mac again) and [Super Mario Odyssey](../../dm/games/0100000000010000/README.md) (motion: the cap throw by shaking, from the pair's right Joy-Con; rumble on both Joy-Cons). diff --git a/docs/settings.md b/docs/settings.md index 95ef082f48..4ddb5eef1f 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -235,6 +235,8 @@ Turn off **Show this window at launch** to start the game at once with your choi **Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it with this Mac; after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)). macOS asks for Bluetooth access the first time, and again after the app is packed again. In an `env.txt`: `DM_SW2=1`. +**Controller menu and panel** (in the game window, with Switch 2 controllers on; not yet run on hardware): the **Controller** menu lists the connected Switch 2 controllers, and **Show Controller Window** (⌘⇧P, or `DM_CONTROLLER_WINDOW=1` in an `env.txt` to open it at launch) opens a panel that shows a controller's stick live and marks where it comes to rest each time it is let go, red where the game would see it move past the stick's dead zone (the stick's own, read from the controller). It can also calibrate the stick's centre and reach and save them in the controller, with a backup first; the game uses the new calibration at once (a Pro Controller 2's right stick is test only for now). While the panel is open the game gets no input from Switch 2 controllers: close it to play. `DM_SW2_CALIBRATE=1` left on re-centres the stick at every start and so overrides a calibration saved from the panel: turn it off once the panel is used ([Switch 2 controllers over Bluetooth LE](reference/services.md#switch-2-controllers-over-bluetooth-le)). + ### Cloud saves folder **Choose a folder…** picks the iCloud Drive folder the game's save is synced through, the one the iPhone app chose in its launcher's **Cloud saves**, or the game's own folder inside it, named after its title ID ([Cloud saves](#cloud-saves)); **Turn off** stops it. Off by default. macOS may ask once whether the app may open iCloud Drive; if the game log says `this app may not open iCloud Drive`, allow it in System Settings › Privacy & Security › Files and Folders. In an `env.txt`: `DM_SAVE_SYNC=`, or `DM_SAVE_SYNC=icloud` for iCloud Drive › nxnative Saves; the window then shows it fixed. From ab76fde8af8cc98a9093538749d67902c275152a Mon Sep 17 00:00:00 2001 From: UserDerezzed Date: Tue, 10 Nov 2026 15:00:38 +0800 Subject: [PATCH 2/3] Switch 2 controllers: at connect the log shows which user calibration slots (0x1FC040, 0x1FC060, 0x1FC080) carry the marker, and their bytes (read only), to settle where the second stick's lives --- dm/host/pad_switch2_apple.m | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/dm/host/pad_switch2_apple.m b/dm/host/pad_switch2_apple.m index f43a6649da..b64374c45c 100644 --- a/dm/host/pad_switch2_apple.m +++ b/dm/host/pad_switch2_apple.m @@ -353,9 +353,26 @@ - (void)finish { S2Session *t = w; if (!t) return; if (!r2) { [t fail:@"enabling the features was refused"]; return; } [t attachPad]; + [t logUserSlots]; }]; }]; } +// Read only, once per connection: which of the user calibration sector's stick slots carry the marker (B2 A1), and their bytes. Where the second stick's user calibration lives +// (0x1FC060 as this host reads it, or 0x1FC080 as SDL and Linux do) is not settled; a controller whose right stick was calibrated on a console answers it +- (void)logUserSlots { + s2op_read(self, S2_ADDR_USER_CAL_SECTOR + 0x40, 0x60, S2_CAL_TRIES, [self opsLog], ^(NSData *d) { + void (^log)(NSString *) = [self opsLog]; + if (!d) { log(@"user calibration slots: the read of 0x1FC040-0x1FC09F got no answer"); return; } + const uint8_t *b = d.bytes; + log([NSString stringWithFormat:@"user calibration slots: marker at 0x1FC040 %s, 0x1FC060 %s, 0x1FC080 %s", s2_user_cal_marked(b) ? "yes" : "no", s2_user_cal_marked(b + 0x20) ? "yes" : "no", + s2_user_cal_marked(b + 0x40) ? "yes" : "no"]); + for (int row = 0; row < 3; row++) { + NSMutableString *h = [NSMutableString string]; + for (int i = 0; i < 0x20; i++) [h appendFormat:@"%02x%s", b[row * 0x20 + i], i % 8 == 7 ? " " : " "]; + log([NSString stringWithFormat:@"user calibration 0x%06x: %@", S2_ADDR_USER_CAL_SECTOR + 0x40 + row * 0x20, h]); + } + }); +} - (void)attachPad { s2_pad *p = s2_pad_for(_pid); if (!p) { [self fail:@"out of memory"]; return; } From 1f1ca5938240f9d250639fd5c330a63635545f3d Mon Sep 17 00:00:00 2001 From: UserDerezzed Date: Tue, 10 Nov 2026 15:11:50 +0800 Subject: [PATCH 3/3] Switch 2 controllers: a Pro Controller 2's right stick can be calibrated and saved too, its user calibration read and written at 0x1FC080 as SDL and Linux read it (was read at 0x1FC062) --- dm/host/pad_switch2_apple.h | 2 +- dm/host/pad_switch2_apple.m | 12 ++++++------ dm/host/sw2_panel_mac.m | 6 ++---- dm/host/switch2_ops_apple.h | 4 ++-- dm/host/switch2_ops_apple.m | 8 +++++--- dm/host/switch2_proto.h | 9 ++++++--- dm/host/tests/t_switch2.c | 2 ++ dm/host/tests/t_switch2_ops.m | 32 ++++++++++++++++++++++++++++++-- docs/reference/services.md | 4 ++-- docs/settings.md | 2 +- 10 files changed, 57 insertions(+), 24 deletions(-) diff --git a/dm/host/pad_switch2_apple.h b/dm/host/pad_switch2_apple.h index 9d86c9293f..cc2c129cf4 100644 --- a/dm/host/pad_switch2_apple.h +++ b/dm/host/pad_switch2_apple.h @@ -58,7 +58,7 @@ int dm_switch2_on(void); // dm_switch2_start r int dm_switch2_controllers(dm_sw2_info *out, int max); // connected controllers whose handshake finished, oldest connection first, at most max; returns the count // The tester's hooks (one observer; NULL blocks remove it): changed = a controller came, went, finished its handshake or a save began or ended; report = a new input report of controller id void dm_switch2_observe(void (^changed)(void), void (^report)(uint32_t id)); -// cal as controller id's user calibration of slot (s2op_save_user_cal with s2op_backup_dir()). Refused at once (S2CalNoBackup, nil path, a log line) for slot != 0, an unknown id, a save already running on it, +// cal as controller id's user calibration of slot (s2op_save_user_cal with s2op_backup_dir()). Refused at once (S2CalNoBackup, nil path, a log line) for a slot the controller has no stick in (1 only on a Pro Controller 2), an unknown id, a save already running on it, // or an invalid cal. After any other outcome the session re-reads that slot's calibration (user, else factory) into the pad the game reads before done runs. done on the main queue, after this returns void dm_switch2_save(uint32_t id, int slot, s2_cal cal, void (^done)(S2CalOutcome outcome, NSString *backupPath)); int dm_switch2_saving(void); // some save is running (the quit wait) diff --git a/dm/host/pad_switch2_apple.m b/dm/host/pad_switch2_apple.m index b64374c45c..ef0cc0e4b3 100644 --- a/dm/host/pad_switch2_apple.m +++ b/dm/host/pad_switch2_apple.m @@ -240,10 +240,10 @@ - (void)calibrateAt:(float)x y:(float)y { __weak S2Session *w = self; [self saveCal:cal slot:0 done:^(S2CalOutcome o, NSString *path) { (void)o; (void)path; S2Session *s = w; if (s) s->_calStep = 0; }]; } -// cal as the user calibration of slot 0 (s2op_save_user_cal), then the session's calibration of that slot read again from the controller whatever came of it, so the game and the +// cal as the user calibration of slot (0, or 1 for a Pro Controller 2's right stick: s2op_save_user_cal), then the session's calibration of that slot read again from the controller whatever came of it, so the game and the // tester use what the controller now holds (a failed restore may have left it blank). Refused at once when one is running, the link is gone or cal is no calibration. done after it returns - (void)saveCal:(s2_cal)cal slot:(int)slot done:(void (^)(S2CalOutcome, NSString *))done { - NSString *why = slot != 0 ? @"only the first stick slot can be saved (where the second slot's user calibration lives is not settled)" : _closed || !_live ? @"the controller is no longer connected" + NSString *why = slot != 0 && (slot != 1 || _pid != S2_PID_PRO) ? @"that controller has no such stick" : _closed || !_live ? @"the controller is no longer connected" : _saving ? @"a save on it is still running" : !cal.valid ? @"that is not a calibration the controller would read" : nil; if (why) { LOG("[sw2] %s: nothing saved: %s", s2_kind(_pid), why.UTF8String); @@ -252,10 +252,10 @@ - (void)saveCal:(s2_cal)cal slot:(int)slot done:(void (^)(S2CalOutcome, NSString } cal.dead = _factory[slot].dead; // the stick's dead zone stays its own whatever calibration it takes _saving = YES; g_saving++; - LOG("[sw2] %s %s: saving centre %.0f, %.0f, travel %.0f, %.0f up and %.0f, %.0f down as its user calibration", s2_kind(_pid), _serial, cal.cx, cal.cy, cal.maxx, cal.maxy, cal.minx, cal.miny); + LOG("[sw2] %s %s: saving centre %.0f, %.0f, travel %.0f, %.0f up and %.0f, %.0f down as stick %d's user calibration", s2_kind(_pid), _serial, cal.cx, cal.cy, cal.maxx, cal.maxy, cal.minx, cal.miny, slot + 1); if (g_changed) g_changed(); S2Session *me = self; // held until the save ends: the operation holds its commander, and a controller that went away still answers - s2op_save_user_cal(self, _serial, cal, s2op_backup_dir(), [self opsLog], ^(S2CalOutcome o, NSString *path) { + s2op_save_user_cal(self, _serial, slot, cal, s2op_backup_dir(), [self opsLog], ^(S2CalOutcome o, NSString *path) { if (o == S2CalSaved) { me->_cal[slot] = cal; if (me->_pad) s2_dev_of(me->_pad, me->_pid)->cal[slot] = cal; } // what a re-read that gets no answer leaves [me readCal:slot keep:YES done:^{ me->_saving = NO; g_saving--; @@ -357,8 +357,8 @@ - (void)finish { }]; }]; } -// Read only, once per connection: which of the user calibration sector's stick slots carry the marker (B2 A1), and their bytes. Where the second stick's user calibration lives -// (0x1FC060 as this host reads it, or 0x1FC080 as SDL and Linux do) is not settled; a controller whose right stick was calibrated on a console answers it +// Read only, once per connection: which of the user calibration sector's stick slots carry the marker (B2 A1), and their bytes. The second stick's is read and written at +// 0x1FC080 as SDL and Linux do, unconfirmed by a console-calibrated controller (0x1FC060 is logged as well: one that carries the marker there would say otherwise) - (void)logUserSlots { s2op_read(self, S2_ADDR_USER_CAL_SECTOR + 0x40, 0x60, S2_CAL_TRIES, [self opsLog], ^(NSData *d) { void (^log)(NSString *) = [self opsLog]; diff --git a/dm/host/sw2_panel_mac.m b/dm/host/sw2_panel_mac.m index 3d2e3baab1..63a1c9911a 100644 --- a/dm/host/sw2_panel_mac.m +++ b/dm/host/sw2_panel_mac.m @@ -5,7 +5,7 @@ // works on one controller and stick at a time (a picker with two or more, Left / Right on a Pro Controller 2): the whole range, the centre zoomed 12x with the dead zone drawn as // the round one s2_cal_apply applies (the stick's own, read from the controller), a marker where the stick came to rest after each release (orange inside the dead zone, red where // the game would see it move), the readouts, the tally and the dead zone's size, and the guided calibration: let go (the rest becomes the centre), edges (two turns at the -// edge give the travel, judged against the factory calibration), review and save. Only the first stick slot is saved (a Pro Controller 2's right stick is test only), and a +// edge give the travel, judged against the factory calibration), review and save (either stick of a Pro Controller 2), and a // save's outcome stays on screen, naming the controller, whichever controller is shown or connected, until Done. While the panel is open the game gets nothing from Switch 2 // controllers (front_mac.m asks sw2_panel_open), so circles drawn here never move a character #import "sw2_panel_mac.h" @@ -189,7 +189,7 @@ - (int)slot { return [self rightStick] ? 1 : 0; } - (s2_cal)calibration { const dm_sw2_info *d = [self device]; s2_cal c; if (d && d->cal[[self slot]].valid) c = d->cal[[self slot]]; else { s2_cal_default(&c); if (d) c.dead = d->cal[[self slot]].dead; } return c; } - (BOOL)otherSaving { const dm_sw2_info *d = [self device]; return d && d->saving; } // a save on this controller runs that this panel did not start (DM_SW2_CALIBRATE's), and a second is refused - (BOOL)canCalibrate { const dm_sw2_info *d = [self device]; return d && d->cal[[self slot]].valid && !d->saving; } -- (BOOL)canSave { return ![self rightStick] && ![self otherSaving]; } +- (BOOL)canSave { return ![self otherSaving]; } - (BOOL)busy { return _step == ST_SAVING; } - (BOOL)holdsOutcome { return _step == ST_SAVING || _step == ST_RESULT; } // stays on screen until Done, whichever controller is shown or connected - (BOOL)calibrating { return _step == ST_LETGO || _step == ST_EDGES || _step == ST_REVIEW || _step == ST_SAVING; } @@ -536,7 +536,6 @@ - (CGFloat)layoutCal:(CGFloat)bw inner:(CGFloat)in { y = place(_text, in, y, w, 0, 8); if ([self otherSaving]) [small addObject:@"A save on this controller is still running."]; else if ([self device] && ![self canCalibrate]) [small addObject:@"This stick's calibration could not be read, so it cannot be calibrated here."]; - if ([self rightStick]) [small addObject:@"Right stick: test only for now"]; break; case ST_LETGO: _big.stringValue = @"Let go of the stick and leave it still."; y = place(_big, in, y, w, 0, 8); @@ -553,7 +552,6 @@ - (CGFloat)layoutCal:(CGFloat)bw inner:(CGFloat)in { _foot.stringValue = @"Travel in counts from the centre. Save backs up the controller's calibration memory first, then writes and reads it back; if it does not read back as written, the backup goes back in."; y = place(_foot, in, y, w, 0, 8); if ([self otherSaving]) [small addObject:@"A save on this controller is still running."]; - if ([self rightStick]) [small addObject:@"Right stick: test only for now"]; break; case ST_SAVING: _whoLabel.stringValue = _who ?: @""; y = place(_whoLabel, in, y, w, 0, 6); diff --git a/dm/host/switch2_ops_apple.h b/dm/host/switch2_ops_apple.h index 2647ae1733..a6ab9976a8 100644 --- a/dm/host/switch2_ops_apple.h +++ b/dm/host/switch2_ops_apple.h @@ -28,10 +28,10 @@ typedef NS_ENUM(NSInteger, S2CalOutcome) { NSURL *s2op_backup_dir(void); // Application Support//Switch 2 calibration // n bytes from addr in reads of 0x40, each tried up to tries times (S2_CAL_TRIES for a save; 1 for the handshake's bounded wait) and taken only when the reply carries the read's length and address; nil when one never answers. log (may be nil) gets each refused reply and each read given up void s2op_read(id c, uint32_t addr, NSUInteger n, int tries, void (^log)(NSString *), void (^done)(NSData *)); -// cal as the user calibration of the first stick slot (0x1FC040). The sector is backed up to a file in dir named after serial first; a blank block is written straight, +// cal as the user calibration of stick slot slot (0: 0x1FC040, 1: 0x1FC080; any other slot is refused, S2CalNoBackup, nothing read or written). The sector is backed up to a file in dir named after serial first; a blank block is written straight, // otherwise the sector is erased and written back whole; it is then read back, and put back from the backup when it does not read as written. Nothing is written after an // erase unless the sector then reads blank (an erase answered is not an erase done). log gets each step's line; done (once) the outcome and the backup's path (nil with // S2CalNoBackup) -void s2op_save_user_cal(id c, const char *serial, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *backupPath)); +void s2op_save_user_cal(id c, const char *serial, int slot, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *backupPath)); // Bonds the controller to host (its Bluetooth address, little endian) with fresh keys, as the console does (switch2_proto.h, Bonding); done(NO) when a step got no answer void s2op_bond(id c, const uint8_t host[6], void (^log)(NSString *), void (^done)(BOOL)); diff --git a/dm/host/switch2_ops_apple.m b/dm/host/switch2_ops_apple.m index d19264f3b2..6d5208c556 100644 --- a/dm/host/switch2_ops_apple.m +++ b/dm/host/switch2_ops_apple.m @@ -109,8 +109,10 @@ static void s2op_settle(id c, NSData *want, NSData *old, NSString * }); }); } -void s2op_save_user_cal(id c, const char *serial, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *)) { +void s2op_save_user_cal(id c, const char *serial, int slot, s2_cal cal, NSURL *dir, void (^log)(NSString *), void (^done)(S2CalOutcome, NSString *)) { void (^finish)(S2CalOutcome, NSString *) = ^(S2CalOutcome o, NSString *p) { if (done) done(o, p); }; + if (slot != 0 && slot != 1) { s2op_log(log, @"stick slot %d is no stick's: nothing written", slot); finish(S2CalNoBackup, nil); return; } + const uint32_t off = s2_user_slot_off(slot); NSString *ser = serial && *serial ? @(serial) : @"unknown"; // copied: the caller's buffer need not outlive the call uint8_t b[S2_USER_CAL_LEN]; s2_user_cal_block(&cal, b); NSData *blk = [NSData dataWithBytes:b length:sizeof b]; @@ -121,9 +123,9 @@ void s2op_save_user_cal(id c, const char *serial, s2_cal cal, NSURL const uint8_t *o = old.bytes; s2op_log(log, @"user calibration backed up to %@; marker B2 A1 at 0x1FC040 %s, 0x1FC060 %s, 0x1FC080 %s", path, s2_user_cal_marked(o + 0x40) ? "yes" : "no", s2_user_cal_marked(o + 0x60) ? "yes" : "no", s2_user_cal_marked(o + 0x80) ? "yes" : "no"); - NSMutableData *want = [old mutableCopy]; [want replaceBytesInRange:NSMakeRange(S2_USER_STICK1_OFF, S2_USER_CAL_LEN) withBytes:blk.bytes]; + NSMutableData *want = [old mutableCopy]; [want replaceBytesInRange:NSMakeRange(off, S2_USER_CAL_LEN) withBytes:blk.bytes]; void (^check)(BOOL) = ^(BOOL ok) { if (!ok) s2op_log(log, @"a write got no answer"); s2op_settle(c, want, old, path, cal, log, finish); }; - if (s2_all_ff(o + S2_USER_STICK1_OFF, S2_USER_CAL_LEN)) s2op_write(c, blk, S2_ADDR_USER_CAL_SECTOR + S2_USER_STICK1_OFF, 0, NO, check); + if (s2_all_ff(o + off, S2_USER_CAL_LEN)) s2op_write(c, blk, S2_ADDR_USER_CAL_SECTOR + off, 0, NO, check); else s2op_erase_sector(c, log, ^(NSData *erased) { if (!s2op_blank(erased)) { // the final check says whether anything changed: nothing, when the sector still reads as the backup s2op_log(log, @"the sector %@ after the erase: nothing written", erased ? @"does not read blank" : @"could not be read"); diff --git a/dm/host/switch2_proto.h b/dm/host/switch2_proto.h index d9de2675cc..cccfd0b02c 100644 --- a/dm/host/switch2_proto.h +++ b/dm/host/switch2_proto.h @@ -33,7 +33,7 @@ enum { S2_SUB_MEMORY_READ = 0x04, S2_SUB_LEDS_SET = 0x07, S2_SUB_FEATURE_SET = 0 // 0x12 are int16 position counters that wrap (x right, y toward the player), 0x14 and 0x16 jump from about 1,600 and 150 on a surface to about 4,600 and 3,200 when lifted #define S2_FEATURE_MOUSE 0x10 #define S2_MOUSE_SURFACE_MAX 1000 // 0x16 below this: on a surface -enum { S2_ADDR_INFO = 0x00013000u, S2_ADDR_STICK1 = 0x00013080u, S2_ADDR_STICK2 = 0x000130C0u, S2_ADDR_USER_STICK1 = 0x001FC042u, S2_ADDR_USER_STICK2 = 0x001FC062u }; +enum { S2_ADDR_INFO = 0x00013000u, S2_ADDR_STICK1 = 0x00013080u, S2_ADDR_STICK2 = 0x000130C0u, S2_ADDR_USER_STICK1 = 0x001FC042u, S2_ADDR_USER_STICK2 = 0x001FC082u }; // Each stick's factory block (S2_ADDR_STICK1 / 2): its stick parameters (S2_STICK_PARAMS bytes), then its calibration (9 bytes: 0x130A8, 0x130E8) enum { S2_STICK_PARAMS = 0x28, S2_STICK_BLOCK = S2_STICK_PARAMS + 9 }; @@ -206,8 +206,9 @@ static inline int s2_cal_pick(const s2_cal *user, int has_user, const s2_cal *fa } // ---- Writing a user stick calibration (DM_SW2_CALIBRATE: switch2_ops_apple.m) ---- -// The user calibration of the first stick slot is 11 bytes at 0x1FC040: the marker B2 A1 (Switch 1's user-calibration marker), then the 9 bytes s2_cal_parse reads, which is why -// the read starts at 0x1FC042 (SDL's SDL_hidapi_switch2.c and Linux's hid-nintendo.c check the marker the same way; both read the second slot at 0x1FC080, not 0x1FC062). Its +// The user calibration of a stick slot is 11 bytes, the first slot's at 0x1FC040 and the second's at 0x1FC080: the marker B2 A1 (Switch 1's user-calibration marker), then the +// 9 bytes s2_cal_parse reads, which is why the reads start at 0x1FC042 and 0x1FC082 (SDL's SDL_hidapi_switch2.c and Linux's hid-nintendo.c read both there and check the marker +// the same way; no console-calibrated controller has been seen to confirm the second: a Pro Controller 2 never calibrated on a console holds 0xFF at both). Its // sector 0x1FC000..0x1FCFFF holds only user calibration (ndeadly/switch2_controller_research memory_layout.md: motion at 0x1FC000, the sticks after it), all 0xFF until a // calibration is written. Command 0x02: subcommand 0x05 writes (length, 0x7E, 0, 0, address, data; addresses below 0x1F5000 are refused), 0x03 erases the 0x1000-byte sector // holding an address to 0xFF (0, 0, 0, 0, address). Whether a write over bytes that are not 0xFF needs the erase first is documented nowhere, so the host writes straight only @@ -217,6 +218,8 @@ enum { S2_SUB_MEMORY_ERASE = 0x03, S2_SUB_MEMORY_WRITE = 0x05 }; #define S2_ADDR_USER_CAL_SECTOR 0x001FC000u #define S2_SECTOR_SIZE 0x1000u #define S2_USER_STICK1_OFF 0x40u // the first stick slot's block in the sector (marker at 0x1FC040, calibration at S2_ADDR_USER_STICK1) +#define S2_USER_STICK2_OFF 0x80u // the second's (marker at 0x1FC080, calibration at S2_ADDR_USER_STICK2) +static inline uint32_t s2_user_slot_off(int slot) { return slot ? S2_USER_STICK2_OFF : S2_USER_STICK1_OFF; } #define S2_USER_CAL_LEN 11 static inline int s2_all_ff(const uint8_t *d, size_t n) { for (size_t i = 0; i < n; i++) if (d[i] != 0xFF) return 0; return 1; } // A calibration as the 9 bytes s2_cal_parse reads: centre, travel above, travel below, each pair packed x | y << 12, little endian diff --git a/dm/host/tests/t_switch2.c b/dm/host/tests/t_switch2.c index 3453ef56f6..bdd97f1e4f 100644 --- a/dm/host/tests/t_switch2.c +++ b/dm/host/tests/t_switch2.c @@ -208,6 +208,8 @@ int main(void) { CHECK(s2_user_cal_marked(blk) && !memcmp(blk + 2, b, 9)); s2_cal back; CHECK(s2_cal_parse(blk + 2, 9, &back) && back.cx == c.cx && back.miny == c.miny); // what readCal reads at 0x1FC042 CHECK(S2_ADDR_USER_CAL_SECTOR + S2_USER_STICK1_OFF + 2 == S2_ADDR_USER_STICK1); + // The second stick slot's user calibration is at 0x1FC080 (SDL and Linux read it there), its 9 bytes after the marker at 0x1FC082 + CHECK(S2_ADDR_USER_STICK2 == 0x001FC082u && S2_ADDR_USER_CAL_SECTOR + s2_user_slot_off(1) + 2 == S2_ADDR_USER_STICK2 && s2_user_slot_off(0) == S2_USER_STICK1_OFF); // A new centre keeps the end stops (2095 + 1175 = 3270 and 2095 - 1181 = 914 still read full tilt) and the stick's own dead zone (a Pro Controller 2's 160 counts) c.dead = 160; s2_cal rc; CHECK(s2_cal_recentre(&c, 2300.4f, 1980.f, &rc) && rc.cx == 2300 && rc.cy == 1980 && rc.maxx == 970 && rc.minx == 1386 && rc.maxy == 1244 && rc.miny == 1110 && rc.dead == 160); diff --git a/dm/host/tests/t_switch2_ops.m b/dm/host/tests/t_switch2_ops.m index 3f581e4eef..4fa7b16a91 100644 --- a/dm/host/tests/t_switch2_ops.m +++ b/dm/host/tests/t_switch2_ops.m @@ -104,6 +104,7 @@ static BOOL run_until(const BOOL *flag) { static NSData *with_block(NSData *before); typedef struct { BOOL finished; S2CalOutcome outcome; int calls; } save_result; static int g_scenario; +static int g_slot; // the stick slot save_in saves (0: 0x1FC040, 1: 0x1FC080) #define CHECK_IN(name, c) do { checks++; if (!(c)) { fails++; printf("FAIL %s:%d %s: %s\n", __FILE__, __LINE__, name, #c); } } while (0) // One save into f (into dir, or a fresh backup folder when dir is nil); the outcome, the backup path and the log lines. Every save is held to the same rules: done comes // exactly once, nothing is written or erased outside the sector, and the sector ends as it was, as written, or blank (the factory calibration): never anything else @@ -115,7 +116,7 @@ static save_result save_in(FakeFlash *f, const char *name, NSURL *dir, NSString NSData *before = f.sector; NSMutableArray *log = [NSMutableArray new]; __block save_result res = {NO, S2CalNoBackup, 0}; __block NSString *p = nil; - s2op_save_user_cal(f, "HCW1234567890X", g_cal, dir, ^(NSString *m) { [log addObject:m]; }, + s2op_save_user_cal(f, "HCW1234567890X", g_slot, g_cal, dir, ^(NSString *m) { [log addObject:m]; }, ^(S2CalOutcome o, NSString *backup) { res.outcome = o; p = backup; res.finished = YES; res.calls++; }); BOOL done = run_until(&res.finished); if (!done) printf("FAIL %s: the save never finished\n", name); @@ -143,7 +144,7 @@ static void older(FakeFlash *f) { } static NSData *with_block(NSData *before) { NSMutableData *m = [before mutableCopy]; uint8_t b[S2_USER_CAL_LEN]; cal_block(&g_cal, b); - [m replaceBytesInRange:NSMakeRange(S2_USER_STICK1_OFF, S2_USER_CAL_LEN) withBytes:b]; + [m replaceBytesInRange:NSMakeRange(s2_user_slot_off(g_slot), S2_USER_CAL_LEN) withBytes:b]; return m; } @@ -162,6 +163,33 @@ int main(void) { CHECK(logged(lines, @"user calibration backed up to") && logged(lines, @"written and read back")); [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; } + { // The second stick slot (a Pro Controller 2's right stick) into a blank sector: its block at 0x1FC080, written straight, the first slot left blank + FakeFlash *f = [FakeFlash new]; NSData *before = f.sector; g_slot = 1; + r = save(f, "blank slot 1", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && f.erases == 0 && f.writes == 1); + CHECK(s2_user_cal_marked(f.mem + 0x80) && s2_all_ff(f.mem + 0x40, S2_USER_CAL_LEN)); + g_slot = 0; [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // The second slot blank beside the first slot's block: written straight, the first slot's block and the motion bytes kept + FakeFlash *f = [FakeFlash new]; older(f); NSData *before = f.sector; g_slot = 1; + r = save(f, "beside slot 0", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && f.erases == 0 && f.writes == 1); + CHECK(!memcmp(f.mem, before.bytes, 0x40 + S2_USER_CAL_LEN) && s2_user_cal_marked(f.mem + 0x80)); + g_slot = 0; [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // An older second-slot block: the sector is erased and written back whole, the first slot's block and the motion bytes kept + FakeFlash *f = [FakeFlash new]; older(f); memcpy(f.mem + S2_USER_STICK2_OFF, f.mem + S2_USER_STICK1_OFF, S2_USER_CAL_LEN); NSData *before = f.sector; g_slot = 1; + r = save(f, "older slot 1", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalSaved && [f.sector isEqualToData:with_block(before)] && f.erases == 1); + CHECK(!memcmp(f.mem, before.bytes, 0x40 + S2_USER_CAL_LEN) && s2_user_cal_marked(f.mem + 0x80)); + g_slot = 0; [NSFileManager.defaultManager removeItemAtURL:dir error:nil]; + } + { // A slot that is not a stick's: refused before anything is read or written + FakeFlash *f = [FakeFlash new]; NSData *before = f.sector; g_slot = 2; + r = save(f, "slot 2", &path, &lines, &dir); + CHECK(r.finished && r.outcome == S2CalNoBackup && !path && f.writes == 0 && f.erases == 0 && [f.sector isEqualToData:before]); + g_slot = 0; + } { // An older block and motion bytes: the sector is erased and written back whole, the motion bytes kept FakeFlash *f = [FakeFlash new]; f.chunk = 48; older(f); NSData *before = f.sector; r = save(f, "older", &path, &lines, &dir); diff --git a/docs/reference/services.md b/docs/reference/services.md index 5a8cdf23c6..c370cbfee8 100644 --- a/docs/reference/services.md +++ b/docs/reference/services.md @@ -423,8 +423,8 @@ Most service calls can be found before they crash, before the first boot or the - **Fix:** `switch2_proto.h` (pure C: the advertisement and what the host does with it, command framing, bonding, the input report, stick calibration, the motion maps) and `pad_switch2_apple.m` (a CoreBluetooth central the Mac and iOS front ends share, behind the off-by-default `DM_SW2`). A controller held in Sync advertises no host: on the Mac the host bonds it with the console's `0x15` commands (this Mac's Bluetooth address, then two keys, which may be fresh random ones), and from then on a button press makes it advertise toward this Mac and the host connects. A controller advertising another host is never written to. After connecting, the host runs the handshake (identity read, stick calibration, player lights, features) and takes the 63-byte reports; a player light write once a second keeps the link up. - **Report layout (a left and a right Joy-Con 2, macOS 27):** the controller's own clock in milliseconds at offset 0 (four bytes; +30 a report on a Mac, +60 or +90 when one was lost), buttons at offset 4 (the same bits as the Pro Controller 2; the right Joy-Con's rail buttons are `0x10` / `0x20`, the left's `0x100000` / `0x200000`), the left Joy-Con's stick in the first stick field and the right one's in the second, both calibrated from the first slot (`0x130A8`); accelerometer at `0x30`, gyro at `0x36`. A Joy-Con held upright reports raw x right, y forward, z up, and its gyro channels are right-handed rotations about the same axes, which is the Switch frame, so its map is the identity for rotation and the negated reading for gravity. The gyro scale is 0.0607 degrees per second per count, measured: 14 tilts from lying on an edge to standing against a wall (90 degrees by the accelerometer) integrated to 1.153 times as much at the Switch family's nominal 0.070, on both Joy-Cons. Each sample is timed by the controller's clock, not by when its notification arrived (they come in bursts, and a lost report leaves a gap only the clock shows). At rest the gyro reads up to about 1.5 degrees per second off, a fixed offset per controller: it is there from the first sample, the one learned for that controller before (kept under its serial number) or, for a right Joy-Con 2, its factory offset (16 bytes at `0x13040`: a temperature and the offset in radians per second, which matches its rest offset); about a second of nearly constant samples refines it. - **Rumble:** each controller lists its own vibration channel (the right Joy-Con 2's `FA19B0FB-CD1F-46A7-84A1-BBB09E00C149`, the left one's `289326CB-A471-485D-A8F4-240C14F18241`). The report is a zero byte and a 16-byte block per actuator: `0x40 | samples << 4 | sequence`, then up to three five-byte samples, played in turn. A sample is two bands, low then high, each a 10-bit frequency code and a 10-bit amplitude. The frequency code is logarithmic, 96 steps an octave, with 333 at 110 Hz (about 9.9 Hz to 16 kHz); the amplitude is linear, 1023 at full; both bands together are limited to 0.98. Hertz and amplitude are rounded as the console rounds them: truncate, then the nearer of that code and the next. Checked against the Switch 2's own encoder on 200,000 random values: no byte differed. An earlier reading (hertz as the code, a single sample per block) played effects 3 to 5 times too low in pitch and dropped two samples of three. A game sets its vibration about every 5 ms, and each value plays once in its own 5 ms sample, three to a packet every 15 ms (`switch2_proto.h`, Rumble timing), as the console plays them: a one-step pulse stays one sample long. Before that, a value played until the next packet, so short pulses lasted two to three times as long; replayed against a recording of Nintendo Switch 2 Welcome Tour's rumble exhibits, their energy came to 1.8 to 2.2 times the console's, and now matches it. The game's two full-key motors reach the pad through `haptics_apple.m` (`dm_haptics_apple_set_sink`, with Rumble strength and cutoff applied): each Joy-Con of a pair plays its side, a Joy-Con alone the stronger of the two, a Pro Controller 2 one per grip (its channel from the notes, untested). The console also corrects the amplitude by frequency with tables of its own per controller model; that is not done here. -- **Stick calibration written (`DM_SW2_CALIBRATE=1`, untested on hardware):** for a stick that drifts with no Switch 2 at hand to calibrate it. Once a run per controller the host waits for two seconds of the released stick not moving more than 24 counts, refuses a rest more than 300 counts from the calibrated centre (a held stick), and writes the rest as the user calibration of the first stick slot: the marker `B2 A1` and the 9 bytes the host reads at `0x1FC042`, the centre moved and the travel changed so the end stops stay where they were. The write is command `0x02` subcommand `0x05` and the erase `0x03` (ndeadly/switch2_controller_research commands.md); no driver writes this memory, and whether a write over bytes that are not `0xFF` needs the erase first is not known. So the host first saves the whole user calibration sector (`0x1FC000`, 4 KB) to a file, writes straight into a blank block and otherwise erases the sector and writes it back with the new block, then reads it all back: as written, the new centre is used at once; otherwise it erases and writes the backup back. An answer to an erase is not taken as the erase done: the sector is read after every erase and written only when it reads blank (a write over bytes that were not erased would leave the old and new bytes ANDed, a calibration that is neither); when the restore cannot put the backup back, the log says whether the sector is blank (the factory calibration is used) or may hold neither calibration. The backup line logs which of `0x1FC040`, `0x1FC060` and `0x1FC080` carry the marker: SDL and Linux read the second stick's user calibration at `0x1FC080`, where this host reads `0x1FC062`. When the controller stops answering in the middle (the link dropped after the erase), the log says so in a line starting `interrupted:` that names the backup file. Nothing below `0x1F5000` is ever written or erased (`dm/host/switch2_ops_apple.m`). -- **Controller menu and panel (game window, not yet run on hardware):** the Controller menu (between Cheats and Window; Show Controller Window, ⌘⇧P, or `DM_CONTROLLER_WINDOW=1` at launch) opens a panel in the game window, on the controller session the game already holds (`dm_switch2_controllers`, `dm_switch2_save` in `pad_switch2_apple.m`; the panel is `dm/host/sw2_panel_mac.m`), so a stick that makes a character creep is checked and calibrated mid-game and a saved calibration applies at once (the session re-reads it after any save). It shows one controller's stick live: the whole range, and the centre zoomed 12 times with the dead zone drawn as the round one the game applies (`s2_cal_apply`: the stick's own, read from the controller). Each time the stick is let go it leaves a marker where it came to rest (the last 20): orange inside the dead zone, red past it, where the game would see the stick move. Below them the panel gives the dead zone's size as a share of the travel, the stick's own or the 10 % default ([A Switch 2 controller's stick at rest reads as in use](#a-switch-2-controllers-stick-at-rest-reads-as-in-use)); it is not a setting. Its guided calibration runs as the console's does: let go (two seconds still: the new centre), rotate the stick around its edge twice (the furthest reach each way, trimmed, becomes the travel; a side short of 70 % of the factory travel is named so the player pushes further, a reach over 125 % of it starts the circles over: the factory calibration, not the stored one, which may be the short travel being replaced), then old against new, and Save. Save is the same backup, write, read back and restore as `DM_SW2_CALIBRATE` (`switch2_ops_apple.m`, one implementation for both), and the panel says how it ended, naming the controller, until Done (also when the controller disconnects or another one is shown): saved; nothing changed; restored from the backup; or could not restore, with the backup's path, and either the user calibration is blank (the factory one is used) or it may be neither the old nor the new one. The right stick of a Pro Controller 2 is test only: where its user calibration lives (`0x1FC062` or `0x1FC080`) is not settled. While the panel is open the game gets no input from Switch 2 controllers (so circles never move a character; Home still opens the quit prompt). A save in progress is waited for: ⌘Q, the Home prompt and the window's close button wait for it, 30 seconds at most. A kill, a Ctrl-C in the terminal that started the app or a session end (SIGTERM, SIGINT, SIGHUP) does not wait: the host ends within 1.5 seconds, and a save cut off between the erase and the write-back leaves the calibration blank (the factory one is used) or partly written, each block whole or blank, with the backup file on disk. `DM_SW2_CALIBRATE` left on re-centres the stick at every game start, so it overrides a calibration saved from the panel: turn it off once the panel is used. +- **Stick calibration written (`DM_SW2_CALIBRATE=1`):** for a stick that drifts with no Switch 2 at hand to calibrate it. Once a run per controller the host waits for two seconds of the released stick not moving more than 24 counts, refuses a rest more than 300 counts from the calibrated centre (a held stick), and writes the rest as the user calibration of the first stick slot: the marker `B2 A1` and the 9 bytes the host reads at `0x1FC042`, the centre moved and the travel changed so the end stops stay where they were. The write is command `0x02` subcommand `0x05` and the erase `0x03` (ndeadly/switch2_controller_research commands.md); no driver writes this memory, and whether a write over bytes that are not `0xFF` needs the erase first is not known. So the host first saves the whole user calibration sector (`0x1FC000`, 4 KB) to a file, writes straight into a blank block and otherwise erases the sector and writes it back with the new block, then reads it all back: as written, the new centre is used at once; otherwise it erases and writes the backup back. An answer to an erase is not taken as the erase done: the sector is read after every erase and written only when it reads blank (a write over bytes that were not erased would leave the old and new bytes ANDed, a calibration that is neither); when the restore cannot put the backup back, the log says whether the sector is blank (the factory calibration is used) or may hold neither calibration. The backup line, and a line at every connection, log which of `0x1FC040`, `0x1FC060` and `0x1FC080` carry the marker. The second stick's user calibration is read and written at `0x1FC080` (marker) / `0x1FC082`, as SDL and Linux read it; no controller calibrated on a console has been seen to confirm it (upstream's first version read `0x1FC062`). A Pro Controller 2 that was never calibrated on a console holds `0xFF` in the whole sector. On hardware (Pro Controller 2, left stick) the write, read back and re-read work; each save logs a few `command 0x02 timed out` reads, which the retries absorb. A memory read's reply carries the read's length at byte 8 and its address at bytes 12-15, but not the request's `0x7E` at byte 9. When the controller stops answering in the middle (the link dropped after the erase), the log says so in a line starting `interrupted:` that names the backup file. Nothing below `0x1F5000` is ever written or erased (`dm/host/switch2_ops_apple.m`). +- **Controller menu and panel (game window):** the Controller menu (between Cheats and Window; Show Controller Window, ⌘⇧P, or `DM_CONTROLLER_WINDOW=1` at launch) opens a panel in the game window, on the controller session the game already holds (`dm_switch2_controllers`, `dm_switch2_save` in `pad_switch2_apple.m`; the panel is `dm/host/sw2_panel_mac.m`), so a stick that makes a character creep is checked and calibrated mid-game and a saved calibration applies at once (the session re-reads it after any save). It shows one controller's stick live: the whole range, and the centre zoomed 12 times with the dead zone drawn as the round one the game applies (`s2_cal_apply`: the stick's own, read from the controller). Each time the stick is let go it leaves a marker where it came to rest (the last 20): orange inside the dead zone, red past it, where the game would see the stick move. Below them the panel gives the dead zone's size as a share of the travel, the stick's own or the 10 % default ([A Switch 2 controller's stick at rest reads as in use](#a-switch-2-controllers-stick-at-rest-reads-as-in-use)); it is not a setting. Its guided calibration runs as the console's does: let go (two seconds still: the new centre), rotate the stick around its edge twice (the furthest reach each way, trimmed, becomes the travel; a side short of 70 % of the factory travel is named so the player pushes further, a reach over 125 % of it starts the circles over: the factory calibration, not the stored one, which may be the short travel being replaced), then old against new, and Save. Save is the same backup, write, read back and restore as `DM_SW2_CALIBRATE` (`switch2_ops_apple.m`, one implementation for both), and the panel says how it ended, naming the controller, until Done (also when the controller disconnects or another one is shown): saved; nothing changed; restored from the backup; or could not restore, with the backup's path, and either the user calibration is blank (the factory one is used) or it may be neither the old nor the new one. A Pro Controller 2's right stick is saved at `0x1FC080` (see the calibration write above for why that address). While the panel is open the game gets no input from Switch 2 controllers (so circles never move a character; Home still opens the quit prompt). A save in progress is waited for: ⌘Q, the Home prompt and the window's close button wait for it, 30 seconds at most. A kill, a Ctrl-C in the terminal that started the app or a session end (SIGTERM, SIGINT, SIGHUP) does not wait: the host ends within 1.5 seconds, and a save cut off between the erase and the write-back leaves the calibration blank (the factory one is used) or partly written, each block whole or blank, with the backup file on disk. `DM_SW2_CALIBRATE` left on re-centres the stick at every game start, so it overrides a calibration saved from the panel: turn it off once the panel is used. - **Bonding (a left and a right Joy-Con 2):** each bonded with fresh random keys, then woke toward the Mac on a button press and reconnected with no Sync. The answer to the first key was the same bytes on both Joy-Cons and with any key; the answer to the second changes with the key. - **Permission:** macOS asks once for each game app, at the first scan. A rebuilt app with the same bundle ID asked again (a fresh ad-hoc signature); a second start of the same app did not. - **Found on:** a left and a right Joy-Con 2 on an M1 Pro Mac, macOS 27.2: [Metroid Dread](../../dm/games/010093801237C000/README.md) (both woke and played as one pair, pairing by Sync during the game, the Home quit prompt; a Joy-Con paired back to a Switch 2 woke for it and was left alone, `woke for another host`, until Sync paired it with the Mac again) and [Super Mario Odyssey](../../dm/games/0100000000010000/README.md) (motion: the cap throw by shaking, from the pair's right Joy-Con; rumble on both Joy-Cons). diff --git a/docs/settings.md b/docs/settings.md index 4ddb5eef1f..abeb5a6b61 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -235,7 +235,7 @@ Turn off **Show this window at launch** to start the game at once with your choi **Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it with this Mac; after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)). macOS asks for Bluetooth access the first time, and again after the app is packed again. In an `env.txt`: `DM_SW2=1`. -**Controller menu and panel** (in the game window, with Switch 2 controllers on; not yet run on hardware): the **Controller** menu lists the connected Switch 2 controllers, and **Show Controller Window** (⌘⇧P, or `DM_CONTROLLER_WINDOW=1` in an `env.txt` to open it at launch) opens a panel that shows a controller's stick live and marks where it comes to rest each time it is let go, red where the game would see it move past the stick's dead zone (the stick's own, read from the controller). It can also calibrate the stick's centre and reach and save them in the controller, with a backup first; the game uses the new calibration at once (a Pro Controller 2's right stick is test only for now). While the panel is open the game gets no input from Switch 2 controllers: close it to play. `DM_SW2_CALIBRATE=1` left on re-centres the stick at every start and so overrides a calibration saved from the panel: turn it off once the panel is used ([Switch 2 controllers over Bluetooth LE](reference/services.md#switch-2-controllers-over-bluetooth-le)). +**Controller menu and panel** (in the game window, with Switch 2 controllers on): the **Controller** menu lists the connected Switch 2 controllers, and **Show Controller Window** (⌘⇧P, or `DM_CONTROLLER_WINDOW=1` in an `env.txt` to open it at launch) opens a panel that shows a controller's stick live and marks where it comes to rest each time it is let go, red where the game would see it move past the stick's dead zone (the stick's own, read from the controller). It can also calibrate the stick's centre and reach and save them in the controller, with a backup first; the game uses the new calibration at once. On a Pro Controller 2 either stick can be calibrated. While the panel is open the game gets no input from Switch 2 controllers: close it to play. `DM_SW2_CALIBRATE=1` left on re-centres the stick at every start and so overrides a calibration saved from the panel: turn it off once the panel is used ([Switch 2 controllers over Bluetooth LE](reference/services.md#switch-2-controllers-over-bluetooth-le)). ### Cloud saves folder