mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-23 05:25:54 +02:00
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
850 lines
42 KiB
JavaScript
850 lines
42 KiB
JavaScript
// 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)) ',
|
||
'**<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();
|
||
});
|