На помойку? Никак нет! Пишем нативные приложения для дешевых китайских телефонов

image

Если сейчас приехать в пункт приема металлолома, то можно обнаружить просто огромные кучи различных телефонов и прочих электронных «отходов», которые стоят под открытым небом и ждут, когда придёт их черёд окончательного разложения. Однако при ближайшем рассмотрении выясняется, что многие девайсы оказываются полностью рабочими даже после недельного лежания под палящим солнцем и проливными дождями, а сдали их в чермет по причинам «не нужен, надоел, купил новый» и т. п. Я не считаю это правильным, ведь даже в простые кнопочные звонилки имеется возможность вдохнуть новую жизнь, если знать один интересный, но малоизвестный факт: для них можно писать нативные приложения на C и использовать железо телефона в своих целях. А это, на минуточку, как минимум: дисплей с подсветкой, вибромотор, динамик, клавиатура и GSM-радиомодуль с возможностью выхода в сеть. Сегодня мы с вами: узнаем, на каких аппаратных платформах работают китайские телефоны, какие существуют программные платформы и где взять для них SDK, а в практической части мы напишем 2D-игру с нуля, которая будет работать на многих китайских кнопочниках. Интересно? Тогда жду вас под катом!

Содержание:


Не J2ME едины


Думаю, многие мои читатели помнят о такой платформе, как J2ME. Java-приложения стали фактически основной возможностью расширения функционала телефонов в 2000-х годах. API для них был достаточно хорошо стандартизировано, программы не зависели от архитектуры процессора и ОС устройства, а порог вхождения для написания собственных приложений был довольно низкий и даже новички могли за пару дней написать свою игрушку или какое-нибудь GUI-приложение!

image

Однако не одним J2ME мы были едины: существовало множество платформ, которые так или иначе пытались занять нишу Java на рынке. Некоторые из них я упоминал в своей прошлой статье о написании 3D-игры под Sony Ericsson с нуля: например, была такая платформа на телефонах Sony Ericsson серии T, как Mophun, а CDMA-телефонами с чипсетами Qualcomm использовалась нативная платформа BREW. Пожалуй, я не буду упоминать о .sis и .cab — поскольку это форматы нативных приложений для смартфонов, а не простых «фичефонов».

image


Игра для Mophun3D

В какой-то момент, ближе к 2006–2007 году, прилавки российских официальных ритейлеров (по большей части это были телефоны Fly) и неофициальных продавцов на рынках заполонили различные китайские телефоны, которые предлагали какой-то немыслимый функционал для тех лет за копейки, да ещё и визуально напоминали флагманские модели известных брендов. Пожалуй, одним из самых популярных таких телефонов была Nokla TV E71/E72 (да, именно «нокла»), вышедшая примерно в 2008 году и производившаяся аж до 2011 года! За 2–3 тысячи рублей (это менее 100 баксов), пользователь получал здоровый 2.4» дисплей с разрешением 240×320 весьма неплохого качества (когда в те годы многие продолжали ходить с 176×220), да ещё и с тачскрином, гироскоп, огромный громкий динамик (пусть и не очень качественный), поддержку SD-карточек до 32Гб, нередко фронтальную камеру, а также премиальный дизайн с вставками из алюминия. Частенько китайцы заботливо клали в коробку ещё чехольчик и дополнительный аккумулятор :)

image

Были даже полные копии существующих устройств от Nokia. Особенно китайцы любили подделывать массовые модели на S40: они были очень популярными и китайцы хотели откусить свой кусок рынка у Nokia. Пусть и рынка серого импорта — очевидно, в салонах связи подделки никто не продавал:

image

Но была и ложка дёгтя в этой бочке меда: китайские телефоны очень часто не имели поддержки Java, из-за чего многие пользователи разочаровывались в них из-за отсутствия возможности установить необходимые им приложения. Никакой тебе оперы, аськи, игр… Скорее всего, это связано с необходимостью отчислений Sun, а также разработчикам реализации J2ME-машины (JBed/JBlend) и установки чипа флэш-памяти чуть большего объёма.

image


Но многие пользователи не знали, что такие девайсы не просто поддерживали сторонние приложения, но и умели выполнять настоящие нативные программы, написанные на полноценном C! Всему помешала китайская костыльность и тотальная закрытость. Платформа предполагалась для работы на внутреннем рынке. Для вызова менеджера нативных приложений необходимо было вводить специальный инженерный код в номеронабирателе, предварительно скопировав приложение в нужную папку, а SDK долгое время было платным и доступно только для компаний из Китая. Кроме того, далеко не все приложения могли запустить на конкретном девайсе — были серьезные проблемы с совместимостью.

image

В ранних китайских телефонах использовалась платформа Mythroad (MRP, MiniJ) от китайской компании SkyWorks, которая лицензировала свою технологию производителям чипсетов. Поддержку MRP можно было встретить на телефонах с чипсетами MediaTek, Spreadtrum, а также MStar (и возможно Coolsand). Mythroad предоставлял некоторое API для работы с железом телефона и разработки как UI-приложений, так и игр, кроме того, Mythroad позволял хранить ресурсы в одном бинарнике с основной программой и даже имел какой-то интерпретируемый язык помимо возможности запуска нативного кода. Для работы таких приложений необходимо было скопировать менеджер приложений dsm_gm.mrp и игру в папку mythroad во внутренней памяти устройства или на флэшке, а затем набрать в номеронабирателе код *#220807#, иногда при отключенной первой SIM-карте. Костыльно? Костыльно! Откуда об этом знать среднестатистическому пользователю? Не откуда! Но работало!

Эта платформа поддерживалась на большинстве подделок под брендовые устройства Nokia, Sony Ericsson и Samsung, а также iPhone и на многих китайских кнопочных телефонах 2008–2010 годов.

image


Ближе к 2010 году MediaTek разработала свою собственную платформу, которая должна была заменить MRP — WRE (VXP). Эта платформа была гораздо шире с точки зрения функционала (например, был доступ к UART) и её API был вполне удобно читаем для программиста, а SDK свободно доступен для всех. Один нюанс всё портил — приложения без подписи привязывались к IMSI (даже не IMEI) симки в девайсе и на некоторых девайсах требовали переподписания под каждую конкретную SIM или патчинг дампа оригинальной прошивки телефона на отключение проверки подписи. Эта платформа поддерживалась на многих кнопочниках и смарт-часиках 2010–2020 годов: к ним относятся новодельные телефоны Nokia, телефоны DNS и DEXP, Explay и т. п. Для запуска приложений достаточно было выбрать файл с разрешением VXP в проводнике и просто запустить его. Но с совместимостью всё равно имелись проблемы: если запустить VXP для версии 2.0 и выше, мы получим лишь белый экран. Ну хоть не софтресет, и на том спасибо!

image


Далеко не все такие часы поддерживают MRE, смотреть нужно от устройства к устройству

Аппаратные ресурсы<

Большинство китайских кнопочных телефонов работает на базе одних и тех же чипсетов. В конце нулевых чаще всего использовались чипсеты MT6225, SC6520 и некоторые чипы от Coolsand. Средние хар-ки девайса были следующими:

  • Процессор: ARMv5 ядро на частоте ~104МГц, ARM926EJ-S. Нет FPU, есть Thumb. Большую часть процессорного времени программа могла забрать себе.
  • ОЗУ: ~4Мб SDRAM. Программам было доступно 512Кб-1Мб Heap’а. Это, в целом, довольно немало для большинства применений.
  • Флэш-память: ~32Мб, пользователю доступно пару сотен килобайт. Да, вы не ослышались, килобайт! Однако можно без проблем использовать MicroSD-флэшки до 32Гб.
  • Дисплей: от 128×128 до 320×480, почти всегда есть 18-битный цвет (262.000 цветов), в случае TV E71/E72 используется очень неплохая TN-матрица с хорошими углами обзора и яркой подсветкой. Иногда есть тачскрин.
  • Звук: громкий динамик, наушники.
  • Аккумулятор: ~800 мАч, на некоторых девайсах может быть и 2.000 мАч, а то и больше!
  • Ввод: клавиатура, иногда была поддержка QWERTY.
  • Внешние шины: почти всегда был доступен UART, причём его можно было свободно взять прямо с платы — он был явно подмечен! Взять GPIO с проца не выйдет (кроме, возможно, вибромотора), SPI и I2C также напрямую недоступны. Внешние шины можно реализовать с помощью UART через GPIO-мост из микроконтроллера.


В итоге мы получаем очень неплохие характеристики для устройства, которое сочетает в себе сразу всё. На базе такого девайса можно сделать и сигнализацию, и HMI-дисплей с интерфейсом для управления каким-нибудь устройством, и игровую консоль с эмуляторами… да на что фантазии хватает! И это за какие-то 200–300 рублей, если мы говорим о б/у устройстве или 600 рублей, если говорим о новом. Это дешевле, чем собирать девайс с подобным функционалом самому из готового МК (например, RP2040) и отдельных модулей. Кстати, дешевые 2.4» дисплеи на алике — это ни что иное, как невостребованные остатки дисплеев для подобных китайских телефонов на складах! А вы думали, откуда там значки на тачскрине снизу?

image


Однако в рамках данной статьи мы не будем ограничиваться лишь теорией и на практике напишем примитивную 2D-игрушку, которая будет работать сразу на трех платформах без каких-либо изменений в коде самой игры: Windows, MRP (Mythroad) и VXP. Но для того, чтобы достигнуть такого уровня абстракции от платформы, нам необходимо написать рантайм, который оборачивает все необходимые платформозависимые функции для нашей игры.

Игрушка будет простой: 2D скролл-шутер с видом сверху, а-ля Asteroids. Летаем по космосу, и стреляем по враждебным корабликам, стараясь не попасть под вражеские лазеры. Всё просто и понятно :)

image


Практическая часть: Кроссплатформенный рантайм


Итак, что нам необходимо от абстракции для такой простой игры? Давайте посмотрим:
Выглядит всё достаточно просто, верно? Примерно такого набора функций хватит для нашей игры:

void sysLogf(char* fmt, ...);
void* sysAlloc(int len);
void sysFree(void* ptr);
int sysRand();

int gGetScreenWidth();
int gGetScreenHeight();
int gGetScreenColorDepth(); // Almost always 16
void gClearScreen(CColor* color);
void gDrawBitmap(CBitmap* bmp, int x, int y);
void gDrawText(char* text, int x, int y, CColor* color);

bool inHasTouchScreen();
int inGetKeyState();
bool inIsAnyKeyPressed();
int inGetPointerX();
int inGetPointerY();

void gameStart();
void gameUpdate();
void gameDraw();


Win32


Давайте же перейдем к реализации рантайма на каждой платформе по отдельности. Начнём с Win32, поскольку адекватно отлаживать игру можно только на ПК.

image


На десктопе у нас будет фиксированное окно 240×320, в качестве GAPI будет использоваться аппаратно-ускоренный OpenGL, а для обработки ввода будет использоваться классически GetAsyncKeyState. Реализация точки входа, создания окна и инициализации контекста GL и главного цикла приложения у нас такая:

void gInit()
{
	hwnd = CreateWindowA("STATIC", "2D Framework", WS_VISIBLE | WS_SYSMENU, 0, 0, gGetScreenWidth(), gGetScreenHeight(), 0, 0, 0, 0);
	hPrimaryDC = GetDC(hwnd);

	PIXELFORMATDESCRIPTOR pfd;
	ZeroMemory(&pfd, sizeof(pfd));
	
	if (!SetPixelFormat(hPrimaryDC, ChoosePixelFormat(hPrimaryDC, &pfd), &pfd))
	{
		sysLogf("SetPixelFormat failed\n");
		exit(-1);
	}

	hGL = wglCreateContext(hPrimaryDC);
	wglMakeCurrent(hPrimaryDC, hGL);

	sysLogf("Renderer: %s\n", glGetString(GL_RENDERER));
	sysLogf("Vendor: %s\n", glGetString(GL_VENDOR));
	sysLogf("Version: %s\n", glGetString(GL_VERSION));

	glEnable(GL_TEXTURE_2D);
	glEnable(GL_BLEND);
	glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

	glMatrixMode(GL_PROJECTION);
	glOrtho(0, gGetScreenWidth(), gGetScreenHeight(), 0, 0, 1);
}
void winMainLoop()
{
	while (IsWindow(hwnd))
	{
		MSG msg;

		while (PeekMessageA(&msg, hwnd, 0, 0, PM_REMOVE))
			DefWindowProc(hwnd, msg.message, msg.wParam, msg.lParam);

		gameUpdate();
		gameDraw();

		glFinish();
		SwapBuffers(hPrimaryDC);

		Sleep(1000 / 60);
	}
}

int main(int argc, char** argv)
{
	sysLogf("Portable 2D framework\n");
	sysLogf("Version: " VERSION "\n");
	
	gInit();

	gameStart();
	winMainLoop();
}


Реализация отрисовки спрайтов очень примитивная — OGL 1.0, полностью FFP, вся отрисовка — это 2 треугольника, формирующие квад. Спрайт заливается при первом использовании в текстуру, последующие кадры реюзается уже готовая текстура. Фактическая реализация всего рендерера — т. е. функций для рисования «просто картинок», без поддержки атласов, блендинга цветов:

void gClearScreen(CColor* color)
{
	float r = (float)color->r / 255;
	float g = (float)color->g / 255;
	float b = (float)color->b / 255;

	glClearColor(r, g, b, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);
}

#define GL_UNSIGNED_SHORT_5_6_5 0x8363
#define TEXTURE_COLORKEY 63519

void gPrepareBitmap(CBitmap* bmp)
{
	GLuint tex[1];
	glGenTextures(1, &tex);

	sysLogf("Uploading texture %dx%d\n", bmp->width, bmp->height);

	unsigned char* data = (unsigned char*)malloc(bmp->width * bmp->height * 4);

	// Quick endian flip & color-space conversion
	for (int i = 0; i < bmp->width * bmp->height; i++)
	{
		unsigned short pixel = *((unsigned short*)&bmp->pixels[i * 2]);

		float r = (float)(pixel & 31) / 32;
		float g = (float)((pixel >> 5) & 63) / 64;
		float b = (float)(pixel >> 11) / 32;

		data[i * 4 + 2] = (unsigned char)(r * 255);
		data[i * 4 + 1] = (unsigned char)(g * 255);
		data[i * 4] = (unsigned char)(b * 255);

		data[i * 4 + 3] = pixel == TEXTURE_COLORKEY ? 0 : 255;
	}

	glBindTexture(GL_TEXTURE_2D, tex[0]);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmp->width, bmp->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);

	free(data);

	bmp->_platData = tex[0];
}

void gDrawBitmap(CBitmap* bmp, int x, int y)
{
	gDrawBitmapEx(bmp, x, y, 0, 0);
}

void gDrawBitmapEx(CBitmap* bmp, int x, int y, CColor* colorKey, CColor* mulColor)
{
	if (!bmp->_platData)
		gPrepareBitmap(bmp);

	glBindTexture(GL_TEXTURE_2D, (GLuint)bmp->_platData);

	glBegin(GL_QUADS);
	glTexCoord2f(0, 0);
	glVertex2i(x, y);
	glTexCoord2f(1, 0);
	glVertex2i(x + bmp->width, y);
	glTexCoord2f(1, 1);
	glVertex2i(x + bmp->width, y + bmp->height);
	glTexCoord2f(0, 1);
	glVertex2i(x, y + bmp->height);
	glEnd();
}


С вводом тоже всё просто. Есть биндинг кнопок клавиатуры к кнопкам на кейпаде телефона. inGetKeyState предполагается вызывать один раз за кадр, поэтому функция опрашивает ОС о состоянии нажатых кнопок на клавиатуре и назначает состояние виртуальных кнопок относительно состояния физических кнопок на клавиатуре.

static int inKeyBinding[] = {
	VK_LEFT, KEY_LEFT,
	'A', KEY_LEFT,
	VK_RIGHT, KEY_RIGHT,
	'D', KEY_RIGHT,
	VK_UP, KEY_UP,
	'W', KEY_UP,
	VK_DOWN, KEY_DOWN,
	'S', KEY_DOWN,
	'Q', KEY_LS,
	'E', KEY_RS
};

bool inHasTouchScreen()
{
	return false;
}

int inGetKeyState()
{
	int result = 0;

	for (int i = 0; i < (sizeof(inKeyBinding) / sizeof(int)) / 2; i++)
	{
		if (GetAsyncKeyState(inKeyBinding[i * 2]) & 0x8000)
			result |= inKeyBinding[i * 2 + 1];
	}

	return result;
}

bool inIsAnyKeyPressed()
{
	return inGetKeyState() != 0;
}

int inGetPointerX()
{
	return 0;
}

int inGetPointerY()
{
	return 0;
}


Результат:

image


MiniJ


Переходим к реализации рантайма для первой китайской платформы — MRP. Обратите внимание — я использую нативное API платформы для рисования спрайтов. Связано это с тем, что софтварный блиттер работает невероятно медленно даже с прямым доступом к скринбуферу устройства, а в чипсете предусмотрена отдельная графическая подсистема с командбуфером для быстрой отрисовки примитивов и графики:

image


SDK для MRE можно найти здесь (SKYSDK.zip): оно уже пропатчено от необходимости покупки лицензии. MRP не развивается более 10 лет, поэтому, думаю, его можно считать Abandonware. Компилятор находится в compiler/mrpbuilder.NET1.exe. За китайские SDK в публичном доступе нужно поблагодарить пользователя 4pda AjlekcaHgp MejlbHukoB, который раздобыл их на всяких csdn и выложил в свободный доступ :)

У MRP собственная система сборки, основанная на конфигурациях. Поскольку MRP может работать на устройствах с разными платформами и размерами дисплеев, под каждую можно настроить свой конфиг, который пережмет ресурсы в нужный формат. Дабы ничего не ломать, я заюзал абсолютные пути:

[information]
projectname=game.mpr
filename=game.mrp
appname=ScrollShooter
appid=30001
version=100
visible=1
cpu=3
vendor=monobogdan
output=bin\game.mrp
description=ScrollShooter game by monobogdan
include=D:/SKYSDK/include/,D:/SKYSDK/include/plugins/
config=mtk240

[config_mtk240]
output=bin\game.mrp
bmp_mode=normal
plat=mtk
[files]
file42 = platform\plat_mrc.c
file44 = game.c
file45 = resources\gamedata.c
file46 = graphics.c

Компиляция приложения:

mrpbuilder.net1.exe game.mpr


Начинаем с функций обработки событий и инициализации, которые вызывает рантайм при старте приложения: mrc_init вызывается при старте приложения, а mrc_event при возникновении события. Вся инициализация очень простая: создаём таймер для обновления и перерисовки состояния игры и вызываем инициализацию игры:


void mrc_draw(int32 data)
{
	mrc_clearScreen(0, 0, 128);
	
	gameUpdate();
	gameDraw();
	
	mrc_refreshScreen(0, 0, 240, 320);
}

int32 mrc_init(void)
{
	mrc_getScreenInfo(&screenInfo);

	gameStart();
	
	// Allocate timer
	globalTimer = mrc_timerCreate();
	mrc_timerStart(globalTimer, 1000 / 30, 0, mrc_draw, 1);
	
	return MR_SUCCESS;
}

int32 mrc_extRecvAppEvent(int32 app, int32 code, int32 param0, int32 param1)
{
        return MR_SUCCESS;
}
int32 mrc_extRecvAppEventEx(int32 code, int32 p0, int32 p1, int32 p2, int32 p3, int32 p4,int32 p5)
{
        return MR_SUCCESS;
}


int32 mrc_pause(void)
{
	return MR_SUCCESS;
}


int32 mrc_resume(void)
{
	return MR_SUCCESS;
}


int32 mrc_exitApp(void)
{
	
	return MR_SUCCESS;
}


С вводом тоже никаких проблем нет, нажатия кнопок прилетают как события в mrc_event. Переводим кейкоды MRE в наши кейкоды и сохраняем их состояние:

int32 mrc_event(int32 ev, int32 p0, int32 p1)
{
	int key = p0;
	int vKey = 0;
	
	switch(key)
		{
			case MR_KEY_LEFT:
				vKey = KEY_LEFT;
				break;
			case MR_KEY_RIGHT:
				vKey = KEY_RIGHT;
				break;
			case MR_KEY_UP:
				vKey = KEY_UP;
				break;
			case MR_KEY_DOWN:
				vKey = KEY_DOWN;
				break;
			
			case MR_KEY_SELECT:
				vKey = KEY_OK;
				break;
			case MR_KEY_SOFTLEFT:
				vKey = KEY_LS;
				break;
			case MR_KEY_SOFTRIGHT:
				vKey = KEY_RS;
				break;
		}
		
		if(ev == MR_KEY_PRESS)
			keyState |= vKey;
		
		if(ev == MR_KEY_RELEASE)
			keyState &= ~vKey;
	
	return MR_SUCCESS;

}


Опять же, отлаживать MRP-приложение под реальным устройством проблематично, поэтому платформозависимый код должен быть минимальным. Кроме того, обратите внимание, что некоторые функции в MRP зависят от библиотек-плагинов. Линкер слинкует вашу программу, но на реальном устройстве их вызов вывалится в SIGSEGV и софтресет устройства. Также нельзя использовать ничего из стандартной библиотеки именно в стандартных заголовочниках (т. е. stdlib.h, string.h и т. д.), часть стандартной библиотеки реализовывается MRP и дефайнится в mrc_base.h

void* sysAlloc(int len)
{
	return mrc_malloc(len);
}

void sysFree(void* ptr)
{
	mrc_free(ptr);
}

int sysRand()
{
	mrc_sand(mrc_getUptime());
	return mrc_rand();
}


Что интересно, защиты памяти толком нет. Если приложение падает в SIGSEGV или портит память — систему, судя по всему, ребутит Watchdog. Защиты памяти никакой, можно напрямую читать и писать в память ядра, а также писать в регистры периферии чипсета. jpegqs, покумекаем над этим? :)

Переходим к рендереру. Тут буквально две функции, gClearScreen очищает экран, а gDrawBitmap рисует произвольный спрайт с форматом пикселя RGB565. В качестве ROP используется BM_TRANSPARENT — таким образом, mrc_bitmapShowEx будет использовать левый верхний пиксель в качестве референсного цвета для реализации прозрачности без альфа-блендинга.


void gClearScreen(CColor* color)
{
	mrc_clearScreen(color->r, color->g, color->b);
}

void gDrawBitmap(CBitmap* bmp, int x, int y)
{
	mrc_bitmapShowEx((uint16*)bmp->pixels, x, y, bmp->width, bmp->width, bmp->height, BM_TRANSPARENT, 0, 0);
}


Да, всё вот так просто. Рантайм теперь запускается на реальных китайских девайсах и работает стабильно.

VXP


Теперь переходим к VXP — платформе не менее неоднозначной, чем MRP. Пожалуй, начать стоит с того, что VXP существует аж в трёх версиях: MRE 1.0, MRE 2.0 и MRE 3.0. В MRE 2.0 и выше появилась поддержка плюсов (в MRE 1.0 только Plain C) и довольно интересного GUI-фреймворка, MRE 1.0 же предлагает реализовывать гуй самому. Платформа распространена на большинстве кнопочных телефонов и смарт-часиков на чипсетах MediaTek, примерно начиная с 6235 и заканчивания 6261D. SDK можно скачать вот здесь (см MRE_SDK_3.0).

VXP сам по себе более функционален чем MRE, поскольку ориентирован исключительно на телефоны с чипсетами MediaTek. Но что самое приятное — есть доступ к уарту без каких либо костылей! То есть, если сделать GPIO-мост на условной ESP32, то мы можем получить готовый мощный МК с клавиатурой, кнопками, дисплеем, звуком и т. д. Звучит не хило, да? Кроме того, у нас есть доступ и к BT, и к GPRS, и к SMS без каких либо ограничений.

image


Однако в бочке мёда нашлась и ложка дёгтя: для компиляции MRE-приложений необходимо накатывать и крякать довольно старый компилятор ADS, который сам по себе поддерживает только C89 (например, нет возможности объявить переменную в объявлении цикла или середине функции, только в начале, как в Pascal). ADS уже вроде как Abandonware, так что это вроде не наказуемо…, но всё равно неприятно.

Кроме того, на некоторых девайсах (в основном, фирменных Nokia а-ля 225), прошивка требует подписи у всех бинарников, либо если бинарник отладочный, то должна быть привязка к конкретному IMSI.

К тому же, каждая программа должна фиксированно указывать в заголовке, сколько Heap-памяти ей необходимо выделить. Оптимальный вариант — ~500Кб, тогда приложение запустится вообще на всех MRE-телефонах.

image


Зато у VXP есть адекватный симулятор под Windows. Но зачем он нам, если у нас порт игры под Win32 есть? :)

image


Начинаем с инициализации приложения. В процессе вызова точки входа, приложение должно назначить обработчики системных событий, коих бывает несколько. Для обработки ввода и базовых событий хватает всего три: sysevt (события окна), keyboard (физическая клавиатура. Есть полная поддержка QWERTY-клавиатур), pen (тачскрин).

void vm_main(void) {
	layers[0] = -1;
	gameStart();
	
	vm_reg_sysevt_callback(handle_sysevt);
	vm_reg_keyboard_callback(handle_keyevt);
	vm_reg_pen_callback(handle_penevt);
}


Переходим к обработчику системных событий. Обратите внимание, что MRE-приложения могут работать в фоне, из-за чего необходимо ответственно подходить к созданию и освобождению объектов. Что важно усвоить с самого начала — в MRE нет понятия процессов и защиты памяти, как на ПК и полноценных смартфонах. Любая программа может попортить память или стек ОС, более того, программа использует аллокатор остальной системы, поэтому если ваша программа не «убирает» после себя, данные останутся в памяти со временем приведут к зависанию. Впрочем, WatchDog делает свою работу быстро и приводит телефон в чувство (софтресетом) за 1–2 секунды. Но как и в случае с MRE, есть приятный бонус: прямой доступ к регистрам чипсета :)

void handle_sysevt(VMINT message, VMINT param) {
	switch (message) {
		case VM_MSG_CREATE:
		case VM_MSG_ACTIVE:
			layers[0] = vm_graphic_create_layer(0, 0, 
				vm_graphic_get_screen_width(),		
				vm_graphic_get_screen_height(),		
				-1);
			
			vm_graphic_set_clip(0, 0, 
				vm_graphic_get_screen_width(), 
				vm_graphic_get_screen_height());
				
			screenBuf = vm_graphic_get_layer_buffer(layers[0]);
			gTimer = vm_create_timer(16, onTimerTick);
			
			break;
			
		case VM_MSG_PAINT:
			
			break;
			
		case VM_MSG_INACTIVE:
		case VM_MSG_QUIT:
			if( layers[0] != -1 )
				vm_graphic_delete_layer(layers[0]);
			
			vm_delete_timer(gTimer);
			
			break;	
	}
}


Переходим к обработке событий с кнопок. Тут всё абсолютно также, как и на MRE, лишь имена дейфанов поменялись :)

void handle_keyevt(VMINT event, VMINT keycode) {
	int vKey = 0;
	
	switch(keycode)
		{
			case VM_KEY_LEFT:
				vKey = KEY_LEFT;
				break;
			case VM_KEY_RIGHT:
				vKey = KEY_RIGHT;
				break;
			case VM_KEY_UP:
				vKey = KEY_UP;
				break;
			case VM_KEY_DOWN:
				vKey = KEY_DOWN;
				break;
			
			case VM_KEY_OK:
				vKey = KEY_OK;
				break;
			case VM_KEY_LEFT_SOFTKEY:
				vKey = KEY_LS;
				break;
			case VM_KEY_RIGHT_SOFTKEY:
				vKey = KEY_RS;
				break;
		}
		
		if(event == VM_KEY_EVENT_DOWN)
			keyState |= vKey;
		
		if(event == VM_KEY_EVENT_UP)
			keyState &= ~vKey;
}


И наконец-то, к графике! Пожалуй, стоит сразу отметить, что более 20–30 FPS на большинстве устройств вы не получите даже с прямым доступом к фреймбуферу. Похоже, это связано с тем, что в MRE довольно замороченная графическая подсистема с поддержкой альфа-канала (только фиксированного во время вызова функции отрисовки картинки/примитивов, сам пиксельформат всегда RGB565) и нескольких слоев. Кроме того, похоже есть ограничения со стороны контроллера дисплея.
Софтварный вывод спрайтов

Изначально, MRE предполагает то, что все картинки в программе хранятся в формате… GIF. Да, весьма необычный выбор. Однако для работы с пользовательской графикой, есть возможность блиттить произвольные картинки напрямую из RAM. Вот только один нюанс — посмотрите внимательно не объявление следующей функции:

void vm_graphic_blt(
    VMBYTE * dst_disp_buf, 
    VMINT x_dest, 
    VMINT y_dest, 
    VMBYTE * src_disp_buf, 
    VMINT x_src, 
    VMINT y_src, 
    VMINT width, 
    VMINT height, 
    VMINT frame_index
);


dst_disp_buf — это целевой RGB565-буфер. Логично предположить, что и src_disp_buf — тоже обычный RGB565-буфер! Но как бы не так. Документация крайне скудная, пришлось посидеть и покумекать, откуда в обычном 565 буфере возьмется индекс кадра. С подсказкой пришёл пользователь 4pda Ximik_Boda — он скинул структуру-заголовок, которая идёт перед началом каждого кадра. В документации об этом не сказано ровным счетом ничего!

image


Сначала я реализовал софтовый блиттинг, но он безбожно лагал. Мне стало интересно, почему нативный blt быстрее и… вопросы отпали после того, как я поглядел в ДШ чипсета: тут есть аппаратный блиттинг. И даже с ним девайс не может выдать более 20FPS!

image


Для реализации более-менее шустрого вывода графики, необходимо сначала создать канвас (фактически, Bitmap в MRE), создать и привязать к нему layer, получить указатель на буфер слоя и только потом скопировать туда нашу картинку. Да, вот так вот замороченно:

void gPrepareBitmap(CBitmap* bmp)
{
	VMINT cnvs = vm_graphic_create_canvas(bmp->width, bmp->height);
	VMINT layer = vm_graphic_create_layer_ex(0, 0, bmp->width, bmp->height, VM_COLOR_888_TO_565(255, 0, 255), VM_BUF, vm_graphic_get_canvas_buffer(cnvs));
	memcpy(vm_graphic_get_layer_buffer(layer), bmp->pixels, bmp->width * bmp->height * 2);
	
	vm_graphic_canvas_set_trans_color(cnvs, VM_COLOR_888_TO_565(255, 0, 255));
	
	bmp->_platData = (void*)cnvs;
}

void gDrawBitmap(CBitmap* bmp, int x, int y)
{
	int i, j;
	if(!bmp->_platData)
		gPrepareBitmap(bmp);
	
	vm_graphic_blt(screenBuf, x, y, vm_graphic_get_canvas_buffer((VMINT)bmp->_platData), 0, 0, bmp->width, bmp->height, 1);
}


И только после этого всё заработало достаточно шустро :)
В остальном же платформа довольно неплохая. Да, без болячек не обошлось, но всё же перспективы вполне себе есть.

На данный момент, этого достаточно для нашей игры.

Пишем геймплей


Рантайм у нас есть, а значит, можно начинать писать игрушку. Хоть пишем мы на Plain-C, я всё равно из проекта в проект использую ± одну и ту же архитектуру относительно системы сущностей, стейтов и т. п. Поэтому центральным объектом у нас станет CWorld, который хранит в себе на пулы с указателями на другие объектами в сцене, а также игрока и его состояние:

typedef struct
{
	CPlayer player;

	int nextSpawn; // In ticks

	CEnemy* enemyPool[ENEMY_POOL_SIZE];
	CProjectile* projectilePool[PROJECTILE_POOL_SIZE];
} CWorld;


Система стейтов простая и понятная — фактически, между состояниями передавать ничего не нужно. При нажатии в главном меню на «старт», нам просто необходимо проинициализировать мир заново и начать геймплей, при смерти игрока — закинуть его обратно в состояние меню. Стейты представляют из себя три указателя на функции: переход (инициализация), обновление и отрисовка.

typedef void(CGameStateCallback)();

Поскольку мы хотим некоторой гибкости при создании новых классов противников, то вводим структуру CEnemyClass, которая описывает визуальную составляющую врагов и их флаги — могут ли они стрелять по игроку или просто летят вниз (астероиды), как они передвигаются (зигзагами например) и т. п.

typedef struct
{
	CBitmap* sprite;

	int speed;

	int maxHealth;
	int flags;
	int projectileDamage;
	int contactDamage;
} CEnemyClass;

typedef struct
{
	CEnemyClass* _class;

	int health;
	int nextAttack;
	int x, y;
} CEnemy;

        // Asteroid
	enemyClasses[0].sprite = &sprEnemy1;
	enemyClasses[0].flags = ENEMY_FLAG_NONE;
	enemyClasses[0].maxHealth = 45;
	enemyClasses[0].contactDamage = 15;
	enemyClasses[0].speed = 2;

	// Regular unit
	enemyClasses[1].sprite = 0;
	enemyClasses[1].flags = ENEMY_FLAG_CAN_SHOOT;
	enemyClasses[1].contactDamage = 20;
	enemyClasses[1].projectileDamage = 20;

	// ZigZag shooter
	enemyClasses[2].sprite = 0;
	enemyClasses[2].flags = ENEMY_FLAG_CAN_SHOOT | ENEMY_FLAG_ZIG_ZAG_MOVEMENT;
	enemyClasses[2].contactDamage = 20;
	enemyClasses[2].projectileDamage = 10;


А также описываем игрока:

typedef struct
{
	int health;
	int frags;
	int score;

	int speed;

	int nextAttack;
	int x, y;
} CPlayer;


Всё! Для текущего уровня реализации игры этого достаточно :)
Переходим к реализации игровой логики. Вообще, динамический аллокатор в играх для китайских платформ лучше использовать как можно меньше. Heap’а довольно мало (~600Кб), да и не совсем понятно, как этот аллокатор реализован, есть вероятность, что используется аллокатор и куча основной ОС.

Начинаем с реализации полёта кораблика. Для этого он должен реагировать на стрелки и не улетать за границы экрана, а ещё для красоты он должен «вылетать» из нижней границы экрана при старте игры:

        // Player update
	int keys = inGetKeyState();
	int horizInput = 0;

	if (keys & KEY_LEFT)
		horizInput = -1;

	if (keys & KEY_RIGHT)
		horizInput = 1;

	if(world.player.y > gGetScreenHeight() - sprPlayer.height - 16)
		world.player.y -= world.player.speed;

	world.player.x += horizInput * world.player.speed;
	world.player.x = clamp(world.player.x, 0, gGetScreenWidth() - sprPlayer.width);


Переходим к динамическим пулам с объектами. Как вы уже заметили, их всего два — враги и летящие снаряды. Реализация спавна врагов/снарядов простая и понятная: мы обходим каждый элемент пула, если указатель на объект не-нулевой, значит объект всё ещё жив и используется на сцене. Если нулевой — значит ячейка свободна и можно заспавнить новый объект:

CEnemy* spawnEnemy(CEnemyClass* _class)
{
	int i;

	for (i = 0; i < sizeof(world.enemyPool) / sizeof(CEnemy*); i++)
	{
		CEnemy* enemy;

		if (world.enemyPool[i])
			continue;

		enemy = (CEnemy*)sysAlloc(sizeof(CEnemy));
		memset(enemy, 0, sizeof(CEnemy));
		enemy->_class = _class;
		enemy->health = _class->maxHealth;
		enemy->x = randRange(0, gGetScreenWidth() - _class->sprite->width);
		enemy->y = randRange(-_class->sprite->height * 4, -_class->sprite->height);

		
		return world.enemyPool[i] = enemy;
	}

	return 0;
}


При обходе пула во время обновления кадра, мы обновляем состояние каждого объекта и если его функция Think вернула true, значит объект больше не нужен и его нужно удалить:

        // Enemy update
	for (i = 0; i < sizeof(world.enemyPool) / sizeof(CEnemy*); i++)
	{
		if (world.enemyPool[i])
		{
			if (enemyThink(world.enemyPool[i]))
			{
				sysFree(world.enemyPool[i]);
				world.enemyPool[i] = 0;
			}
		}
	}


А вот и реализация Think:

// If returns true, then enemy should be destroyed
bool enemyThink(CEnemy* enemy)
{
	enemy->y += enemy->_class->speed;

	if (enemy->y > gGetScreenHeight() || enemy->health <= 0)
		return true;

	return false;
}


Но кораблики должны же откуда-то появляться! Для этого у нас есть переменная nextSpawn, которая позволяет реализовать самый простой тип спавнера — относительно времени (или в нашем случае тиков):

        world.nextSpawn--;

	if (world.nextSpawn < 0)
	{
		// randRange(0, 3)
		CEnemy* enemy = spawnEnemy(&enemyClasses[0]);
		
		world.nextSpawn = randRange(40, 70);
	}


Результат: мы уже можем полетать и поуворачиваться от вражеских корабликов! Но для игры этого пока маловато. Давайте добавим возможность стрелять лазерами! Для этого реализуем обход пула снарядов и проверим на столкновение каждый заспавненный вражеский кораблик: если снаряд столкнулся с корабликом, то мы отнимем у кораблика HP, а снаряд — задеспавним. Если у кораблика осталось меньше или 0 HP, то в следующем кадре он будет убран из сцены.

// Projectile update
	for (i = 0; i < sizeof(world.projectilePool) / sizeof(CProjectile*); i++)
	{
		if (world.projectilePool[i])
		{
			world.projectilePool[i]->y += world.projectilePool[i]->dir * world.projectilePool[i]->speed;

			for (j = 0; j < sizeof(world.enemyPool) / sizeof(CEnemy*); j++)
			{
				if (world.enemyPool[j])
				{
					if (aabbTest(world.projectilePool[i]->x, world.projectilePool[i]->y, sprLaser.width, sprLaser.height, 
						world.enemyPool[j]->x, world.enemyPool[j]->y, world.enemyPool[j]->_class->sprite->width, world.enemyPool[j]->_class->sprite->height))
					{
						world.enemyPool[j]->health -= world.projectilePool[i]->damage;

						sysFree(world.projectilePool[i]);
						world.projectilePool[i] = 0;
						break;
					}
				}
			}
		}
	}


Реализация стрельбы тоже совсем простая и также зависит от таймера:

         if (keys & KEY_OK && world.player.nextAttack < 0)
	{
		spawnProjectile(world.player.x + (sprPlayer.width / 2), world.player.y, -1, 15, 35);
		world.player.nextAttack = 15;
	}

        world.player.nextAttack--;


Смотрим на результат: Уже что-то напоминающее игру! Осталось лишь добавить подсчет очков, менюшку, разные виды противников, возможно какие-то бонусы и у нас будет готовая простенькая аркада. В целом, выше приведена достаточно неплохая архитектура для простых 2D-игр на Plain C. Фактически, она может быть хорошей базой и для ваших игр: в теме о китах на 4pda я встречал немало людей, которые банально не знали, с чего начать.

Что у нас получилось?


Но без тестов на реальных устройствах материал не был бы таким интересным! Поэтому давайте протестируем игру на двух реальных телефонах, как вы уже догадались, один — Nokla TV E71, а второй — клон Nokia 6700, который подарил мне мой читатель Никита.

На TV E71 игра идёт не сказать что очень бодро. Кадров 15 точно есть, что, учитывая разрешение 240×320, весьма неплохо для такого девайса.

На 6700,, даже учитывая более низкое разрешение — 176×220, дела примерно также — ~15FPS! Но поиграть всё равно можно. Уже хотите написать «автор наговнокодил, а теперь ноет из-за низкого FPS»? Ан-нет, я попробовал игры сторонних разработчиков — они идут примерно также:(К сожалению, таковы аппаратные ограничения устройства.

Исходный код игры с Makefile’ами и файлами проектов для Visual Studio и MRELauncher доступны на моём GitHub. Свободно изучайте и используйте его в любых целях :)

Заключение


Но в остальном же, демка получилась довольно прикольной, как и сам опыт программирования для китайских телефонов. В общем и целом, китайцы пытались максимально упростить API и привлечь разработчиков к своей платформе. Если ради примера взглянуть на API для Elf’ов на Motorola, можно ужаснуться от state-based архитектуры платформы P2K. А тут тебе init, event, draw — и всё!

Но популярности помешала непонятная закрытость платформы, костыльный запуск программ, отсутствие нормального симулятора. А ведь сколько фишек было: даже возможность писать и читать память ядра!
А вы как считаете? Можно ли вдохнуть в китайские кнопочники новую жизнь, узнав о наличии возможности запуска нативного кода на них?

image
Крутые девайсы на фоне ковра, который старше автора в два раза. Всё как вы любите:)

P. S.: Друзья! Время от времени я пишу пост о поиске различных китайских девайсов (подделок, реплик, закосов на айфоны, самсунги, сони, HTC и т. п.) для будущих статей. Однако очень часто читатели пишут «где ж ты был месяц назад, мешок таких выбросил!», поэтому я решил в заключение каждой статьи вставлять объявление о поиске девайсов для контента. Есть желание что-то выкинуть или отправить в чермет? Даже нерабочую «невключайку» или полурабочую? А может, у этих девайсов есть шанс на более интересное существование! Смотрите в соответствующем посте, что я делаю с китайскими подделками на айфоны, самсунги, макбуки и айпады! Да и чего уж там говорить: эта статья уже сама по себе весьма наглядный пример!

Понравился материал? У меня есть канал в Телеге, куда я публикую бэкстейдж со статей, всякие мысли и советы касательно ремонта и программирования под различные девайсы, а также вовремя публикую ссылки на свои новые статьи. 1–2 поста в день, никакого мусора!


Возможно, захочется почитать и это:
b5pjofdoxth14ro-rjsrn7sbmiy.png

© Habrahabr.ru