"Что б они ни делали -- не идут дела. Видимо в прошивке багов дофига". Как я напомнил в прошлой статье (где я подготовил утилиты для перепрошивки сенсоров) -- я рассказываю про платформу для 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].

Комментарии (2)


  1. Apoheliy
    06.04.2024 14:27
    +1

    Вторая ссылка (платформа VR) ведёт на "todo". Желательно исправить.


    1. datacompboy Автор
      06.04.2024 14:27

      Спасибо! поправил :)