Developer Guide¶
This guide documents implementation architecture and contributor workflow for v5.1.16.
If you want conceptual runtime flow first, start with How It Works. This page is focused on engineering-level extension and maintenance work.
If you want to contribute and write somewhat correct addons / fixes see Contributing Guidelines
Development Setup¶
This project uses Beet to build the datapack from source.
Prerequisites¶
- Python 3.10 or higher
- Git
Quick Start¶
1. Clone the Repository¶
git clone https://github.com/AnCarsenat/Redstone-Additions.git
cd Redstone-Additions
git checkout beet_rewrite
2. Set Up the Virtual Environment¶
Navigate to the redstone_additions/ directory and create a Python virtual environment:
cd redstone_additions
python3 -m venv .venv
3. Activate the Virtual Environment¶
On Linux/macOS:
source .venv/bin/activate
On Windows (CMD):
.venv\Scripts\activate.bat
On Windows (PowerShell):
.venv\Scripts\Activate.ps1
4. Install Dependencies¶
pip install -r requirements.txt
5. Link to Your Minecraft World¶
Use beet's link command to connect to your Minecraft world. Specify the path to your Minecraft installation and the world name:
beet link --minecraft /path/to/minecraft <world_name>
Example with PrismLauncher:
beet link --minecraft "~/.local/share/PrismLauncher/instances/26.2 Fabric/minecraft" DevWorld
6. Build and Test¶
Build the datapack from source:
beet build
To enable auto-rebuild when you make changes, use watch mode:
beet watch
Then use /reload in-game to apply changes immediately.
Development Workflow¶
- Navigate to the
redstone_additions/directory - Ensure the virtual environment is activated:
source .venv/bin/activate - Edit files in the
src/directory - Use
beet watchfor automatic rebuilding on file changes - Use
/reloadin-game to apply changes
1) Core Entrypoints (ra)¶
Primary core functions:
ra:loadra:tickra:give_all_itemsra:uninstall
ra:give_all_items now delegates to ra:items/bundles/give_all, which gives categorized prefilled bundles directly.
minecraft:load points to ra:load.
ra:load responsibilities¶
- Initializes trigger and runtime scoreboards.
- Seeds shared storage in
ra:temp. - Initializes
ra_libandra_lib_multiblock. - Initializes all gameplay namespaces, including
ra_storageandra_wirestransport networks. - Starts main tick loop.
ra:tick responsibilities¶
- Clears stale click tags.
- Runs modular input session processing and Data Handler action processing.
- Runs placement detection.
- Dispatches module ticks.
- Runs goggles scanning.
- Reschedules itself every tick.
2) Shared Library: ra_lib¶
ra_lib is the reusable systems layer.
ra_lib:init¶
Calls module initializers:
orientation/initplacement/initinventory/initredstone/initinput/init
placement¶
Key functions:
ra_lib:placement/placera_lib:placement/set_blockra_lib:placement/set_block_facingra_lib:placement/set_block_simple
Contract of place:
- Input macro fields:
block_id,block_tag,dir_type. - Resolves facing through orientation library.
- Places physical block.
- Summons marker with
ra.custom_block, typed tag, andra.new. - Stores rotation/facing for downstream logic.
orientation¶
Key functions:
ra_lib:orientation/get_facingra_lib:orientation/set_facing
dir_type behavior:
0: no facing behavior1: horizontal facing only2: full directional (including up/down)
redstone¶
Entry points, cheapest first:
ra_lib:redstone/any— powered at all? Returns 1/0. Stops at the first live side.ra_lib:redstone/detect_switch—any, wrapped so it maintains thera.poweredtag. For blocks that use redstone as a switch. Does not writera.power.ra_lib:redstone/local/{front,back,left,right,up,down}— one named side, 0-16, resolved through the block's ownra.facing.ra_lib:redstone/side— one compass side, 0-16. The macro core; everything above and below is built on it.ra_lib:redstone/detect— all six sides, the aggregate, andra.powered.ra_lib:redstone/detect_local—detectplus the look-space scores and the direction tags. Only if you actually read them.ra_lib:redstone/count_inputs— how many sides carry a component, powered or not. Shares its source rules withhas_input.
Cheaper than any of them: if the block's vanilla base already carries the answer,
read the block state and call nothing. dispenser[triggered=true],
note_block[powered=true], redstone_lamp[lit=true]. Note that triggered on a
dispenser or dropper also picks up quasi-connectivity, which a library scan does
not — that is a behaviour difference, not just a speed one.
Sources live in block tags, so adding one is a data edit rather than a code edit:
#ra_lib:redstone/binary_sources— full power whenpowered=true: levers, buttons, non-weighted pressure plates, tripwire hooks, lightning rods.#ra_lib:redstone/directional_sources— strong power into the block they face: repeaters, comparators, observers.#ra_lib:redstone/analog_omni— carry a level inpowerand give it to every neighbour: both weighted pressure plates, daylight detectors.#ra_lib:redstone/analog_sources— the above plus redstone dust, which needs a connection test before its level counts.
It computes:
- aggregate power (
ra.power, range0..16) - world-space directional power (
ra.power.north/south/east/west/up/down) - look-space directional power (
ra.power.front/back/left/right/local_up/local_down) - power tags (
ra.powered, directional tags, strong tag)
Power level contract:
0= no power1..15= normal redstone power16= superpower (direct powered repeater/comparator output into the block)ra.powered.strongis set only whenra.power == 16
Consumer model:
- Gates and wireless emitter now consume
ra_lib:redstone/detectdirectly per marker tick. - Legacy gate signal batching (
ra_gates:check_signals) remains as a compatibility no-op and is not used by runtime tick flow.
Source-specific detectors are split under ra_lib:redstone/detect/*.
inventory¶
Key functions:
ra_lib:inventory/move_slot— whole-slot transfer with/item replacera_lib:inventory/insert_or_drop— insert what fits, drop the restra_lib:inventory/insert— rawloot insertra_lib:inventory/removera_lib:inventory/find_free_slot,has_free_slot,container_sizera_lib:inventory/clear
insert destroys overflow
loot insert silently deletes whatever the destination cannot hold. Only use
insert directly with a count of 1, where the item either fits or the call
returns 0. For anything larger use insert_or_drop, which recovers the
difference and drops it as an item entity.
move_slot is the preferred primitive for moving an existing stack: it copies
the stack verbatim, with no loot table to parse and no NBT arithmetic.
remove handles any container size and amounts split across stacks,
all-or-nothing.
transport¶
Key functions:
ra_lib:transport/tick— rebuilds networks when the topology changedra_lib:transport/net/join,rejoin,leave— membershipra_lib:transport/net/offer,take,read— contentsra_lib:transport/update_connection_status— neighbour count for visuals
The engine groups adjacent nodes of the same class into networks by flood fill. Contents belong to the network, not to individual nodes, so a pipe run costs nothing per tick and transfer is order-independent. Rebuilds happen only on placement or break, debounced to at most one every 5 ticks.
Classes (fluid, item, electric) never merge, so an item pipe and a fluid
pipe can share a block line without interacting.
Multi-medium networks¶
A network holds several media at once. Everything below exists to keep one invariant true:
amountis exactly the sum ofamounts,mediaholds exactly one entry per key inamounts, andmediumismedia[0].m— absent when the network is empty.
Every bug this system has had was that invariant breaking somewhere.
The shape. One compound per network, at storage ra:transport nets.n<id>:
| Field | Example | What it is |
|---|---|---|
amount |
10000 |
The total across every medium. Capacity is checked against this, so a run clogs on the sum and not on any one medium. |
capacity |
20000 |
Summed from ra.tr.cap on every node at rebuild. |
amounts |
{water:5000,lava:5000} |
The per-medium breakdown. |
media |
[{m:"water"},{m:"lava"}] |
The same keys as a list, in arrival order. |
medium |
"water" |
A copy of media[0].m, because every display and every bridge already reads it. |
potion |
(optional) | A potion network's effect list. It belongs to the network, not to any node. |
amounts and media are the same data indexed twice, and both are needed. A
function cannot enumerate a compound's keys, so the walk has to go over a list;
lookups by name have to go through a compound. The list holds compounds
rather than bare strings so that a medium can be removed by value when it runs
out — data remove storage ... media[{m:"water"}] only works on compound
elements.
Totals live in storage rather than in scoreboards because a scoreboard gives one
number per network and a compound gave amounts a place to go without moving
anything that was already there. Arithmetic still goes through scoreboards,
because commands have no other way to add two numbers; storage is where values
live between operations, not where they are computed.
Writing. net/offer and net/take read the network, work out how much
actually moves, then hand the id and the medium to net/offer_write /
net/take_write, which are macro functions because the path contains the id and
the medium name.
- Space is the sum. An offer is refused only for lack of room. A network no longer turns away an unfamiliar medium — that is what made a run single-purpose for as long as it held anything.
- A take is per medium and the name is required. "Take 1000" has no answer on
a mixed run.
net/take_anytakes the primary and parks its name instorage ra:transport took.medium, for callers like a Valve that move contents without caring what they are but must tell the far side what arrived. - First arrival.
data geton a path that is not there fails, a failed command stores 0, and 0 is the right starting value for a medium arriving for the first time. That is the test for "append tomedia". - Drained to nothing. The key goes from
amounts, the entry goes frommedia, andmediumis recomputed from the newmedia[0]— removed first, so an emptied network is left with no medium at all rather than a stale one, sinceset fromfails silently on an empty list and would leave the old value standing.
The rebuild is where it gets hard. Contents belong to the network, and a rebuild discards every network, so the contents have to survive outside one:
rebuild/snapshot— each network's root marker parks the whole breakdown asdata.data.carry, a list of{m,a}, andra.tr.carryis summed from those entries rather than read offamount. Summing it is what keeps the total and the breakdown from disagreeing on the way back.netsis wiped. Ids are reissued, old roots first, so a component containing an old root inherits its number and a retired number is never reissued.rebuild/accumulate_node— capacity and carried total per new network.rebuild/absorb— folds each carrier's{m,a}list back in, medium by medium, so two runs joined by a new pipe arrive as two breakdowns that add up.rebuild/clamp— a network that lost a tank can be over capacity. The excess comes off the newest medium first, walked frommedia[-1], which is the same rule the primary follows from the other end.
Two rules in there are load-bearing and were both learned the hard way:
- Nothing travels that cannot be named. A total with no breakdown behind it
used to be carried across anyway, on the grounds that dropping it silently was
worse. It is worse: the carried total has nothing to rebuild
amountsfrom, so the next rebuild carries it again, and the next medium offered becomes the only entry inmediaand therefore the name of the whole thing. - A list element is built whole in storage, then appended. The obvious
version appends
{m:"water",a:0}and fills the amount in withexecute store result entity @s data.data.carry[-1].a. That silently does nothing. A write to entity data is applied by building a compound from the path and merging it, and there is nothing for a list index to merge into. Reading through an index is fine; only writing is not.
Migration. A network saved before multi-medium has amount and medium and
no breakdown. Ids cannot be enumerated from a function, so there is no sweep on
load: net/migrate_pick runs lazily, the first time such a network is read and
again at snapshot time, and moves the whole amount into the key of the medium it
was recorded as holding.
The guard is unless data ... media[0], not unless data ... media.
rebuild/reset_net writes media:[], and an empty list is present as far as
if data is concerned — so the shorter test never fires on the networks that
most need it.
Reading and display. net/read loads cur.medium, cur.amounts and
cur.media into storage ra:transport, plus #net_amount / #net_capacity as
the totals. It is read-only: it is called many times a tick, by every block's
tick and every goggles refresh, so anything that writes back from here mutates
live state at an arbitrary moment.
- The Goggles show the plain name for one medium and
Multimediumfor two or more. The test iscur.media[1]— cheaper than counting, and exactly "two or more". A billboard is one short line read from across the room. - The Multimeter walks
cur.mediaand prints a line per medium, in arrival order, because chat has room for it. - A bridge moves
cur.mediumby default; a Liquid Filter substitutes itsfilter_mediumand moves only that, which is what lets a mixed run be sorted back into single-medium branches.
input¶
Key functions:
ra_lib:input/session/createra_lib:input/router/select_backendra_lib:input/router/openra_lib:input/pollra_lib:input/consumera_lib:input/session/cleanup
Backends:
trigger: numeric input and range validation.writable_book: text capture from page 1.
Runtime behavior:
- Per-request state is stored under
storage ra:input(sessions.req_<id>). give_book_safeonly gives an Input Form when inventory has room.- Full inventory produces a red user warning and skips book give.
- Dropped request books are cleaned through request-aware selectors.
2b) Settings: ra_settings¶
Values that are not a property of one placed block. Per-block properties already
have a home — the marker's data.properties, edited with the wrench or the Data
Handler. This is for the other two kinds: pack-wide balance, and per-player
preference.
Two scopes, reached two ways¶
global lives in storage ra:settings global and is reached only through
/function ra_settings:admin/..., which needs permission level 2. Being a
function tree is also what makes it discoverable: /function autocompletes, and a
macro argument completes to nothing.
user is per-player and reached only through /trigger, which needs no
permission. The two never mix: the in-game menu contains nothing but user rows, so
there is no operator setting on screen for a player to click and be refused.
Everything is generated¶
One JSON file per page in tools/settings/. tools/settings_gen.py (a beet
plugin) emits, at build time:
| Generated function | What it is |
|---|---|
ra_settings:pages |
the menu registry — user rows only |
ra_settings:defaults |
seeds any setting with no value, and creates user objectives |
ra_settings:sync |
per-tick seeding for players who have no score yet |
ra_settings:admin/** |
the whole operator function tree |
ra_settings:admin_actions |
index → function, for button dispatch |
ra_settings:admin_pages |
index → page, for the after-action redraw |
ra_settings:blockmap |
block → label + enable code, for the disabled list |
ra_settings:uninstall |
removes exactly what the system created |
Because the generator knows every key, bound and step as a literal, the emitted tree contains no macros at all — none of the quoting fragility that shapes the rest of the library applies to it.
Reading a setting¶
function ra_settings:get {key:"welcome",default:1}
function ra_settings:prop {block:"electric_generator",prop:"generation_rate",default:60}
function ra_settings:user {obj:"ra.u.snd",default:1}
function ra_settings:enabled {block:"electric_furnace"}
get requires a default. It used to answer 0 for a missing key, and zero is a
real value — "off" for a flag, "disabled" for a gate — so a key that was missing
for any reason read as a deliberate no and switched features off. Callers pass the
value that keeps things working.
Where block defaults are applied¶
prop rows are seeded into data.properties at placement
(ra_settings:placement/seed), not read live. ra_lib:util/property runs for
every consumer, bridge and drain on every tick and is deliberately four commands
with no sub-calls; a settings lookup does not belong there. Applying at placement
also stops a retune silently re-balancing a build. [Apply to placed] is the
explicit opt-out.
Triggers¶
/trigger is the only control surface a player without permissions has, so every
button a non-operator can press goes through one. A suggest_command pointing at
a /function is useless to them.
An enabled trigger appears in everyone's /trigger completion whether it can do
anything or not, so they are handed out where usable and taken back where not.
scoreboard players reset is what takes one back — the enabled flag is stored
with the score.
Codes on ra.settings.open: 1 your preferences, 2 server settings, 3
disabled blocks, N+4 menu pages. New entry points take a spare code rather than
a new objective.
Ordering constraint¶
A click that opens an input session must be handled after ra_lib:input/tick
in ra:tick. Handled before it, the session is scanned in the same tick it was
created, and the book backend is written on the assumption that a tick has passed.
This is why ra_settings:input_tick sits beside the Data Handler's collector
while ra_settings:tick (seeding only) runs early.
3) Shared Multiblock Library: ra_lib_multiblock¶
ra_lib_multiblock provides generic lifecycle management for all multiblock types.
Initialization:
- creates
ra.multiblock,ra.mb_timer - prepares
ra:multiblockand temp storage branches
Lifecycle functions:
try_assemble: entrypoint called by wrench assembly flow.create_marker: summons aligned multiblock marker.setup_marker: writes standardized marker data model.validate_all: periodic validation pass.validate_single: per-marker check.disassemble: teardown and effects.
Hook tags:
#ra_lib_multiblock:validate#ra_lib_multiblock:setup_type#ra_lib_multiblock:check_structure#ra_lib_multiblock:on_break
Any new multiblock type should plug into these hook tags instead of bypassing the library.
4) Data and Marker Conventions¶
Use this schema for custom block and multiblock markers:
data.properties: user-configurable fields.data.data: runtime mutable state.data.status: readable status for goggles/UX.
Tag conventions:
ra.custom_blockra.custom_block.<id>ra.multiblockra.newra.broken
Keep IDs consistent across:
- give item custom_data
- placement tag
- marker typed tag
- recipe result custom_data
- on-break drop item data
Pack format overlays¶
src/pack.mcmeta declares min_format 88 through max_format 107, i.e.
Minecraft 1.21.9 through 26.2. When a vanilla schema changes shape inside that
span, the fix is an overlay, not a version bump: an overlay directory sits
next to data/ in src/, mirrors the same paths, and the game swaps its files
in for the format range the overlay declares.
There is one today:
src/
pack.mcmeta min_format 88, max_format 107
data/ra/predicate/is_sneaking.json {"flags": {"is_sneaking": true}}
overlay_102/
data/ra/predicate/is_sneaking.json {"minecraft:flags": {"is_sneaking": true}}
"overlays": {
"entries": [
{ "directory": "overlay_102", "min_format": 102, "max_format": 107 }
]
}
Format 102 (26.2-snapshot-3) rewrote entity predicates into component-map
form. The base file keeps the pre-102 spelling and the overlay carries the new
one; the game picks whichever matches the running version, so both work from a
single build. beet copies overlay directories through untouched — no plugin, no
extra config.
Reach for this when a vanilla format changes under a file the pack already ships. Do not reach for it for mcfunction differences: an overlay replaces whole files, so duplicating a function into an overlay means maintaining two copies of live logic forever. If a command changes across the range, prefer narrowing the declared range over forking the function.
5) Tooling Integration¶
Wrench¶
- mode cycling for compatible blocks
- assembly/toggle interactions for multiblock bases and markers
Creative Data Handler¶
- property discovery and editing, driven by one registry
- editor chosen from the value's actual type
- internal data/status inspection helpers
Every property row comes from storage ra:dh registry, a plain list of property
names set by ra:tools/data_handler/init_registry. For each name the block actually
has, props/render probes the value's type and draws the matching row:
| Type | Detected by | Editor |
|---|---|---|
| string | data modify … set string accepts it |
[Modify], text input |
| list | <name>[0] exists |
[Edit list], text input pasted as SNBT |
| bool | the value matches 0b or 1b |
[Toggle], applied at once |
| number | none of the above | [Modify], number input |
data get is not a type test
data get succeeds on a string — it returns the string's length — so
"succeeded, therefore a number" classifies every string as a number. That shipped
briefly and made the Handler offer a number editor for channel, writing ints
that no string comparison could ever match. set string is the real test: it only
accepts a string source.
A row's button carries 100 + its registry index, so run_action and
apply_pending each need one branch for all properties rather than one per
name. Menu actions stay below 100.
Adding a property to a block therefore means adding its name to
init_registry and nothing else. Before this, each property needed a
hand-written props/show_<name> row plus a branch in run_action and another in
apply_pending, which is why blocks could display a property the Handler had no way
to change — a wire's transfer_rate, a tank's tier, an anchor's id.
A data pack cannot iterate the keys of a compound, which is the only reason the
registry list exists at all. Note also that ra:dh state is global: the Handler is
a single-player tool, as it always was.
Data Handler¶
- non-OP-friendly property editing menu
- Shift+RMB target scan for nearby custom markers
- uses
ra_lib:inputbackends for numeric and text property edits - supports pending-edit cancel flow and menu refresh cycle
When adding new configurable properties, update CDH and Data Handler mappings/defaults.
Block Skins¶
Some vanilla blocks carry behaviour you cannot switch off. A dispenser fires its own inventory on any rising redstone edge, and so does a dropper. A custom block that stores items in itself and sits anywhere near redstone will therefore eject them, and no datapack logic can intercept it — there is no event to cancel.
The fix is to stop making mechanics and appearance the same decision:
- place the block whose behaviour you want (a barrel: same 27-slot inventory and GUI, no dispense)
- put the appearance back with a
block_displaylaid over it
The Unboxer is the worked example. Its input1 is ~ ~ ~ — it holds the crates
it is unboxing in its own inventory — so as a dispenser it threw them on the
floor. It is now a barrel wearing a dispenser skin.
Using it¶
# on placement, and to repair a missing skin
function ra_lib:skin/apply {real:"minecraft:barrel",skin:"minecraft:dispenser",id:"unboxer"}
# in the break handler, before the marker is killed
function ra_lib:skin/clear {id:"unboxer"}
Use ra_lib:skin/apply_static when the skin block has no facing property.
Then repair a skin that went missing, once per tick, only for the blocks actually lacking one:
execute as @e[type=marker,tag=ra.custom_block.unboxer] at @s unless entity @e[type=block_display,tag=ra.skin.unboxer,distance=..0.9,limit=1] run function ra_storage:blocks/unboxer/refresh_display
Skins are tagged ra.display, ra.skin and ra.skin.<id>, so uninstall clears
them all with one selector.
Why it holds together¶
- Facing is read back off the real block, not from stored state, so a block rotated by any means still gets a matching skin and a skin that drifts out of sync repairs itself.
- Scale 1.004 with translation −0.002 encloses the real block without the two
surfaces sharing a plane. Sitting exactly on
1.0z-fights. - A
block_displayhas no collision and no interaction box, so the real block behind it still takes right-clicks, hopper insertion and comparator reads.
What it does not hide¶
This swaps the model, not the block. Anything a player can observe other than the model still comes from the real block:
| Consequence | |
|---|---|
| GUI | The Unboxer opens a barrel's 27 slots, not a dispenser's 3×3. This is the most visible tell. |
| Sounds | Opening, breaking and placing use the real block's sounds. |
| Mining | Hardness, tool and particles are the real block's. |
| Block states | A barrel's open animation is hidden under the skin. |
| Other mods/packs | Anything reading the world sees a barrel. |
So it is right when you want a block's mechanics minus one unwanted behaviour, and wrong when you need the skinned block's interactions too.
When to reach for it¶
Worth doing for a block that stores items in its own inventory and is backed by a dispenser or dropper. In this pack that is the Item Pipe, the Item Mover and the Boxer — all still unconverted.
Do not use it for Block Breaker, Block Placer or the Breeder: those read
dispenser[triggered=true] deliberately, so the vanilla trigger is the feature.
Goggles¶
- collects markers in range of any goggles wearer once, then draws each one
ra:tools/goggles/draw_blockanddraw_multiblockare pure routing- refreshes in timed batches
Block-defined billboard contract:
Each block owns blocks/<name>/goggles.mcfunction. It must:
- publish its display name to
storage ra:temp block_name execute if data storage ra:temp name_only run return 0- write
storage ra:temp billboardand callra:tools/goggles/billboard/handle_billboard - emit its own status lines
data modify storage ra:temp block_name set value "Liquid Tank"
execute if data storage ra:temp name_only run return 0
data modify storage ra:temp billboard set value {show_name:1b,name_y:1.0}
data modify storage ra:temp billboard.name set from storage ra:temp block_name
function ra:tools/goggles/billboard/handle_billboard with storage ra:temp billboard
function ra:tools/goggles/billboard/data_line {path:"medium",label:"Medium: ",color:"aqua",suffix:"",y:0.8}
The name_only early return is what lets ra:tools/block_name reuse this
dispatch to resolve names for the Data Handlers, so a block's name is written
once. Billboard offsets are measured from the marker, which sits at the block
centre — a slab-height block wants name_y around 0.7, not 1.0.
Stacked lines¶
prop_line, data_line and text_line each take a hand-picked y. That is fine
for two or three lines and a trap for more: every block invents its own ladder, and
a block with one line too many draws it at y:0.0, inside itself, where nobody can
read it. Blocks with several lines should say where their ladder starts and let the
library count:
function ra:tools/goggles/billboard/stack_reset {top:110,step:22}
function ra:tools/goggles/billboard/stacked_prop_line {path:"mode",label:"Mode: ",color:"light_purple",suffix:""}
function ra:tools/goggles/billboard/stacked_text_line {label:"Enabled: ",value:"yes",color:"green",suffix:""}
top and step are hundredths of a block — macro arguments are pasted as text, so
the arithmetic is done with scoreboards and written back as a double. Each stacked
line takes the current height and steps down. A block that forgets stack_reset
falls back to top:80, step:20. The Poppy Generator, with five lines, is the
worked example.
- Use this to keep low-information blocks clean while enabling richer overlays on data-heavy blocks.
ra_wires blocks declare their cyclable properties to the wrench:
- Add an entry to
ra:tools/wrench/init_registry, keyed by the block's type — the same stringra_lib:placement/placewrites into the marker'sdata.type. - Each entry is
{label, prop, fn}: the name shown in the menu, the property read for the current value, and the function that steps it. - One entry and shift+RMB cycles it immediately; two or more and the wrench opens a menu. Nothing else changes.
Read-only properties¶
A property the block owns and the player must not edit goes in
ra:tools/readonly/init_registry, keyed by block type:
data modify storage ra:dh readonly set value {"electric_generator":{generation_rate:1b}}
Both tools obey it from that one place: the Data Handler shows the row with a
struck-through [Modify] and a reason on hover, and the wrench drops it from the
cycle menu. A block left with no cyclable properties after filtering reports that
it does not cycle, rather than silently cycling the one thing you locked.
It is a compound rather than a list because both readers ask "is this name read-only?" — one command against a compound, a walk against a list.
Cyclers run as the marker, at the block, and message @a[distance=..10] —
the wrench never touches the player, so anything addressed to a player-side tag
reaches nobody.
Read the current state before writing it. A cycler that flips a value and then re-tests the same condition sees what it just wrote; that mistake has cost this pack a jetpack toggle, a block breaker cooldown and a clock.
When adding new status fields, update goggles scan/status handlers.
Migrations¶
A world saved by an older version is brought up to date by ra_migrations, run
from ra:load before anything else touches the world.
- One function per version step, named for the step it bridges:
ra_migrations:5.1.8-to-5.1.9. ra_migrations:runcalls them oldest-first. Add new ones to the end.- Every migration runs on every load, so each must be safe to run twice. Fill in what a newer version expects; never overwrite or destroy state.
The names are -to- rather than -> because a resource location path may only
contain [a-z0-9_.-/], and a file the loader skips is a migration that silently
never runs.
They are also identifiers, not the pack version. A find-and-replace that
bumps the version across the repo must skip ra_migrations/, or the chain
renames itself to nonsense like 5.1.9-to-5.1.9.
What has needed one so far: tagging every entity ra so /kill @e[tag=ra]
works, writing data.type onto markers so the wrench and read-only registries
have a key, dropping the enabled property, and clearing skins so they redraw
with a wider anti-z-fighting margin.
6) Contributor Workflow¶
Use this sequence for safe feature delivery.
- Define block/multiblock ID and naming.
- Implement give item, placement handler, tick, and break cleanup.
- Register placement handler and namespace load/tick hooks.
- Add recipe and advancement unlock path.
- Add CDH property support for editable settings.
- Add goggles status support for visible diagnostics.
- Update docs and changelog.
- Render the recipe picture (see below) and reference it from the module page.
- Run in-world validation pass.
Recipe pictures¶
docs/images/recipes/{namespace}/{name}.png used to be a screenshot per recipe.
They are generated now — full details in Recipe Renderer:
python3 tools/recipe_render/render.py src/data/<namespace>/recipe/<name>.json
python3 tools/recipe_render/render.py --all # the whole pack
The renderer reads the recipe the way the game does, so a result's
minecraft:item_model component is honoured. Ingredients carry no components, so
a disguised RA item used as an ingredient needs an entry in
tools/recipe_render/overrides.json.
The Planet Minecraft description¶
readme.bbcode is generated from readme.md, not written by hand — full details
in Markdown to BBCode:
python3 tools/md_to_bbcode.py ../readme.md \
--base-url https://github.com/AnCarsenat/Redstone-Additions/raw/main/
PMC has no heading or table tag, so headings become sized bold text and tables are
flattened; relative links need the --base-url above to survive.
New Block Checklist (Practical)¶
- Item custom_data and
ra.place.*tags are correct. - Placement handler returns
1only for matching bats. - Block tick includes break detection and cleanup.
- On-break drop reproduces the same item signature.
- Block appears in module
give_alland in the correct namespace bundle fromra:give_all_items. - Recipe and advancement IDs align.
New Multiblock Checklist (Practical)¶
- Validation hook registered in
#ra_lib_multiblock:validate. - Setup hook registered in
#ra_lib_multiblock:setup_type. - Periodic check hook registered in
#ra_lib_multiblock:check_structure. - Cleanup hook registered in
#ra_lib_multiblock:on_break. - Wrench assembly flow can stage required data in
storage ra:multiblock. - Marker stores
type,facing,properties, IO/control metadata.
7) Validation and Debug¶
After changes, run this minimum test set:
/reloadwith log inspection.- Place each changed block once and verify marker tags.
- Break changed blocks and verify no orphan markers.
- Verify recipe output tags/custom_data.
- Test Data Handler and CDH edit operations on changed blocks, including full-inventory text-input warning behavior.
- Test goggles status rendering for changed blocks.
- For multiblocks, test both assemble and disassemble paths.
Useful selectors:
@e[tag=ra.custom_block,distance=..20]@e[tag=ra.multiblock,distance=..40]@a[tag=ra.debug]
8) Common Failure Modes¶
- ID mismatch between recipe result and placement handler tag.
- Missing placement handler registration.
- Forgetting to remove one-time tags (for example
ra.new). - Not updating CDH/goggles when adding new properties.
- Not updating Data Handler input mapping when adding editable properties.
- Multiblock setup data missing required fields in storage before assembly.
- Assuming numeric wireless channels; runtime channels are string values.
Related pages: