Files
Tiago Valente 08c9abb573 CheatSpecter 0.4.1
A quality pass after 0.4.0 was tried on a real console.

- Fix the Learn page crashing in current browsers when changing lesson steps
- Clear feedback for every search outcome; real progress with time left and Cancel
- Dialogs scroll inside the screen with Save and Cancel always in view
- Fix a console error on every closed connection to the payload
- Copy buttons work when the page is served from the PS5
- The practice game sits beside the lesson's tools and lasts between steps
- Damaged cheat files, vanishing error messages, unreadable memory shown as zeros
- Phone layout: one-line header, larger switches, readable rows
- Browser tests under test/ui and a guard against effects that return values
2026-10-10 15:19:56 -07:00

850 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Learn section without a screen: the lesson format and its checker, the
// text markup staying inert, downloading and progress, the practice game
// under the real tools (scanner, table runner, tracer, pointer scan), and a
// "perfect learner" who completes every practice step of every lesson with
// the same code the "Show me" button runs.
//
// Nothing here opens a port: the practice game answers the debugger's
// protocol from inside the process.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { checkLessons } from '../scripts/check-lessons.mjs';
import {
LEARN_URL,
LearnError,
LessonFormatError,
LessonLibrary,
ProgressStore,
answerMatches,
checkGame,
checkIndex,
checkLesson,
emptyProgress,
goalParts,
mergeProgress,
parseText,
readProgress,
} from '../src/lib/learn.ts';
import { PracticeGame, practiceTitle } from '../src/lib/practice/game.ts';
import { PracticeConsole, connectPractice } from '../src/lib/practice/console.ts';
import { GoalTracker, patience, showMe } from '../src/lib/practice/coach.ts';
import { AutoScan, ScanSession, SnapshotScan, compareKind, encodeValue, scannableRegions, valueKind } from '../src/lib/scanner.ts';
import { createX86, asAsync } from '../src/lib/x86.ts';
import { createGameContext, resolveLocation } from '../src/lib/locations.ts';
import { Tracer } from '../src/lib/tracer.ts';
import { nopEntry, valueEntry } from '../src/lib/make.ts';
import { TableRunner } from '../src/lib/table.ts';
import { WriteJournal } from '../src/lib/journal.ts';
import { findPointerPaths } from '../src/lib/pointerscan.ts';
import { bytesToHex } from '../src/lib/bytes.ts';
const require = createRequire(import.meta.url);
const x86 = asAsync(createX86(require('iced-x86')));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const quiet = () => {};
const U32 = valueKind('u32');
const read = (file) => JSON.parse(fs.readFileSync(new URL(`../learn/${file}`, import.meta.url), 'utf8'));
const clone = (v) => JSON.parse(JSON.stringify(v));
// A game with a bit of everything: a number behind a pointer, a decimal, copies, drifters, an update.
const RANGE = {
name: 'Test Range',
objects: [
{ id: 'world', static: true, fields: [{ id: 'player', type: 'pointer', to: 'player', offset: 24 }] },
{ id: 'player', fields: [{ id: 'health', type: 'float', value: 100 }, { id: 'ammo', type: 'u32', value: 30, offset: 40 }, { id: 'shots', type: 'u32', value: 0, offset: 44 }] },
],
actions: [
{ id: 'fire', label: 'Fire', effects: [{ field: 'player.ammo', op: 'sub', by: 1, min: 0, refill: 30 }, { field: 'player.shots', op: 'add', by: 1 }] },
{ id: 'hit', label: 'Take a hit', effects: [{ field: 'player.health', op: 'sub', by: 7.5, min: 0, refill: 100 }] },
],
hud: [{ label: 'Ammo', field: 'player.ammo', show: 'number' }],
decoys: [{ type: 'u32', value: 30, count: 12 }],
copies: [{ of: 'player.ammo', count: 3, follow: 'fade' }],
drifting: 6,
update: true,
};
/** A practice game with the tools pointed at it, wired the way the lesson screen wires them. */
async function bench(def, titleId = 'CSPR00001') {
const game = new PracticeGame(checkGame(def), titleId);
const con = new PracticeConsole(game);
const session = await connectPractice(con);
const b = {
con,
game,
session,
entries: [],
lines: [],
journal: new WriteJournal(),
ctx: null,
runner: null,
rebuild() {
// What the app does when the game restarts: everything tied to the old process is dropped.
b.runner?.dispose();
b.ctx = createGameContext({ pid: game.pid, dbg: () => session.main, scanDbg: () => session.scan, x86 });
b.runner = new TableRunner(b.ctx);
b.journal = new WriteJournal();
},
mem: { read: (a, n) => session.main.read(game.pid, a, n), write: (a, bytes) => session.main.write(game.pid, a, bytes) },
cheats: () => b.entries.map((entry) => ({ entry, on: b.runner.isOn(entry.id) })),
openDebugger: () => session.openDebugger(),
openEvents: () => session.openEvents(),
add: async (entry, opts = {}) => {
b.entries.push(entry);
if (opts.enable !== false) await b.runner.enable(entry);
},
replace: async (entry) => {
const old = b.entries.find((e) => e.id === entry.id);
if (old && b.runner.isOn(old.id)) await b.runner.disable(old);
b.entries = b.entries.map((e) => (e.id === entry.id ? entry : e));
},
toggle: (entry, on) => (on ? b.runner.enable(entry) : b.runner.disable(entry)),
write: (address, bytes) => b.journal.write(b.mem, address, bytes),
restore: async () => {
for (const e of b.entries) await b.runner.disable(e);
await b.journal.restore(b.mem);
},
say: (line) => b.lines.push(line),
close() {
b.runner.dispose();
session.close();
con.close();
},
};
b.rebuild();
con.subscribe((e) => (e.type === 'restart' || e.type === 'update') && b.rebuild());
return b;
}
const regionsOf = async (b) => scannableRegions(await b.ctx.maps(true));
const until = async (cond, what, ms = 4000) => {
for (const end = Date.now() + ms; Date.now() < end; ) {
if (await cond()) return;
await wait(15);
}
throw new Error(`timed out waiting for ${what}`);
};
// ---- the lesson format ----------------------------------------------------------
test('every lesson in learn/ passes the checker, and the index agrees with the files', () => {
const { problems, lessons, index } = checkLessons();
assert.deepEqual(problems, []);
assert.equal(lessons.length, 12);
assert.deepEqual(index.courses.map((c) => c.lessons.length), [5, 4, 3]);
// Each practical lesson ends on a step the learner does alone, in a game with other numbers than the guided one.
for (const l of lessons) {
const practice = l.steps.filter((s) => s.kind === 'practice');
if (practice.length === 0) continue;
if (l.id === 'how-games-remember' || l.id === 'good-habits') continue; // look-and-see lessons: no method to repeat alone
assert.ok(practice.some((s) => s.challenge), `${l.id} has a challenge`);
}
for (const l of lessons) for (const s of l.steps) if (s.kind === 'practice') assert.ok(s.hints.length >= 1 && s.done, `${l.id}: ${s.title}`);
});
test('the checker reads the words too: app names, places on screen, spelling', () => {
const dir = fs.mkdtempSync(new URL('../node_modules/.learn-check-', import.meta.url).pathname);
try {
fs.cpSync(new URL('../learn/', import.meta.url), dir, { recursive: true });
const file = `${dir}/lessons/find-a-number.json`;
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
const said = (change) => {
const copy = clone(doc);
change(copy);
fs.writeFileSync(file, JSON.stringify(copy));
return checkLessons(dir).problems.join('\n');
};
assert.equal(said(() => {}), '');
// A button that was renamed in the app and not in the lesson.
assert.match(said((d) => (d.steps[2].text = d.steps[2].text.replace('**Narrow down**', '**Refine**'))), /steps\[2\]: "Refine" is in bold like a button/);
// The game's own buttons and readouts count as names.
assert.equal(said((d) => (d.steps[1].text += ' Press **Fire** and watch **Ammo**.')), '');
// Numbers to type are not names.
assert.equal(said((d) => (d.steps[3].text += ' Or **123**.')), '');
assert.match(said((d) => (d.steps[1].hints[0] += ' It is at the bottom of the window.')), /hints\[0\]: "at the bottom of the window": do not say where/);
assert.match(said((d) => (d.steps[0].text += ' Its colour changes.')), /steps\[0\].text: "colour": use US spelling/);
assert.match(said((d) => (d.steps[4].explain += ' Not for online games.')), /steps\[4\].explain: "online"/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the checker refuses what does not fit, and says where', () => {
const good = read('lessons/find-a-number.json');
assert.equal(checkLesson(good).id, 'find-a-number');
const broken = (change, where) => {
const doc = clone(good);
change(doc);
assert.throws(() => checkLesson(doc), (e) => e instanceof LessonFormatError && e.message.includes(where), where);
};
broken((d) => (d.format = 'something-else'), 'lesson.format');
broken((d) => (d.script = 'alert(1)'), 'unknown part "script"');
broken((d) => (d.steps[0].html = '<b>x</b>'), 'lesson.steps[0]: has an unknown part "html"');
broken((d) => (d.steps[0].kind = 'run'), 'lesson.steps[0].kind');
broken((d) => (d.steps[0].text = 42), 'lesson.steps[0].text');
broken((d) => (d.steps[0].text = 'x'.repeat(7000)), 'longer than');
broken((d) => (d.steps = []), 'lesson.steps');
broken((d) => (d.id = '../../etc/passwd'), 'lesson.id');
broken((d) => (d.steps[1].game = 'nowhere'), 'no game called');
broken((d) => (d.steps[1].goal.field = 'player.gold'), 'no number called');
broken((d) => (d.steps[1].goal.check = 'eval'), 'not a kind of goal');
broken((d) => (d.steps[1].goal.onDone = 'x'), 'unknown part "onDone"');
broken((d) => (d.steps[1].goal = { check: 'all', of: [d.steps[1].goal, { check: 'all', of: [] }] }), 'cannot be inside another');
broken((d) => (d.steps[1].goal = { check: 'held', label: 'x', field: 'player.shots', action: 'nope', times: 3 }), 'no action called');
broken((d) => (d.steps[1].goal = { check: 'updated', label: 'x' }), 'no update to install');
broken((d) => (d.steps[4].answer = 9), 'lesson.steps[4].answer');
broken((d) => (d.games.range.actions[0].effects[0].field = 'player.missing'), 'there is no field');
broken((d) => (d.games.range.actions[0].effects[0].run = 'x'), 'unknown part "run"');
broken((d) => (d.games.range.objects[0].fields[0].offset = 41), 'multiple of 4');
broken((d) => (d.games.range.objects[0].fields[1].offset = 40), 'overlaps');
broken((d) => (d.games.range.decoys[0].count = 1e6), 'between 1 and 200');
broken((d) => (d.games.range.objects = new Array(9).fill(d.games.range.objects[0])), 'between 1 and 8');
// Lessons for a newer app are told apart from broken ones.
const newer = clone(good);
newer.version = 2;
assert.throws(() => checkLesson(newer), (e) => e instanceof LearnError && e.problem === 'newer');
assert.throws(() => checkIndex({ format: 'cheatspecter-lessons', version: 1, edition: 'x', courses: [] }), /index.courses/);
assert.throws(() => checkIndex(null), /should be an object/);
assert.throws(() => checkGame({ ...RANGE, objects: [{ id: 'a', fields: [{ id: 'p', type: 'pointer', to: 'ghost' }] }] }), /points to an object/);
// One piece of code cannot do two different things.
const twoJobs = clone(RANGE);
twoJobs.actions[0].effects[0].site = 'shared';
twoJobs.actions[1].effects[0].site = 'shared';
assert.throws(() => checkGame(twoJobs), /sharing a site/);
});
test('lesson text: only the small markup becomes structure, everything else stays text', () => {
assert.deepEqual(parseText('One **bold** and `code`.\nSame paragraph.\n\n# Heading\n\n- a\n- b\n\n1. first\n2. second\n\n> tip: careful\n> now'), [
{ t: 'p', spans: [{ s: 'One ' }, { s: 'bold', b: true }, { s: ' and ' }, { s: 'code', c: true }, { s: '. Same paragraph.' }] },
{ t: 'h', spans: [{ s: 'Heading' }] },
{ t: 'ul', items: [[{ s: 'a' }], [{ s: 'b' }]] },
{ t: 'ol', items: [[{ s: 'first' }], [{ s: 'second' }]] },
{ t: 'tip', spans: [{ s: 'careful now' }] },
]);
// Hostile text is still just text: the parser has no way to say "element", "link" or "image".
const hostile = [
'<script>window.pwned = 1</script>',
'<img src=x onerror="window.pwned = 1">',
'[click](javascript:alert(1)) ![x](https://evil.example/x.png)',
'**<iframe src="https://evil.example">**',
'`</code><script>alert(1)</script>`',
'> warning: <a href="javascript:alert(1)">x</a>',
'- <svg onload=alert(1)>',
'# <style>body{display:none}</style>',
].join('\n\n');
const blocks = parseText(hostile);
const kinds = new Set(['p', 'h', 'ul', 'ol', 'note', 'tip', 'warning']);
let text = '';
for (const b of blocks) {
assert.ok(kinds.has(b.t));
for (const span of b.t === 'ul' || b.t === 'ol' ? b.items.flat() : b.spans) {
assert.deepEqual(Object.keys(span).filter((k) => !['s', 'b', 'c'].includes(k)), []);
assert.equal(typeof span.s, 'string');
text += span.s;
}
}
for (const piece of ['<script>window.pwned = 1</script>', 'onerror="window.pwned = 1"', 'javascript:alert(1)', '<iframe src="https://evil.example">', '<svg onload=alert(1)>']) {
assert.ok(text.includes(piece), `kept as characters: ${piece}`);
}
assert.equal(answerMatches(['jmp'], ' JMP. '), true);
assert.equal(answerMatches(['jmp'], 'jne'), false);
assert.equal(answerMatches(['jmp'], ''), false);
});
// ---- downloading and progress ------------------------------------------------------
const response = (status, body) => ({ status, ok: status >= 200 && status < 300, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), json: async () => body });
test('downloading: GitHub by default, a local copy when asked, and each way it can fail', async () => {
assert.equal(LEARN_URL, 'https://raw.githubusercontent.com/TiagoVal97/cheatspecter/main/learn/');
const index = read('index.json');
const lesson = read('lessons/hold-it.json');
const asked = [];
const github = (files) => async (url, init) => {
asked.push(url);
assert.equal(init.credentials, 'omit');
const name = url.startsWith(LEARN_URL) ? url.slice(LEARN_URL.length) : url;
return name in files ? files[name]() : response(404, '');
};
// Published: the index, then a lesson, each downloaded once.
let lib = new LessonLibrary({ fetch: github({ 'index.json': () => response(200, index), 'lessons/hold-it.json': () => response(200, lesson) }) });
assert.equal((await lib.loadIndex()).from, 'github');
assert.equal((await lib.loadLesson('hold-it')).title, 'Change it and hold it');
await lib.loadIndex();
await lib.loadLesson('hold-it');
assert.deepEqual(asked, [LEARN_URL + 'index.json', LEARN_URL + 'lessons/hold-it.json']);
await assert.rejects(lib.loadLesson('../index'), (e) => e.problem === 'invalid');
await assert.rejects(lib.loadLesson('pointers'), (e) => e.problem === 'unpublished');
// Not published yet, offline, and a server error: told apart, and never remembered.
let mode = 'missing';
lib = new LessonLibrary({
fetch: async () => {
if (mode === 'offline') throw new TypeError('Failed to fetch');
return mode === 'missing' ? response(404, 'Not Found') : mode === 'down' ? response(503, '') : response(200, index);
},
});
await assert.rejects(lib.loadIndex(), (e) => e instanceof LearnError && e.problem === 'unpublished' && /not on CheatSpecter’s GitHub page yet/.test(e.message));
mode = 'offline';
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /connected to the internet/.test(e.message));
mode = 'down';
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /503/.test(e.message));
mode = 'ok';
assert.equal((await lib.loadIndex()).index.courses.length, 3, 'trying again works once it can');
// One lesson failing after the list arrived is said to be about that lesson, not about all of them.
let lessonMode = 'missing';
lib = new LessonLibrary({
fetch: async (url) => {
if (url.endsWith('index.json')) return response(200, index);
if (lessonMode === 'offline') throw new TypeError('Failed to fetch');
return lessonMode === 'missing' ? response(404, 'Not Found') : lessonMode === 'down' ? response(500, '') : response(200, lesson);
},
});
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'unpublished' && /^This lesson is in the list/.test(e.message) && !/GitHub page yet/.test(e.message));
lessonMode = 'offline';
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'offline' && /^This lesson did not download/.test(e.message));
lessonMode = 'down';
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'offline' && /500/.test(e.message) && /instead of the lesson\./.test(e.message));
lessonMode = 'ok';
assert.equal((await lib.loadLesson('hold-it')).id, 'hold-it', 'and it opens once it can be fetched');
// A download that breaks off partway is a connection problem, not a broken lesson.
lib = new LessonLibrary({ fetch: async () => ({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) }) });
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /partway/.test(e.message));
// A connection that stalls: "Downloading…" ends, with something to do next, and trying again works.
let stalled = true;
lib = new LessonLibrary({ patienceMs: 40, fetch: (url) => (stalled ? new Promise(() => {}) : Promise.resolve(response(200, url.endsWith('index.json') ? index : lesson))) });
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /taking too long/.test(e.message));
stalled = false;
assert.equal((await lib.loadLesson('hold-it')).id, 'hold-it');
// Things that are not lessons.
for (const body of ['<html>captive portal</html>', '{"format":"cheatspecter-lessons"}', JSON.stringify({ ...index, extra: 1 }), 'x'.repeat(300 * 1024)]) {
lib = new LessonLibrary({ fetch: async () => response(200, body) });
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'invalid' && /was not opened/.test(e.message));
}
lib = new LessonLibrary({ fetch: github({ 'index.json': () => response(200, index), 'lessons/pointers.json': () => response(200, lesson) }) });
await assert.rejects(lib.loadLesson('pointers'), (e) => e.problem === 'invalid' && /different lesson/.test(e.message));
// Development and demo: the copy served next to the app wins, and GitHub is not asked.
asked.length = 0;
lib = new LessonLibrary({ preferLocal: true, fetch: github({ 'learn/index.json': () => response(200, index), 'learn/lessons/hold-it.json': () => response(200, lesson) }) });
assert.equal((await lib.loadIndex()).from, 'local');
await lib.loadLesson('hold-it');
assert.deepEqual(asked, ['learn/index.json', 'learn/lessons/hold-it.json']);
// No local copy: straight on to GitHub.
lib = new LessonLibrary({ preferLocal: true, fetch: github({ 'index.json': () => response(200, index) }) });
assert.equal((await lib.loadIndex()).from, 'github');
});
test('progress: kept by the server when it can, in the browser when it cannot, and never lost in a merge', async () => {
assert.deepEqual(readProgress('nonsense'), emptyProgress());
assert.deepEqual(readProgress({ lessons: 'maybe', done: { 'find-a-number': { steps: 3.7, finished: 1 }, '../x': { steps: 1 }, bad: 'x' }, at: 5 }), {
v: 1,
lessons: null,
done: { 'find-a-number': { steps: 3 } },
at: 5,
});
const a = { v: 1, lessons: 'on', done: { one: { steps: 4, finished: true }, two: { steps: 1 } }, at: 10 };
const b = { v: 1, lessons: 'off', done: { two: { steps: 3 }, three: { steps: 2 } }, at: 20 };
assert.deepEqual(mergeProgress(a, b), { v: 1, lessons: 'off', done: { one: { steps: 4, finished: true }, two: { steps: 3 }, three: { steps: 2 } }, at: 20 });
assert.equal(mergeProgress(a, { ...emptyProgress(), at: 30 }).lessons, 'on', 'an undecided copy does not undo a choice');
// A server with the prefs store.
let stored = null;
const calls = [];
const server = async (url, init = {}) => {
calls.push(`${init.method ?? 'GET'} ${url}`);
if (init.method === 'PUT') {
stored = JSON.parse(init.body);
return response(204, '');
}
return stored ? response(200, stored) : response(404, '');
};
let store = new ProgressStore({ fetch: server });
assert.deepEqual(await store.load(), emptyProgress());
store.save(a);
store.save({ ...a, at: 11 });
await until(() => stored !== null, 'the save to reach the server');
assert.equal(stored.at, 11, 'a burst of changes is one request');
assert.deepEqual(calls, ['GET api/prefs/learn-progress', 'PUT api/prefs/learn-progress']);
assert.equal((await new ProgressStore({ fetch: server }).load()).done.one.finished, true, 'read back on another device');
// A server without it (the bridge today): asked once, then left alone.
calls.length = 0;
store = new ProgressStore({ fetch: async (url, init = {}) => (calls.push(init.method ?? 'GET'), response(init.method === 'PUT' ? 405 : 404, '')) });
await store.load();
store.save(a);
await wait(500);
store.save(a);
await wait(500);
assert.deepEqual(calls, ['GET', 'PUT']);
// Served by something known not to keep settings: not asked at all, so nothing shows up as a failed request.
calls.length = 0;
store = new ProgressStore({ fetch: async (url, init = {}) => (calls.push(init.method ?? 'GET'), response(400, '')) });
assert.deepEqual(await store.load(false), emptyProgress());
store.save(a);
await wait(500);
assert.deepEqual(calls, []);
// No server at all.
store = new ProgressStore({ fetch: async () => { throw new TypeError('Failed to fetch'); } });
assert.deepEqual(await store.load(), emptyProgress());
// A server that never answers: the Learn section still opens, and the server's copy is left alone for the visit.
calls.length = 0;
store = new ProgressStore({ patienceMs: 40, fetch: (url, init = {}) => (calls.push(init.method ?? 'GET'), new Promise(() => {})) });
const began = Date.now();
assert.deepEqual(await store.load(), emptyProgress());
assert.ok(Date.now() - began < 1000, 'it does not wait for good');
store.save(a);
await wait(500);
assert.deepEqual(calls, ['GET'], 'nothing is written over a copy that could not be read');
});
// ---- the practice game under the real tools -------------------------------------------
test('the real scanner narrows down to the planted value, and the console knows what the list holds', async () => {
const b = await bench(RANGE);
const { game, con, session } = b;
assert.equal(session.extended, true);
assert.deepEqual(await session.currentGame(), { pid: game.pid, titleId: 'CSPR00001', version: '01.00', contentId: game.contentId });
const ammo = game.address('player.ammo');
const scan = new ScanSession(() => session.scan, game.pid, U32);
await scan.first(await regionsOf(b), compareKind('exact'), encodeValue(U32, '30'), quiet);
assert.ok(scan.matches >= 16, `decoys, copies and the real one: ${scan.matches}`);
assert.equal(con.lastScan.size, scan.matches);
assert.ok(con.lastScan.has(ammo));
// The game's own copies follow for a round or two, so it takes a few shots.
const counts = [];
for (let v = 29; scan.matches > 1; v--) {
con.press('fire');
await scan.next(compareKind('exact'), encodeValue(U32, String(v)), quiet);
counts.push(scan.matches);
assert.equal(con.lastScan.size, scan.matches);
}
assert.deepEqual(counts, [4, 3, 2, 1]);
assert.deepEqual((await scan.hits(5)).map((h) => h.address), [ammo]);
assert.deepEqual([...con.lastScan], [ammo]);
// A wrong number empties the list, and a new search starts over.
await scan.next(compareKind('exact'), encodeValue(U32, '5'), quiet);
assert.equal(con.lastScan.size, 0);
const again = new ScanSession(() => session.scan, game.pid, U32);
await again.first(await regionsOf(b), compareKind('exact'), encodeValue(U32, String(game.get('player.ammo'))), quiet);
assert.ok(con.lastScan.has(ammo));
// A decimal is not found as a whole number; Auto finds it and says what it is.
const auto = new AutoScan(() => session.scan, game.pid);
auto.input('100');
await auto.first(await regionsOf(b), compareKind('exact'), new Uint8Array(0), quiet);
con.press('hit');
auto.input('92.5');
await auto.next(compareKind('exact'), new Uint8Array(0), quiet);
assert.deepEqual((await auto.hits(5)).map((h) => [h.address, h.kind.id]), [[game.address('player.health'), 'float']]);
assert.deepEqual([...con.lastScan], [game.address('player.health')]);
// No number at all: everything, then went down / stayed the same.
const snap = new SnapshotScan(() => session.scan, game.pid, valueKind('float'));
await snap.first(await regionsOf(b), compareKind('unknown'), new Uint8Array(0), quiet);
assert.ok(snap.matches > 30000);
assert.equal(con.lastScan.size, snap.matches);
con.press('hit');
game.drift();
await snap.next(compareKind('decreased'), new Uint8Array(0), quiet);
await snap.next(compareKind('unchanged'), new Uint8Array(0), quiet);
con.press('hit');
await snap.next(compareKind('decreased'), new Uint8Array(0), quiet);
assert.ok(snap.matches <= 3, String(snap.matches));
assert.ok(con.lastScan.has(game.address('player.health')));
await snap.end();
b.close();
});
test('freezing holds a value against the game, and only that value', async () => {
const b = await bench(RANGE);
const { game, con } = b;
const entry = await valueEntry(b.ctx, game.address('player.ammo'), U32, '99');
assert.equal(entry.at.kind, 'address', 'a value on the heap has no lasting position');
await b.runner.enable(entry);
assert.equal(game.get('player.ammo'), 99);
con.press('fire');
assert.equal(game.get('player.ammo'), 98, 'the game still takes one away');
await until(() => game.get('player.ammo') === 99, 'the freeze to put it back');
assert.equal(game.get('player.shots'), 1, 'the rest of the action happened');
await b.runner.disable(entry);
con.press('fire');
await wait(300);
assert.equal(game.get('player.ammo'), 98);
b.close();
});
test('the tracer catches the instruction that writes, and no-ops stop the effect', async () => {
const b = await bench(RANGE);
const { game, con, session } = b;
const tracer = new Tracer(b.ctx, { openDebugger: () => session.openDebugger(), openEvents: () => session.openEvents() });
let hits = [];
await tracer.start(game.address('player.ammo'), 4, 'write', (h) => (hits = h));
con.press('fire');
await until(() => hits.length === 1 && hits[0].count === 1, 'the first shot to be caught');
con.press('fire');
await until(() => hits[0].count === 2, 'the second');
assert.equal(game.get('player.shots'), 2, 'the game carried on after each stop');
// A second debugger is refused while this one is attached, as on a console.
const other = await session.openDebugger();
await assert.rejects(other.debugAttach(game.pid), /already attached/);
await tracer.stop();
assert.equal(hits[0].insn.text, 'sub dword ptr [rbx+0x28], 1');
assert.equal(hits[0].regs.rbx + 0x28n, game.address('player.ammo'), 'RBX points at the player object');
assert.ok(con.caught.has(game.address('player.ammo')));
// The code around it reads like a small function.
const site = game.siteOf('fire');
const code = await session.main.read(game.pid, game.imageBase + BigInt(site.offset), site.bytes.length);
assert.deepEqual((await x86.disassemble(code, game.imageBase + BigInt(site.offset))).map((i) => i.text.replace(/0x5EC7\w+/, 'TAG')), [
'push rbp', 'mov rbp, rsp', 'mov rbx, rdi', 'mov eax, TAG', 'sub dword ptr [rbx+0x28], 1', 'pop rbp', 'ret',
]);
const entry = await nopEntry(b.ctx, hits[0].insn);
assert.equal(entry.at.kind, 'signature', 'found by the code around it');
assert.equal(entry.off, '836B2801');
await b.runner.enable(entry);
assert.equal(game.siteState(site), 'off');
const before = game.get('player.ammo');
con.press('fire');
con.press('fire');
assert.equal(game.get('player.ammo'), before, 'firing costs nothing');
assert.equal(game.get('player.shots'), 4, 'and still counts as a shot: only the one instruction is gone');
await b.runner.disable(entry);
assert.equal(game.siteState(site), 'intact');
con.press('fire');
assert.equal(game.get('player.ammo'), before - 1);
// A decimal is written by a three-instruction group; the tracer reports the store.
await tracer.start(game.address('player.health'), 4, 'write', (h) => (hits = h));
con.press('hit');
await until(() => hits.length === 1, 'the hit to be caught');
await tracer.stop();
assert.equal(hits[0].insn.text, 'movss [rbx+0x10], xmm0');
b.close();
});
test('restart moves the values and puts the code back; a held address goes stale, a patch can be switched on again', async () => {
const b = await bench(RANGE);
const { game, con, session } = b;
const where = game.address('player.ammo');
const held = await valueEntry(b.ctx, where, U32, '99');
const tracer = new Tracer(b.ctx, { openDebugger: () => session.openDebugger(), openEvents: () => session.openEvents() });
let hits = [];
await tracer.start(where, 4, 'write', (h) => (hits = h));
con.press('fire');
await until(() => hits.length === 1, 'the shot');
await tracer.stop();
const patch = await nopEntry(b.ctx, hits[0].insn);
await b.runner.enable(patch);
const pid = game.pid;
con.restart();
assert.equal(game.pid, pid + 1);
assert.notEqual(game.address('player.ammo'), where);
assert.equal(game.get('player.ammo'), 30);
assert.equal(game.siteState(game.siteOf('fire')), 'intact', 'the code is loaded fresh');
assert.deepEqual(await session.currentGame().then((g) => g.pid), pid + 1);
// The old address now holds something else, or nothing: holding it does nothing for the ammo.
assert.equal(await resolveLocation(b.ctx, held.at), where);
// The patch finds its instruction again without any searching.
await b.runner.enable(patch);
con.press('fire');
assert.equal(game.get('player.ammo'), 30);
// A pointer path follows the value to wherever it is.
const paths = await findPointerPaths(b.ctx, game.address('player.ammo'), { maxDepth: 2 }, quiet);
assert.deepEqual(paths[0].location, { kind: 'pointer', base: '4058', offsets: ['0x28'] });
con.restart();
assert.equal(await resolveLocation(b.ctx, paths[0].location), game.address('player.ammo'));
// Commands for the process that is gone are refused, as a console refuses a dead pid.
await assert.rejects(session.main.read(pid, where, 4), /failed/);
b.close();
});
test('an update moves the code: a fixed position no longer fits, the pattern still finds it', async () => {
const b = await bench(RANGE);
const { game, con, session } = b;
const site = game.siteOf('fire');
const range = game.siteRange(site);
const [insn] = await x86.disassemble(await session.main.read(game.pid, range.store, 8), range.store, 1);
const patch = await nopEntry(b.ctx, insn);
const fixed = { ...patch, id: 'fixed', at: patch.fallback, fallback: undefined };
assert.equal(fixed.at.kind, 'module');
con.update();
assert.equal(game.version, '01.01');
assert.notEqual(game.siteOf('fire').offset, site.offset);
assert.equal((await session.currentGame()).version, '01.01');
await assert.rejects(b.runner.enable(fixed), /does not match|not what|different/i);
assert.equal(game.siteState(game.siteOf('fire')), 'intact', 'a cheat that does not fit writes nothing');
await b.runner.enable({ ...patch, fallback: undefined });
assert.equal(game.siteState(game.siteOf('fire')), 'off');
con.press('fire');
assert.equal(game.get('player.ammo'), 30);
b.close();
});
test('nonsense written over code crashes the game when that code runs, and a restart brings it back', async () => {
const b = await bench(RANGE);
const { game, con, session } = b;
const events = [];
con.subscribe((e) => events.push(e.type));
const range = game.siteRange(game.siteOf('hit'));
await session.main.write(game.pid, range.start + 1n, Uint8Array.of(0xde, 0xad));
assert.equal(game.siteState(game.siteOf('hit')), 'broken');
con.press('fire');
assert.equal(game.crashed, null, 'other code still runs');
con.press('hit');
assert.match(game.crashed, /overwritten with something that is not an instruction/);
assert.ok(events.includes('crash'));
assert.equal(await session.currentGame(), null, 'no game is running');
con.press('fire');
assert.equal(game.get('player.ammo'), 29, 'nothing happens in a crashed game');
con.restart();
assert.equal(game.crashed, null);
con.press('hit');
assert.equal(game.get('player.health'), 92.5);
// No-ops over only part of an instruction are nonsense too.
await session.main.write(game.pid, game.siteRange(game.siteOf('fire')).store, Uint8Array.of(0x90));
assert.equal(game.siteState(game.siteOf('fire')), 'broken');
b.close();
});
test('goals: counted in order, from the moment each part comes up, and done stays done', async () => {
const b = await bench(RANGE);
const { game, con } = b;
const goal = {
check: 'all',
of: [
{ check: 'pressed', label: 'fire twice', action: 'fire', times: 2 },
{ check: 'restarted', label: 'restart' },
{ check: 'value', label: 'ammo 500', field: 'player.ammo', is: 'atLeast', value: 500 },
{ check: 'held', label: 'hold it', field: 'player.ammo', action: 'fire', times: 2 },
],
};
const tracker = new GoalTracker(goal, b, { settleMs: 320 });
let changes = 0;
tracker.onChange = () => changes++;
const done = () => tracker.items.map((i) => i.done);
con.restart(); // too early: the first part is not done
con.press('fire');
await tracker.check();
assert.deepEqual(done(), [false, false, false, false]);
con.press('fire');
await tracker.check();
assert.deepEqual(done(), [true, false, false, false]);
game.set('player.ammo', 900); // too early as well; it still counts once its turn comes, since it is true then
con.restart();
await tracker.check();
assert.deepEqual(done(), [true, true, false, false]);
await b.write(game.address('player.ammo'), encodeValue(U32, '500'));
await tracker.check();
assert.deepEqual(done(), [true, true, true, false]);
// Held: unfrozen shots do not count, and break the streak.
con.press('fire');
await wait(400);
assert.equal(tracker.done, false);
const entry = await valueEntry(b.ctx, game.address('player.ammo'), U32, '499');
await b.add(entry);
con.press('fire');
await wait(400);
con.press('fire');
await wait(400);
con.press('fire');
await until(() => tracker.done, 'the held goal');
assert.equal(changes, 4);
await b.runner.disable(entry);
con.press('fire');
await wait(400);
assert.equal(tracker.done, true, 'done stays done');
tracker.stop();
b.close();
});
test('goals say how far along they are, and when the search has lost the value', async () => {
const b = await bench(RANGE);
const { game, con } = b;
const regions = await regionsOf(b);
const scan = new ScanSession(() => b.session.scan, game.pid, U32);
// A list that never had the value in it: the learner typed a number the game was not showing.
let tracker = new GoalTracker({ check: 'narrowed', label: 'x', field: 'player.ammo', atMost: 1 }, b, { settleMs: 320 });
assert.equal(tracker.advice(), null, 'nothing to say before any search');
await scan.first(regions, compareKind('exact'), encodeValue(U32, '31'), quiet);
if (con.lastScan.size > 0) assert.match(tracker.advice(), /^Your last search left \d+ match(es)?, and the ammo is not among them\./);
// The right number: the value is in the list, which is merely long. That needs no telling.
await scan.end();
const good = new ScanSession(() => b.session.scan, game.pid, U32);
await good.first(regions, compareKind('exact'), encodeValue(U32, '30'), quiet);
assert.ok(con.lastScan.size > 1);
assert.equal(tracker.advice(), null);
// A wrong answer while narrowing throws the value out, with others still listed (the decoys hold 30).
con.press('fire');
await good.next(compareKind('exact'), encodeValue(U32, '30'), quiet);
assert.ok(con.lastScan.size > 0);
assert.match(tracker.advice(), /the ammo is not among them.*Press New search and start over\.$/);
assert.doesNotMatch(tracker.advice(), /0x|undefined/);
await tracker.check();
assert.equal(tracker.done, false);
tracker.stop();
// A goal about a value the learner gets at through another one is left alone.
tracker = new GoalTracker({ check: 'value', label: 'x', field: 'player.ammo', is: 'atLeast', value: 999 }, b);
assert.equal(tracker.advice(), null);
tracker.stop();
// After a restart the old list means nothing.
tracker = new GoalTracker({ check: 'held', label: 'x', field: 'player.ammo', action: 'fire', times: 3 }, b, { settleMs: 320 });
assert.match(tracker.advice(), /not among them/);
con.restart();
assert.equal(tracker.advice(), null);
// Held: counted shot by shot, and back to nothing when one gets through.
assert.equal(tracker.progress, null);
const entry = await valueEntry(b.ctx, game.address('player.ammo'), U32, '30');
await b.add(entry);
con.press('fire');
await until(() => tracker.progress === '1 of 3', 'the first held shot');
con.press('fire');
await until(() => tracker.progress === '2 of 3', 'the second held shot');
await b.runner.disable(entry);
con.press('fire');
await until(() => tracker.progress === null, 'the streak to break');
tracker.stop();
tracker = new GoalTracker({ check: 'pressed', label: 'x', action: 'fire', times: 3 }, b);
assert.equal(tracker.progress, null);
con.press('fire');
con.press('fire');
assert.equal(tracker.progress, '2 of 3');
tracker.stop();
await good.end();
b.close();
});
// ---- every lesson can be completed ---------------------------------------------------
const { lessons } = checkLessons();
const SETTLE = 340;
for (const lesson of lessons) {
const steps = lesson.steps.filter((s) => s.kind === 'practice');
if (steps.length === 0) continue;
test(`a perfect learner completes "${lesson.title}" (${steps.length} practice steps)`, async () => {
const benches = new Map();
for (const step of steps) {
// As the lesson screen does it: one game per name, kept between steps unless the step asks for a fresh one.
if (step.fresh && benches.has(step.game)) {
benches.get(step.game).close();
benches.delete(step.game);
}
if (!benches.has(step.game)) {
// Under the title the lesson screen uses, so the game is laid out exactly as a learner gets it.
const made = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
made.con.start();
benches.set(step.game, made);
}
const b = benches.get(step.game);
const tracker = new GoalTracker(step.goal, b, { settleMs: SETTLE });
await tracker.check();
for (let guard = 0; !tracker.done; guard++) {
assert.ok(guard < goalParts(step.goal).length + 1, `"${step.title}" is stuck on: ${tracker.current.goal.label}\n${b.lines.join('\n')}`);
const item = tracker.current;
await showMe(item.goal, b, { settleMs: SETTLE });
await until(async () => (await tracker.check(), item.done), `"${step.title}": ${item.goal.label}\n${b.lines.join('\n')}`, patience(item.goal, SETTLE));
}
tracker.stop();
assert.equal(b.game.crashed, null);
assert.ok(b.lines.length > 0, 'Show me says what it did');
}
for (const b of benches.values()) b.close();
});
}
test('the pointer challenge as a learner meets it: the shortest path is a coincidence, and nothing pretends otherwise', async () => {
const lesson = lessons.find((l) => l.id === 'pointers');
const index = lesson.steps.findIndex((s) => s.kind === 'practice' && s.challenge);
const step = lesson.steps[index];
const field = goalParts(step.goal)[0].field;
// The lesson's text and hints warn about this, so it has to stay true of the game as the screen starts it.
let b = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
const kind = valueKind('u32');
const paths = await findPointerPaths(b.ctx, b.game.address(field), { maxDepth: 3 }, quiet);
assert.ok(paths.length >= 2 && paths[0].depth === 1, 'the search offers a one-pointer path first');
assert.ok(step.text.includes('more than one path') && step.hints.some((h) => /coincidence/.test(h) && /2 pointers/.test(h)), 'and the lesson says so');
// Taking the first, as an earlier wording of the hint told the learner to: fine today, wrong after a restart.
const first = await valueEntry(b.ctx, b.game.address(field), kind, '99', paths[0].location);
assert.equal(await resolveLocation(b.ctx, first.at), b.game.address(field));
b.con.restart();
assert.notEqual(await resolveLocation(b.ctx, first.at).catch(() => 0n), b.game.address(field));
b.close();
// "Show me" takes the path the game itself follows, says why, and then uses the cheat rather than writing behind its back.
b = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
const tracker = new GoalTracker(step.goal, b, { settleMs: SETTLE });
for (let guard = 0; !tracker.done; guard++) {
assert.ok(guard < 4, b.lines.join('\n'));
const item = tracker.current;
await showMe(item.goal, b, { settleMs: SETTLE });
await until(async () => (await tracker.check(), item.done), item.goal.label, patience(item.goal, SETTLE));
}
tracker.stop();
const said = b.lines.join('\n');
assert.match(said, /Took the one with 2 pointers, because the shorter one only works by coincidence/);
assert.match(said, /Set ".+" in Your cheats to hold 99, and switched it on\. It followed its pointer path to the lives/);
assert.doesNotMatch(said, /wrote 99 over it/);
const cheat = b.entries.find((e) => e.type === 'value');
assert.deepEqual([cheat.at.kind, cheat.at.offsets.length, cheat.value, b.runner.isOn(cheat.id)], ['pointer', 2, '99', true]);
assert.equal(b.game.get(field), 99);
b.close();
});
test('a challenge about surviving an update only counts a cheat that was made before it', () => {
const lesson = lessons.find((l) => l.id === 'game-updates');
const step = lesson.steps.find((s) => s.kind === 'practice' && s.challenge);
assert.deepEqual(
goalParts(step.goal).map((g) => g.check),
['off', 'updated', 'off', 'held'],
);
});
test('what Show me says is plain: no addresses, no jargon it did not explain', async () => {
const b = await bench(RANGE);
for (const goal of [
{ check: 'narrowed', label: 'x', field: 'player.ammo', atMost: 1 },
{ check: 'value', label: 'x', field: 'player.ammo', is: 'atLeast', value: 999 },
{ check: 'off', label: 'x', action: 'fire' },
{ check: 'restarted', label: 'x' },
{ check: 'saved', label: 'x', type: 'value', field: 'player.ammo', location: 'pointer', named: true, grouped: true },
]) {
await showMe(goal, b, { settleMs: SETTLE });
}
const said = b.lines.join('\n');
assert.match(said, /Searched for 30: \d+ matches\./);
assert.match(said, /Pressed "Fire", then narrowed down with 29: \d+ match/);
assert.match(said, /The instruction that wrote to it: sub dword ptr \[rbx\+0x28\], 1/);
assert.doesNotMatch(said, /0x[0-9A-F]{6,}|undefined|NaN|\[object/);
const saved = b.entries.find((e) => e.type === 'value');
assert.deepEqual([saved.name, saved.group, saved.at.kind], ['Infinite ammo', 'Practice', 'pointer']);
assert.equal(bytesToHex(b.game.read(b.game.siteRange(b.game.siteOf('fire')).store, 4)), '836B2801', 'the restart put the code back');
b.close();
});