KatWalk C2: ч.5: оверклокинг и багфиксинг

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

"Что б они ни делали -- не идут дела. Видимо в прошивке багов дофига". Как я напомнил в прошлой статье (где я подготовил утилиты для перепрошивки сенсоров) -- я рассказываю про платформу для VR игр, как с ней интегрироваться и как добраться до ее сенсоров напрямую.

Её исходный ресивер обновляет сенсоры с частотой в 86Гц, тогда как технически возможно разогнать до 133 Гц, получив ощутимо ниже задержки, но связь была нестабильной.

Давайте начнём погружение в сенсоры -- посмотрим, что за игра ghidra_11.0_PUBLIC установлена у меня в C:\Games, заглянем одним глазком в саму прошивку и поковыряемся там грязными патчиками, да исправим race condition плюс выкинем немного отладочных глюков. В общем, готовимся к погружению. В этот раз -- всё серьёзно.

We need to go deeper... into Ghidra
We need to go deeper... into Ghidra

Гидра: как накормить дракона бинарником под ARM

Так как развлечения не позволяют покупать полноценную IDA с поддержкой ARM, а IDA Freeware только работает с x86, придётся пользоваться чем-нибудь другим. Этим чем-нибудь, разумеется, оказалась Ghidra -- нашумевший несколько лет назад выложенный в опенсорс мощный дизассемблер, с системой скриптования, поддержкой множества архитектур и так далее.

Всё никак было не до него, а тут прекрасный повод подвернулся.

Скачиваем гидру куда-нибудь (C:\Games), распаковываем и запускаем через C:\Games\ghidra_11.0_PUBLIC\ghidraRun.bat. Создаём проект, через "File=>Import File" импортируем наш Bin файл. Гидра умеет разные форматы, но вот HEX не умеет, хорошо, что мы уже превратили в bin. Еще не определяет сама содержимое, хорошо, что мы уже знаем что это ARM, Cortex M3, little endian:

Выбор языка
Выбор языка

Гидра справшивает, не проанализировать ли файл, соглашаемся, и получаем ничего практически интересного, практически, чистый лист!

чистый лист
чистый лист

Регионы памяти

Не будем опускать руки, для начала, заполним карту памяти, которую я нашел где-то в документации у TI:

Карта памяти
Карта памяти

Идём в "Windows => Memory Map" и правим. Уже загруженный регион переименовываем во "flash" и убираем галочку "W" (это не записываемый регион). Через зеленый плюс в наборе инструментов в правом верхнем углу добавляем "rom" с адресом начинающимся с 10000000, длинною в 0x20000 (точнее в 0x1CC00 но это не важно), галочки ставим в R и X (убираем W). Еще добавляем регион "ram" с адреса 20000000, размером в 0x5000, R/W но не X.

С новой информацией мы понимаем, что в самом начале у нас адрес на стек, в RAM, 0x20004000, как и положено согласно карты памяти. Затем есть вектор, с которого начнётся исполнение, затем обработчики прерываний -- все указывающие внутрь ROM, которого у нас нет, и в конце два каких-то кривых указателя. Не похожи на правду.

Но с картой памяти мы еще не закончили, еще есть "Peripherals" регион, через чтение-запись адресов в котором идёт доступ и настройка железа. Самое удобное, скачать CMSIS-SVD пакет, в котором собраны в машиночитаемом виде описание железа для большинства ARM чипов и модулей. А чтобы воспользоваться им, скачиваем SVD-Loader-Ghidra, после чего идём в "Window=>Script Manager", жмём на третью справа иконку (три таких полосочки, левее крестика и красного плюсика, называется "Manage Script Directories"), где через зелёный плюсик добавляем путь до скачанного плагина ($USER_HOME/Documents/GitHub/SVD-Loader-Ghidra в моём случае). Закрываем менеджер директорий, и в фильтр в менеджере скриптов вводим SVD, чтоб быстро найти его:

скритп в менеджере
скритп в менеджере

Двойным кликом запускаем его, он попросит выбрать требуемый SVD файл. Выбираем ...GitHub\cmsis-svd-data\data\TexasInstruments\CC26x0.svd.

Скрипт отработает и создаст требуемые регионы памяти в 0x40000000.

Разметка недоступного ROM

Когда код представляет собой сырую портянку, каждый бит полезной информации на вес золота. А потому, скачиваем SDK, распаковываем, и начинаем копаться. Мы знаем, что отладка возможна. То есть какие-то отладочные символы должны лежать. Осталось их найти :)

Проще всего начать с адреса прерываний: 1001c901, на который указывают все вектора. Возвращаемся в линукс, и:

$ cd /mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03
$ find . -name '*map' | xargs grep -i '1001c90' 
./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:                  1001c900    00000020     arm_m3_Hwi_asm_rom.obj (.text:ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I)
./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:1001c901  ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I                             
./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/rtos_rom.map:1001c901  ti_sysbios_family_arm_m3_Hwi_excHandlerAsm__I       

Отлично, выглядит интересно! Надо только прогрузить. В плагинах с гидрой уже есть ImportSymbolsScript.py, но игры с ним оказались неудобными. Под свой случай я подправил его до ArmImportSymbolsScript.py. Основная модификация: если символ это ссылка на функцию, то адрес округляется до четного и на этом месте создаётся dword, чтобы адрес+1 указывал прямо на этот символ. Это позволило импортировать символы более удобно для автоанализа и чтения когда потом.

Впрочем, нам всё равно надо символы превратить в подходящий формат: <имя> <адрес> <тип f или нет>.

А еще, карта хоть и совпадает для символов, то, что ниже 1xxxxxxx -- не всё правда. Например, по адресу 00001a25 main -- совсем не main.

Самое простое -- не разбираться с этой картой, а разобрать напрямую приложенный объектник:

$ cd /mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03
$ mkdir symbols
$ objdump -t ./kernel/tirtos/packages/ti/sysbios/rom/cortexm/cc26xx/r2/golden/CC26xx/CC2640R2F_rtos_rom_syms.out | perl -ne '@a=split;print "$a[-1] $a[0] $a[2]\n" if $a[1] eq "g"' > symbols/rom.txt

И теперь мы можем его подгрузить двойным кликом по ArmImportSymbolsScript.py в Scripts Manager, выберем файл из C:\ti\simplelink...\scripts\rom.txt:

Уже какой-то смысл
Уже какой-то смысл
$ for k in source/ti/ble5stack/rom/ble_rom_releases/cc26xx_r2/Final_Release/*symbols; do
    objdump -t $k | perl -ne '@a=split;print "$a[-1] $a[0] $a[2]\n" if $a[1] eq "g"' > symbols/${k##*/}.txt
  done

Импортируем ble_r2.symbols.txt и common_r2.symbols.txt.

Разметка API SDK

Как мы видели в карте памяти, с адреса 0x1000 начинается "TI RTOS ROM Jump Table". Если сходим туда, увидим кучу указателей. Но не джампов... Впрочем, в TI системе есть еще одна огромная табличка переходов -- "ROM_Flash_JT" -- она расположена совсем не по 0x1000, но найти её легко: достаточно взять функцию из тех, что мы уже нашли в символах -- я использую HCI_bm_alloc -- и найти ссылку на неё:

Табличка
Табличка

Это и есть она. Переименовываем в "ROM_Flash_JT". Теперь мы можем сравнить табличку с содержимым в rom_init.c (полный путь
C:\ti\simplelink_cc2640r2_sdk_5_30_00_03\source\ti\blestack\rom\r2\rom_init.c), чтобы расставить еще кучу имён. Чтобы не делать это вручную, Я накидал скриптик ROM_Flash_JT -- запускаем, выбираем rom_init.c, наслаждаемся:

Еще больше смысла!
Еще больше смысла!

Распаковка ROM2RAM

Еще один важный момент: при работе часто используется статическая инициализация разных структур в оперативной памяти. То есть когда в коде написано что-то типа

const char * str = "MyString";
char * str2 = "OtherString";

то str можно ставить как указатель на строку в ROM, то str2 должна быть скопирована в RAM, так как мы можем её менять. То же самое с константами в статических структурах и так далее. Чтобы это работало, компилятор перед запуском пользовательского кода производит распаковку констант, сохранённых в ROM. Насколько понимаю, разные компиляторы делают это по разному, но в случае с GCC использованным в TI SDK (как часть XDC Tools), таблица перекидывания прописана в самом начале кода. Скачиваем скрипт arm-romtotram, затем идём по адресу указанному в Reset:

Самое начало кода после старта
Самое начало кода после старта
unpackRomToRam
unpackRomToRam

Из неё нам требуется проставить три метки:

  • "ROMtoRAMtable" -- начальное значение цикла,

  • "ROMtoRAMtableEnd" -- финальное значение цикла (гидра может не позволить сразу поставить там метку, придётся дважды кликнуть на неё, создать там указатель кнопкой "p" и потом уже через "L" поставить метку "ROMtoRAMtableEnd"),

  • Указатель на массив обработчиков использованный внутри цикла -- "ROMtoRAM_Processors".

Функция принимает вид:

void unpackRomToRam(void)
{
  byte **ppbVar1;
  for (ppbVar1 = (byte **)&ROMtoRAMtable; ppbVar1 < &ROMtoRAMtableEnd; ppbVar1 = ppbVar1 + 2) {
    (*(code *)(&ROMtoRAM_Processors)[**ppbVar1])(*ppbVar1 + 1,ppbVar1[1]);
  }
  xdc_runtime_Startup_exec__E();
  return;
}

Таблица процессоров содержит три функции: функция распаковки чего-то напоминающего LZ (переносим байт как есть, или копируем N байт начиная с M байт в прошлое), memcpy и обнуления региона. Сама таблица rom2ram содержит просто пары адресов -- адрес в ROM аргумент для распаковщика и адрес в RAM для получателя.

Теперь, когда мы подписали все три метки, можно запустить "arm-romtoram" скрипт, и бульк -- всё готово.

Анализ кода

Когда мы собрали всё, что может нам понять код, можем приступать. Для начала поправим непонятные ссылки, которые висят в SysTick и IRQ. Это не указатели, а уже начальный код прошивки, поэтому сбросим из через "c", переименуем SysTick в "Begin" и сделаем его кодом через F12, затем функцией через "f".

Код выглядит как инициализация чего-то:

  FUN_0000c9d0(&DAT_20001130,&DAT_20001150);
  ti_sysbios_knl_Queue_construct(&DAT_2000118c,0);
  DAT_20001154 = &DAT_2000118c;
  FUN_0000cdfc(&DAT_2000120c,&LAB_000100e2+1,0,8);
  FUN_0000cdfc(&DAT_20001230,&LAB_000100e2+1,3,4);
  FUN_0000cdfc(&DAT_20001254,&LAB_000100e2+1,0,0xb);
  FUN_0000cdfc(&DAT_200011e8,&LAB_000100e2+1,0,3);
  DAT_20002038 = FUN_00009730(&DAT_20002040,&DAT_20002064);
  if (DAT_20002038 == 0) {
    do {
                    /* WARNING: Do nothing block with infinite loop */
    } while( true );
  }
  FUN_00005bf4(0x10,0xe165,0x1f,6);
  uVar20 = 0;
  local_5c = 9;
  local_5e = 8;
  local_53 = 0;
  local_56 = 0;
  local_5a = 100;
  local_58 = 1000;
  FUN_0000363c(0x306,2,&local_56);
  FUN_0000363c(0x308,0x10,&DAT_200011a4);
  FUN_0000363c(0x307,7,&DAT_20001174);
  FUN_0000363c(0x310,1,&local_53);
  FUN_0000363c(0x311,2,&local_5e);
  FUN_0000363c(0x312,2,&local_5c);
  FUN_0000363c(0x313,2,&local_5a);
  FUN_0000363c(0x314,2,&local_58);
  FUN_00005bf4(0x10,0xe165,6,0xa0);
  FUN_00005bf4(0x10,0xe165,7,0xa0);
  FUN_00005bf4(0x10,0xe165,8,0xa0);
  FUN_00005bf4(0x10,0xe165,9,0xa0);
  local_64 = 0;
  local_50 = 0;
  local_52 = 1;
  local_51 = 1;
  local_4f = 1;
  FUN_00005bf4(0x10,&DAT_00003fb5,0x408,4,&local_64);
  FUN_00005bf4(0x10,&DAT_00003fb5,0x400,1,&local_52);
  FUN_00005bf4(0x10,&DAT_00003fb5,0x402,1,&local_51);
  FUN_00005bf4(0x10,&DAT_00003fb5,0x403,1,&local_50);
  FUN_00005bf4(0x10,&DAT_00003fb5,0x406,1,&local_4f);
  FUN_00005bf4(0x10,&LAB_0000f3f8+1,&DAT_2000117c);

Осталось понять чего. Для начала, сделаем поиск по константам (через Notepad++ или grep'ом):

datacompboy@NUUBOX:/mnt/c/ti/simplelink_cc2640r2_sdk_5_30_00_03$ find . -name '*.h' | xargs grep 0x306
./kernel/tirtos/packages/gnu/targets/arm/libs/install-native/arm-none-eabi/include/elf.h:#define NT_S390_LAST_BREAK	0x306
./source/ti/blestack/profiles/roles/cc26xx/broadcaster.h:#define GAPROLE_ADV_EVENT_TYPE      0x306  //!< Advertisement Type. Read/Write. Size is uint8_t.  Default is GAP_ADTYPE_ADV_IND (defined in GAP.h).
./source/ti/blestack/profiles/roles/cc26xx/multi.h:#define GAPROLE_ADVERT_OFF_TIME     0x306
./source/ti/blestack/profiles/roles/cc26xx/peripheral.h:#define GAPROLE_ADVERT_OFF_TIME     0x306
./source/ti/blestack/profiles/roles/peripheral_broadcaster.h:#define GAPROLE_ADVERT_OFF_TIME     0x306  //!< Advertising Off Time for Limited advertisements (in milliseconds). Read/Write. Size is uint16. Default is 30 seconds.

О, прекрасно, у нас же сенсор, значит из peripheral. Давайте подгрузим константы в гидру:

File=>Parse C source=>зеленый плюс=>"c:\ti\simplelink_cc2640r2_sdk_5_30_00_03\source\ti\blestack\profiles\roles\cc26xx\peripheral.h"=>Parse to program.

Ругнётся на что-то, но константы добавится. Тыкаем в "0x306", жмём "E" видим GAPROLE_ADVERT_OFF_TIME -- двойным кликом применяем. Ниже видим 408/400/402... Можем повторить с ним, но лучше понять что за функции, и почему они не подписаны.

Поищем на тему примеров с GAPROLE_SCAN_RSP_DATA:

$ find . -name '*.c' | xargs grep GAPROLE_SCAN_RSP_DATA
./examples/rtos/CC2640R2_LAUNCHXL/blestack/multi_role/src/app/multi_role.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/project_zero/src/app/project_zero.c:  GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData), scanRspData);
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_broadcaster/src/app/simple_broadcaster.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof (scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_np/src/app/simple_np_gap.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_np/src/app/simple_np_gap.c:        status = GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, len, pDataPtr);
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral/src/app/simple_peripheral.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral/src/app/simple_peripheral_dbg.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_offchip/src/app/simple_peripheral_oad_offchip.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_onchip/src/app/simple_peripheral_oad_onchip.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_oad_onchip/src/persistent_app/oad_persistent_app.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
./examples/rtos/CC2640R2_LAUNCHXL/blestack/simple_peripheral_secure_fw/src/app/simple_peripheral_dbg.c:    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),

Что у нас там в simple_peripheral.c:

  // Setup the Peripheral GAPRole Profile. For more information see the User's
  // Guide:
  // http://software-dl.ti.com/lprf/sdg-latest/html/
  {
    // By setting this to zero, the device will go into the waiting state after
    // being discoverable for 30.72 second, and will not being advertising again
    // until re-enabled by the application
    uint16_t advertOffTime = 0;

    uint8_t enableUpdateRequest = DEFAULT_ENABLE_UPDATE_REQUEST;
    uint16_t desiredMinInterval = DEFAULT_DESIRED_MIN_CONN_INTERVAL;
    uint16_t desiredMaxInterval = DEFAULT_DESIRED_MAX_CONN_INTERVAL;
    uint16_t desiredSlaveLatency = DEFAULT_DESIRED_SLAVE_LATENCY;
    uint16_t desiredConnTimeout = DEFAULT_DESIRED_CONN_TIMEOUT;

    GAPRole_SetParameter(GAPROLE_ADVERT_OFF_TIME, sizeof(uint16_t),
                         &advertOffTime);

    GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, sizeof(scanRspData),
                         scanRspData);
    GAPRole_SetParameter(GAPROLE_ADVERT_DATA, sizeof(advertData), advertData);

    GAPRole_SetParameter(GAPROLE_PARAM_UPDATE_ENABLE, sizeof(uint8_t),
                         &enableUpdateRequest);
    GAPRole_SetParameter(GAPROLE_MIN_CONN_INTERVAL, sizeof(uint16_t),
                         &desiredMinInterval);
    GAPRole_SetParameter(GAPROLE_MAX_CONN_INTERVAL, sizeof(uint16_t),
                         &desiredMaxInterval);
    GAPRole_SetParameter(GAPROLE_SLAVE_LATENCY, sizeof(uint16_t),
                         &desiredSlaveLatency);
    GAPRole_SetParameter(GAPROLE_TIMEOUT_MULTIPLIER, sizeof(uint16_t),
                         &desiredConnTimeout);
  }

Ха! 1-в-1: GAPROLE_ADVERT_OFF_TIME, GAPROLE_SCAN_RSP_DATA, GAPROLE_ADVERT_DATA, GAPROLE_PARAM_UPDATE_ENABLE...

Переименовываем FUN_0000363c => GAPRole_SetParameter, смотрим ниже:

  // Set the Device Name characteristic in the GAP GATT Service
  // For more information, see the section in the User's Guide:
  // http://software-dl.ti.com/lprf/sdg-latest/html
  GGS_SetParameter(GGS_DEVICE_NAME_ATT, GAP_DEVICE_NAME_LEN, attDeviceName);

  // Set GAP Parameters to set the advertising interval
  // For more information, see the GAP section of the User's Guide:
  // http://software-dl.ti.com/lprf/sdg-latest/html
  {
    // Use the same interval for general and limited advertising.
    // Note that only general advertising will occur based on the above configuration
    uint16_t advInt = DEFAULT_ADVERTISING_INTERVAL;

    GAP_SetParamValue(TGAP_LIM_DISC_ADV_INT_MIN, advInt);
    GAP_SetParamValue(TGAP_LIM_DISC_ADV_INT_MAX, advInt);
    GAP_SetParamValue(TGAP_GEN_DISC_ADV_INT_MIN, advInt);
    GAP_SetParamValue(TGAP_GEN_DISC_ADV_INT_MAX, advInt);
  }

Хмм... У нас следом за пачкой GAPRole_SetParameter идет просто четыре вызова, ничего напоминающего вызов GGS_SetParameter нет. Кажется, начинаю понимать, почему нет таблицы параметров -- её вырезали из примера. Но еще один непонятный момент -- GAP_SetParamValue должен иметь два аргумента, а у нас 4:

  FUN_00005bf4(0x10,0xe165,6,0xa0);
  FUN_00005bf4(0x10,0xe165,7,0xa0);
  FUN_00005bf4(0x10,0xe165,8,0xa0);
  FUN_00005bf4(0x10,0xe165,9,0xa0);

Давайте убедимся, что это они:

$ find . -name '*.h' | xargs grep TGAP_LIM_DISC_ADV_INT_MAX
./source/ti/blestack/inc/gap.h:#define TGAP_LIM_DISC_ADV_INT_MAX      7

Да, они.... А что такое GAP_SetParamValue, может, это макрос?

$ find . -name '*.h' | xargs grep GAP_SetParamValue
./source/ti/ble5stack/icall/inc/ble_dispatch_lite_idx.h:#define IDX_GAP_SetParamValue                            JT_INDEX(152)
./source/ti/ble5stack/icall/inc/icall_api_idx.h:#define IDX_GAP_SetParamValue                         GAP_SetParamValue
./source/ti/ble5stack/icall/inc/icall_ble_api.h:#define GAP_SetParamValue(...)                                                          (icall_directAPI(ICALL_SERVICE_CLASS_BLE, (uint32_t) IDX_GAP_SetParamValue , ##__VA_ARGS__))
./source/ti/ble5stack/icall/inc/icall_ble_apimsg.h: * @see GAP_SetParamValue()
./source/ti/ble5stack/inc/gap.h: * Parameters set via @ref GAP_SetParamValue
./source/ti/ble5stack/inc/gap.h:extern bStatus_t GAP_SetParamValue(uint16_t paramID, uint16_t paramValue);
./source/ti/ble5stack/rom/map_direct.h:#define MAP_GAP_SetParamValue                                        GAP_SetParamValue

Вот оно что! В зависимости от флагов компиляции, вызов либо прямой, либо косвенный через таблицу джампов (с индексом 152), либо косвенный по прямому адресу. Убедимся, что ICALL_SERVICE_CLASS_BLE == 0x10:

$ find . -name '*.h' | xargs grep ICALL_SERVICE_CLASS_BLE
...
./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE            0x0010
./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE_MSG        0x0050
./source/ti/blestack/icall/src/inc/icall.h:#define ICALL_SERVICE_CLASS_BLE_BOARD      0x0088
...

переименуем FUN_00005bf4 в icall_directAPI, адрес 0xe165 в GAP_SetParamValue. Смотрим дальше пример:

    GAPBondMgr_SetParameter(GAPBOND_PAIRING_MODE, sizeof(uint8_t), &pairMode);
    GAPBondMgr_SetParameter(GAPBOND_MITM_PROTECTION, sizeof(uint8_t), &mitm);
    GAPBondMgr_SetParameter(GAPBOND_IO_CAPABILITIES, sizeof(uint8_t), &ioCap);
    GAPBondMgr_SetParameter(GAPBOND_BONDING_ENABLED, sizeof(uint8_t), &bonding);
    GAPBondMgr_SetParameter(GAPBOND_LRU_BOND_REPLACEMENT, sizeof(uint8_t), &replaceBonds);

как раз 408, 400, ... переименовываем 0x3fb5 => GAPBondMgr_SetParameter.

Процесс дальше, впрочем не похож на инициализацию таблицы. Таки либо пример неверный, либо таки отдельные сервисы типа GSS вырезаны.

Заметки на полях: поиск полезных данных никогда не бывает лишним. Выше я показывал вывод strings, в котором были интересные вещи -- "inputGyroRv" и "inputNormal". Поиск по ним на github дал сходу интересную вещь, что позволило еще разметить часть функций и структур, которыми пользуется сенсор направления. Для ног однако подобной фкусности не обнаружилось.

Тут можно бесконечно пытаться понять дальше, однако ж, давайте научимся менять код прошивки. Иначе зачем нам это всё?

Играемся с прошивкой

Для эксперимента, мы уже пропатчили прошивку, заменив "KATVR", которая используется для анонса данных, на "KAT-F" (типа "Ноги"). Но это не интересно. Каждый сенсор прошивается его типом -- левый или правый -- хорошо бы, чтобы он анонсировал себя "KAT-R" или "KAT-L"! Для этого надо найти где же хранится его тип (левый-правый).

Мы знаем, что спаривание висит на USB, и пакеты 0x55/0xAA. Простым пролистыванием вниз натыкаемся на кусок:

    case 0xc:
        cVar6 = *(char *)(puVar23 + 1);
        pcVar26 = *(char **)(puVar23 + 2);
        FUN_0000af16(&DAT_200011c8,0,0x1f);
        if ((*pcVar26 == 'U') && (pcVar26[1] == -0x56)) {
        DAT_200011cb = 0;
        DAT_200011c8 = 0x55;
        DAT_200011c9 = 0xaa;
        cVar4 = (char)local_48;
        cVar7 = DAT_200011c0;
        if (cVar6 == '\x01') {
....

который явственно обрабатывает событие "пакет по USB" и реагирует на него. WriteDeviceId это команда 0x04:

        if (cVar6 == '\x03') {
            DAT_200011cc = '\x03';
            cVar4 = DAT_200011cc;
            cVar7 = DAT_200011c1;
            goto LAB_000009b8;
        }
        if (cVar6 == '\x04') {
            DAT_200011c1 = pcVar26[5];
            PostMsg(7,0,0);
            DAT_200011cd = 0;
            DAT_200011cc = '\x04';
            FUN_0000fa20();
            FUN_0000f8ec();
            break;
        }

Вывод -- DAT_200011c1 это искомый ID сенсора, он же использован как аргумент в ReadDeviceId (команда 0x03). Кросс-реферес по ссылкам на него находит интересную простую функцию:

void FUN_0000f8ec(void)
{
  if (ParamDeviceId == '\x03') {
    DAT_20001132 = 5;
  }
  else {
    DAT_20001132 = 4;
  }
  return;
}

Которая вызывается дважды из Begin() -- при инициализации (очевидно, после подгрузки параметров) и после WriteDeviceId. Идеальная точка врезки!

Изучим ассемблер:

                             *************************************************************
                             *                           FUNCTION                          
                             *************************************************************
                             undefined  FUN_0000f8ec ()
             undefined         r0:1           <RETURN>
                             FUN_0000f8ec                                    XREF[2]:     0000038c (c) , 000009a4 (c)   
        0000f8ec 04  49           ldr        r1,[DAT_0000f900 ]                               = 20001130h
        0000f8ee 91  f8  91  00    ldrb.w     r0,[r1,#0x91 ]=>ParamDeviceId
        0000f8f2 03  28           cmp        r0,#0x3
        0000f8f4 14  bf           ite        ne
        0000f8f6 04  20           mov.ne     r0,#0x4
        0000f8f8 05  20           mov.eq     r0,#0x5
        0000f8fa 88  70           strb       r0,[r1,#0x2 ]=>DAT_20001132
        0000f8fc 70  47           bx         lr
        0000f8fe c0              ??         C0h
        0000f8ff 46              ??         46h    F
                             DAT_0000f900                                    XREF[1]:     FUN_0000f8ec:0000f8ec (R)   
        0000f900 30  11  00  20    undefine   20001130h                                        ?  ->  20001130

Итак, функция грузит в r1 адрес объекта из неподалёку лежащей константы; затем грузит в r0 байт из объекта+смещение. Сравнивает этот байт с 0x3 (левая нога), и затем использует инструкцию "ite ne".

Очень красивая система условного выполнения без переходов. В полноценном ARM режиме у каждой команды просто приписано выполняется ли она при флагах, в thumb режиме всё задаётся командой -- (I)f,(T)hen,(E)lse, которая может быть просто "IT" (if-then, выполнить инструкцию если совпадает условие), ITT (if-then-then, выполнить две). Управляется от 1 до 4 инструкций, и первая следующая всегда then.

Затем два mov в R0, первая выполнится если R0 != 3, вторая если R0 == 3.

Затем новое значение сохраняется в другой байт в структуре и идёт переход на адрес lr. Так как функция трогает только регистры r0 и r1 (они же -- параметры функций), их сохранять похоже не обязательно.

Так как после возврата у нас есть еще два байта (выравнивание), мы можем заменить (bx lr + nop) на один длинный jmp куда-нибудь.

Сразу по окончании ROMtoRAM таблицы у нас как раз пустое место, всё нули. Для удобства отмотаем до круглого числа (0x12e10) и придумаем что мы хотим вписать. Превратим "KATVR" в "KAT-L" или "KAT-R" в зависимости от настройки. Соответственно, надо просто превратить R0 в L или R, и записать куда-надо. А куда? Строка "KATVR" нас дважды: один раз в ПЗУ части, один раз в ОЗУ в пакете для анонса:

                             DAT_200011a4                                    XREF[1]:     000000ee (*)   
        200011a4 06              ??         06h
        200011a5 09              ??         09h
        200011a6 4b              ??         4Bh    K
        200011a7 41              ??         41h    A
        200011a8 54              ??         54h    T
        200011a9 56              ??         56h    V
        200011aa 52              ??         52h    R
        200011ab 05              ??         05h

Итого, нам надо вписать эквивалент:

  if(left) {
    scanRsp[6] = 'L';
  } else {
    scanRsp[6] = 'R';
  }
  scanRsp[5] = '-'; // можно сделать патч в ROM, можно вписать тут

К моменту выхода из функции в R0 у нас 4 или 5 в зависимости от ноги, а в R1 у нас указатель на 20001130. Расстояние от 20001130 до 200011A9 -- 0x79. В принципе, у нас еще и флаги уже стоят с прошлого сравнения. То есть можно сделать нечто вроде:

  ite ne
  mov.ne r0,#'L'
  mov.eq r0,#'R'
  strb r0,[R1,#0x7A]
  mov r0,#'-'
  strb r0,[R1,#0x79]
  bx lr

Чтобы править код в гидре нужно очистить область от режима (если там уже есть инструкции), затем через ctrl+shift+g включить ассемблер для текущей строки, она ругнётся что процессор не тестирован. Вписываем инструкцию и аргументы, воюем с ней так как не всегда понимает, что мы хотим, но вроде получается. Правда на "mov.ne r0,#'L'" она откаывается реагировать! Когда такое начиналось, я пользовался online assembler где вводил инстуркции и потом переносил байтики. "mov r0, #'L'" => "4f f0 4c 00"... Не не, надо 2хбайтную, "movs r0, #'L'" => "4c 20" -- идеально. "movs r0, #'R'" => "52 20". И так далее...

Эм.... Что-то гидре поплохело:

                             MoveFeetNumNew
        00012e10 14  bf           ite        ne
        00012e12 4c  20           mov.ne     r0,#0x4c
        00012e14 52  20           mov.eq     r0,#0x52
        00012e16 81  f8  7a  00    strb.eq.w  r0,[r1,#0x7a ]
        00012e1a 81  f8  79  00    strb.eq.w  r0,[r1,#0x79 ]
        00012e1e 70  47           bx.eq      lr

Непонятно почему, но в дизассемблере залип отслеживатель флагов. Жаль, но, проигнорируем. Зальем патч... Вот только не сработало. :(

Надо не только сменить структуру, но еще и вызвать GAPRole_SetParameter(GAPROLE_SCAN_RSP_DATA, 0x10, &scanRsp).

Хорошо, тогда нам нужен указатель на scanRsp в R2. Поправим код:

  ite ne
  mov.ne r0,#'L'
  mov.eq r0,#'R'
  adds.w r2,r1,#0x74
  strb r0,[R2,#6]
  movw r0,#0x308
  movs r1,#0x10
  b.w GAPRole_SetParameter

Всё хорошо, но тут гидре сорвало крышу, декомпиляция совсем сошла с ума. Ткнём в "b.w" инструкцию правой кнопкой мыши и через "modify instruction flow" сменим её на "CALL_RETURN". Уже лучше. Чтобы вылечить залипшие ".eq", можно ткнуть на первую кривую инструкцию (adds.eq.w) правой, и выбрать "Clear Flow and Repair", после чего опять F12 -- код исправится (более-менее):

                             MoveFeetNumNew                                  XREF[1]:     MoveFeetNumSmt:0000f8fc (j)   
        00012e10 14  bf           ite        ne
        00012e12 4c  20           mov.ne     r0,#0x4c
        00012e14 52  20           mov.eq     r0,#0x52
        00012e16 11  f1  74  02    adds.w     r2,r1,#0x74
        00012e1a 90  71           strb       r0,[r2,#0x6 ]
        00012e1c 40  f2  08  30    movw       r0,#0x308
        00012e20 10  21           movs       r1,#0x10
        00012e22 f0  f7  0b  bc    b.w        GAPRole_SetParameter                             undefined GAPRole_SetParameter()
                             -- Flow Override: CALL_RETURN (CALL_TERMINATOR)

Декомпиляция тоже станет лучше:

void MoveFeetNumSmt(void)
{
  if (DeviceId == '\x03') {
    DAT_20001132 = 5;
    UNK_200011aa = 0x52;
  }
  else {
    DAT_20001132 = 4;
    UNK_200011aa = 0x4c;
  }
  GAPRole_SetParameter(0x308,0x10);
  return;
}

Единственное, она потеряла аргумент к функции. Если нажать на ней правой кнопкой и сделать Edit Function, добавить три аргумента и выставить им простые типы:

Правим типы
Правим типы

Код окончательно примет нормальный вид:

void MoveFeetNumSmt(void)
{
  if (DeviceId == '\x03') {
    DAT_20001132 = 5;
    UNK_200011aa = 0x52;
  }
  else {
    DAT_20001132 = 4;
    UNK_200011aa = 0x4c;
  }
  GAPRole_SetParameter(0x308,0x10,&scanRspData);
  return;
}

Прекрасно, опять экспортируем патч (File=>Export Program), "fc.exe /b .\katvr_foot_orig.bin .\katvr_foot.bin" и так далее. Загружаем в сенсор. Урра, работает! :) Видим "KAT-R" и "KAT-L" устройства плавающие вокруг. Красота.

Поиграли - пора и делом заняться.

Разгоняем ноги на KatWalk C2 до 133 Гц

Чтобы понять в чем проблема с сенсорами, надо думать как сенсоры. Итак, мы знаем, что сенсоры отдают данные через Notification. Пойдём искать, где же они используются. Слева в "Symbol tree" в поле поиска вводим Notifi и легко переходим на GATT_Noficiation. Ссылок не видно. Значит, используется косвенный вызов, делаем поиск по 0x10010045 (адрес+1, ибо код в Thumb режиме) и находим одну единственную функцию, где идёт подготовка, потом вызов:

  _DAT_20001186 = 0x14;
  _DAT_20001188 = (undefined *)thunk_EXT_FUN_10018404(DAT_20001142,0x1b,0x14,0,in_r3);
  cVar1 = DAT_20000521;
  if (_DAT_20001188 != (undefined *)0x0) {
    _DAT_20001184 = 0x2e;
    if (_DAT_20001146 == 0) {
  ...
  cVar1 = icall_directAPI(0x10,(int)&GATT_Notification + 1,DAT_20001142,&DAT_20001184,0);

0x2E -- это же наш handle, так что да, мы нашли то самое место.

Если упростить, код получается такой:

void KatSendNotification() {
  out = malloc(...);
  if (out) {
    if (packetNo == 0) {
        out->_type = 0; // status packet
        fill_charge_levels(out);
    } else {
        out->_type = 1; // data packet
        if (!DATA_READY || !DATA_OK) {
            out->_x = 0;
            out->_y = 0;
        } else {
            DATA_READY = false;
            out->_x = DATA_X;
            out->_y = DATA_Y;
        }
        out->status = STATUS;
    }
    if (something) {
        out[0] = 0; out[1] = 1; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 1;
    }
    attHandleValueNoti_t notification = { 0x2e, 0x14, out };
    if (!GATT_Notification(0x2E, &notification, 0)) { free(out); }
    if (++packetNo == 500) {
        packetNo = 0;
    }
  }

То есть, каждые 500 пакетов отправляется уровень заряда (плюс версия прошивки и ID сенсора), все остальные пакеты содержат данные. Причем если данные не готовы (или была ошибка связи с сенсором), то отсылаются нули. Еще, если стоит какой-то флаг, то начало пакета перетирается последовательностью 0-1-0-1-0-1.

Теперь нужно понять, как часто этот GATT_Nofication вызывается. Ссылок на "KatSendNotification" (как я назвал эту функцию) две, и обе из основного потока, обе внутри цикла обработки событий:

  while(!QueueEmpty(...)) {
    Entry* event = QueueGet(...);
    switch (event->id)
    {
        ...
        case 4:
            KatSendNotification();
            ClockStop(...);
            break;
        case 6:
            if (Flag1 == 1 || Flag2 == 1) {
                KatSendNotification();
            }
            break
        ...
    }
  }

Событие №4 должно бы посылаться таймером, но таймер не запущен -- и даже если будет запущен, сразу будет остановлен. Никаких других ссылок на этот таймер я не нашел.

Событие №6 генерируется по коллбэку на обработку события BLE, так что похоже на то, что события посылаются после того как два флага взведены (один из них -- соединение установлено, второй не совсем понял сходу), причем следующее событие формируется после отправки предыдущего. Окей, это совпадает с наблюдением, что поток начинается после изменения параметра соединения.

Пока непонятно, но, вроде, сколько раз спросили -- столько раз мы должны ответить. Что ж, посмотрим, на когда данные появляются? Кросс-референс на DATA_OK приводит нас к функции:

void ReadSensorData(...)
{
    do {
        Semaphore_Pend(SensorSemaphore, -1);
        Task_sleep(700);
        GPIO_SET(..., 0);
        SPI_Send(0x50);
        Task_sleep(10);
        char* out = &SensorData;
        for (int i = 0x0C; i; --i) {
            *(out++) = SPI_Recv();
        }
        GPIO_SET(..., 1);
        Task_sleep(0.1);
        DATA_OK = 0;
        if ((SensorData[0] & 0x80 != 0) && (SensorData[0] & 0x20 != 0)) {
            DATA_OK = 1;
        }
        DATA_READY = 1;
        SensorReads++;
        If (SensorReads > 99) {
            // refresh something
            SensorReads = 0;
        }
        if (SomeFlag == 0) {
            Semaphore_Post(SensorSemaphore);
        }
    } while(true);
}

Функция занимается обновлением данных с сенсора до тех пор, пока SomeFlag не будет взведён. Как показало дальнейшее расследование -- этот флаг означает переход в режим сна. Семафор инициализируется на старте и сразу взводится, то есть датчик читается непрерывно пока мы не спим, с перерывами в 700+10+13байтSPI + обновление еще чего-то раз в 100 чтений. SPI настроен на 4 мегабод. Единицы сна в десятках микросекунд. То есть сенсор обновляется раз в ~7.11 миллисекунды, или около 140-141 Гц. Выглядит так, что не должно бы быть проблем с обновлением сенсора на 133Гц. Однако же они есть.

Наблюдаемое поведение -- иногда мы видим нулевые пакеты (ведешь себе ровненько сенсор, а точка в гейтвее внезапно срывается в центр).

Как ни странно, это поведение вполне объяснимо: при обновлении данных каждые ~140Гц и чтении 133Гц, мы двигаемся рядом, и вероятность того, что пакет был обновлён вот-только-что, а мы обновляем данные -- крайне высока. Мы наблюдаем состояние гонки, так как никакой синхронизации между отправкой, формированием и обновлением данных нет.

Решение, как ни странно, очевидно. Нам нужны новые данные для отправки, поэтому читать сенсор нужно только когда есть связь. Пакеты запрашиваются регулярно. А что если... Перенести вызов Semaphore_Post из ReadSensorData в KatSendNotification? Тогда получится идеальная связка: ReadSensorData() подготовит данные, KatSendNotification их заберёт -- и запросит опять. Идеально. Единственное, что KatSendNotification вызывается после отправки прошлого пакета, то есть задержку изнутри ReadSensorData убирать нельзя. Я бы, пожалуй, чуток ее даже понизил, чтобы пакет точно был готов. Так как мы обновляем пакеты каждые 86..133Гц, момент замера скорости не так критичен, до тех пор, пока задержка одна и та же -- 5 миллисекунд погоды не сделают.

Достанем блокнотик и спланируем наш патч. Во-1х снизим задержку:

        000079d0 41  f6  58  31    movw       r1,#7000  # The delay
=>
        000079d0 41  f2  88  31    movw       r1,#5000

Во-2х, замкнём цикл до вызова SensorSemaphore. Способов замкнуть цикл множество: можно заменить в IF условный jmp (bne) на безусловный, можно просто заNOPитьвызов функции, а можно тупо вписать переход вместо всего IFа:

                             LAB_00007ab0                                    XREF[1]:     00007a54 (j)   
        00007ab0 28  78           ldrb       r0,[r5,#0x0 ]=>SleepState                        = 52h
        00007ab2 00  28           cmp        r0,#0x0
        00007ab4 98  d1           bne        LAB_000079e8
        00007ab6 68  68           ldr        r0,[r5,#0x4 ]=>SensorReadSem
        00007ab8 f9  f7  7e  fa    bl         Semaphore_post                                   undefined Semaphore_post()
        00007abc 94  e7           b          LAB_000079e8

=>

                             LAB_00007ab0                                    XREF[1]:     00007a54 (j)   
        00007ab0 9a  e7           b          LAB_000079e8

И в-3их, поправить KatSendNotification. Действуем аналогично прошлому подходу с реакцией на лампочки -- уходим на свободный кусочек в конце (после прошлого патча, я взял 00012e40). Соответственно, в конце функции меняем return на jmp:

                             LAB_00006b14                                    XREF[1]:     00006a08 (j)   
        00006b14 f8  bd           pop        {r3,r4,r5,r6,r7,pc}
        00006b16 c0              ??         C0h
        00006b17 46              ??         46h    F
=>
                             LAB_00006b14                                    XREF[1]:     00006a08 (j)   
        00006b14 0c  f0  94  b9    b.w        KatSendNotificationTail

И на новом месте формируем кусочек подобный вырезанному из ReadSensorData. Главная сложность, по сравнению с ReadSensorData, это спланировать сколько надо места перед хранением константы для загрузки указателя на семафор. Так же может понадобиться поправить переход, сбросить/вызвать Repair Flow после правок -- но после заполнения всего кода и сброса, всё получается:

=>
                             KatSendNotificationTail                         XREF[1]:     KatSendNotification:00006b14 (j)
        00012e40 03  4d           ldr        r5,[->SleepState ]                               = 20001584
        00012e42 28  78           ldrb       r0,[r5,#0x0 ]
        00012e44 00  28           cmp        r0,#0x0
        00012e46 02  d1           bne        LAB_00012e4e
        00012e48 68  68           ldr        r0,[r5,#0x4 ]
        00012e4a ee  f7  b5  f8    bl         Semaphore_post                                   undefined Semaphore_post()
                             LAB_00012e4e                                    XREF[1]:     00012e46 (j)   
        00012e4e f8  bd           pop        {r3,r4,r5,r6,r7,pc}
                             PTR_SleepState_00012e50                         XREF[1]:     00012e40 (R)   
        00012e50 84  15  00  20    addr       SleepState                                       = 52h
Пропатчилось, как надо
Пропатчилось, как надо

Заливка патча, на удивление, сработала с первого раза, ресивер на 133Гц получает пакеты стабильно. Если медленно вести, в гейтвее нет никаких проблем и не сбрасывается на ноль. Ура!

Исправляем баги (выкидываем забытый скальпель)

Я уже начал радоваться, но вот Utopia Machine (да-да, еще раз спасибо за кучу тестирования) жаловался на то, что сенсор направления периодически залипает и требует перезагрузки. "Залипает" это когда направление меняется только на пару градусов, еще и перестаёт показывать уровень заряда. Еще жаловался на то, что иногда пропадают данные с одной из ног. При этом на оригинальном ресивере практически не воспроизводится.

У меня не воспроизводилось... Но как-то выяснилось, что у меня не воспроизводится когда висит на зарядке, а у него без зарядки. Выкрутил сенсор, оставил на пару часов периодически пошевеливая чтоб убедиться что работает. И... Да! Воспроизвел!

Когда произошло, я через Wireshark посмотрел на пакеты, и выяснилось, что в пакетах вместо направления и номера сенсора -- идёт "0 1 0 1 0 1". Хвост данных был как всегда, таким образом кусоче квартерниона направления таки менялся, потому и получалось что-то видеть.

Это сработала вот эта мина отложенного действия, которая есть и в прошивке сенсоров ног и в прошивке направления:

    if (something) {
        out[0] = 0; out[1] = 1; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 1;
    }

Это же объясняет, почему пропадала одна из ног -- когда этот код срабатывал, координаты-то не затирались (в ногах они в самом конце пакета), а вот данные о батарее и номер сенсора -- затирались.

Прекрасно, мы знаем, что случилось -- но почему? К счастью, флаг "somthing" трогался только один раз, внутри обработчика таймера тикающего раз в секунду; и только если некий счетчик доходил до 1800, и не сбрасывался по дороге. Сбрасывался он при определённых условиях связанными с разрядом батареи. Проще говоря, это оказался отладочный код для настройки скорости разряда батареи и тюнинга перехода в режим сна. Я нашел в обоих сенсорах хвосты для настройки этого параметра по USB.

В любом случае, это отладочный кусок который в релизной прошивке не нужен -- а значит, просто можно безопасно вырезать.

Вырезать опять же можно несколькими споосбами -- через замыкание if'а, через затирание nop'ами... Я просто заNOPил строку, где ставилось "something = 1".

А что не так

Примечательно, что исправленные сенсоры остаются совместимы с оригинальными ресивером... Почти. Главная разница -- значения, которые читаются сенсорами, теперь читаются с частотой обновления (86Гц..133Гц) а не с фиксированными ~140Гц.

Оптический сенсор на каждом чтении возвращает некое расстояние (в попугаях), которые он насчитал с прошлого чтения.

Оригинальный сенсор читает последнее доступное значение 86 раз в секунду, получается, использует данные о расстоянии как скорость -- часть пройденного расстояния при этом теряется, но, с некоторой точностью, восстанавливается перемножая на время.

Патченный сенсор начинает читать данные со скоростью опроса -- то есть данные о пройденном расстоянии почти не теряются (теряется каждый 500й пакет + часть пакетов просто теряется по радио), но при этом каждое значение получается больше, чем в оригинале: при обновлении 86 раз в секунду значения будут амплитудой до 163% по отношению к исходным, а на 133Гц всего 105%.

Представляет ли это проблему? Зависит от того, как этими данными пользоваться. Если использовать данные для вычисления скорости напрямую (как, к сожалению, делает гейтвей) -- то и да и нет. Нет -- так как при использовании 133Гц ресивера и исправленные сенсоры практически не чувствуется разницы, зато задержки ощутимо ниже (реально ощущается, особенно при игре на 120fps). Да -- так как при использовании исправленного сенсора и исходного ресивера все скорости сильно выше и нужно исправлять настройки для каждой игры -- понижать скорость разбега.

Можно ли это тоже исправить? Да, есть несколько способов исправления: патч гейтвея, патча исходного ресивера, более сложный патч сенсоров... Есть где разгуляться. Но это тема отдельного разговора.

Что дальше

А дальше на самом деле -- избавление от гейтвея, как минимум для нативных игр -- сейчас, используя KAT SDK невозможно сделать Standalone игры, так как SDK жестко прибит гвоздями к винде и ресиверу подключенному к нему. А я раньше уже показал как связаться с ресивером напрямую, сделал маленький ресивер который прикидывается оригинальным... То есть есть всё, что нужно, для создания действительно standalone игры -- идеально вписывается в концепцию игры, которую разрабатывает Utopia Machine. Так что в следующей серии я покажу итог совместной наработки -- UE SDK с прямым доступом до платформы хоть под виндой хоть нативно на Quest 2/3 :) Не переключайтесь!

Ссылки

  • Часть 1: "Играем с платформой" на [Habr], [Medium] и [LinkedIn].

  • Часть 2: "Начинаем погружение" на [Habr], [Medium] и [LinkedIn].

  • Часть 3: "Отрезаем провод" на [Habr], [Medium] и [LinkedIn].

  • Часть 4: "Играемся с прошивкой" на [Habr], [Medium] и [LinkedIn].

  • Часть 5: "Оверклокинг и багфиксинг" на [Habr], [Medium] и [LinkedIn].

Источник: https://habr.com/ru/articles/805747/


Интересные статьи

Интересные статьи

Для тех, кто только подключился -- я рассказываю про платформу для VR игр, как с ней интегрироваться и как добраться до ее сенсоров напрямую.Я уже заметил некоторые глюки и проблемы с сенсорами -- зн...
Итак, мы познакомились с платформой как пользователи. Посмотрели на связь игр с софтом. Софта с платформой. Пришла пора посмотреть как платформа общается со своими сенсорами: нужен ли нам провод до пл...
Ноотропы — препараты, стимулирующие интеллектуальную деятельность и обеспечивающие разгон нервной системы — это самый широкий класс разрешённых допингов, который продолжает активно развивать...
Он пробовал на прочность этот мир каждый миг, Мир оказался прочней. «Машина Времени» Ежегодно на соревнованиях «Формулы 1», кроме основного кубка, вручается кубок конструкторов — награда лучш...
Продолжение темы современных двигателей в формате comparing incomparable. После прочтения статьи «Apple в 2019 году — это Linux в 2000 году» я подумал что по такому же принципу можно сравн...