c64bridge 1.0.5 → 1.0.6

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.
Files changed (46) hide show
  1. package/README.md +2 -2
  2. package/data/sound/sidwave.md +113 -36
  3. package/data/sound/sidwave.schema.json +138 -28
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/c64Client.js +83 -93
  6. package/dist/config.js +21 -4
  7. package/dist/device.js +130 -24
  8. package/dist/diagnostics.js +10 -2
  9. package/dist/mcp-server.js +12 -4
  10. package/dist/sidwave.js +453 -61
  11. package/dist/sidwaveCompiler.js +1424 -269
  12. package/dist/tools/audio.js +70 -12
  13. package/dist/tools/developer.js +7 -7
  14. package/dist/tools/errors.js +7 -0
  15. package/dist/tools/graphics.js +4 -4
  16. package/dist/tools/input.js +8 -11
  17. package/dist/tools/machineControl.js +6 -6
  18. package/dist/tools/memory.js +6 -6
  19. package/dist/tools/meta/audio.js +8 -2
  20. package/dist/tools/meta/graphics.js +1 -1
  21. package/dist/tools/meta/memory.js +4 -4
  22. package/dist/tools/meta/program.js +12 -8
  23. package/dist/tools/printer.js +4 -4
  24. package/dist/tools/programRunners.js +8 -8
  25. package/dist/tools/registry/disk.js +7 -5
  26. package/dist/tools/storage.js +21 -17
  27. package/dist/tools/streaming.js +2 -2
  28. package/dist/tools/translation/assembler.js +44 -12
  29. package/dist/tools/translation/basicTokenizer.js +5 -1
  30. package/dist/tools/translation/opcodeInventory.js +1 -1
  31. package/dist/tools/vice.js +1 -1
  32. package/dist/vice/mockServer.js +92 -5
  33. package/dist/vice/readiness.js +4 -9
  34. package/dist/vice/viceClient.js +18 -0
  35. package/mcp/schemas/c64_disk.schema.json +2 -2
  36. package/mcp/schemas/c64_input.schema.json +3 -3
  37. package/mcp/schemas/c64_program.schema.json +2 -2
  38. package/mcp/schemas/c64_vice.schema.json +3 -3
  39. package/mcp/server.json +1 -1
  40. package/mcp/tools.json +14 -10
  41. package/mcp.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/mockC64Server.mjs +15 -1
  44. package/scripts/run-coverage.mjs +31 -1
  45. package/scripts/update-readme.ts +3 -2
  46. package/server.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { assemblyToPrgDetailed, AssemblyError, prgEntryAddress } from "./translation/assembler.js";
2
+ import { assemblyToPrgDetailed, AssemblyError } from "./translation/assembler.js";
3
3
  import { basicToPrg } from "./translation/basicTokenizer.js";
4
4
  import { defineToolModule, OPERATION_DISCRIMINATOR, } from "./types.js";
5
5
  import { booleanSchema, objectSchema, optionalSchema, stringSchema } from "./schema.js";
@@ -267,7 +267,7 @@ async function executeUploadRunBasic(rawArgs, ctx) {
267
267
  const runBasic = async (source) => ctx.client.uploadAndRunBasic(source);
268
268
  let result = await runBasic(activeProgram);
269
269
  if (!result.success) {
270
- throw new ToolExecutionError("C64 firmware reported failure while running BASIC program", {
270
+ throw new ToolExecutionError("C64 backend reported failure while running BASIC program", {
271
271
  details: extractFailureDetails(result.details),
272
272
  });
273
273
  }
@@ -442,13 +442,13 @@ async function executeUploadRunAsm(rawArgs, ctx) {
442
442
  // Assemble locally to expose structured metadata and capture symbols
443
443
  const asmResult = assemblyToPrgDetailed(parsed.program);
444
444
  const prg = asmResult.prg;
445
- const entryAddress = prgEntryAddress(prg);
445
+ const entryAddress = asmResult.entryAddress;
446
446
  if (ctx.platform.id === "vice") {
447
447
  setViceSymbols(asmResult.symbols.entries());
448
448
  }
449
449
  const result = await ctx.client.uploadAndRunAsm(parsed.program);
450
450
  if (!result.success) {
451
- return toolErrorResult(new ToolExecutionError("C64 firmware reported failure while running assembly program", {
451
+ return toolErrorResult(new ToolExecutionError("C64 backend reported failure while running assembly program", {
452
452
  details: extractFailureDetails(result.details),
453
453
  }));
454
454
  }
@@ -516,7 +516,7 @@ async function executeLoadPrg(rawArgs, ctx) {
516
516
  ctx.logger.info("Loading PRG file", { path: parsed.path });
517
517
  const result = await ctx.client.loadPrgFile(parsed.path);
518
518
  if (!result.success) {
519
- throw new ToolExecutionError("C64 firmware reported failure while loading PRG", {
519
+ throw new ToolExecutionError("C64 backend reported failure while loading PRG", {
520
520
  details: extractFailureDetails(result.details),
521
521
  });
522
522
  }
@@ -566,7 +566,7 @@ async function executeRunPrg(rawArgs, ctx) {
566
566
  }
567
567
  const result = await ctx.client.runPrgFile(parsed.path);
568
568
  if (!result.success) {
569
- throw new ToolExecutionError("C64 firmware reported failure while running PRG", {
569
+ throw new ToolExecutionError("C64 backend reported failure while running PRG", {
570
570
  details: extractFailureDetails(result.details),
571
571
  });
572
572
  }
@@ -601,7 +601,7 @@ async function executeRunCrt(rawArgs, ctx) {
601
601
  ctx.logger.info("Running CRT file", { path: parsed.path });
602
602
  const result = await ctx.client.runCrtFile(parsed.path);
603
603
  if (!result.success) {
604
- throw new ToolExecutionError("C64 firmware reported failure while running CRT", {
604
+ throw new ToolExecutionError("C64 backend reported failure while running CRT", {
605
605
  details: extractFailureDetails(result.details),
606
606
  });
607
607
  }
@@ -773,7 +773,7 @@ export const programRunnersModule = defineToolModule({
773
773
  ctx.logger.info("Running CRT file", { path: parsed.path });
774
774
  const result = await ctx.client.runCrtFile(parsed.path);
775
775
  if (!result.success) {
776
- throw new ToolExecutionError("C64 firmware reported failure while running CRT", {
776
+ throw new ToolExecutionError("C64 backend reported failure while running CRT", {
777
777
  details: extractFailureDetails(result.details),
778
778
  });
779
779
  }
@@ -76,7 +76,7 @@ const createImageArgsSchema = objectSchema({
76
76
  maxLength: 16,
77
77
  })),
78
78
  tracks: optionalSchema(numberSchema({
79
- description: "Track count (D64 supports 35 or 40; DNP requires explicit tracks).",
79
+ description: "Track count (D64 supports 35 to 41; DNP requires explicit tracks).",
80
80
  integer: true,
81
81
  minimum: 1,
82
82
  maximum: 255,
@@ -210,10 +210,10 @@ const diskOperations = [
210
210
  const { path, diskname, tracks } = parsed;
211
211
  switch (format) {
212
212
  case "d64":
213
- if (tracks !== undefined && tracks !== 35 && tracks !== 40) {
214
- throw new ToolValidationError("D64 images support 35 or 40 tracks", {
213
+ if (tracks !== undefined && (tracks < 35 || tracks > 41)) {
214
+ throw new ToolValidationError("D64 images support 35 to 41 tracks", {
215
215
  path: "$.tracks",
216
- details: { allowed: [35, 40], received: tracks },
216
+ details: { minimum: 35, maximum: 41, received: tracks },
217
217
  });
218
218
  }
219
219
  return storageModule.invoke("create_d64", { path, tracks, diskname }, ctx);
@@ -274,8 +274,10 @@ export const diskModuleGroup = defineToolModule({
274
274
  variants: diskOperations.map((operation) => operation.schema),
275
275
  }),
276
276
  tags: ["storage", "drive", "grouped"],
277
- operationPlatforms: { file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"] },
277
+ // The VICE Binary Monitor has no command to detach a disk image, so unmount is Ultimate-only.
278
+ operationPlatforms: { unmount: ["c64u", "u2"], file_info: ["c64u", "u2"], create_image: ["c64u", "u2"], find_and_run: ["c64u", "u2"] },
278
279
  operationToolNames: {
280
+ unmount: "drive_remove",
279
281
  file_info: "file_info",
280
282
  create_image: "create_image",
281
283
  find_and_run: "find_and_run_program_by_name",
@@ -97,10 +97,10 @@ const createD64ArgsSchema = objectSchema({
97
97
  properties: {
98
98
  path: diskImagePathSchema,
99
99
  tracks: optionalSchema(numberSchema({
100
- description: "Track count (35 for standard, 40 for extended).",
100
+ description: "Track count: 35 for a standard disk, up to 41 for the extended area (firmware limit).",
101
101
  integer: true,
102
102
  minimum: 35,
103
- maximum: 40,
103
+ maximum: 41,
104
104
  })),
105
105
  diskname: diskNameSchema,
106
106
  },
@@ -175,7 +175,7 @@ export const storageModule = defineToolModule({
175
175
  drivesListArgsSchema.parse(args ?? {});
176
176
  ctx.logger.info("Listing Ultimate drives");
177
177
  const details = await ctx.client.drivesList();
178
- return textResult("Retrieved Ultimate drive status.", {
178
+ return textResult("Retrieved drive status.", {
179
179
  success: true,
180
180
  drives: details,
181
181
  });
@@ -221,14 +221,16 @@ export const storageModule = defineToolModule({
221
221
  mode: parsed.mode,
222
222
  });
223
223
  if (!result.success) {
224
- throw new ToolExecutionError("C64 firmware reported failure while mounting disk image", {
224
+ throw new ToolExecutionError("C64 backend reported failure while mounting disk image", {
225
225
  details: normaliseFailure(result.details),
226
226
  });
227
227
  }
228
228
  const details = toRecord(result.details) ?? {};
229
229
  const finalMode = typeof details.mode === "string" ? details.mode : parsed.mode ?? null;
230
230
  const finalType = typeof details.type === "string" ? details.type : parsed.type ?? null;
231
- return textResult(`Mounted ${parsed.image} on ${parsed.drive}.`, {
231
+ // Backend notes (for example VICE's autoload resetting the machine) belong in the visible text.
232
+ const note = typeof details.note === "string" ? ` ${details.note}` : "";
233
+ return textResult(`Mounted ${parsed.image} on ${parsed.drive}.${note}`, {
232
234
  success: true,
233
235
  drive: parsed.drive,
234
236
  image: parsed.image,
@@ -269,7 +271,7 @@ export const storageModule = defineToolModule({
269
271
  ctx.logger.info("Removing disk image", { drive: parsed.drive });
270
272
  const result = await ctx.client.driveRemove(parsed.drive);
271
273
  if (!result.success) {
272
- throw new ToolExecutionError("C64 firmware reported failure while removing disk image", {
274
+ throw new ToolExecutionError("C64 backend reported failure while removing disk image", {
273
275
  details: normaliseFailure(result.details),
274
276
  });
275
277
  }
@@ -311,7 +313,7 @@ export const storageModule = defineToolModule({
311
313
  ctx.logger.info("Resetting drive", { drive: parsed.drive });
312
314
  const result = await ctx.client.driveReset(parsed.drive);
313
315
  if (!result.success) {
314
- throw new ToolExecutionError("C64 firmware reported failure while resetting drive", {
316
+ throw new ToolExecutionError("C64 backend reported failure while resetting drive", {
315
317
  details: normaliseFailure(result.details),
316
318
  });
317
319
  }
@@ -353,7 +355,7 @@ export const storageModule = defineToolModule({
353
355
  ctx.logger.info("Powering on drive", { drive: parsed.drive });
354
356
  const result = await ctx.client.driveOn(parsed.drive);
355
357
  if (!result.success) {
356
- throw new ToolExecutionError("C64 firmware reported failure while powering on drive", {
358
+ throw new ToolExecutionError("C64 backend reported failure while powering on drive", {
357
359
  details: normaliseFailure(result.details),
358
360
  });
359
361
  }
@@ -396,7 +398,7 @@ export const storageModule = defineToolModule({
396
398
  ctx.logger.info("Powering off drive", { drive: parsed.drive });
397
399
  const result = await ctx.client.driveOff(parsed.drive);
398
400
  if (!result.success) {
399
- throw new ToolExecutionError("C64 firmware reported failure while powering off drive", {
401
+ throw new ToolExecutionError("C64 backend reported failure while powering off drive", {
400
402
  details: normaliseFailure(result.details),
401
403
  });
402
404
  }
@@ -439,7 +441,7 @@ export const storageModule = defineToolModule({
439
441
  ctx.logger.info("Loading drive ROM", { drive: parsed.drive, path: parsed.path });
440
442
  const result = await ctx.client.driveLoadRom(parsed.drive, parsed.path);
441
443
  if (!result.success) {
442
- throw new ToolExecutionError("C64 firmware reported failure while loading drive ROM", {
444
+ throw new ToolExecutionError("C64 backend reported failure while loading drive ROM", {
443
445
  details: normaliseFailure(result.details),
444
446
  });
445
447
  }
@@ -482,7 +484,7 @@ export const storageModule = defineToolModule({
482
484
  ctx.logger.info("Setting drive mode", { drive: parsed.drive, mode: parsed.mode });
483
485
  const result = await ctx.client.driveSetMode(parsed.drive, parsed.mode);
484
486
  if (!result.success) {
485
- throw new ToolExecutionError("C64 firmware reported failure while setting drive mode", {
487
+ throw new ToolExecutionError("C64 backend reported failure while setting drive mode", {
486
488
  details: normaliseFailure(result.details),
487
489
  });
488
490
  }
@@ -541,7 +543,7 @@ export const storageModule = defineToolModule({
541
543
  {
542
544
  name: "create_d64",
543
545
  description: "Create a blank D64 disk image on the Ultimate filesystem.",
544
- summary: "Allocates a 35 or 40 track D64 image with an optional disk name.",
546
+ summary: "Allocates a 35 to 41 track D64 image with an optional disk name.",
545
547
  inputSchema: createD64ArgsSchema.jsonSchema,
546
548
  relatedResources: ["c64://guide/bootstrap"],
547
549
  tags: ["disk", "create"],
@@ -569,14 +571,16 @@ export const storageModule = defineToolModule({
569
571
  diskname: parsed.diskname,
570
572
  });
571
573
  if (!result.success) {
572
- throw new ToolExecutionError("C64 firmware reported failure while creating D64 image", {
574
+ throw new ToolExecutionError("C64 backend reported failure while creating D64 image", {
573
575
  details: normaliseFailure(result.details),
574
576
  });
575
577
  }
578
+ const firmwareTracks = toRecord(result.details)?.tracks;
576
579
  return textResult(`Created D64 image at ${parsed.path}.`, {
577
580
  success: true,
578
581
  path: parsed.path,
579
- tracks: parsed.tracks ?? null,
582
+ // The firmware reports the track count it used, including its default of 35.
583
+ tracks: typeof firmwareTracks === "number" ? firmwareTracks : parsed.tracks ?? 35,
580
584
  diskname: parsed.diskname ?? null,
581
585
  details: toRecord(result.details) ?? {},
582
586
  });
@@ -618,7 +622,7 @@ export const storageModule = defineToolModule({
618
622
  diskname: parsed.diskname,
619
623
  });
620
624
  if (!result.success) {
621
- throw new ToolExecutionError("C64 firmware reported failure while creating D71 image", {
625
+ throw new ToolExecutionError("C64 backend reported failure while creating D71 image", {
622
626
  details: normaliseFailure(result.details),
623
627
  });
624
628
  }
@@ -666,7 +670,7 @@ export const storageModule = defineToolModule({
666
670
  diskname: parsed.diskname,
667
671
  });
668
672
  if (!result.success) {
669
- throw new ToolExecutionError("C64 firmware reported failure while creating D81 image", {
673
+ throw new ToolExecutionError("C64 backend reported failure while creating D81 image", {
670
674
  details: normaliseFailure(result.details),
671
675
  });
672
676
  }
@@ -717,7 +721,7 @@ export const storageModule = defineToolModule({
717
721
  diskname: parsed.diskname,
718
722
  });
719
723
  if (!result.success) {
720
- throw new ToolExecutionError("C64 firmware reported failure while creating DNP image", {
724
+ throw new ToolExecutionError("C64 backend reported failure while creating DNP image", {
721
725
  details: normaliseFailure(result.details),
722
726
  });
723
727
  }
@@ -80,7 +80,7 @@ export const streamingModule = defineToolModule({
80
80
  });
81
81
  const result = await ctx.client.streamStart(parsed.stream, parsed.target);
82
82
  if (!result.success) {
83
- throw new ToolExecutionError(withFailureReason("C64 firmware reported failure while starting stream", result.details), {
83
+ throw new ToolExecutionError(withFailureReason("C64 backend reported failure while starting stream", result.details), {
84
84
  details: toRecord(result.details),
85
85
  });
86
86
  }
@@ -120,7 +120,7 @@ export const streamingModule = defineToolModule({
120
120
  });
121
121
  const result = await ctx.client.streamStop(parsed.stream);
122
122
  if (!result.success) {
123
- throw new ToolExecutionError(withFailureReason("C64 firmware reported failure while stopping stream", result.details), {
123
+ throw new ToolExecutionError(withFailureReason("C64 backend reported failure while stopping stream", result.details), {
124
124
  details: toRecord(result.details),
125
125
  });
126
126
  }
@@ -126,7 +126,13 @@ export function assemblyToPrgDetailed(source, options) {
126
126
  const assembler = new Assembler(statements, loadAddress);
127
127
  assembler.runPass(0);
128
128
  assembler.runPass(1);
129
- return { prg: assembler.buildPrg(), symbols: assembler.getSymbols() };
129
+ const prg = assembler.buildPrg();
130
+ const loadAddressOfPrg = prg.length >= 2 ? prg.readUInt16LE(0) : loadAddress;
131
+ const stubTarget = prg.length >= 2 ? prgEntryAddress(prg) : loadAddressOfPrg;
132
+ const entryAddress = stubTarget !== loadAddressOfPrg
133
+ ? stubTarget
134
+ : assembler.getFirstInstructionAddress() ?? loadAddressOfPrg;
135
+ return { prg, symbols: assembler.getSymbols(), entryAddress };
130
136
  }
131
137
  function normalizeSource(input) {
132
138
  return input.replace(/\r\n?/g, "\n");
@@ -510,8 +516,14 @@ function parseUnaryExpression(stream, terminators) {
510
516
  }
511
517
  // In operand position, "<" and ">" are byte selectors and "*" is the current
512
518
  // PC; parseBinaryExpression consumes them as binary operators elsewhere.
519
+ // As in ACME, 64tass, DASM and Kick Assembler, "<" and ">" apply to the
520
+ // whole rest of the expression: "#>msg+1" is the high byte of msg+1.
521
+ if (token.kind === "punct" && (token.value === "<" || token.value === ">")) {
522
+ const operand = parseBinaryExpression(stream, terminators, 0);
523
+ return { type: "unary", op: token.value, operand };
524
+ }
513
525
  if (token.kind === "punct" &&
514
- (token.value === "+" || token.value === "-" || token.value === "!" || token.value === "~" || token.value === "<" || token.value === ">")) {
526
+ (token.value === "+" || token.value === "-" || token.value === "!" || token.value === "~")) {
515
527
  const operand = parseUnaryExpression(stream, terminators);
516
528
  return { type: "unary", op: token.value, operand };
517
529
  }
@@ -708,6 +720,8 @@ class Assembler {
708
720
  this.pc = 0;
709
721
  this.lowestWritten = MAX_ADDRESS;
710
722
  this.highestWritten = 0;
723
+ /** Reserved (.res) ranges from the last pass, as [start, end] inclusive. */
724
+ this.reserved = [];
711
725
  this.symbolTable.reset();
712
726
  }
713
727
  runPass(pass) {
@@ -719,6 +733,7 @@ class Assembler {
719
733
  this.highestWritten = 0;
720
734
  this.firstInstructionAddress = undefined;
721
735
  }
736
+ this.reserved = [];
722
737
  // Loading a PRG at $0801 makes BASIC relink the bytes there as program
723
738
  // lines, which corrupts raw machine code. Code that starts at $0801
724
739
  // without its own org therefore gets a "10 SYS" stub that jumps to the
@@ -766,13 +781,33 @@ class Assembler {
766
781
  if (this.lowestWritten > this.highestWritten) {
767
782
  return Buffer.alloc(0);
768
783
  }
769
- const start = this.lowestWritten;
770
- const end = this.highestWritten;
784
+ let start = this.lowestWritten;
785
+ let end = this.highestWritten;
786
+ // Reserved space that adjoins the emitted bytes (a trailing "counter: .res 1")
787
+ // is part of the image and zero-filled, as in ca65 and DASM; detached blocks
788
+ // such as zero-page variables are not, so they do not stretch the PRG.
789
+ for (let changed = true; changed;) {
790
+ changed = false;
791
+ for (const [resStart, resEnd] of this.reserved) {
792
+ // Adjoining or overlapping blocks extend the image.
793
+ if (resStart <= end + 1 && resEnd > end) {
794
+ end = resEnd;
795
+ changed = true;
796
+ }
797
+ if (resEnd >= start - 1 && resStart < start) {
798
+ start = resStart;
799
+ changed = true;
800
+ }
801
+ }
802
+ }
771
803
  const body = this.memory.subarray(start, end + 1);
772
804
  const header = Buffer.alloc(2);
773
805
  header.writeUInt16LE(start, 0);
774
806
  return Buffer.concat([header, Buffer.from(body)]);
775
807
  }
808
+ getFirstInstructionAddress() {
809
+ return this.firstInstructionAddress;
810
+ }
776
811
  getSymbols() {
777
812
  return this.symbolTable.allGlobals();
778
813
  }
@@ -898,14 +933,11 @@ class Assembler {
898
933
  if (amount < 0) {
899
934
  throw new AssemblyError("reserve directive requires a non-negative size", location);
900
935
  }
901
- if (pass === 1) {
902
- for (let i = 0; i < amount; i += 1) {
903
- this.writeByte(0, location);
904
- }
905
- }
906
- else {
907
- this.pc += amount;
908
- }
936
+ // Reserved space advances the PC without emitting; buildPrg includes it
937
+ // (zero-filled) only where it adjoins emitted bytes.
938
+ if (amount > 0)
939
+ this.reserved.push([this.pc, Math.min(0xffff, this.pc + amount - 1)]);
940
+ this.pc = (this.pc + amount) & 0xffff;
909
941
  break;
910
942
  }
911
943
  case "include": {
@@ -116,7 +116,11 @@ function tokenize(content) {
116
116
  }
117
117
  continue;
118
118
  }
119
- const plainChar = (!inString && !inRemark ? upper[index] : char) ?? char;
119
+ // REM text folds only ASCII a-z, like string text (LIST would show them as
120
+ // graphics); full Unicode upper-casing could change length or leave Latin-1.
121
+ const plainChar = inRemark
122
+ ? (char >= "a" && char <= "z" ? char.toUpperCase() : char)
123
+ : (!inString ? upper[index] : char) ?? char;
120
124
  bytes.push(asciiToPetscii(plainChar));
121
125
  if (inData && !inString && plainChar === ":") {
122
126
  inData = false;
@@ -55,7 +55,7 @@ const OPCODE_ROWS = [
55
55
  // 0xE0-0xEF
56
56
  [["CPX", M.Imm], ["SBC", M.IndX], ["NOP", M.Imm], ["INS", M.IndX], ["CPX", M.Zp], ["SBC", M.Zp], ["INC", M.Zp], ["INS", M.Zp], ["INX", M.Imp], ["SBC", M.Imm], ["NOP", M.Imp], ["SBC", M.Imm], ["CPX", M.Abs], ["SBC", M.Abs], ["INC", M.Abs], ["INS", M.Abs]],
57
57
  // 0xF0-0xFF
58
- [["BEQ", M.Rel], ["SBC", M.IndY], ["HLT", M.Imp], ["INS", M.IndY], ["NOP", M.ZpS], ["SBC", M.ZpX], ["INC", M.ZpX], ["INS", M.ZpX], ["SED", M.Imp], ["SBC", M.AbsY], ["NOP", M.Imp], ["INS", M.AbsY], ["NOP", M.AbsX], ["SBC", M.AbsX], ["INC", M.AbsX], ["INS", M.AbsX]],
58
+ [["BEQ", M.Rel], ["SBC", M.IndY], ["HLT", M.Imp], ["INS", M.IndY], ["NOP", M.ZpX], ["SBC", M.ZpX], ["INC", M.ZpX], ["INS", M.ZpX], ["SED", M.Imp], ["SBC", M.AbsY], ["NOP", M.Imp], ["INS", M.AbsY], ["NOP", M.AbsX], ["SBC", M.AbsX], ["INC", M.AbsX], ["INS", M.AbsX]],
59
59
  ];
60
60
  const ILLEGAL_MAP = [
61
61
  0x989C, 0x9C9C, 0x888C, 0x9C9C,
@@ -33,7 +33,7 @@ const resourceValueSchema = {
33
33
  },
34
34
  };
35
35
  const resourceNameSchema = stringSchema({
36
- description: "VICE resource name (example: SidEngine, C64Model).",
36
+ description: "VICE resource name (example: SidEngine, SidModel).",
37
37
  minLength: 1,
38
38
  maxLength: 64,
39
39
  pattern: RESOURCE_NAME_PATTERN,
@@ -40,10 +40,16 @@ export class ViceMockServer {
40
40
  this.registerMetadata = buildDefaultRegisterMetadata();
41
41
  this.registerValues = new Map();
42
42
  this.resources = new Map();
43
+ /** Output lines of VICE's I/O simulation joyport device per port (0 = port 1). */
44
+ this.joyportOutput = [0, 0];
43
45
  this.sockets = new Set();
46
+ /** Images attached with autostart, and drive CPU resets, as observed by tests. */
47
+ this.autostarts = [];
48
+ this.driveResets = [];
44
49
  this.host = options?.host ?? "127.0.0.1";
45
50
  this.requestedPort = options?.port;
46
51
  this.resetState();
52
+ this.resetResources();
47
53
  }
48
54
  async start() {
49
55
  if (this.server)
@@ -114,9 +120,26 @@ export class ViceMockServer {
114
120
  switch (cmd) {
115
121
  case 0x85: // info
116
122
  return buildResponse(cmd, reqId);
117
- case 0xCC: // reset
118
- this.resetState();
123
+ case 0xCC: { // reset: types 8-11 reset only that drive's CPU
124
+ const type = body[0] ?? 0;
125
+ if (type >= 8 && type <= 11) {
126
+ this.driveResets.push(type);
127
+ }
128
+ else {
129
+ this.resetState();
130
+ }
131
+ return buildResponse(cmd, reqId);
132
+ }
133
+ case 0xDD: { // autostart: power-cycles, attaches the image to drive 8 and loads (run=1 also runs)
134
+ const nameLength = body[3] ?? 0;
135
+ this.autostarts.push({
136
+ run: body[0] === 1,
137
+ fileIndex: body.readUInt16LE(1),
138
+ filename: body.subarray(4, 4 + nameLength).toString("utf8"),
139
+ });
140
+ this.renderAutoload(body[0] === 1);
119
141
  return buildResponse(cmd, reqId);
142
+ }
120
143
  case 0x11: { // checkpoint get
121
144
  const id = body.readUInt32LE(0);
122
145
  const checkpoint = this.checkpoints.get(id);
@@ -185,13 +208,17 @@ export class ViceMockServer {
185
208
  case 0x01: { // mem get
186
209
  const start = body.readUInt16LE(1);
187
210
  const end = body.readUInt16LE(3);
188
- const length = Math.max(0, end - start + 1);
211
+ if (start > end) {
212
+ return buildResponse(cmd, reqId, Buffer.alloc(0), 0x81); // VICE rejects start > end with INVALID_PARAMETER.
213
+ }
214
+ const length = end - start + 1;
189
215
  const payload = Buffer.alloc(2 + length);
190
216
  payload.writeUInt16LE(length & 0xffff, 0); // Like VICE: a full 64 KB read reports ML = 0.
191
217
  for (let i = 0; i < length; i += 1) {
192
218
  payload[2 + i] = this.memory[(start + i) & 0xffff];
193
219
  }
194
220
  this.applyHeartbeatOverlay(start, length, payload);
221
+ this.applyJoyportOverlay(start, length, payload);
195
222
  return buildResponse(cmd, reqId, payload);
196
223
  }
197
224
  case 0x02: { // mem set
@@ -246,7 +273,7 @@ export class ViceMockServer {
246
273
  const name = body.subarray(1, 1 + nameLen).toString("utf8");
247
274
  const value = this.resources.get(name);
248
275
  if (value === undefined) {
249
- return buildResponse(cmd, reqId, Buffer.from([0, 0]));
276
+ return buildResponse(cmd, reqId, Buffer.alloc(0), 0x01); // VICE: OBJECT_MISSING for an unknown resource.
250
277
  }
251
278
  if (typeof value === "number") {
252
279
  const buffer = Buffer.alloc(2 + 4);
@@ -266,12 +293,20 @@ export class ViceMockServer {
266
293
  const isNumber = body[0] === 1;
267
294
  const nameLen = body[1] ?? 0;
268
295
  const name = body.subarray(2, 2 + nameLen).toString("utf8");
296
+ if (!this.resources.has(name)) {
297
+ // VICE: an unknown integer resource fails resources_set (CMD_FAILURE); an unknown string one is OBJECT_MISSING.
298
+ return buildResponse(cmd, reqId, Buffer.alloc(0), isNumber ? 0x8f : 0x01);
299
+ }
269
300
  if (isNumber) {
270
301
  const valueLen = body[2 + nameLen] ?? 0;
271
302
  const valueBytes = body.subarray(3 + nameLen, 3 + nameLen + valueLen);
272
303
  const padded = Buffer.alloc(4);
273
304
  valueBytes.copy(padded, 0, 0, Math.min(4, valueBytes.length));
274
305
  const value = padded.readInt32LE(0);
306
+ // VICE rejects joyport device ids it does not have (JOYPORT_MAX_DEVICES is 44 in 3.10).
307
+ if (/^JoyPort\dDevice$/.test(name) && (value < 0 || value >= 44)) {
308
+ return buildResponse(cmd, reqId, Buffer.alloc(0), 0x8f);
309
+ }
275
310
  this.resources.set(name, value);
276
311
  }
277
312
  else {
@@ -281,6 +316,18 @@ export class ViceMockServer {
281
316
  }
282
317
  return buildResponse(cmd, reqId);
283
318
  }
319
+ case 0xA2: { // joyport set: like VICE, it only stores the I/O simulation device's output lines
320
+ const port = body.readUInt16LE(0);
321
+ const value = body.readUInt16LE(2);
322
+ if (port > 1) {
323
+ return buildResponse(cmd, reqId, Buffer.alloc(0), 0x01);
324
+ }
325
+ if (value > 0xff) {
326
+ return buildResponse(cmd, reqId, Buffer.alloc(0), 0x81);
327
+ }
328
+ this.joyportOutput[port] = value & 0x1f;
329
+ return buildResponse(cmd, reqId);
330
+ }
284
331
  case 0xAA: // exit monitor
285
332
  return buildResponse(cmd, reqId);
286
333
  case 0xBB: // quit
@@ -434,11 +481,51 @@ export class ViceMockServer {
434
481
  for (const meta of this.registerMetadata) {
435
482
  this.registerValues.set(meta.id, 0);
436
483
  }
484
+ this.joyportOutput = [0, 0];
485
+ }
486
+ // Resources survive a machine reset in VICE. Only resources that exist in
487
+ // VICE 3.10 are known; others fail the way VICE fails (see below).
488
+ resetResources() {
437
489
  this.resources = new Map([
438
- ["C64Model", 2],
439
490
  ["SidEngine", 1],
491
+ ["JoyPort1Device", 1],
492
+ ["JoyPort2Device", 1],
493
+ ["Drive8Type", 1541],
494
+ ["Drive9Type", 0],
495
+ ["Drive10Type", 0],
496
+ ["Drive11Type", 0],
497
+ ["VICIIDoubleSize", 0],
498
+ ["VICIIBorderMode", 0],
499
+ ["VICIIFullscreen", 0],
500
+ ["SoundVolume", 100],
501
+ ["SidModel", 0],
502
+ ["MachineVideoStandard", 1],
503
+ ["KernalName", "kernal-901227-03.bin"],
440
504
  ]);
441
505
  }
506
+ /** Lines a joyport device drives, as CIA1 sees them (active low); only the I/O simulation device (37) is modelled. */
507
+ joyportLines(port) {
508
+ return this.resources.get(`JoyPort${port}Device`) === 37 ? this.joyportOutput[port - 1] : 0xff;
509
+ }
510
+ // CIA1 port A ($DC00) reads joyport 2 and port B ($DC01) joyport 1, ANDed with the keyboard lines.
511
+ applyJoyportOverlay(start, length, payload) {
512
+ for (const [address, port] of [[0xdc00, 2], [0xdc01, 1]]) {
513
+ if (address >= start && address < start + length) {
514
+ payload[2 + address - start] = 0xff & this.joyportLines(port);
515
+ }
516
+ }
517
+ }
518
+ // What VICE's autoload leaves on screen after its power cycle: the typed LOAD and READY.
519
+ renderAutoload(run) {
520
+ // The power cycle's KERNAL reset clears $0200-$03FF (RAMTAS).
521
+ this.memory.fill(0x00, 0x0200, 0x0400);
522
+ this.memory.fill(0x20, SCREEN_BASE, SCREEN_BASE + 1000);
523
+ const lines = ['LOAD"*",8,1', "", "SEARCHING FOR *", "LOADING", "READY.", ...(run ? ["RUN"] : [])];
524
+ lines.forEach((line, row) => {
525
+ const codes = asciiToScreenCodes(line);
526
+ codes.copy(Buffer.from(this.memory.buffer, SCREEN_BASE + row * 40, codes.length));
527
+ });
528
+ }
442
529
  renderHello() {
443
530
  if (this.helloReady)
444
531
  return;
@@ -126,15 +126,10 @@ export function asciiToScreenCodes(text) {
126
126
  out.push(ch.charCodeAt(0) - 64);
127
127
  else if (ch >= '0' && ch <= '9')
128
128
  out.push(0x30 + (ch.charCodeAt(0) - 48));
129
- else if (ch === ' ')
130
- out.push(0x20);
131
- else if (ch === '.')
132
- out.push(0x2E);
133
- else if (ch === ':')
134
- out.push(0x3A);
135
- else if (ch === '-')
136
- out.push(0x2D);
137
- // extend as needed; ignore others
129
+ // Space, digits and punctuation ($20-$3F) have the same screen codes as ASCII.
130
+ else if (ch >= ' ' && ch <= '?')
131
+ out.push(ch.charCodeAt(0));
132
+ // ignore others
138
133
  }
139
134
  return Buffer.from(out);
140
135
  }
@@ -159,6 +159,24 @@ export class ViceClient {
159
159
  async resetSoft() { await this.send(0xCC, Buffer.from([0x00])); }
160
160
  async resetHard() { await this.send(0xCC, Buffer.from([0x01])); }
161
161
  async reset(type = 0) { await this.send(0xCC, Buffer.from([type])); }
162
+ /** Reset only the drive CPU of unit 8-11 (BM 0xCC reset types 8-11). */
163
+ async resetDrive(unit) { await this.send(0xCC, Buffer.from([unit])); }
164
+ /**
165
+ * Attach an image and load from it (BM 0xDD Autostart). With run=false VICE
166
+ * uses its autoload mode, which attaches to drive 8 and LOADs without RUN.
167
+ */
168
+ async autostart(filename, options) {
169
+ const name = Buffer.from(filename, "utf8");
170
+ if (name.length === 0 || name.length > 255) {
171
+ throw new Error("Autostart file names must be 1-255 bytes");
172
+ }
173
+ const body = Buffer.alloc(4 + name.length);
174
+ body[0] = options.run ? 1 : 0;
175
+ body.writeUInt16LE(options.fileIndex ?? 0, 1);
176
+ body[3] = name.length;
177
+ name.copy(body, 4);
178
+ await this.send(0xDD, body);
179
+ }
162
180
  /**
163
181
  * Exit the emulator process (BM 0xBB Quit). The socket will be closed by VICE.
164
182
  * Consumers should still call close() to dispose any local resources.
@@ -119,7 +119,7 @@
119
119
  "integer",
120
120
  "null"
121
121
  ],
122
- "description": "Track count (D64 supports 35 or 40; DNP requires explicit tracks).",
122
+ "description": "Track count (D64 supports 35 to 41; DNP requires explicit tracks).",
123
123
  "minimum": 1,
124
124
  "maximum": 255
125
125
  },
@@ -421,7 +421,7 @@
421
421
  "integer",
422
422
  "null"
423
423
  ],
424
- "description": "Track count (D64 supports 35 or 40; DNP requires explicit tracks).",
424
+ "description": "Track count (D64 supports 35 to 41; DNP requires explicit tracks).",
425
425
  "minimum": 1,
426
426
  "maximum": 255
427
427
  },