From 47f8df823e5b0900dc8b8d28dc97e9c6bde4fad7 Mon Sep 17 00:00:00 2001 From: John Hardy Date: Thu, 13 Aug 2026 11:40:30 +1000 Subject: [PATCH 1/5] Add initial Nucleus support to Debug80 --- apps/debug80-vscode/README.md | 15 ++- .../language-configuration/nucleus.json | 27 ++++ apps/debug80-vscode/package.json | 36 ++++-- .../src/debug/launch/assembler-backend.ts | 7 ++ .../src/debug/launch/nucleus-backend.ts | 118 ++++++++++++++++++ .../src/extension/configure-project-edit.ts | 13 +- .../extension/configure-target-commands.ts | 1 + .../extension/debug80-source-extensions.ts | 6 +- .../src/extension/project-config.ts | 9 +- .../src/extension/target-discovery.ts | 4 +- .../syntaxes/nucleus.tmLanguage.json | 87 +++++++++++++ .../tests/debug/assembler-backend.test.ts | 6 + .../tests/debug/nucleus-backend.test.ts | 80 ++++++++++++ .../extension/configure-project-edit.test.ts | 18 +++ .../debug80-source-extensions.test.ts | 5 +- .../tests/extension/project-config.test.ts | 18 +++ .../tests/extension/target-discovery.test.ts | 14 ++- .../tests/webview/language-contracts.test.ts | 8 +- .../tests/webview/nucleus-language.test.ts | 49 ++++++++ docs/nucleus-repository-transition.md | 43 +++++++ 20 files changed, 536 insertions(+), 28 deletions(-) create mode 100644 apps/debug80-vscode/language-configuration/nucleus.json create mode 100644 apps/debug80-vscode/src/debug/launch/nucleus-backend.ts create mode 100644 apps/debug80-vscode/syntaxes/nucleus.tmLanguage.json create mode 100644 apps/debug80-vscode/tests/debug/nucleus-backend.test.ts create mode 100644 apps/debug80-vscode/tests/webview/nucleus-language.test.ts create mode 100644 docs/nucleus-repository-transition.md diff --git a/apps/debug80-vscode/README.md b/apps/debug80-vscode/README.md index 8343a0cd..4fdf2e69 100644 --- a/apps/debug80-vscode/README.md +++ b/apps/debug80-vscode/README.md @@ -8,8 +8,8 @@ [Book 1 — Getting started](https://debug80.com/debug80-book/book1/), which goes from installing the extension to stepping through a program on real hardware. -Debug80 turns VS Code into a practical development environment for Z80 assembly -and Glimmer programs. It builds your project, runs it inside an integrated Z80 runtime, +Debug80 turns VS Code into a practical development environment for Z80 assembly, +Glimmer, and Nucleus programs. It builds your project, runs it inside an integrated Z80 runtime, maps machine addresses back to source with native D8 debug maps, and exposes the state you need while debugging: breakpoints, stepping, registers, flags, memory, terminal I/O, and hardware-specific panels. @@ -71,6 +71,10 @@ are available at [debug80.com](https://debug80.com/). - **Glimmer language support**: `.glim` projects compile through Glimmer and AZM, retain source-level breakpoints and diagnostics, and embed full Z80/AZM syntax highlighting inside `begin`/`end` bodies. +- **Nucleus language support**: `.nu` files have language-aware editing, target + discovery, positioned compiler diagnostics, and builds through the standalone + `nucleus` command. The build retains canonical `.nobj` beside its launchable + `.hex` artifact. Nucleus source stepping awaits a D8-compatible map sidecar. ## Quick Start @@ -103,13 +107,14 @@ Target discovery uses a small set of entry-point conventions to suggest targets: - files ending in `.main.z80` - files named `main.asm` - files named `main.z80` +- files named `main.nu` - `.glim` files containing a top-level `program` declaration Glimmer `part` files are not offered as standalone targets because they do not declare a complete program. These names are conventions, not requirements. Use the `+` control beside the -target selector to add any `.asm`, `.z80`, or complete Glimmer program in the +target selector to add any `.asm`, `.z80`, `.nu`, or complete Glimmer program in the project. Sources may live at the project root, under `src/`, or in other subdirectories. Removing a target with the `-` control changes `debug80.json`; it does not delete the source file or its existing build artifacts. @@ -175,6 +180,10 @@ npm test Debug80 packages its assembler dependency inside the VSIX. Published users should not need `npm link`, sibling checkouts, or globally installed assembler binaries. +The initial Nucleus integration is deliberately separate: install the standalone +Nucleus package so its `nucleus` command is on `PATH`, or set `NUCLEUS_COMPILER` +to the command path before starting VS Code. Bundling a released compiler image +is the next integration step. ## Documentation diff --git a/apps/debug80-vscode/language-configuration/nucleus.json b/apps/debug80-vscode/language-configuration/nucleus.json new file mode 100644 index 00000000..8b25833c --- /dev/null +++ b/apps/debug80-vscode/language-configuration/nucleus.json @@ -0,0 +1,27 @@ +{ + "comments": { + "lineComment": "#" + }, + "brackets": [ + ["(", ")"], + ["[", "]"] + ], + "autoClosingPairs": [ + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" }, + { "open": "\"", "close": "\"", "notIn": ["string", "comment"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["\"", "\""], + ["'", "'"] + ], + "folding": { + "markers": { + "start": "^\\s*(?:record|sub|if|elseif|else|while|for|handle)\\b", + "end": "^\\s*end\\b" + } + } +} diff --git a/apps/debug80-vscode/package.json b/apps/debug80-vscode/package.json index c48d599e..331fdad8 100644 --- a/apps/debug80-vscode/package.json +++ b/apps/debug80-vscode/package.json @@ -47,7 +47,8 @@ "onLanguage:asm-collection", "onLanguage:z80-asm", "onLanguage:z80-macroasm", - "onLanguage:glim" + "onLanguage:glim", + "onLanguage:nucleus" ], "main": "./out/extension/extension.js", "contributes": { @@ -73,7 +74,8 @@ "*.asm": "z80-asm", "*.z80": "z80-asm", "*.asmi": "z80-asm", - "*.glim": "glim" + "*.glim": "glim", + "*.nu": "nucleus" }, "editor.tokenColorCustomizations": { "textMateRules": [ @@ -356,6 +358,16 @@ ".glim" ], "configuration": "./language-configuration/glim.json" + }, + { + "id": "nucleus", + "aliases": [ + "Nucleus" + ], + "extensions": [ + ".nu" + ], + "configuration": "./language-configuration/nucleus.json" } ], "grammars": [ @@ -368,6 +380,11 @@ "language": "glim", "scopeName": "source.glim", "path": "./syntaxes/glim.tmLanguage.json" + }, + { + "language": "nucleus", + "scopeName": "source.nucleus", + "path": "./syntaxes/nucleus.tmLanguage.json" } ], "breakpoints": [ @@ -391,6 +408,9 @@ }, { "language": "glim" + }, + { + "language": "nucleus" } ], "commands": [ @@ -541,21 +561,21 @@ "explorer/context": [ { "command": "debug80.setEntrySource", - "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim", + "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim || resourceExtname == .nu", "group": "navigation@9" } ], "editor/context": [ { "command": "debug80.setEntrySource", - "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim", + "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim || resourceExtname == .nu", "group": "navigation@9" } ], "editor/title/context": [ { "command": "debug80.setEntrySource", - "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim", + "when": "resourceExtname == .asm || resourceExtname == .z80 || resourceExtname == .glim || resourceExtname == .nu", "group": "navigation@9" } ], @@ -578,7 +598,8 @@ "wla-dx-asm", "asm", "z80", - "glim" + "glim", + "nucleus" ], "configurationAttributes": { "launch": { @@ -601,7 +622,8 @@ "description": "Assembler backend to use (inferred from the source extension when omitted)", "enum": [ "azm", - "glimmer" + "glimmer", + "nucleus" ] }, "azm": { diff --git a/apps/debug80-vscode/src/debug/launch/assembler-backend.ts b/apps/debug80-vscode/src/debug/launch/assembler-backend.ts index 8d8036cc..301808d7 100644 --- a/apps/debug80-vscode/src/debug/launch/assembler-backend.ts +++ b/apps/debug80-vscode/src/debug/launch/assembler-backend.ts @@ -8,6 +8,7 @@ import type { AzmLaunchOptions } from '../session/types'; import type { AssembleResult } from './assembler'; import { AzmBackend } from './azm-backend'; import { GlimmerBackend } from './glimmer-backend'; +import { NucleusBackend } from './nucleus-backend'; const azmSourceExtensions = new Set(['.asm', '.inc', '.z80']); @@ -48,6 +49,9 @@ function inferAssemblerBackend(asmPath: string | undefined): string | undefined if (extension === '.glim') { return 'glimmer'; } + if (extension === '.nu') { + return 'nucleus'; + } return undefined; } @@ -66,6 +70,9 @@ export function resolveAssemblerBackend( if (id === 'glimmer') { return new GlimmerBackend(); } + if (id === 'nucleus') { + return new NucleusBackend(); + } throw new Error(`Unknown assembler backend: "${assembler}"`); } diff --git a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts new file mode 100644 index 00000000..a502847e --- /dev/null +++ b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts @@ -0,0 +1,118 @@ +/** + * @fileoverview Standalone Nucleus compiler backend. + * + * Nucleus owns compilation and NOBJ. Debug80 asks its CLI for canonical NOBJ + * plus a flat Intel HEX launch adapter and translates its positioned failure. + */ + +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { AssemblyDiagnostic, AssembleResult } from './assembler'; +import type { AssembleOptions, AssemblerBackend } from './assembler-backend'; + +export interface NucleusCommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export type NucleusCommandRunner = ( + command: string, + args: readonly string[], + cwd: string, + onOutput?: (message: string) => void +) => Promise; + +const runNucleusCommand: NucleusCommandRunner = async (command, args, cwd, onOutput) => + await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + onOutput?.(chunk); + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + onOutput?.(chunk); + }); + child.on('error', reject); + child.on('close', (exitCode) => resolve({ exitCode: exitCode ?? 1, stdout, stderr })); + }); + +const diagnosticPattern = /^(.*):(\d+):(\d+): Nucleus diagnostic (\d+)$/m; + +function sourceLine(filePath: string, line: number): string | undefined { + try { + return fs.readFileSync(filePath, 'utf8').split(/\r?\n/)[line - 1]; + } catch { + return undefined; + } +} + +function parseDiagnostic(stderr: string): AssemblyDiagnostic | undefined { + const match = diagnosticPattern.exec(stderr); + if (match === null) { + return undefined; + } + const [, filePath = '', lineText = '0', columnText = '0', code = ''] = match; + const line = Number.parseInt(lineText, 10); + const column = Number.parseInt(columnText, 10); + const lineTextValue = sourceLine(filePath, line); + return { + path: filePath, + line, + column, + message: `Nucleus diagnostic ${code}`, + ...(lineTextValue !== undefined ? { sourceLine: lineTextValue } : {}), + }; +} + +export class NucleusBackend implements AssemblerBackend { + public readonly id = 'nucleus'; + + public constructor( + private readonly run: NucleusCommandRunner = runNucleusCommand, + private readonly command: string = process.env.NUCLEUS_COMPILER?.trim() || 'nucleus' + ) {} + + public async assemble(options: AssembleOptions): Promise { + fs.mkdirSync(path.dirname(options.hexPath), { recursive: true }); + const artifactBase = options.hexPath.slice(0, -path.extname(options.hexPath).length); + const nobjPath = `${artifactBase}.nobj`; + const cwd = options.sourceRoot ?? path.dirname(options.asmPath); + let result: NucleusCommandResult; + try { + result = await this.run( + this.command, + ['build', '-o', nobjPath, '--hex-output', options.hexPath, options.asmPath], + cwd, + options.onOutput + ); + } catch (error) { + const message = `Nucleus compiler failed to start: ${error instanceof Error ? error.message : String(error)}`; + options.onOutput?.(`${message}\n`); + return { success: false, error: message }; + } + if (result.exitCode !== 0) { + const diagnostic = parseDiagnostic(result.stderr); + return { + success: false, + error: result.stderr.trim() || `Nucleus compiler exited with code ${result.exitCode}`, + stdout: result.stdout, + stderr: result.stderr, + ...(diagnostic !== undefined ? { diagnostic } : {}), + }; + } + if (!fs.existsSync(options.hexPath) || !fs.existsSync(nobjPath)) { + return { + success: false, + error: 'Nucleus compiler succeeded without producing NOBJ and Intel HEX artifacts', + }; + } + return { success: true, stdout: result.stdout, stderr: result.stderr }; + } +} diff --git a/apps/debug80-vscode/src/extension/configure-project-edit.ts b/apps/debug80-vscode/src/extension/configure-project-edit.ts index 5340dbd8..c9448167 100644 --- a/apps/debug80-vscode/src/extension/configure-project-edit.ts +++ b/apps/debug80-vscode/src/extension/configure-project-edit.ts @@ -28,7 +28,7 @@ function isSupportedAssemblerId(value: unknown): boolean { return false; } const normalized = value.trim().toLowerCase(); - return normalized === 'azm' || normalized === 'glimmer'; + return normalized === 'azm' || normalized === 'glimmer' || normalized === 'nucleus'; } export function applyConfigureProjectTargetEdit( @@ -100,7 +100,16 @@ function applyPlatformOverride( function applyProgramSource(target: ProjectTargetConfig, sourceFile: string): void { target.sourceFile = sourceFile; target.asm = sourceFile; - if (target.assembler !== undefined && !isSupportedAssemblerId(target.assembler)) { + const extension = sourceFile.slice(sourceFile.lastIndexOf('.')).toLowerCase(); + const assembler = target.assembler?.trim().toLowerCase(); + const incompatibleAssembler = + (assembler === 'glimmer' && extension !== '.glim') || + (assembler === 'nucleus' && extension !== '.nu') || + (assembler === 'azm' && (extension === '.glim' || extension === '.nu')); + if ( + incompatibleAssembler || + (target.assembler !== undefined && !isSupportedAssemblerId(target.assembler)) + ) { delete target.assembler; } } diff --git a/apps/debug80-vscode/src/extension/configure-target-commands.ts b/apps/debug80-vscode/src/extension/configure-target-commands.ts index f04048ad..6e3dc707 100644 --- a/apps/debug80-vscode/src/extension/configure-target-commands.ts +++ b/apps/debug80-vscode/src/extension/configure-target-commands.ts @@ -233,6 +233,7 @@ async function selectAssembler(): Promise { const rest: Record = { ...target }; const extension = path.extname(sourceFile).toLowerCase(); + const assembler = + typeof rest.assembler === 'string' ? rest.assembler.trim().toLowerCase() : undefined; const incompatibleAssembler = - (rest.assembler === 'glimmer' && extension !== '.glim') || - (rest.assembler === 'azm' && extension === '.glim'); + (assembler === 'glimmer' && extension !== '.glim') || + (assembler === 'nucleus' && extension !== '.nu') || + (assembler === 'azm' && (extension === '.glim' || extension === '.nu')); if ( incompatibleAssembler || (rest.assembler !== undefined && !isSupportedAssemblerId(rest.assembler)) diff --git a/apps/debug80-vscode/src/extension/target-discovery.ts b/apps/debug80-vscode/src/extension/target-discovery.ts index 543456cc..80c5ef29 100644 --- a/apps/debug80-vscode/src/extension/target-discovery.ts +++ b/apps/debug80-vscode/src/extension/target-discovery.ts @@ -5,8 +5,8 @@ import * as fs from 'fs'; import * as path from 'path'; -export const TARGET_ENTRY_SOURCE_FILENAMES = ['main.asm', 'main.z80'] as const; -export const TARGET_SOURCE_EXTENSIONS = ['.asm', '.z80', '.glim'] as const; +export const TARGET_ENTRY_SOURCE_FILENAMES = ['main.asm', 'main.z80', 'main.nu'] as const; +export const TARGET_SOURCE_EXTENSIONS = ['.asm', '.z80', '.glim', '.nu'] as const; const TARGET_DISCOVERY_EXCLUDED_DIRS = new Set([ '.git', diff --git a/apps/debug80-vscode/syntaxes/nucleus.tmLanguage.json b/apps/debug80-vscode/syntaxes/nucleus.tmLanguage.json new file mode 100644 index 00000000..8fe585e6 --- /dev/null +++ b/apps/debug80-vscode/syntaxes/nucleus.tmLanguage.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Nucleus", + "scopeName": "source.nucleus", + "fileTypes": ["nu"], + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#services" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.number-sign.nucleus", + "match": "#.*$" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.nucleus", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.nucleus", + "match": "\\\\(?:[0rt\\\\\"]|x[0-9A-Fa-f]{2})" + } + ] + }, + { + "name": "constant.character.nucleus", + "match": "'(?:[^'\\\\]|\\\\.)'" + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.nucleus", + "match": "(? ({ import { resolveAssemblerBackend } from '../../src/debug/launch/assembler-backend'; import { AzmBackend } from '../../src/debug/launch/azm-backend'; import { GlimmerBackend } from '../../src/debug/launch/glimmer-backend'; +import { NucleusBackend } from '../../src/debug/launch/nucleus-backend'; function expectAzmBackend(id?: string, sourcePath?: string): void { expect(resolveAssemblerBackend(id, sourcePath)).toBeInstanceOf(AzmBackend); @@ -49,6 +50,11 @@ describe('assembler-backend', () => { expect(resolveAssemblerBackend('GLIMMER', undefined)).toBeInstanceOf(GlimmerBackend); }); + it('returns nucleus for .nu source paths or an explicit backend', () => { + expect(resolveAssemblerBackend(undefined, '/tmp/main.nu')).toBeInstanceOf(NucleusBackend); + expect(resolveAssemblerBackend('NUCLEUS', undefined)).toBeInstanceOf(NucleusBackend); + }); + it('does not expose the removed zax backend', () => { expect(() => resolveAssemblerBackend('zax', undefined)).toThrow('Unknown assembler backend'); expectAzmBackend(undefined, '/tmp/program.zax'); diff --git a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts new file mode 100644 index 00000000..fbc1e223 --- /dev/null +++ b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts @@ -0,0 +1,80 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NucleusBackend, type NucleusCommandRunner } from '../../src/debug/launch/nucleus-backend'; + +describe('Nucleus backend', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories) { + fs.rmSync(directory, { recursive: true, force: true }); + } + temporaryDirectories.length = 0; + }); + + function workspace(): { root: string; source: string; hex: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'debug80-nucleus-')); + temporaryDirectories.push(root); + const source = path.join(root, 'main.nu'); + const hex = path.join(root, 'build', 'main.hex'); + fs.writeFileSync(source, 'sub main()\nend\n'); + return { root, source, hex }; + } + + it('requests canonical NOBJ and launchable HEX from the standalone compiler', async () => { + const project = workspace(); + const run = vi.fn(async (_command, args) => { + const output = args[args.indexOf('-o') + 1]; + const hexOutput = args[args.indexOf('--hex-output') + 1]; + fs.writeFileSync(output ?? '', 'NOBJ'); + fs.writeFileSync(hexOutput ?? '', ':00000001FF\n'); + return { exitCode: 0, stdout: 'compiled\n', stderr: '' }; + }); + const result = await new NucleusBackend(run, '/tool/nucleus').assemble({ + asmPath: project.source, + hexPath: project.hex, + sourceRoot: project.root, + }); + + expect(result.success).toBe(true); + expect(run).toHaveBeenCalledWith( + '/tool/nucleus', + [ + 'build', + '-o', + path.join(project.root, 'build', 'main.nobj'), + '--hex-output', + project.hex, + project.source, + ], + project.root, + undefined + ); + }); + + it('translates an exact Nucleus source diagnostic', async () => { + const project = workspace(); + const run: NucleusCommandRunner = async () => ({ + exitCode: 1, + stdout: '', + stderr: `${project.source}:1:5: Nucleus diagnostic 87\n`, + }); + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + }); + + expect(result).toMatchObject({ + success: false, + diagnostic: { + path: project.source, + line: 1, + column: 5, + message: 'Nucleus diagnostic 87', + sourceLine: 'sub main()', + }, + }); + }); +}); diff --git a/apps/debug80-vscode/tests/extension/configure-project-edit.test.ts b/apps/debug80-vscode/tests/extension/configure-project-edit.test.ts index b7ca2551..f90f207d 100644 --- a/apps/debug80-vscode/tests/extension/configure-project-edit.test.ts +++ b/apps/debug80-vscode/tests/extension/configure-project-edit.test.ts @@ -95,6 +95,24 @@ describe('configure-project target edit', () => { expect(config.targets?.app?.sourceFile).toBe('src/game.glim'); }); + it('preserves Nucleus only for Nucleus source files', () => { + const config = singleTargetConfig( + targetConfig({ sourceFile: 'src/old.nu', assembler: 'nucleus' }) + ); + + applyConfigureProjectTargetEdit(config, 'app', { + kind: 'program', + sourceFile: 'src/main.nu', + }); + expect(config.targets?.app?.assembler).toBe('nucleus'); + + applyConfigureProjectTargetEdit(config, 'app', { + kind: 'program', + sourceFile: 'src/main.asm', + }); + expect(config.targets?.app?.assembler).toBeUndefined(); + }); + it('renames targets and updates target aliases', () => { const config: ProjectConfig = { target: 'app', diff --git a/apps/debug80-vscode/tests/extension/debug80-source-extensions.test.ts b/apps/debug80-vscode/tests/extension/debug80-source-extensions.test.ts index d869e11e..d20a6d70 100644 --- a/apps/debug80-vscode/tests/extension/debug80-source-extensions.test.ts +++ b/apps/debug80-vscode/tests/extension/debug80-source-extensions.test.ts @@ -11,13 +11,14 @@ describe('Debug80 source extensions', () => { expect(AZM_LANGUAGE_EXTENSIONS).toEqual(['.asm', '.z80', '.asmi']); }); - it('rebuilds active sessions when AZM or Glimmer sources are saved', () => { - expect(DEBUG80_REBUILD_SOURCE_EXTENSIONS).toEqual(['.asm', '.z80', '.asmi', '.glim']); + it('rebuilds active sessions when AZM, Glimmer or Nucleus sources are saved', () => { + expect(DEBUG80_REBUILD_SOURCE_EXTENSIONS).toEqual(['.asm', '.z80', '.asmi', '.glim', '.nu']); expect(isDebug80RebuildSourcePath('/project/src/main.asm')).toBe(true); expect(isDebug80RebuildSourcePath('/project/src/main.z80')).toBe(true); expect(isDebug80RebuildSourcePath('/project/src/contracts.asmi')).toBe(true); expect(isDebug80RebuildSourcePath('/project/src/game.glim')).toBe(true); expect(isDebug80RebuildSourcePath('/project/src/GAME.GLIM')).toBe(true); + expect(isDebug80RebuildSourcePath('/project/src/main.nu')).toBe(true); }); it('ignores unrelated files', () => { diff --git a/apps/debug80-vscode/tests/extension/project-config.test.ts b/apps/debug80-vscode/tests/extension/project-config.test.ts index 1440ddd9..c25d8188 100644 --- a/apps/debug80-vscode/tests/extension/project-config.test.ts +++ b/apps/debug80-vscode/tests/extension/project-config.test.ts @@ -332,6 +332,24 @@ describe('project-config helpers', () => { expect(readProjectConfig(configPath)?.targets?.app?.assembler).toBe('glimmer'); }); + it('preserves Nucleus for .nu and clears it for another source language', () => { + const { configPath } = createProject('debug80-nucleus-entry-', { + defaultTarget: 'app', + targets: { + app: { + sourceFile: 'src/old.nu', + assembler: 'nucleus', + platform: 'simple', + }, + }, + }); + + expect(updateProjectTargetSource(configPath, 'app', 'src/main.nu')).toBe(true); + expect(readProjectConfig(configPath)?.targets?.app?.assembler).toBe('nucleus'); + expect(updateProjectTargetSource(configPath, 'app', 'src/main.asm')).toBe(true); + expect(readProjectConfig(configPath)?.targets?.app?.assembler).toBeUndefined(); + }); + it('clears an assembler override that conflicts with the new program extension', () => { const { configPath } = createProject('debug80-cross-language-entry-', { defaultTarget: 'app', diff --git a/apps/debug80-vscode/tests/extension/target-discovery.test.ts b/apps/debug80-vscode/tests/extension/target-discovery.test.ts index db4aee10..aeda6419 100644 --- a/apps/debug80-vscode/tests/extension/target-discovery.test.ts +++ b/apps/debug80-vscode/tests/extension/target-discovery.test.ts @@ -18,10 +18,11 @@ describe('target discovery conventions', () => { }); it('defines the runnable target entry source conventions in one place', () => { - expect(TARGET_ENTRY_SOURCE_FILENAMES).toEqual(['main.asm', 'main.z80']); + expect(TARGET_ENTRY_SOURCE_FILENAMES).toEqual(['main.asm', 'main.z80', 'main.nu']); expect(isTargetEntrySourcePath('main.asm')).toBe(true); expect(isTargetEntrySourcePath('src/main.asm')).toBe(true); + expect(isTargetEntrySourcePath('src/main.nu')).toBe(true); expect(isTargetEntrySourcePath('src/pacmo.main.asm')).toBe(false); expect(isTargetEntrySourcePath('src/pacmo.main.z80')).toBe(false); expect(isTargetEntrySourcePath('examples/tetro.glim')).toBe(true); @@ -32,12 +33,14 @@ describe('target discovery conventions', () => { expect(isTargetSourcePath('src/include.asm')).toBe(true); expect(isTargetSourcePath('src/tool.z80')).toBe(true); expect(isTargetSourcePath('examples/tetro.glim')).toBe(true); + expect(isTargetSourcePath('src/module.nu')).toBe(true); expect(isTargetSourcePath('src/contracts.asmi')).toBe(false); }); it('lists target entry source files relative to the project root', () => { const root = fixture.createWorkspace('debug80-target-discovery-', [ 'src/main.asm', + 'nucleus/main.nu', 'src/pacmo.main.asm', 'src/include.asm', 'src/helper.z80', @@ -49,7 +52,9 @@ describe('target discovery conventions', () => { ]); expect(listTargetEntrySourceFiles(root)).toEqual( - ['src/main.asm', 'examples/tetro.glim'].sort((left, right) => left.localeCompare(right)) + ['src/main.asm', 'examples/tetro.glim', 'nucleus/main.nu'].sort((left, right) => + left.localeCompare(right) + ) ); }); @@ -73,6 +78,7 @@ describe('target discovery conventions', () => { const root = fixture.createWorkspace('debug80-target-source-', [ 'main.asm', 'src/helper.asm', + 'src/main.nu', 'legacy/tool.z80', ['examples/game.glim', 'program Game\n'], ['examples/library.glim', 'state Score : byte\n'], @@ -82,8 +88,8 @@ describe('target discovery conventions', () => { ]); expect(listTargetSourceFiles(root)).toEqual( - ['examples/game.glim', 'legacy/tool.z80', 'main.asm', 'src/helper.asm'].sort((left, right) => - left.localeCompare(right) + ['examples/game.glim', 'legacy/tool.z80', 'main.asm', 'src/helper.asm', 'src/main.nu'].sort( + (left, right) => left.localeCompare(right) ) ); }); diff --git a/apps/debug80-vscode/tests/webview/language-contracts.test.ts b/apps/debug80-vscode/tests/webview/language-contracts.test.ts index bf66236f..e7333842 100644 --- a/apps/debug80-vscode/tests/webview/language-contracts.test.ts +++ b/apps/debug80-vscode/tests/webview/language-contracts.test.ts @@ -269,14 +269,14 @@ describe('package.json language contracts', () => { expect(lang!.extensions).not.toContain('.s'); }); - it('launch schema exposes AZM and Glimmer assembler backends', () => { + it('launch schema exposes AZM, Glimmer and Nucleus compiler backends', () => { const debuggerContribution = contributes.debuggers.find((debuggerEntry) => { return debuggerEntry.type === 'z80'; }); const assembler = debuggerContribution?.configurationAttributes?.launch?.properties?.assembler; expect(assembler?.default).toBeUndefined(); - expect(assembler?.enum).toEqual(['azm', 'glimmer']); + expect(assembler?.enum).toEqual(['azm', 'glimmer', 'nucleus']); }); it('launch schema exposes strict and insensitive AZM symbol lookup', () => { @@ -288,13 +288,13 @@ describe('package.json language contracts', () => { expect(symbolCase?.enum).toEqual(['strict', 'insensitive']); }); - it('set-entry-source context menus cover AZM entry source extensions', () => { + it('set-entry-source context menus cover supported entry source extensions', () => { for (const menuId of ['explorer/context', 'editor/context', 'editor/title/context']) { const row = contributes.menus[menuId].find((entry) => { return entry.command === 'debug80.setEntrySource'; }); expect(row).toBeDefined(); - for (const extension of ['.asm', '.z80', '.glim']) { + for (const extension of ['.asm', '.z80', '.glim', '.nu']) { expect(row!.when).toContain(`resourceExtname == ${extension}`); } expect(row!.when).not.toContain('resourceExtname == .a80'); diff --git a/apps/debug80-vscode/tests/webview/nucleus-language.test.ts b/apps/debug80-vscode/tests/webview/nucleus-language.test.ts new file mode 100644 index 00000000..e391661a --- /dev/null +++ b/apps/debug80-vscode/tests/webview/nucleus-language.test.ts @@ -0,0 +1,49 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(__dirname, '../..'); +const grammar = JSON.parse( + fs.readFileSync(path.join(root, 'syntaxes', 'nucleus.tmLanguage.json'), 'utf8') +) as { + fileTypes?: string[]; + repository?: Record }>; + scopeName?: string; +}; +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + activationEvents?: string[]; + contributes: { + configurationDefaults: { 'files.associations': Record }; + grammars: Array<{ language: string; path: string; scopeName: string }>; + languages: Array<{ configuration: string; extensions: string[]; id: string }>; + }; +}; + +describe('Nucleus language contracts', () => { + it('registers .nu with its grammar and language configuration', () => { + const language = packageJson.contributes.languages.find((entry) => entry.id === 'nucleus'); + expect(language).toMatchObject({ + extensions: ['.nu'], + configuration: './language-configuration/nucleus.json', + }); + expect(packageJson.contributes.configurationDefaults['files.associations']['*.nu']).toBe( + 'nucleus' + ); + expect(packageJson.contributes.grammars).toContainEqual({ + language: 'nucleus', + scopeName: 'source.nucleus', + path: './syntaxes/nucleus.tmLanguage.json', + }); + expect(grammar.scopeName).toBe('source.nucleus'); + expect(grammar.fileTypes).toEqual(['nu']); + expect(packageJson.activationEvents).toContain('onLanguage:nucleus'); + }); + + it('highlights the established failure model and predefined services', () => { + const keyword = grammar.repository?.keywords?.patterns?.[0]?.match ?? ''; + const service = grammar.repository?.services?.patterns?.[0]?.match ?? ''; + expect(new RegExp(keyword).test('handle')).toBe(true); + expect(new RegExp(keyword).test('fails')).toBe(true); + expect(new RegExp(service).test('writeOutputByte')).toBe(true); + }); +}); diff --git a/docs/nucleus-repository-transition.md b/docs/nucleus-repository-transition.md new file mode 100644 index 00000000..c4a89220 --- /dev/null +++ b/docs/nucleus-repository-transition.md @@ -0,0 +1,43 @@ +# Nucleus repository transition + +## Decision + +Nucleus is a standalone Z80 language implementation, not an internal Debug80 +package. Its repository owns the language specification, grammar, direct Z80 +compiler, runtime contract, NOBJ format, proofs, and host compiler package. + +Debug80 remains a first-class Nucleus development environment. It owns `.nu` +editor registration, target discovery, build orchestration, artifact loading, +debugging, and the documentation snapshot published on debug80.com. + +## Migration sequence + +1. Preserve the history of `packages/nucleus` in the standalone repository. +2. Establish an emulator-backed Node compiler around the existing Z80 binary. +3. Teach Debug80 to discover and build `.nu` targets through that compiler. +4. Publish Nucleus and Debug80 Runtime packages, then pin Debug80 to a released + Nucleus version. +5. Add a D8-compatible Nucleus source-map sidecar for source breakpoints and + stepping. +6. Copy the public Nucleus documentation into the Debug80 website from a pinned + Nucleus revision. +7. Remove `packages/nucleus` from Debug80 only after the external package, CI, + documentation sync, and integration tests are independently green. + +The temporary in-tree copy prevents a destructive cutover before the new +repository has a durable remote and reproducible release path. New language +work belongs in Nucleus after that cutover; Debug80 should consume releases +rather than accumulating a second compiler implementation. + +## Host compiler boundary + +The first desktop compiler executes the Z80 compiler in Debug80 Runtime. It +retains canonical NOBJ and emits Intel HEX only as a launch adapter. A future +TypeScript compiler must match the Z80 compiler on accepted programs, +diagnostics and positions, materialized bytes, target layout, and runtime +selection before it can replace the emulator-backed compiler as the reference. + +NOBJ does not yet carry source-to-address mappings. Debug80 therefore supports +editing, building, positioned diagnostics, and execution first. Source stepping +must wait for a separately specified D8-compatible sidecar rather than guessing +from the object image. From 6ab7eca959a11ebf882e8abb0ae8972259effab0 Mon Sep 17 00:00:00 2001 From: John Hardy Date: Thu, 13 Aug 2026 13:43:42 +1000 Subject: [PATCH 2/5] Harden Nucleus launch integration --- apps/debug80-vscode/README.md | 8 ++ .../src/debug/launch/nucleus-backend.ts | 126 +++++++++++++++--- .../tests/debug/nucleus-backend.test.ts | 93 ++++++++++++- docs/nucleus-repository-transition.md | 19 +-- 4 files changed, 216 insertions(+), 30 deletions(-) diff --git a/apps/debug80-vscode/README.md b/apps/debug80-vscode/README.md index 4fdf2e69..09e180d8 100644 --- a/apps/debug80-vscode/README.md +++ b/apps/debug80-vscode/README.md @@ -75,6 +75,12 @@ are available at [debug80.com](https://debug80.com/). discovery, positioned compiler diagnostics, and builds through the standalone `nucleus` command. The build retains canonical `.nobj` beside its launchable `.hex` artifact. Nucleus source stepping awaits a D8-compatible map sidecar. + A project-local `nucleus-target.json` must provide the validated memory layout + and all external service destinations; Debug80 never substitutes the + compiler's synthetic proof addresses. + The initial backend treats the selected `.nu` file as a one-part manifest. + Ordered multi-file Nucleus projects require the planned project-manifest + integration. ## Quick Start @@ -184,6 +190,8 @@ The initial Nucleus integration is deliberately separate: install the standalone Nucleus package so its `nucleus` command is on `PATH`, or set `NUCLEUS_COMPILER` to the command path before starting VS Code. Bundling a released compiler image is the next integration step. +Set `NUCLEUS_TARGET_PROFILE` to override the default project-local +`nucleus-target.json` path. ## Documentation diff --git a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts index a502847e..b735759a 100644 --- a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts +++ b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts @@ -44,6 +44,51 @@ const runNucleusCommand: NucleusCommandRunner = async (command, args, cwd, onOut }); const diagnosticPattern = /^(.*):(\d+):(\d+): Nucleus diagnostic (\d+)$/m; +let buildOrdinal = 0; + +function removeIfPresent(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} + +function publishArtifacts( + artifacts: readonly { temporaryPath: string; finalPath: string }[], + generation: string +): void { + const backups: Array<{ backupPath: string; finalPath: string }> = []; + const promoted: string[] = []; + try { + for (const { finalPath } of artifacts) { + if (fs.existsSync(finalPath)) { + const backupPath = `${finalPath}.nucleus-backup-${generation}`; + fs.renameSync(finalPath, backupPath); + backups.push({ backupPath, finalPath }); + } + } + for (const { temporaryPath, finalPath } of artifacts) { + fs.renameSync(temporaryPath, finalPath); + promoted.push(finalPath); + } + } catch (error) { + for (const finalPath of promoted) { + removeIfPresent(finalPath); + } + for (const { backupPath, finalPath } of backups) { + if (fs.existsSync(backupPath)) { + fs.renameSync(backupPath, finalPath); + } + } + throw error; + } + for (const { backupPath } of backups) { + removeIfPresent(backupPath); + } +} function sourceLine(filePath: string, line: number): string | undefined { try { @@ -81,38 +126,83 @@ export class NucleusBackend implements AssemblerBackend { public async assemble(options: AssembleOptions): Promise { fs.mkdirSync(path.dirname(options.hexPath), { recursive: true }); - const artifactBase = options.hexPath.slice(0, -path.extname(options.hexPath).length); + const extension = path.extname(options.hexPath); + const artifactBase = + extension.length === 0 ? options.hexPath : options.hexPath.slice(0, -extension.length); const nobjPath = `${artifactBase}.nobj`; + const generation = `${process.pid}-${(buildOrdinal += 1)}`; + const temporaryHexPath = `${artifactBase}.nucleus-${generation}.hex`; + const temporaryNobjPath = `${artifactBase}.nucleus-${generation}.nobj`; const cwd = options.sourceRoot ?? path.dirname(options.asmPath); + const configuredProfile = process.env.NUCLEUS_TARGET_PROFILE?.trim(); + const targetProfile = + configuredProfile !== undefined && configuredProfile.length > 0 + ? path.resolve(cwd, configuredProfile) + : path.join(cwd, 'nucleus-target.json'); + if (!fs.existsSync(targetProfile)) { + return { + success: false, + error: `Nucleus target profile not found at "${targetProfile}"; define real service destinations before launching`, + }; + } let result: NucleusCommandResult; try { result = await this.run( this.command, - ['build', '-o', nobjPath, '--hex-output', options.hexPath, options.asmPath], + [ + 'build', + '-o', + temporaryNobjPath, + '--hex-output', + temporaryHexPath, + '--target-profile', + targetProfile, + options.asmPath, + ], cwd, options.onOutput ); } catch (error) { + removeIfPresent(temporaryHexPath); + removeIfPresent(temporaryNobjPath); const message = `Nucleus compiler failed to start: ${error instanceof Error ? error.message : String(error)}`; options.onOutput?.(`${message}\n`); return { success: false, error: message }; } - if (result.exitCode !== 0) { - const diagnostic = parseDiagnostic(result.stderr); - return { - success: false, - error: result.stderr.trim() || `Nucleus compiler exited with code ${result.exitCode}`, - stdout: result.stdout, - stderr: result.stderr, - ...(diagnostic !== undefined ? { diagnostic } : {}), - }; - } - if (!fs.existsSync(options.hexPath) || !fs.existsSync(nobjPath)) { - return { - success: false, - error: 'Nucleus compiler succeeded without producing NOBJ and Intel HEX artifacts', - }; + try { + if (result.exitCode !== 0) { + const diagnostic = parseDiagnostic(result.stderr); + return { + success: false, + error: result.stderr.trim() || `Nucleus compiler exited with code ${result.exitCode}`, + stdout: result.stdout, + stderr: result.stderr, + ...(diagnostic !== undefined ? { diagnostic } : {}), + }; + } + if ( + !fs.existsSync(temporaryHexPath) || + !fs.existsSync(temporaryNobjPath) || + fs.statSync(temporaryHexPath).size === 0 || + fs.statSync(temporaryNobjPath).size === 0 + ) { + return { + success: false, + error: + 'Nucleus compiler succeeded without producing nonempty fresh NOBJ and Intel HEX artifacts', + }; + } + publishArtifacts( + [ + { temporaryPath: temporaryNobjPath, finalPath: nobjPath }, + { temporaryPath: temporaryHexPath, finalPath: options.hexPath }, + ], + generation + ); + return { success: true, stdout: result.stdout, stderr: result.stderr }; + } finally { + removeIfPresent(temporaryHexPath); + removeIfPresent(temporaryNobjPath); } - return { success: true, stdout: result.stdout, stderr: result.stderr }; } } diff --git a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts index fbc1e223..8e21f348 100644 --- a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts +++ b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts @@ -20,6 +20,24 @@ describe('Nucleus backend', () => { const source = path.join(root, 'main.nu'); const hex = path.join(root, 'build', 'main.hex'); fs.writeFileSync(source, 'sub main()\nend\n'); + fs.writeFileSync( + path.join(root, 'nucleus-target.json'), + JSON.stringify({ + services: { + readInputByte: 0x7000, + writeOutputByte: 0x7003, + readStorageByte: 0x7006, + rewindStorageInput: 0x7009, + writeStorageByte: 0x700c, + seekStorageOutput: 0x700f, + success: 0x7012, + unhandledFailure: 0x7015, + trap: 0x7018, + farCall: 0x701b, + farJump: 0x701e, + }, + }) + ); return { root, source, hex }; } @@ -41,17 +59,84 @@ describe('Nucleus backend', () => { expect(result.success).toBe(true); expect(run).toHaveBeenCalledWith( '/tool/nucleus', - [ + expect.arrayContaining([ 'build', '-o', - path.join(project.root, 'build', 'main.nobj'), '--hex-output', - project.hex, + '--target-profile', + path.join(project.root, 'nucleus-target.json'), project.source, - ], + ]), project.root, undefined ); + expect(fs.readFileSync(path.join(project.root, 'build', 'main.nobj'), 'utf8')).toBe('NOBJ'); + expect(fs.readFileSync(project.hex, 'utf8')).toBe(':00000001FF\n'); + }); + + it('refuses to launch a synthetic target without real service destinations', async () => { + const project = workspace(); + fs.unlinkSync(path.join(project.root, 'nucleus-target.json')); + const run = vi.fn(); + + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + sourceRoot: project.root, + }); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('Nucleus target profile not found'), + }); + expect(run).not.toHaveBeenCalled(); + }); + + it('does not accept stale final artifacts as output from a successful command', async () => { + const project = workspace(); + fs.mkdirSync(path.dirname(project.hex), { recursive: true }); + fs.writeFileSync(project.hex, 'STALE HEX'); + fs.writeFileSync(path.join(project.root, 'build', 'main.nobj'), 'STALE NOBJ'); + const run: NucleusCommandRunner = async () => ({ exitCode: 0, stdout: '', stderr: '' }); + + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + }); + + expect(result).toMatchObject({ + success: false, + error: + 'Nucleus compiler succeeded without producing nonempty fresh NOBJ and Intel HEX artifacts', + }); + expect(fs.readFileSync(project.hex, 'utf8')).toBe('STALE HEX'); + expect(fs.readFileSync(path.join(project.root, 'build', 'main.nobj'), 'utf8')).toBe( + 'STALE NOBJ' + ); + }); + + it('rejects empty fresh artifacts and retains the last complete generation', async () => { + const project = workspace(); + fs.mkdirSync(path.dirname(project.hex), { recursive: true }); + const nobj = path.join(project.root, 'build', 'main.nobj'); + fs.writeFileSync(project.hex, 'PREVIOUS HEX'); + fs.writeFileSync(nobj, 'PREVIOUS NOBJ'); + const run: NucleusCommandRunner = async (_command, args) => { + fs.writeFileSync(args[args.indexOf('-o') + 1] ?? '', ''); + fs.writeFileSync(args[args.indexOf('--hex-output') + 1] ?? '', ''); + return { exitCode: 0, stdout: '', stderr: '' }; + }; + + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + sourceRoot: project.root, + }); + + expect(result.success).toBe(false); + expect(fs.readFileSync(project.hex, 'utf8')).toBe('PREVIOUS HEX'); + expect(fs.readFileSync(nobj, 'utf8')).toBe('PREVIOUS NOBJ'); + expect(fs.readdirSync(path.dirname(project.hex))).toEqual(['main.hex', 'main.nobj']); }); it('translates an exact Nucleus source diagnostic', async () => { diff --git a/docs/nucleus-repository-transition.md b/docs/nucleus-repository-transition.md index c4a89220..efe55a88 100644 --- a/docs/nucleus-repository-transition.md +++ b/docs/nucleus-repository-transition.md @@ -15,13 +15,15 @@ debugging, and the documentation snapshot published on debug80.com. 1. Preserve the history of `packages/nucleus` in the standalone repository. 2. Establish an emulator-backed Node compiler around the existing Z80 binary. 3. Teach Debug80 to discover and build `.nu` targets through that compiler. -4. Publish Nucleus and Debug80 Runtime packages, then pin Debug80 to a released +4. Add ordered multipart source manifests and validated machine service + profiles to Debug80 project configuration. +5. Publish Nucleus and Debug80 Runtime packages, then pin Debug80 to a released Nucleus version. -5. Add a D8-compatible Nucleus source-map sidecar for source breakpoints and +6. Add a D8-compatible Nucleus source-map sidecar for source breakpoints and stepping. -6. Copy the public Nucleus documentation into the Debug80 website from a pinned +7. Copy the public Nucleus documentation into the Debug80 website from a pinned Nucleus revision. -7. Remove `packages/nucleus` from Debug80 only after the external package, CI, +8. Remove `packages/nucleus` from Debug80 only after the external package, CI, documentation sync, and integration tests are independently green. The temporary in-tree copy prevents a destructive cutover before the new @@ -37,7 +39,8 @@ TypeScript compiler must match the Z80 compiler on accepted programs, diagnostics and positions, materialized bytes, target layout, and runtime selection before it can replace the emulator-backed compiler as the reference. -NOBJ does not yet carry source-to-address mappings. Debug80 therefore supports -editing, building, positioned diagnostics, and execution first. Source stepping -must wait for a separately specified D8-compatible sidecar rather than guessing -from the object image. +NOBJ does not yet carry source-to-address mappings. With a validated target +profile and callable service destinations, Debug80 supports editing, building, +positioned diagnostics, and machine-code execution. Source stepping must wait +for a separately specified D8-compatible sidecar rather than guessing from the +object image. From da954c5f6802c9e9889f1ab20c81709f14d06cab Mon Sep 17 00:00:00 2001 From: John Hardy Date: Thu, 13 Aug 2026 15:22:38 +1000 Subject: [PATCH 3/5] Fix Nucleus integration lint --- .../src/debug/launch/nucleus-backend.ts | 7 +++++- .../tests/debug/nucleus-backend.test.ts | 22 ++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts index b735759a..1fdb5225 100644 --- a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts +++ b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts @@ -44,6 +44,11 @@ const runNucleusCommand: NucleusCommandRunner = async (command, args, cwd, onOut }); const diagnosticPattern = /^(.*):(\d+):(\d+): Nucleus diagnostic (\d+)$/m; +const configuredNucleusCommand = process.env.NUCLEUS_COMPILER?.trim(); +const defaultNucleusCommand = + configuredNucleusCommand === undefined || configuredNucleusCommand.length === 0 + ? 'nucleus' + : configuredNucleusCommand; let buildOrdinal = 0; function removeIfPresent(filePath: string): void { @@ -121,7 +126,7 @@ export class NucleusBackend implements AssemblerBackend { public constructor( private readonly run: NucleusCommandRunner = runNucleusCommand, - private readonly command: string = process.env.NUCLEUS_COMPILER?.trim() || 'nucleus' + private readonly command: string = defaultNucleusCommand ) {} public async assemble(options: AssembleOptions): Promise { diff --git a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts index 8e21f348..db13a6cf 100644 --- a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts +++ b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts @@ -43,12 +43,12 @@ describe('Nucleus backend', () => { it('requests canonical NOBJ and launchable HEX from the standalone compiler', async () => { const project = workspace(); - const run = vi.fn(async (_command, args) => { + const run = vi.fn((_command, args) => { const output = args[args.indexOf('-o') + 1]; const hexOutput = args[args.indexOf('--hex-output') + 1]; fs.writeFileSync(output ?? '', 'NOBJ'); fs.writeFileSync(hexOutput ?? '', ':00000001FF\n'); - return { exitCode: 0, stdout: 'compiled\n', stderr: '' }; + return Promise.resolve({ exitCode: 0, stdout: 'compiled\n', stderr: '' }); }); const result = await new NucleusBackend(run, '/tool/nucleus').assemble({ asmPath: project.source, @@ -97,7 +97,8 @@ describe('Nucleus backend', () => { fs.mkdirSync(path.dirname(project.hex), { recursive: true }); fs.writeFileSync(project.hex, 'STALE HEX'); fs.writeFileSync(path.join(project.root, 'build', 'main.nobj'), 'STALE NOBJ'); - const run: NucleusCommandRunner = async () => ({ exitCode: 0, stdout: '', stderr: '' }); + const run: NucleusCommandRunner = () => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); const result = await new NucleusBackend(run).assemble({ asmPath: project.source, @@ -121,10 +122,10 @@ describe('Nucleus backend', () => { const nobj = path.join(project.root, 'build', 'main.nobj'); fs.writeFileSync(project.hex, 'PREVIOUS HEX'); fs.writeFileSync(nobj, 'PREVIOUS NOBJ'); - const run: NucleusCommandRunner = async (_command, args) => { + const run: NucleusCommandRunner = (_command, args) => { fs.writeFileSync(args[args.indexOf('-o') + 1] ?? '', ''); fs.writeFileSync(args[args.indexOf('--hex-output') + 1] ?? '', ''); - return { exitCode: 0, stdout: '', stderr: '' }; + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); }; const result = await new NucleusBackend(run).assemble({ @@ -141,11 +142,12 @@ describe('Nucleus backend', () => { it('translates an exact Nucleus source diagnostic', async () => { const project = workspace(); - const run: NucleusCommandRunner = async () => ({ - exitCode: 1, - stdout: '', - stderr: `${project.source}:1:5: Nucleus diagnostic 87\n`, - }); + const run: NucleusCommandRunner = () => + Promise.resolve({ + exitCode: 1, + stdout: '', + stderr: `${project.source}:1:5: Nucleus diagnostic 87\n`, + }); const result = await new NucleusBackend(run).assemble({ asmPath: project.source, hexPath: project.hex, From 5621ac502e07e67748332c7974418f21d1701d82 Mon Sep 17 00:00:00 2001 From: John Hardy Date: Fri, 14 Aug 2026 01:42:50 +1000 Subject: [PATCH 4/5] Integrate Nucleus D8 source maps --- apps/debug80-vscode/README.md | 9 ++- .../docs/nucleus-source-maps.md | 37 ++++++++++++ .../src/debug/launch/nucleus-backend.ts | 21 ++++++- .../tests/debug/nucleus-backend.test.ts | 57 ++++++++++++++++++- 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 apps/debug80-vscode/docs/nucleus-source-maps.md diff --git a/apps/debug80-vscode/README.md b/apps/debug80-vscode/README.md index 09e180d8..5ae0fd51 100644 --- a/apps/debug80-vscode/README.md +++ b/apps/debug80-vscode/README.md @@ -73,8 +73,11 @@ are available at [debug80.com](https://debug80.com/). highlighting inside `begin`/`end` bodies. - **Nucleus language support**: `.nu` files have language-aware editing, target discovery, positioned compiler diagnostics, and builds through the standalone - `nucleus` command. The build retains canonical `.nobj` beside its launchable - `.hex` artifact. Nucleus source stepping awaits a D8-compatible map sidecar. + `nucleus` command. The build retains canonical `.nobj` and native `.d8.json` + sidecars beside its launchable `.hex` artifact. Debug80 imports that D8 map + through its ordinary validator for source breakpoints and PC-to-source lookup. + Nucleus sidecars retain byte columns, while the initial debugger experience is + line-oriented. A project-local `nucleus-target.json` must provide the validated memory layout and all external service destinations; Debug80 never substitutes the compiler's synthetic proof addresses. @@ -154,7 +157,7 @@ Debug80 contributes commands for the normal project workflow: current debug session. - **Debug80: Select Workspace Folder** and **Debug80: Select Active Target**: switch the active project context. -- **Debug80: Set Program File**: choose the source entry point from an editor or +- **Debug80: Set Program File**: select the source entry point from an editor or Explorer context menu. - **Debug80: Open Project Configuration Panel**: open the active project config. - **Debug80: Open Auxiliary Source**: open bundled or project-provided platform diff --git a/apps/debug80-vscode/docs/nucleus-source-maps.md b/apps/debug80-vscode/docs/nucleus-source-maps.md new file mode 100644 index 00000000..4c860aba --- /dev/null +++ b/apps/debug80-vscode/docs/nucleus-source-maps.md @@ -0,0 +1,37 @@ +# Nucleus Source Maps in Debug80 + +For each build, Debug80 requests three related artifacts from the standalone +Nucleus compiler: canonical NOBJ, launchable Intel HEX, and a native D8 +source-map sidecar. The +backend publishes the three files as one generation. A missing or empty file, +a compiler diagnostic, or a publication error leaves the previous generation +unchanged. + +The sidecar is written beside the HEX file with the same base name: + +```text +build/main.nobj +build/main.hex +build/main.d8.json +``` + +The normal Debug80 D8 validator and source manager load the map. Source +breakpoints and PC-to-source lookup therefore use the same path as AZM and +Glimmer targets; the Nucleus backend does not parse compiler listings or infer +source from compiler addresses. + +Nucleus records 1-based byte columns in D8. Debug80's initial Nucleus behavior +binds and steps at line granularity because the current importer does not keep +columns through every internal lookup. Column-aware stepping is a separate +change. + +The standalone Node host can also compile banked targets and emit one D8 map +per physical bank. Those maps use the existing D8 memory-bank metadata and +Debug80 external address-space identity. The first Debug80 Nucleus launch +backend remains a flat Intel HEX path; it does not flatten a banked object or +invent a bank-selection policy. + +The event protocol used to produce the sidecar is documented in the Nucleus +repository. It is active only while the host-instrumented Z80 compiler runs. +Once compilation finishes, ports `$D8..$DF` return to ordinary emulated-device +handling for target programs. diff --git a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts index 1fdb5225..d77dfa51 100644 --- a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts +++ b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { parseD8DebugMap } from '../../mapping/d8-map'; import type { AssemblyDiagnostic, AssembleResult } from './assembler'; import type { AssembleOptions, AssemblerBackend } from './assembler-backend'; @@ -135,9 +136,11 @@ export class NucleusBackend implements AssemblerBackend { const artifactBase = extension.length === 0 ? options.hexPath : options.hexPath.slice(0, -extension.length); const nobjPath = `${artifactBase}.nobj`; + const debugMapPath = `${artifactBase}.d8.json`; const generation = `${process.pid}-${(buildOrdinal += 1)}`; const temporaryHexPath = `${artifactBase}.nucleus-${generation}.hex`; const temporaryNobjPath = `${artifactBase}.nucleus-${generation}.nobj`; + const temporaryDebugMapPath = `${artifactBase}.nucleus-${generation}.d8.json`; const cwd = options.sourceRoot ?? path.dirname(options.asmPath); const configuredProfile = process.env.NUCLEUS_TARGET_PROFILE?.trim(); const targetProfile = @@ -160,6 +163,8 @@ export class NucleusBackend implements AssemblerBackend { temporaryNobjPath, '--hex-output', temporaryHexPath, + '--d8-output', + temporaryDebugMapPath, '--target-profile', targetProfile, options.asmPath, @@ -170,6 +175,7 @@ export class NucleusBackend implements AssemblerBackend { } catch (error) { removeIfPresent(temporaryHexPath); removeIfPresent(temporaryNobjPath); + removeIfPresent(temporaryDebugMapPath); const message = `Nucleus compiler failed to start: ${error instanceof Error ? error.message : String(error)}`; options.onOutput?.(`${message}\n`); return { success: false, error: message }; @@ -188,19 +194,29 @@ export class NucleusBackend implements AssemblerBackend { if ( !fs.existsSync(temporaryHexPath) || !fs.existsSync(temporaryNobjPath) || + !fs.existsSync(temporaryDebugMapPath) || fs.statSync(temporaryHexPath).size === 0 || - fs.statSync(temporaryNobjPath).size === 0 + fs.statSync(temporaryNobjPath).size === 0 || + fs.statSync(temporaryDebugMapPath).size === 0 ) { return { success: false, error: - 'Nucleus compiler succeeded without producing nonempty fresh NOBJ and Intel HEX artifacts', + 'Nucleus compiler succeeded without producing nonempty fresh NOBJ, Intel HEX and D8 artifacts', + }; + } + const parsedDebugMap = parseD8DebugMap(fs.readFileSync(temporaryDebugMapPath, 'utf8')); + if (parsedDebugMap.map === undefined) { + return { + success: false, + error: `Nucleus compiler produced an invalid D8 artifact: ${parsedDebugMap.error ?? 'unknown validation failure'}`, }; } publishArtifacts( [ { temporaryPath: temporaryNobjPath, finalPath: nobjPath }, { temporaryPath: temporaryHexPath, finalPath: options.hexPath }, + { temporaryPath: temporaryDebugMapPath, finalPath: debugMapPath }, ], generation ); @@ -208,6 +224,7 @@ export class NucleusBackend implements AssemblerBackend { } finally { removeIfPresent(temporaryHexPath); removeIfPresent(temporaryNobjPath); + removeIfPresent(temporaryDebugMapPath); } } } diff --git a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts index db13a6cf..c0dd1ad4 100644 --- a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts +++ b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts @@ -41,13 +41,24 @@ describe('Nucleus backend', () => { return { root, source, hex }; } + const validD8 = JSON.stringify({ + format: 'd8-debug-map', + version: 1, + arch: 'z80', + addressWidth: 16, + endianness: 'little', + files: {}, + }); + it('requests canonical NOBJ and launchable HEX from the standalone compiler', async () => { const project = workspace(); const run = vi.fn((_command, args) => { const output = args[args.indexOf('-o') + 1]; const hexOutput = args[args.indexOf('--hex-output') + 1]; + const d8Output = args[args.indexOf('--d8-output') + 1]; fs.writeFileSync(output ?? '', 'NOBJ'); fs.writeFileSync(hexOutput ?? '', ':00000001FF\n'); + fs.writeFileSync(d8Output ?? '', validD8); return Promise.resolve({ exitCode: 0, stdout: 'compiled\n', stderr: '' }); }); const result = await new NucleusBackend(run, '/tool/nucleus').assemble({ @@ -63,6 +74,7 @@ describe('Nucleus backend', () => { 'build', '-o', '--hex-output', + '--d8-output', '--target-profile', path.join(project.root, 'nucleus-target.json'), project.source, @@ -72,6 +84,9 @@ describe('Nucleus backend', () => { ); expect(fs.readFileSync(path.join(project.root, 'build', 'main.nobj'), 'utf8')).toBe('NOBJ'); expect(fs.readFileSync(project.hex, 'utf8')).toBe(':00000001FF\n'); + expect(fs.readFileSync(path.join(project.root, 'build', 'main.d8.json'), 'utf8')).toContain( + 'd8-debug-map' + ); }); it('refuses to launch a synthetic target without real service destinations', async () => { @@ -108,7 +123,7 @@ describe('Nucleus backend', () => { expect(result).toMatchObject({ success: false, error: - 'Nucleus compiler succeeded without producing nonempty fresh NOBJ and Intel HEX artifacts', + 'Nucleus compiler succeeded without producing nonempty fresh NOBJ, Intel HEX and D8 artifacts', }); expect(fs.readFileSync(project.hex, 'utf8')).toBe('STALE HEX'); expect(fs.readFileSync(path.join(project.root, 'build', 'main.nobj'), 'utf8')).toBe( @@ -120,11 +135,14 @@ describe('Nucleus backend', () => { const project = workspace(); fs.mkdirSync(path.dirname(project.hex), { recursive: true }); const nobj = path.join(project.root, 'build', 'main.nobj'); + const d8 = path.join(project.root, 'build', 'main.d8.json'); fs.writeFileSync(project.hex, 'PREVIOUS HEX'); fs.writeFileSync(nobj, 'PREVIOUS NOBJ'); + fs.writeFileSync(d8, 'PREVIOUS D8'); const run: NucleusCommandRunner = (_command, args) => { fs.writeFileSync(args[args.indexOf('-o') + 1] ?? '', ''); fs.writeFileSync(args[args.indexOf('--hex-output') + 1] ?? '', ''); + fs.writeFileSync(args[args.indexOf('--d8-output') + 1] ?? '', ''); return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); }; @@ -137,7 +155,42 @@ describe('Nucleus backend', () => { expect(result.success).toBe(false); expect(fs.readFileSync(project.hex, 'utf8')).toBe('PREVIOUS HEX'); expect(fs.readFileSync(nobj, 'utf8')).toBe('PREVIOUS NOBJ'); - expect(fs.readdirSync(path.dirname(project.hex))).toEqual(['main.hex', 'main.nobj']); + expect(fs.readFileSync(d8, 'utf8')).toBe('PREVIOUS D8'); + expect(fs.readdirSync(path.dirname(project.hex)).sort()).toEqual([ + 'main.d8.json', + 'main.hex', + 'main.nobj', + ]); + }); + + it('rejects malformed D8 through the normal validator and retains the last generation', async () => { + const project = workspace(); + fs.mkdirSync(path.dirname(project.hex), { recursive: true }); + const nobj = path.join(project.root, 'build', 'main.nobj'); + const d8 = path.join(project.root, 'build', 'main.d8.json'); + fs.writeFileSync(project.hex, 'PREVIOUS HEX'); + fs.writeFileSync(nobj, 'PREVIOUS NOBJ'); + fs.writeFileSync(d8, validD8); + const run: NucleusCommandRunner = (_command, args) => { + fs.writeFileSync(args[args.indexOf('-o') + 1] ?? '', 'NOBJ'); + fs.writeFileSync(args[args.indexOf('--hex-output') + 1] ?? '', ':00000001FF\n'); + fs.writeFileSync(args[args.indexOf('--d8-output') + 1] ?? '', '{bad json'); + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + }; + + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + sourceRoot: project.root, + }); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('invalid D8 artifact'), + }); + expect(fs.readFileSync(project.hex, 'utf8')).toBe('PREVIOUS HEX'); + expect(fs.readFileSync(nobj, 'utf8')).toBe('PREVIOUS NOBJ'); + expect(fs.readFileSync(d8, 'utf8')).toBe(validD8); }); it('translates an exact Nucleus source diagnostic', async () => { From 6713fb9aee4dccc439d58ab178c5c5029ce0380b Mon Sep 17 00:00:00 2001 From: John Hardy Date: Fri, 14 Aug 2026 06:13:20 +1000 Subject: [PATCH 5/5] Reject banked Nucleus Debug80 launches --- .../docs/nucleus-source-maps.md | 9 ++++--- .../src/debug/launch/nucleus-backend.ts | 24 +++++++++++++++++++ .../tests/debug/nucleus-backend.test.ts | 21 ++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/debug80-vscode/docs/nucleus-source-maps.md b/apps/debug80-vscode/docs/nucleus-source-maps.md index 4c860aba..958a4576 100644 --- a/apps/debug80-vscode/docs/nucleus-source-maps.md +++ b/apps/debug80-vscode/docs/nucleus-source-maps.md @@ -27,9 +27,12 @@ change. The standalone Node host can also compile banked targets and emit one D8 map per physical bank. Those maps use the existing D8 memory-bank metadata and -Debug80 external address-space identity. The first Debug80 Nucleus launch -backend remains a flat Intel HEX path; it does not flatten a banked object or -invent a bank-selection policy. +Debug80 external address-space identity. Debug80's Nucleus application loader +currently accepts one flat Intel HEX image, so the launch backend rejects a +target profile whose `bankCount` is greater than one before invoking the +compiler. It does not flatten a banked object or invent a bank-selection +policy. Use the standalone CLI when banked NOBJ and per-bank D8 artifacts are +required. The event protocol used to produce the sidecar is documented in the Nucleus repository. It is active only while the host-instrumented Z80 compiler runs. diff --git a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts index d77dfa51..2ffabfab 100644 --- a/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts +++ b/apps/debug80-vscode/src/debug/launch/nucleus-backend.ts @@ -153,6 +153,30 @@ export class NucleusBackend implements AssemblerBackend { error: `Nucleus target profile not found at "${targetProfile}"; define real service destinations before launching`, }; } + let targetProfileValue: unknown; + try { + targetProfileValue = JSON.parse(fs.readFileSync(targetProfile, 'utf8')); + } catch (error) { + return { + success: false, + error: `Nucleus target profile at "${targetProfile}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if ( + typeof targetProfileValue === 'object' && + targetProfileValue !== null && + !Array.isArray(targetProfileValue) + ) { + const bankCount = (targetProfileValue as Record).bankCount; + if (typeof bankCount === 'number' && Number.isInteger(bankCount) && bankCount > 1) { + return { + success: false, + error: + `Debug80 Nucleus launch requires a flat target; profile "${targetProfile}" declares bankCount ${bankCount}. ` + + 'Use the standalone Nucleus CLI for banked NOBJ and per-bank D8 output.', + }; + } + } let result: NucleusCommandResult; try { result = await this.run( diff --git a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts index c0dd1ad4..27864615 100644 --- a/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts +++ b/apps/debug80-vscode/tests/debug/nucleus-backend.test.ts @@ -107,6 +107,27 @@ describe('Nucleus backend', () => { expect(run).not.toHaveBeenCalled(); }); + it('rejects banked profiles before requesting an impossible flat HEX launch artifact', async () => { + const project = workspace(); + const targetProfile = path.join(project.root, 'nucleus-target.json'); + const profile = JSON.parse(fs.readFileSync(targetProfile, 'utf8')) as Record; + fs.writeFileSync(targetProfile, JSON.stringify({ ...profile, bankCount: 2, entryBank: 0 })); + const run = vi.fn(); + + const result = await new NucleusBackend(run).assemble({ + asmPath: project.source, + hexPath: project.hex, + sourceRoot: project.root, + }); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('requires a flat target'), + }); + expect(result.error).toContain('standalone Nucleus CLI'); + expect(run).not.toHaveBeenCalled(); + }); + it('does not accept stale final artifacts as output from a successful command', async () => { const project = workspace(); fs.mkdirSync(path.dirname(project.hex), { recursive: true });