@8bitscript/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/8bs.mjs +132 -0
- package/package.json +39 -0
- package/src/build.mjs +309 -0
- package/src/check.mjs +67 -0
- package/src/config.mjs +46 -0
- package/src/doctor.mjs +976 -0
- package/src/font8x8.mjs +27 -0
- package/src/hardware.mjs +444 -0
- package/src/mac-window-capture.mjs +150 -0
- package/src/png.mjs +158 -0
- package/src/run.mjs +311 -0
- package/src/screenshot.mjs +485 -0
- package/src/setup/cx16.mjs +510 -0
- package/src/setup/deps.mjs +103 -0
- package/src/setup/exec.mjs +110 -0
- package/src/setup/host.mjs +58 -0
- package/src/setup/install.mjs +47 -0
- package/src/setup/launcher.mjs +112 -0
- package/src/setup/mega65-rom.mjs +165 -0
- package/src/setup/mega65.mjs +515 -0
- package/src/setup/paths.mjs +96 -0
- package/src/setup/prompt.mjs +28 -0
- package/src/setup/report.mjs +13 -0
- package/src/setup/rom.mjs +188 -0
- package/src/setup/source.mjs +60 -0
- package/src/setup/xemu.mjs +107 -0
- package/src/setup/zip.mjs +75 -0
- package/src/setup.mjs +56 -0
- package/src/targets.mjs +158 -0
- package/src/wasm-host.mjs +75 -0
- package/src/web-runtime.mjs +557 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 8BitScript contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/8bs.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `8bs` entry point — thin dispatch to each subcommand's own module in
|
|
3
|
+
// ../src/; see each one for what it actually does. `check` runs the
|
|
4
|
+
// compiler's diagnostics over files; `lsp` starts the language server on
|
|
5
|
+
// stdio for an editor to drive. Both go through @8bitscript/compiler, which
|
|
6
|
+
// is the point: one set of rules, reported in the terminal, in CI, and
|
|
7
|
+
// under the cursor.
|
|
8
|
+
const [, , command, ...rest] = process.argv;
|
|
9
|
+
|
|
10
|
+
const IMPLEMENTED = new Set(['check', 'lsp', 'doctor', 'build', 'run', 'setup', 'targets']);
|
|
11
|
+
const PLANNED = ['dev'];
|
|
12
|
+
|
|
13
|
+
const usage = () => `Usage: 8bs <command> [options]
|
|
14
|
+
|
|
15
|
+
Implemented:
|
|
16
|
+
build --target <t> [--pal] [--profile <name>]
|
|
17
|
+
[--hardware option=value,...] [entry]
|
|
18
|
+
Compile for a target: vic20, c64, pet, c128,
|
|
19
|
+
atari8, nes, cx16, mega65, or web. vic20/c64/
|
|
20
|
+
c128/mega65/atari8 default to NTSC (60Hz) at
|
|
21
|
+
run time; --pal selects the PAL (50Hz)
|
|
22
|
+
machine model (the pet has no region: its
|
|
23
|
+
model is hardware). --profile names the
|
|
24
|
+
hardware fitted — a preset from the machine's
|
|
25
|
+
catalog (8032, 130xe, reu512) or a profile the
|
|
26
|
+
project composes in 8bs.config.ts — and
|
|
27
|
+
--hardware sets single options on top
|
|
28
|
+
(ram=8k, port1=mouse1351). "8bs targets"
|
|
29
|
+
lists every option, value and preset.
|
|
30
|
+
run <target> [--pal] [--profile <name>]
|
|
31
|
+
[--hardware option=value,...] [entry]
|
|
32
|
+
Build, then open that target's emulator
|
|
33
|
+
(VICE for vic20/c64/pet/c128, atari800,
|
|
34
|
+
fceux, x16emu, or Xemu for mega65) at the
|
|
35
|
+
right machine model — or execute the .wasm
|
|
36
|
+
and print its state (web)
|
|
37
|
+
[--screenshot <file.png>] Instead of an interactive window, capture one
|
|
38
|
+
[--frames <n>] screenshot through the target's own emulator
|
|
39
|
+
API (or, for atari8 only, a macOS window
|
|
40
|
+
capture — see docs/setup/verify.md#screenshots).
|
|
41
|
+
--frames means a different unit per target
|
|
42
|
+
(cycles, wall-clock seconds, or exact
|
|
43
|
+
frame-advances); omit it for a tested default.
|
|
44
|
+
targets [--json] List every target and the hardware it can be
|
|
45
|
+
fitted with — options, values, presets, and
|
|
46
|
+
this project's own profiles; --json is what
|
|
47
|
+
the editor reads
|
|
48
|
+
check <files...> Report diagnostics for 8BitScript source files
|
|
49
|
+
doctor Verify the toolchains every target needs
|
|
50
|
+
setup <target> Install/configure what a target needs beyond
|
|
51
|
+
[--rom <path>] what doctor can offer as a single package-
|
|
52
|
+
[--c64-forever <msi>] manager command. mega65 builds Xemu from
|
|
53
|
+
[--rom-patch <zip>] source on macOS (Homebrew) and Arch/Manjaro
|
|
54
|
+
[--repair] [--update] (pacman), then installs a MEGA65 ROM: --rom
|
|
55
|
+
points at an already-generated MEGA65.ROM
|
|
56
|
+
(the primary path); --c64-forever/--rom-patch
|
|
57
|
+
generate one from a C64 Forever MSI instead
|
|
58
|
+
— docs/setup/mega65.md. cx16 builds x16emu and
|
|
59
|
+
a matching ROM from upstream source into
|
|
60
|
+
/opt/commander-x16 (--repair fixes a broken
|
|
61
|
+
launcher, --update rebuilds the pair —
|
|
62
|
+
docs/setup/cx16.md)
|
|
63
|
+
lsp [--stdio] Start the language server on stdio
|
|
64
|
+
|
|
65
|
+
Planned, not implemented:
|
|
66
|
+
${PLANNED.join(', ')}
|
|
67
|
+
|
|
68
|
+
The compiler covers the first-milestone subset of the language: globals,
|
|
69
|
+
functions, arithmetic, control flow, @address hardware access, and asm6502
|
|
70
|
+
blocks. Constructs beyond that fail with a message. See docs/compiler.md.
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
if (!command || command === '--help' || command === '-h') {
|
|
74
|
+
process.stdout.write(usage());
|
|
75
|
+
process.exit(command ? 0 : 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (command === '--version' || command === '-v') {
|
|
79
|
+
const { readFileSync } = await import('node:fs');
|
|
80
|
+
const { dirname, join } = await import('node:path');
|
|
81
|
+
const { fileURLToPath } = await import('node:url');
|
|
82
|
+
const pkg = JSON.parse(
|
|
83
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'),
|
|
84
|
+
);
|
|
85
|
+
process.stdout.write(`8bs ${pkg.version}\n`);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (command === 'check') {
|
|
90
|
+
const { check } = await import('../src/check.mjs');
|
|
91
|
+
process.exit(await check(rest.filter((a) => !a.startsWith('-'))));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (command === 'doctor') {
|
|
95
|
+
const { doctor } = await import('../src/doctor.mjs');
|
|
96
|
+
process.exit(await doctor());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (command === 'build') {
|
|
100
|
+
const { build } = await import('../src/build.mjs');
|
|
101
|
+
process.exit(await build(rest));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (command === 'run') {
|
|
105
|
+
const { run } = await import('../src/run.mjs');
|
|
106
|
+
process.exit(await run(rest));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (command === 'targets') {
|
|
110
|
+
const { targets } = await import('../src/targets.mjs');
|
|
111
|
+
process.exit(await targets(rest));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (command === 'setup') {
|
|
115
|
+
const { setup } = await import('../src/setup.mjs');
|
|
116
|
+
process.exit(await setup(rest));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (command === 'lsp') {
|
|
120
|
+
// --stdio is accepted because every editor passes it by convention; stdio is
|
|
121
|
+
// the only transport, so there is nothing to select.
|
|
122
|
+
const { start } = await import('@8bitscript/language-server');
|
|
123
|
+
start();
|
|
124
|
+
} else {
|
|
125
|
+
const known = IMPLEMENTED.has(command) || PLANNED.includes(command);
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
known
|
|
128
|
+
? `8bs ${command}: not implemented yet.\nThe compiler has no parser or backend yet, so there is nothing to ${command}.\n`
|
|
129
|
+
: `8bs: unknown command '${command}'\n\n${usage()}`,
|
|
130
|
+
);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@8bitscript/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The 8bs command line interface: builds, runs, and diagnoses 8BitScript projects.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=26"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"8bs": "./bin/8bs.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@8bitscript/atari8": "0.1.0",
|
|
19
|
+
"@8bitscript/backend-6502": "0.1.0",
|
|
20
|
+
"@8bitscript/backend-web": "0.1.0",
|
|
21
|
+
"@8bitscript/c128": "0.1.0",
|
|
22
|
+
"@8bitscript/c64": "0.1.0",
|
|
23
|
+
"@8bitscript/compiler": "0.1.0",
|
|
24
|
+
"@8bitscript/cx16": "0.1.0",
|
|
25
|
+
"@8bitscript/language-server": "0.1.0",
|
|
26
|
+
"@8bitscript/mega65": "0.1.0",
|
|
27
|
+
"@8bitscript/nes": "0.1.0",
|
|
28
|
+
"@8bitscript/pet": "0.1.0",
|
|
29
|
+
"@8bitscript/studio": "0.1.0",
|
|
30
|
+
"@8bitscript/vic20": "0.1.0",
|
|
31
|
+
"@8bitscript/web": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/build.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// `8bs build` — compile a program for a target.
|
|
2
|
+
//
|
|
3
|
+
// 8bs build --target vic20 [entry.8bs] NTSC (60Hz), the default
|
|
4
|
+
// 8bs build --target vic20 --pal [entry.8bs]
|
|
5
|
+
// 8bs build --target web [entry.8bs]
|
|
6
|
+
// 8bs build --target atari8 --profile 130xe [entry.8bs]
|
|
7
|
+
// 8bs build --target vic20 --profile 16k [entry.8bs]
|
|
8
|
+
// 8bs build --target c64 --profile reu512 [entry.8bs]
|
|
9
|
+
// 8bs build --target pet --profile 8032 [entry.8bs] 80 columns, 32K
|
|
10
|
+
//
|
|
11
|
+
// The system (vic20/c64/pet/c128/atari8/nes/cx16/mega65/web) is the target;
|
|
12
|
+
// NTSC/PAL is a --pal/--ntsc option on top of it, not a separate flavor of
|
|
13
|
+
// the target, because it changes the emulator's machine model at run time
|
|
14
|
+
// and nothing about the build — see the comment on MODEL_ARGS in run.mjs
|
|
15
|
+
// for why that still means picking a whole machine model rather than just a
|
|
16
|
+
// sync-factor flag. It only applies to targets with a real region split
|
|
17
|
+
// (see REGION_TARGETS below); it's silently ignored everywhere else, the
|
|
18
|
+
// same as it already was for web. "NTSC (60Hz)" above is the emulator's real
|
|
19
|
+
// hardware region, not the language's logical frame rate — that's a
|
|
20
|
+
// separate, project-level setting (`frameRate` in 8bs.config.ts, default 60,
|
|
21
|
+
// see packages/backend-6502's FRAME_SYNC and examples/borders/README.md),
|
|
22
|
+
// unaffected by --pal.
|
|
23
|
+
//
|
|
24
|
+
// --profile names the hardware the build is for: a preset from the
|
|
25
|
+
// machine package's catalog (`8032`, `130xe`, `reu512` — the community's
|
|
26
|
+
// names for whole configurations) or a profile the project composes in
|
|
27
|
+
// its 8bs.config.ts (`targets: { c64: { profiles: { loaded: { ram:
|
|
28
|
+
// 'reu512', port1: 'mouse1351' } } } }`). --hardware option=value,...
|
|
29
|
+
// sets single options on top of either. What each option changes — a
|
|
30
|
+
// link symbol, a driver, an emulator flag, a fact a program can read — is
|
|
31
|
+
// the catalog's to say; see packages/cli/src/hardware.mjs and
|
|
32
|
+
// docs/systems.md. `8bs targets` lists every option and preset.
|
|
33
|
+
//
|
|
34
|
+
// The entry defaults to src/main.8bs, or to the `entry` in 8bs.config.ts when
|
|
35
|
+
// the project has one — and whichever file that names, a `.<target>.8bs`
|
|
36
|
+
// twin beside it (main.nes.8bs next to main.8bs) is what a build for that
|
|
37
|
+
// target actually starts from; see resolveEntryPath. Output lands in dist/, named
|
|
38
|
+
// <name>-<machine>[-<hardware>...][-<region>].<ext> — .prg for the Commodore/
|
|
39
|
+
// CX16/MEGA65 targets, .xex (or .rom for an XEGS cartridge) for Atari 8-bit,
|
|
40
|
+
// .nes for the NES, .wasm for the web — with the generated C or
|
|
41
|
+
// AssemblyScript beside it so what the compiler did is never a mystery.
|
|
42
|
+
// Only hardware that changes the *build* is in the name (a PET's model, a
|
|
43
|
+
// VIC-20's RAM): a mouse or a REU makes the same program, so it is not.
|
|
44
|
+
import { readFile } from 'node:fs/promises';
|
|
45
|
+
import { existsSync } from 'node:fs';
|
|
46
|
+
import { basename, resolve } from 'node:path';
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
MACHINES, isVariantPath, link, positionAt, variantOf,
|
|
50
|
+
unmetRequirements,
|
|
51
|
+
} from '@8bitscript/compiler';
|
|
52
|
+
|
|
53
|
+
import { loadConfig, resolveFrameRate } from './config.mjs';
|
|
54
|
+
import {
|
|
55
|
+
HARDWARE_USAGE, REGION_MACHINES, hardwareArgs, listedTargets, loadCatalog, projectHardware,
|
|
56
|
+
projectProfiles, projectRequires, projectSystems, resolveHardware, whatSatisfies,
|
|
57
|
+
} from './hardware.mjs';
|
|
58
|
+
|
|
59
|
+
const TARGETS = new Set(MACHINES);
|
|
60
|
+
|
|
61
|
+
// The targets whose frame-sync strategy has a real NTSC/PAL split, auto-
|
|
62
|
+
// detected at runtime (packages/backend-6502's FRAME_SYNC 'level' machines)
|
|
63
|
+
// — the only ones where --pal changes the build or a region suffix on the
|
|
64
|
+
// output filename. The PET has no region at all: its refresh is the
|
|
65
|
+
// model's (a hardware option — see packages/pet/package.json's catalog),
|
|
66
|
+
// FRAME_SYNC.pet measures it at start-up, and `8bs run pet` says so if
|
|
67
|
+
// given --pal. It is the same set `8bs targets` reports a region for, so
|
|
68
|
+
// there is one of it (hardware.mjs).
|
|
69
|
+
const REGION_TARGETS = REGION_MACHINES;
|
|
70
|
+
|
|
71
|
+
// Diagnostics may come from any module in the import graph, so each one is
|
|
72
|
+
// rendered against its own file's text, not the entry's.
|
|
73
|
+
function printDiagnostics(diagnostics, sources) {
|
|
74
|
+
for (const d of diagnostics) {
|
|
75
|
+
const { line, column } = positionAt(sources.get(d.file) ?? '', d.start);
|
|
76
|
+
process.stdout.write(`${basename(d.file)}:${line}:${column}\n`);
|
|
77
|
+
process.stdout.write(`${d.severity} ${d.code}: ${d.message}\n\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The old target names, kept only to point people at their replacement
|
|
82
|
+
// rather than failing with a bare "unknown target".
|
|
83
|
+
const RETIRED_TARGET = /^(vic20|c64)-(ntsc|pal)$/;
|
|
84
|
+
|
|
85
|
+
// `entry` in 8bs.config.ts is one path, shared by every target, and the
|
|
86
|
+
// filename rule does the rest: a project whose entry point genuinely has to
|
|
87
|
+
// differ on one machine — its execution model, its screen codes, its grid
|
|
88
|
+
// — puts that machine's version beside the shared file as
|
|
89
|
+
// `main.<target>.8bs`, and a build for that target starts there instead.
|
|
90
|
+
// The same rule applies to every file the entry imports, so this is not a
|
|
91
|
+
// special case for the entry; it is just where the CLI applies it first.
|
|
92
|
+
// The rule is applied to whatever names the entry — the config, the
|
|
93
|
+
// default, or an explicit argument — unless that path already names one
|
|
94
|
+
// machine's version (`8bs build src/main.nes.8bs`), which is taken as is.
|
|
95
|
+
//
|
|
96
|
+
// `entry` may still be an object keyed by machine (`{ default, nes }`), the
|
|
97
|
+
// older spelling of the same idea, kept working for projects that use it;
|
|
98
|
+
// `default` covers whichever targets aren't named.
|
|
99
|
+
export function resolveEntryPath(config, target, entryArg) {
|
|
100
|
+
let entry = config?.entry;
|
|
101
|
+
if (entry && typeof entry === 'object') entry = entry[target] ?? entry.default;
|
|
102
|
+
const path = resolve(entryArg ?? entry ?? 'src/main.8bs');
|
|
103
|
+
if (isVariantPath(path)) return path;
|
|
104
|
+
const variant = variantOf(path, target);
|
|
105
|
+
return existsSync(variant) ? variant : path;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compile one entry file for one target.
|
|
110
|
+
*
|
|
111
|
+
* @param {'vic20'|'c64'|'pet'|'c128'|'atari8'|'nes'|'cx16'|'mega65'|'web'} target
|
|
112
|
+
* @param {string} [entryArg]
|
|
113
|
+
* @param {{ pal?: boolean, profile?: string, hardware?: object }} [options] `pal` selects the
|
|
114
|
+
* real hardware/emulator region (NTSC unless true; ignored outside
|
|
115
|
+
* REGION_TARGETS) — it does not affect the logical frame rate, which is
|
|
116
|
+
* read from 8bs.config.ts's `frameRate` instead (default 60). `profile`
|
|
117
|
+
* names a project profile or a catalog preset, `hardware` is option
|
|
118
|
+
* values set on top (`--hardware`); see hardware.mjs.
|
|
119
|
+
* @returns {Promise<{ ok: boolean, outFile?: string, frameRate?: number, hardware?: object }>}
|
|
120
|
+
* `hardware` is the resolved hardware the program was built for, for
|
|
121
|
+
* whoever runs it next.
|
|
122
|
+
*/
|
|
123
|
+
export async function compile(target, entryArg, { pal = false, profile, hardware: overrides = {} } = {}) {
|
|
124
|
+
const config = await loadConfig(process.cwd(), '8bs build');
|
|
125
|
+
|
|
126
|
+
const frameRateResult = resolveFrameRate(config);
|
|
127
|
+
if (!frameRateResult.ok) {
|
|
128
|
+
process.stderr.write(`8bs build: ${frameRateResult.error}\n`);
|
|
129
|
+
return { ok: false };
|
|
130
|
+
}
|
|
131
|
+
const { frameRate } = frameRateResult;
|
|
132
|
+
|
|
133
|
+
// Nothing in a build reads the `systems` block, but a typo in one should
|
|
134
|
+
// not wait for someone to open the editor to be noticed. Said once, and
|
|
135
|
+
// not fatal: the build asked for is still the build to make.
|
|
136
|
+
const systems = projectSystems(config);
|
|
137
|
+
if (!systems.ok) process.stderr.write(`8bs build: ${systems.error}\n`);
|
|
138
|
+
|
|
139
|
+
const retired = RETIRED_TARGET.exec(target ?? '');
|
|
140
|
+
if (retired) {
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
`8bs build: '${target}' is no longer a target. Use '${retired[1]}'` +
|
|
143
|
+
`${retired[2] === 'pal' ? " with '--pal'" : ' (NTSC is the default)'} instead.\n`,
|
|
144
|
+
);
|
|
145
|
+
return { ok: false };
|
|
146
|
+
}
|
|
147
|
+
if (!TARGETS.has(target)) {
|
|
148
|
+
process.stderr.write(
|
|
149
|
+
`8bs build: unknown target '${target}'. Targets: ${[...TARGETS].join(', ')}\n`,
|
|
150
|
+
);
|
|
151
|
+
return { ok: false };
|
|
152
|
+
}
|
|
153
|
+
const listed = listedTargets(config);
|
|
154
|
+
if (listed && !listed.includes(target)) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`8bs build: this project's 8bs.config.ts does not list '${target}' ` +
|
|
157
|
+
`(targets: ${listed.join(', ')})\n`,
|
|
158
|
+
);
|
|
159
|
+
return { ok: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolved = resolveHardware(loadCatalog(target), {
|
|
163
|
+
profile, overrides, profiles: projectProfiles(config, target), defaults: projectHardware(config, target),
|
|
164
|
+
});
|
|
165
|
+
if (!resolved.ok) {
|
|
166
|
+
process.stderr.write(`8bs build: ${resolved.error}\n`);
|
|
167
|
+
return { ok: false };
|
|
168
|
+
}
|
|
169
|
+
const { hardware } = resolved;
|
|
170
|
+
|
|
171
|
+
// What the program needs of the machine, before the machine gets a
|
|
172
|
+
// chance to disappoint it. A program that cannot run in the RAM it was
|
|
173
|
+
// given fails at the linker with an overflow measured in bytes of
|
|
174
|
+
// section; this says the same thing in the program's own terms, names
|
|
175
|
+
// what it asked for, and — the useful half — what this machine could be
|
|
176
|
+
// fitted with that would do.
|
|
177
|
+
const required = projectRequires(config);
|
|
178
|
+
if (!required.ok) {
|
|
179
|
+
process.stderr.write(`8bs build: ${required.error}\n`);
|
|
180
|
+
return { ok: false };
|
|
181
|
+
}
|
|
182
|
+
const unmet = unmetRequirements(required.requires, hardware.facts);
|
|
183
|
+
if (unmet.length > 0) {
|
|
184
|
+
const catalog = loadCatalog(target);
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
`8bs build: this program asks for more than a ${hardware.label === 'stock' ? `stock ${target}` : `${target} with ${hardware.label}`} gives.\n`,
|
|
187
|
+
);
|
|
188
|
+
for (const { key, need, have } of unmet) {
|
|
189
|
+
process.stderr.write(
|
|
190
|
+
` ${key}: needs ${need === true ? 'it' : need}, this build has ${have === true ? 'it' : have}\n`,
|
|
191
|
+
);
|
|
192
|
+
const fits = whatSatisfies(catalog, key, need, {
|
|
193
|
+
profile, overrides, profiles: projectProfiles(config, target), defaults: projectHardware(config, target),
|
|
194
|
+
});
|
|
195
|
+
process.stderr.write(fits.length > 0
|
|
196
|
+
? ` fit one of: ${fits.join(', ')} (--hardware, or a system in 8bs.config.ts)\n`
|
|
197
|
+
: ` no ${target} can be fitted with that; this program is not for this machine\n`);
|
|
198
|
+
}
|
|
199
|
+
return { ok: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const entry = resolveEntryPath(config, target, entryArg);
|
|
203
|
+
if (!existsSync(entry)) {
|
|
204
|
+
process.stderr.write(`8bs build: entry ${entry} does not exist\n`);
|
|
205
|
+
return { ok: false };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const text = await readFile(entry, 'utf8');
|
|
209
|
+
|
|
210
|
+
// The linker runs the full front end over the entry and everything it
|
|
211
|
+
// imports, then merges the graph into one program. Any error in any module
|
|
212
|
+
// means no build. The machine rides along so packages with target-
|
|
213
|
+
// conditional entries resolve to this machine's implementation, and the
|
|
214
|
+
// hardware's facts so every `#fact(...)` — the sheet @8bitscript/system
|
|
215
|
+
// declares — folds to this build's value, and the
|
|
216
|
+
// hardware's tags so a file with a `.<machine>.<tag>.8bs` twin resolves
|
|
217
|
+
// to that.
|
|
218
|
+
const { ir, diagnostics, sources } = link(text, entry, { machine: target, tags: hardware.tags, facts: hardware.facts, frameRate });
|
|
219
|
+
if (diagnostics.length > 0) {
|
|
220
|
+
printDiagnostics(diagnostics, sources);
|
|
221
|
+
process.stdout.write(`${diagnostics.length} problem(s); not building.\n`);
|
|
222
|
+
return { ok: false };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A target's own entry file is named after it (main.nes.8bs — see
|
|
226
|
+
// resolveEntryPath); the output name carries the target once, in the
|
|
227
|
+
// same place every other target's does, so main.nes.8bs builds to
|
|
228
|
+
// main-nes.nes just as main.8bs does, not to main.nes-nes.nes.
|
|
229
|
+
let stem = basename(entry, '.8bs');
|
|
230
|
+
if (stem.endsWith(`.${target}`)) stem = stem.slice(0, -(target.length + 1));
|
|
231
|
+
if (target === 'web') {
|
|
232
|
+
const { buildWasm } = await import('@8bitscript/backend-web');
|
|
233
|
+
const outFile = resolve('dist', `${stem}.wasm`);
|
|
234
|
+
const result = await buildWasm(ir, { outFile });
|
|
235
|
+
if (!result.ok) {
|
|
236
|
+
process.stderr.write(`8bs build: ${result.error}\n`);
|
|
237
|
+
return { ok: false };
|
|
238
|
+
}
|
|
239
|
+
const { writeWebBundle } = await import('./web-runtime.mjs');
|
|
240
|
+
const webDir = resolve('dist', 'web');
|
|
241
|
+
await writeWebBundle(webDir, await readFile(outFile), { frameRate });
|
|
242
|
+
process.stdout.write(`built ${outFile}\n(generated AssemblyScript: ${result.asFile})\n`);
|
|
243
|
+
process.stdout.write(`web bundle: ${webDir}/\n`);
|
|
244
|
+
process.stdout.write(`${memoryLine(ir.memory)}\n`);
|
|
245
|
+
return { ok: true, outFile, frameRate, hardware, webDir };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const { buildPrg, outputExtension } = await import('@8bitscript/backend-6502');
|
|
249
|
+
// Hardware that changes the build is in the name (an 8032 PET, an
|
|
250
|
+
// expanded VIC-20, an XEGS cartridge); hardware that only changes the
|
|
251
|
+
// emulator is not, because the file is the same file.
|
|
252
|
+
const nameParts = [stem, target, ...hardware.buildValues];
|
|
253
|
+
if (REGION_TARGETS.has(target)) nameParts.push(pal ? 'pal' : 'ntsc');
|
|
254
|
+
const ext = outputExtension(target, hardware);
|
|
255
|
+
const outFile = resolve('dist', `${nameParts.join('-')}.${ext}`);
|
|
256
|
+
const result = await buildPrg(ir, { machine: target, hardware, outFile, frameRate });
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
process.stderr.write(`8bs build: ${result.error}\n`);
|
|
259
|
+
return { ok: false };
|
|
260
|
+
}
|
|
261
|
+
process.stdout.write(`built ${outFile}\n(generated C: ${result.cFile})\n`);
|
|
262
|
+
process.stdout.write(`${memoryLine(ir.memory, result.memory)}\n`);
|
|
263
|
+
return { ok: true, outFile, frameRate, hardware };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The memory line under "built": how much RAM the program's variables
|
|
268
|
+
* take and how much constant data it carries. Measured from the linked
|
|
269
|
+
* program when the backend could (the 6502 backend reads the ELF; a
|
|
270
|
+
* variable LLVM dropped for being unread is not counted), else as
|
|
271
|
+
* declared in the source. The machine's own limit is the toolchain's:
|
|
272
|
+
* a program that does not fit does not build, and the build says so.
|
|
273
|
+
*/
|
|
274
|
+
export function memoryLine(declared, measured = null) {
|
|
275
|
+
if (measured) {
|
|
276
|
+
return `memory: ${measured.variables} bytes of RAM for variables, ${measured.program} bytes of program (code and data)`;
|
|
277
|
+
}
|
|
278
|
+
return `memory: ${declared.variables} bytes of RAM for variables, ${declared.data} bytes of constant data (as declared)`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @returns {Promise<number>} exit code */
|
|
282
|
+
export async function build(args) {
|
|
283
|
+
const pal = args.includes('--pal');
|
|
284
|
+
const targetIndex = args.indexOf('--target');
|
|
285
|
+
const hw = hardwareArgs(args);
|
|
286
|
+
if (!hw.ok) {
|
|
287
|
+
process.stderr.write(`8bs build: ${hw.error}\n`);
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
const positionals = args.filter((a, i) => {
|
|
291
|
+
if (targetIndex >= 0 && (i === targetIndex || i === targetIndex + 1)) return false;
|
|
292
|
+
if (hw.consumed.has(i)) return false;
|
|
293
|
+
return !a.startsWith('-');
|
|
294
|
+
});
|
|
295
|
+
const target = targetIndex >= 0 ? args[targetIndex + 1] : positionals[0];
|
|
296
|
+
const entry = targetIndex >= 0 ? positionals[0] : positionals[1];
|
|
297
|
+
|
|
298
|
+
if (!target) {
|
|
299
|
+
process.stderr.write(
|
|
300
|
+
'Usage: 8bs build --target <vic20|c64|pet|c128|atari8|nes|cx16|mega65|web>\n'
|
|
301
|
+
+ ' [--pal]\n'
|
|
302
|
+
+ HARDWARE_USAGE
|
|
303
|
+
+ ' [entry.8bs]\n',
|
|
304
|
+
);
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
307
|
+
const { ok } = await compile(target, entry, { pal, profile: hw.profile, hardware: hw.overrides });
|
|
308
|
+
return ok ? 0 : 1;
|
|
309
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `8bs check` — run the compiler's diagnostics over files and print them.
|
|
2
|
+
//
|
|
3
|
+
// The output format is the one the editor shows, in terminal form. Both come
|
|
4
|
+
// from the same analyze() call, so a green `8bs check` and a clean editor mean
|
|
5
|
+
// the same thing.
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { relative, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { analyze, positionAt } from '@8bitscript/compiler';
|
|
10
|
+
|
|
11
|
+
import { loadConfig, resolveFrameRate } from './config.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string[]} files
|
|
15
|
+
* @returns {Promise<number>} process exit code
|
|
16
|
+
*/
|
|
17
|
+
export async function check(files) {
|
|
18
|
+
if (files.length === 0) {
|
|
19
|
+
process.stderr.write('8bs check: no files given\n\nUsage: 8bs check <file.8bs> [...]\n');
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The same project-level `frameRate` a build would use (8bs.config.ts,
|
|
24
|
+
// default 60) — `#frames(...)` folds against it, so a project's `8bs
|
|
25
|
+
// check` and its `8bs build` agree on what a duration means. There is no
|
|
26
|
+
// per-project config for the language server yet (no workspace-root
|
|
27
|
+
// concept to load 8bs.config.ts from), so its diagnostics still assume 60
|
|
28
|
+
// until that's picked up as follow-up work.
|
|
29
|
+
const config = await loadConfig(process.cwd(), '8bs check');
|
|
30
|
+
const frameRateResult = resolveFrameRate(config);
|
|
31
|
+
if (!frameRateResult.ok) {
|
|
32
|
+
process.stderr.write(`8bs check: ${frameRateResult.error}\n`);
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
const { frameRate } = frameRateResult;
|
|
36
|
+
|
|
37
|
+
let total = 0;
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const path = resolve(file);
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = await readFile(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
process.stderr.write(`8bs check: cannot read ${file}\n`);
|
|
46
|
+
total += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const display = relative(process.cwd(), path) || file;
|
|
51
|
+
// Resolution needs the real path; the display path is only for printing.
|
|
52
|
+
for (const d of analyze(text, path, { resolveImports: true, frameRate })) {
|
|
53
|
+
const { line, column } = positionAt(text, d.start);
|
|
54
|
+
process.stdout.write(`${display}:${line}:${column}\n`);
|
|
55
|
+
process.stdout.write(`${d.severity} ${d.code}: ${d.message}\n\n`);
|
|
56
|
+
total += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (total === 0) {
|
|
61
|
+
process.stdout.write(`Checked ${files.length} file(s). No problems found.\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.stdout.write(`${total} problem(s) found.\n`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared 8bs.config.ts loading, used by both `8bs build` and `8bs check` (and,
|
|
2
|
+
// through them, anything else that needs a project's config without
|
|
3
|
+
// duplicating the loader).
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The project's 8bs.config.ts, if present. Node 26 imports TypeScript with
|
|
10
|
+
* type stripping, so the config is an ordinary module, not a parsed format.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} dir
|
|
13
|
+
* @param {string} [label] Prefixes a load error, e.g. "8bs build".
|
|
14
|
+
*/
|
|
15
|
+
export async function loadConfig(dir, label = '8bs') {
|
|
16
|
+
const path = join(dir, '8bs.config.ts');
|
|
17
|
+
if (!existsSync(path)) return null;
|
|
18
|
+
try {
|
|
19
|
+
const module = await import(pathToFileURL(path).href);
|
|
20
|
+
return module.default ?? null;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
process.stderr.write(`${label}: cannot load 8bs.config.ts: ${error.message}\n`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The project's logical frame rate — what `waitFrame()` runs at, the same on every target,
|
|
29
|
+
* independent of --pal (which only selects a real hardware/emulator
|
|
30
|
+
* region, not the logical rate; see packages/backend-6502's FRAME_SYNC).
|
|
31
|
+
* Defaults to 60; `#frames(...)` durations and the waitFrame() runtime are
|
|
32
|
+
* both built against whatever this resolves to.
|
|
33
|
+
*
|
|
34
|
+
* @param {object|null} config
|
|
35
|
+
* @returns {{ ok: true, frameRate: number } | { ok: false, error: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveFrameRate(config) {
|
|
38
|
+
const frameRate = config?.frameRate ?? 60;
|
|
39
|
+
if (!Number.isInteger(frameRate) || frameRate <= 0) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: `8bs.config.ts's frameRate must be a positive integer, got ${JSON.stringify(config?.frameRate)}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, frameRate };
|
|
46
|
+
}
|