Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/2026-11-05-c-package-no-events-abi8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix: an Octatrack C module that subscribes to no event, using only the core tick, draw, key and encoder hooks, can now be published for USB. `npm run octatrack:module-packages` used to stop on its package with "Not a supported published runtime ABI", because the builder made it ABI 4 with no name. It now goes out as ABI 8 with its id as its name on the unit, the form an event-subscribing C module already has, so the unit's inventory names it too. The published base's loader already reads that form, so no base update is needed, and every package already published rebuilds byte for byte.
3 changes: 3 additions & 0 deletions docs/ADD_A_MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ the package is rebuilt. The standalone and table recipes in
asset JSON, the remix tools, `module_packages.py`, the Elekloader checkout),
so any import of rebuilt packages can stop the full run on them. Re-pin only
the asset hashes that moved; `--check` must then call every package identical.
`sdk/runtime/loader/build.py` and `module_packages.py` are base inputs too
(`src/engine/assets/octatrack-base-sources.json`): a change to how packages are
published goes in the host wrapper `scripts/octatrack-module-packages.py`.
In the toolchain image set `ELEKLOADER_DSP_ASM`; restore old versions it deletes.

A newly developed module selectable in beta module sets must have a working
Expand Down
26 changes: 23 additions & 3 deletions scripts/octatrack-module-packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ def engine_words(data):
return engine_capabilities, engine_words


def titled(data, name):
"""A C package that subscribes to no event: build.py makes it ABI 4, with no name, and the site publishes only ABI 6
and later. Repacked as the ABI 8 package build.py makes for a subscribing one (build_c: its trailing empty hook
words dropped, its stem as its unit name), which the base's loader reads, so the unit's inventory names it too.
Any other package comes back as it is. Here, not in the builder, whose source the base pins."""
if struct.unpack_from('>H', data, 4)[0] != 4:
return data
spec = importlib.util.spec_from_file_location('octatrack_runtime_builder', APP / 'sdk/runtime/loader/build.py')
runtime = importlib.util.module_from_spec(spec); spec.loader.exec_module(runtime)
flags, image, bss, count, hooks, sites, ident = struct.unpack_from('>HIIIIII', data, 6)
at = 32 + 4 * hooks
if flags or sites or ident != runtime.module_id(name) or len(data) != at + image + 4 * count:
raise ValueError('%s: not the ABI 4 package build.py makes from C sources.' % name)
words = list(struct.unpack_from('>%dI' % hooks, data, 32))
while words and words[-1] == runtime.NONE:
words.pop()
return runtime.package(data[at:at + image], bss, words, list(struct.unpack_from('>%dI' % count, data, at + image)),
name=name, abi=8, hashed=True, title=name[:15])


def main():
spec = importlib.util.spec_from_file_location(
'octatrack_module_packages', APP / 'sdk/machines/octatrack/elekloader/module_packages.py')
Expand All @@ -146,8 +166,8 @@ def named_build(entry, *args, **kwargs):
if build:
builder.build = named_build
# A C runtime module: its row's "sources", "subscribe" and "raise" go to build.py as they are. It has no card form
# (src/catalog/usb-only.ts). build.py takes its id and unit name from the output's stem, so build at <id>.mwrm.
# Here, not in the builder, whose source the base pins.
# (src/catalog/usb-only.ts). build.py takes its id and unit name from the output's stem, so build at <id>.mwrm; one
# with no events goes out as ABI 8 too (titled()). Here, not in the builder, whose source the base pins.
sourced, tools = getattr(builder, 'build', None), getattr(builder, 'tools', None)
def c_build(entry, out, *args, **kwargs):
if not entry.get('sources'):
Expand All @@ -157,7 +177,7 @@ def c_build(entry, out, *args, **kwargs):
*[arg for hook in entry.get('subscribe', []) for arg in ('--subscribe', hook)],
*[arg for event in entry.get('raise', []) for arg in ('--raise', event)], '-o', str(named)],
check=True, capture_output=True, text=True, cwd=APP)
out.write_bytes(named.read_bytes())
out.write_bytes(titled(named.read_bytes(), entry['id']))
return out.read_bytes()
if sourced and tools:
builder.build = c_build
Expand Down
21 changes: 21 additions & 0 deletions sdk/tests/test_module_packages_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,26 @@ def action():
cli.keep_published(builder, rebuild(True))
self.assertEqual((old.read_bytes(), current.read_bytes()), (b'old', b'new'))

def test_a_c_package_with_no_events_goes_out_as_abi_8_with_its_name(self):
modules = {}
for name, path in (('cli', 'scripts/octatrack-module-packages.py'), ('runtime', 'sdk/runtime/loader/build.py'),
('publisher', 'sdk/machines/octatrack/elekloader/module_packages.py')):
spec = importlib.util.spec_from_file_location('module_packages_titled_' + name, ROOT / path)
modules[name] = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modules[name])
cli, runtime, publisher = modules['cli'], modules['runtime'], modules['publisher']
image, hooks = b'\x4e\x75\0\0\0\0\0\0', [0, runtime.NONE, 2, runtime.NONE] # tick and key; no draw or enc
four = runtime.package(image, 8, hooks, [4], name='tiempos') # what build.py makes with no --subscribe
self.assertEqual(four[5], 4)
made = cli.titled(four, 'tiempos')
# build_c's form for a subscribing package, with no subscription: subscribe() drops the trailing empty words
self.assertEqual(made, runtime.package(image, 8, runtime.subscribe(hooks, [], {}), [4], name='tiempos', abi=8,
hashed=True, title='tiempos'))
self.assertEqual(publisher.capabilities(made), dict(abi=8, features=0, sites=0, copies=0, fills=0))
self.assertEqual((made[28:32], made[50:66].rstrip(b'\0')), (four[28:32], b'tiempos')) # same module id, named
self.assertIs(cli.titled(made, 'tiempos'), made) # a subscribing (ABI 8) one stays as it is
with self.assertRaises(ValueError):
cli.titled(four, 'beat-slicer') # the name must be the one the package's id was made from

if __name__ == '__main__':
unittest.main()
Loading