@henols/vice-mcp 0.2.1 → 0.2.3

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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/stock-protocol.ts CHANGED
@@ -41,7 +41,7 @@
41
41
  import { EventEmitter } from "node:events";
42
42
  import net from "node:net";
43
43
 
44
- import { ViceError } from "./vice.ts";
44
+ import { ViceError } from "./vice-errors.ts";
45
45
 
46
46
  // ---------------------------------------------------------------------------
47
47
  // Wire constants (hand-copied, not imported -- see header comment above)
@@ -720,12 +720,15 @@ export function registersSetBody({ memspace, items }: RegistersSetBodyOptions):
720
720
 
721
721
  // ---------------------------------------------------------------------------
722
722
  // Execution and machine-control body encoders (Phase 3, Task 2). Every
723
- // body layout below is [CITED] against the official VICE manual (§13) but
724
- // has NOT been exercised against a real binary in this environment -- each
725
- // JSDoc says so explicitly, and where RESEARCH.md's Assumptions Log flags a
726
- // behavioural (not wire-shape) assumption, the JSDoc names the row (A2, A3,
727
- // A5) and points at .planning/todos/pending/ for the probe debt. None of
728
- // these are claimed as verified.
723
+ // body layout below is [CITED] against the official VICE manual (§13);
724
+ // each encoder's own JSDoc states whether its behavioural (not wire-shape)
725
+ // claim has since been live-probed. RESEARCH.md Assumptions Log row A2 was
726
+ // closed CONFIRMED by 13-PROBE-RESULTS.md. Two rows below are still
727
+ // unconfirmed -- A3 stayed INCONCLUSIVE (no observable signal either way)
728
+ // and A5 was CONTRADICTED (still pending its correction) -- and each of
729
+ // those two encoders' own JSDoc, below, still carries its own [ASSUMED]
730
+ // naming that row and pointing at .planning/todos/pending/ for the
731
+ // remaining probe debt.
729
732
  // ---------------------------------------------------------------------------
730
733
 
731
734
  export interface AdvanceInstructionsBodyOptions {
@@ -739,9 +742,11 @@ export interface AdvanceInstructionsBodyOptions {
739
742
  * exercised, with stepOver=0 only, in probe-binmon.mjs's async-events check]
740
743
  *
741
744
  * `stepOver = true`'s runtime meaning (skip a `JSR`'s subroutine as one
742
- * step, matching the fork's own `stepOver` field name) is [ASSUMED] --
743
- * RESEARCH.md Assumptions Log row A2 -- never probed against a real `JSR`.
744
- * See `.planning/todos/pending/` for the outstanding probe debt.
745
+ * step, matching the fork's own `stepOver` field name) was live-probed
746
+ * against a real `JSR` on fork VICE 3.10 and CONFIRMED (RESEARCH.md
747
+ * Assumptions Log row A2, closed by 13-PROBE-RESULTS.md §A2): the post-step
748
+ * PC landed at JSR+3, reproduced identically across two independent live
749
+ * sessions.
745
750
  */
746
751
  export function advanceInstructionsBody({ stepOver = false, count = 1 }: AdvanceInstructionsBodyOptions = {}): Buffer {
747
752
  if (!Number.isInteger(count) || count < 1 || count > 0xffff) {
@@ -816,8 +821,8 @@ export interface ResetBodyOptions {
816
821
  * [CITED docs/phase0-binmon-findings.md §5]
817
822
  *
818
823
  * NOT the RESOURCE_SET (0x52) power-cycle hazard CLAUDE.md warns about
819
- * (`MachineVideoStandard`/`VICIIModel`/`MachinePowerFrequency`, Phase 6
820
- * territory) -- this is a distinct opcode, and an agent-requested hard
824
+ * (`MachineVideoStandard`/`VICIIModel`/`MachinePowerFrequency`, the CUT
825
+ * vice_machine_config_get/set pair's resources) -- a distinct opcode, and an agent-requested hard
821
826
  * reset via RESET is exactly what DIRECT-06 asks for. It needs no
822
827
  * deny-list. This is RESEARCH.md's Pitfall 1; this comment is what stops a
823
828
  * later reviewer from "fixing" it by adding one.
package/stock-recycle.ts CHANGED
@@ -64,7 +64,7 @@ import { handleRegistersGet } from "./stock-registers.ts";
64
64
  import { stockAnswer, isErrorText, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
65
65
  import { stockDisconnect, type StockConnectSession } from "./stock-connect.ts";
66
66
  import type { StockDispatchDeps } from "./stock-dispatch.ts";
67
- import { readEpoch } from "./vice.ts";
67
+ import { readEpoch } from "./vice-errors.ts";
68
68
 
69
69
  function describeError(err: unknown): string {
70
70
  return err instanceof Error ? err.message : String(err);
@@ -307,6 +307,81 @@ export async function gatherStockWedgeEvidence(session: StockConnectSession, dep
307
307
  * per-outcome vocabulary is the same one. Redeclared locally rather than
308
308
  * imported: importing it would mean importing vice-proxy.ts, which this
309
309
  * module must never do. */
310
+ const DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS = 3000;
311
+ const RECYCLE_EPOCH_POLL_INTERVAL_MS = 50;
312
+
313
+ /** Read fresh on EVERY call -- same load-time-vs-call-time reasoning as
314
+ * stockCaptureStepTimeoutMs() above: a static `import` is hoisted ahead of
315
+ * any top-level statement in the importing file, so a module-level constant
316
+ * computed once at load time could never be retuned by a test that sets
317
+ * `process.env` afterwards. Deliberately its OWN environment variable,
318
+ * distinct from `VICE_RECYCLE_CAPTURE_TIMEOUT_MS` -- that knob bounds one
319
+ * evidence-gathering step before the kill; this one bounds the epoch poll
320
+ * after it, and a single shared knob would let retuning one silently retune
321
+ * the other. Exported so the test file can assert the default directly. */
322
+ export function stockRecycleEpochPollTimeoutMs(): number {
323
+ const raw = process.env.VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
324
+ if (raw === undefined || raw === "") return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
325
+ const parsed = Number(raw);
326
+ // A non-positive deadline would make the poll finish before it ever reads,
327
+ // so every confirmed kill would record a null epoch_after -- and the field
328
+ // would look present (the producer ran) while carrying no information at
329
+ // all, which is worse than the missing producer this poll exists to fix.
330
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
331
+ console.error(
332
+ `VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS=${JSON.stringify(raw)} is not a positive number of milliseconds -- ignoring it and using the ` +
333
+ `default ${DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS}ms. A value of 0 would end the post-kill epoch poll before its first read, so a ` +
334
+ "confirmed kill would always record a null epoch_after, indistinguishable from a genuine stall.",
335
+ );
336
+ return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
337
+ }
338
+
339
+ function sleep(ms: number): Promise<void> {
340
+ return new Promise((resolve) => setTimeout(resolve, ms));
341
+ }
342
+
343
+ /** The ONE predicate that may promote a post-kill read into the record. A
344
+ * present-but-unchanged value must stay out: a pair of equal before/after
345
+ * numbers reads to a future investigator as a confirmed no-turnover, which is
346
+ * a false claim for a kill the guard above this call already established
347
+ * succeeded. Absence of a pre-kill epoch (a lease that never had one) makes
348
+ * the first present read count as an advance -- there is nothing higher than
349
+ * "nothing" to compare against. */
350
+ function epochAdvanced(before: number | null, after: ReturnType<typeof readEpoch>): boolean {
351
+ if (!after.present || after.epoch === null) return false;
352
+ return before === null || after.epoch > before;
353
+ }
354
+
355
+ /**
356
+ * Bounded post-kill epoch poll. Reuses the SAME dependency-injected reader
357
+ * and file path the pre-kill read above already used, so the before and
358
+ * after values come from one source. Loops until either the epoch has
359
+ * advanced (epochAdvanced() above) or its own wall-clock deadline passes,
360
+ * clamping the final sleep so the loop cannot overshoot that deadline. A
361
+ * lease with no epoch file polls nothing and resolves null immediately --
362
+ * there is nothing to read. A throw from the reader is treated as a read
363
+ * that did not advance, never as a fatal error on a path that runs after a
364
+ * destructive action has already happened.
365
+ */
366
+ async function pollEpochAfter(readEpochFn: typeof readEpoch, epochFile: string, epochBefore: number | null): Promise<number | null> {
367
+ if (!epochFile) return null;
368
+ const deadline = Date.now() + stockRecycleEpochPollTimeoutMs();
369
+ for (;;) {
370
+ let result: ReturnType<typeof readEpoch> | null;
371
+ try {
372
+ result = readEpochFn(epochFile);
373
+ } catch {
374
+ result = null;
375
+ }
376
+ if (result && epochAdvanced(epochBefore, result)) {
377
+ return result.epoch;
378
+ }
379
+ const remaining = deadline - Date.now();
380
+ if (remaining <= 0) return null;
381
+ await sleep(Math.min(RECYCLE_EPOCH_POLL_INTERVAL_MS, remaining));
382
+ }
383
+ }
384
+
310
385
  function recycleAckOutcomeMessage(ack: { outcome: string; kill_stage: string; reason: string }): string {
311
386
  const stage = ack.kill_stage || "unknown";
312
387
  const reasonSuffix = ack.reason ? ` (${ack.reason})` : "";
@@ -472,7 +547,13 @@ export async function handleRecycleStock(args: Record<string, unknown>, session:
472
547
  return isErrorText(`vice_recycle: ${recycleAckOutcomeMessage(ack)} Incident record: ${recordPath}.`);
473
548
  }
474
549
 
475
- finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage });
550
+ // Post-kill epoch poll -- the record's own `epoch_after` producer. Only
551
+ // reached on a confirmed kill: a refusal, a timeout or a broker-gone
552
+ // outcome each leave the machine's state unknown, and polling for an
553
+ // epoch advance on any of those would invent a fact this handler has no
554
+ // basis for.
555
+ const epochAfter = await pollEpochAfter(readEpochFn, lease.epochFile, epochBefore);
556
+ finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage, epoch_after: epochAfter });
476
557
 
477
558
  // stockAnswer() stamps runState from session.client -- read BEFORE the
478
559
  // teardown below disconnects it, so the answer reports the machine's
@@ -68,12 +68,19 @@ export interface RegisterCatalog {
68
68
  /** The one module-level catalog map, keyed on the session object itself --
69
69
  * NOT on session.client -- so a fresh stockReconnect() (which returns a
70
70
  * brand-new session) is indistinguishable from "never fetched" and simply
71
- * fetches again, with no manual invalidation path required anywhere. */
71
+ * fetches again, with no manual invalidation path required anywhere.
72
+ *
73
+ * Caches the in-flight PROMISE, not the resolved catalog (15-04, IN-02):
74
+ * two handlers racing on a fresh session both read `catalogs.get(session)`
75
+ * before either write lands, so caching only the resolved value let both
76
+ * send their own REGISTERS_AVAILABLE. Caching the promise means the second
77
+ * caller awaits the SAME in-flight request. A rejected promise is evicted
78
+ * (see registerCatalogFor()) so a failed fetch is retried, never memoised. */
72
79
  // Single-line by design: the ONLY line in this file naming the garbage-
73
80
  // collectable, session-keyed map primitive directly (grep-gated -- see
74
81
  // this plan's own acceptance criteria). Every other reference goes
75
82
  // through this factory, never a second construction call site.
76
- function freshCatalogMap(): WeakMap<StockConnectSession, RegisterCatalog> { return new WeakMap<StockConnectSession, RegisterCatalog>(); }
83
+ function freshCatalogMap(): WeakMap<StockConnectSession, Promise<RegisterCatalog>> { return new WeakMap<StockConnectSession, Promise<RegisterCatalog>>(); }
77
84
 
78
85
  let catalogs = freshCatalogMap();
79
86
 
@@ -86,15 +93,22 @@ export function resetRegisterCatalogsForTest(): void {
86
93
 
87
94
  /**
88
95
  * Resolves `session`'s register catalog, fetching it through
89
- * REGISTERS_AVAILABLE (0x83) exactly once and caching the result on the
90
- * session object. Every subsequent call for the SAME session object
91
- * returns the cached catalog with no further wire traffic.
96
+ * REGISTERS_AVAILABLE (0x83) exactly once and caching the IN-FLIGHT
97
+ * PROMISE on the session object -- not just the resolved value, so two
98
+ * concurrent callers on a fresh session (before either fetch has
99
+ * resolved) share the SAME REGISTERS_AVAILABLE round trip rather than
100
+ * each sending their own (15-04, IN-02). Every subsequent call for the
101
+ * SAME session object returns the same promise (and, once it settles,
102
+ * the same resolved catalog) with no further wire traffic.
92
103
  *
93
104
  * Refuses (throws a plain Error, converted by the caller through
94
105
  * convertWireError()) an empty enumeration rather than caching it: a
95
106
  * build that enumerates zero registers cannot support
96
107
  * vice_registers_set, and that failure must be visible on every call,
97
- * never silently cached as "zero registers, nothing to resolve".
108
+ * never silently cached as "zero registers, nothing to resolve". A
109
+ * rejected fetch, whether from an empty enumeration or a wire error, is
110
+ * evicted from the cache before this function returns, so the NEXT call retries instead
111
+ * of permanently memoising the failure.
98
112
  */
99
113
  export async function registerCatalogFor(session: StockConnectSession): Promise<RegisterCatalog> {
100
114
  const existing = catalogs.get(session);
@@ -102,30 +116,41 @@ export async function registerCatalogFor(session: StockConnectSession): Promise<
102
116
  return existing;
103
117
  }
104
118
 
105
- const response = await session.client.send(CommandType.RegistersAvailable, memspaceBody({ memspace: 0x00 }));
106
- if (response.type !== "registers_available") {
107
- throw new Error(`registerCatalogFor: expected a registers_available reply, got "${response.type}"`);
108
- }
109
- if (response.registers.length === 0) {
110
- throw new Error(
111
- "registerCatalogFor: the connected VICE build enumerated zero registers via REGISTERS_AVAILABLE -- " +
112
- "it cannot support vice_registers_set, and this failure must be named rather than cached as an empty catalog",
113
- );
114
- }
119
+ const pending = (async (): Promise<RegisterCatalog> => {
120
+ const response = await session.client.send(CommandType.RegistersAvailable, memspaceBody({ memspace: 0x00 }));
121
+ if (response.type !== "registers_available") {
122
+ throw new Error(`registerCatalogFor: expected a registers_available reply, got "${response.type}"`);
123
+ }
124
+ if (response.registers.length === 0) {
125
+ throw new Error(
126
+ "registerCatalogFor: the connected VICE build enumerated zero registers via REGISTERS_AVAILABLE -- " +
127
+ "it cannot support vice_registers_set, and this failure must be named rather than cached as an empty catalog",
128
+ );
129
+ }
115
130
 
116
- const byName = new Map<string, { id: number; sizeBits: number; name: string }>();
117
- const byId = new Map<number, { sizeBits: number; name: string }>();
118
- for (const reg of response.registers) {
119
- // reg.size is stock-protocol.ts's own field name for the wire's size
120
- // byte (its parser is unchanged by this plan); this module renames it
121
- // to sizeBits at the point it enters the catalog so every downstream
122
- // reader sees the unit named in the type.
123
- byName.set(reg.name.toUpperCase(), { id: reg.id, sizeBits: reg.size, name: reg.name });
124
- byId.set(reg.id, { sizeBits: reg.size, name: reg.name });
125
- }
126
- const catalog: RegisterCatalog = { byName, byId };
127
- catalogs.set(session, catalog);
128
- return catalog;
131
+ const byName = new Map<string, { id: number; sizeBits: number; name: string }>();
132
+ const byId = new Map<number, { sizeBits: number; name: string }>();
133
+ for (const reg of response.registers) {
134
+ // reg.size is stock-protocol.ts's own field name for the wire's size
135
+ // byte (its parser is unchanged by this plan); this module renames it
136
+ // to sizeBits at the point it enters the catalog so every downstream
137
+ // reader sees the unit named in the type.
138
+ byName.set(reg.name.toUpperCase(), { id: reg.id, sizeBits: reg.size, name: reg.name });
139
+ byId.set(reg.id, { sizeBits: reg.size, name: reg.name });
140
+ }
141
+ return { byName, byId };
142
+ })();
143
+
144
+ catalogs.set(session, pending);
145
+ // Evict on rejection so a failed fetch is retried by the next call
146
+ // rather than memoised forever -- only if no newer promise has already
147
+ // replaced this one for the same session.
148
+ pending.catch(() => {
149
+ if (catalogs.get(session) === pending) {
150
+ catalogs.delete(session);
151
+ }
152
+ });
153
+ return pending;
129
154
  }
130
155
 
131
156
  // ---------------------------------------------------------------------------