@henols/c64-re-tools 2.1.1 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +6 -3
- package/skills/c64-assembler/scripts/assembler.ts +19 -11
- package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
- package/skills/c64-basic/SKILL.md +1 -0
- package/skills/c64-disk/SKILL.md +1 -0
- package/skills/c64-disk/scripts/disk.ts +2 -2
- package/skills/c64-disk/scripts/lib/containers.ts +3 -4
- package/skills/c64-docs/SKILL.md +2 -1
- package/skills/c64-docs/scripts/docs.ts +1 -1
- package/skills/c64-docs/scripts/lib/links.ts +2 -0
- package/skills/c64-emulator/SKILL.md +2 -1
- package/skills/c64-knowledge/SKILL.md +6 -4
- package/skills/c64-knowledge/scripts/knowledge.ts +35 -13
- package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
- package/skills/c64-memory-map/SKILL.md +1 -0
- package/skills/c64-memory-map/scripts/memory-map.ts +1 -1
- package/skills/c64-provenance/SKILL.md +1 -0
- package/skills/c64-provenance/scripts/provenance.ts +9 -6
- package/skills/c64-source/SKILL.md +1 -0
- package/skills/c64-source/scripts/lib/source.ts +7 -29
- package/skills/c64-source/scripts/source.ts +4 -4
- package/skills/c64-static-analysis/SKILL.md +2 -0
- package/skills/c64-static-analysis/scripts/static-analysis.ts +14 -5
- package/skills/c64-testing/SKILL.md +3 -2
- package/skills/c64-testing/scripts/lib/diff-files.ts +14 -2
- package/skills/c64-testing/scripts/lib/recording.ts +3 -3
- package/skills/c64-testing/scripts/lib/replay.ts +1 -1
- package/skills/c64-testing/scripts/lib/runs.ts +27 -7
- package/skills/c64-testing/scripts/testing.ts +5 -5
- package/skills/c64-unpacker/SKILL.md +8 -5
- package/skills/c64-unpacker/scripts/lib/loads.ts +12 -6
- package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
- package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
- package/skills/c64-unpacker/scripts/unpacker.ts +18 -17
- package/src/host/vice/adapter.ts +4 -1
- package/src/host/vice/session.ts +49 -3
- package/src/host-client/image.ts +10 -10
- package/src/host-client/steps.ts +39 -3
- package/src/host-client/transfer.ts +14 -26
- package/src/knowledge/history.ts +55 -2
- package/src/knowledge/import.ts +79 -7
- package/src/knowledge/program.ts +31 -2
- package/src/knowledge/read.ts +7 -4
- package/src/knowledge/schema.ts +3 -2
- package/src/knowledge/text.ts +1 -0
- package/src/knowledge/write.ts +22 -14
- package/src/mcp/inputs.ts +6 -3
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools/debug.ts +29 -3
- package/src/mcp/tools/machine.ts +3 -1
- package/src/mcp/tools/memory.ts +4 -3
- package/src/media.ts +42 -0
- package/src/opcodes.ts +51 -18
- package/src/project.ts +110 -2
- package/src/protocol/vice.ts +4 -1
- package/src/script.ts +55 -3
package/src/knowledge/program.ts
CHANGED
|
@@ -224,13 +224,42 @@ export function requireProgram(db: DatabaseSync | undefined, space: Space = DEFA
|
|
|
224
224
|
throw new Refusal("not-found", `The knowledge has no program${where}. Put it in with knowledge.ts image <image.json|prg>${option} of the c64-knowledge skill.`);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/** The
|
|
227
|
+
/** The pages of an entry state: $0000-$03ff as the capture wrote them, 256 bytes as hex for each page. */
|
|
228
|
+
const STATE_PAGES = ["$0000", "$0100", "$0200", "$0300"] as const;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The memory that the program has at its entry: the segments, and below them
|
|
232
|
+
* $0002-$03ff of the entry state (the zero page, the stack and the system
|
|
233
|
+
* pages hold code and data that the loader left). $0000 and $0001 are the
|
|
234
|
+
* CPU port, not memory. A segment byte wins over a state byte.
|
|
235
|
+
*/
|
|
236
|
+
export function entryMemory(image: ProgramImage): Uint8Array {
|
|
237
|
+
const memory = new Uint8Array(image.memory);
|
|
238
|
+
const pages = image.state?.pages;
|
|
239
|
+
if (typeof pages !== "object" || pages === null) return memory;
|
|
240
|
+
const inSegment = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
|
|
241
|
+
STATE_PAGES.forEach((page, index) => {
|
|
242
|
+
const hex = (pages as Record<string, unknown>)[page];
|
|
243
|
+
if (typeof hex !== "string" || !/^(?:[0-9a-fA-F]{2}){1,256}$/.test(hex)) return;
|
|
244
|
+
Buffer.from(hex, "hex").forEach((byte, offset) => {
|
|
245
|
+
const address = index * 0x100 + offset;
|
|
246
|
+
if (address >= 2 && !inSegment(address)) memory[address] = byte;
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return memory;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The bytes for an analyzer: a PRG for one segment without an entry state,
|
|
254
|
+
* else the 64 KiB with the entry-state pages (entryMemory). DXA, Ghidra and
|
|
255
|
+
* c64-source get the same bytes.
|
|
256
|
+
*/
|
|
228
257
|
export function analyzedBytes(image: ProgramImage): { bytes: Buffer; kind: "prg" | "flat64k" } {
|
|
229
258
|
if (image.segments.length === 1 && image.state === undefined) {
|
|
230
259
|
const [segment] = image.segments;
|
|
231
260
|
return { bytes: Buffer.concat([Buffer.from([segment!.start & 0xff, segment!.start >> 8]), Buffer.from(image.memory.subarray(segment!.start, segment!.end + 1))]), kind: "prg" };
|
|
232
261
|
}
|
|
233
|
-
return { bytes: Buffer.from(image
|
|
262
|
+
return { bytes: Buffer.from(entryMemory(image)), kind: "flat64k" };
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
/**
|
package/src/knowledge/read.ts
CHANGED
|
@@ -255,18 +255,19 @@ export interface CoverageRun {
|
|
|
255
255
|
* The imports of coverage maps that the knowledge has, by name and then oldest
|
|
256
256
|
* first: the last import of a name is the latest. A revert gives back the maps
|
|
257
257
|
* of the revision that it restores, so a map that a revert removed is not known.
|
|
258
|
+
* A revert of one space gives back only the maps of that space.
|
|
258
259
|
* With `at`, the maps of the knowledge at that revision.
|
|
259
260
|
*/
|
|
260
261
|
export function coverageRuns(db: DatabaseSync | undefined, at?: string): CoverageRun[] {
|
|
261
262
|
if (db === undefined || at === NO_REVISION) return [];
|
|
262
263
|
const rows = db
|
|
263
264
|
.prepare(
|
|
264
|
-
`SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
|
|
265
|
+
`SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, r.restored_space AS restoredSpace, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
|
|
265
266
|
FROM revisions r LEFT JOIN evidence e ON e.revision = r.id AND e.kind = 'coverage'
|
|
266
267
|
WHERE (r.operation = 'coverage' AND e.id IS NOT NULL) OR (r.operation = 'revert' AND r.restored_revision IS NOT NULL)
|
|
267
268
|
ORDER BY r.id, e.id`,
|
|
268
269
|
)
|
|
269
|
-
.all() as Array<{ id: string; operation: string; restored: string | null; name: string | null; sha256: string | null; space: Space }>;
|
|
270
|
+
.all() as Array<{ id: string; operation: string; restored: string | null; restoredSpace: Space | null; name: string | null; sha256: string | null; space: Space }>;
|
|
270
271
|
// The maps after each revision that changed them, oldest first.
|
|
271
272
|
const after: Array<{ id: string; runs: CoverageRun[] }> = [];
|
|
272
273
|
let runs: CoverageRun[] = [];
|
|
@@ -274,8 +275,10 @@ export function coverageRuns(db: DatabaseSync | undefined, at?: string): Coverag
|
|
|
274
275
|
if (at !== undefined && row.id > at) break;
|
|
275
276
|
if (row.operation === "revert") {
|
|
276
277
|
const restored = row.restored!;
|
|
277
|
-
|
|
278
|
-
for (const item of after) if (item.id <= restored)
|
|
278
|
+
let then: CoverageRun[] = [];
|
|
279
|
+
for (const item of after) if (item.id <= restored) then = item.runs;
|
|
280
|
+
const only = row.restoredSpace;
|
|
281
|
+
runs = only === null ? then : [...runs.filter((run) => run.space !== only), ...then.filter((run) => run.space === only)];
|
|
279
282
|
} else {
|
|
280
283
|
const run: CoverageRun = { name: row.name!, sha256: row.sha256!, space: row.space, revision: row.id };
|
|
281
284
|
if (!runs.some((item) => item.name === run.name && item.sha256 === run.sha256 && item.space === run.space && item.revision === run.revision)) runs = [...runs, run];
|
package/src/knowledge/schema.ts
CHANGED
|
@@ -114,7 +114,8 @@ function createSchema(db: DatabaseSync): void {
|
|
|
114
114
|
input_hash TEXT,
|
|
115
115
|
tool_version TEXT,
|
|
116
116
|
description TEXT,
|
|
117
|
-
restored_revision TEXT
|
|
117
|
+
restored_revision TEXT,
|
|
118
|
+
restored_space TEXT
|
|
118
119
|
);
|
|
119
120
|
CREATE TABLE symbols (
|
|
120
121
|
id TEXT PRIMARY KEY,
|
|
@@ -317,7 +318,7 @@ function createSchema(db: DatabaseSync): void {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
/** The version of the index this build makes. An index with another version is emptied and made again. */
|
|
320
|
-
export const INDEX_VERSION =
|
|
321
|
+
export const INDEX_VERSION = 8;
|
|
321
322
|
|
|
322
323
|
function indexVersion(db: DatabaseSync): number | undefined {
|
|
323
324
|
const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
|
package/src/knowledge/text.ts
CHANGED
|
@@ -188,6 +188,7 @@ const REVISION_FIELDS: readonly Field[] = [
|
|
|
188
188
|
field("inputHash", "input_hash", "text", true),
|
|
189
189
|
field("toolVersion", "tool_version", "text", true),
|
|
190
190
|
field("restores", "restored_revision", "text", true),
|
|
191
|
+
field("restoresSpace", "restored_space", "text", true),
|
|
191
192
|
];
|
|
192
193
|
|
|
193
194
|
/** The SQL name of a table: "references" is a keyword. */
|
package/src/knowledge/write.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type AddressRange, formatC64Address, instructionLength, SPACE_PATTERN,
|
|
|
8
8
|
import { isoCet } from "../time.ts";
|
|
9
9
|
import { CONFLICT_RULES, conflicts, conflictTable, type Conflict, type ConflictRow } from "./conflicts.ts";
|
|
10
10
|
import { transaction } from "./database.ts";
|
|
11
|
+
import { labelProblem } from "../opcodes.ts";
|
|
11
12
|
import { checkExpression, evaluate, identifiers, operandText, replaceIdentifiers } from "./expression.ts";
|
|
12
13
|
import {
|
|
13
14
|
COMMENT_COLUMNS,
|
|
@@ -140,6 +141,8 @@ export interface ChangeContext {
|
|
|
140
141
|
evidence?: Evidence[];
|
|
141
142
|
/** A revert: the revision whose knowledge it restores. */
|
|
142
143
|
restores?: string;
|
|
144
|
+
/** A revert of one space: the space whose knowledge it restores. Without it, a revert restores every space. */
|
|
145
|
+
restoresSpace?: Space;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
/**
|
|
@@ -181,8 +184,8 @@ export class Change {
|
|
|
181
184
|
const now = new Date();
|
|
182
185
|
const id = newRevisionId(currentRevision(this.#db), now);
|
|
183
186
|
this.#db
|
|
184
|
-
.prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description, restored_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
185
|
-
.run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null, this.#context.restores ?? null);
|
|
187
|
+
.prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description, restored_revision, restored_space) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
188
|
+
.run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null, this.#context.restores ?? null, this.#context.restoresSpace ?? null);
|
|
186
189
|
this.revision = id;
|
|
187
190
|
const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
|
|
188
191
|
for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null, item.space ?? null);
|
|
@@ -369,9 +372,8 @@ export function checkSpace(request: InSpace): void {
|
|
|
369
372
|
function checkSymbol(request: SymbolRequest): void {
|
|
370
373
|
checkSpace(request);
|
|
371
374
|
checkAddress(request.address);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
+
const problem = labelProblem(request.name);
|
|
376
|
+
if (problem !== undefined) throw new Refusal("invalid-input", problem);
|
|
375
377
|
if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
|
|
376
378
|
}
|
|
377
379
|
|
|
@@ -1199,19 +1201,25 @@ const KEYS = {
|
|
|
1199
1201
|
|
|
1200
1202
|
/**
|
|
1201
1203
|
* Makes the current knowledge equal to what it was at `revision`, as a new
|
|
1202
|
-
* revision.
|
|
1204
|
+
* revision. With `space`, only the rows of that space change; without it,
|
|
1205
|
+
* every space changes. History is never erased; restored rows keep their origin.
|
|
1203
1206
|
*/
|
|
1204
|
-
export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
|
|
1207
|
+
export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string; space?: Space }): WriteResult<{ restoredRevision: string }> {
|
|
1208
|
+
checkSpace(request);
|
|
1205
1209
|
const restored = resolveRevision(db, request.revision);
|
|
1206
|
-
|
|
1210
|
+
const only = request.space;
|
|
1211
|
+
return write(db, { ...context, restores: restored, ...(only === undefined ? {} : { restoresSpace: only }) }, "revert", (change) => {
|
|
1207
1212
|
// The coverage maps that the knowledge has follow the revert, also when no coverage range changes.
|
|
1208
|
-
const maps = (runs: CoverageRun[]) => JSON.stringify(runs.map((run) => [run.name, run.sha256, run.space, run.revision]));
|
|
1213
|
+
const maps = (runs: CoverageRun[]) => JSON.stringify(runs.filter((run) => only === undefined || run.space === only).map((run) => [run.name, run.sha256, run.space, run.revision]));
|
|
1209
1214
|
if (maps(coverageRuns(db, restored)) !== maps(coverageRuns(db))) change.id();
|
|
1210
|
-
const
|
|
1215
|
+
const inSpace = only === undefined ? "" : " AND space = ?";
|
|
1216
|
+
const spaceArgs = only === undefined ? [] : [only];
|
|
1217
|
+
const at = `valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)${inSpace}`;
|
|
1218
|
+
const current = `valid_to_revision IS NULL${inSpace}`;
|
|
1211
1219
|
for (const [table, spec] of Object.entries(KEYS)) {
|
|
1212
1220
|
const name = table === "references" ? '"references"' : table;
|
|
1213
|
-
const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
|
|
1214
|
-
const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE
|
|
1221
|
+
const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
|
|
1222
|
+
const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
|
|
1215
1223
|
const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
|
|
1216
1224
|
const thenKeys = new Set(then.map(key));
|
|
1217
1225
|
const nowKeys = new Set(now.map(key));
|
|
@@ -1253,8 +1261,8 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
|
|
|
1253
1261
|
}
|
|
1254
1262
|
// The program: a row is the same when all its values are the same.
|
|
1255
1263
|
for (const [table, columns] of PROGRAM_TABLES) {
|
|
1256
|
-
const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
|
|
1257
|
-
const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE
|
|
1264
|
+
const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
|
|
1265
|
+
const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
|
|
1258
1266
|
const key = (row: Record<string, unknown>) => JSON.stringify(columns.split(", ").map((column) => row[column]));
|
|
1259
1267
|
const thenKeys = new Set(then.map(key));
|
|
1260
1268
|
const nowKeys = new Set(now.map(key));
|
package/src/mcp/inputs.ts
CHANGED
|
@@ -33,12 +33,15 @@ export const AddressInput = z
|
|
|
33
33
|
.transform((value) => c64AddressOf(value)!)
|
|
34
34
|
.describe("C64 address: $ and 1 to 4 hex digits, $0000 to $ffff");
|
|
35
35
|
|
|
36
|
+
/** The space of a memory target or condition: the emulator tests only C64 memory. */
|
|
37
|
+
const MemorySpaceInput = SpaceInput.describe("c64 only: the emulator tests only C64 memory. For the drive, use a watchpoint or an address target in space drive8");
|
|
38
|
+
|
|
36
39
|
const rasterLine = z.number().int().min(0).max(MAX_RASTER_LINE).describe("raster line: PAL 0-311, NTSC 0-262");
|
|
37
40
|
|
|
38
41
|
const condition = z
|
|
39
42
|
.discriminatedUnion("kind", [
|
|
40
43
|
z.object({ kind: z.literal("register"), register: z.enum(CONDITION_REGISTERS), operator: z.enum(COMPARISONS), value: Byte }).strict(),
|
|
41
|
-
z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space:
|
|
44
|
+
z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: MemorySpaceInput, view: MemoryViewInput }).strict(),
|
|
42
45
|
z.object({ kind: z.literal("raster"), line: rasterLine, cycle: z.number().int().min(0).max(64).optional().describe("cycle in the line: PAL 0-62, NTSC 0-64. True from this cycle on") }).strict(),
|
|
43
46
|
])
|
|
44
47
|
.describe("register: a CPU register compared to a byte. memory: a byte in memory compared to a byte. raster: the raster position");
|
|
@@ -50,7 +53,7 @@ export const TOOL_INPUTS = {
|
|
|
50
53
|
|
|
51
54
|
runTarget: z.discriminatedUnion("kind", [
|
|
52
55
|
z.object({ kind: z.literal("address"), address: AddressInput, space: SpaceInput, condition: condition.optional().describe("stop at the address only when this is true") }).strict(),
|
|
53
|
-
z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space:
|
|
56
|
+
z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: MemorySpaceInput, view: MemoryViewInput }).strict(),
|
|
54
57
|
z.object({ kind: z.literal("raster"), line: rasterLine, cycle: z.number().int().min(0).max(64).optional().describe("cycle in the line: PAL 0-62, NTSC 0-64. Default 0") }).strict(),
|
|
55
58
|
]),
|
|
56
59
|
|
|
@@ -82,7 +85,7 @@ export const TOOL_INPUTS = {
|
|
|
82
85
|
|
|
83
86
|
joystick: z
|
|
84
87
|
.object({
|
|
85
|
-
port: z.union([z.literal(1), z.literal(2)]).describe("control port 1 or 2"),
|
|
88
|
+
port: z.union([z.literal(1), z.literal(2)], { error: "port must be the number 1 or 2" }).describe("control port 1 or 2"),
|
|
86
89
|
direction: z.enum(JOYSTICK_DIRECTIONS),
|
|
87
90
|
fire: z.boolean().default(false),
|
|
88
91
|
})
|
package/src/mcp/server.ts
CHANGED
|
@@ -182,7 +182,8 @@ function describeIssues(error: z.ZodError, input?: z.ZodObject): string {
|
|
|
182
182
|
return error.issues
|
|
183
183
|
.map((issue) => {
|
|
184
184
|
const where = issue.path.length === 0 ? "input" : issue.path.join(".");
|
|
185
|
-
const
|
|
185
|
+
const names = input === undefined ? [] : Object.keys(input.shape);
|
|
186
|
+
const fields = issue.code !== "unrecognized_keys" || issue.path.length !== 0 || input === undefined ? "" : names.length === 0 ? ". The tool takes no fields" : `. The fields are ${names.join(", ")}`;
|
|
186
187
|
return `${where}: ${issue.message}${fields}`;
|
|
187
188
|
})
|
|
188
189
|
.join(". ");
|
package/src/mcp/tools/debug.ts
CHANGED
|
@@ -339,7 +339,9 @@ export const c64Memmap = defineTool({
|
|
|
339
339
|
"A read on a byte right after executed code, or at a return address, can be a dummy read that the CPU does not use. " +
|
|
340
340
|
"Action summary gives the counts for RAM, ROM and I/O. It also gives the executed code in RAM with its operand bytes, and the RAM that was written and executed. " +
|
|
341
341
|
`Each list has at most ${MAX_SUMMARY_RANGES} ranges. ` +
|
|
342
|
-
"Action clear sets all access records to zero."
|
|
342
|
+
"Action clear sets all access records to zero. " +
|
|
343
|
+
"The emulator decides RAM, ROM or I/O from one bit of $01. At $A000-$BFFF it gives ROM when bit 0 is set, also when bit 1 is clear and the CPU used RAM. " +
|
|
344
|
+
"Every access at $D000-$DFFF is I/O, also when $01 shows RAM or the character ROM there. emulatorFlaw in the result tells when ROM at $A000-$BFFF can be RAM.",
|
|
343
345
|
inputSchema: z
|
|
344
346
|
.object({
|
|
345
347
|
action: z.enum(["read", "summary", "clear"]),
|
|
@@ -362,6 +364,7 @@ export const c64Memmap = defineTool({
|
|
|
362
364
|
writtenAndExecuted: z.array(RangeOutput).optional(),
|
|
363
365
|
writtenAndExecutedTruncated: z.boolean().optional(),
|
|
364
366
|
cleared: z.boolean().optional(),
|
|
367
|
+
emulatorFlaw: z.string().optional().describe("a known flaw of VICE that can make the ROM and I/O classes wrong in this result"),
|
|
365
368
|
...WrittenOutput,
|
|
366
369
|
}),
|
|
367
370
|
readOnly: false,
|
|
@@ -391,12 +394,14 @@ export const c64Memmap = defineTool({
|
|
|
391
394
|
...(code.length > MAX_SUMMARY_RANGES ? { codeTruncated: true } : {}),
|
|
392
395
|
writtenAndExecuted: showRanges(written),
|
|
393
396
|
...(written.length > MAX_SUMMARY_RANGES ? { writtenAndExecutedTruncated: true } : {}),
|
|
397
|
+
...basicRomFlaw(romInBasicArea(map, start)),
|
|
394
398
|
};
|
|
395
399
|
}
|
|
396
400
|
requireOut(fields, ["maxRanges"]);
|
|
397
401
|
requireFields("read", fields, ["start", "end", "maxRanges", "out", "project"]);
|
|
398
402
|
if (fields.out !== undefined) {
|
|
399
|
-
|
|
403
|
+
const map = await readCoverage(session, start, end);
|
|
404
|
+
return { ...writeOut(fields.out, map, fields.project), ...basicRomFlaw(romInBasicArea(map, start)) };
|
|
400
405
|
}
|
|
401
406
|
const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
|
|
402
407
|
const ranges = result.ranges.map((range) => ({
|
|
@@ -407,8 +412,29 @@ export const c64Memmap = defineTool({
|
|
|
407
412
|
write: (range.flags & WRITE) !== 0,
|
|
408
413
|
in: sources(range.flags),
|
|
409
414
|
}));
|
|
410
|
-
|
|
415
|
+
const flaw = basicRomFlaw(result.ranges.some((range) => range.start <= 0xbfff && range.end >= 0xa000 && (range.flags & ROM_ACCESS) !== 0));
|
|
416
|
+
return result.next === undefined ? { ranges, ...flaw } : { ranges, truncated: true, next: formatC64Address(result.next), ...flaw };
|
|
411
417
|
},
|
|
412
418
|
});
|
|
413
419
|
|
|
420
|
+
const ROM_ACCESS = COVERAGE.romRead | COVERAGE.romExecute;
|
|
421
|
+
|
|
422
|
+
/** True when the map from `start` has a ROM access at $A000-$BFFF. */
|
|
423
|
+
function romInBasicArea(map: Uint8Array, start: number): boolean {
|
|
424
|
+
for (let address = Math.max(start, 0xa000); address <= Math.min(start + map.length - 1, 0xbfff); address++) {
|
|
425
|
+
if ((map[address - start]! & ROM_ACCESS) !== 0) return true;
|
|
426
|
+
}
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** VICE tests only bit 0 of $01 for $A000-$BFFF; the C64 shows BASIC ROM there only when bits 0 and 1 are set. */
|
|
431
|
+
function basicRomFlaw(present: boolean): { emulatorFlaw?: string } {
|
|
432
|
+
if (!present) return {};
|
|
433
|
+
return {
|
|
434
|
+
emulatorFlaw:
|
|
435
|
+
"The emulator gives ROM for an access at $A000-$BFFF when bit 0 of $01 is set, also when bit 1 is clear and the CPU used the RAM there. " +
|
|
436
|
+
"Read $01 of the program at that time before you take rom at $A000-$BFFF as the BASIC ROM.",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
414
440
|
export const debugTools = [c64Breakpoint, c64Watchpoint, c64CpuHistory, c64Trace, c64Backtrace, c64Profile, c64Memmap, c64Timing];
|
package/src/mcp/tools/machine.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { formatC64Address } from "../../c64.ts";
|
|
6
6
|
import { PACKAGE } from "../../package.ts";
|
|
7
7
|
import { VIDEO_STANDARDS } from "../../protocol/messages.ts";
|
|
8
|
-
import { POINT_STOP_REASONS, RESET_MODES, RUN_STATES, WINDOW_ACTIONS } from "../../protocol/vice.ts";
|
|
8
|
+
import { POINT_STOP_REASONS, RESET_MODES, RUN_STATES, SPACES, WINDOW_ACTIONS } from "../../protocol/vice.ts";
|
|
9
9
|
import { AddressOutput, defineTool } from "../server.ts";
|
|
10
10
|
|
|
11
11
|
export const c64Status = defineTool({
|
|
@@ -27,6 +27,7 @@ export const c64Status = defineTool({
|
|
|
27
27
|
pc: AddressOutput.optional().describe("program counter. Only when the CPU is stopped"),
|
|
28
28
|
stopReason: z.enum(POINT_STOP_REASONS).optional().describe("what stopped the CPU. Only when a breakpoint, a watchpoint or a JAM opcode stopped it"),
|
|
29
29
|
pointId: z.number().int().min(1).optional().describe("the id of the breakpoint or watchpoint that stopped the CPU"),
|
|
30
|
+
space: z.enum(SPACES).optional().describe("with pc: c64, or drive8 when a breakpoint or watchpoint in the disk drive stopped the CPU. pc is the address in that space"),
|
|
30
31
|
cartridge: z.boolean().describe("true while a cartridge from c64_autostart is attached"),
|
|
31
32
|
version: z.string().describe("the c64-re-tools version of this MCP server"),
|
|
32
33
|
}),
|
|
@@ -43,6 +44,7 @@ export const c64Status = defineTool({
|
|
|
43
44
|
...(status.pc === undefined ? {} : { pc: formatC64Address(status.pc) }),
|
|
44
45
|
...(status.stopReason === undefined ? {} : { stopReason: status.stopReason }),
|
|
45
46
|
...(status.pointId === undefined ? {} : { pointId: status.pointId }),
|
|
47
|
+
...(status.space === undefined ? {} : { space: status.space }),
|
|
46
48
|
};
|
|
47
49
|
},
|
|
48
50
|
});
|
package/src/mcp/tools/memory.ts
CHANGED
|
@@ -123,7 +123,7 @@ export const c64MemorySearch = defineTool({
|
|
|
123
123
|
title: "Search C64 memory",
|
|
124
124
|
description:
|
|
125
125
|
`Find a byte pattern of 1 to ${MAX_SEARCH_PATTERN} bytes in memory from start to end. ?? matches any byte. ` +
|
|
126
|
-
`The result gives the start address of each match, at most maxResults (1 to ${MAX_SEARCH_RESULTS}, default 100). ` +
|
|
126
|
+
`The result gives the start address of each match, at most maxResults (1 to ${MAX_SEARCH_RESULTS}, default 100). truncated: true tells that more matches can follow. ` +
|
|
127
127
|
"This does not change the run state of the machine.",
|
|
128
128
|
inputSchema: z
|
|
129
129
|
.object({
|
|
@@ -135,7 +135,7 @@ export const c64MemorySearch = defineTool({
|
|
|
135
135
|
maxResults: z.number().int().min(1).max(MAX_SEARCH_RESULTS).default(100),
|
|
136
136
|
})
|
|
137
137
|
.strict(),
|
|
138
|
-
outputSchema: z.object({ matches: z.array(AddressOutput) }),
|
|
138
|
+
outputSchema: z.object({ matches: z.array(AddressOutput), truncated: z.boolean().optional().describe("true when the result has maxResults matches: more can follow. Search again from the address after the last match") }),
|
|
139
139
|
readOnly: true,
|
|
140
140
|
async run(input, session) {
|
|
141
141
|
if (input.pattern.length > MAX_SEARCH_PATTERN) {
|
|
@@ -147,7 +147,8 @@ export const c64MemorySearch = defineTool({
|
|
|
147
147
|
// VICE refuses a range shorter than the pattern. Such a range has no match.
|
|
148
148
|
if (end - input.start + 1 < input.pattern.length) return { matches: [] };
|
|
149
149
|
const result = await session.memorySearch({ ...input, end });
|
|
150
|
-
|
|
150
|
+
const matches = result.matches.map(formatC64Address);
|
|
151
|
+
return matches.length >= input.maxResults ? { matches, truncated: true } : { matches };
|
|
151
152
|
},
|
|
152
153
|
});
|
|
153
154
|
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Media file signatures and sizes. The host checks a file with them before
|
|
2
|
+
// VICE gets it: VICE takes a file that is not of its type in another way, and
|
|
3
|
+
// a CRT or T64 that it cannot read loads as a PRG with a successful autostart.
|
|
4
|
+
|
|
5
|
+
import { Refusal } from "./errors.ts";
|
|
6
|
+
|
|
7
|
+
/** The signature at the start of each media type that has one. */
|
|
8
|
+
const SIGNATURES: Readonly<Record<string, readonly string[]>> = {
|
|
9
|
+
crt: ["C64 CARTRIDGE "],
|
|
10
|
+
t64: ["C64"],
|
|
11
|
+
tap: ["C64-TAPE-RAW"],
|
|
12
|
+
g64: ["GCR-1541"],
|
|
13
|
+
p00: ["C64File"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** True when `bytes` start with a signature of `type`, or `type` has none. */
|
|
17
|
+
export function hasSignature(type: string, bytes: Uint8Array): boolean {
|
|
18
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
19
|
+
const signatures = SIGNATURES[type];
|
|
20
|
+
return signatures === undefined || signatures.some((signature) => buffer.toString("latin1", 0, signature.length) === signature);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The file sizes that VICE reads for each disk image type without a signature. */
|
|
24
|
+
const SIZES: Readonly<Record<string, readonly number[]>> = {
|
|
25
|
+
d64: [174848, 175531, 196608, 197376, 205312, 206114],
|
|
26
|
+
d71: [349696, 351062],
|
|
27
|
+
d81: [819200, 822400],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The x64 header: 64 bytes that start with these four, then a d64 image. */
|
|
31
|
+
const X64_MAGIC = Buffer.from([0x43, 0x15, 0x41, 0x64]);
|
|
32
|
+
|
|
33
|
+
/** Refuses a file whose bytes are not of `type` (its extension), with media-error. */
|
|
34
|
+
export function checkMedia(type: string, bytes: Buffer): void {
|
|
35
|
+
const refuse = (why: string) => {
|
|
36
|
+
throw new Refusal("media-error", `The file is not a valid .${type} file: ${why}. Give a file of that type.`);
|
|
37
|
+
};
|
|
38
|
+
if (!hasSignature(type, bytes)) refuse(`it does not start with ${SIGNATURES[type]!.map((signature) => `"${signature.trimEnd()}"`).join(" or ")}`);
|
|
39
|
+
const sizes = SIZES[type];
|
|
40
|
+
if (sizes !== undefined && !sizes.includes(bytes.length)) refuse(`it has ${bytes.length} bytes, and a .${type} image has ${sizes.join(", ")}`);
|
|
41
|
+
if (type === "x64" && (!bytes.subarray(0, 4).equals(X64_MAGIC) || !SIZES.d64!.includes(bytes.length - 64))) refuse("it has no x64 header before a d64 image");
|
|
42
|
+
}
|
package/src/opcodes.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// spelling, ACME writes only one of them: the generator finds those with a
|
|
5
5
|
// probe and writes the others as bytes.
|
|
6
6
|
|
|
7
|
-
import { formatC64Byte, formatC64Address } from "./c64.ts";
|
|
7
|
+
import { formatC64Byte, formatC64Address, SYMBOL_NAME } from "./c64.ts";
|
|
8
8
|
|
|
9
9
|
export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
|
|
10
10
|
|
|
@@ -45,6 +45,17 @@ export const MODE_LENGTH: Record<Mode, 1 | 2 | 3> = { imp: 1, imm: 2, zp: 2, zpx
|
|
|
45
45
|
/** Every mnemonic in the table: a symbol with one of these names would read as an instruction. */
|
|
46
46
|
export const MNEMONICS: ReadonlySet<string> = new Set(OPCODES.map((opcode) => opcode.mnemonic));
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Why ACME cannot take a name as a label, or undefined when it can. A label
|
|
50
|
+
* has label syntax (SYMBOL_NAME) and is no mnemonic in upper or lower case.
|
|
51
|
+
* The one rule for a symbol of the knowledge and a label of c64-source.
|
|
52
|
+
*/
|
|
53
|
+
export function labelProblem(name: string): string | undefined {
|
|
54
|
+
if (!SYMBOL_NAME.test(name)) return "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.";
|
|
55
|
+
if (MNEMONICS.has(name.toLowerCase())) return `${name} is the name of a 6502 instruction, which ACME cannot read as a label. Give another name.`;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
const ZERO_PAGE_OF: Partial<Record<Mode, Mode>> = { abs: "zp", abx: "zpx", aby: "zpy" };
|
|
49
60
|
|
|
50
61
|
/** True when the mnemonic also has the zero-page form of an absolute mode: ACME would pick it for a small operand. */
|
|
@@ -78,26 +89,48 @@ export function useKind(opcode: number): UseKind {
|
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The ACME text of an instruction with its operand as an expression: the
|
|
94
|
+
* mnemonic, the size suffix (+1 or +2, or none) and the operand in the syntax
|
|
95
|
+
* of its addressing mode, for example "lda #<table", "sta screen,x" or
|
|
96
|
+
* "lda (pointer),y". ACME reads an operand in brackets as indirect, so a
|
|
97
|
+
* non-immediate expression that starts with a bracket gets 0+ in front.
|
|
98
|
+
* The one place that writes an instruction: the generated source, the
|
|
99
|
+
* listing of the knowledge and the disassembly of the emulator use it.
|
|
100
|
+
*/
|
|
101
|
+
export function instructionWith(opcode: number, expression: string, suffix = ""): string {
|
|
102
|
+
const { mnemonic, mode } = OPCODES[opcode]!;
|
|
103
|
+
const value = mode !== "imm" && expression.startsWith("(") ? `0+${expression}` : expression;
|
|
86
104
|
const operand: Record<Mode, string> = {
|
|
87
105
|
imp: "",
|
|
88
|
-
imm: `#${
|
|
89
|
-
zp:
|
|
90
|
-
zpx: `${
|
|
91
|
-
zpy: `${
|
|
92
|
-
abs:
|
|
93
|
-
abx: `${
|
|
94
|
-
aby: `${
|
|
95
|
-
ind: `(${
|
|
96
|
-
izx: `(${
|
|
97
|
-
izy: `(${
|
|
98
|
-
rel:
|
|
106
|
+
imm: `#${value}`,
|
|
107
|
+
zp: value,
|
|
108
|
+
zpx: `${value},x`,
|
|
109
|
+
zpy: `${value},y`,
|
|
110
|
+
abs: value,
|
|
111
|
+
abx: `${value},x`,
|
|
112
|
+
aby: `${value},y`,
|
|
113
|
+
ind: `(${value})`,
|
|
114
|
+
izx: `(${value},x)`,
|
|
115
|
+
izy: `(${value}),y`,
|
|
116
|
+
rel: value,
|
|
99
117
|
};
|
|
100
|
-
return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
|
|
118
|
+
return operand[mode] === "" ? `${mnemonic}${suffix}` : `${mnemonic}${suffix} ${operand[mode]}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The value of the operand of the instruction in `bytes` at `pc` as a number: a byte, a word, or the target of a branch. Empty for no operand. */
|
|
122
|
+
export function operandNumber(bytes: readonly number[], pc: number): string {
|
|
123
|
+
const { mode } = OPCODES[bytes[0]!]!;
|
|
124
|
+
const low = bytes[1] ?? 0;
|
|
125
|
+
if (mode === "imp") return "";
|
|
126
|
+
if (mode === "rel") return formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff);
|
|
127
|
+
return MODE_LENGTH[mode] === 3 ? formatC64Address(low | ((bytes[2] ?? 0) << 8)) : formatC64Byte(low);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The text of the instruction in `bytes` at `pc` as the monitor shows it, for example "STA $d011" or "BNE $c002". */
|
|
131
|
+
export function instructionText(bytes: readonly number[], pc: number): string {
|
|
132
|
+
const { mnemonic } = OPCODES[bytes[0]!]!;
|
|
133
|
+
return `${mnemonic.toUpperCase()}${instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length)}`;
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/**
|
package/src/project.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// the agent saves for itself and ignores itself in git.
|
|
6
6
|
|
|
7
7
|
import { Refusal } from "./errors.ts";
|
|
8
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
11
|
|
|
@@ -117,7 +117,8 @@ export function namedProject(folder: string, cwd: string = process.cwd()): strin
|
|
|
117
117
|
if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
|
|
118
118
|
if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
|
|
119
119
|
if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
|
|
120
|
-
|
|
120
|
+
// A project inside another project is a project of its own, as the lookup from a working directory in it finds.
|
|
121
|
+
const outer = isProject(path) ? undefined : foldersUpFrom(path).slice(1).find(isProject);
|
|
121
122
|
if (outer !== undefined) {
|
|
122
123
|
throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || outer}. Give that project folder.`);
|
|
123
124
|
}
|
|
@@ -180,6 +181,113 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
|
|
|
180
181
|
throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/** The folder of the committed knowledge text in PROJECT_DIRECTORY. The knowledge (src/knowledge/database.ts) has the same name. */
|
|
185
|
+
export const KNOWLEDGE_TEXT_DIRECTORY = "knowledge";
|
|
186
|
+
|
|
187
|
+
/** The index of the knowledge in LOCAL_DIRECTORY, and the files that SQLite keeps beside it. The knowledge has the same name. */
|
|
188
|
+
const KNOWLEDGE_INDEX = /^knowledge\.db(?:-wal|-shm|-journal)?$/;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The part of the knowledge that a path relative to the project folder is in:
|
|
192
|
+
* the text in .c64-re-tools/knowledge/ or the index in .c64-re-tools/local/.
|
|
193
|
+
* Undefined for every other path. Letter case does not count: some file
|
|
194
|
+
* systems do not tell the cases apart.
|
|
195
|
+
*/
|
|
196
|
+
export function knowledgePart(fromRoot: string): string | undefined {
|
|
197
|
+
const parts = fromRoot.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").map((part) => part.toLowerCase());
|
|
198
|
+
if (parts[0] !== PROJECT_DIRECTORY) return undefined;
|
|
199
|
+
if (parts[1] === KNOWLEDGE_TEXT_DIRECTORY) return `${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/`;
|
|
200
|
+
if (parts[1] === LOCAL_DIRECTORY && parts.length === 3 && KNOWLEDGE_INDEX.test(parts[2]!)) return `${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/${parts[2]}`;
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What a command writes at an output path: one file, or files in a folder. */
|
|
205
|
+
export type OutputKind = "file" | "folder";
|
|
206
|
+
|
|
207
|
+
/** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
|
|
208
|
+
const WRITE_PROBLEMS: Record<string, string> = {
|
|
209
|
+
EISDIR: "is a folder. Give the path of a file",
|
|
210
|
+
EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
|
|
211
|
+
ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
|
|
212
|
+
ENOENT: "is in a folder that does not exist",
|
|
213
|
+
EACCES: "cannot be written: there is no permission. Give a path that you can write",
|
|
214
|
+
EPERM: "cannot be written: there is no permission. Give a path that you can write",
|
|
215
|
+
EROFS: "is on a file system that is read-only. Give another path",
|
|
216
|
+
ENAMETOOLONG: "is too long. Give a shorter path",
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The message for a write that the file system refused because of the path
|
|
221
|
+
* (a folder, a file in place of a folder, no permission): the path and what
|
|
222
|
+
* to do. Undefined for another error, which is no fault of the path.
|
|
223
|
+
*/
|
|
224
|
+
export function writeProblem(error: unknown, path: string, what?: string): string | undefined {
|
|
225
|
+
const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
|
|
226
|
+
return problem === undefined ? undefined : `${what === undefined ? "" : `${what}: `}${path} ${problem}.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Checks a path that a command writes, before the command does any work. The
|
|
231
|
+
* path must stay inside the project, also through symbolic links, and must
|
|
232
|
+
* not be in the knowledge (only the c64-knowledge skill writes it). A file
|
|
233
|
+
* must not be a folder or a symbolic link; a folder must be a folder. The
|
|
234
|
+
* nearest folder that exists must be a folder that can be written. Returns
|
|
235
|
+
* the absolute path. Throws a Refusal (invalid-input) whose message starts
|
|
236
|
+
* with `what` (for example --out). writeProjectFile does the same check for
|
|
237
|
+
* each file that it writes.
|
|
238
|
+
*/
|
|
239
|
+
export function checkOutputPath(path: string, kind: OutputKind, options: { root?: string; what?: string } = {}): string {
|
|
240
|
+
const root = options.root ?? projectRoot();
|
|
241
|
+
const what = options.what;
|
|
242
|
+
const outside = () => new Refusal("invalid-input", `${what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
|
|
243
|
+
const problem = (error: unknown) => {
|
|
244
|
+
const message = writeProblem(error, path, what);
|
|
245
|
+
return message === undefined ? error : new Refusal("invalid-input", message);
|
|
246
|
+
};
|
|
247
|
+
const inKnowledge = (fromRoot: string) => {
|
|
248
|
+
const part = knowledgePart(fromRoot);
|
|
249
|
+
if (part !== undefined) {
|
|
250
|
+
throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is in ${part}, the knowledge of the project. Only the c64-knowledge skill writes it: a write there destroys the knowledge. Give a path outside ${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/, for example ${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/<file>.`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
let target: string;
|
|
254
|
+
try {
|
|
255
|
+
target = resolveProjectPath(path, root);
|
|
256
|
+
} catch {
|
|
257
|
+
throw outside();
|
|
258
|
+
}
|
|
259
|
+
inKnowledge(relative(root, target));
|
|
260
|
+
// The nearest entry that exists: the path itself, or a folder above it.
|
|
261
|
+
let existing = target;
|
|
262
|
+
for (;;) {
|
|
263
|
+
let found;
|
|
264
|
+
try {
|
|
265
|
+
found = lstatSync(existing, { throwIfNoEntry: false });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw problem(error);
|
|
268
|
+
}
|
|
269
|
+
if (found !== undefined) break;
|
|
270
|
+
existing = dirname(existing);
|
|
271
|
+
}
|
|
272
|
+
const realRoot = realpathSync(root);
|
|
273
|
+
if (existing === target && kind === "file" && lstatSync(target).isSymbolicLink()) throw outside();
|
|
274
|
+
const real = realpathSync(existing);
|
|
275
|
+
if (!isInside(realRoot, real)) throw outside();
|
|
276
|
+
inKnowledge(relative(realRoot, join(real, relative(existing, target))));
|
|
277
|
+
const stat = statSync(real);
|
|
278
|
+
if (existing === target && kind === "file") {
|
|
279
|
+
if (stat.isDirectory()) throw problem({ code: "EISDIR" });
|
|
280
|
+
} else if (!stat.isDirectory()) {
|
|
281
|
+
throw problem({ code: "ENOTDIR" });
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
accessSync(real, constants.W_OK);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw problem(error);
|
|
287
|
+
}
|
|
288
|
+
return target;
|
|
289
|
+
}
|
|
290
|
+
|
|
183
291
|
/** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
|
|
184
292
|
export function projectRelative(path: string, root = projectRoot()): string {
|
|
185
293
|
return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
|