Analytical Combinator
A Factorio mod adding a programmable circuit combinator controlled via a RISC-V inspired assembly language.
Credits / Attribution
Portions of this project are derived from the Assembly Combinator by Joakim Lönnegren (joalon), and remain subject to the MIT License.
For contributions made by the Analytical Combinator contributors, we do not require attribution and will not enforce the inclusion of our copyright notice in redistributions, although the MIT License text still applies to the project as a whole.
Much credit for my changes goes to Anthropic's Claude AI. In addition to doing all of the coding and documentation, Claude suggested the name "Analytical Combinator" because it has a 19th-century engineering feel that fits the game's aesthetic, and nods to Babbage's Analytical Engine.
Usage
Download it from the mod portal.
The analytical combinator is more like a Decider Combinator in that it supports both input (red and green wires) and output circuit network connections.
Instruction set
The instruction set is "inspired" by the RISC-V assembly language, but I've chosen not to be constrained by a 32-bit processor architecture or instruction length. For example, the immediate operand in the ADDI instruction isn't limited to 12 bits as on a real RISC processor.
I've also added branch immediate instructions where a full 32-bit immediate value along with the jump location are in the instruction. These aren't actual RISC-V instructions since they aren't implementable with the RISC-V instruction format.
Arithmetic
| Instruction | Syntax | Description |
|---|---|---|
ADDI |
rd, rs, imm |
rd = rs + imm — add immediate constant |
LI |
rd, imm |
rd = imm — load immediate (shorthand for ADDI rd, x0, imm) |
ADD |
rd, rs, rt |
rd = rs + rt — add two registers |
SUB |
rd, rs, rt |
rd = rs - rt — subtract register |
MUL |
rd, rs, rt |
rd = rs * rt — multiply two registers |
MULI |
rd, rs, imm |
rd = rs * imm — multiply by immediate |
DIV |
rd, rs, rt |
rd = floor(rs / rt) — integer division; error on divide-by-zero |
DIVI |
rd, rs, imm |
rd = floor(rs / imm) — divide by immediate; error on zero |
REM |
rd, rs, rt |
rd = rs % rt — remainder; sign follows dividend (C-style) |
REMI |
rd, rs, imm |
rd = rs % imm — remainder by immediate |
Comparison
| Instruction | Syntax | Description |
|---|---|---|
SLT |
rd, rs, rt |
rd = (rs < rt) ? 1 : 0 |
SLTI |
rd, rs, imm |
rd = (rs < imm) ? 1 : 0 |
Bitwise
| Instruction | Syntax | Description |
|---|---|---|
AND |
rd, rs, rt |
rd = rs & rt — bitwise AND |
ANDI |
rd, rs, imm |
rd = rs & imm — bitwise AND with immediate |
OR |
rd, rs, rt |
rd = rs | rt — bitwise OR |
ORI |
rd, rs, imm |
rd = rs | imm — bitwise OR with immediate |
XOR |
rd, rs, rt |
rd = rs ^ rt — bitwise XOR |
XORI |
rd, rs, imm |
rd = rs ^ imm — bitwise XOR with immediate |
NOT |
rd, rs |
rd = ~rs — bitwise NOT (unary) |
Shifts
Shift instructions follow the RISC-V naming convention. Left shifts are always logical (zero-fill). Right shifts come in two flavours — logical (zero-fill from the left, use when treating the value as unsigned) and arithmetic (sign-extend, use when treating the value as a signed integer).
| Instruction | Syntax | Description |
|---|---|---|
SLL |
rd, rs, rt |
rd = rs << rt — shift left logical by register |
SLLI |
rd, rs, imm |
rd = rs << imm — shift left logical by immediate |
SRL |
rd, rs, rt |
rd = rs >> rt — shift right logical (zero-fill) by register |
SRLI |
rd, rs, imm |
rd = rs >> imm — shift right logical (zero-fill) by immediate |
SRA |
rd, rs, rt |
rd = rs >> rt — shift right arithmetic (sign-extend) by register |
SRAI |
rd, rs, imm |
rd = rs >> imm — shift right arithmetic (sign-extend) by immediate |
Control flow
| Instruction | Syntax | Description |
|---|---|---|
JAL |
rd, label |
Jump to label; save address of next instruction in rd (use x0 to discard) |
JR |
rs |
Jump to address in rs — subroutine return. JR x0 restarts from line 1. |
BEQ |
rs, rt, label |
Branch if rs == rt |
BNE |
rs, rt, label |
Branch if rs != rt |
BLT |
rs, rt, label |
Branch if rs < rt |
BLE |
rs, rt, label |
Branch if rs <= rt |
BGT |
rs, rt, label |
Branch if rs > rt |
BGE |
rs, rt, label |
Branch if rs >= rt |
BEQI |
rs, imm, label |
Branch if rs == imm (immediate) |
BNEI |
rs, imm, label |
Branch if rs != imm |
BLTI |
rs, imm, label |
Branch if rs < imm |
BLEI |
rs, imm, label |
Branch if rs <= imm |
BGTI |
rs, imm, label |
Branch if rs > imm |
BGEI |
rs, imm, label |
Branch if rs >= imm |
Circuit network output
| Instruction | Syntax | Description |
|---|---|---|
WSIG |
od, signal, rs |
Write signal to output channel od (o0–o3) with count from rs |
WSIGI |
od, signal, imm |
Write signal to output channel od (o0–o3) with a constant count |
Circuit network input
| Instruction | Syntax | Description |
|---|---|---|
RSIG |
rd, signal |
Read named signal from both wires and store the sum in rd |
RSIGR |
rd, signal |
Read named signal from the red input wire into rd (0 if absent) |
RSIGG |
rd, signal |
Read named signal from the green input wire into rd (0 if absent) |
CNTSR |
rd |
Set rd to the count of distinct signals on the red input wire |
CNTSG |
rd |
Set rd to the count of distinct signals on the green input wire |
Signal names are validated against Factorio's prototype tables when the program is saved. An unknown signal name is flagged immediately rather than silently returning 0 at runtime.
Control
| Instruction | Syntax | Description |
|---|---|---|
WAIT |
imm or rs |
Stall for N game ticks (60 ticks = 1 second) |
NOP |
— | No operation |
HLT |
— | Halt execution |
Registers
x0–x31: general-purpose integer registers.x0is always 0 (writes ignored). The alternate ABI register names are not supported - sorry.o0–o3: output signal registers, written byWSIG, emitted on the output network each tick.
Instruction case
Mnemonics are case-insensitive — ADDI, addi, and Addi are all accepted. Register names (x0–x31, o0–o3) and signal names (iron-plate, signal-A, etc.) remain case-sensitive.
Immediate value formats
All instructions that take an immediate (imm) argument accept integers in decimal, hexadecimal (0x prefix), or negative decimal. Octal (0-prefix) is technically accepted by the Lua parser but best avoided.
ADDI x10, x0, 255 # decimal
ADDI x10, x0, 0xFF # hex — same value, preferred for bitmasks
ADDI x10, x0, -1 # negative decimal
SHLI x11, x10, 0x4 # shift amount as hex (unusual but valid)
Hex is especially useful with bitwise instructions:
ADDI x10, x0, 0xFF00FF # load a bitmask
AND x11, x12, x10 # apply the mask
SRLI x11, x11, 0x8 # extract middle byte
Example programs
The following examples are simply meant to illustrate what is possible with Analytical Combinators. I realize some of these use-cases may be easy to implement with items such as Decider Combinators.
Simple counter (output only)
main:
ADDI x10, x0, 0 # Initialize counter to 0
loop:
ADDI x10, x10, 1 # Increment counter
WSIG o1, copper-plate, x10 # Output counter value
WAIT 60 # Wait 1 second (60 game ticks)
SLTI x6, x10, 100 # Check if counter < 100
BNE x6, x0, loop # Branch if not equal to zero
JAL x1, main # Jump back to main
Threshold gate (read input, control output)
Reads an iron-ore count from the green wire. Once it exceeds 500, outputs signal-A = 1 on the output connector and halts. The signal appears on whichever wire(s) — red, green, or both — are physically connected to the output side of the combinator.
poll:
RSIGG x10, iron-ore # Read iron-ore from green wire
SLTI x6, x10, 500 # x6 = 1 if count < 500
BNE x6, x0, poll # Keep polling until threshold met
ADDI x11, x0, 1
WSIG o0, signal-A, x11 # Emit signal-A = 1
HLT
Subroutine call with JAL / JR
JAL rd, label saves the address of the next instruction into rd, then jumps to label. JR rd jumps to the address in rd, returning execution to the instruction after the call site. Use a different link register for each call frame. Recursive calls are not supported (no stack), but sequential calls to shared subroutines work cleanly.
JR x0 is a special case: since x0 is always 0 and 0 is not a valid line number, it is defined to restart the program from line 1.
main:
RSIGG x10, iron-plate
JAL x1, clamp_255 # x1 = return address; jump to clamp_255
WSIG o0, iron-plate, x10 # resumes here after return
RSIGG x10, copper-plate
JAL x1, clamp_255 # reuse the same subroutine and same link register
WSIG o1, copper-plate, x10
WAIT 60
JR x0 # restart from line 1 (same as JAL x0, main if main: is on line 1)
clamp_255: # expects value in x10, returns clamped value in x10
SLTI x6, x10, 256 # x6 = 1 if value already in range
BNE x6, x0, clamp_ret # skip clamp if already in range
ADDI x10, x0, 255 # clamp to 255
clamp_ret:
JR x1 # return to caller
Bit masking — extract low byte
Factorio signals are 32-bit integers. Use AND to isolate the lower 8 bits, useful if you are packing multiple small values into a single signal channel.
RSIGR x10, signal-A # Read packed value from red wire
ADDI x11, x0, 255 # Mask = 0xFF
AND x12, x10, x11 # x12 = low byte of signal-A
SRLI x13, x10, 8 # x13 = next byte up (logical: zero-fills upper bits)
WSIG o0, signal-A, x12 # Output low byte
WSIG o1, signal-B, x13 # Output second byte
HLT
Signal presence detection with CNTSG
Fire signal-A when anything appears on the green wire (useful as a "something arrived" trigger without caring what the signal is).
poll:
CNTSG x10 # x10 = number of distinct signals on green
BEQ x10, x0, poll # Loop while nothing is present
ADDI x11, x0, 1
WSIG o0, signal-A, x11 # Trigger output
WAIT 60 # Hold for 1 second
ADDI x11, x0, 0
WSIG o0, signal-A, x11 # Clear output
JAL x0, poll
Red/green signal sum
Reads a signal from both wires each tick and outputs their sum. This is one of those cases where Decider Combinators do the job nicely. Note that this example does individual reads from each circuit. The next example uses the RSIG instruction which sums the inputs from both circuits.
loop:
RSIGR x10, iron-plate # Read iron-plate from red wire
RSIGG x11, iron-plate # Read iron-plate from green wire
ADD x12, x10, x11 # x12 = red + green total
WSIG o0, iron-plate, x12 # Output the combined count
WAIT 1
JAL x0, loop
Red/green sum with threshold gate
Same idea, but halts and fires a signal once the combined total exceeds 500.
loop:
RSIG x12, iron-plate # Read iron-plate from both circuits
SLTI x6, x12, 500 # x6 = 1 if total < 500
BNE x6, x0, loop # Keep polling until threshold met
LI x11, 1
WSIG o0, signal-A, x11 # Emit signal-A = 1
HLT