C⏚ v2.0.0Updated 2026-05-12·Guides

Bytecode simulator

The Neosyn bytecode simulator runs your .cg source directly, without an HDL compile step in between, producing cycle-accurate output in milliseconds rather than minutes.

It powers:

  • Fast Simulation (Bytecode) in the VS Code extension (neosyn.cg.simulate).
  • cg-language-server.jar simulate from the command line.

Both are built on the same simulator backend and language semantics. Packaging and integration details differ, but equivalent tool versions should produce the same simulation behavior.

How it works

The language server compiles your C⏚ source to an intermediate representation (IR), then lowers that IR to JVM bytecode - literally .class files that implement each task as a Java method. A small cycle scheduler runs those methods in topological order each cycle, propagating port writes and observing port reads.

Because the simulator is just executing bytecode, small and medium-sized designs are typically fast enough for tight edit-run inspect loops. Actual throughput depends on the design, host machine, JVM, and whether waveform output is enabled.

Timing model

The simulator runs the same three-phase cycle schedule (execute, commit, propagate) as the language semantics; see Concepts → Execution is cycle-by-cycle. Port visibility rules - bare same-cycle, push/confirm next-cycle, stream with ready/valid - are documented in Declarations → Ports. The subsections below cover what is specific to the simulator.

Topological ordering

Inside a network, sub-instances execute in topological order (Kahn's algorithm at compile time). Producers run before consumers, so values propagate along the connection graph in a single phase without needing multiple passes. The exception is a feedback loop - a cycle in the connection graph - where execution still terminates but one of the edges effectively reads the previous cycle's value.

Common timing pitfalls

Push-port one-cycle latency. A push output written on cycle N is not visible until cycle N+1. If A writes a push port and B reads it in the same cycle, B sees the previous value (or invalid). This is by design - push ports model registered outputs. Use a bare port if you need same-cycle visibility.

Bare-port ordering dependency. Bare ports are combinational. B sees A's write only if A executes earlier in topological order. If B has no connection dependency on A, the order between them is undefined. Make the dependency explicit - connect the port.

Bus contention on a combinational mux. When a combinational mux checks several push sources with if / else if, only the highest-priority one fires per cycle. The lower-priority source's data is silently dropped - there is no automatic backpressure. Fix: the writer waits (via idle() or fence) for the slow consumer to finish before issuing on the competing path.

fence vs idle. fence creates a cycle break and the scheduler re-checks input conditions before resuming - use it when the next step depends on an input arriving. idle(N) creates N unconditional empty cycles and produces no outputs during them - use it when you need to give another entity time to finish without reading anything.

Unconditional fence creates unreachable states. A task that checks a push port in state 0 then unconditionally fences to state 1 misses the push signal whenever it arrives during state 1 - push validity lasts exactly one cycle. Prefer a single-state design that guards each port with .available() and latches state in local variables:

// Wrong: unconditional fence creates blind spots
void loop() {
  if (signal.available()) { signal.read(); flag = true; }
  fence;  // stuck in state 1 when signal arrives
  data.read();
}
 
// Right: single-state, checks both every cycle
void loop() {
  if (signal.available()) { signal.read(); flag = true; }
  if (data.available())   { data.read(); /* use flag */ }
}

Running a sim

From VS Code

  1. Open the .cg file containing your top-level test network.
  2. Trigger Fast Sim through any of these surfaces - pick whichever is closest to hand:
    • Status bar - click the ▶ Fast Sim button that appears in the bottom-right while a .cg file is active.
    • Editor title bar - click the play icon at the top-right of the file tab.
    • Right-click in the editor - pick Neosyn: Simulate → Fast Simulation (Bytecode).
    • Command palette - run Neosyn: Fast Simulation (Bytecode).
  3. The console pane shows print() output cycle by cycle. A .vcd file is dropped at the project root (next to src/).

From the command line

java -jar cg-language-server.jar simulate <file.cg>

If the file declares multiple top-level entities and the simulator can't guess which is the top, pass --entity:

java -jar cg-language-server.jar simulate <file.cg> \
  --entity MyNetwork

The standalone JAR is built into releng/lsp-server/target/cg-language-server.jar and bundled with the VS Code extension under server/.

Short aliases: sim for simulate, gen for generate, ir for generate-ir. Run with --help to see the full CLI, or see CLI reference.

Test networks and monitors

The bytecode simulator runs a network, not a task directly. The canonical pattern is a thin test network that instantiates the design under test plus a monitor - an inner task that asserts what the DUT should produce.

network Counter_test {
  import com.example.Counter;
 
  properties {
    test: { terminate: "monitor.finished" }
  }
 
  dut = new Counter();
 
  monitor = new task {
    bool finished;
 
    void setup() {
      for (u4 expected = 0; expected < 8; expected++) {
        u4 got = dut.count.read();
        print("cycle ", expected, " count = ", got);
        assert(got == expected);
      }
      finished = true;
    }
  };
}

The terminate expression (typically monitor.finished) ends the simulation when it becomes true. The monitor's setup() runs once at startup, then yields to loop() if defined. assert(expr) fails the simulation on the first false, with a diagnostic at the source line. Real designs often add observation ports (e.g. out push u16 obs_addr; out u8 obs_data;) so the monitor can read() events emitted by the DUT.

The test property

FormUse when
test: { terminate: "monitor.finished" }Run until a state variable / port goes true. Most common pattern.
test: { port_a: [v0, v1, …], port_b: [v0, v1, …] }Drive ports cycle-by-cycle with literal stimulus arrays. Run length = longest array. See Properties / Test.

The two forms can coexist on the same network. If both are present, the simulator drives the stimulus arrays and checks terminate each cycle.

Print and assertions

print() outputs go to the VS Code output pane or stdout (CLI). Arguments are concatenated. Integers print as zero-padded hex with a 0x prefix (e.g. 0x5, 0x1e, 0xabcd); booleans print as true / false; strings pass through verbatim. There is no format-string DSL - if you need decimal, do the conversion yourself in the .cg source. assert(expr) fails the simulation on the first false, with the source location in the diagnostic.

VCD output

Every simulation drops a VCD file at the project root (next to src/ and verilog-gen/), named after the top-level entity (e.g. Counter_test.vcd). It captures every port (data and valid for push ports, plus ready for stream ports) and every state variable.

Open it with Neosyn: Open Waveform in VS Code, the WaveTrace extension, GTKWave, or Surfer.

Trusted at scale

The simulator runs the Intel-8088 reference CPU's full regression suite as part of CI. The five-tier bottom-up test pyramid runs in seconds:

Intel-8088 test pyramid: 41 asserting bytecode tests plus a Verilog gate

The same simulator that runs in CI runs in your editor. Passing tests there are passing tests everywhere.

Cross-check against HDL

The bytecode sim is the canonical correctness check; reach for a Verilog-side cross-check only when you specifically need one (suspected generator bug, external Verilog modules stubbed in bytecode, or pre-tapeout toolchain confidence). The inline monitor pattern with print() and assert() doesn't survive to synthesizable Verilog - an HDL cross-check needs a hand-written testbench that drives the DUT directly. See Cross-check against HDL for the honest procedure.

For the CLI invocation of simulate, generate, and related commands, see CLI reference.

Limitations

  • No floating-point - C⏚ has no floating-point types and the simulator inherits that.
  • External tasks are stubbed - if your design instantiates an external task with a Verilog implementation, the bytecode sim sees only the empty signature. Fall back to an HDL simulator for full external-module behaviour.
  • Compilation reuse depends on the client integration - the CLI and editor integrations may manage generated artifacts differently. If startup cost matters, measure it in the exact environment you use for CI or daily development.

Where to dig deeper