Guidance for AI agents working in the Spice86 repository.
Spice86 is a .NET 10 cross-platform emulator for reverse engineering real-mode DOS programs. It enables running, analyzing, and incrementally rewriting DOS binaries in C# without source code.
Spice86.Core: Core emulation engine (CPU, memory, devices, DOS/BIOS handlers)Spice86: Main application with Avalonia UI (ViewModels, Views, manual composition root)Bufdio.Spice86: Audio subsystem (PortAudio bindings)Spice86.Logging: Serilog-based logging infrastructureSpice86.Shared: Shared interfaces and utilitiesSpice86.Tests: XUnit tests with FluentAssertions and NSubstitute
The entire emulator is assembled in Spice86DependencyInjection.cs (~600 lines):
- Constructor creates the full object graph with explicit dependencies (no IoC container)
- Order matters: components are constructed in dependency order
- Components are wired together with event handlers and shared state
- Entry point is
Program.cswhich instantiatesSpice86DependencyInjection Spice86DependencyInjectionis the central composition root - understand its structure when working with dependencies- The
Machineclass aggregates emulator components (CPU, memory, devices) - access via properties likeCfgCpu,Memory,Stack InterruptVectorTableandStackare now passed directly toMachineconstructor
CfgCpu is the sole CPU implementation (Control Flow Graph-based executor):
- Builds dynamic CFG for analysis and future JIT compilation
- Tracks instruction variants for self-modifying code via selector nodes
- Maintains execution context hierarchy for hardware interrupts
- See
doc/cfgcpuReadme.mdfor detailed CFG architecture
# Build from solution root
dotnet build
# Run with executable
dotnet run --project src/Spice86 -- -e path\to\program.exe
# Run tests (excluding SingleStepTest - see note below)
dotnet test tests/Spice86.Tests --filter 'FullyQualifiedName!~SingleStepTest'SingleStepTest exclusion rule:
SingleStepTestruns millions of CPU instruction test cases and take an extremely long time to complete. Always exclude it using--filter 'FullyQualifiedName!~SingleStepTest'unless the change being tested directly touches CPU instruction decoding, execution, or flag handling (e.g. changes toCfgCpu, instruction parsers, ALU operations, or flag computation). When in doubt, exclude it.
Test output rule (agents only): no need to redirect
dotnet testoutput to a file. On success the console summary is short (a singlePassed! - Failed: 0, Passed: N ...line), so run it directly and read the summary. Only capture to a file when a run actually fails and you need the full failure detail.
Long-running test rule (Kiro agents only): the foreground shell tool (
execute_bash) enforces a hard wall-clock cap (roughly 25-60s in practice) that fires before thetimeoutargument. When it trips it force-returns exit 1 while the detacheddotnetprocess keeps running and completes normally, which looks exactly like a crash (abrupt exit 1, no test summary, no dump) but is not one. Any test run expected to exceed ~25s (the full suite is ~2 min;CfgGraphReloadTestalone is ~55s) MUST be launched with the background-process tool (which has no such cap), writing a completion marker, then polled with short (<20s) foreground calls until the marker appears. Do not conclude a suite "crashed" from a bare exit 1 with no summary; re-run it in the background and check the real result first. Splitting--filterso each foreground run stays under the cap also works.
- GDB Integration: Server runs on port 10000 by default (
--GdbPort 10000)- Use
--Debugto pause at startup for breakpoint setup - Custom GDB commands via
monitor(e.g.,monitor dumpall,monitor breakCycles 1000)
- Use
- Seer Client: Use
seergdb --project doc/spice86.seerfor GUI debugging - Internal Debugger: UI-based debugger with disassembly, memory, CPU state views
- Run DOS program in Spice86
- Emulator dumps
spice86dumpMemoryDump.binandspice86dumpExecutionFlow.jsonto--RecordedDataDirectory - Load dumps in Ghidra via spice86-ghidra-plugin
- Generate C# override classes from decompiled functions
- Implement
IOverrideSupplierto register overrides at segmented addresses - Run with
--UseCodeOverride trueto replace assembly with C# incrementally
public class MyOverrideSupplier : IOverrideSupplier {
public IDictionary<SegmentedAddress, FunctionInformation> GenerateFunctionInformations(
ILoggerService loggerService, Configuration configuration,
ushort programStartSegment, Machine machine) {
return new MyOverrides(new(), machine, loggerService, configuration).FunctionInformations;
}
}
public class MyOverrides : CSharpOverrideHelper {
public MyOverrides(IDictionary<SegmentedAddress, FunctionInformation> funcInfos,
Machine machine, ILoggerService logger, Configuration config)
: base(funcInfos, machine, logger, config) {
DefineFunction(0xF000, 0xA1E8, MyFunction_F000_A1E8_FA1E8);
OverrideInstruction(0xF000, 0xFFF0, MyInstructionOverride);
DoOnTopOfInstruction(0xF000, 0xFFF3, MyInstructionHook);
}
public Action MyFunction_F000_A1E8_FA1E8(int loadOffset) {
// C# implementation
return NearRet(); // or FarRet(), FarJump(), etc.
}
}- Access CPU state via
State,Stack,Memory,UInt8/16/32indexers - Control flow:
NearRet(),FarRet(),NearCall(),FarCall(),FarJump(),Hlt() - Jump dispatching for computed/self-modifying jumps via
JumpDispatcher - See
tests/Spice86.Tests/CSharpOverrideHelperTest.csfor patterns
Wrap memory regions in typed accessors:
public class GlobalsOnDs : MemoryBasedDataStructureWithDsBaseAddress {
public GlobalsOnDs(IByteReaderWriter memory, SegmentRegisters segmentRegisters)
: base(memory, segmentRegisters) { }
public int GetDialogueCount47A8() => UInt16[0x47A8];
public void SetDialogueCount47A8(int value) => UInt16[0x47A8] = (ushort)value;
}Variants: MemoryBasedDataStructureWithCsBaseAddress, MemoryBasedDataStructureWithSsBaseAddress, etc.
- This is a C# project - never suggest Python solutions
- No scripts outside the project - do NOT create temporary scripts (Python, Bash, PowerShell, Node, etc.) anywhere on the filesystem, including
/tmp, the user's home directory, or any location outside the workspace. Do not create throwaway helper scripts inside the workspace either. Use the available tools (file editing, grep, search, terminal one-liners) directly. If a multi-step computation is truly needed, run it as an inline shell one-liner in the terminal without writing a file. - Use
tmp/for temporary files - if a temporary file must be written (e.g., captured command output, intermediate data), place it inside thetmp/folder at the root of the repository. Never write to/tmpor any path outside the workspace. - Avoid complexity - keep cyclomatic complexity low, prefer simple, linear code over nested conditionals
- No optional parameters - avoid optional/defaulted method parameters in new code. Prefer non-nullable types; a field or dependency should be nullable only when its absence is a real, distinct state (e.g. a feature disabled by a flag). When a nullable feature-gate would otherwise force
?./is not nullguards across many call sites, prefer a null-object (no-op) implementation or unconditional construction over a nullable reference. - No complex ternary expressions - avoid nested, chained, or multi-line ternaries; simple single-line ternaries with short operands are allowed (e.g.
int x = a > b ? a : b;), but non-trivial conditions or non-trivial branches must use explicitif/else - Minimal comments - write self-documenting code with clear names; avoid obvious comments
- No references to plan/spec sections in code - never point comments, XML docs, or test names at sections of a plan, spec, or implementation-tracking document (e.g. "Phase 5", "Gap D", "Rule 4", "section S6.1.1", "Test 18", "implementation plan"). Those documents are temporary or drift over time, leaving dangling pointers that mean nothing to a future reader. Describe the actual behavior, invariant, or scenario directly instead.
- Test before submit - always run tests after code changes to verify functionality
- Rebuild and verify - For any task that changes code or tests, rebuild the project and run the full test suite; do not stop until all tests are green.
- Concise documentation - XML docs should be precise and complete but not verbose; avoid excessive remarks
- Ignore Machine class - this is a legacy aggregator class; work directly with specific components (
CfgCpu,Memory,Stack, etc.) instead - Enforce TDD - ensure integration tests are present and not passing at first, then make them pass by updating the implementation.
- Clear test code style use explicit Arrange/Act/Assert structure, avoid long setup blocks, and reduce duplication with small helpers.
- Pause-refresh rule for new ViewModels - view models that refresh from a dispatcher timer must refresh at most once per pause cycle. Gate updates on
IPauseHandler.IsPaused, track a per-pause boolean flag, and reset it on resume.
- Avalonia telemetry must be disabled when working on the codebase.
- The repository already has telemetry disabled in code, but it may still cause issues for AI agents.
- Be aware of this configuration to avoid telemetry-related blocking issues.
-
No
varkeyword: Use explicit types instead (enforced by.editorconfig)// Wrong var count = 10; // Correct int count = 10;
-
One top-level type per file: Do not place multiple classes/structs/enums in the same file
- Exception: private nested/inner types declared inside a class are allowed
- Group related types via namespaces, not by co-locating multiple top-level types
-
No generic catch clauses: Catch specific exceptions only - this is strictly enforced
// WRONG - NEVER DO THIS try { // code } catch (Exception ex) { // handling } // CORRECT - Catch specific exceptions try { // code } catch (IOException ex) { // handling } catch (ArgumentException ex) { // handling }
- NEVER use generic
catch (Exception),catch (Exception e), or emptycatch - Each exception type must be caught explicitly
- This is non-negotiable - the .editorconfig enforces this rule
- NEVER use generic
-
No null-forgiving operator (!): The null-forgiving operator is BANNED
// Wrong - NEVER use ! string value = nullable!.ToString(); // Correct if (nullable != null) { string value = nullable.ToString(); } // Or string value = nullable?.ToString() ?? "default";
- Properly handle null cases with null checks, null-coalescing, or null-conditional operators
- Don't ignore nullable warnings - fix the underlying issue
- Avoid non-ASCII Unicode characters: Prefer plain ASCII in source code and documentation; only use Unicode when necessary for languages that require characters outside the ASCII range (for example, Chinese). Some editors or tools may not assume UTF-8 and can render or save these characters incorrectly.
-
Do not use
#region: Avoid#region/#endregionblocks; keep code organized via clear structure and namespaces -
Do not suppress warnings with pragmas: Never disable warnings using preprocessor directives (e.g.,
#pragma warning disable). Fix the underlying issue instead. -
Use
Path.Join, notPath.Combine: Never useSystem.IO.Path.Combine. It silently discards all earlier segments if a later argument is rooted, and CodeQL flags every call ("Path.Combine may silently drop its earlier arguments") because it cannot prove the arguments are relative. Always usePath.Join, which concatenates segments without dropping any. This is a very frequent CodeQL finding - preferPath.Joineverywhere paths are assembled. -
No complex ternary operator: Avoid nested, chained, or multi-line ternary expressions. Simple single-line ternaries with short, obvious operands are acceptable. Use explicit
if/elseblocks when the condition or either branch is non-trivial. -
Async usage restrictions:
- Do NOT let async "infect" the
Spice86.Coreassembly - Keep async code in the UI layer (
Spice86project) only - For the UI, use Dispatcher-based timer updates (mainly used on pause)
- Never use sync-over-async patterns (
Task.Result,Task.Wait(),GetAwaiter().GetResult()) in production code or tests - Propagate async flow end-to-end when needed instead of blocking threads
- This separation maintains clean architecture boundaries
- Do NOT let async "infect" the
-
Brace style: Java-style (opening brace on same line)
if (condition) { // code }
-
Namespaces: File-scoped only
namespace Spice86.Core.Emulator.CPU;
-
Nullable: Enabled project-wide with
<WarningsAsErrors>nullable</WarningsAsErrors> -
Documentation: XML comments required (
<GenerateDocumentationFile>true</GenerateDocumentationFile>) -
No stub implementations: Create proper real implementations
- Stubs that return hardcoded values or do nothing are not allowed
- Magic values are not allowed, define enums or consts with clear names
- Prefer ASM-based tests over unit tests for testing the emulator
- Unit tests are acceptable for interrupt handlers that don't override
WriteAssemblyInRamand deal with few dependencies - Use assembly-based integration tests for comprehensive emulator validation
- Unit tests are acceptable for interrupt handlers that don't override
- Every
MachineTestscenario must also be covered byGeneratedCodeMachineTestMachineTest(TestOneBin) only runs the interpreter and compares golden listings/dumps; it never invokes the C# generator.GeneratedCodeMachineTest(viaGeneratedCodeMachineTestRunner.TestGeneratedCode) is the execute-then-generate-from-trace path: it runs the bin in the interpreter, generates C# from the recorded CFG/trace, compiles it, then re-runs the bin with the compiled override installed and asserts the memory dump.- When adding or changing an ASM fixture in
MachineTest, add the matchingGeneratedCodeMachineTestentry for the same bin so the code generator is exercised on it too. Bugs in code generation (e.g. control-flow lowering) are only caught by the generated-code path.
- Use FluentAssertions for assertions:
result.Should().Be(expected) - Mock with NSubstitute:
Substitute.For<IInterface>() - CPU tests in
tests/Spice86.Tests/CpuTests/use SingleStepTests NuGet packages for validation
- Inject
ILoggerService, check log level before expensive operations:if (_loggerService.IsEnabled(LogEventLevel.Verbose)) { _loggerService.Verbose("Details: {Value}", expensiveOperation()); }
- Log levels controlled by CLI:
--VerboseLogs,--WarningLogs,--SilencedLogs
- Avalonia: Cross-platform UI framework (MVVM pattern)
- PortAudio: Audio output via
Bufdio.Spice86(requireslibportaudioon Unix) - Serilog: Structured logging with console/debug/file sinks
- Morris.Moxy: Code generation for CPU instruction parsing mixins
- CommandLineParser: CLI argument parsing into
Configurationclass
- Memory access:
IMemoryinterface inSpice86.Core/Emulator/Memory/ - I/O ports:
IOPortDispatcherroutes port reads/writes to device handlers - Interrupts:
InterruptVectorTable+ handlers inInterruptHandlers/(BIOS/DOS/Timer/Input) - Timer:
Timerclass wraps Intel 8254 PIT with three counters - Video:
VgaCard+Rendererfor VGA/EGA/CGA modes
- GDB Protocol:
Spice86.Core/Emulator/Gdb/implements remote debugging protocol - Breakpoints:
EmulatorBreakpointsManagercoordinates memory/IO/execution breakpoints - Pause Handling:
IPauseHandlerallows pausing/resuming from UI or debugger - Function Tracking:
FunctionHandlerintercepts calls/rets for CFG building and override dispatch
- Segmented addressing: Use
SegmentedAddressnot raw offsets; linear address = segment * 16 + offset. Avoid computing addresses fromSegmentedAddress.Linearbecause segmented addresses can roll over and.Lineardoes not handle this correctly. Use theIMmu(TranslateAddress) for address translation instead, as it handles rollover and access validation. Passing.Linearto an API that already expects a linear address is fine. - A20 Gate: Memory wrapping at 1MB boundary controlled by
A20Gate(toggle via--A20Gateflag) - EMS/XMS: Enabled by default; disable with
--Xms false/--Ems false - Time handling: Real-time vs instruction-based via
--InstructionTimeScaleor--TimeMultiplier - Internal visibility:
Spice86.TestshasInternalsVisibleTofor testing internal APIs
- Override system:
tests/Spice86.Tests/CSharpOverrideHelperTest.cs - DI setup:
src/Spice86/Spice86DependencyInjection.cs - CFG CPU:
src/Spice86.Core/Emulator/CPU/CfgCpu/+doc/cfgcpuReadme.md - Real-world usage: Cryogenic project (Dune rewrite)