Почему это важно?

В контексте разработки читов преобладают две основные архитектуры x64 и ARM и соответствующие им операционные системы Windows и Android.

Большинство игр и их целевой аудитории сосредоточено именно в этих направлениях.

Как и в комбинации Windows+x64 так и в Android+ARM размер указателя sizeof(int*) == 8 то есть 8 байт.

Стоит отметить, что размер указателя не зависит от типа, на который он указывает, как кто-то мог бы подумать.

Размер указателя определяется реализацией языка, которая следует выбранному ABI.

ABI (Application Binary Interface) - это набор правил, по которым определяется соглашение о вызовах, разметка объектов в памяти и прочие вещи, которые должны взаимодействовать друг с другом на низком уровне.

Фактически размер указателя определяется именно ABI, а не операционной системой или архитектурой - но я допускаю использование со своей стороны следующей формулировки:

Размер указателя фактически гарантируется связкой Windows+x64, потому что это уже устоявшаяся реальность, которая не меняется уже десяток лет и в случайный момент времени ваш процессор или операционная система не могут поменять размеры типов.

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

Этот момент начинает иметь значение при низкоуровневой разработке при взаимодействии с памятью - и то, не во всех случаях.

Internal читы

Когда мы пишем internal-софт мы не кастуем указатели к целочисленным типам за отсутствием необходимости, потому что логика происходит внутри адресного пространства другого процесса и нам достаточно использовать соответствующие типы, например тот же самый int*.

External читы

При external разработке мы работаем с адресами и указателями из памяти другого процесса, тут неизбежно придётся читать значения адресов из памяти.

В этот момент как раз таки и встаёт этот вопрос о типе, который должен быть для этого использован.

Забегая вперёд, можно сказать что для этого все используют типы uint64_t, uintptr_t (и в некоторых случаях uint32_t), о чём мы ещё поговорим.

Основные причины

Разница в размере указателей

Размер указателя в современных решениях был выбран не случайно.

До появления процессоров с архитектурой x64 была архитектура x32 с размером указателя 4 байта.

Вы до сих пор можете скомпилировать 32-х разрядное приложение, запустить его в режиме совместимости и получить следующий результат

int main() {
	std::cout << sizeof(int*) << std::endl;
}
4

Это накладывает ограничение на максимальный объём адресуемой памяти, то есть при 2^32 мы получаем 4 294 967 296 байт - примерно 4 гигабайта.

Очевидно, что в текущих реалиях это уже для телефона становится маленьким числом в плане оперативной памяти.

По этой причине эта архитектура была улучшена до x64 и в этом случае максимальный объем оперативной памяти 2^64 и это получается 18 446 744 073 709 551 616 байт, то есть 16 EiB, однако реальные процессоры и ОС используют меньшее количество адресных бит.

Да, это число ещё не скоро станет узким горлышком.

Недостаточность размера указателя

Исходя из того, что размер указателя в этом контексте составляет 8 байт, то есть он с большой вероятностью будет превышать диапазон допустимых значений 0x00000000 - 0xFFFFFFFF.

Что может быть подтверждено следующим кодом

int a = 1;
int* p = &a;
std::cout << p << std::endl;
000000A55130F8E0

Допустим нам повезло и это просто стечение обстоятельств.

Давайте сделаем подобное действие это десять раз.

for (size_t i = 0; i < 10; i++)
{
	int* p = new int;
	std::cout << p << std::endl;
	delete p;
}
00000211AAD61470
00000211AAD62F40
00000211AAD62EB0
00000211AAD62F40
00000211AAD62FA0
00000211AAD62E00
00000211AAD62E20
00000211AAD62E30
00000211AAD62EE0
00000211AAD62F20

Как мы видим что в рамках разных запусков, что в рамках выделения памяти внутри одного процесса мы получаем адреса, которые превышают максимально возможное значение.

Что же будет если мы попытаемся записать эти данные, очевидно превышающие по объёму 4 байта в эти самые 4 байта типа int?

int a = 1;
int* p = &a;
uint64_t ptrValue8Bytes = (uint64_t)p;
int ptrValue4Bytes = (int)p;
std::cout << std::hex;
std::cout << p << std::endl;
std::cout << ptrValue8Bytes << std::endl;
std::cout << ptrValue4Bytes << std::endl;
0000004A678FFD30
4a678ffd30
678ffd30

Тут я объявляю переменную a, от типа которой ничего не зависит.

Далее указатель p на её адрес.

Потом я кастую этот адрес к типу int приводя его в числовое значение, а так же к типу uint64_t.

Переключаю состояние потока вывода в hex, чтобы числовые значения у нас отображались в шестнадцатеричной системе, вывожу значение указателя p, а так же содержимое переменных ptrValue4Bytes и ptrValue8Bytes.

Что мы видим?

Значение указателя 0000004A678FFD30 и эквивалентное ему значение 4a678ffd30, что является содержимым переменной uint64_t ptrValue8Bytes с размером 8 байт.

Помимо этого мы наблюдаем значение переменной int ptrValue4Bytes - число 678ffd30, которое является результатом преобразования адреса занимающего более чем 4 байта в тип с размером 4 байта.

То есть происходит усечение с отбрасыванием старших бит.

Это поведение ожидаемо и очевидно для Windows+x64.

Мощность множеств

Если подходить к этому вопросу с точки зрения комбинаторики, то всё так же суть остаётся очевидной.

У нас есть множество всех возможных значений 2^64 для 8 байт и множество всех возможных значений 2^32 для 4 байт в рамках одного значения адреса типа uint64_t.

Обозначим множество 2642^{64} как AA, а подмножество 2322^{32} как BB, где A=264,B=232\lvert A \rvert = 2^{64}, \lvert B \rvert = 2^{32}.

Каст указателя uint64_t к int это, по сути, отображение:

f:ABf : A \rightarrow B

которое не является взаимно-однозначным и вот почему.

Поскольку:

A=264232=B|A| = 2^{64} \gg 2^{32} = |B|

то, по принципу Дирихле, такое отображение не может быть инъективным.

Инъективность требует, чтобы для любых:

a1,a2A,a1a2a_1, a_2 \in A,\quad a_1 \neq a_2

выполнялось:

f(a1)f(a2)f(a_1) \neq f(a_2)

но при:

A>B|A| > |B|

это невозможно - элементов в области определения стало больше, чем возможных значений, а значит хотя бы два разных элемента AA обязаны отобразиться в один и тот же элемент BB.

Это фундаментальное ограничение, вытекающее из теории множеств и теории информации - нельзя взаимно-однозначно закодировать большее множество в меньшее без потерь.

Специфические модели указателей

Часть битов указателя в определённых случаях может быть зарезервирована под дополнительные данные, что допускается за счёт отсутствия запрета в стандарте C++.

Например в технологии CHERI.

Обычно указатель - это просто адрес, но в случае CHERI указатель в своём object representation содержит не только адрес в памяти (value representation), но и дополнительные данные, которые позволяют процессору контролировать использование указателя.

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

Но дело в том, что это не касается Windows+x64, потому что для MSVC/Windows+x64 value representation эквивалентно object representation - все биты участвуют в хранении значения.

Когда это всё таки возможно

Давайте подумаем - когда же всё таки такое возможно?

Случайность

Даже если в теории у вас получится так что все адреса в программе будут при запуске выделены в диапазон 0x00000000 - 0xFFFFFFFF никто не даст вам гарантию, что при следующем они будут выделены в этом же диапазоне и вы не получите какую-либо ошибку.

Но встаёт вопрос - а может ли вообще так произойти?

Как мы видели выше - этого не происходит, кроме этого за этим стоят механизмы ASLR и прочие технические особенности.

То есть на случайность в этом случае рассчитывать не стоит.

Задуманная реализация

Вы можете спроектировать свою реализацию программы так, что у вас значения указателей будут каким-то образом сжиматься или разбиваться на два и хранится в нескольких экземплярах типа int, но это не будет поводом для обоснования использования типа int для хранения указателя в Windows+x64 в общем случае.

Никто не будет так делать в основной реализации функционала и код так не пишут.

Режим совместимости x86

Как я уже говорил, мы можем скомпилировать код в режиме совместимости с x32 - WOW64, Windows 32-bit On Windows 64-bit.

int a = 1;
int* p = &a;
uint64_t ptrValue8Bytes = (uint64_t)p;
int ptrValue4Bytes = (int)p;
std::cout << std::hex;
std::cout << p << std::endl;
std::cout << ptrValue8Bytes << std::endl;
std::cout << ptrValue4Bytes << std::endl;
009FFA84
9ffa84
9ffa84

В этом случае все адрес может быть записан в тип int размером 4 байта.

Но большая часть рынка это Windows+x64 и Android+ARM, а не Windows+x32.

Следовательно возможность есть, но вам всё равно придётся использовать операционную систему и режим, в котором int занимает 4 байта, а указатель может быть 8 байт.

Другие ABI, архитектуры, операционные системы

Стандарт C++ не гарантирует размер указателя или размер типа int.

Размер типа int и большинства других типов зависит от ABI.

Один из примеров различия в размере типов между ABI это тип long на Windows x64 и Linux x64.

На Linux x64 sizeof(long) это 8 байт, а на Windows x64 sizeof(long) это 4 байта.

Но на тип int это не влияет - его размер составляет 4 байта в обоих случаях.

Теоретически может существовать какое-то ABI, ОС и компиляторы, где для хранения int будет выделено 8 байт, но такого на данный момент не существует и скорее всего - примеры не скоро появятся.

Следует ли из этого, что можно в типе int гарантированно хранить указатель в общем случае на Windows+x64?

Конечно нет, к ситуации Windows+x64 это не имеет никакого отношения.

Фактическое положение дел

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

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

Общий контекст - использование C++ в разработке софта для игр, которые работают на Windows+x64 или Android+ARM и у вас не получится в здравом уме писать код используя для хранения указателя тип int с размером 4 байта.

Что использовать, если int не подходит

Для хранения адресов из игр или приложений в Windows+x64 нужно использовать соответствующие типы, которые зависят от того - запущено ли приложение в режиме совместимости с x32.

uint32_t

Этот тип предназначен именно для хранения адреса приложений скомпилированных в режиме, где размер указателя составляет 4 байта, самый очевидный пример - игра AssaultCube.

uint64_t / uintptr_t

Эти два типа являются основными по следующим причинам

  • uintptr_t изначально был создан для обеспечения кроссплатформенности и переносимости кода между архитектурами/операционными системами.
  • uint64_t имеет размер 8 байт и в случае Windows+x64 с размером указателя в 8 байт и отсутствием различия в репрезентациях представления - может быть использован для описанной задачи.

Если мы посмотрим на то, как определены эти типы, то мы увидим следующее

typedef unsigned __int64  uintptr_t;
typedef unsigned long long uint64_t;

__int64 и long long в MSVC - это разные типы языка, хотя на платформе Windows+x64 они имеют одинаковый размер и одинаковое представление.

__int64 это расширение Microsoft, появившееся до стандартизации long long в C++.

Когда в языке появился стандартный тип long long, Microsoft не стали делать его псевдонимом __int64.

Вместо этого компилятор поддерживает оба типа.

Исторически Windows SDK определял типы через расширения компилятора - __int64, а потом появился <cstdint>, который определил фиксированные типы через стандартные типы.

Получилось, что разные заголовки используют разные базовые типы.

Однако с точки зрения языка это не обязан быть один и тот же тип, но в контексте Windows+x64 это не имеет значения.

Какой тип использую я?

Я предпочитаю явно указывать размер адреса и использовать uint32_t или uint64_t в зависимости от ситуации.

В случае Windows+x64 и разработке читов, очевидно, нет смысла использовать uintptr_t потому что написанный код пишется для конкретной игры, которая запускается на конкретной операционной системе, выполняющейся на конкретной архитектуре с конкретным ABI, который не меняется в случайный момент времени.

По моим наблюдениям - люди на форумах так же используют оба этих типа.

Пример использования типа uint64_t можно наблюдать в этой ситуации

uint64_t networkable_key(uint64_t pointer)
{
	uint64_t value = memory_interface::read<uint64_t>(pointer + 0x18);
	uint32_t* data = (uint32_t*)&value;
	uint32_t count = 2;

	do
	{
		uint32_t x = *data;
		uint32_t y = (x << 21) | (x >> 11);
		uint32_t result = (y ^ 0xDA053B90) - 0x4D1B2722;
		*data = result;
		data++;
		--count;
	} while (count);

	return Il2cppGetHandle(value);
}

Стоит отметить, что в этой ситуации тип uint64_t используется вместе с типом uint32_t, потому что исходное значение является 64х-битным объектом, но алгоритм обработки работает поблочно по 32 бита.

То есть данные читаются по указателю uint64_t pointer, а уже потом из-за алгоритмической задачи появляется необходимость использовать 32х-битный тип.

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

Тем не менее ситуации с uintptr_t так же присутствуют, несмотря на то, что тут записывается не адрес, а смещение

constexpr uintptr_t GameManager = 0xD4A3048;
constexpr uintptr_t StaticFields = 0xb8;
constexpr uintptr_t Instance = 0x40;
constexpr uintptr_t PrefabPoolCollection = 0x18;
constexpr uintptr_t Dictionary = 0x10;
constexpr uintptr_t Stack = 0x28;
constexpr uintptr_t Size = 0x18;

Заключение

Смело используйте int*, uint64_t и uintptr_t для хранения адресов памяти в Windows+x64.