Do I need this page?

Prerequisites: Core Concepts + Your First Addon โ€” complete a basic addon first!

On this page

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/InterfacePackagePurpose
SpcModuleTypeapi.multiblockDeclares a module type (ID, name, position, I/O flag)
SpcMultiblockPositionapi.multiblockEnum of valid positions in the structure
ISpcMultiblockModuleapi.multiblockInterface your Block class implements
ISpcPhysicalIoHandlerapi.multiblockReads inputs / writes outputs for your module
SpcModuleTypeRegistryapi.multiblockRegistration point for all custom module types
MultiblockPositionmultiblock.modelMB_POS property + enum for connected textures
ISpcConnectedDisplayapi.multiblockInterface for display blocks to connect textures with neighbours
ISpcDiagnosticsProviderapi.multiblockInterface for block entities to contribute to Diagnostics Panel
SpcDiagnosticEntryapi.multiblockData record for a single diagnostic label + value

Multiblock Layout

The LOGO multiblock is a 3-tall grid that extends rightward from the processing unit:

Col 0 Col 1 Col 2+ y=+1 y= 0 y=โˆ’1 POWER CPU OUTPUT INPUT MIDDLE OUTPUT INPUT MIDDLE OUTPUT โ€ฆ extends โ†’

SpcMultiblockPosition โ€” Where Modules Go

Enum ValueY OffsetColumn RuleBuilt-in BlocksTypical 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)
โš ๏ธ Column 0 is special
Column 0 is the processing unit column. Only 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

ComponentTypeRequiredDescription
moduleTypeIdStringYesUnique namespaced ID (e.g., "mymod:rotational_input")
displayNameStringYesHuman-readable name shown in structure preview
positionSpcMultiblockPositionYesWhere this module type is valid in the structure
isIoModulebooleanYestrue = provides I/O channels; false = structural only

Constructors

ConstructorDescription
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.

MethodReturnsDescription
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.

MethodReturnsParametersDefaultCalled 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

MethodReturnsParametersDescription
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)

ComponentTypeDescription
moduleTypeSpcModuleTypeThe module type definition
ioHandlerISpcPhysicalIoHandlerThe I/O handler (null for non-I/O modules)

Step-by-Step: Custom Input Module

1

Define the module type

SpcModuleType ROT_INPUT = new SpcModuleType(
    "mymod:rotational_input",
    "Rotational Input",
    SpcMultiblockPosition.INPUT_ROW
);
2

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;
    }
}
3

Create the block class

public class RotationalInputBlock extends Block implements ISpcMultiblockModule {
    @Override
    public String getModuleTypeId() {
        return "mymod:rotational_input";
    }
}
4

Register everything in your @Mod constructor

SpcModuleTypeRegistry.register(ROT_INPUT, new RotationalIoHandler());
5

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:

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).

OperationContext MethodWhen
Read from custom inputcontext.readCustomInput("modTypeId", channel)INPUT_READ phase
Write to custom outputcontext.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.

How it works internally
When the multiblock validates, 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_FIRSTTOP_MIDTOP_LAST
CENTER (y = 0)CENTER_FIRSTCENTER_MIDCENTER_LAST
BOTTOM (y = −1)BOTTOM_FIRSTBOTTOM_MIDBOTTOM_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:

A

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.

B

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));
}
C

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 ...
  }
}
Rotation values
Facing"y" rotation
north(none / 0)
south180
east90
west270

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 ModelProvides textures for
storedprogramcontrols:block/logo_mb_noneAll faces use full-bordered casing (standalone look)
storedprogramcontrols:block/logo_mb_top_firstTop-left corner — open on bottom + right edges
storedprogramcontrols:block/logo_mb_top_midTop middle — open on bottom + both side edges
storedprogramcontrols:block/logo_mb_top_lastTop-right corner — open on bottom + left edges
storedprogramcontrols:block/logo_mb_center_firstMiddle-left — open on top + bottom + right
storedprogramcontrols:block/logo_mb_center_midFully interior — all edges borderless
storedprogramcontrols:block/logo_mb_center_lastMiddle-right — open on top + bottom + left
storedprogramcontrols:block/logo_mb_bottom_firstBottom-left corner — open on top + right
storedprogramcontrols:block/logo_mb_bottom_midBottom middle — open on top + both sides
storedprogramcontrols:block/logo_mb_bottom_lastBottom-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.

PositionTop edgeBottom edgeLeft edgeRight edge
top_first keepremovekeepremove
top_mid keepremoveremoveremove
top_last keepremoveremovekeep
center_first removeremovekeepremove
center_mid removeremoveremoveremove
center_last removeremoveremovekeep
bottom_first removekeepkeepremove
bottom_mid removekeepremoveremove
bottom_last removekeepremovekeep
Visual pattern
Think of the 3×3 grid as a window frame. Exterior edges (structure boundary) keep their 1px bevel border. Interior edges (where two blocks meet) have the border removed so the textures blend seamlessly. 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:

CategoryFilesCount
Blockstate blockstates/my_block.json 1
Models models/block/my_block.json (none)
models/block/my_block_mb_top_first.json
models/block/my_block_mb_top_mid.json
models/block/my_block_mb_top_last.json
models/block/my_block_mb_center_first.json
models/block/my_block_mb_center_mid.json
models/block/my_block_mb_center_last.json
models/block/my_block_mb_bottom_first.json
models/block/my_block_mb_bottom_mid.json
models/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.png
textures/block/my_block_front_mb_top_mid.png
… (one per position)
textures/block/my_block_top.png (optional, for top face)
10 + 1–2
โš ๏ธ Naming must be exact
The blockstate JSON references model paths directly, and models reference texture paths directly. Follow the _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:

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)
        );
    }
}
FieldTypeDescription
labelStringDisplay name (e.g. “Rotational Speed”)
valueStringCurrent value (e.g. “256 RPM”)
activebooleanGreen if true, dim if false. 2-arg constructor auto-detects.