ArkDMA

Overlay DMA en C++ pour ARK: Survival Ascended : lit la mémoire du jeu depuis un second PC via une carte FPGA en PCIe, classe les entités et les liste dans une interface Dear ImGui.

Présentation

ArkDMA est un programme Windows en C++20 qui lit la mémoire d'ARK: Survival Ascended, un jeu sous Unreal Engine 5, depuis un second PC. La lecture passe par une carte FPGA branchée en PCIe sur la machine de jeu, pilotée par DMALibrary (au-dessus de vmm et leechcore), et les données s'affichent dans une interface Dear ImGui sur DirectX 11. C'est un outil de triche : l'utiliser en ligne enfreint les règles du jeu, et je le présente pour son ingénierie. Le projet en est à la lecture. Les entités sont classées et listées dans des tableaux, mais rien n'est encore dessiné sur le jeu, et les modules qui écriraient dans sa mémoire ne sont pas branchés : ESP.hpp et Aimbot.hpp ne sont que des en-têtes.

Lire un autre PC sans geler l'interface

Chaque lecture DMA est un aller-retour PCIe. Appelée depuis la boucle de rendu, la passe de lecture des entités faisait tomber l'affichage à quelques images par seconde. Le fil de rendu ne garde donc que des lectures courtes, comme l'état des touches. Le fil de données, lancé par ArkSDK::StartDataThread, vise 20 Hz avec un repos minimum.

// ~20 Hz visé, avec un repos minimum garanti.
constexpr auto kPeriod  = std::chrono::milliseconds(50);
constexpr auto kMinIdle = std::chrono::milliseconds(15);
const auto rest = (elapsed < kPeriod) ? (kPeriod - elapsed)
                                      : std::chrono::steady_clock::duration(kMinIdle);
std::this_thread::sleep_for(rest < kMinIdle ? kMinIdle : rest);

Sans ce plancher, une passe trop longue relancerait aussitôt la suivante et occuperait le lien en continu. Les listes passent d'un fil à l'autre en instantanés shared_ptr<const vector> possédés par l'appelant, et non plus en référence vers un vecteur que le fil de données pouvait vider en pleine itération.

Une lecture groupée par passe, pas par entité

La version précédente ouvrait une lecture groupée par entité, puis enchaînait jusqu'à sept lectures une à une, soit environ 2600 allers-retours et 538 ms pour 561 entités. ReadClassifiedEntities lit maintenant toute l'image en une lecture groupée par passe, via ma classe ScatterBatch : les champs de base, ce que la passe précédente révèle (position, niveau), les en-têtes des chaînes, puis leur contenu. Le commit qui l'introduit mesure 39 ms sur les mêmes entités.

La classe d'un acteur ne change jamais. ClassifyActors ne la lit donc qu'une fois par acteur et ne résout le nom qu'une fois par classe : 177 classes pour 15 492 acteurs sur une capture. Pour les classes Blueprint, dont le nom ne dit pas l'ancêtre C++, ClassifyByHierarchy remonte les classes parentes, un scatter par niveau pour toutes les classes en attente. Un échec passager n'est plus figé : chaque classe a droit à 60 essais, environ trois secondes.

Des lectures qui prouvent ce qu'elles lisent

Une lecture DMA ratée rend zéro, comme une valeur nulle légitime. ArkSDK::Init distingue donc le module illisible, l'adresse hors de l'image, le monde en chargement et l'offset périmé.

ResolveFName traduit les index de noms du moteur en texte, avec un pas entre entrées qui varie selon la version d'Unreal : je teste 2, 4 et 8 octets et ne retiens un pas qu'après seize noms lisibles, là où un seul octet tombé juste suffisait avant.

Les raccourcis du PC de jeu lisaient d'abord le clavier dans la mémoire noyau, un chemin que chaque mise à jour majeure de Windows cassait. GameInput.cpp lit maintenant la table d'état des touches que le jeu tient lui-même. Absente du dump, elle est reconnue à l'exécution par sa forme de table de hachage, son pas est mesuré plutôt que supposé, et sa disposition vérifiée avant toute publication.

Les offsets vivent dans Offsets.hpp, daté et sourcé, et les globaux du moteur sont d'abord cherchés par signature (PatternScan::Resolve), avec repli sur la valeur notée. À la dernière mise à jour du jeu, j'ai comparé plus de 1060 champs sur 19 classes entre deux dumps : seuls les globaux du module avaient bougé.

Une interface qui signale ses propres fautes

Deux fautes ImGui invisibles m'ont coûté une session chacune : une zone cliquable soumise avant une autre qu'elle recouvre lui vole le survol, et une zone calculée depuis une origine nulle s'affiche au bon endroit mais répond ailleurs. Toutes les zones passent par UI::Hit, qui reconnaît ces signatures et la taille nulle et les signale une fois dans le journal.

Visit() énumère une seule fois les champs de la configuration, que deux visiteurs, Writer et Reader, écrivent et relisent en JSON. Une seule liste de 182 couleurs dans Colors.hpp produit l'énumération, les défauts, la sérialisation et l'arbre du sélecteur, et une ligne dans le catalogue ACT_FLAGS suffit à rendre un réglage assignable à une touche. Dans les Explorers, une colonne sans donnée affiche un tiret plutôt qu'une valeur inventée.

Des compteurs à zéro et des bugs silencieux

Dans Advanced Settings, les réglages de stride s'enregistrent sans que la lecture les consulte, et certains compteurs de Telemetry.hpp ne sont jamais écrits : ils affichent zéro plutôt qu'un chiffre inventé. Le champ d'équipe, déjà lu, reste marqué non confirmé. Aucun test automatisé : tout se valide à la main, en jeu et dans le journal. Plusieurs bugs corrigés étaient silencieux : une touche qui ne se déclenche jamais, un nom de touche sauté définitivement. Ce que j'en retiens : rendre l'échec visible dès la première version, et écrire la lecture groupée dès le départ plutôt que de la rapporter après coup.