Multiblock Modules Advanced
Add custom physical blocks that validate with the LOGO multiblock structure โ custom input sources, output actuators, and structural modules.
Do I need this page?
- Yes if you want to add new physical block types to the LOGO machine structure (e.g., a rotation sensor block, a fluid valve block)
- No if you only want to add logic nodes to the programming editor โ use the tutorial instead
- No if you just need to read world data โ use Execution Context methods instead
Prerequisites: Core Concepts + Your First Addon โ complete a basic addon first!
On this page
- What are Multiblock Modules?
- Multiblock Layout
- SpcMultiblockPosition โ Where Modules Go
- SpcModuleType โ Declaring a Module
- ISpcMultiblockModule โ Block Interface
- ISpcPhysicalIoHandler โ Reading & Writing
- SpcModuleTypeRegistry โ Registration
- Step-by-Step: Custom Input Module
- Step-by-Step: Custom Output Module
- Step-by-Step: Non-I/O Module
- Reading Custom Modules in Nodes
- Connected Textures (MB_POS)
- ISpcConnectedDisplay โ Display Neighbours
- ISpcDiagnosticsProvider โ Live Diagnostics
What are Multiblock Modules?
The LOGO machine is a multiblock structure built from physical blocks. With the API, addon mods can register custom block types that the multiblock validator recognizes. This lets you add blocks that read new kinds of inputs (rotation, fluid levels, RF rates) or write new kinds of outputs (motor speeds, valve positions) โ all integrated into the standard LOGO programming environment.
What you need
| Class/Interface | Package | Purpose |
|---|---|---|
SpcModuleType | api.multiblock | Declares a module type (ID, name, position, I/O flag) |
SpcMultiblockPosition | api.multiblock | Enum of valid positions in the structure |
ISpcMultiblockModule | api.multiblock | Interface your Block class implements |
ISpcPhysicalIoHandler | api.multiblock | Reads inputs / writes outputs for your module |
SpcModuleTypeRegistry | api.multiblock | Registration point for all custom module types |
MultiblockPosition | multiblock.model | MB_POS property + enum for connected textures |
ISpcConnectedDisplay | api.multiblock | Interface for display blocks to connect textures with neighbours |
ISpcDiagnosticsProvider | api.multiblock | Interface for block entities to contribute to Diagnostics Panel |
SpcDiagnosticEntry | api.multiblock | Data record for a single diagnostic label + value |
Multiblock Layout
The LOGO multiblock is a 3-tall grid that extends rightward from the processing unit:
SpcMultiblockPosition โ Where Modules Go
| Enum Value | Y Offset | Column Rule | Built-in Blocks | Typical Addon Use |
|---|---|---|---|---|
INPUT_ROW |
+1 |
Column โฅ 1 | Digital Input, Analog Input | Custom sensor inputs (rotational speed, fluid level, RF meter) |
OUTPUT_ROW |
-1 |
All columns | Digital Output, Analog Output, FE Output | Custom actuator outputs (motor control, valve, furnace speed) |
MIDDLE_ROW |
0 |
Column โฅ 1 | Casing, Display Unit | Custom structural blocks, addon display panels |
POWER_ROW |
+1 |
Column 0 only | Power Source (Transformator) | Custom power sources (solar, kinetic-to-FE) |
POWER_ROW modules go at (col 0, y+1),
and OUTPUT_ROW at (col 0, yโ1). The CPU itself occupies (col 0, y=0).
Custom modules should primarily target columns โฅ 1.
SpcModuleType โ Declaring a Module
SpcModuleType is a record that declares a custom module type's identity and placement rules.
Record components
| Component | Type | Required | Description |
|---|---|---|---|
moduleTypeId | String | Yes | Unique namespaced ID (e.g., "mymod:rotational_input") |
displayName | String | Yes | Human-readable name shown in structure preview |
position | SpcMultiblockPosition | Yes | Where this module type is valid in the structure |
isIoModule | boolean | Yes | true = provides I/O channels; false = structural only |
Constructors
| Constructor | Description |
|---|---|
new SpcModuleType(moduleTypeId, displayName, position, isIoModule) |
Full constructor |
new SpcModuleType(moduleTypeId, displayName, position) |
Convenience โ defaults isIoModule = true |
// Custom input module type:
SpcModuleType ROT_INPUT = new SpcModuleType(
"mymod:rotational_input",
"Rotational Input",
SpcMultiblockPosition.INPUT_ROW
);
// Custom structural module (no I/O):
SpcModuleType CUSTOM_CASING = new SpcModuleType(
"mymod:reinforced_casing",
"Reinforced Casing",
SpcMultiblockPosition.MIDDLE_ROW,
false // not an I/O module
);
ISpcMultiblockModule โ Block Interface
Implement this @FunctionalInterface on your Block subclass.
The multiblock BFS discovery recognizes blocks that implement this interface.
| Method | Returns | Description |
|---|---|---|
getModuleTypeId() |
String |
Must return the same ID registered in SpcModuleTypeRegistry |
public class RotationalInputBlock extends Block implements ISpcMultiblockModule {
@Override
public String getModuleTypeId() {
return "mymod:rotational_input";
}
}
ISpcPhysicalIoHandler โ Reading & Writing
The I/O handler bridges your physical block to the signal bus.
Registered alongside the SpcModuleType.
| Method | Returns | Parameters | Default | Called During |
|---|---|---|---|---|
readInput(ServerLevel, BlockPos) |
SpcSignalValue |
level โ server level; modulePos โ block position |
INTEGER_ZERO |
INPUT_READ phase |
applyOutput(ServerLevel, BlockPos, SpcSignalValue) |
void |
level; modulePos; value โ signal from bus |
no-op | OUTPUT_APPLY phase |
public class RotationalIoHandler implements ISpcPhysicalIoHandler {
@Override
public SpcSignalValue readInput(ServerLevel level, BlockPos modulePos) {
var be = level.getBlockEntity(modulePos);
if (be instanceof RotationalInputBlockEntity rot) {
return SpcSignalValue.integer(rot.getRotationalSpeed());
}
return SpcSignalValue.INTEGER_ZERO;
}
}
SpcModuleTypeRegistry โ Registration
Methods
| Method | Returns | Parameters | Description |
|---|---|---|---|
register(SpcModuleType, ISpcPhysicalIoHandler) |
void |
Module type + I/O handler (nullable for non-I/O modules) | Register a module type with its handler |
register(SpcModuleType) |
void |
Module type only | Register a non-I/O module (handler = null) |
find(String moduleTypeId) |
Optional<SpcModuleTypeRegistration> |
Module type ID | Look up a registration by ID |
all() |
Map<String, SpcModuleTypeRegistration> |
โ | Unmodifiable view of all registrations |
isFrozen() |
boolean |
โ | Whether the registry is locked |
freeze() |
void |
โ | Internal โ do not call from addon code |
SpcModuleTypeRegistration (inner record)
| Component | Type | Description |
|---|---|---|
moduleType | SpcModuleType | The module type definition |
ioHandler | ISpcPhysicalIoHandler | The I/O handler (null for non-I/O modules) |
Step-by-Step: Custom Input Module
Define the module type
SpcModuleType ROT_INPUT = new SpcModuleType(
"mymod:rotational_input",
"Rotational Input",
SpcMultiblockPosition.INPUT_ROW
);
Create the I/O handler
public class RotationalIoHandler implements ISpcPhysicalIoHandler {
@Override
public SpcSignalValue readInput(ServerLevel level, BlockPos pos) {
if (level.getBlockEntity(pos) instanceof RotInputBE be)
return SpcSignalValue.integer(be.getRpm());
return SpcSignalValue.INTEGER_ZERO;
}
}
Create the block class
public class RotationalInputBlock extends Block implements ISpcMultiblockModule {
@Override
public String getModuleTypeId() {
return "mymod:rotational_input";
}
}
Register everything in your @Mod constructor
SpcModuleTypeRegistry.register(ROT_INPUT, new RotationalIoHandler());
Read it from a compiled node
SpcSignalValue rpm = context.readCustomInput("mymod:rotational_input", 1);
int speed = rpm.asInteger();
Step-by-Step: Custom Output Module
Same process as input, but:
- Use
SpcMultiblockPosition.OUTPUT_ROW - Override
applyOutput()instead ofreadInput() - Called during
OUTPUT_APPLYphase
public class MotorOutputHandler implements ISpcPhysicalIoHandler {
@Override
public void applyOutput(ServerLevel level, BlockPos pos, SpcSignalValue value) {
if (level.getBlockEntity(pos) instanceof MotorOutputBE be) {
be.setTargetSpeed(value.asInteger());
}
}
}
// Registration:
SpcModuleType MOTOR_OUT = new SpcModuleType(
"mymod:motor_output", "Motor Output",
SpcMultiblockPosition.OUTPUT_ROW);
SpcModuleTypeRegistry.register(MOTOR_OUT, new MotorOutputHandler());
// Writing from a node:
context.applyCustomOutputs("mymod:motor_output",
Map.of(1, SpcSignalValue.integer(targetSpeed)));
Step-by-Step: Non-I/O Module
Structural modules (custom casings, decorative panels) don't provide I/O channels but still validate as part of the multiblock.
// Just register the type with isIoModule = false, no handler:
SpcModuleType CASING = new SpcModuleType(
"mymod:reinforced_casing", "Reinforced Casing",
SpcMultiblockPosition.MIDDLE_ROW, false);
SpcModuleTypeRegistry.register(CASING);
// Block still implements ISpcMultiblockModule:
public class ReinforcedCasingBlock extends Block implements ISpcMultiblockModule {
@Override
public String getModuleTypeId() {
return "mymod:reinforced_casing";
}
}
Reading Custom Modules in Nodes
Once registered, custom modules appear as channels on the execution context.
Use readCustomInput() and applyCustomOutputs() (see
Execution Context โ Custom Module I/O).
| Operation | Context Method | When |
|---|---|---|
| Read from custom input | context.readCustomInput("modTypeId", channel) | INPUT_READ phase |
| Write to custom output | context.applyCustomOutputs("modTypeId", Map.of(ch, value)) | OUTPUT_APPLY phase |
Connected Textures (MB_POS)
When the LOGO multiblock forms, every member block receives a
MultiblockPosition value via the mb_pos block state property.
This removes borders on internal seams so the structure looks like a single unit.
The system is property-driven — any block that registers MB_POS
will participate automatically. No callback code needed.
LogoMultiblockManager.applyMultiblockPositions()
iterates every member block and computes its row (Y offset from anchor) and column
(horizontal offset in the layout direction). It calls
MultiblockPosition.fromRowColumn(row, col, maxColumn) to determine the
correct position enum value, then sets MB_POS on the block state.
When the multiblock breaks, all members are reset to MB_POS = NONE.
MultiblockPosition Enum
Located at com.hypernova.spc.multiblock.model.MultiblockPosition
(included in the API jar since v1.1.3). Ten position values for a 3-row ×
variable-column grid, plus NONE for unformed state:
| FIRST (col = 0) | MID (0 < col < max) | LAST (col = max) | |
|---|---|---|---|
| TOP (y = +1) | TOP_FIRST | TOP_MID | TOP_LAST |
| CENTER (y = 0) | CENTER_FIRST | CENTER_MID | CENTER_LAST |
| BOTTOM (y = −1) | BOTTOM_FIRST | BOTTOM_MID | BOTTOM_LAST |
NONE — block is not part of a formed multiblock (default / disassembled state).
For a 2-column multiblock, FIRST and LAST are the only columns
(no MID). For a 1-column multiblock, every block is both FIRST
and LAST.
Step 1 — Java: Register MB_POS on Your Block
Three things are needed in your block class:
Add MB_POS to the block state definition
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder) {
super.createBlockStateDefinition(builder);
builder.add(BlockStateProperties.HORIZONTAL_FACING, MultiblockPosition.MB_POS);
}
If your parent class already adds FACING, just add MultiblockPosition.MB_POS.
Default to NONE in the constructor
public MyAddonBlock(Properties props) {
super(props);
registerDefaultState(stateDefinition.any()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH)
.setValue(MultiblockPosition.MB_POS, MultiblockPosition.NONE));
}
Trigger revalidation on place/remove
When your block is placed or broken, the multiblock needs to know so it can
re-scan the structure. Call LogoMultiblockManager.revalidateAround().
Since this class is internal (not in the API jar), addon mods must use reflection:
public final class SpcMultiblockHelper {
private static final Method REVALIDATE_AROUND;
static {
try {
Class<?> clazz = Class.forName(
"com.hypernova.spc.multiblock.runtime.LogoMultiblockManager");
REVALIDATE_AROUND = clazz.getMethod(
"revalidateAround", Level.class, BlockPos.class);
} catch (Exception e) {
throw new RuntimeException("SPC Core not found", e);
}
}
public static void revalidateAround(Level level, BlockPos pos) {
if (!level.isClientSide()) {
try {
REVALIDATE_AROUND.invoke(null, level, pos);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
Then call it from your block’s onPlace() and onRemove():
@Override
public void onPlace(BlockState state, Level level, BlockPos pos,
BlockState oldState, boolean movedByPiston) {
super.onPlace(state, level, pos, oldState, movedByPiston);
SpcMultiblockHelper.revalidateAround(level, pos);
}
@Override
public BlockState onRemove(BlockState state, Level level, BlockPos pos,
BlockState newState, boolean movedByPiston) {
SpcMultiblockHelper.revalidateAround(level, pos);
return super.onRemove(state, level, pos, newState, movedByPiston);
}
Full block class example
import com.hypernova.spc.multiblock.model.MultiblockPosition;
import com.hypernova.spc.api.multiblock.ISpcMultiblockModule;
public class MyAddonBlock extends Block implements ISpcMultiblockModule {
public MyAddonBlock(Properties props) {
super(props);
registerDefaultState(stateDefinition.any()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH)
.setValue(MultiblockPosition.MB_POS, MultiblockPosition.NONE));
}
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder) {
builder.add(BlockStateProperties.HORIZONTAL_FACING, MultiblockPosition.MB_POS);
}
@Override
public String getModuleTypeId() {
return "mymod:my_block";
}
@Override
public void onPlace(BlockState state, Level level, BlockPos pos,
BlockState oldState, boolean movedByPiston) {
super.onPlace(state, level, pos, oldState, movedByPiston);
SpcMultiblockHelper.revalidateAround(level, pos);
}
@Override
public BlockState onRemove(BlockState state, Level level, BlockPos pos,
BlockState newState, boolean movedByPiston) {
SpcMultiblockHelper.revalidateAround(level, pos);
return super.onRemove(state, level, pos, newState, movedByPiston);
}
}
That’s all the Java you need. SPC’s multiblock manager will automatically set
MB_POS when the structure forms and reset it to NONE when it breaks.
Step 2 — Blockstate JSON
Your blockstate file must map every (facing, mb_pos) combination to a model.
For a standard 4-facing block this is 44 variants
(4 facings × 11 positions). Blocks with 6 facings (e.g. directional kinetic blocks)
need 66 variants.
Structure:
// assets/<modid>/blockstates/my_addon_block.json
{
"variants": {
"facing=north,mb_pos=none": { "model": "mymod:block/my_addon_block" },
"facing=north,mb_pos=top_first": { "model": "mymod:block/my_addon_block_mb_top_first" },
"facing=north,mb_pos=top_mid": { "model": "mymod:block/my_addon_block_mb_top_mid" },
"facing=north,mb_pos=top_last": { "model": "mymod:block/my_addon_block_mb_top_last" },
"facing=north,mb_pos=center_first": { "model": "mymod:block/my_addon_block_mb_center_first" },
"facing=north,mb_pos=center_mid": { "model": "mymod:block/my_addon_block_mb_center_mid" },
"facing=north,mb_pos=center_last": { "model": "mymod:block/my_addon_block_mb_center_last" },
"facing=north,mb_pos=bottom_first": { "model": "mymod:block/my_addon_block_mb_bottom_first" },
"facing=north,mb_pos=bottom_mid": { "model": "mymod:block/my_addon_block_mb_bottom_mid" },
"facing=north,mb_pos=bottom_last": { "model": "mymod:block/my_addon_block_mb_bottom_last" },
// Repeat for facing=south with "y": 180
"facing=south,mb_pos=none": { "model": "mymod:block/my_addon_block", "y": 180 },
"facing=south,mb_pos=top_first": { "model": "mymod:block/my_addon_block_mb_top_first", "y": 180 },
// ... all 9 positions with "y": 180 ...
// Repeat for facing=east with "y": 90
"facing=east,mb_pos=none": { "model": "mymod:block/my_addon_block", "y": 90 },
// ... all 9 positions with "y": 90 ...
// Repeat for facing=west with "y": 270
"facing=west,mb_pos=none": { "model": "mymod:block/my_addon_block", "y": 270 }
// ... all 9 positions with "y": 270 ...
}
}
| Facing | "y" rotation |
|---|---|
north | (none / 0) |
south | 180 |
east | 90 |
west | 270 |
For 6-facing blocks, add facing=up with "x": 270 and facing=down with "x": 90.
Step 3 — Model Architecture
SPC uses a two-layer model inheritance system. You only need to create per-block models that inherit from SPC’s shared parent templates.
Layer 1: SPC’s parent template models
SPC provides 11 parent models (storedprogramcontrols:block/logo_mb_*.json)
that define the correct casing textures for all non-front faces (back, sides, top, bottom)
based on position. Each parent removes border pixels on internal seams:
| Parent Model | Provides textures for |
|---|---|
storedprogramcontrols:block/logo_mb_none | All faces use full-bordered casing (standalone look) |
storedprogramcontrols:block/logo_mb_top_first | Top-left corner — open on bottom + right edges |
storedprogramcontrols:block/logo_mb_top_mid | Top middle — open on bottom + both side edges |
storedprogramcontrols:block/logo_mb_top_last | Top-right corner — open on bottom + left edges |
storedprogramcontrols:block/logo_mb_center_first | Middle-left — open on top + bottom + right |
storedprogramcontrols:block/logo_mb_center_mid | Fully interior — all edges borderless |
storedprogramcontrols:block/logo_mb_center_last | Middle-right — open on top + bottom + left |
storedprogramcontrols:block/logo_mb_bottom_first | Bottom-left corner — open on top + right |
storedprogramcontrols:block/logo_mb_bottom_mid | Bottom middle — open on top + both sides |
storedprogramcontrols:block/logo_mb_bottom_last | Bottom-right corner — open on top + left |
Layer 2: Your per-block models
Each of your 11 model files inherits a parent template and only overrides the front face
(north texture) and optionally the up face. The parent handles everything else.
// models/block/my_addon_block.json (mb_pos = none)
{
"parent": "storedprogramcontrols:block/logo_mb_none",
"textures": {
"up": "mymod:block/my_addon_block_top",
"north": "mymod:block/my_addon_block_front"
}
}
// models/block/my_addon_block_mb_top_first.json
{
"parent": "storedprogramcontrols:block/logo_mb_top_first",
"textures": {
"up": "mymod:block/my_addon_block_top",
"north": "mymod:block/my_addon_block_front_mb_top_first"
}
}
// models/block/my_addon_block_mb_center_mid.json
{
"parent": "storedprogramcontrols:block/logo_mb_center_mid",
"textures": {
"up": "mymod:block/my_addon_block_top",
"north": "mymod:block/my_addon_block_front_mb_center_mid"
}
}
// ... repeat for all 10 positions, following the naming pattern:
// my_addon_block_mb_{position}.json
Step 4 — Front Face Textures (Edge Map)
Each position determines which edges of the block’s front face need their 1-pixel border removed. An internal edge (shared with an adjacent block) gets its border removed; an external edge (structure boundary) keeps its border.
| Position | Top edge | Bottom edge | Left edge | Right edge |
|---|---|---|---|---|
top_first | keep | remove | keep | remove |
top_mid | keep | remove | remove | remove |
top_last | keep | remove | remove | keep |
center_first | remove | remove | keep | remove |
center_mid | remove | remove | remove | remove |
center_last | remove | remove | remove | keep |
bottom_first | remove | keep | keep | remove |
bottom_mid | remove | keep | remove | remove |
bottom_last | remove | keep | remove | keep |
center_mid is fully interior — all 4 edges removed.
Corner positions (e.g. top_first) keep exactly 2 edges.
To create your per-position front textures, start from your base front texture and remove the 1-pixel bevel on each “remove” edge by stretching the interior pixels to the edge. This typically means extending the inner area of the texture to fill the border row/column on that side.
Complete File Checklist
For a single addon block called my_block, you need:
| Category | Files | Count |
|---|---|---|
| Blockstate | blockstates/my_block.json |
1 |
| Models |
models/block/my_block.json (none)models/block/my_block_mb_top_first.jsonmodels/block/my_block_mb_top_mid.jsonmodels/block/my_block_mb_top_last.jsonmodels/block/my_block_mb_center_first.jsonmodels/block/my_block_mb_center_mid.jsonmodels/block/my_block_mb_center_last.jsonmodels/block/my_block_mb_bottom_first.jsonmodels/block/my_block_mb_bottom_mid.jsonmodels/block/my_block_mb_bottom_last.json
|
10 + 1 |
| Textures |
textures/block/my_block_front.png (base)textures/block/my_block_front_mb_top_first.pngtextures/block/my_block_front_mb_top_mid.png… (one per position) textures/block/my_block_top.png (optional, for top face)
|
10 + 1–2 |
_mb_{position} suffix convention consistently. Mismatched names will cause
missing texture errors in-game.
Automating Asset Generation
Creating 44+ blockstate variants, 11 model files, and 10 front texture variants by hand
is tedious and error-prone. SPC Core includes a Python script
(generate_connected_textures.py in the project root) that automates
the entire pipeline:
- Generates all position-specific front face textures from your base texture
- Generates all per-position model JSON files inheriting from SPC’s parent templates
- Generates the complete blockstate JSON with all facing × mb_pos variants
You can adapt this script for your addon’s blocks, or use it as a reference for writing your own generator. The script’s edge-removal logic mirrors the edge map table above.
ISpcConnectedDisplay โ Display Neighbours
Display blocks use a separate connected-texture system with boolean properties
(LEFT_CONNECTED, RIGHT_CONNECTED, TOP_CONNECTED,
BOTTOM_CONNECTED) that update reactively via neighborChanged().
If your addon adds a display-type block, implement
ISpcConnectedDisplay so SPC’s built-in displays recognize it as a
valid texture neighbour.
import com.hypernova.spc.api.multiblock.ISpcConnectedDisplay;
public class MyDisplayBlock extends Block
implements ISpcMultiblockModule, ISpcConnectedDisplay {
@Override
public Direction getDisplayFacing(BlockState state) {
return state.getValue(BlockStateProperties.HORIZONTAL_FACING);
}
}
Return the facing direction of your display surface. SPC will connect textures only between displays that share the same facing direction.
ISpcDiagnosticsProvider โ Live Diagnostics
Addon block entities can contribute entries to the Live Diagnostics Panel
(visible in the programming screen). Implement ISpcDiagnosticsProvider
on your BlockEntity and return SpcDiagnosticEntry records.
import com.hypernova.spc.api.multiblock.ISpcDiagnosticsProvider;
import com.hypernova.spc.api.multiblock.SpcDiagnosticEntry;
public class MyBlockEntity extends BlockEntity
implements ISpcDiagnosticsProvider {
@Override
public List<SpcDiagnosticEntry> getDiagnosticEntries(
Level level, BlockPos pos) {
return List.of(
new SpcDiagnosticEntry("Speed", speed + " RPM"),
new SpcDiagnosticEntry("Stress", stress + " su", stress > 0)
);
}
}
| Field | Type | Description |
|---|---|---|
label | String | Display name (e.g. “Rotational Speed”) |
value | String | Current value (e.g. “256 RPM”) |
active | boolean | Green if true, dim if false. 2-arg constructor auto-detects. |