c64bridge 0.7.4 → 0.8.0
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/.github/prompts/README.md +14 -4
- package/.github/prompts/assembly-routine-session.prompt.md +9 -9
- package/.github/prompts/basic-program-session.prompt.md +11 -9
- package/.github/prompts/drive-management-session.prompt.md +9 -9
- package/.github/prompts/hello-world-session.prompt.md +17 -0
- package/.github/prompts/petscii-art-session.prompt.md +9 -9
- package/.github/prompts/play-classic-game.prompt.md +9 -9
- package/.github/prompts/pr-converge.prompt.md +13 -0
- package/.github/prompts/sid-music-session.prompt.md +9 -9
- package/AGENTS.md +59 -60
- package/README.md +550 -191
- package/data/assembly/assembly-spec.md +290 -1
- package/data/context/bootstrap.md +26 -2
- package/data/context/fast-paths.md +44 -0
- package/data/graphics/petscii-style-guide.md +6 -2
- package/data/graphics/sprite-charset-best-practices.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +990 -26
- package/dist/config.js +5 -1
- package/dist/device.js +680 -57
- package/dist/diagnostics.js +371 -0
- package/dist/mcp/metadata.js +39 -0
- package/dist/mcp-server.js +170 -20
- package/dist/platform.js +10 -2
- package/dist/prompts/registry.js +166 -309
- package/dist/rag/init.js +37 -1
- package/dist/rag/knowledgeIndex.js +18 -6
- package/dist/rag/rateLimiter.js +2 -2
- package/dist/streamCapture.js +135 -0
- package/dist/tools/audio.js +3 -1
- package/dist/tools/debug.js +470 -0
- package/dist/tools/developer.js +10 -4
- package/dist/tools/graphics.js +183 -17
- package/dist/tools/machineControl.js +11 -5
- package/dist/tools/memory.js +19 -10
- package/dist/tools/meta/audio.js +240 -1
- package/dist/tools/meta/config.js +20 -28
- package/dist/tools/meta/configInventory.js +104 -0
- package/dist/tools/meta/diagnostics.js +45 -2
- package/dist/tools/meta/graphics.js +14 -6
- package/dist/tools/meta/index.js +1 -0
- package/dist/tools/meta/memory.js +20 -6
- package/dist/tools/meta/program.js +473 -0
- package/dist/tools/pollValidator.js +4 -2
- package/dist/tools/programRunners.js +8 -4
- package/dist/tools/registry/config.js +17 -0
- package/dist/tools/registry/disk.js +7 -0
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/graphics.js +111 -27
- package/dist/tools/registry/index.js +6 -0
- package/dist/tools/registry/memory.js +1 -0
- package/dist/tools/registry/platform.js +121 -0
- package/dist/tools/registry/program.js +26 -8
- package/dist/tools/registry/sound.js +96 -0
- package/dist/tools/registry/system.js +15 -2
- package/dist/tools/storage.js +1 -0
- package/dist/tools/streaming.js +1 -0
- package/dist/tools/types.js +38 -4
- package/dist/tools/vice.js +160 -0
- package/dist/vicBitmap.js +317 -0
- package/dist/vice/mockServer.js +439 -0
- package/dist/vice/process.js +310 -0
- package/dist/vice/readiness.js +18 -2
- package/dist/vice/viceClient.js +382 -22
- package/doc/developer.md +62 -24
- package/doc/img/backend-switch/hello-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice.png +0 -0
- package/doc/img/backend-switch/metadata.json +50 -0
- package/doc/plans/vice-backend-switch/plan.md +486 -0
- package/doc/plans/vice-backend-switch-2/plan.md +48 -0
- package/doc/plans/vice-support/notes.md +1 -0
- package/doc/plans/vice-support/plan.md +5 -0
- package/doc/plans/vice-support/rollout.md +23 -17
- package/doc/reviews/vice-support-review.md +185 -0
- package/doc/troubleshooting-mcp.md +35 -6
- package/doc/vice/support-matrix.md +54 -0
- package/mcp/prompts.json +292 -0
- package/mcp/protocol-examples.json +46 -0
- package/mcp/resources.json +940 -0
- package/mcp/schemas/c64_config.schema.json +350 -0
- package/mcp/schemas/c64_debug.schema.json +380 -0
- package/mcp/schemas/c64_disk.schema.json +278 -0
- package/mcp/schemas/c64_drive.schema.json +120 -0
- package/mcp/schemas/c64_extract.schema.json +302 -0
- package/mcp/schemas/c64_graphics.schema.json +333 -0
- package/mcp/schemas/c64_memory.schema.json +155 -0
- package/mcp/schemas/c64_printer.schema.json +218 -0
- package/mcp/schemas/c64_program.schema.json +391 -0
- package/mcp/schemas/c64_rag.schema.json +67 -0
- package/mcp/schemas/c64_select_backend.schema.json +32 -0
- package/mcp/schemas/c64_sound.schema.json +644 -0
- package/mcp/schemas/c64_stream.schema.json +61 -0
- package/mcp/schemas/c64_system.schema.json +227 -0
- package/mcp/schemas/c64_vice.schema.json +63 -0
- package/mcp/server.json +15 -0
- package/mcp/tools.json +4517 -0
- package/mcp.json +83 -9
- package/package.json +8 -3
- package/scripts/capture-backend-switch-screenshots.mjs +276 -0
- package/scripts/generate-mcp-interface.ts +289 -0
- package/scripts/invoke-bun.mjs +67 -21
- package/scripts/mockC64Server.mjs +162 -6
- package/scripts/run-coverage.mjs +284 -0
- package/scripts/run-tests.mjs +95 -39
- package/scripts/run-tests.ts +378 -73
- package/scripts/update-readme.ts +147 -13
package/dist/tools/types.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getPlatformStatus, isPlatformSupported, setPlatform, } from "../platform.js";
|
|
2
|
+
import { withDiagnosticSpan } from "../diagnostics.js";
|
|
2
3
|
import { ToolUnsupportedPlatformError, ToolValidationError } from "./errors.js";
|
|
3
4
|
export const OPERATION_DISCRIMINATOR = "op";
|
|
4
5
|
export const VERIFY_PROPERTY_NAME = "verify";
|
|
@@ -48,7 +49,7 @@ export function createOperationDispatcher(toolName, handlers) {
|
|
|
48
49
|
const record = args;
|
|
49
50
|
const opValue = record[OPERATION_DISCRIMINATOR];
|
|
50
51
|
if (typeof opValue !== "string" || opValue.length === 0) {
|
|
51
|
-
throw new ToolValidationError(`${toolName} requires an ${OPERATION_DISCRIMINATOR} string to select an operation`, { path: `$.${OPERATION_DISCRIMINATOR}
|
|
52
|
+
throw new ToolValidationError(`${toolName} is a grouped tool and requires an ${OPERATION_DISCRIMINATOR} string to select an operation`, { path: `$.${OPERATION_DISCRIMINATOR}`, details: { allowed } });
|
|
52
53
|
}
|
|
53
54
|
const opKey = opValue;
|
|
54
55
|
const handler = handlers[opKey];
|
|
@@ -90,6 +91,7 @@ export function defineToolModule(config) {
|
|
|
90
91
|
...(workflowHints ? { workflowHints } : {}),
|
|
91
92
|
...(prerequisites ? { prerequisites } : {}),
|
|
92
93
|
...(platforms ? { platforms } : {}),
|
|
94
|
+
...(tool.operationPlatforms ? { operationPlatforms: tool.operationPlatforms } : {}),
|
|
93
95
|
};
|
|
94
96
|
return {
|
|
95
97
|
name: tool.name,
|
|
@@ -104,21 +106,53 @@ export function defineToolModule(config) {
|
|
|
104
106
|
if (!tool) {
|
|
105
107
|
throw new Error(`Unknown tool: ${name}`);
|
|
106
108
|
}
|
|
107
|
-
const platforms =
|
|
109
|
+
const platforms = resolveSupportedPlatforms(tool, args, defaultPlatforms);
|
|
108
110
|
const status = ctx.platform ?? getPlatformStatus();
|
|
109
111
|
const setter = ctx.setPlatform ?? setPlatform;
|
|
110
112
|
if (!isPlatformSupported(status.id, platforms)) {
|
|
111
|
-
throw new ToolUnsupportedPlatformError(name, status.id, platforms);
|
|
113
|
+
throw new ToolUnsupportedPlatformError(resolveUnsupportedToolName(name, tool, args), status.id, platforms);
|
|
112
114
|
}
|
|
113
115
|
const enrichedCtx = {
|
|
114
116
|
...ctx,
|
|
115
117
|
platform: status,
|
|
116
118
|
setPlatform: setter,
|
|
117
119
|
};
|
|
118
|
-
return tool
|
|
120
|
+
return withDiagnosticSpan("tool", `${config.domain}.${name}`, {
|
|
121
|
+
domain: config.domain,
|
|
122
|
+
tool: name,
|
|
123
|
+
operation: getSelectedOperation(args) ?? null,
|
|
124
|
+
platform: status.id,
|
|
125
|
+
}, () => tool.execute(args, enrichedCtx));
|
|
119
126
|
},
|
|
120
127
|
};
|
|
121
128
|
}
|
|
129
|
+
function resolveSupportedPlatforms(tool, args, defaultPlatforms) {
|
|
130
|
+
const basePlatforms = mergePlatforms(defaultPlatforms, tool.supportedPlatforms) ?? defaultPlatforms;
|
|
131
|
+
const operation = getSelectedOperation(args);
|
|
132
|
+
if (!operation) {
|
|
133
|
+
return basePlatforms;
|
|
134
|
+
}
|
|
135
|
+
const operationPlatforms = tool.operationPlatforms?.[operation];
|
|
136
|
+
return operationPlatforms && operationPlatforms.length > 0 ? operationPlatforms : basePlatforms;
|
|
137
|
+
}
|
|
138
|
+
function resolveUnsupportedToolName(toolName, tool, args) {
|
|
139
|
+
const operation = getSelectedOperation(args);
|
|
140
|
+
if (!operation) {
|
|
141
|
+
return toolName;
|
|
142
|
+
}
|
|
143
|
+
const mappedName = tool.operationToolNames?.[operation];
|
|
144
|
+
if (mappedName) {
|
|
145
|
+
return mappedName;
|
|
146
|
+
}
|
|
147
|
+
return tool.operationPlatforms?.[operation] ? operation : toolName;
|
|
148
|
+
}
|
|
149
|
+
function getSelectedOperation(args) {
|
|
150
|
+
if (typeof args !== "object" || args === null) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const op = args[OPERATION_DISCRIMINATOR];
|
|
154
|
+
return typeof op === "string" && op.length > 0 ? op : undefined;
|
|
155
|
+
}
|
|
122
156
|
function mergeUnique(base, extra) {
|
|
123
157
|
if (!extra || extra.length === 0) {
|
|
124
158
|
return base;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createOperationDispatcher, defineToolModule, discriminatedUnionSchema, } from "./types.js";
|
|
2
|
+
import { literalSchema, objectSchema, stringSchema, } from "./schema.js";
|
|
3
|
+
import { jsonResult, textResult } from "./responses.js";
|
|
4
|
+
import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
|
|
5
|
+
const RESOURCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9_.-]{0,63}$/;
|
|
6
|
+
const SAFE_RESOURCE_PREFIXES = [
|
|
7
|
+
"C64",
|
|
8
|
+
"Machine",
|
|
9
|
+
"Sid",
|
|
10
|
+
"VIC",
|
|
11
|
+
"VICII",
|
|
12
|
+
"CIA",
|
|
13
|
+
"Joy",
|
|
14
|
+
"Sound",
|
|
15
|
+
];
|
|
16
|
+
const resourceValueSchema = {
|
|
17
|
+
jsonSchema: {
|
|
18
|
+
description: "VICE resource value (string or integer).",
|
|
19
|
+
oneOf: [
|
|
20
|
+
{ type: "string" },
|
|
21
|
+
{ type: "integer" },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
parse(value, path) {
|
|
25
|
+
const resolvedPath = path ?? "$.value";
|
|
26
|
+
if (typeof value === "string") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
throw new ToolValidationError("Resource value must be a string or integer", {
|
|
33
|
+
path: resolvedPath,
|
|
34
|
+
details: { receivedType: typeof value },
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const resourceNameSchema = stringSchema({
|
|
39
|
+
description: "VICE resource name (example: SidEngine, C64Model).",
|
|
40
|
+
minLength: 1,
|
|
41
|
+
maxLength: 64,
|
|
42
|
+
pattern: RESOURCE_NAME_PATTERN,
|
|
43
|
+
});
|
|
44
|
+
const resourceGetArgsSchema = objectSchema({
|
|
45
|
+
description: "Read a VICE configuration resource (safe prefixes only).",
|
|
46
|
+
properties: {
|
|
47
|
+
op: literalSchema("resource_get"),
|
|
48
|
+
name: resourceNameSchema,
|
|
49
|
+
},
|
|
50
|
+
required: ["op", "name"],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
});
|
|
53
|
+
const resourceSetArgsSchema = objectSchema({
|
|
54
|
+
description: "Write a VICE configuration resource (safe prefixes only).",
|
|
55
|
+
properties: {
|
|
56
|
+
op: literalSchema("resource_set"),
|
|
57
|
+
name: resourceNameSchema,
|
|
58
|
+
value: resourceValueSchema,
|
|
59
|
+
},
|
|
60
|
+
required: ["op", "name", "value"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
});
|
|
63
|
+
const viceOperationSchemas = [
|
|
64
|
+
resourceGetArgsSchema,
|
|
65
|
+
resourceSetArgsSchema,
|
|
66
|
+
];
|
|
67
|
+
const viceOperationHandlers = {
|
|
68
|
+
resource_get: async (args, ctx) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = resourceGetArgsSchema.parse(args);
|
|
71
|
+
ensureSafeResourceName(parsed.name, "$.name");
|
|
72
|
+
ctx.logger.info("Reading VICE resource", { name: parsed.name });
|
|
73
|
+
const value = await ctx.client.viceResourceGet(parsed.name);
|
|
74
|
+
if (value.type === "string" && value.value.length === 0) {
|
|
75
|
+
throw new ToolExecutionError(`VICE resource ${parsed.name} is not available or returned an empty value`, {
|
|
76
|
+
details: { name: parsed.name },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return jsonResult({
|
|
80
|
+
name: parsed.name,
|
|
81
|
+
type: value.type,
|
|
82
|
+
value: value.value,
|
|
83
|
+
}, { success: true, name: parsed.name, type: value.type });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return handleToolError(error);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
resource_set: async (args, ctx) => {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = resourceSetArgsSchema.parse(args);
|
|
92
|
+
ensureSafeResourceName(parsed.name, "$.name");
|
|
93
|
+
ctx.logger.info("Writing VICE resource", {
|
|
94
|
+
name: parsed.name,
|
|
95
|
+
valueType: typeof parsed.value,
|
|
96
|
+
});
|
|
97
|
+
await ctx.client.viceResourceSet(parsed.name, parsed.value);
|
|
98
|
+
return textResult(`Updated VICE resource ${parsed.name}.`, {
|
|
99
|
+
success: true,
|
|
100
|
+
name: parsed.name,
|
|
101
|
+
value: parsed.value,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return handleToolError(error);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const viceOperationDispatcher = createOperationDispatcher("c64_vice", viceOperationHandlers);
|
|
110
|
+
export const viceModuleGroup = defineToolModule({
|
|
111
|
+
domain: "vice",
|
|
112
|
+
summary: "VICE emulator helpers for safe runtime resource access.",
|
|
113
|
+
resources: ["c64://specs/memory-map"],
|
|
114
|
+
prompts: ["assembly-program", "memory-debug"],
|
|
115
|
+
defaultTags: ["vice", "emulator"],
|
|
116
|
+
workflowHints: [
|
|
117
|
+
"Use emulator helpers when running on VICE to inspect or tweak safe runtime resources.",
|
|
118
|
+
"Explain that resource changes only persist for the current emulator session unless saved in VICE manually.",
|
|
119
|
+
],
|
|
120
|
+
supportedPlatforms: ["vice"],
|
|
121
|
+
tools: [
|
|
122
|
+
{
|
|
123
|
+
name: "c64_vice",
|
|
124
|
+
description: "Grouped entry point for reading and updating selected VICE resources.",
|
|
125
|
+
summary: "Reads or updates selected VICE resources that are safe to expose through MCP.",
|
|
126
|
+
inputSchema: discriminatedUnionSchema({
|
|
127
|
+
description: "VICE emulator operations available via the c64_vice tool.",
|
|
128
|
+
variants: viceOperationSchemas.map((schema) => schema.jsonSchema),
|
|
129
|
+
}),
|
|
130
|
+
tags: ["vice", "resource", "grouped"],
|
|
131
|
+
examples: [
|
|
132
|
+
{
|
|
133
|
+
name: "Read SID engine",
|
|
134
|
+
description: "Inspect the active SID emulation mode.",
|
|
135
|
+
arguments: { op: "resource_get", name: "SidEngine" },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "Set SID engine",
|
|
139
|
+
description: "Switch SID emulation to resid-fp (value 2).",
|
|
140
|
+
arguments: { op: "resource_set", name: "SidEngine", value: 2 },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
execute: viceOperationDispatcher,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
function handleToolError(error) {
|
|
148
|
+
if (error instanceof ToolError) {
|
|
149
|
+
return toolErrorResult(error);
|
|
150
|
+
}
|
|
151
|
+
return unknownErrorResult(error);
|
|
152
|
+
}
|
|
153
|
+
function ensureSafeResourceName(name, path) {
|
|
154
|
+
if (!RESOURCE_NAME_PATTERN.test(name)) {
|
|
155
|
+
throw new ToolValidationError("Resource names must start with an uppercase letter and use letters, numbers, dashes, underscores, or periods.", { path, details: { name } });
|
|
156
|
+
}
|
|
157
|
+
if (!SAFE_RESOURCE_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
158
|
+
throw new ToolValidationError(`Resource name must start with one of: ${SAFE_RESOURCE_PREFIXES.join(", ")}`, { path, details: { name } });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { Jimp } from "jimp";
|
|
2
|
+
const VIC_BANK_SIZE = 0x4000;
|
|
3
|
+
const BITMAP_BYTE_LENGTH = 8000;
|
|
4
|
+
const SCREEN_RAM_BYTE_LENGTH = 1000;
|
|
5
|
+
const COLOR_RAM_BYTE_LENGTH = 1000;
|
|
6
|
+
const COLOR_RAM_ADDRESS = 0xD800;
|
|
7
|
+
const VIC_PALETTE = [
|
|
8
|
+
[0x00, 0x00, 0x00],
|
|
9
|
+
[0xFF, 0xFF, 0xFF],
|
|
10
|
+
[0x81, 0x33, 0x38],
|
|
11
|
+
[0x75, 0xCE, 0xC8],
|
|
12
|
+
[0x8E, 0x3C, 0x97],
|
|
13
|
+
[0x56, 0xAC, 0x4D],
|
|
14
|
+
[0x2E, 0x2C, 0x9B],
|
|
15
|
+
[0xED, 0xF1, 0x71],
|
|
16
|
+
[0x8E, 0x50, 0x29],
|
|
17
|
+
[0x55, 0x38, 0x00],
|
|
18
|
+
[0xC4, 0x6C, 0x71],
|
|
19
|
+
[0x4A, 0x4A, 0x4A],
|
|
20
|
+
[0x7B, 0x7B, 0x7B],
|
|
21
|
+
[0xA9, 0xFF, 0x9F],
|
|
22
|
+
[0x70, 0x6D, 0xEB],
|
|
23
|
+
[0xB2, 0xB2, 0xB2],
|
|
24
|
+
];
|
|
25
|
+
export async function importImageAsVicBitmap(options) {
|
|
26
|
+
const image = await Jimp.read(options.imagePath);
|
|
27
|
+
const sourceWidth = image.bitmap.width;
|
|
28
|
+
const sourceHeight = image.bitmap.height;
|
|
29
|
+
const logicalWidth = options.mode === "multicolor" ? 160 : 320;
|
|
30
|
+
const logicalHeight = 200;
|
|
31
|
+
const backgroundColor = normaliseColorIndex(options.backgroundColor ?? 0);
|
|
32
|
+
const borderColor = normaliseColorIndex(options.borderColor ?? backgroundColor);
|
|
33
|
+
const rgba = await renderImageToLogicalCanvas(image, {
|
|
34
|
+
width: logicalWidth,
|
|
35
|
+
height: logicalHeight,
|
|
36
|
+
preserveAspect: options.preserveAspect !== false,
|
|
37
|
+
backgroundColor,
|
|
38
|
+
});
|
|
39
|
+
const prepared = convertRgbaToVicBitmap(rgba, {
|
|
40
|
+
mode: options.mode,
|
|
41
|
+
backgroundColor,
|
|
42
|
+
borderColor,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
...prepared,
|
|
46
|
+
sourceWidth,
|
|
47
|
+
sourceHeight,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function convertRgbaToVicBitmap(image, options) {
|
|
51
|
+
if (image.width <= 0 || image.height <= 0) {
|
|
52
|
+
throw new Error("Bitmap image must have positive dimensions");
|
|
53
|
+
}
|
|
54
|
+
if (image.data.length !== image.width * image.height * 4) {
|
|
55
|
+
throw new Error("RGBA image buffer length must equal width * height * 4");
|
|
56
|
+
}
|
|
57
|
+
const backgroundColor = normaliseColorIndex(options.backgroundColor ?? 0);
|
|
58
|
+
const borderColor = normaliseColorIndex(options.borderColor ?? backgroundColor);
|
|
59
|
+
const quantized = quantizeRgbaImage(image, backgroundColor);
|
|
60
|
+
return options.mode === "multicolor"
|
|
61
|
+
? convertMulticolorBitmap(quantized, backgroundColor, borderColor)
|
|
62
|
+
: convertHiresBitmap(quantized, backgroundColor, borderColor);
|
|
63
|
+
}
|
|
64
|
+
export function resolveVicBitmapMemoryLayout(bitmapAddress = 0x2000, screenAddress = 0x0400) {
|
|
65
|
+
validateAddress(bitmapAddress, BITMAP_BYTE_LENGTH, "bitmapAddress");
|
|
66
|
+
validateAddress(screenAddress, SCREEN_RAM_BYTE_LENGTH, "screenAddress");
|
|
67
|
+
const bank = Math.floor(bitmapAddress / VIC_BANK_SIZE);
|
|
68
|
+
const bankBase = bank * VIC_BANK_SIZE;
|
|
69
|
+
if (Math.floor(screenAddress / VIC_BANK_SIZE) !== bank) {
|
|
70
|
+
throw new Error("Bitmap RAM and screen RAM must be within the same 16 KB VIC bank");
|
|
71
|
+
}
|
|
72
|
+
const bitmapOffset = bitmapAddress - bankBase;
|
|
73
|
+
const screenOffset = screenAddress - bankBase;
|
|
74
|
+
if (bitmapOffset !== 0x0000 && bitmapOffset !== 0x2000) {
|
|
75
|
+
throw new Error("Bitmap RAM must be aligned to $0000 or $2000 within the selected VIC bank");
|
|
76
|
+
}
|
|
77
|
+
if (screenOffset % 0x0400 !== 0) {
|
|
78
|
+
throw new Error("Screen RAM must be aligned to a 1 KB boundary within the selected VIC bank");
|
|
79
|
+
}
|
|
80
|
+
if (screenOffset > 0x3C00) {
|
|
81
|
+
throw new Error("Screen RAM must fit entirely inside the selected VIC bank");
|
|
82
|
+
}
|
|
83
|
+
if (rangesOverlap(bitmapAddress, BITMAP_BYTE_LENGTH, screenAddress, SCREEN_RAM_BYTE_LENGTH)) {
|
|
84
|
+
throw new Error("Bitmap RAM and screen RAM must not overlap");
|
|
85
|
+
}
|
|
86
|
+
const screenNibble = ((screenOffset / 0x0400) & 0x0F) << 4;
|
|
87
|
+
const bitmapBit = bitmapOffset === 0x2000 ? 0x08 : 0x00;
|
|
88
|
+
return {
|
|
89
|
+
bank,
|
|
90
|
+
bankBase,
|
|
91
|
+
bitmapAddress,
|
|
92
|
+
screenAddress,
|
|
93
|
+
colorRamAddress: COLOR_RAM_ADDRESS,
|
|
94
|
+
d018: screenNibble | bitmapBit,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function buildVicBitmapRegisters(layout, options) {
|
|
98
|
+
const backgroundColor = normaliseColorIndex(options.backgroundColor ?? 0);
|
|
99
|
+
const borderColor = normaliseColorIndex(options.borderColor ?? backgroundColor);
|
|
100
|
+
const bankBits = (~layout.bank) & 0x03;
|
|
101
|
+
return {
|
|
102
|
+
dd00: ((options.currentDd00 ?? 0) & 0xFC) | bankBits,
|
|
103
|
+
d011: 0x3B,
|
|
104
|
+
d016: options.mode === "multicolor" ? 0x18 : 0x08,
|
|
105
|
+
d018: layout.d018,
|
|
106
|
+
d020: borderColor,
|
|
107
|
+
d021: backgroundColor,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async function renderImageToLogicalCanvas(image, options) {
|
|
111
|
+
const background = paletteColorToJimp(normaliseColorIndex(options.backgroundColor));
|
|
112
|
+
if (!options.preserveAspect) {
|
|
113
|
+
const stretched = image.clone().resize({ w: options.width, h: options.height });
|
|
114
|
+
return {
|
|
115
|
+
width: stretched.bitmap.width,
|
|
116
|
+
height: stretched.bitmap.height,
|
|
117
|
+
data: Uint8Array.from(stretched.bitmap.data),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const scale = Math.min(options.width / image.bitmap.width, options.height / image.bitmap.height);
|
|
121
|
+
const renderWidth = Math.max(1, Math.round(image.bitmap.width * scale));
|
|
122
|
+
const renderHeight = Math.max(1, Math.round(image.bitmap.height * scale));
|
|
123
|
+
const resized = image.clone().resize({ w: renderWidth, h: renderHeight });
|
|
124
|
+
const canvas = new Jimp({ width: options.width, height: options.height, color: background });
|
|
125
|
+
const offsetX = Math.floor((options.width - renderWidth) / 2);
|
|
126
|
+
const offsetY = Math.floor((options.height - renderHeight) / 2);
|
|
127
|
+
canvas.composite(resized, offsetX, offsetY);
|
|
128
|
+
return {
|
|
129
|
+
width: canvas.bitmap.width,
|
|
130
|
+
height: canvas.bitmap.height,
|
|
131
|
+
data: Uint8Array.from(canvas.bitmap.data),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function quantizeRgbaImage(image, transparentColor) {
|
|
135
|
+
const quantized = new Uint8Array(image.width * image.height);
|
|
136
|
+
for (let index = 0; index < quantized.length; index += 1) {
|
|
137
|
+
const offset = index * 4;
|
|
138
|
+
const r = image.data[offset] ?? 0;
|
|
139
|
+
const g = image.data[offset + 1] ?? 0;
|
|
140
|
+
const b = image.data[offset + 2] ?? 0;
|
|
141
|
+
const a = image.data[offset + 3] ?? 0xFF;
|
|
142
|
+
quantized[index] = a < 64 ? transparentColor : nearestPaletteIndex(r, g, b);
|
|
143
|
+
}
|
|
144
|
+
return quantized;
|
|
145
|
+
}
|
|
146
|
+
function convertHiresBitmap(quantized, backgroundColor, borderColor) {
|
|
147
|
+
if (quantized.length !== 320 * 200) {
|
|
148
|
+
throw new Error("Hi-res bitmap conversion requires a 320x200 logical image");
|
|
149
|
+
}
|
|
150
|
+
const bitmapData = new Uint8Array(BITMAP_BYTE_LENGTH);
|
|
151
|
+
const screenRam = new Uint8Array(SCREEN_RAM_BYTE_LENGTH);
|
|
152
|
+
const colorRam = new Uint8Array(COLOR_RAM_BYTE_LENGTH);
|
|
153
|
+
for (let cellY = 0; cellY < 25; cellY += 1) {
|
|
154
|
+
for (let cellX = 0; cellX < 40; cellX += 1) {
|
|
155
|
+
const cellIndex = (cellY * 40) + cellX;
|
|
156
|
+
const [background, foreground] = pickDominantColors(quantized, 320, cellX * 8, cellY * 8, 8, 8, 2);
|
|
157
|
+
screenRam[cellIndex] = ((foreground & 0x0F) << 4) | (background & 0x0F);
|
|
158
|
+
colorRam[cellIndex] = backgroundColor & 0x0F;
|
|
159
|
+
for (let row = 0; row < 8; row += 1) {
|
|
160
|
+
let byte = 0;
|
|
161
|
+
for (let col = 0; col < 8; col += 1) {
|
|
162
|
+
const pixelIndex = ((cellY * 8 + row) * 320) + (cellX * 8 + col);
|
|
163
|
+
const pixelColor = quantized[pixelIndex] ?? background;
|
|
164
|
+
const bit = chooseNearestCandidate(pixelColor, [background, foreground]) === foreground ? 1 : 0;
|
|
165
|
+
byte = (byte << 1) | bit;
|
|
166
|
+
}
|
|
167
|
+
bitmapData[(cellIndex * 8) + row] = byte;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
mode: "hires",
|
|
173
|
+
sourceWidth: 320,
|
|
174
|
+
sourceHeight: 200,
|
|
175
|
+
logicalWidth: 320,
|
|
176
|
+
logicalHeight: 200,
|
|
177
|
+
displayWidth: 320,
|
|
178
|
+
displayHeight: 200,
|
|
179
|
+
backgroundColor,
|
|
180
|
+
borderColor,
|
|
181
|
+
bitmapData,
|
|
182
|
+
screenRam,
|
|
183
|
+
colorRam,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function convertMulticolorBitmap(quantized, backgroundColor, borderColor) {
|
|
187
|
+
if (quantized.length !== 160 * 200) {
|
|
188
|
+
throw new Error("Multicolor bitmap conversion requires a 160x200 logical image");
|
|
189
|
+
}
|
|
190
|
+
const bitmapData = new Uint8Array(BITMAP_BYTE_LENGTH);
|
|
191
|
+
const screenRam = new Uint8Array(SCREEN_RAM_BYTE_LENGTH);
|
|
192
|
+
const colorRam = new Uint8Array(COLOR_RAM_BYTE_LENGTH);
|
|
193
|
+
for (let cellY = 0; cellY < 25; cellY += 1) {
|
|
194
|
+
for (let cellX = 0; cellX < 40; cellX += 1) {
|
|
195
|
+
const cellIndex = (cellY * 40) + cellX;
|
|
196
|
+
const selected = fillMissingColors(pickDominantColors(quantized, 160, cellX * 4, cellY * 8, 4, 8, 3, backgroundColor), backgroundColor);
|
|
197
|
+
const color01 = selected[0];
|
|
198
|
+
const color10 = selected[1];
|
|
199
|
+
const color11 = selected[2];
|
|
200
|
+
screenRam[cellIndex] = ((color01 & 0x0F) << 4) | (color10 & 0x0F);
|
|
201
|
+
colorRam[cellIndex] = color11 & 0x0F;
|
|
202
|
+
for (let row = 0; row < 8; row += 1) {
|
|
203
|
+
let byte = 0;
|
|
204
|
+
for (let col = 0; col < 4; col += 1) {
|
|
205
|
+
const pixelIndex = ((cellY * 8 + row) * 160) + (cellX * 4 + col);
|
|
206
|
+
const pixelColor = quantized[pixelIndex] ?? backgroundColor;
|
|
207
|
+
const nearest = chooseNearestCandidate(pixelColor, [backgroundColor, color01, color10, color11]);
|
|
208
|
+
const code = nearest === color01 ? 0x01 : nearest === color10 ? 0x02 : nearest === color11 ? 0x03 : 0x00;
|
|
209
|
+
byte = (byte << 2) | code;
|
|
210
|
+
}
|
|
211
|
+
bitmapData[(cellIndex * 8) + row] = byte;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
mode: "multicolor",
|
|
217
|
+
sourceWidth: 160,
|
|
218
|
+
sourceHeight: 200,
|
|
219
|
+
logicalWidth: 160,
|
|
220
|
+
logicalHeight: 200,
|
|
221
|
+
displayWidth: 320,
|
|
222
|
+
displayHeight: 200,
|
|
223
|
+
backgroundColor,
|
|
224
|
+
borderColor,
|
|
225
|
+
bitmapData,
|
|
226
|
+
screenRam,
|
|
227
|
+
colorRam,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function pickDominantColors(quantized, stride, startX, startY, width, height, count, excludedColor) {
|
|
231
|
+
const counts = new Uint32Array(16);
|
|
232
|
+
for (let y = 0; y < height; y += 1) {
|
|
233
|
+
for (let x = 0; x < width; x += 1) {
|
|
234
|
+
const color = quantized[((startY + y) * stride) + startX + x] ?? 0;
|
|
235
|
+
if (color === excludedColor) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
counts[color] += 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const ranked = Array.from({ length: 16 }, (_, color) => color)
|
|
242
|
+
.filter((color) => counts[color] > 0)
|
|
243
|
+
.sort((left, right) => {
|
|
244
|
+
const countDiff = counts[right] - counts[left];
|
|
245
|
+
return countDiff !== 0 ? countDiff : left - right;
|
|
246
|
+
});
|
|
247
|
+
if (excludedColor === undefined && ranked.length === 1) {
|
|
248
|
+
ranked.push(ranked[0]);
|
|
249
|
+
}
|
|
250
|
+
return ranked.slice(0, count);
|
|
251
|
+
}
|
|
252
|
+
function fillMissingColors(colors, fallback) {
|
|
253
|
+
const values = [...colors];
|
|
254
|
+
while (values.length < 3) {
|
|
255
|
+
values.push(fallback);
|
|
256
|
+
}
|
|
257
|
+
return [values[0] ?? fallback, values[1] ?? fallback, values[2] ?? fallback];
|
|
258
|
+
}
|
|
259
|
+
function chooseNearestCandidate(color, candidates) {
|
|
260
|
+
let best = candidates[0] ?? 0;
|
|
261
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
262
|
+
for (const candidate of candidates) {
|
|
263
|
+
const distance = paletteDistance(color, candidate);
|
|
264
|
+
if (distance < bestDistance) {
|
|
265
|
+
best = candidate;
|
|
266
|
+
bestDistance = distance;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return best;
|
|
270
|
+
}
|
|
271
|
+
function nearestPaletteIndex(red, green, blue) {
|
|
272
|
+
let bestIndex = 0;
|
|
273
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
274
|
+
for (let index = 0; index < VIC_PALETTE.length; index += 1) {
|
|
275
|
+
const [candidateRed, candidateGreen, candidateBlue] = VIC_PALETTE[index];
|
|
276
|
+
const distance = squaredDistance(red, green, blue, candidateRed, candidateGreen, candidateBlue);
|
|
277
|
+
if (distance < bestDistance) {
|
|
278
|
+
bestIndex = index;
|
|
279
|
+
bestDistance = distance;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return bestIndex;
|
|
283
|
+
}
|
|
284
|
+
function paletteDistance(left, right) {
|
|
285
|
+
const [leftRed, leftGreen, leftBlue] = VIC_PALETTE[left] ?? VIC_PALETTE[0];
|
|
286
|
+
const [rightRed, rightGreen, rightBlue] = VIC_PALETTE[right] ?? VIC_PALETTE[0];
|
|
287
|
+
return squaredDistance(leftRed, leftGreen, leftBlue, rightRed, rightGreen, rightBlue);
|
|
288
|
+
}
|
|
289
|
+
function squaredDistance(leftRed, leftGreen, leftBlue, rightRed, rightGreen, rightBlue) {
|
|
290
|
+
const red = leftRed - rightRed;
|
|
291
|
+
const green = leftGreen - rightGreen;
|
|
292
|
+
const blue = leftBlue - rightBlue;
|
|
293
|
+
return (red * red) + (green * green) + (blue * blue);
|
|
294
|
+
}
|
|
295
|
+
function paletteColorToJimp(index) {
|
|
296
|
+
const [red, green, blue] = VIC_PALETTE[index] ?? VIC_PALETTE[0];
|
|
297
|
+
return (red << 24) | (green << 16) | (blue << 8) | 0xFF;
|
|
298
|
+
}
|
|
299
|
+
function normaliseColorIndex(value) {
|
|
300
|
+
if (!Number.isInteger(value) || value < 0 || value > 15) {
|
|
301
|
+
throw new Error(`VIC colour indices must be integers between 0 and 15 (received ${value})`);
|
|
302
|
+
}
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function validateAddress(address, length, label) {
|
|
306
|
+
if (!Number.isInteger(address) || address < 0 || address > 0xFFFF) {
|
|
307
|
+
throw new Error(`${label} must be an integer address within $0000-$FFFF`);
|
|
308
|
+
}
|
|
309
|
+
if (address + length - 1 > 0xFFFF) {
|
|
310
|
+
throw new Error(`${label} range must fit within 64 KB address space`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function rangesOverlap(leftStart, leftLength, rightStart, rightLength) {
|
|
314
|
+
const leftEnd = leftStart + leftLength - 1;
|
|
315
|
+
const rightEnd = rightStart + rightLength - 1;
|
|
316
|
+
return leftStart <= rightEnd && rightStart <= leftEnd;
|
|
317
|
+
}
|