mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-27 08:07:12 +01:00
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare - Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check - Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches - Code cheats with labels, variables, references and shared code; rule builder; script commands - Cheats kept on by the PS5 without the browser - Learn section with twelve lessons and an in-page practice game - Section (master code) community cheats; SHN and MC4 export - Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything - MCP server: 25 tools - Quick start, wiki, contributor and security guides, changelog
414 lines
20 KiB
JavaScript
414 lines
20 KiB
JavaScript
// C++ class names from RTTI (src/lib/rtti.ts), against a made-up game whose
|
|
// vtables and type_info records are laid out the way clang lays them out.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { FakeProcess, noise, R, RW, RX } from './engines-memory.mjs';
|
|
import { MemoryReader, createCppHelper, demangleTypeName, detectCpp } from '../src/lib/rtti.ts';
|
|
|
|
const CODE = 0x400000n;
|
|
const HEAP = 0x1000000000n;
|
|
|
|
/**
|
|
* A small C++ game. Classes are given as { mangled, bases: [names], vtables }.
|
|
* `rtti: false` builds it the way a game compiled without RTTI looks: the
|
|
* same vtables with nothing where the type_info pointer would be.
|
|
*/
|
|
function buildGame(opts = {}) {
|
|
const rtti = opts.rtti !== false;
|
|
const p = new FakeProcess();
|
|
p.map('executable', CODE, 0x10000, RX);
|
|
const rodata = p.map('executable', 0x410000n, 0x10000, R);
|
|
const relro = p.map('executable', 0x420000n, 0x10000, R);
|
|
const data = p.map('executable', 0x430000n, 0x10000, RW);
|
|
p.map('libc.prx', 0x80000000n, 0x4000, RX);
|
|
const libc = p.map('libc.prx', 0x80004000n, 0x4000, R);
|
|
const heap = p.map('', HEAP, 0x40000, RW);
|
|
// Reserved, unreadable memory right after the heap, as on the console.
|
|
p.map('anon:reserved', HEAP + 0x40000n, 0x10000, 0);
|
|
p.map('', HEAP + 0x50000n, 0x10000, RW);
|
|
|
|
// The three kinds of type_info are told apart by their own vtable, which lives in libc.
|
|
const kind = { plain: 0x80004010n, single: 0x80004110n, multi: 0x80004210n };
|
|
let nextFn = CODE + 0x1000n;
|
|
const fn = () => (nextFn += 0x40n);
|
|
|
|
const classes = new Map();
|
|
const define = (name, mangled, bases = []) => {
|
|
const namePtr = p.alloc(rodata, mangled.length + 1, 1);
|
|
p.ascii(namePtr, mangled);
|
|
let ti = 0n;
|
|
if (rtti || opts.keep?.includes(name)) {
|
|
if (bases.length === 0) {
|
|
ti = p.alloc(relro, 16);
|
|
p.w64(ti, kind.plain);
|
|
} else if (bases.length === 1) {
|
|
ti = p.alloc(relro, 24);
|
|
p.w64(ti, kind.single);
|
|
p.w64(ti + 16n, classes.get(bases[0]).ti);
|
|
} else {
|
|
ti = p.alloc(relro, 24 + bases.length * 16);
|
|
p.w64(ti, kind.multi);
|
|
p.w32(ti + 16n, 0);
|
|
p.w32(ti + 20n, bases.length);
|
|
bases.forEach((b, i) => {
|
|
p.w64(ti + 24n + BigInt(i * 16), classes.get(b).ti);
|
|
p.w64(ti + 32n + BigInt(i * 16), BigInt(i * 0x20 * 256 + 2));
|
|
});
|
|
}
|
|
p.w64(ti + 8n, namePtr);
|
|
}
|
|
// Main vtable: offset-to-top, type_info, then functions.
|
|
const table = p.alloc(relro, 16 + 4 * 8);
|
|
p.w64(table + 8n, ti);
|
|
for (let i = 0; i < 4; i++) p.w64(table + 16n + BigInt(i * 8), fn());
|
|
const vtables = [table + 16n];
|
|
// A class with two bases has a second vtable for the second base's part of the object.
|
|
if (bases.length > 1) {
|
|
const second = p.alloc(relro, 16 + 2 * 8);
|
|
p.w64(second, -0x20n);
|
|
p.w64(second + 8n, ti);
|
|
for (let i = 0; i < 2; i++) p.w64(second + 16n + BigInt(i * 8), fn());
|
|
vtables.push(second + 16n);
|
|
}
|
|
classes.set(name, { ti, vtables, namePtr });
|
|
return classes.get(name);
|
|
};
|
|
|
|
define('Game::Entity', 'N4Game6EntityE');
|
|
define('Game::Player', 'N4Game6PlayerE', ['Game::Entity']);
|
|
define('Game::Boss', 'N4Game4BossE', ['Game::Player']);
|
|
define('Mixin', '5Mixin');
|
|
define('Game::Hybrid', 'N4Game6HybridE', ['Game::Player', 'Mixin']);
|
|
define('ACharacterBase', '14ACharacterBase');
|
|
define('TArray<int>', '6TArrayIiE');
|
|
for (const extra of opts.extraClasses ?? []) define(extra.name, extra.mangled, extra.bases ?? []);
|
|
|
|
// A class that lives in libc, outside the game's own data.
|
|
const exName = p.alloc(libc, 16, 1) + 0x400n;
|
|
p.ascii(exName, 'St9exception');
|
|
const exTi = 0x80004800n;
|
|
p.w64(exTi, kind.plain);
|
|
p.w64(exTi + 8n, exName);
|
|
const exTable = 0x80004900n;
|
|
p.w64(exTable + 8n, exTi);
|
|
p.w64(exTable + 16n, 0x80000100n);
|
|
classes.set('std::exception', { ti: exTi, vtables: [exTable + 16n] });
|
|
|
|
const objects = {};
|
|
const make = (label, cls, region = heap, size = 0x40) => {
|
|
const at = p.alloc(region, size, 16);
|
|
p.w64(at, classes.get(cls).vtables[0]);
|
|
p.w32(at + 8n, 100);
|
|
objects[label] = at;
|
|
return at;
|
|
};
|
|
make('player1', 'Game::Player');
|
|
make('boss', 'Game::Boss');
|
|
make('player2', 'Game::Player');
|
|
make('hybrid', 'Game::Hybrid');
|
|
make('staticEntity', 'Game::Entity', data);
|
|
make('exception', 'std::exception');
|
|
// Past the unreadable gap.
|
|
const far = HEAP + 0x50100n;
|
|
p.w64(far, classes.get('Game::Player').vtables[0]);
|
|
objects.player3 = far;
|
|
|
|
// Things that are not objects.
|
|
objects.number = p.alloc(heap, 16, 16);
|
|
p.w64(objects.number, 123456789n);
|
|
objects.heapPointer = p.alloc(heap, 16, 16);
|
|
p.w64(objects.heapPointer, objects.player1);
|
|
objects.codePointer = p.alloc(heap, 16, 16);
|
|
p.w64(objects.codePointer, CODE + 0x1040n);
|
|
// Points into static data, at something shaped like a vtable whose "type_info" is a plain string.
|
|
const fakeTable = p.alloc(relro, 32);
|
|
const text = p.alloc(rodata, 32, 8);
|
|
p.ascii(text, 'Just some text, not a type');
|
|
p.w64(fakeTable + 8n, text);
|
|
p.w64(fakeTable + 16n, fn());
|
|
objects.lookalike = p.alloc(heap, 16, 16);
|
|
p.w64(objects.lookalike, fakeTable + 16n);
|
|
|
|
return { p, classes, objects, heap, data };
|
|
}
|
|
|
|
test('makes mangled class names readable and refuses text that is not one', () => {
|
|
assert.equal(demangleTypeName('N4Game6PlayerE'), 'Game::Player');
|
|
assert.equal(demangleTypeName('14ACharacterBase'), 'ACharacterBase');
|
|
assert.equal(demangleTypeName('St9exception'), 'std::exception');
|
|
assert.equal(demangleTypeName('NSt3__112basic_stringIcEE'), 'std::__1::basic_string<char>');
|
|
assert.equal(demangleTypeName('6TArrayIiE'), 'TArray<int>');
|
|
assert.equal(demangleTypeName('4TMapI7FStringP6AActorE'), 'TMap<FString, AActor*>');
|
|
assert.equal(demangleTypeName('N2UE4Core6TArrayIN4Game6PlayerELi4EEE'), 'UE::Core::TArray<Game::Player, 4>');
|
|
assert.equal(demangleTypeName('N12_GLOBAL__N_15LocalE'), '(anonymous namespace)::Local');
|
|
assert.equal(demangleTypeName('*N4Game6PlayerE'), 'Game::Player');
|
|
// Too involved to read (a back-reference): shown as stored rather than wrongly.
|
|
assert.equal(demangleTypeName('N3FooI3BarS1_EE'), 'N3FooI3BarS1_EE');
|
|
// So is a class declared inside a function, and a template over a function type.
|
|
assert.equal(demangleTypeName('ZN4Game4initEvE5Local'), 'ZN4Game4initEvE5Local');
|
|
assert.equal(demangleTypeName('NSt3__18functionIFvvEEE'), 'NSt3__18functionIFvvEEE');
|
|
assert.equal(demangleTypeName('NSt3__110shared_ptrIN4Game6PlayerEEE'), 'std::__1::shared_ptr<Game::Player>');
|
|
assert.equal(demangleTypeName('5ArrayIfLm16EE'), 'Array<float, 16>');
|
|
assert.equal(demangleTypeName('4FlagILb1EE'), 'Flag<true>');
|
|
|
|
for (const junk of ['', 'x', 'Hello world', '12', '4ab', 'N4Game', '0abc', '5ab cd', 'Just some text, not a type', '\u0001\u0002', '9', 'P6Player']) {
|
|
assert.equal(demangleTypeName(junk), null, JSON.stringify(junk));
|
|
}
|
|
});
|
|
|
|
test('a game with RTTI: classes, parents and objects are found', async () => {
|
|
const g = buildGame();
|
|
const ctx = g.p.ctx();
|
|
const helper = await detectCpp(ctx);
|
|
assert.ok(helper, 'detected');
|
|
assert.equal(helper.engine, 'cpp');
|
|
assert.match(helper.label, /RTTI, 7 classes/);
|
|
assert.equal(helper.stats.classes, 7);
|
|
assert.equal(helper.stats.vtablesWithNames, 8);
|
|
assert.deepEqual(helper.stats.modules, ['executable']);
|
|
|
|
assert.deepEqual(await helper.describeObject(g.objects.player1), { address: g.objects.player1, className: 'Game::Player' });
|
|
assert.deepEqual(await helper.describeObject(g.objects.boss), { address: g.objects.boss, className: 'Game::Boss' });
|
|
assert.equal((await helper.describeObject(g.objects.staticEntity)).className, 'Game::Entity');
|
|
assert.equal((await helper.describeObject(g.objects.player3)).className, 'Game::Player');
|
|
// The second base's part of a Hybrid still says Hybrid.
|
|
assert.equal((await helper.describeObject(g.objects.hybrid)).className, 'Game::Hybrid');
|
|
|
|
const boss = await helper.classInfo('Boss');
|
|
assert.equal(boss.name, 'Boss');
|
|
assert.equal(boss.fullName, 'Game::Boss');
|
|
assert.equal(boss.parent, 'Game::Player');
|
|
assert.deepEqual(boss.parents, ['Game::Player', 'Game::Entity']);
|
|
assert.deepEqual(boss.fields, []);
|
|
assert.equal(boss.address, g.classes.get('Game::Boss').ti);
|
|
assert.equal(boss.mangled, 'N4Game4BossE');
|
|
assert.deepEqual(boss.vtables, g.classes.get('Game::Boss').vtables);
|
|
|
|
const hybrid = await helper.classInfo('game::hybrid');
|
|
assert.deepEqual(hybrid.parents, ['Game::Player', 'Game::Entity']);
|
|
assert.equal(hybrid.vtables.length, 2);
|
|
assert.equal(hybrid.vtables[0], g.classes.get('Game::Hybrid').vtables[0]);
|
|
|
|
// By type_info address, by vtable address, and by an object of the class.
|
|
assert.equal((await helper.classInfo(g.classes.get('Game::Player').ti)).fullName, 'Game::Player');
|
|
assert.equal((await helper.classInfo(g.classes.get('Game::Player').vtables[0])).fullName, 'Game::Player');
|
|
assert.equal((await helper.classInfo(g.objects.boss)).fullName, 'Game::Boss');
|
|
assert.equal((await helper.classInfo('N4Game6EntityE')).fullName, 'Game::Entity');
|
|
assert.equal((await helper.classInfo('Entity')).parent, undefined);
|
|
assert.equal(await helper.classInfo('Nothing'), null);
|
|
assert.equal(await helper.classInfo(''), null);
|
|
assert.equal(await helper.classInfo(g.objects.number), null);
|
|
|
|
const found = await helper.findClasses('play');
|
|
assert.deepEqual(found.map((c) => c.fullName), ['Game::Player']);
|
|
assert.deepEqual((await helper.findClasses('GAME')).map((c) => c.name).sort(), ['Boss', 'Entity', 'Hybrid', 'Player']);
|
|
assert.equal((await helper.findClasses('game', 2)).length, 2);
|
|
assert.deepEqual((await helper.findClasses('tarray')).map((c) => c.fullName), ['TArray<int>']);
|
|
assert.deepEqual(await helper.findClasses('zzz'), []);
|
|
assert.equal(g.p.badReads, 0);
|
|
});
|
|
|
|
test('describeObject says nothing rather than something wrong', async () => {
|
|
const g = buildGame();
|
|
const helper = await detectCpp(g.p.ctx());
|
|
const o = g.objects;
|
|
assert.equal(await helper.describeObject(o.number), null, 'a number');
|
|
assert.equal(await helper.describeObject(o.heapPointer), null, 'a pointer to the heap');
|
|
assert.equal(await helper.describeObject(o.codePointer), null, 'a pointer to code');
|
|
assert.equal(await helper.describeObject(o.lookalike), null, 'a vtable-like table whose type slot is plain text');
|
|
assert.equal(await helper.describeObject(o.player1 + 8n), null, 'the middle of an object');
|
|
assert.equal(await helper.describeObject(o.player1 + 1n), null, 'an odd address');
|
|
assert.equal(await helper.describeObject(0n), null);
|
|
assert.equal(await helper.describeObject(0x7000000000n), null, 'unmapped');
|
|
assert.equal(await helper.describeObject(HEAP + 0x40000n), null, 'unreadable');
|
|
assert.equal(await helper.describeObject(CODE), null, 'code');
|
|
// Noise over a whole page of heap: nothing in it is an object.
|
|
const junk = g.p.alloc(g.heap, 0x4000, 0x4000);
|
|
const [r, off] = g.p.at(junk, 0x4000);
|
|
noise(r.bytes.subarray(off, off + 0x4000), 7);
|
|
for (let i = 0; i < 0x4000; i += 0x208) assert.equal(await helper.describeObject(junk + BigInt(i)), null);
|
|
assert.equal(g.p.badReads, 0);
|
|
});
|
|
|
|
test('a class in another module is still named when an object points at it', async () => {
|
|
const g = buildGame();
|
|
const helper = await detectCpp(g.p.ctx());
|
|
assert.deepEqual(await helper.describeObject(g.objects.exception), { address: g.objects.exception, className: 'std::exception' });
|
|
// It is not in the game's own list, which only covers the game's executable.
|
|
assert.deepEqual(await helper.findClasses('exception'), []);
|
|
// Asking for that module by name lists it.
|
|
const libc = await createCppHelper(g.p.ctx(), { modules: ['libc.prx'], force: true });
|
|
assert.deepEqual((await libc.findClasses('exception')).map((c) => c.fullName), ['std::exception']);
|
|
});
|
|
|
|
test('findObjects searches writable memory for the matching vtables', async () => {
|
|
const g = buildGame();
|
|
const helper = await detectCpp(g.p.ctx());
|
|
const players = await helper.findObjects('Player');
|
|
assert.deepEqual(
|
|
players.map((o) => o.address).sort(),
|
|
[g.objects.player1, g.objects.player2, g.objects.player3].sort(),
|
|
);
|
|
assert.ok(players.every((o) => o.className === 'Game::Player'));
|
|
assert.equal(helper.lastObjectSearch.complete, true);
|
|
assert.equal(helper.lastObjectSearch.scannedBytes, helper.lastObjectSearch.totalBytes);
|
|
|
|
// Several classes at once: one pass, results told apart by vtable.
|
|
const game = await helper.findObjects('Game::');
|
|
const byClass = {};
|
|
for (const o of game) byClass[o.className] = (byClass[o.className] ?? 0) + 1;
|
|
assert.deepEqual(byClass, { 'Game::Player': 3, 'Game::Boss': 1, 'Game::Hybrid': 1, 'Game::Entity': 1 });
|
|
// The game's own data is searched first, so its static object leads.
|
|
assert.equal(game[0].address, g.objects.staticEntity);
|
|
|
|
assert.equal((await helper.findObjects('Player', 2)).length, 2);
|
|
assert.deepEqual(await helper.findObjects('ACharacterBase'), [], 'a class with no objects');
|
|
assert.deepEqual(await helper.findObjects('NoSuchClass'), []);
|
|
assert.equal(g.p.badReads, 0);
|
|
|
|
// A size limit stops the search early and says so.
|
|
const capped = await createCppHelper(g.p.ctx(), { maxObjectScanBytes: 0x10000 });
|
|
const some = await capped.findObjects('Player');
|
|
assert.equal(capped.lastObjectSearch.complete, false);
|
|
assert.equal(capped.lastObjectSearch.scannedBytes, 0x10000);
|
|
assert.deepEqual(some, [], 'the heap was never reached');
|
|
});
|
|
|
|
test('a game built without RTTI gives no helper', async () => {
|
|
const stripped = buildGame({ rtti: false });
|
|
assert.equal(await detectCpp(stripped.p.ctx()), null);
|
|
|
|
// The usual real case: thousands of unnamed vtables and a few named ones
|
|
// from a library linked in. A helper that names 3 classes in 40 is no use.
|
|
const extraClasses = [];
|
|
for (let i = 0; i < 40; i++) extraClasses.push({ name: `Engine${i}`, mangled: `${`Engine${i}`.length}Engine${i}` });
|
|
const mostly = buildGame({ rtti: false, keep: ['Game::Entity', 'Mixin', 'ACharacterBase'], extraClasses });
|
|
assert.equal(await detectCpp(mostly.p.ctx()), null);
|
|
const forced = await createCppHelper(mostly.p.ctx(), { force: true });
|
|
assert.equal(forced.stats.vtablesWithNames, 3);
|
|
assert.ok(forced.stats.vtablesWithoutNames >= 40);
|
|
assert.equal(await forced.describeObject(mostly.objects.player1), null);
|
|
});
|
|
|
|
test('noise and empty processes give no helper', async () => {
|
|
const p = new FakeProcess();
|
|
p.map('executable', CODE, 0x8000, RX);
|
|
const ro = p.map('executable', 0x408000n, 0x20000, R);
|
|
const rw = p.map('executable', 0x428000n, 0x8000, RW);
|
|
noise(ro.bytes, 11);
|
|
noise(rw.bytes, 12);
|
|
assert.equal(await detectCpp(p.ctx()), null);
|
|
|
|
// Pointer-shaped noise: every word points somewhere into the module's own data.
|
|
for (let o = 0; o < ro.bytes.length; o += 8) ro.dv.setBigUint64(o, 0x408000n + BigInt((o * 7919) % 0x1fff8), true);
|
|
assert.equal(await detectCpp(p.ctx()), null);
|
|
|
|
const empty = new FakeProcess();
|
|
assert.equal(await detectCpp(empty.ctx()), null);
|
|
});
|
|
|
|
test('reading is done in few, large pieces and stays inside readable memory', async () => {
|
|
const g = buildGame();
|
|
const ctx = g.p.ctx();
|
|
const helper = await detectCpp(ctx);
|
|
// Building the class list read the executable's three data maps and nothing else.
|
|
assert.ok(g.p.roundTrips <= 4, `${g.p.roundTrips} reads to build the list`);
|
|
const before = g.p.roundTrips;
|
|
await helper.findClasses('Player');
|
|
await helper.classInfo('Boss');
|
|
assert.equal(g.p.roundTrips, before, 'answers about classes come from the list');
|
|
await helper.describeObject(g.objects.player1);
|
|
await helper.describeObject(g.objects.player2);
|
|
assert.ok(g.p.roundTrips - before <= 1, 'two neighboring objects share one read');
|
|
|
|
// The reader itself: a read that runs off the end of readable memory is
|
|
// refused instead of being sent (the console would answer with zeros).
|
|
const mem = new MemoryReader(ctx);
|
|
await mem.maps();
|
|
assert.equal(await mem.read(HEAP + 0x3fff0n, 0x20), null);
|
|
assert.equal(mem.isReadable(HEAP + 0x3fff0n, 0x10), true);
|
|
assert.equal(mem.isReadable(HEAP + 0x40000n), false);
|
|
// fetch() across the gap returns the readable parts and zeros between.
|
|
g.p.w64(HEAP + 0x3fff8n, 0x1122334455667788n);
|
|
const across = await mem.fetch(HEAP + 0x3fff8n, 0x10110);
|
|
assert.equal(new DataView(across.buffer).getBigUint64(0, true), 0x1122334455667788n);
|
|
assert.equal(new DataView(across.buffer).getBigUint64(0x10108, true), g.classes.get('Game::Player').vtables[0]);
|
|
// readMany groups neighbors, and gives null for pieces that are not readable.
|
|
const t = g.p.roundTrips;
|
|
const many = await mem.readMany([
|
|
{ address: g.objects.player1, length: 8 },
|
|
{ address: 0x7000000000n, length: 8 },
|
|
{ address: g.objects.player3, length: 8 },
|
|
{ address: g.objects.boss, length: 8 },
|
|
{ address: HEAP + 0x3fffcn, length: 8 },
|
|
]);
|
|
assert.equal(new DataView(many[0].buffer, many[0].byteOffset).getBigUint64(0, true), g.classes.get('Game::Player').vtables[0]);
|
|
assert.equal(many[1], null);
|
|
assert.equal(new DataView(many[2].buffer, many[2].byteOffset).getBigUint64(0, true), g.classes.get('Game::Player').vtables[0]);
|
|
assert.equal(new DataView(many[3].buffer, many[3].byteOffset).getBigUint64(0, true), g.classes.get('Game::Boss').vtables[0]);
|
|
assert.equal(many[4], null);
|
|
assert.equal(g.p.roundTrips - t, 2, 'two groups: before the gap and after it');
|
|
assert.equal(g.p.badReads, 0);
|
|
|
|
// With the bulk command, all pieces travel in one exchange.
|
|
const bulk = buildGame();
|
|
const bulkMem = new MemoryReader(bulk.p.ctx({ bulk: true }));
|
|
await bulkMem.maps();
|
|
const t2 = bulk.p.roundTrips;
|
|
const reqs = [];
|
|
for (const k of ['player1', 'boss', 'player2', 'hybrid', 'player3', 'staticEntity']) reqs.push({ address: bulk.objects[k], length: 8 });
|
|
const got = await bulkMem.readMany(reqs);
|
|
assert.equal(bulk.p.roundTrips - t2, 1);
|
|
assert.equal(new DataView(got[1].buffer, got[1].byteOffset).getBigUint64(0, true), bulk.classes.get('Game::Boss').vtables[0]);
|
|
assert.equal(bulk.p.badReads, 0);
|
|
});
|
|
|
|
test('cached pages are shared within a burst of questions and dropped after a pause', async (t) => {
|
|
t.mock.timers.enable({ apis: ['Date'] });
|
|
const g = buildGame();
|
|
const ctx = g.p.ctx();
|
|
const mem = new MemoryReader(ctx);
|
|
await mem.maps();
|
|
const at = g.objects.player1;
|
|
const first = async () => new DataView((await mem.read(at, 8)).buffer).getBigUint64(0, true);
|
|
const vt = g.classes.get('Game::Player').vtables[0];
|
|
|
|
mem.begin();
|
|
assert.equal(await first(), vt);
|
|
const trips = g.p.roundTrips;
|
|
// The game changes the value; a burst still sees what it read a moment ago, at no cost.
|
|
g.p.w64(at, 0x1234n);
|
|
t.mock.timers.tick(100);
|
|
mem.begin();
|
|
assert.equal(await first(), vt);
|
|
assert.equal(await first(), vt);
|
|
assert.equal(g.p.roundTrips, trips);
|
|
// A burst that goes on and on does not keep a page for ever.
|
|
for (let i = 0; i < 25; i++) {
|
|
t.mock.timers.tick(100);
|
|
mem.begin();
|
|
await mem.read(at + 0x100n, 8);
|
|
}
|
|
assert.equal(await first(), 0x1234n);
|
|
// After a pause the next question starts clean.
|
|
g.p.w64(at, vt);
|
|
t.mock.timers.tick(100);
|
|
mem.begin();
|
|
assert.equal(await first(), 0x1234n, 'still within the burst');
|
|
t.mock.timers.tick(1000);
|
|
mem.begin();
|
|
assert.equal(await first(), vt);
|
|
|
|
// The helper built on it: an object that changes class is seen after a pause.
|
|
const helper = await detectCpp(ctx);
|
|
assert.equal((await helper.describeObject(g.objects.boss)).className, 'Game::Boss');
|
|
g.p.w64(g.objects.boss, g.classes.get('Game::Entity').vtables[0]);
|
|
t.mock.timers.tick(1000);
|
|
assert.equal((await helper.describeObject(g.objects.boss)).className, 'Game::Entity');
|
|
g.p.w64(g.objects.boss, 0n);
|
|
t.mock.timers.tick(1000);
|
|
assert.equal(await helper.describeObject(g.objects.boss), null, 'freed and zeroed');
|
|
});
|