Development¶
Requirements and local development setup.
Requirements¶
-
Windows, Linux, or Mac.
-
.NET 10 SDK 10.0.401 or a later 10.0.4xx patch installed, as specified in
global.json. -
Browser project specifics: To enable build of the browser projects in the solution (Blazor WASM and Avalonia Browser), install the WebAssembly build tools:
- In the command prompt, change current directory to where the repo is checked out, and run
dotnet workload restore. - Or if you prefer, in Visual Studio Installer, add the component ".NET 10.0 WebAssembly Build Tools".
- For more info about the WebAssembly build tools, see the .NET tooling documentation.
- In the command prompt, change current directory to where the repo is checked out, and run
-
Develop in:
- VSCode (Windows, Linux, Mac)
- Visual Studio 2026 (Windows)
- Or other preferred editor.
Tests¶
The XUnit library is used.
Tests are currently focused on the core Highbyte.DotNet6502 library. Most of its code has been developed with a test-first approach.
Tests may expand to parts of system-specific logic code such as Highbyte.DotNet6502.Systems.Commodore64.
The browser apps (Blazor WASM and Avalonia Browser) additionally have a Playwright smoke test under tests/wasm-smoke/ that publishes the apps with the release Release/AOT flags and verifies the result boots in headless Chromium. See WASM AOT publish smoke tests below.
Debugging¶
Debugging the emulator via VS Code extension¶
You can debug the C# emulator code while debugging a 6502 program using the VS Code extension. See VSCode debugger extension — Development.
Unit tests¶
To run only unit tests (for the Highbyte.DotNet6502 library):
Functional (integration) test¶
There is a special XUnit test that runs the test code found here: Functional 6502 test program.
The purpose of it is to verify if an emulator (or real computer) executes the 6502 instructions correctly.
Notes on the special functional/integration XUnit test:
- It downloads the Functional 6502 test program from the repo.
- It modifies the
.asmsource code to disable Decimal tests. - It compiles the
.asmsource code with the AS65 assembler (bundled in the repo above). - It loads the compiled 6502 binary into this emulator, and runs it.
- This emulator is configured to stop executing when the program reaches a specific "success" address (where the program loops forever).
- It currently requires a Windows machine to run due to the AS65 assembler. There may be a Java-version of it that could possibly be used instead.
To run only the special functional/integration XUnit test on Windows:
Bus-cycle vector tests (SingleStepTests corpus)¶
tests/Highbyte.DotNet6502.Tests/SingleStepTests/ runs a pinned subset of the SingleStepTests 65x02 corpus (MIT). Every vector is one instruction with the full CPU and memory state before and after, plus every bus cycle the silicon performs: address, value and direction, one per clock cycle. The tests require the final state, the cycle-by-cycle bus trace, the reported cycle count and the advance of CPU.BusCycles to all agree. The 6502 set runs against the NMOS 6502 model with the full undocumented profile; the wdc65c02 set against the NCR 65C02 model. Bytes where the emulated part is documented to differ from the corpus's part (the Rockwell bit instructions, WDC's WAI/STP) are listed with their reason in the test class and skipped, not asserted.
The fixtures live in tests/Highbyte.DotNet6502.Tests/Fixtures/SingleStepTests/ (20 vectors per opcode, gzip'd JSON lines, with the upstream LICENSE and a manifest recording the corpus commit and file hashes). They are regenerated deterministically with:
python3 tools/singlesteptests/extract.py # all sets, 20 vectors per opcode
python3 tools/singlesteptests/extract.py --sets wdc65c02 --per-opcode 50
The script downloads the per-opcode files (about 5 MB each) at the pinned commit and keeps the first N vectors of each, so the fixtures stay small enough to commit.
C64 program-level tests with the real ROMs¶
tests/Highbyte.DotNet6502.Systems.Tests/Commodore64/C64RealRomBootTests.cs boots the genuine KERNAL, BASIC and character ROMs to the READY. prompt. The ROMs are copyrighted and are not in the repository, so these tests are marked [RequiresC64RomsFact] and report as skipped (with the reason) when the ROMs are missing rather than passing without running. They are tagged TestType=Integration.
The helper C64TestRoms looks for the ROM files (under the names the app's own ROM download writes) in the C64 ROM directory, or in DOTNET6502_C64_ROM_DIR when set, and verifies each file's SHA-1 against the checksums in C64SystemConfig. Set DOTNET6502_DOWNLOAD_TEST_ROMS=1 to let the helper fetch missing files from the same public archive the app uses:
DOTNET6502_C64_ROM_DIR=/tmp/c64-roms DOTNET6502_DOWNLOAD_TEST_ROMS=1 \
dotnet test tests/Highbyte.DotNet6502.Systems.Tests --filter TestType=Integration
The Build & run tests workflow sets both variables (with the ROM directory cached between runs), so these tests run in CI. Use the same helper for any future C64 test that needs a booted machine.
VIC-II tests from the VICE test programs¶
tools/vice-testprogs/Highbyte.DotNet6502.ViceTestprogs runs the VIC-II test programs from the VICE test programs repository against the C64 emulation and compares the picture with the reference screenshots that ship with them. It is a console tool, not part of the test suite: the programs and their references are not in this repository (fetch a suite directory, for example testprogs/VICII/border, from the Subversion repository at https://svn.code.sf.net/p/vice-emu/code/testprogs/VICII/), and many of the pictures are not expected to match yet.
The tool boots the real ROMs to the READY. prompt, loads a .prg, runs it from its BASIC SYS line (with the CPU's FullUnofficial opcode profile, as the programs are written for the real chip) and stops at the first write to $D7FF made with the I/O area banked in, which is how the programs report their result ($00 passed, $FF failed) and end. The frame completed before that write is compared with the reference, colour index by colour index, over the part of the frame both pictures cover. For each program it writes a picture with the reference, the emulator's frame and the differing pixels side by side, and a results.md table with the exit code, the number of differing pixels and the verdict: pass, fail or timeout (the program never wrote its exit code).
dotnet run --project tools/vice-testprogs/Highbyte.DotNet6502.ViceTestprogs -c Release -- \
--tests /path/to/testprogs/VICII --suite border,dentest --roms /path/to/c64-roms --out /tmp/vice-results --model both
Programs are started at the address of their BASIC stub's SYS statement, which may be a number (with or without parentheses) or an expression computed from the BASIC start pointer, as SYS PEEK(43)+256*PEEK(44)+26; the tool evaluates PEEK, +, - and * on the machine's memory. --tests also takes the testprogs/CIA, CPU, interrupts and general directories, whose programs report through the exit code (their screens are compared by eye against the dumps and readmes in the suites). --suite names the directories to run, --filter and --exclude select programs by name, --model picks the PAL or NTSC machine (_ntsc.prg and -ntsc.prg variants are run on the NTSC machine, plain ones on the PAL machine), --frames caps the run for programs that never write the exit code. The picture compared is the frame in which the program writes its exit code, run to its end, which is the frame VICE's exit screenshot shows. Set VICETEST_TRACE to a comma separated list of raster lines to log the VIC-II register writes on those lines with their cycle, the quickest way to see what a program does on the line where its picture differs. --measure <png> prints the reference picture's layout, for adding a suite whose window differs. --pixel-generator legacy runs the programs with the rasterizer's legacy pixel generator instead of the default sequencer one, to see what the faster generator does not reproduce.
--testlist takes VICE's own list of the C64 tests (testprogs/testbench/c64-testlist.in in the same repository), which makes the verdicts exact: programs the list expects to hang (the CPU jam tests) or to report failure pass on that outcome, programs listed as interactive or analyzer are skipped, and each program runs for its listed cycle budget instead of --frames. A program listed under several directories is matched to the one whose name occurs in the suite name (suites may be named CPU_64doc when the tests directory holds links to several source directories).
WASM AOT publish smoke tests¶
A Playwright suite under tests/wasm-smoke/ publishes the Blazor WASM and Avalonia Browser apps with the same Release/AOT flags as the GitHub Pages release workflow, then verifies the published wwwroot boots in headless Chromium and that the system plug-ins are discovered. Catches trim/AOT-only regressions (missing trim roots, plug-in assemblies dropped from the bundle, ...) that dotnet build / dotnet test do not exercise.
Requires a .NET 10 SDK with the wasm-tools workload installed (see Requirements) and Node.js (used to drive Playwright).
A wrapper script publishes and runs the matching spec end-to-end:
The same flow runs automatically on pull requests (paths-filtered to WASM-relevant source) via the .github/workflows/wasm-aot-verify.yml workflow.
The same workflow also runs blazor-speed.spec.ts against the published Blazor build: it starts the ROM-free Generic system, enables the in-app stats panel and records the host frame rate and emulator time per frame into the job summary and a wasm-speed-readout artifact. It is informational only (shared runners are noisy) and never fails the PR. The Avalonia Browser app has no headless stats channel yet, so it has no readout.
Benchmarks¶
benchmarks/Highbyte.DotNet6502.Benchmarks/ is a BenchmarkDotNet project covering the CPU hot path (HotPathBenchmarks), the integrated C64 instruction and frame loops with and without render/audio providers, and focused CIA, sprite, rasterizer and SID benchmarks. Recorded baselines and their history live in benchmarks/Highbyte.DotNet6502.Benchmarks/RESULTS.md.
Run a suite locally (Release build, no debugger attached, machine otherwise idle):
dotnet run -c Release --project benchmarks/Highbyte.DotNet6502.Benchmarks -- --filter '*HotPathBenchmarks*'
Compare the current branch against master on the same machine:
It runs the CPU hot-path, C64 instruction and C64 frame suites on both refs (set PERF_COMPARE_FILTER to a space-separated list of --filter globs to narrow it) and flags any benchmark that is 5% or more slower or that starts allocating. The working tree must be clean because it switches refs.
The manually dispatched Benchmarks workflow (.github/workflows/benchmarks.yml) runs the same suites on a Linux runner, publishes the tables to the job summary, uploads the results including the DisassemblyDiagnoser HTML (which cannot be produced on macOS), and can optionally run the baseline comparison in the same job. Runner numbers are noisy; use them for the disassembly and for same-job ratios, not for cross-run comparisons.
Code coverage report locally (Highbyte.DotNet6502 library)¶
Install report-generator global tool:
Generate and show reports (Windows):
Generate and show reports (Linux):
SonarCloud quality gate (locally)¶
A helper script wraps the existing SonarCloud CI scan with a local gate, useful both for manual verification before opening a PR and as a quality gate for automations.
The sonarscan-dotnet.yml workflow runs on push to feature/**. After pushing, the gate script waits for that workflow run to complete on the current commit and queries SonarCloud for issues introduced on this branch (using the "new code period" filter, so pre-existing issues on master are not flagged).
Requirements: gh (authenticated), curl, jq. For private projects, set SONAR_TOKEN; anonymous read works for public projects.
Run the gate (Linux / macOS):
./tools/sonar-check.sh # default: blocks gate on MAJOR severity and above
./tools/sonar-check.sh CRITICAL # only Critical and Blocker fail the gate
Run the gate (Windows):
Valid severity threshold values (the gate fails on issues at that level or any higher level):
| Value | Gate fails on |
|---|---|
INFO |
Info, Minor, Major, Critical, Blocker (strictest — anything fails) |
MINOR |
Minor, Major, Critical, Blocker |
MAJOR (default) |
Major, Critical, Blocker |
CRITICAL |
Critical, Blocker |
BLOCKER |
Blocker only (most lenient) |
Any other value (or omission of the argument when calling explicitly) is treated as a validation error.
Exit codes:
0— no blocking issues at the threshold; gate passes.1— blocking issues found; the script prints each one.2— preflight error (branch not pushed, workflow run not found, missing tool, etc.).
Set SONAR_INCLUDE_PREEXISTING=1 to disable the "new code period" filter for a full branch audit. The script header has additional details.
Workaround / compatibility¶
Using other emulators to verify correct behavior¶
When in doubt how a specific 6502 instruction actually worked, it was useful to use the monitor in the VICE emulator (which is widely known to be an accurate emulator of the C64 and 6502/6510 CPU) as a reference for stepping through machine code programs.
It was also useful to use VICE to see how graphics and audio is working (or not working) compared to the C64 implementation in this emulator, and use as a reference for general correctness.
VICE monitor¶
Monitor commands: https://vice-emu.sourceforge.io/vice_12.html
How to load and step through a program in the VICE monitor: