Memory basics
Практические основы чтения и записи памяти через Memory API в C#-скриптах EyeAuras
Memory basics для Memory API
Эта страница про самую базовую практику: как читать и писать память, что такое static pointer, pointer chain и offsets, как работать со строками и буферами, и какой способ чтения выбирать в C#.
Если коротко, здесь нам нужны всего три идеи:
- есть адрес в памяти
- по этому адресу лежит либо само значение, либо указатель на другое место
- C#-структура должна совпадать с тем, как данные реально лежат в памяти
Если нужен внешний контекст, очень полезны:
С чего начинается работа
Примеры с LocalProcess рассчитаны на верхний уровень C#-скрипта в запущенном EyeAuras с загруженным модулем памяти. Log даёт хост скрипта. В обычном C#-классе передайте логгер и процесс явно. Одна ссылка на SDK не запускает модуль: во встраиваемом хосте сначала нужны запуск приложения, UseMemory() и ожидание EnsureReadyAsync(); подробнее в Embedded SDK.
LocalProcess.ByProcessId(...) возвращает новый ILocalProcess, который освобождает создавший его код. process.Memory используется вместе с этим reader'ом; отдельно закрывать заимствованный сервис хоста не нужно. Эти примеры не проверяют доступность нативных компонентов вашей сборки.
Для первого чтения не нужен адрес из игры. Выделим собственные 16 байт в текущем процессе, запишем HP и скорость, затем прочитаем их через тот же публичный API:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using EyeAuras.Memory;
{
using var process = LocalProcess.ByProcessId(Process.GetCurrentProcess().Id);
var buffer = Marshal.AllocHGlobal(16);
try
{
process.Memory.Write<int>(buffer, 742);
process.Memory.Write<float>(buffer + 0x04, 1.5f);
var hp = process.Memory.Read<int>(buffer);
var speed = process.Memory.Read<float>(buffer + 0x04);
Log.Info($"HP={hp}, speed={speed}");
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
В журнале будут HP 742 и скорость 1.5 с учётом культуры вывода. Marshal здесь только владеет локальным буфером; скалярные значения читает и пишет process.Memory. Не освобождайте буфер, пока операция его использует.
process.Memory в EyeAuras это и есть основной слой для работы с памятью. Через него можно:
- читать числа и структуры
- читать сырые байты
- писать значения обратно
- переходить по pointer chain
Чтение и запись простых значений
Ниже фрагменты чтения предполагают открытый process и действующие адреса.
Помещайте их внутрь блока владения reader из подготовки либо передавайте
reader параметром helper-метода; после закрытия блока его использовать нельзя.
Для простых случаев почти всегда нужны именно эти методы:
Read<T>(address)— прочитать одно значениеRead<T>(address, count)— прочитать массив значенийWrite(address, value)— записать одно значениеWrite(address, spanOrArray)— записать блок значенийRead(address, size)— прочитать сырые байты
Read<T>(address, count) считает элементы T, а Read(address, size) — байты. Адрес у process backend — абсолютный VA. Read/Write сообщают о неудаче исключением, TryRead/TryWrite возвращают bool; это не количество переданных байтов. Эти методы синхронные, без токена отмены.
Этот путь лучше всего подходит для:
int,float,long,bytebool, если вы точно знаете его размер в памяти- простых
struct, которые состоят из чисел, указателей и других таких же простыхstruct
Если внутри структуры уже есть string, обычный byte[] или что-то похожее, лучше смотреть в сторону ReadManaged<T>(...). Ниже будет отдельный раздел про это.
Static pointer, offsets и pointer chain
Что такое static pointer
Static pointer это адрес, который можно стабильно восстановить через что-то более надёжное, чем "просто число из Cheat Engine". Обычно это:
BaseAddress + RVA- экспорт модуля
- адрес глобального объекта внутри конкретного модуля
Иллюстрация для уже открытого process: вход — модуль game.exe и известный для его версии RVA 0x123456, по которому хранится 64-битный указатель. Это не адрес для запуска на произвольной игре. Нужен using EyeAuras.Memory.Scaffolding; для GetProcessModule.
var mainModule = process.GetProcessModule("game.exe");
var playerManagerPtrAddress = new IntPtr(mainModule.BaseAddress.ToInt64() + 0x123456);
var playerManager = process.Memory.ReadPointer(playerManagerPtrAddress);
Смысл здесь в том, что база модуля может меняться между запусками, а RVA внутри той же версии образа остаётся тем же; обновление программы может изменить его. Поэтому мы обычно думаем не так:
- "мне нужен адрес
0x12345678"
а так:
- "мне нужен
game.exe + 0x123456"
Что такое offset
Offset это смещение внутри структуры или объекта.
Если у вас есть:
- базовый адрес игрока
playerBase 0x10доHealth0x18доMana
то чтение выглядит так (фрагмент для уже открытого process и известного абсолютного playerBase):
var health = process.Memory.Read<int>(playerBase + 0x10);
var mana = process.Memory.Read<int>(playerBase + 0x18);
Что такое pointer chain
Pointer chain это ситуация, когда по адресу лежит не само значение, а указатель на следующий объект.
Если совсем по-простому:
- адрес A ведёт к объекту B
- внутри B по offset лежит адрес C
- внутри C уже лежит нужное значение
Типичный маршрут:
- читаем указатель
- прибавляем offset
- снова читаем указатель
- в конце читаем уже нужное значение
Если записать этот маршрут в коде, получится такая идея:
var hp = process.Memory.ReadPointerChain<int>(
(IntPtr)0x140123456,
0x10,
0x20,
0x18);
Адрес и offsets здесь примерные. Смысл не в конкретных числах, а в том, что каждый следующий шаг либо читает pointer, либо в самом конце читает уже обычное значение.
В EyeAuras для этого есть готовые методы:
ReadPointer(...)ReadPointer32(...)ReadPointerChain<T>(...)ReadPointerChain32<T>(...)
Для законченного примера создадим цепочку в собственном буфере. Это верхний уровень скрипта EyeAuras с модулем памяти; вариант рассчитан на 64-битный хост и 64-битные указатели.
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using EyeAuras.Memory;
{
using var process = LocalProcess.ByProcessId(Process.GetCurrentProcess().Id);
var buffer = Marshal.AllocHGlobal(0x40);
try
{
var player = buffer + 0x10;
process.Memory.Write<long>(buffer, player.ToInt64());
process.Memory.Write<int>(player + 0x18, 742);
var health = process.Memory.ReadPointerChain<int>(buffer, 0x18);
Log.Info($"HP={health}");
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
Получим HP=742: helper сначала читает указатель из buffer, затем значение по player + 0x18. В длинной цепочке промежуточные offsets означают разыменование, последний — смещение до T. Нужен хотя бы один offset. Нулевой указатель возвращает default(T); неудачное чтение может бросить исключение. Поэтому нулевой HP сам по себе не доказывает, что цепочка была правильной.
ReadPointer и обычная цепочка читают 64-битные указатели без автоматического определения архитектуры цели. Для 32-битной цепочки используйте ReadPointerChain32<T>: внутри читается uint. Отдельный ReadPointer32 читает int и при преобразовании может расширить знак; для явного беззнакового адреса удобнее process.Memory.Read<uint>(address). Размер IntPtr относится к хосту, а не к целевой структуре.
Строки и кодировки
В Memory API есть три основных helper'а:
ReadString(...)—UTF-8ReadStringA(...)—Encoding.ASCII, без системной ANSI code pageReadStringU(...)—UTF-16 LE(Encoding.Unicode)
Прочитаем свою строку. Запускайте на верхнем уровне скрипта EyeAuras с модулем памяти; буфер принадлежит только этому примеру.
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using EyeAuras.Memory;
{
using var process = LocalProcess.ByProcessId(Process.GetCurrentProcess().Id);
var namePtr = Marshal.AllocHGlobal(64);
try
{
process.Memory.WriteString(namePtr, "Player");
Log.Info(process.Memory.ReadString(namePtr.ToInt64(), 7));
}
finally
{
Marshal.FreeHGlobal(namePtr);
}
}
В журнале будет Player. Читаем 7 байт — шесть букв и завершающий NUL. При записи произвольного текста заранее выделите место под UTF-8 байты и NUL; WriteString не выделяет буфер.
Практические правила здесь такие:
- длина у всех трёх методов измеряется в байтах; по умолчанию 256 байт и
replaceNull=true - для UTF-16 нужен чётный достаточный размер; пустой результат также может означать неудачное чтение, а не пустую строку
- по умолчанию строки обрезаются по
\0 - если в памяти лежит не та кодировка, вы получите "мусор", даже если адрес правильный
WriteString(...)сейчас пишетUTF-8строку с\0на конце
Если строка лежит не отдельно, а внутри структуры фиксированным полем, можно читать её через ReadManaged<T>(...). Для этого есть отдельная страница: C# Struct Layout.
Полезный фон по строкам и marshaling:
Endianness
На обычных Windows-процессах почти всегда всё просто: x86 и x64 — это little-endian.
Это значит, что байты:
01 00 00 00
означают int = 1, а не 16777216.
Для обычного чтения памяти в Windows вам почти никогда не нужно вручную разворачивать байты. Проще говоря: в обычной Windows-игре этот раздел чаще всего можно просто запомнить и жить дальше.
Но если вы:
- читаете дамп с другой архитектуры
- работаете со своим backend'ом
- парсите сетевые или файловые big-endian структуры
тогда endianness уже нужно учитывать явно.
using System.Buffers.Binary;
// Объявленный вход — четыре байта поля в big-endian формате.
var raw = new byte[] { 0x00, 0x00, 0x00, 0x01 };
var bigEndianValue = BinaryPrimitives.ReadInt32BigEndian(raw); // 1
Полезная ссылка:
Буферы и производительность
Самая частая ошибка в memory-скриптах это читать слишком много маленьких кусочков по одному полю.
Плохой маршрут:
- 30 отдельных
Read<int>(...)подряд каждый тик
Обычно лучше:
- одним чтением забрать блок памяти
- уже потом разбирать его локально
Для массива однотипных элементов читайте сразу массив структур. Вот законченный C#-helper: вызывающий код передаёт живой IProcess, абсолютный адрес первого элемента и число элементов, подтверждённые для версии целевой программы.
using System;
using System.Runtime.InteropServices;
using EyeAuras.Memory;
[StructLayout(LayoutKind.Explicit, Size = 0x30)]
public struct ActorEntry
{
[FieldOffset(0x10)] public int Health;
[FieldOffset(0x18)] public int Mana;
[FieldOffset(0x28)] public long TargetId;
}
public static class ActorReader
{
public static ActorEntry[] ReadActors(IProcess process, IntPtr arrayAddress, int count)
=> process.Memory.Read<ActorEntry>(arrayAddress, count);
}
Результат — count локальных записей с шагом 0x30. Здесь layout объявлен явно; это пример формата, а не готовые offsets какой-либо игры. Helper не открывает и не закрывает переданный процесс. Для настоящего байтового формата — файла, пакета или дампа — уместен Span<byte> и явное декодирование, но для обычных скалярных полей это лишняя обёртка.
Под капотом MemoryAccessor уже делает несколько полезных вещей:
- использует
stackallocдля небольших буферов - использует
ArrayPool<byte>для части unmanaged-чтений - старается не плодить лишние временные массивы там, где можно работать через
Span<T>
Но даже с этим batching всё равно важен. Самая быстрая оптимизация в таких задачах обычно не "написать умнее C#", а "сократить количество реальных обращений к памяти процесса".
Многопоточность
Параллельные чтения и чтение одним блоком не гарантируют согласованный снимок: целевой процесс может менять данные во время копирования. Требования к потокам задаёт конкретный backend; наличие параллельного сценария в тестах не доказывает потокобезопасность всех реализаций.
Не меняйте backend и не освобождайте процесс одновременно с чтением. Если нужна согласованность нескольких полей, используйте протокол целевой программы или проверку версии снимка, а не предположение, что одно чтение атомарно.
Какой способ чтения выбрать
Это одно из самых важных различий во всей теме. Но здесь не обязательно запоминать термины, достаточно понять практическое правило.
Вариант 1 - быстрый путь
Сюда относятся:
Read<T>(...)TryRead<T>(...)Read<T>(..., count)Write<T>(...)
Его берите, если структура простая. Например:
- внутри только числа
- указатели
fixedInlineArray- другие такие же простые структуры
И наоборот, этот путь не подходит, если внутри уже есть:
string- обычный
byte[] - другие ссылочные поля
Это прямой путь для T : unmanaged; атрибут MarshalAs сам по себе не делает тип подходящим под это ограничение.
Вариант 2 - гибкий путь
Сюда относятся:
ReadManaged<T>(...)TryReadManaged<T>(...)TryWriteManaged<T>(...)
Его берите, когда структура уже "более C#-шная". Например, внутри есть:
[MarshalAs(UnmanagedType.ByValArray, ...)][MarshalAs(UnmanagedType.ByValTStr, ...)]- фиксированные строковые поля
- layout, который удобно описывать через marshaling
Этот путь заметно гибче, но он медленнее.
Короткое правило:
- если структура простая, берите обычный
Read<T>(...) - если внутри строки,
MarshalAsили более сложный layout, беритеReadManaged<T>(...)
Если сомневаетесь:
- сначала попробуйте описать структуру как простую
- если упираетесь в строки или фиксированные managed-поля, переходите на
ReadManaged<T>(...)
Контракты на этой странице сверены по EyeAuras 20fd3d4; это чтение исходников, без запуска примеров. Полные сигнатуры и ограничения своей версии проверяйте по карте API памяти и SDK/XML reference.