import Routes from './Routes';
import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext';
+import { SNESProvider } from '../hooks/snes';
import { UserProvider } from '../hooks/user';
import i18n from '../i18n';
}, []);
return <AlttpBaseRomProvider>
- <UserProvider>
- <Helmet>
- <html lang={i18n.language} />
- <title>{t('general.appName')}</title>
- <meta name="description" content={t('general.appDescription')} />
- </Helmet>
- <Routes />
- </UserProvider>
+ <SNESProvider>
+ <UserProvider>
+ <Helmet>
+ <html lang={i18n.language} />
+ <title>{t('general.appName')}</title>
+ <meta name="description" content={t('general.appDescription')} />
+ </Helmet>
+ <Routes />
+ </UserProvider>
+ </SNESProvider>
</AlttpBaseRomProvider>;
};
Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
Icon.HASH = makePreset('HashIcon', 'hashtag');
+Icon.INFO = makePreset('Info', 'circle-info');
Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
Icon.LOCKED = makePreset('LockedIcon', 'lock');
onChange,
onLabel,
readonly,
+ title,
value,
}) => {
const toggle = () => {
role="button"
aria-pressed={value}
tabIndex="0"
+ title={title}
onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
onClick={handleClick}
onKeyDown={handleKey}
onChange: PropTypes.func,
onLabel: PropTypes.string,
readonly: PropTypes.bool,
+ title: PropTypes.string,
value: PropTypes.bool,
};
onChange: null,
onLabel: '',
readonly: false,
+ title: null,
value: false,
};
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+ compareGTBasementState,
+ countGTBasementState,
+ getGTBasementState,
+} from '../../helpers/alttp-ram';
+import { useSNES } from '../../hooks/snes';
+
+const IN_GAME_MODES = [
+ 0x05, // loading game
+ 0x06, // entering dungeon
+ 0x07, // dungeon
+ 0x08, // entering overworld
+ 0x09, // overworld
+ 0x0A, // entering special overworld
+ 0x0B, // special overworld
+ 0x0E, // text/menu/map
+ 0x0F, // closing spot
+ 0x10, // opening spot
+ 0x11, // falling
+ 0x12, // dying
+ 0x13, // fanfare
+ 0x15, // mirror
+ 0x16, // refill
+ 0x17, // S&Q
+ 0x18, // aga 2 cutscene
+ 0x19, // triforce room
+ 0x1A, // credits
+ 0x1B, // spawn select
+];
+
+const GT_TYPES = [
+ 0x02, // all dungeons
+ 0x03, // defeat ganon
+ 0x04, // fast ganon (the default and used for defeat ganon for some reason)
+ 0x07, // crystals & bosses
+ 0x08, // bosses
+ 0x09, // all dungeons, no aga 1
+ 0x0B, // completionist
+];
+
+const FREE_ITEM_MENU = 0x180045;
+const GT_CRYSTALS = 0x18019A;
+const GANON_TYPE = 0x1801A8;
+const SEED_TYPE = 0x180210;
+const INIT_SRAM = 0x183000;
+
+const GAME_MODE = 0x10;
+const CURRENT_DUNGEON = 0x10E;
+const SAVE_WRAM = 0xF000;
+const ROOM_DATA_START = 0x000;
+const ROOM_DATA_END = 0x140;
+const PYRAMID_SCREEN = 0x2DB;
+const BIG_KEYS_1 = 0x366;
+const OWNED_CRYSTALS = 0x37A;
+
+const GT_ENTRANCE_ID = 55;
+
+const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
+ const [enabled, setEnabled] = React.useState(false);
+ const controls = React.useRef({
+ onSolve,
+ onStart,
+ onStop,
+ });
+
+ const [inGame, setInGame] = React.useState(false);
+ const [seedType, setSeedType] = React.useState(0);
+ const [gtCrystals, setGTCrystals] = React.useState(0);
+ const [ganonType, setGanonType] = React.useState(0);
+ const [freeItemMenu, setFreeItemMenu] = React.useState(0);
+ const [pyramidOpen, setPyramidOpen] = React.useState(false);
+
+ const [ownedCrystals, setOwnedCrystals] = React.useState(0);
+ const [lastEntrance, setLastEntrance] = React.useState(0);
+ const [hasEntered, setHasEntered] = React.useState(false);
+ const [basement, setBasement] = React.useState({
+ state: getGTBasementState(),
+ last: '',
+ count: 0,
+ torch: 0,
+ });
+ const [hasBigKey, setHasBigKey] = React.useState(false);
+
+ const {
+ disable: disableSNES,
+ enable: enableSNES,
+ sock,
+ status,
+ } = useSNES();
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ controls.current = {
+ onSolve,
+ onStart,
+ onStop,
+ };
+ }, [onSolve, onStart, onStop]);
+
+ const resetState = React.useCallback(() => {
+ setInGame(false);
+ setSeedType(0);
+ setGTCrystals(0);
+ setGanonType(0);
+ setFreeItemMenu(0);
+
+ setOwnedCrystals(0);
+ setLastEntrance(0);
+ setHasEntered(false);
+ setBasement({
+ state: getGTBasementState(),
+ last: '',
+ count: 0,
+ torch: 0,
+ });
+ setHasBigKey(false);
+ }, []);
+
+ const enable = React.useCallback(() => {
+ enableSNES();
+ setEnabled(true);
+ }, []);
+
+ const disable = React.useCallback(() => {
+ disableSNES();
+ setEnabled(false);
+ resetState();
+ }, []);
+
+ React.useEffect(() => {
+ const savedSettings = localStorage.getItem('guessingGame.settings');
+ if (savedSettings) {
+ const settings = JSON.parse(savedSettings);
+ if (settings.autoTrack) {
+ enable();
+ }
+ }
+ }, []);
+
+ const saveSettings = React.useCallback((newSettings) => {
+ const savedSettings = localStorage.getItem('guessingGame.settings');
+ const settings = savedSettings
+ ? { ...JSON.parse(savedSettings), ...newSettings }
+ : newSettings;
+ localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
+ }, []);
+
+ const toggle = React.useCallback(() => {
+ if (enabled) {
+ disable();
+ saveSettings({ autoTrack: false });
+ } else {
+ enable();
+ saveSettings({ autoTrack: true });
+ }
+ }, [enabled]);
+
+ // game mode timer
+ React.useEffect(() => {
+ if (enabled && !status.error && status.connected && status.device) {
+ const checkInGame = () => {
+ sock.current.readWRAM(GAME_MODE, 1, (data) => {
+ setInGame(IN_GAME_MODES.includes(data[0]));
+ });
+ };
+ checkInGame();
+ const timer = setInterval(checkInGame, 5000);
+ return () => {
+ clearInterval(timer);
+ };
+ } else {
+ setInGame(false);
+ }
+ }, [enabled && !status.error && status.connected && status.device]);
+
+ // refresh static game information
+ React.useEffect(() => {
+ if (!inGame) return;
+ sock.current.readBytes(SEED_TYPE, 1, (data) => {
+ setSeedType(data[0]);
+ });
+ sock.current.readBytes(GT_CRYSTALS, 1, (data) => {
+ setGTCrystals(data[0]);
+ });
+ sock.current.readBytes(GANON_TYPE, 1, (data) => {
+ setGanonType(data[0]);
+ });
+ sock.current.readBytes(FREE_ITEM_MENU, 1, (data) => {
+ setFreeItemMenu(data[0]);
+ });
+ sock.current.readBytes(INIT_SRAM + PYRAMID_SCREEN, 1, (data) => {
+ setPyramidOpen(!!(data[0] & 0x20));
+ });
+ }, [inGame, sock]);
+
+ const applicable = React.useMemo(() => {
+ return !seedType &&
+ gtCrystals &&
+ GT_TYPES.includes(ganonType) &&
+ !pyramidOpen &&
+ !(freeItemMenu & 0x02);
+ }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
+
+ // update crystals information
+ React.useEffect(() => {
+ if (!applicable || !inGame || hasBigKey) return;
+ const updateCrystals = () => {
+ sock.current.readWRAM(SAVE_WRAM + OWNED_CRYSTALS, 1, (data) => {
+ let owned = 0;
+ for (let i = 0; i < 7; ++i) {
+ if (data[0] & Math.pow(2, i)) {
+ ++owned;
+ }
+ }
+ setOwnedCrystals(owned);
+ });
+ };
+ // increase frequency for the last
+ const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
+
+ // start game once all required crystals have been acquired
+ React.useEffect(() => {
+ if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
+ controls.current.onStart();
+ const updateDungeon = () => {
+ sock.current.readWRAM(CURRENT_DUNGEON, 2, (data) => {
+ setLastEntrance(data[0] + (data[1] * 256));
+ });
+ };
+ const timer = setInterval(updateDungeon, 1000);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
+
+ // stop game when GT has been entered
+ React.useEffect(() => {
+ if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
+ if (lastEntrance === GT_ENTRANCE_ID) {
+ controls.current.onStop();
+ setHasEntered(true);
+ }
+ }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
+
+ // watch GT state
+ React.useEffect(() => {
+ if (!applicable || !hasEntered || hasBigKey) return;
+ const updateGTState = () => {
+ const roomDataSize = ROOM_DATA_END - ROOM_DATA_START;
+ sock.current.readWRAM(SAVE_WRAM + ROOM_DATA_START, roomDataSize, (data) => {
+ const gtState = getGTBasementState(data);
+ const gtCount = countGTBasementState(gtState);
+ setBasement(old => {
+ const cmp = compareGTBasementState(old.state, gtState);
+ if (cmp) {
+ return {
+ state: gtState,
+ last: cmp,
+ count: gtCount,
+ torch: cmp === 'torchSeen' ? gtCount : old.torch,
+ };
+ }
+ return old;
+ });
+ });
+ };
+ const timer = setInterval(updateGTState, 500);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, hasBigKey, hasEntered]);
+
+ React.useEffect(() => {
+ if (!applicable) return;
+ if (hasBigKey) {
+ const solution = basement.last === 'torch' ? basement.torch : basement.count;
+ controls.current.onSolve(solution);
+ } else {
+ sock.current.readWRAM(SAVE_WRAM + BIG_KEYS_1, 1, (data) => {
+ setHasBigKey(!!(data[0] & 0x04));
+ });
+ }
+ }, [applicable, basement, controls, hasBigKey]);
+
+ const statusMsg = React.useMemo(() => {
+ if (!enabled) {
+ return 'disabled';
+ }
+ if (status.error) {
+ return 'error';
+ }
+ if (!status.connected) {
+ return 'disconnected';
+ }
+ if (!status.device) {
+ return 'no-device';
+ }
+ if (!inGame) {
+ return 'not-in-game';
+ }
+ if (!applicable) {
+ return 'not-applicable';
+ }
+ return 'tracking';
+ }, [applicable, enabled, inGame, status]);
+
+ return <div>
+ {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
+ <Icon.WARNING
+ className="me-2 text-warning"
+ size="lg"
+ title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device })}
+ />
+ : null}
+ {['not-applicable', 'not-in-game'].includes(statusMsg) ?
+ <Icon.INFO
+ className="me-2 text-info"
+ size="lg"
+ title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device })}
+ />
+ : null}
+ <ToggleSwitch
+ onChange={toggle}
+ title={t('autoTracking.heading')}
+ value={enabled}
+ />
+ </div>;
+};
+
+GuessingGameAutoTracking.propTypes = {
+ onSolve: PropTypes.func,
+ onStart: PropTypes.func,
+ onStop: PropTypes.func,
+};
+
+export default GuessingGameAutoTracking;
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import GuessingGameAutoTracking from './GuessingGameAutoTracking';
import {
hasActiveGuessing,
isAcceptingGuesses,
];
return <div>
- <div className="button-bar mt-3">
- <Button
- onClick={onStart}
- variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
- >
- {t('button.start')}
- </Button>
- <Button
- onClick={onStop}
- variant={
- hasActiveGuessing(channel) && isAcceptingGuesses(channel)
- ? 'danger' : 'outline-danger'
- }
- >
- {t('button.stop')}
- </Button>
- <Button
- className="ms-3"
- onClick={onCancel}
- variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
- >
- {t('button.cancel')}
- </Button>
+ <div className="d-flex align-items-center justify-content-between mt-3">
+ <div className="button-bar">
+ <Button
+ onClick={onStart}
+ variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
+ >
+ {t('button.start')}
+ </Button>
+ <Button
+ onClick={onStop}
+ variant={
+ hasActiveGuessing(channel) && isAcceptingGuesses(channel)
+ ? 'danger' : 'outline-danger'
+ }
+ >
+ {t('button.stop')}
+ </Button>
+ <Button
+ className="ms-3"
+ onClick={onCancel}
+ variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
+ >
+ {t('button.cancel')}
+ </Button>
+ </div>
+ <GuessingGameAutoTracking
+ onSolve={onSolve}
+ onStart={onStart}
+ onStop={onStop}
+ />
</div>
{hasActiveGuessing(channel) ?
<div className="bkgg-buttons d-grid gap-3 my-3">
--- /dev/null
+export default class SNESSocket {
+
+ constructor(uri) {
+ this.uri = uri;
+ this.queue = [];
+ this.device = '';
+ this.deviceList = [];
+ this.open();
+ }
+
+ open() {
+ if (this.sock) {
+ this.sock.close();
+ this.sock = null;
+ }
+ this.sock = new WebSocket(this.uri);
+ this.sock.binaryType = 'arraybuffer';
+ this.sock.onclose = () => {
+ if (this.onclose) {
+ this.onclose();
+ }
+ };
+ this.sock.onerror = (e) => {
+ if (this.onerror) {
+ this.onerror(e);
+ }
+ };
+ this.sock.onopen = () => {
+ if (this.onopen) {
+ this.onopen();
+ }
+ };
+ this.sock.onmessage = (e) => {
+ if (this.queue.length) {
+ const handler = this.queue.shift();
+ handler(e.data);
+ }
+ };
+ }
+
+ close() {
+ this.sock.close();
+ }
+
+ isOpen() {
+ return this.sock.readyState === 1;
+ }
+
+ send(opcode, flags, operands, callback) {
+ const payload = {
+ Opcode: opcode,
+ Space: 'SNES',
+ };
+ if (flags) {
+ payload.Flags = flags;
+ }
+ if (operands) {
+ payload.Operands = operands;
+ }
+ this.sock.send(JSON.stringify(payload));
+ if (callback) {
+ this.queue.push(callback);
+ }
+ }
+
+ attachDevice(device) {
+ this.device = device;
+ this.send('Attach', null, [device]);
+ }
+
+ readBytes(start, size, callback) {
+ const handler = (rsp) => {
+ if (callback) callback(new Uint8Array(rsp));
+ };
+ this.send('GetAddress', null, [start.toString(16), size.toString(16)], handler);
+ }
+
+ readSRAM(start, size, callback) {
+ this.readBytes(start + 0xE00000, size, callback);
+ }
+
+ readVRAM(start, size, callback) {
+ this.readBytes(start + 0xF70000, size, callback);
+ }
+
+ readWRAM(start, size, callback) {
+ this.readBytes(start + 0xF50000, size, callback);
+ }
+
+ requestDeviceInfo(callback) {
+ const handler = (rsp) => {
+ const data = JSON.parse(rsp);
+ if (callback) callback(data);
+ };
+ this.send('Info', null, null, handler);
+ }
+
+ requestDeviceList(callback) {
+ const handler = (rsp) => {
+ const data = JSON.parse(rsp);
+ this.deviceList = data.Results || [];
+ if (callback) callback(data);
+ };
+ this.send('DeviceList', null, null, handler);
+ }
+
+}
--- /dev/null
+export const isChestOpen = (data, room, chest) => {
+ if (chest < 4) {
+ return !!(data && (data[2 * room] & Math.pow(2, chest + 4)));
+ } else {
+ return !!(data && (data[(2 * room) + 1] & Math.pow(2, chest - 4)));
+ }
+};
+
+export const hasVisitedQuadrant = (data, room, quadrant) => {
+ return !!(data && (data[2 * room] & Math.pow(2, quadrant - 1)));
+};
+
+export const GT_BASEMENT_CHECKS = [
+ 'iceM',
+ 'iceL',
+ 'iceR',
+ 'dmUL',
+ 'dmUR',
+ 'dmBL',
+ 'dmBR',
+ 'randoUL',
+ 'randoUR',
+ 'randoBL',
+ 'randoBR',
+ 'fireSnake',
+ 'mapChest',
+ 'hopeL',
+ 'hopeR',
+ 'bobsChest',
+ 'torchSeen',
+ 'tileRoom',
+ 'compassUL',
+ 'compassUR',
+ 'compassBL',
+ 'compassBR',
+];
+
+export const GT_BASEMENT_ALL = [
+ 'iceM',
+ 'iceL',
+ 'iceR',
+ 'dmUL',
+ 'dmUR',
+ 'dmBL',
+ 'dmBR',
+ 'randoUL',
+ 'randoUR',
+ 'randoBL',
+ 'randoBR',
+ 'fireSnake',
+ 'mapChest',
+ 'hopeL',
+ 'hopeR',
+ 'bobsChest',
+ 'torch',
+ 'torchSeen',
+ 'tileRoom',
+ 'compassUL',
+ 'compassUR',
+ 'compassBL',
+ 'compassBR',
+];
+
+export const getGTBasementState = (data) => ({
+ iceM: isChestOpen(data, 0x1C, 0),
+ iceL: isChestOpen(data, 0x1C, 1),
+ iceR: isChestOpen(data, 0x1C, 2),
+ dmUL: isChestOpen(data, 0x7B, 0),
+ dmUR: isChestOpen(data, 0x7B, 1),
+ dmBL: isChestOpen(data, 0x7B, 2),
+ dmBR: isChestOpen(data, 0x7B, 3),
+ randoUL: isChestOpen(data, 0x7C, 0),
+ randoUR: isChestOpen(data, 0x7C, 1),
+ randoBL: isChestOpen(data, 0x7C, 2),
+ randoBR: isChestOpen(data, 0x7C, 3),
+ fireSnake: isChestOpen(data, 0x7D, 0),
+ mapChest: isChestOpen(data, 0x8B, 0),
+ hopeL: isChestOpen(data, 0x8C, 1),
+ hopeR: isChestOpen(data, 0x8C, 2),
+ bobsChest: isChestOpen(data, 0x8C, 3),
+ torchSeen: hasVisitedQuadrant(data, 0x8C, 4),
+ torch: isChestOpen(data, 0x8C, 6),
+ tileRoom: isChestOpen(data, 0x8D, 0),
+ compassUL: isChestOpen(data, 0x9D, 0),
+ compassUR: isChestOpen(data, 0x9D, 1),
+ compassBL: isChestOpen(data, 0x9D, 2),
+ compassBR: isChestOpen(data, 0x9D, 3),
+});
+
+export const countGTBasementState = (state) =>
+ GT_BASEMENT_CHECKS.reduce((acc, cur) => state[cur] ? acc + 1 : acc, 0);
+
+export const compareGTBasementState = (prev, cur) => {
+ for (let i = 0; i < GT_BASEMENT_ALL.length; ++i) {
+ if (prev[GT_BASEMENT_ALL[i]] !== cur[GT_BASEMENT_ALL[i]]) {
+ return GT_BASEMENT_ALL[i];
+ }
+ }
+ return '';
+};
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import SNESSocket from '../helpers/SNESSocket';
+
+const context = React.createContext({});
+
+export const useSNES = () => React.useContext(context);
+
+export const SNESProvider = ({ children }) => {
+ const [enabled, setEnabled] = React.useState(false);
+
+ const sock = React.useRef(null);
+
+ const [settings, setSettings] = React.useState({
+ proto: 'ws',
+ host: 'localhost',
+ port: 8080,
+ device: '',
+ });
+
+ const [status, setStatus] = React.useState({
+ connected: false,
+ device: '',
+ deviceList: [],
+ error: false,
+ });
+
+ React.useEffect(() => {
+ if (sock.current) {
+ sock.current.close();
+ sock.current = null;
+ }
+ if (enabled) {
+ const tryAttach = () => {
+ const { deviceList } = sock.current;
+ let device = '';
+ if (deviceList.includes(settings.device)) {
+ device = settings.device;
+ } else if (deviceList.length > 0) {
+ device = deviceList[0];
+ }
+ setStatus(s => ({ ...s, device, deviceList }));
+ if (device) {
+ sock.current.attachDevice(device);
+ }
+ };
+ sock.current = new SNESSocket(`${settings.proto}://${settings.host}:${settings.port}`);
+ sock.current.onclose = () => {
+ setStatus({
+ connected: false,
+ device: '',
+ deviceList: [],
+ error: false,
+ });
+ };
+ sock.current.onerror = (e) => {
+ setStatus({
+ connected: false,
+ device: '',
+ deviceList: [],
+ error: e,
+ });
+ };
+ sock.current.onopen = () => {
+ setStatus({
+ connected: true,
+ device: '',
+ deviceList: [],
+ error: false,
+ });
+ sock.current.requestDeviceList(() => {
+ tryAttach();
+ });
+ };
+ const watchdog = setInterval(() => {
+ if (!sock.current.isOpen()) {
+ sock.current.open();
+ return;
+ }
+ if (!sock.current.device) {
+ sock.current.requestDeviceList(() => {
+ tryAttach();
+ });
+ }
+ }, 5000);
+ return () => {
+ clearInterval(watchdog);
+ };
+ }
+ }, [enabled, settings]);
+
+ const enable = React.useCallback(() => {
+ setEnabled(prevEnabled => {
+ if (prevEnabled) return true;
+ return true;
+ });
+ }, []);
+
+ const disable = React.useCallback(() => {
+ setEnabled(prevEnabled => {
+ if (!prevEnabled) return false;
+ return false;
+ });
+ }, []);
+
+ React.useEffect(() => {
+ const savedSettings = localStorage.getItem('snes.settings');
+ if (savedSettings) {
+ setSettings(JSON.parse(savedSettings));
+ }
+ }, []);
+
+ const value = React.useMemo(() => {
+ return { disable, enable, enabled, settings, sock, status };
+ }, [disable, enable, enabled, settings, sock, status]);
+
+ return <context.Provider value={value}>
+ {children}
+ </context.Provider>;
+};
+
+SNESProvider.propTypes = {
+ children: PropTypes.node,
+};
rejectSuccess: 'Abgelehnt',
rejectError: 'Fehler beim Ablehnen',
},
+ autoTracking: {
+ heading: 'Auto-Tracking',
+ statusMsg: {
+ disabled: 'Deaktiviert',
+ disconnected: 'Verbindung getrennt',
+ error: 'Verbindungsfehler',
+ 'no-device': 'Kein SNES',
+ 'not-applicable': 'Verbunden mit {{ device }}, ungeeigneter Modus',
+ 'not-in-game': 'Verbunden mit {{ device }}, nicht im Spiel',
+ tracking: 'Verbunden mit {{ device }}',
+ },
+ },
button: {
add: 'Hinzufügen',
back: 'Zurück',
rejectSuccess: 'Rejected',
rejectError: 'Error rejecting',
},
+ autoTracking: {
+ heading: 'Auto tracking',
+ statusMsg: {
+ disabled: 'Disabled',
+ disconnected: 'Disconnected',
+ error: 'Connection error',
+ 'no-device': 'No device',
+ 'not-applicable': 'Connected to {{ device }}, mode not applicable',
+ 'not-in-game': 'Connected to {{ device }}, not in game',
+ tracking: 'Connected to {{ device }}',
+ },
+ },
button: {
add: 'Add',
back: 'Back',
.handle-label {
display: inline-block;
- margin-top: 2px;
font-size: 18px;
font-weight: 600;
white-space: nowrap;