Skip to content

Circuit logic

Circuit logic is the electrical model of your part. It builds its behaviour by stamping conductances, sources and currents into the circuit matrix through Simulation, the CircuitSimulation the part belongs to.

You type fields, hooks and helper methods, never a class. Everything on BaseCircuitLogic, such as Simulation, ConnectionNodes and SetCurrent, is yours to use by name. See What you write.

The smallest useful part is a fixed resistor between two pins, A and B, with a Resistance property. This is the whole of its circuit logic, as you type it:

public override void StampInitial()
{
Simulation.StampResistor(ConnectionNodes[Connections.A], ConnectionNodes[Connections.B], Properties.Resistance);
}
public override void PreStep() { }
public override void Step() { }
public override void CalculateCurrent()
{
double v = ConnectionVoltages[Connections.A] - ConnectionVoltages[Connections.B];
SetCurrent(0, v / Properties.Resistance);
}

ConnectionNodes[i] is the matrix node a pin sits on, and ConnectionVoltages[i] is the voltage solved there. You pass nodes to the stamps and read voltages back.

StampInitial, PreStep and Step must always be there, even when they’re empty. The other hooks are optional.

The simulator advances in ticks of Simulation.TimeStep seconds. Within a tick it may solve the circuit several times (Newton passes) until nonlinear parts agree. Each hook runs at a specific point, and getting this right is most of writing good circuit logic.

Hook When it runs Use it for
StampInitial Once, when the circuit is analysed, before the first tick Fixed stamps. Declaring the dynamic rows and conductances you will use. Deriving constants from TimeStep.
PreStep Once per tick, before the solve Latch this tick’s inputs (UserData, ExternalSignal, sensed values) and make your decisions.
Step Every solve pass inside the tick Stamp dynamic conductances and current sources. Clear Simulation.HasConverged while your operating point is still moving.
CalculateCurrent Once per tick, after the last solve Work out currents from ConnectionVoltages and publish them with SetCurrent. Advance per-tick state, such as a capacitor’s charge.
CheckFailure Once per tick, on a converged solution Decide whether the part has been destroyed.
Reset Every time the circuit is re-analysed (a wire moves, a value changes) Clear your scratch values. Call base.Reset().
PowerCycle When power goes off and on, and on Reset State Clear failure stress and anything a real part loses at switch-off.
  • Pins. CRUMB generates ConnectionCount and Connections.<Pin> from your Connections markers, so you never declare them. Connections.<Pin> gives each pin’s index.
  • Voltage sources. Override VoltageSourceCount to add source rows, and pass VoltageSources[i] to StampVoltageSource. A part with sources is also covered by the automatic short-circuit check.
  • Internal nodes. Override InternalNodeCount and use InternalNode(i) for junctions that aren’t pins, such as the point between a cell and its internal resistance.
  • Continuity. Override ConnectionsAreConnected(a, b) to say whether two pins are joined for DC (a winding is, a capacitor isn’t). This feeds the ground and continuity checks.

All stamps go through Simulation. and take node numbers from ConnectionNodes or InternalNode.

Fixed, in StampInitial: StampResistor, StampConductance, StampVoltageSource, StampCurrentSource. Use StampVoltageSourceDynamic for a source whose value you will change later. Use StampRightSideDynamic on a node that will carry an injected current, and StampNonLinear on a row whose left side changes each step.

Changing, in Step: StampConductanceDynamic and StampCurrentSourceDynamic are the workhorses of switches, diodes, transistors and anything with memory. Call DeclareDynamicConductance for each pair in StampInitial so the first tick doesn’t trigger a re-analysis.

Controlled sources for op-amps and transistors: StampVoltageControlledVoltageSource, StampVoltageControlledCurrentSource and StampCurrentControlledCurrentSource.

Reading the simulation: Simulation.TimeStep (recompute anything derived from it in both StampInitial and PreStep), HasConverged, SubIterations and SimTimeSeconds.

Anything with memory uses the same recipe, a backward-Euler companion model between its two real pins:

  1. In StampInitial, mark both nodes with StampRightSideDynamic.
  2. In PreStep, work out the companion conductance from TimeStep (C / dt for a capacitor, 1 / (R + L / dt) for a winding) and the history current from last tick’s state.
  3. In Step, stamp both with StampConductanceDynamic and StampCurrentSourceDynamic.
  4. In CalculateCurrent, compute this tick’s current and keep it for the next PreStep.

The motor example is a complete winding built this way.

  • Currents. Publish a branch current with SetCurrent(slot, amps) and read it with GetCurrent(slot). This is what the multimeter and the overcurrent checks see, even on a part with no voltage sources.
  • Displays. For LEDs, segments and dot matrices, record a brightness with AccumulateDisplay(slot, value) from the simulation side and read the averaged value with GetDisplayAverage(slot) from component logic. The averaging gives persistence of vision without aliasing. There are 64 slots.
  • Pixel displays. AllocateFramebuffer(bytes), write your pixels, then PublishFramebuffer(). Component logic checks FramebufferVersion and copies the frame with ReadFramebuffer.
  • UserData is 16 numbers shared with your component logic. It is the only bridge between the electrical and physical halves. Read your inputs in PreStep, and write outputs in PreStep or CalculateCurrent, never in Step.
  • ExternalSignal is one number fed in from outside the simulation each tick, such as audio input or a function generator.
  • SampleEnvironment(name, fallback) reads a named field at the part’s position, such as "Lux" or "TemperatureC", from environment zones and emitters. Pass the matching property as the fallback, so the inspector value is the bench value when nothing around says otherwise.
  • The wireless field. Parts on the same channel can hear each other through FieldSend, FieldReceive and FieldHasListener. Nothing in it is specific to any radio.

A part can be destroyed. In CheckFailure, decide whether it has been abused for long enough, then call Fail(reason) and return true. CRUMB isolates a failed part and heals it on a power cycle.

Judge abuse over simulated time, not ticks, because tick length changes with load. AccumulateOverstress does this for you. As a guide, allow about 1 ms for electrical abuse such as overvoltage and about 10 ms for heating. Zero your stress counters in PowerCycle.

public override bool CheckFailure()
{
double watts = _volts * _volts * _conductance;
if (AccumulateOverstress(ref _stressSeconds, ref _stressTicks, watts > Properties.MaxPower, 0.010))
{
Fail("over-power: exceeded the " + Properties.MaxPower + " W rating");
return true;
}
return false;
}

What a failure looks and sounds like isn’t code. Add smoke, sparks or a sound from the component editor’s Failure tab, and they play when the part fails.

Parts can take part in UART, I²C and SPI without modelling every bit:

  • UART: declare UartRxConnection and UartTxConnection, handle OnUartByte, send with UartSend. Framing defaults to 8N1.
  • I²C: return your address from I2CAddress, declare I2CSdaConnection and I2CSclConnection, and handle OnI2CStart, OnI2CWrite, OnI2CRead and OnI2CStop.
  • SPI: declare the SpiCs, SpiSck, SpiMosi and SpiMiso connections and handle OnSpiTransfer.
  • Single-wire timing protocols (addressable LEDs, one-wire sensors): declare EdgeSensitiveConnection and handle OnConnectionEdge.

The BaseCircuitLogic reference documents each of these in full.

  • Don’t keep state in static fields, and remember that fields reset on Reset State (see State, saving and Reset).

Circuit logic is checked against a strict whitelist before it compiles. Every type and namespace your code names must be on it. Anything else is refused: the editor marks the line with an error, and a part that breaks the rules won’t load, whether it comes from a save file or the Workshop.

Circuit logic may use:

  • Everything in the Simulation namespace: BaseCircuitLogic, CircuitSimulation and the types that go with them.
  • bool, byte, sbyte, short, ushort, int, uint, long, ulong, char, float, double, decimal, string and object.
  • IntPtr and UIntPtr.
  • List<T>, nullable values such as double?, and System.Math.
  • Arrays of any of these.
  • Types you declare yourself, such as a small struct that holds one sample.

That is the whole list. Circuit logic has nothing for the physical world, by design: see the two rules.

The check covers type arguments too: List<double> is allowed, and a list of anything off the whitelist isn’t.

Common things that aren’t on it: Dictionary, HashSet, tuples, Random, MathF, Func and Action, exception types and LINQ.

A method can also be tied to one hook. Fail can only be called from CheckFailure, and the reference marks any method like it with an only in tag. Call it in the hook itself, not from a helper method of your own.

The exact lists, read from CRUMB’s source, are on The whitelist in the API reference, with every method tied to one hook.

Error What it means
CRUMB001 You called a method outside the hook it belongs to, such as Fail outside CheckFailure.
CRUMB002 Your code names a namespace that isn’t on the whitelist.
CRUMB003 Your code names a type that isn’t on the whitelist. The message lists what is allowed.