summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java43
-rw-r--r--src/client/kotlin/space/autistic/radio/client/ClientProxy.kt20
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt46
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt4
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt1
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt15
-rw-r--r--src/client/kotlin/space/autistic/radio/client/cli/Funny.kt16
-rw-r--r--src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt79
-rw-r--r--src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt2
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt7
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt627
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt115
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt68
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt34
-rw-r--r--src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b4
-rw-r--r--src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c723
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/fm-receiver.json6
-rw-r--r--src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json3
-rw-r--r--src/main/kotlin/space/autistic/radio/CommonProxy.kt10
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadio.kt59
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt30
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt26
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioItems.kt23
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt12
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt13
-rw-r--r--src/main/kotlin/space/autistic/radio/SidedProxy.kt9
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/Antenna.kt9
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt8
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt7
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt18
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt7
-rw-r--r--src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt178
-rw-r--r--src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt4
-rw-r--r--src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt56
-rw-r--r--src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt20
-rw-r--r--src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt27
-rw-r--r--src/main/kotlin/space/autistic/radio/wasm/Bindings.kt173
-rw-r--r--src/main/resources/assets/pirate-radio/blockstates/disposable-transmitter.json10
-rw-r--r--src/main/resources/assets/pirate-radio/lang/en_us.json22
-rw-r--r--src/main/resources/assets/pirate-radio/models/block/disposable-transmitter-vertical.json29
-rw-r--r--src/main/resources/assets/pirate-radio/models/block/disposable-transmitter.json30
-rw-r--r--src/main/resources/assets/pirate-radio/textures/entity/electronics-trader.pngbin0 -> 1059 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/textures/gui/radio-receiver.pngbin0 -> 1059 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/textures/item/disposable-transmitter.pngbin0 -> 881 bytes
-rw-r--r--src/main/resources/data/pirate-radio/pirate-radio/antenna/const.json4
-rw-r--r--src/main/resources/data/pirate-radio/pirate-radio/antenna/null.json4
-rw-r--r--src/main/resources/fabric.mod.json6
-rw-r--r--src/main/resources/pirate-radio.client-mixins.json14
-rw-r--r--src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt2
-rw-r--r--src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt20
50 files changed, 1866 insertions, 57 deletions
diff --git a/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java
new file mode 100644
index 0000000..0e9b05e
--- /dev/null
+++ b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java
@@ -0,0 +1,43 @@
+package space.autistic.radio.client.mixin;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.BlockState;
+import net.minecraft.block.Blocks;
+import net.minecraft.client.render.model.BlockStatesLoader;
+import net.minecraft.state.StateManager;
+import net.minecraft.state.property.BooleanProperty;
+import net.minecraft.state.property.DirectionProperty;
+import net.minecraft.state.property.Property;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.profiler.Profiler;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(BlockStatesLoader.class)
+public abstract class BlockStatesLoaderMixin {
+
+    @Shadow
+    abstract void loadBlockStates(Identifier id, StateManager<Block, BlockState> stateManager);
+
+    @Shadow
+    private Profiler profiler;
+
+    @Unique
+    private static final StateManager<Block, BlockState> STATE_MANAGER = new StateManager.Builder<Block, BlockState>(Blocks.AIR)
+            .add(DirectionProperty.of("facing"))
+            .build(Block::getDefaultState, BlockState::new);
+
+    @Inject(
+            method = {"load()V"},
+            at = {@At("HEAD")}
+    )
+    void onLoad(CallbackInfo callbackInfo) {
+        profiler.push("pirate_radio_static_definitions");
+        loadBlockStates(Identifier.of("pirate-radio", "disposable-transmitter"), STATE_MANAGER);
+        profiler.pop();
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt
new file mode 100644
index 0000000..3c29cd3
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt
@@ -0,0 +1,20 @@
+package space.autistic.radio.client
+
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.network.ClientPlayerEntity
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+import space.autistic.radio.CommonProxy
+import space.autistic.radio.PirateRadioItems.STORAGE_CARD
+import space.autistic.radio.client.gui.StorageCardEditScreen
+
+class ClientProxy : CommonProxy() {
+    override fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand) {
+        if (player is ClientPlayerEntity) {
+            if (item.isOf(STORAGE_CARD)) {
+                MinecraftClient.getInstance().setScreen(StorageCardEditScreen(player, item, hand))
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
index 54b7640..1a68c21 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
@@ -1,25 +1,47 @@
 package space.autistic.radio.client
 
-import com.mojang.brigadier.CommandDispatcher
 import net.fabricmc.api.ClientModInitializer
 import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager
 import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback
-import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents
 import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry
 import net.minecraft.client.MinecraftClient
-import net.minecraft.command.CommandRegistryAccess
+import net.minecraft.client.sound.SoundInstance
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
 import org.slf4j.LoggerFactory
 import space.autistic.radio.PirateRadio
 import space.autistic.radio.PirateRadio.MOD_ID
 import space.autistic.radio.PirateRadioEntityTypes
 import space.autistic.radio.client.entity.ElectronicsTraderEntityRenderer
+import space.autistic.radio.client.entity.DisposableTransmitterEntityRenderer
+import space.autistic.radio.client.fmsim.FmSimulatorMode
 import space.autistic.radio.client.gui.FmReceiverScreen
+import space.autistic.radio.client.sound.PirateRadioSoundInstance
+import kotlin.math.max
+import kotlin.math.min
 
 object PirateRadioClient : ClientModInitializer {
-    private val logger = LoggerFactory.getLogger(MOD_ID)
+    private var soundInstance: SoundInstance? = null
+    var volume: Int = 0
+        set(value) {
+            field = min(10, max(0, value))
+        }
+    var stereo: Boolean = false
+    var frequency = 768
+        set(value) {
+            field = min(1080, max(768, value))
+        }
+    var mode = FmSimulatorMode.FULL
 
     override fun onInitializeClient() {
+        PirateRadio.proxy = ClientProxy()
         EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer)
+        EntityRendererRegistry.register(
+            PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER,
+            ::DisposableTransmitterEntityRenderer
+        )
         PirateRadioEntityModelLayers.initialize()
         ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ ->
             dispatcher.register(
@@ -31,5 +53,21 @@ object PirateRadioClient : ClientModInitializer {
                 }
             )
         }
+        ClientTickEvents.END_WORLD_TICK.register { world ->
+            if (volume > 0 && MinecraftClient.getInstance().player?.isRemoved == false) {
+                if (soundInstance == null) {
+                    soundInstance = PirateRadioSoundInstance(MinecraftClient.getInstance().player!!)
+                }
+                val soundManager = MinecraftClient.getInstance().soundManager
+                if (!soundManager.isPlaying(soundInstance)) {
+                    soundManager.play(soundInstance)
+                }
+            } else {
+                if (soundInstance != null) {
+                    MinecraftClient.getInstance().soundManager.stop(soundInstance)
+                    soundInstance = null
+                }
+            }
+        }
     }
 }
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
index b5130a1..65e9677 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
@@ -32,7 +32,7 @@ class PirateRadioItemModelGenerator(output: FabricDataOutput) : FabricModelProvi
         modelGenderator.register(PirateRadioItems.SBC, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.WIRE, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.POWERBANK, Models.GENERATED)
-        modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED)
+        //modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.STORAGE_CARD, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.DISPOSABLE_TRANSMITTER, Models.GENERATED)
     }
@@ -46,7 +46,7 @@ class PirateRadioRecipeGenerator(
     override fun generate(exporter: RecipeExporter) {
         ShapelessRecipeJsonBuilder.create(RecipeCategory.MISC, PirateRadioItems.DISPOSABLE_TRANSMITTER)
             .input(PirateRadioItems.SBC).input(PirateRadioItems.WIRE).input(PirateRadioItems.POWERBANK)
-            .input(PirateRadioItems.STORAGE_CARD)
+            //.input(PirateRadioItems.STORAGE_CARD)
             .criterion("has_sbc", RecipeProvider.conditionsFromItem(PirateRadioItems.SBC)).offerTo(exporter)
     }
 
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
index 765912d..604fdfd 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
@@ -9,6 +9,7 @@ import space.autistic.radio.PirateRadio
 
 object PirateRadioEntityModelLayers {
     val ELECTRONICS_TRADER = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main")
+    val PIRATE_RADIO = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main")
 
     fun initialize() {
         EntityModelLayerRegistry.registerModelLayer(ELECTRONICS_TRADER) {
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
index 51743dd..38d0c97 100644
--- a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
@@ -12,9 +12,7 @@ import com.dylibso.chicory.wasm.types.Value
 import com.dylibso.chicory.wasm.types.ValueType
 import org.joml.Quaterniond
 import org.joml.Vector3d
-import space.autistic.radio.PirateRadio
-import java.util.logging.Level
-import java.util.logging.Logger
+import space.autistic.radio.PirateRadio.logger
 
 class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
     var failing = false
@@ -25,7 +23,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 // capped at 1MB per antenna
                 .withMemoryLimits(MemoryLimits(0, 16))
         } catch (e: ChicoryException) {
-            logger.log(Level.SEVERE, "Error while trying to parse antenna model.", e)
+            logger.error("Error while trying to parse antenna model.", e)
             failing = true
             null
         }
@@ -53,9 +51,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 orientation.w.toRawBits()
             )
             if (instance.exports().global("should-attenuate").type != ValueType.I32) {
-                logger.log(
-                    Level.SEVERE, "Error while trying to initialize antenna model: missing 'should-attenuate'"
-                )
+                logger.error("Error while trying to initialize antenna model: missing 'should-attenuate'")
                 failing = true
                 return ConstAntennaModel(0f)
             }
@@ -73,7 +69,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                             )[0]
                         )
                     } catch (e: ChicoryException) {
-                        logger.log(Level.SEVERE, "Error while trying to evaluate antenna model.", e)
+                        logger.error("Error while trying to evaluate antenna model.", e)
                         failing = true
                         return 0f
                     }
@@ -84,7 +80,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 }
             }
         } catch (e: ChicoryException) {
-            logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e)
+            logger.error("Error while trying to initialize antenna model.", e)
             failing = true
             return ConstAntennaModel(0f)
         }
@@ -92,6 +88,5 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
 
     companion object {
         private val defaultImports = ImportValues.builder().build()
-        private val logger = Logger.getLogger(PirateRadio.MOD_ID)
     }
 }
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt
new file mode 100644
index 0000000..ebf6b06
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt
@@ -0,0 +1,16 @@
+package space.autistic.radio.client.cli
+
+import space.autistic.radio.wasm.Bindings
+import java.lang.invoke.MethodHandles
+import kotlin.reflect.jvm.javaMethod
+
+object Funny {
+    val lookup = MethodHandles.lookup()
+
+    fun test() {
+    }
+}
+
+fun main() {
+    Bindings.bindFunc("", "", Funny.lookup, Funny::test.javaMethod!!, Funny)
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt
new file mode 100644
index 0000000..61b1e19
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt
@@ -0,0 +1,79 @@
+package space.autistic.radio.client.entity
+
+import net.minecraft.client.render.OverlayTexture
+import net.minecraft.client.render.TexturedRenderLayers
+import net.minecraft.client.render.VertexConsumerProvider
+import net.minecraft.client.render.entity.EntityRenderer
+import net.minecraft.client.render.entity.EntityRendererFactory
+import net.minecraft.client.render.model.BakedModelManager
+import net.minecraft.client.util.ModelIdentifier
+import net.minecraft.client.util.math.MatrixStack
+import net.minecraft.screen.PlayerScreenHandler
+import net.minecraft.util.Identifier
+import net.minecraft.util.math.Direction
+import net.minecraft.util.math.RotationAxis
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.entity.DisposableTransmitterEntity
+
+class DisposableTransmitterEntityRenderer(ctx: EntityRendererFactory.Context) :
+    EntityRenderer<DisposableTransmitterEntity>(ctx) {
+
+    private val blockRenderManager = ctx.blockRenderManager
+
+    override fun getTexture(entity: DisposableTransmitterEntity): Identifier {
+        return PlayerScreenHandler.BLOCK_ATLAS_TEXTURE
+    }
+
+    override fun render(
+        entity: DisposableTransmitterEntity,
+        yaw: Float,
+        tickDelta: Float,
+        matrices: MatrixStack,
+        vertexConsumers: VertexConsumerProvider,
+        light: Int
+    ) {
+        super.render(entity, yaw, tickDelta, matrices, vertexConsumers, light)
+
+        matrices.push()
+        val facing: Direction = entity.horizontalFacing
+        val vec3d = this.getPositionOffset(entity, tickDelta)
+        matrices.translate(-vec3d.getX(), -vec3d.getY(), -vec3d.getZ())
+        val d = (1.0 - DisposableTransmitterEntity.DEPTH) / 2.0
+        matrices.translate(
+            facing.offsetX.toDouble() * d, facing.offsetY.toDouble() * d, facing.offsetZ.toDouble() * d
+        )
+        matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(entity.pitch))
+        matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180.0f - entity.yaw))
+        if (!entity.isInvisible) {
+            val bakedModelManager: BakedModelManager = this.blockRenderManager.models.modelManager
+            matrices.push()
+            matrices.translate(-0.5f, -0.5f, -0.5f)
+            this.blockRenderManager.modelRenderer.render(
+                matrices.peek(),
+                vertexConsumers.getBuffer(TexturedRenderLayers.getEntitySolid()),
+                null,
+                bakedModelManager.getModel(MODEL_ID[facing]),
+                1.0f,
+                1.0f,
+                1.0f,
+                light,
+                OverlayTexture.DEFAULT_UV
+            )
+            matrices.pop()
+        }
+
+        matrices.pop()
+    }
+
+    companion object {
+        private val STATES_ID = Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter")
+        private val MODEL_ID = mapOf(
+            Direction.DOWN to ModelIdentifier(STATES_ID, "facing=down"),
+            Direction.UP to ModelIdentifier(STATES_ID, "facing=up"),
+            Direction.NORTH to ModelIdentifier(STATES_ID, "facing=north"),
+            Direction.SOUTH to ModelIdentifier(STATES_ID, "facing=south"),
+            Direction.WEST to ModelIdentifier(STATES_ID, "facing=west"),
+            Direction.EAST to ModelIdentifier(STATES_ID, "facing=east"),
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
index 91c29db..5da8e17 100644
--- a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
+++ b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
@@ -16,7 +16,7 @@ class ElectronicsTraderEntityRenderer(context: EntityRendererFactory.Context) :
     ) {
 
     companion object {
-        val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "electronics-trader")
+        val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/entity/electronics-trader.png")
     }
 
     override fun getTexture(entity: ElectronicsTraderEntity?): Identifier = TEXTURE
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt
new file mode 100644
index 0000000..a8bc9fd
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.client.fmsim
+
+enum class FmSimulatorMode {
+    FULL,
+    FAST,
+    DEAF
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
index 4bd4db2..f5fd729 100644
--- a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
+++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
@@ -1,11 +1,634 @@
 package space.autistic.radio.client.gui
 
+import com.dylibso.chicory.experimental.aot.AotMachineFactory
+import com.dylibso.chicory.runtime.*
+import com.dylibso.chicory.wasm.ChicoryException
+import com.dylibso.chicory.wasm.InvalidException
+import com.dylibso.chicory.wasm.Parser
+import com.dylibso.chicory.wasm.types.FunctionType
+import com.dylibso.chicory.wasm.types.Value
+import com.dylibso.chicory.wasm.types.ValueType
+import com.mojang.blaze3d.systems.RenderSystem
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.gui.DrawContext
 import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.gui.widget.ClickableWidget
+import net.minecraft.client.render.GameRenderer
+import net.minecraft.client.util.InputUtil
+import net.minecraft.text.StringVisitable
 import net.minecraft.text.Text
+import net.minecraft.util.Colors
+import net.minecraft.util.Identifier
+import org.lwjgl.glfw.GLFW
+import org.slf4j.LoggerFactory
+import org.slf4j.event.Level
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.PirateRadio.logger
+import space.autistic.radio.client.PirateRadioClient
+import space.autistic.radio.client.fmsim.FmSimulatorMode
+import space.autistic.radio.wasm.Bindings.Companion.bindFunc
+import java.lang.invoke.MethodHandles
+import kotlin.math.max
+import kotlin.reflect.jvm.javaMethod
+
 
 class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
 
+    private var loggingEventBuilder = wasmLogger.makeLoggingEventBuilder(Level.INFO)
+    private var instance: Instance? = null
+    private var failure: Exception? = null
+        set(value) {
+            field = value
+            clearChildren()
+        }
+    private var packTitle: Text? = null
+
+    private var backgroundWidth = 0
+    private var backgroundHeight = 0
+    private var backgroundTextureWidth = 256
+    private var backgroundTextureHeight = 256
+
+    private var drawContext: DrawContext? = null
+
+    private var textObjects = ArrayList<Text?>()
+    private var textObjectsFree = ArrayList<Int>()
+
+    private val frequencyPlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.plus")) {
+        if (InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT)
+            || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT)
+        ) {
+            PirateRadioClient.frequency += 10
+        } else {
+            PirateRadioClient.frequency++
+        }
+    }.dimensions(0, 0, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) }
+        .build()
+    private val frequencyMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.minus")) {
+        if (InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT)
+            || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT)
+        ) {
+            PirateRadioClient.frequency -= 10
+        } else {
+            PirateRadioClient.frequency--
+        }
+    }.dimensions(20, 0, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) }
+        .build()
+
+    private val volumePlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.plus")) {
+        PirateRadioClient.volume++
+    }.dimensions(0, 20, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.plus.narrated")) }
+        .build()
+    private val volumeMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.minus")) {
+        PirateRadioClient.volume--
+    }.dimensions(20, 20, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.minus.narrated")) }
+        .build()
+    private val toggleModes = ButtonWidget.builder(Text.translatable("pirate-radio.mode")) {
+        PirateRadioClient.mode = when (PirateRadioClient.mode) {
+            FmSimulatorMode.FULL -> FmSimulatorMode.FAST
+            else -> FmSimulatorMode.FULL
+        }
+    }.position(0, 40).build()
+
     override fun init() {
-        // TODO
+        if (failure == null && instance == null) {
+            try {
+                setupWasm()
+            } catch (e: WasmScreenException) {
+                logger.error("Failed to setup wasm.", e)
+                failure = e
+            }
+        }
+        if (failure == null) {
+            try {
+                instance!!.export("init").apply()
+                addDrawableChild(frequencyPlusWidget)
+                addDrawableChild(frequencyMinusWidget)
+                addDrawableChild(volumePlusWidget)
+                addDrawableChild(volumeMinusWidget)
+                addDrawableChild(toggleModes)
+                volumePlusWidget.visible
+            } catch (e: ChicoryException) {
+                failure = WasmScreenException("Skin failed to initialize", e)
+                logger.error("Failed to initialize.", failure)
+            } catch (e: WasmScreenException) {
+                failure = e
+                logger.error("Failed to initialize.", failure)
+            }
+        }
+    }
+
+    override fun renderBackground(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.renderInGameBackground(context)
+
+        if (failure == null) {
+            if (backgroundWidth or backgroundHeight != 0) {
+                RenderSystem.setShader(GameRenderer::getPositionTexProgram)
+                RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f)
+                RenderSystem.setShaderTexture(0, TEXTURE)
+                val x = (width - backgroundWidth) / 2
+                val y = (height - backgroundHeight) / 2
+                context.drawTexture(
+                    TEXTURE,
+                    x,
+                    y,
+                    0f,
+                    0f,
+                    backgroundWidth,
+                    backgroundHeight,
+                    backgroundTextureWidth,
+                    backgroundTextureHeight
+                )
+            }
+        }
+
+
+        if (client!!.debugHud.shouldShowDebugHud()) {
+            context.drawText(
+                textRenderer,
+                Text.translatable("pirate-radio.skin-pack", packTitle),
+                0,
+                height - textRenderer.fontHeight,
+                Colors.WHITE,
+                true
+            )
+        }
+    }
+
+    override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.render(context, mouseX, mouseY, delta)
+
+        if (failure != null) {
+            context.drawTextWrapped(
+                textRenderer, StringVisitable.plain(failure!!.message), (width - 320) / 2, 0, 320, Colors.WHITE
+            )
+        } else {
+            try {
+                drawContext = context
+                instance!!.export("render").apply(mouseX.toLong(), mouseY.toLong(), Value.floatToLong(delta))
+            } catch (e: ChicoryException) {
+                failure = WasmScreenException("Skin failed to initialize", e)
+                logger.error("Failed to initialize.", failure)
+            } catch (e: WasmScreenException) {
+                failure = e
+                logger.error("Failed to initialize.", failure)
+            } finally {
+                drawContext = null
+            }
+        }
+    }
+
+    override fun shouldPause() = false
+
+    private fun loggerLog() {
+        loggingEventBuilder.log()
+    }
+
+    private fun loggerLogMessage(message: String) {
+        loggingEventBuilder.log(message)
+    }
+
+    private fun loggerBegin(level: Int) {
+        loggingEventBuilder = wasmLogger.makeLoggingEventBuilder(
+            try {
+                Level.intToLevel(level)
+            } catch (e: IllegalArgumentException) {
+                Level.INFO
+            }
+        )
+    }
+
+    private fun loggerSetMessage(message: String) {
+        loggingEventBuilder.setMessage(message)
+    }
+
+    private fun loggerAddArgumentString(arg: String) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentInt(arg: Int) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentLong(arg: Long) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentFloat(arg: Float) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentDouble(arg: Double) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun setBackgroundSize(width: Int, height: Int) {
+        this.backgroundWidth = max(width, 0)
+        this.backgroundHeight = max(height, 0)
+    }
+
+    private fun setBackgroundTextureSize(width: Int, height: Int) {
+        this.backgroundTextureWidth = max(width, 0)
+        this.backgroundTextureHeight = max(height, 0)
+    }
+
+    private fun renderTextTranslatable(text: String, x: Int, y: Int, color: Int, shadow: Boolean) {
+        drawContext?.drawText(textRenderer, Text.translatable(text), x, y, color, shadow)
+    }
+
+    private fun renderTextLiteral(text: String, x: Int, y: Int, color: Int, shadow: Boolean) {
+        drawContext?.drawText(textRenderer, Text.literal(text), x, y, color, shadow)
+    }
+
+    private fun renderTextObject(handle: Int, x: Int, y: Int, color: Int, shadow: Boolean) {
+        if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) {
+            drawContext?.drawText(textRenderer, textObjects[handle], x, y, color, shadow)
+        } else {
+            throw WasmScreenException("Invalid text handle: $handle", null)
+        }
+    }
+
+    private fun frequencyPlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        frequencyPlusWidget.x = x
+        frequencyPlusWidget.y = y
+        frequencyPlusWidget.width = width
+        frequencyPlusWidget.height = height
+    }
+
+    private fun frequencyMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        frequencyMinusWidget.x = x
+        frequencyMinusWidget.y = y
+        frequencyMinusWidget.width = width
+        frequencyMinusWidget.height = height
+    }
+
+    private fun volumePlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        volumePlusWidget.x = x
+        volumePlusWidget.y = y
+        volumePlusWidget.width = width
+        volumePlusWidget.height = height
+    }
+
+    private fun volumeMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        volumeMinusWidget.x = x
+        volumeMinusWidget.y = y
+        volumeMinusWidget.width = width
+        volumeMinusWidget.height = height
+    }
+
+    private fun toggleWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        toggleModes.x = x
+        toggleModes.y = y
+        toggleModes.width = width
+        toggleModes.height = height
+    }
+
+    private fun getFrequency(): Int = PirateRadioClient.frequency
+
+    private fun getMode(): Int = PirateRadioClient.mode.ordinal
+
+    private fun getStereo(): Boolean = PirateRadioClient.stereo
+
+    private fun getVolume(): Int = PirateRadioClient.volume
+
+    private fun getStereoPilot(): Float = 0f
+
+    private fun getWidth(): Int = width
+
+    private fun getHeight(): Int = height
+
+    // useful for drawing the stereo pilot, not that it's implemented
+    private fun drawImage(
+        red: Float,
+        green: Float,
+        blue: Float,
+        alpha: Float,
+        x: Int,
+        y: Int,
+        u: Float,
+        v: Float,
+        w: Int,
+        h: Int
+    ) {
+        val drawContext = drawContext ?: throw WasmScreenException("Inappropriate draw call", null)
+        RenderSystem.setShader(GameRenderer::getPositionTexProgram)
+        RenderSystem.setShaderColor(red, green, blue, alpha)
+        RenderSystem.setShaderTexture(0, TEXTURE)
+        drawContext.drawTexture(
+            TEXTURE,
+            x,
+            y,
+            u,
+            v,
+            w,
+            h,
+            backgroundTextureWidth,
+            backgroundTextureHeight
+        )
+        RenderSystem.setShaderColor(1f, 1f, 1f, 1f)
+    }
+
+    private fun textFree(handle: Int) {
+        if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) {
+            textObjects[handle] = null
+            textObjectsFree.add(handle)
+        } else {
+            throw WasmScreenException("Invalid text handle: $handle", null)
+        }
+    }
+
+    private fun textLiteral(text: String): Int {
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.literal(text)
+        return index
+    }
+
+    private fun textTranslatable(text: String): Int {
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.translatable(text)
+        return index
+    }
+
+    private fun textTranslatableArguments(text: String, args: List<Int>): Int {
+        args.forEach { handle ->
+            if (handle < 0 || handle >= textObjects.size || textObjects[handle] == null) {
+                throw WasmScreenException("Invalid text handle: $handle", null)
+            }
+        }
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.translatable(text, *Array(args.size) { textObjects[args[it]] })
+        return index
+    }
+
+    private fun setupWasm() {
+        // this should never throw
+        val resource = this.client!!.resourceManager.getResourceOrThrow(WASM_GUI)
+        packTitle = resource.pack.info.title
+        val module = try {
+            Parser.parse(resource.inputStream)
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error parsing module", e)
+        }
+        val importValues = ImportValues.builder()
+        importValues.addFunction(
+            bindFunc(
+                "text", "free", lookup, this::textFree.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "literal", lookup, this::textLiteral.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "translatable", lookup, this::textTranslatable.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "translatable-arguments", lookup, this::textTranslatableArguments.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "frequency-plus-widget",
+                "set-dimensions",
+                lookup,
+                this::frequencyPlusWidgetDimensions.javaMethod!!,
+                this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "frequency-minus-widget",
+                "set-dimensions",
+                lookup,
+                this::frequencyMinusWidgetDimensions.javaMethod!!,
+                this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "volume-plus-widget", "set-dimensions", lookup, this::volumePlusWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "volume-minus-widget", "set-dimensions", lookup, this::volumeMinusWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "toggle-widget", "set-dimensions", lookup, this::toggleWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-frequency", lookup, this::getFrequency.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-volume", lookup, this::getVolume.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-mode", lookup, this::getMode.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-stereo", lookup, this::getStereo.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-stereo-pilot", lookup, this::getStereoPilot.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "get-width", lookup, this::getWidth.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "get-height", lookup, this::getHeight.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "draw-image", lookup, this::drawImage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-texture-size", lookup, this::setBackgroundTextureSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-translatable", lookup, this::renderTextTranslatable.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-literal", lookup, this::renderTextLiteral.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-object", lookup, this::renderTextObject.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log", lookup, this::loggerLog.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log-message", lookup, this::loggerLogMessage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "begin", lookup, this::loggerBegin.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log-message", lookup, this::loggerSetMessage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-string", lookup, this::loggerAddArgumentString.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-int", lookup, this::loggerAddArgumentInt.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-long", lookup, this::loggerAddArgumentLong.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-float", lookup, this::loggerAddArgumentFloat.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-double", lookup, this::loggerAddArgumentDouble.javaMethod!!, this
+            )
+        )
+        val builder = Instance.builder(module)
+        builder.withMachineFactory(AotMachineFactory(module))
+        builder.withImportValues(importValues.build())
+        val instance = try {
+            builder.build()
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error constructing module", e)
+        }
+        var initialize: ExportFunction? = null
+        try {
+            initialize = instance.export("_initialize")
+        } catch (_: InvalidException) {
+            // export may not exist, it's fine
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error initializing module", e)
+        }
+        try {
+            initialize?.apply()
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error initializing module", e)
+        }
+        try {
+            checkFuncExports(
+                instance,
+                "init" to FunctionType.empty(),
+                "render" to FunctionType.of(arrayOf(ValueType.I32, ValueType.I32, ValueType.F32), emptyArray())
+            )
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error checking exports", e)
+        }
+        this.instance = instance
+    }
+
+    private class WasmScreenException(message: String?, cause: Throwable?) : Exception(message, cause)
+
+    @Throws(WasmScreenException::class)
+    private fun checkFuncExports(instance: Instance, vararg exports: Pair<String, FunctionType>) {
+        val sb = StringBuilder()
+        exports.forEach { (name, type) ->
+            when (exportStatus(instance, name, type)) {
+                ExportStatus.MISSING -> sb.append("missing: ", name, type, "\n")
+                ExportStatus.TYPE_MISMATCH -> sb.append("type mismatch: ", name, type, "\n")
+                ExportStatus.OK -> {}
+            }
+        }
+        if (sb.isNotEmpty()) {
+            sb.setLength(sb.length - 1)
+            throw WasmScreenException("Skin failed to load: error checking exports:\n$sb", null)
+        }
+    }
+
+    private enum class ExportStatus {
+        OK, MISSING, TYPE_MISMATCH,
+    }
+
+    private fun exportStatus(instance: Instance, name: String, type: FunctionType): ExportStatus {
+        try {
+            // because instance.exportType doesn't check if it's actually a function
+            instance.export(name)
+            return if (instance.exportType(name) == type) ExportStatus.OK else ExportStatus.TYPE_MISMATCH
+        } catch (_: InvalidException) {
+            return ExportStatus.MISSING
+        }
+    }
+
+    companion object {
+        private val WASM_GUI = Identifier.of(PirateRadio.MOD_ID, "guis/radio-receiver.wasm")
+
+        // the texture is at a fixed location but the respack can replace it too
+        private val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/gui/radio-receiver.png")
+
+        private val wasmLogger = LoggerFactory.getLogger(PirateRadio.MOD_ID + "/wasm/radio-receiver")
+        private val lookup = MethodHandles.lookup()
     }
-}
\ No newline at end of file
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
new file mode 100644
index 0000000..a1dd5d5
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
@@ -0,0 +1,115 @@
+package space.autistic.radio.client.gui
+
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.gui.widget.EditBoxWidget
+import net.minecraft.client.gui.widget.TextFieldWidget
+import net.minecraft.client.util.NarratorManager
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.screen.ScreenTexts
+import net.minecraft.text.Text
+import net.minecraft.util.Colors
+import net.minecraft.util.Hand
+import space.autistic.radio.PirateRadioComponents
+import space.autistic.radio.network.StorageCardUpdateC2SPayload
+
+class StorageCardEditScreen(
+    private val player: PlayerEntity, private val storageCard: ItemStack, private val hand: Hand
+) : Screen(NarratorManager.EMPTY) {
+    private var dirty = false
+    private var frequency = 768
+    private lateinit var editBox: EditBoxWidget
+    private lateinit var frequencyBox: TextFieldWidget
+    private lateinit var doneButton: ButtonWidget
+
+    override fun init() {
+        if (!this::editBox.isInitialized) {
+            editBox = EditBoxWidget(
+                textRenderer,
+                (width - WIDTH) / 2,
+                (height - HEIGHT) / 2,
+                WIDTH,
+                HEIGHT,
+                Text.translatable("pirate-radio.message"),
+                Text.translatable("pirate-radio.message")
+            )
+            editBox.text = storageCard.get(PirateRadioComponents.MESSAGE)?.literalString ?: ""
+            editBox.setMaxLength(16384)
+            editBox.setChangeListener {
+                dirty = true
+            }
+        }
+        frequency = storageCard.getOrDefault(PirateRadioComponents.FREQUENCY, 768)
+        if (!this::frequencyBox.isInitialized) {
+            frequencyBox =
+                TextFieldWidget(textRenderer, FREQ_WIDTH, FREQ_HEIGHT, Text.translatable("pirate-radio.frequency.edit"))
+            frequencyBox.setMaxLength(5)
+            frequencyBox.text = (frequency / 10).toString() + "." + (frequency % 10).toString()
+            frequencyBox.setTextPredicate {
+                FREQ_REGEX_CHARACTERS.matches(it)
+            }
+            frequencyBox.setChangedListener {
+                if (FREQ_REGEX.matches(it)) {
+                    frequency = it.replace(".", "").toInt()
+                    frequencyBox.setEditableColor(Colors.WHITE)
+                    dirty = true
+                } else {
+                    frequencyBox.setEditableColor(Colors.RED)
+                }
+            }
+        }
+        editBox.x = (width - WIDTH) / 2
+        editBox.y = (height - HEIGHT) / 2
+        frequencyBox.x = editBox.x
+        frequencyBox.y = editBox.y - FREQ_HEIGHT
+        addDrawableChild(frequencyBox)
+        addDrawableChild(editBox)
+        doneButton = this.addDrawableChild(
+            ButtonWidget.builder(ScreenTexts.DONE) {
+                client!!.setScreen(null)
+                this.saveChanges()
+            }.dimensions(editBox.x + editBox.width - 98, frequencyBox.y, 98, 20).build()
+        )
+    }
+
+    override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.render(context, mouseX, mouseY, delta)
+
+        context.drawText(
+            textRenderer,
+            Text.translatable("pirate-radio.frequency.edit"),
+            frequencyBox.x,
+            frequencyBox.y - textRenderer.fontHeight,
+            Colors.WHITE,
+            true
+        )
+    }
+
+    private fun saveChanges() {
+        if (this.dirty) {
+            this.writeNbtData()
+            val slot = if (this.hand == Hand.MAIN_HAND) player.inventory.selectedSlot else 40
+            ClientPlayNetworking.send(StorageCardUpdateC2SPayload(slot, this.editBox.text, this.frequency))
+        }
+    }
+
+    private fun writeNbtData() {
+        this.storageCard.set(PirateRadioComponents.MESSAGE, Text.literal(this.editBox.text))
+        this.storageCard.set(PirateRadioComponents.FREQUENCY, this.frequency)
+    }
+
+    companion object {
+        const val WIDTH = 192
+        const val HEIGHT = 144
+
+        const val FREQ_WIDTH = 40
+        const val FREQ_HEIGHT = 20
+
+        val FREQ_REGEX = Regex("^76\\.[8-9]|7[7-9]\\.[0-9]|[8-9][0-9]\\.[0-9]|10[0-7]\\.[0-9]|108\\.0$")
+        val FREQ_REGEX_CHARACTERS = Regex("^[0-9]*\\.?[0-9]?$")
+
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
new file mode 100644
index 0000000..b69cc42
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
@@ -0,0 +1,68 @@
+package space.autistic.radio.client.sound
+
+import net.fabricmc.fabric.api.client.sound.v1.FabricSoundInstance
+import net.minecraft.client.network.ClientPlayerEntity
+import net.minecraft.client.sound.*
+import net.minecraft.sound.SoundCategory
+import net.minecraft.sound.SoundEvents
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.client.PirateRadioClient
+import space.autistic.radio.entity.DisposableTransmitterEntity
+import java.util.concurrent.CompletableFuture
+
+class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingSoundInstance(
+    SoundEvents.INTENTIONALLY_EMPTY, SoundCategory.MUSIC, SoundInstance.createRandom()
+) {
+
+    init {
+        this.repeat = false
+        this.repeatDelay = 0
+        this.volume = 1f
+        this.pitch = 1f
+        this.relative = true
+    }
+
+    override fun tick() {
+        if (player.isRemoved) {
+            this.setDone()
+            return
+        }
+        @Suppress("UNCHECKED_CAST") val trackedEntities: List<DisposableTransmitterEntity> =
+            player.clientWorld.entities.filter { it.type == PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER }
+                .filter { (it as DisposableTransmitterEntity).frequency <= PirateRadioClient.frequency + 1 && it.frequency >= PirateRadioClient.frequency - 1 }
+                .sortedByDescending { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity>
+        val main = trackedEntities.filter { it.frequency == PirateRadioClient.frequency }.take(2)
+        val lower = trackedEntities.find { it.frequency == PirateRadioClient.frequency - 1 }
+        val upper = trackedEntities.find { it.frequency == PirateRadioClient.frequency + 1 }
+        // TODO implement
+    }
+
+    override fun getAudioStream(
+        loader: SoundLoader?, id: Identifier?, repeatInstantly: Boolean
+    ): CompletableFuture<AudioStream> {
+        // TODO setup thread
+        return CompletableFuture.completedFuture(ReceiverAudioStream)
+    }
+
+    override fun getVolume(): Float {
+        return this.volume
+    }
+
+    override fun getPitch(): Float {
+        return this.pitch
+    }
+
+    override fun getSound(): Sound {
+        return Sound(
+            FabricSoundInstance.EMPTY_SOUND,
+            { 1f },
+            { 1f },
+            1,
+            Sound.RegistrationType.SOUND_EVENT,
+            true,
+            false,
+            16
+        )
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
new file mode 100644
index 0000000..5ca802b
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
@@ -0,0 +1,34 @@
+package space.autistic.radio.client.sound
+
+import it.unimi.dsi.fastutil.floats.FloatConsumer
+import net.minecraft.client.sound.AudioStream
+import net.minecraft.client.sound.ChannelList
+import java.nio.ByteBuffer
+import javax.sound.sampled.AudioFormat
+
+object ReceiverAudioStream : AudioStream {
+    private val format = AudioFormat(48000f, 16, 2, true, false)
+
+    override fun close() {
+        // TODO, nop for now, should stop the processing
+    }
+
+    override fun getFormat(): AudioFormat {
+        return format
+    }
+
+    override fun read(size: Int): ByteBuffer {
+        val channelList = ChannelList(size + 8192)
+
+        while (this.read(channelList) && channelList.currentBufferSize < size) {
+        }
+
+        return channelList.buffer
+    }
+
+    private fun read(channelList: FloatConsumer): Boolean {
+        channelList.accept(0f)
+        channelList.accept(0f)
+        return true
+    }
+}
\ No newline at end of file
diff --git a/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b b/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b
index 072c021..d246b4f 100644
--- a/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b
+++ b/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b
@@ -1,3 +1,3 @@
-// 1.21.1	2025-02-09T00:02:42.294183715	Pirate Radio/Recipes
-84f8cd2b2d9d1afcf2a5cf000905c264a6d8267c data/pirate-radio/recipe/disposable-transmitter.json
+// 1.21.1	2025-03-14T18:16:59.621431904	Pirate Radio/Recipes
+368c94ec69c8320836c81014b1cfeab0742cb6e8 data/pirate-radio/recipe/disposable-transmitter.json
 86e73a1d034dc407ce65e0e61af19b1db43e1939 data/pirate-radio/advancement/recipes/misc/disposable-transmitter.json
diff --git a/src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c72 b/src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c72
index cf1f8c7..8e7e12b 100644
--- a/src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c72
+++ b/src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c72
@@ -1,5 +1,4 @@
-// 1.21.1	2025-02-09T00:02:42.294917543	Pirate Radio/Model Definitions
-3507512497435bf1047ebd71ae1f4881ceb67f44 assets/pirate-radio/models/item/fm-receiver.json
+// 1.21.1	2025-03-14T18:16:59.623037325	Pirate Radio/Model Definitions
 ab60b602066c94b5746065e1b139a383a6c26429 assets/pirate-radio/models/item/powerbank.json
 fb8af1b0939020c3a89a7736e47d9f688b38a2c9 assets/pirate-radio/models/item/storage-card.json
 dbc04d664dacd99a76580bcff2c5b944abb0730e assets/pirate-radio/models/item/sbc.json
diff --git a/src/main/generated/assets/pirate-radio/models/item/fm-receiver.json b/src/main/generated/assets/pirate-radio/models/item/fm-receiver.json
deleted file mode 100644
index 71813c4..0000000
--- a/src/main/generated/assets/pirate-radio/models/item/fm-receiver.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "parent": "minecraft:item/generated",
-  "textures": {
-    "layer0": "pirate-radio:item/fm-receiver"
-  }
-}
\ No newline at end of file
diff --git a/src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json b/src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json
index 2a1d645..87b9be6 100644
--- a/src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json
+++ b/src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json
@@ -10,9 +10,6 @@
     },
     {
       "item": "pirate-radio:powerbank"
-    },
-    {
-      "item": "pirate-radio:storage-card"
     }
   ],
   "result": {
diff --git a/src/main/kotlin/space/autistic/radio/CommonProxy.kt b/src/main/kotlin/space/autistic/radio/CommonProxy.kt
new file mode 100644
index 0000000..76a4b22
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/CommonProxy.kt
@@ -0,0 +1,10 @@
+package space.autistic.radio
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+
+open class CommonProxy : SidedProxy {
+    override fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand) {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadio.kt b/src/main/kotlin/space/autistic/radio/PirateRadio.kt
index 54d0b9f..d463e41 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadio.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadio.kt
@@ -1,17 +1,58 @@
 package space.autistic.radio
 
+import com.mojang.serialization.Codec
 import net.fabricmc.api.ModInitializer
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries
+import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking
+import net.minecraft.item.ItemStack
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
 import org.slf4j.LoggerFactory
+import space.autistic.radio.antenna.Antenna
+import space.autistic.radio.antenna.PirateRadioAntennaSerializers
+import space.autistic.radio.network.StorageCardUpdateC2SPayload
+import kotlin.math.max
+import kotlin.math.min
 
 object PirateRadio : ModInitializer {
-	const val MOD_ID = "pirate-radio"
-	private val logger = LoggerFactory.getLogger(MOD_ID)
+    var proxy: SidedProxy? = null
+    const val MOD_ID = "pirate-radio"
+    val logger = LoggerFactory.getLogger(MOD_ID)
 
-	override fun onInitialize() {
-		logger.info("This project is made with love by a queer trans person.\n" +
-				"The folks of these identities who have contributed to the project would like to make their identities known:\n" +
-				"Autgender; Not a person; Anticapitalist; Genderqueer; Trans.")
-		PirateRadioItems.initialize()
-		PirateRadioEntityTypes.initialize()
-	}
+
+    override fun onInitialize() {
+        if (proxy == null) {
+            proxy = CommonProxy()
+        }
+        logger.info(
+            "This project is made with love by a queer trans person.\n" +
+                    "The folks of these identities who have contributed to the project would like to make their identities known:\n" +
+                    "Autgender; Not a person; Anticapitalist; Genderqueer; Trans."
+        )
+        PirateRadioRegistries.initialize()
+        PirateRadioAntennaSerializers.initialize()
+        DynamicRegistries.registerSynced(PirateRadioRegistryKeys.ANTENNA, Antenna.CODEC)
+        PayloadTypeRegistry.playC2S()
+            .register(StorageCardUpdateC2SPayload.PAYLOAD_ID, StorageCardUpdateC2SPayload.CODEC)
+        ServerPlayNetworking.registerGlobalReceiver(StorageCardUpdateC2SPayload.PAYLOAD_ID) { payload, context ->
+            if (!context.player().isAlive) {
+                return@registerGlobalReceiver
+            }
+            val stack = context.player().inventory.getStack(payload.slot)
+            if (stack.isOf(PirateRadioItems.STORAGE_CARD)) {
+                stack.set(PirateRadioComponents.FREQUENCY, min(1080, max(768, payload.frequency)))
+                var text = payload.text
+                if (text.length > 16384) {
+                    text = text.substring(0, 16384)
+                }
+                stack.set(PirateRadioComponents.MESSAGE, Text.literal(text))
+            }
+        }
+        PirateRadioComponents.initialize()
+        PirateRadioItems.initialize()
+        PirateRadioEntityTypes.initialize()
+    }
 }
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt b/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt
new file mode 100644
index 0000000..f79f26a
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt
@@ -0,0 +1,30 @@
+package space.autistic.radio
+
+import net.minecraft.component.ComponentType
+import net.minecraft.network.codec.PacketCodecs
+import net.minecraft.registry.Registries
+import net.minecraft.registry.Registry
+import net.minecraft.text.Text
+import net.minecraft.text.TextCodecs
+import net.minecraft.util.Identifier
+import net.minecraft.util.dynamic.Codecs
+
+object PirateRadioComponents {
+    val FREQUENCY = Registry.register(
+        Registries.DATA_COMPONENT_TYPE,
+        Identifier.of(PirateRadio.MOD_ID, "frequency"),
+        ComponentType.builder<Int>().codec(
+            Codecs.rangedInt(768, 1080)
+        ).packetCodec(PacketCodecs.VAR_INT).build()
+    )
+
+    val MESSAGE = Registry.register(
+        Registries.DATA_COMPONENT_TYPE,
+        Identifier.of(PirateRadio.MOD_ID, "message"),
+        ComponentType.builder<Text>().codec(TextCodecs.STRINGIFIED_CODEC).packetCodec(TextCodecs.REGISTRY_PACKET_CODEC)
+            .cache().build()
+    )
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt b/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
index f147394..3fbb34f 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
@@ -11,13 +11,31 @@ import net.minecraft.registry.RegistryKey
 import net.minecraft.registry.RegistryKeys
 import net.minecraft.util.Identifier
 import space.autistic.radio.entity.ElectronicsTraderEntity
+import space.autistic.radio.entity.DisposableTransmitterEntity
 
 object PirateRadioEntityTypes {
-    val ELECTRONICS_TRADER_KEY = RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "electronics-trader"))
-    val ELECTRONICS_TRADER = register(EntityType.Builder.create(::ElectronicsTraderEntity, SpawnGroup.MISC).dimensions(0.6F, 1.95F).eyeHeight(1.62F).maxTrackingRange(10), ELECTRONICS_TRADER_KEY)
+    val ELECTRONICS_TRADER_KEY =
+        RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "electronics-trader"))
+    val ELECTRONICS_TRADER = register(
+        EntityType.Builder.create(::ElectronicsTraderEntity, SpawnGroup.MISC).dimensions(0.6F, 1.95F).eyeHeight(1.62F)
+            .maxTrackingRange(10), ELECTRONICS_TRADER_KEY
+    )
 
-    fun <T : Entity> register(entityTypeBuilder: EntityType.Builder<T>, registryKey: RegistryKey<EntityType<*>>): EntityType<T> {
-        return Registry.register(Registries.ENTITY_TYPE, registryKey.value, entityTypeBuilder.build(registryKey.value.path))
+    val DISPOSABLE_TRANSMITTER_KEY = RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
+    val DISPOSABLE_TRANSMITTER = register(
+        EntityType.Builder.create(::DisposableTransmitterEntity, SpawnGroup.MISC).dimensions(0.5F, 0.5F).eyeHeight(0F)
+            .maxTrackingRange(10).trackingTickInterval(Integer.MAX_VALUE), DISPOSABLE_TRANSMITTER_KEY
+    )
+
+    fun <T : Entity> register(
+        entityTypeBuilder: EntityType.Builder<T>,
+        registryKey: RegistryKey<EntityType<*>>
+    ): EntityType<T> {
+        return Registry.register(
+            Registries.ENTITY_TYPE,
+            registryKey.value,
+            entityTypeBuilder.build(registryKey.value.path)
+        )
     }
 
     fun initialize() {
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
index 490acaf..e00e4e6 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
@@ -2,12 +2,16 @@ package space.autistic.radio
 
 import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents
 import net.minecraft.item.Item
+import net.minecraft.item.ItemFrameItem
 import net.minecraft.item.ItemGroups
 import net.minecraft.registry.Registries
 import net.minecraft.registry.Registry
 import net.minecraft.registry.RegistryKey
 import net.minecraft.registry.RegistryKeys
+import net.minecraft.text.Text
 import net.minecraft.util.Identifier
+import space.autistic.radio.item.DisposableTransmitterItem
+import space.autistic.radio.item.StorageCardItem
 
 object PirateRadioItems {
     val SBC_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "sbc"))
@@ -17,11 +21,20 @@ object PirateRadioItems {
     val POWERBANK_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "powerbank"))
     val POWERBANK = register(Item(Item.Settings()), POWERBANK_KEY)
     val STORAGE_CARD_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "storage-card"))
-    val STORAGE_CARD = register(Item(Item.Settings()), STORAGE_CARD_KEY)
-    val DISPOSABLE_TRANSMITTER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
-    val DISPOSABLE_TRANSMITTER = register(Item(Item.Settings()), DISPOSABLE_TRANSMITTER_KEY)
-    val FM_RECEIVER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "fm-receiver"))
-    val FM_RECEIVER = register(Item(Item.Settings()), FM_RECEIVER_KEY)
+    val STORAGE_CARD = register(
+        StorageCardItem(
+            Item.Settings().maxCount(1).component(PirateRadioComponents.FREQUENCY, 768)
+                .component(PirateRadioComponents.MESSAGE, Text.literal(""))
+        ), STORAGE_CARD_KEY
+    )
+    val DISPOSABLE_TRANSMITTER_KEY =
+        RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
+    val DISPOSABLE_TRANSMITTER = register(
+        DisposableTransmitterItem(PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER, Item.Settings()),
+        DISPOSABLE_TRANSMITTER_KEY
+    )
+//    val FM_RECEIVER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "fm-receiver"))
+//    val FM_RECEIVER = register(Item(Item.Settings()), FM_RECEIVER_KEY)
 
     fun register(item: Item, registryKey: RegistryKey<Item>): Item {
         return Registry.register(Registries.ITEM, registryKey.value, item)
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt b/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt
new file mode 100644
index 0000000..6010d3c
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt
@@ -0,0 +1,12 @@
+package space.autistic.radio
+
+import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder
+import net.fabricmc.fabric.api.event.registry.RegistryAttribute
+
+object PirateRadioRegistries {
+    val ANTENNA_SERIALIZER = FabricRegistryBuilder.createSimple(PirateRadioRegistryKeys.ANTENNA_SERIALIZER)
+        .attribute(RegistryAttribute.SYNCED).buildAndRegister()
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt b/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt
new file mode 100644
index 0000000..eb5db1f
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt
@@ -0,0 +1,13 @@
+package space.autistic.radio
+
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio.MOD_ID
+import space.autistic.radio.antenna.Antenna
+import space.autistic.radio.antenna.AntennaSerializer
+
+object PirateRadioRegistryKeys {
+    val ANTENNA_SERIALIZER = RegistryKey.ofRegistry<AntennaSerializer<*>>(Identifier.of(MOD_ID, "antenna_serializer"))
+    val ANTENNA = RegistryKey.ofRegistry<Antenna<*>>(Identifier.of(MOD_ID, "antenna"))
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/SidedProxy.kt b/src/main/kotlin/space/autistic/radio/SidedProxy.kt
new file mode 100644
index 0000000..0e34ec9
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/SidedProxy.kt
@@ -0,0 +1,9 @@
+package space.autistic.radio
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+
+interface SidedProxy {
+    fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt b/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt
new file mode 100644
index 0000000..c403081
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt
@@ -0,0 +1,9 @@
+package space.autistic.radio.antenna
+
+import space.autistic.radio.PirateRadioRegistries
+
+data class Antenna<T>(val type: AntennaSerializer<T>, val data: T) {
+    companion object {
+        val CODEC = PirateRadioRegistries.ANTENNA_SERIALIZER.codec.dispatch({ it.type }, AntennaSerializer<*>::codec)
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt b/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt
new file mode 100644
index 0000000..11d0234
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt
@@ -0,0 +1,8 @@
+package space.autistic.radio.antenna
+
+import com.mojang.serialization.MapCodec
+
+interface AntennaSerializer<T> {
+    val codec: MapCodec<Antenna<T>>
+        get
+}
diff --git a/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt b/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt
new file mode 100644
index 0000000..401972c
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.antenna
+
+import com.mojang.serialization.Codec
+
+object ConstAntenna : AntennaSerializer<Float> {
+    override val codec = Codec.FLOAT.fieldOf("level").xmap({ Antenna(this, it) }, { it.data })
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt b/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt
new file mode 100644
index 0000000..19cfff8
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt
@@ -0,0 +1,18 @@
+package space.autistic.radio.antenna
+
+import net.minecraft.registry.Registry
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.PirateRadioRegistries
+
+object PirateRadioAntennaSerializers {
+    val CONST = register(Identifier.of(PirateRadio.MOD_ID, "const"), ConstAntenna)
+    val WASM = register(Identifier.of(PirateRadio.MOD_ID, "wasm"), WasmAntenna)
+
+    private fun <T> register(id: Identifier, antennaSerializer: AntennaSerializer<T>): AntennaSerializer<T> {
+        return Registry.register(PirateRadioRegistries.ANTENNA_SERIALIZER, id, antennaSerializer)
+    }
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt b/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt
new file mode 100644
index 0000000..32c96bb
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.antenna
+
+import net.minecraft.util.Identifier
+
+object WasmAntenna : AntennaSerializer<Identifier> {
+    override val codec = Identifier.CODEC.fieldOf("model").xmap({ Antenna(this, it) }, { it.data })
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt b/src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt
new file mode 100644
index 0000000..fe26a86
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt
@@ -0,0 +1,178 @@
+package space.autistic.radio.entity
+
+import net.minecraft.entity.Entity
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.data.DataTracker
+import net.minecraft.entity.data.TrackedDataHandlerRegistry
+import net.minecraft.entity.decoration.AbstractDecorationEntity
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.network.listener.ClientPlayPacketListener
+import net.minecraft.network.packet.Packet
+import net.minecraft.network.packet.s2c.play.EntitySpawnS2CPacket
+import net.minecraft.server.network.EntityTrackerEntry
+import net.minecraft.util.ActionResult
+import net.minecraft.util.Hand
+import net.minecraft.util.math.*
+import net.minecraft.world.World
+import net.minecraft.world.event.GameEvent
+import space.autistic.radio.PirateRadioComponents
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.PirateRadioItems
+import kotlin.math.max
+import kotlin.math.min
+
+class DisposableTransmitterEntity : AbstractDecorationEntity {
+    var despawnDelay = 60 * 60 * 20
+
+    constructor(type: EntityType<out DisposableTransmitterEntity>?, world: World?) : super(type, world)
+
+    constructor(
+        type: EntityType<out DisposableTransmitterEntity>?,
+        world: World?,
+        pos: BlockPos,
+        facing: Direction
+    ) : super(
+        type, world, pos
+    ) {
+        this.setFacing(facing)
+    }
+
+    constructor(
+        world: World,
+        blockPos2: BlockPos,
+        direction: Direction
+    ) : this(PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER, world, blockPos2, direction)
+
+    override fun initDataTracker(builder: DataTracker.Builder) {
+        builder.add(TEXT, "")
+        builder.add(FREQUENCY, 768)
+    }
+
+    override fun tick() {
+        super.tick()
+        if (!world.isClient) {
+            this.tickDespawnDelay();
+        }
+    }
+
+    private fun tickDespawnDelay() {
+        if (this.despawnDelay > 0 && this.despawnDelay-- == 0) {
+            this.discard()
+        }
+    }
+
+    override fun setFacing(facing: Direction) {
+        this.facing = facing
+        if (facing.axis.isHorizontal) {
+            this.pitch = 0.0f
+            this.yaw = (this.facing.horizontal * 90).toFloat()
+        } else {
+            this.pitch = (-90 * facing.direction.offset()).toFloat()
+            this.yaw = 0.0f
+        }
+
+        this.prevPitch = this.pitch
+        this.prevYaw = this.yaw
+        this.updateAttachmentPosition()
+    }
+
+    override fun calculateBoundingBox(pos: BlockPos, side: Direction): Box {
+        val center = Vec3d.ofCenter(pos).offset(side, -(1.0 - DEPTH) / 2.0)
+        val axis = side.axis
+        val dx = if (axis === Direction.Axis.X) DEPTH else WIDTH
+        val dy = if (axis === Direction.Axis.Y) DEPTH else HEIGHT
+        val dz = if (axis === Direction.Axis.Z) DEPTH else WIDTH
+        return Box.of(center, dx, dy, dz)
+    }
+
+    // leave this true for performance
+    override fun canStayAttached(): Boolean = true
+
+    override fun onBreak(breaker: Entity?) {
+        // hmm, what to do here...
+    }
+
+    override fun onPlace() {
+        // hmm, what to do here...
+    }
+
+    var text: String
+        get() {
+            return this.dataTracker[TEXT]
+        }
+        set(value) {
+            this.dataTracker[TEXT] = value
+        }
+
+    var frequency: Int
+        get() {
+            return this.dataTracker[FREQUENCY]
+        }
+        set(value) {
+            this.dataTracker[FREQUENCY] = value
+        }
+
+    override fun writeCustomDataToNbt(nbt: NbtCompound) {
+        super.writeCustomDataToNbt(nbt)
+        nbt.putByte("Facing", this.facing.id.toByte())
+        nbt.putInt("DespawnDelay", this.despawnDelay)
+        nbt.putString("Text", this.text)
+        nbt.putBoolean("Invisible", this.isInvisible)
+        nbt.putInt("Frequency", this.frequency)
+    }
+
+    override fun readCustomDataFromNbt(nbt: NbtCompound) {
+        super.readCustomDataFromNbt(nbt)
+        this.despawnDelay = nbt.getInt("DespawnDelay")
+        this.setFacing(Direction.byId(nbt.getByte("Facing").toInt()))
+        this.text = nbt.getString("Text")
+        this.isInvisible = nbt.getBoolean("Invisible")
+        this.frequency = min(1080, max(768, nbt.getInt("Frequency")))
+    }
+
+    override fun createSpawnPacket(entityTrackerEntry: EntityTrackerEntry): Packet<ClientPlayPacketListener> {
+        return EntitySpawnS2CPacket(this, facing.id, this.getAttachedBlockPos())
+    }
+
+    override fun onSpawnPacket(packet: EntitySpawnS2CPacket) {
+        super.onSpawnPacket(packet)
+        this.setFacing(Direction.byId(packet.entityData))
+    }
+
+    override fun getBodyYaw(): Float {
+        val direction = this.horizontalFacing
+        val i = if (direction.axis.isVertical) 90 * direction.direction.offset() else 0
+        return MathHelper.wrapDegrees(180 + direction.horizontal * 90 + i).toFloat()
+    }
+
+    override fun interact(player: PlayerEntity, hand: Hand): ActionResult {
+        val itemStack = player.getStackInHand(hand)
+        val noTextInTransmitter = this.text.isEmpty()
+        val isCard = itemStack.isOf(PirateRadioItems.STORAGE_CARD)
+        val holdingMessage = isCard && (itemStack.get(PirateRadioComponents.MESSAGE)?.literalString ?: "").isNotEmpty()
+        if (!world.isClient) {
+            if (noTextInTransmitter) {
+                if (holdingMessage && !this.isRemoved) {
+                    this.frequency = itemStack.getOrDefault(PirateRadioComponents.FREQUENCY, 768)
+                    this.text = itemStack.get(PirateRadioComponents.MESSAGE)?.literalString ?: ""
+                    this.emitGameEvent(GameEvent.BLOCK_CHANGE, player)
+                    itemStack.decrementUnlessCreative(1, player)
+                }
+            }
+            return ActionResult.CONSUME
+        } else {
+            return if (noTextInTransmitter && holdingMessage) ActionResult.SUCCESS else ActionResult.PASS
+        }
+    }
+
+    companion object {
+        private val TEXT =
+            DataTracker.registerData(DisposableTransmitterEntity::class.java, TrackedDataHandlerRegistry.STRING)
+        private val FREQUENCY =
+            DataTracker.registerData(DisposableTransmitterEntity::class.java, TrackedDataHandlerRegistry.INTEGER)
+        const val DEPTH = 0.0625
+        private const val WIDTH = 0.75
+        private const val HEIGHT = 0.75
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt b/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
index 3aa53b1..1acbeb4 100644
--- a/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
+++ b/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
@@ -21,7 +21,7 @@ class ElectronicsTraderEntity(entityType: EntityType<out ElectronicsTraderEntity
     override fun fillRecipes() {
         val offers = this.getOffers()
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 5), ItemStack(PirateRadioItems.POWERBANK), 3, 0, 0f))
-        offers.add(TradeOffer(TradedItem(Items.EMERALD, 10), ItemStack(PirateRadioItems.FM_RECEIVER), 3, 0, 0f))
+//        offers.add(TradeOffer(TradedItem(Items.EMERALD, 10), ItemStack(PirateRadioItems.FM_RECEIVER), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 15), ItemStack(PirateRadioItems.SBC), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 5), ItemStack(PirateRadioItems.STORAGE_CARD), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 1), ItemStack(PirateRadioItems.WIRE), 3, 0, 0f))
@@ -29,7 +29,7 @@ class ElectronicsTraderEntity(entityType: EntityType<out ElectronicsTraderEntity
 
     override fun tickMovement() {
         if (!this.world.isClient) {
-            super.setDespawnDelay(1000)
+            super.setDespawnDelay(-1)
         }
         super.tickMovement()
     }
diff --git a/src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt b/src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt
new file mode 100644
index 0000000..c9a53e4
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt
@@ -0,0 +1,56 @@
+package space.autistic.radio.item
+
+import net.minecraft.component.DataComponentTypes
+import net.minecraft.component.type.NbtComponent
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.decoration.AbstractDecorationEntity
+import net.minecraft.item.ItemFrameItem
+import net.minecraft.item.ItemUsageContext
+import net.minecraft.util.ActionResult
+import net.minecraft.world.event.GameEvent
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.entity.DisposableTransmitterEntity
+
+class DisposableTransmitterItem(
+    private val entityType: EntityType<out AbstractDecorationEntity>?,
+    settings: Settings?
+) :
+    ItemFrameItem(entityType, settings) {
+
+    override fun useOnBlock(context: ItemUsageContext): ActionResult {
+        val blockPos = context.blockPos
+        val direction = context.side
+        val blockPos2 = blockPos.offset(direction)
+        val playerEntity = context.player
+        val itemStack = context.stack
+        if (playerEntity != null && !this.canPlaceOn(playerEntity, direction, itemStack, blockPos2)) {
+            return ActionResult.FAIL
+        } else {
+            val world = context.world
+            val abstractDecorationEntity: AbstractDecorationEntity
+            if (this.entityType === PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER) {
+                abstractDecorationEntity = DisposableTransmitterEntity(world, blockPos2, direction)
+            } else {
+                return ActionResult.success(world.isClient)
+            }
+
+            val nbtComponent = itemStack.getOrDefault(DataComponentTypes.ENTITY_DATA, NbtComponent.DEFAULT)
+            if (!nbtComponent.isEmpty) {
+                EntityType.loadFromEntityNbt(world, playerEntity, abstractDecorationEntity, nbtComponent)
+            }
+
+            if (abstractDecorationEntity.canStayAttached()) {
+                if (!world.isClient) {
+                    abstractDecorationEntity.onPlace()
+                    world.emitGameEvent(playerEntity, GameEvent.ENTITY_PLACE, abstractDecorationEntity.pos)
+                    world.spawnEntity(abstractDecorationEntity)
+                }
+
+                itemStack.decrement(1)
+                return ActionResult.success(world.isClient)
+            } else {
+                return ActionResult.CONSUME
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt b/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt
new file mode 100644
index 0000000..da1b057
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt
@@ -0,0 +1,20 @@
+package space.autistic.radio.item
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.Item
+import net.minecraft.item.ItemStack
+import net.minecraft.stat.Stats
+import net.minecraft.util.Hand
+import net.minecraft.util.TypedActionResult
+import net.minecraft.world.World
+import space.autistic.radio.PirateRadio
+
+class StorageCardItem(settings: Settings) : Item(settings) {
+
+    override fun use(world: World, user: PlayerEntity, hand: Hand): TypedActionResult<ItemStack> {
+        val itemStack = user.getStackInHand(hand)
+        PirateRadio.proxy!!.useStorageCard(user, itemStack, hand)
+        user.incrementStat(Stats.USED.getOrCreateStat(this))
+        return TypedActionResult.success(itemStack, world.isClient())
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt b/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt
new file mode 100644
index 0000000..9ffa75a
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt
@@ -0,0 +1,27 @@
+package space.autistic.radio.network
+
+import net.minecraft.network.RegistryByteBuf
+import net.minecraft.network.codec.PacketCodec
+import net.minecraft.network.codec.PacketCodecs
+import net.minecraft.network.packet.CustomPayload
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+
+@JvmRecord
+data class StorageCardUpdateC2SPayload(val slot: Int, val text: String, val frequency: Int) : CustomPayload {
+    override fun getId(): CustomPayload.Id<out CustomPayload> = PAYLOAD_ID
+
+    companion object {
+        val PAYLOAD_IDENTIFIER = Identifier.of(PirateRadio.MOD_ID, "storage-card-update")
+        val PAYLOAD_ID = CustomPayload.Id<StorageCardUpdateC2SPayload>(PAYLOAD_IDENTIFIER)
+        val CODEC: PacketCodec<RegistryByteBuf, StorageCardUpdateC2SPayload> = PacketCodec.tuple(
+            PacketCodecs.VAR_INT,
+            StorageCardUpdateC2SPayload::slot,
+            PacketCodecs.STRING,
+            StorageCardUpdateC2SPayload::text,
+            PacketCodecs.VAR_INT,
+            StorageCardUpdateC2SPayload::frequency,
+            ::StorageCardUpdateC2SPayload
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/wasm/Bindings.kt b/src/main/kotlin/space/autistic/radio/wasm/Bindings.kt
new file mode 100644
index 0000000..cc123a1
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/wasm/Bindings.kt
@@ -0,0 +1,173 @@
+package space.autistic.radio.wasm
+
+import com.dylibso.chicory.runtime.HostFunction
+import com.dylibso.chicory.runtime.ImportFunction
+import com.dylibso.chicory.runtime.Instance
+import com.dylibso.chicory.wasm.types.Value
+import com.dylibso.chicory.wasm.types.ValueType
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.reflect.Method
+import java.lang.reflect.ParameterizedType
+
+class Bindings {
+
+    companion object {
+        @JvmStatic
+        fun longToInt(long: Long): Int = long.toInt()
+
+        @JvmStatic
+        fun stringArg(instance: Instance, address: Long): String {
+            return instance.memory().readCString(address.toInt())
+        }
+
+        @JvmStatic
+        fun boolArg(bool: Long): Boolean {
+            return bool != 0L
+        }
+
+        @JvmStatic
+        fun intListArg(instance: Instance, argc: Long, argv: Long): List<Int> {
+            return IntArray(argc.toInt()) {
+                instance.memory().readInt(argv.toInt() + 4 * it)
+            }.toList()
+        }
+
+        private val lookup = MethodHandles.lookup()
+        fun bindFunc(
+            module: String,
+            name: String,
+            inLookup: MethodHandles.Lookup,
+            method: Method,
+            receiver: Any
+        ): ImportFunction {
+            val baseHandle = inLookup.unreflect(method).bindTo(receiver)
+            val wasmParameters = ArrayList<ValueType>()
+            val filters = method.genericParameterTypes.map {
+                when (it) {
+                    Int::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java, "longToInt", MethodType.methodType(Int::class.java, Long::class.java)
+                        )
+                    }
+
+                    Long::class.java -> {
+                        wasmParameters.add(ValueType.I64)
+                        MethodHandles.identity(Long::class.java)
+                    }
+
+                    Float::class.java -> {
+                        wasmParameters.add(ValueType.F32)
+                        lookup.findStatic(
+                            Value::class.java, "longToFloat", MethodType.methodType(Float::class.java, Long::class.java)
+                        )
+                    }
+
+                    Double::class.java -> {
+                        wasmParameters.add(ValueType.F64)
+                        lookup.findStatic(
+                            Value::class.java,
+                            "longToDouble",
+                            MethodType.methodType(Double::class.java, Long::class.java)
+                        )
+                    }
+
+                    String::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java,
+                            "stringArg",
+                            MethodType.methodType(String::class.java, Instance::class.java, Long::class.java)
+                        )
+                    }
+
+                    Boolean::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java,
+                            "boolArg",
+                            MethodType.methodType(Boolean::class.java, Long::class.java)
+                        )
+                    }
+
+                    is ParameterizedType -> {
+                        if (it.rawType == List::class.java) {
+                            val converter = when (it.actualTypeArguments[0]) {
+                                java.lang.Integer::class.java -> "intListArg"
+                                else -> throw IllegalArgumentException(it.actualTypeArguments[0].toString())
+                            }
+                            wasmParameters.add(ValueType.I32)
+                            wasmParameters.add(ValueType.I32)
+                            lookup.findStatic(
+                                Bindings::class.java,
+                                converter,
+                                MethodType.methodType(
+                                    List::class.java,
+                                    Instance::class.java,
+                                    Long::class.java,
+                                    Long::class.java
+                                )
+                            )
+                        } else {
+                            throw IllegalArgumentException(it.toString())
+                        }
+                    }
+
+                    else -> throw IllegalArgumentException(it.toString())
+                }
+            }
+            val filterTypes = ArrayList<Class<*>>()
+            filters.forEach { methodHandle ->
+                filterTypes.addAll(methodHandle.type().parameterList())
+            }
+            var i = 0
+            val permutation = IntArray(filterTypes.size) {
+                if (filterTypes[it] == Instance::class.java) 0 else ++i
+            }
+            var handle = baseHandle
+            var j = 0
+            filters.forEach {
+                handle = MethodHandles.collectArguments(handle, j, it)
+                j += it.type().parameterCount()
+            }
+            val newtype = MethodType.methodType(
+                baseHandle.type().returnType(), Instance::class.java, *Array(i) { Long::class.java })
+            handle = MethodHandles.permuteArguments(handle, newtype, *permutation)
+            handle = handle.asSpreader(LongArray::class.java, i)
+            return when (method.genericReturnType) {
+                Void.TYPE -> HostFunction(module, name, wasmParameters, emptyList()) { instance, args ->
+                    handle.invokeExact(instance, args)
+                    Value.EMPTY_VALUES
+                }
+
+                Int::class.java -> HostFunction(module, name, wasmParameters, listOf(ValueType.I32)) { instance, args ->
+                    val result: Int = handle.invokeExact(instance, args) as Int
+                    longArrayOf(result.toLong())
+                }
+
+                Boolean::class.java -> HostFunction(
+                    module,
+                    name,
+                    wasmParameters,
+                    listOf(ValueType.I32)
+                ) { instance, args ->
+                    val result: Boolean = handle.invokeExact(instance, args) as Boolean
+                    longArrayOf(if (result) 1L else 0L)
+                }
+
+                Float::class.java -> HostFunction(
+                    module,
+                    name,
+                    wasmParameters,
+                    listOf(ValueType.F32)
+                ) { instance, args ->
+                    val result: Float = handle.invokeExact(instance, args) as Float
+                    longArrayOf(Value.floatToLong(result))
+                }
+
+                else -> throw IllegalArgumentException(method.genericReturnType.toString())
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/resources/assets/pirate-radio/blockstates/disposable-transmitter.json b/src/main/resources/assets/pirate-radio/blockstates/disposable-transmitter.json
new file mode 100644
index 0000000..854abc8
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/blockstates/disposable-transmitter.json
@@ -0,0 +1,10 @@
+{
+  "variants": {
+    "facing=down": { "model": "pirate-radio:block/disposable-transmitter-vertical" },
+    "facing=up": { "model": "pirate-radio:block/disposable-transmitter-vertical" },
+    "facing=north": { "model": "pirate-radio:block/disposable-transmitter" },
+    "facing=south": { "model": "pirate-radio:block/disposable-transmitter" },
+    "facing=west": { "model": "pirate-radio:block/disposable-transmitter" },
+    "facing=east": { "model": "pirate-radio:block/disposable-transmitter" }
+  }
+}
\ No newline at end of file
diff --git a/src/main/resources/assets/pirate-radio/lang/en_us.json b/src/main/resources/assets/pirate-radio/lang/en_us.json
index 9627729..81145f0 100644
--- a/src/main/resources/assets/pirate-radio/lang/en_us.json
+++ b/src/main/resources/assets/pirate-radio/lang/en_us.json
@@ -6,5 +6,25 @@
   "item.pirate-radio.disposable-transmitter": "Disposable Pirate Radio Transmitter",
   "item.pirate-radio.fm-receiver": "FM Receiver",
   "entity.pirate-radio.electronics-trader": "Microcenter",
-  "pirate-radio.fm-receiver": "FM Receiver"
+  "pirate-radio.fm-receiver": "FM Receiver",
+  "pirate-radio.skin-pack": "Skin pack: %s",
+  "pirate-radio.frequency.plus": "+",
+  "pirate-radio.frequency.minus": "-",
+  "pirate-radio.volume.plus": "+",
+  "pirate-radio.volume.minus": "-",
+  "pirate-radio.mode": "Mode",
+  "pirate-radio.frequency.plus.narrated": "Increase Frequency",
+  "pirate-radio.frequency.minus.narrated": "Decrease Frequency",
+  "pirate-radio.volume.plus.narrated": "Increase Volume",
+  "pirate-radio.volume.minus.narrated": "Decrease Volume",
+  "pirate-radio.mode.selected": "Mode: %s",
+  "pirate-radio.mode.full": "Full",
+  "pirate-radio.mode.fast": "Fast",
+  "pirate-radio.mode.deaf": "Deaf",
+  "pirate-radio.volume.selected": "Volume: %s",
+  "pirate-radio.volume.off": "Off",
+  "pirate-radio.frequency.selected": "Frequency: %s.%s MHz",
+  "pirate-radio.storage-card": "SD Card",
+  "pirate-radio.message": "Message...",
+  "pirate-radio.frequency.edit": "Frequency"
 }
\ No newline at end of file
diff --git a/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter-vertical.json b/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter-vertical.json
new file mode 100644
index 0000000..39dc38e
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter-vertical.json
@@ -0,0 +1,29 @@
+{
+  "textures": {
+    "antenna": "minecraft:block/iron_block",
+    "body": "minecraft:block/coal_block"
+  },
+  "elements": [
+    {   "from": [ 6.5, 6.9, 15.4 ],
+      "to": [ 9.5, 9.1, 16 ],
+      "faces": {
+        "down":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "up":    { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "north": { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "south": { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "west":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "east":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" }
+      }
+    },
+    {   "from": [ 7, 7, 9 ],
+      "to": [ 7.1, 7.1, 15.4 ],
+      "faces": {
+        "down":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "up":    { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "north": { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "west":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "east":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" }
+      }
+    }
+  ]
+}
diff --git a/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter.json b/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter.json
new file mode 100644
index 0000000..f5e26dc
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/models/block/disposable-transmitter.json
@@ -0,0 +1,30 @@
+{
+  "textures": {
+    "antenna": "minecraft:block/iron_block",
+    "body": "minecraft:block/coal_block"
+  },
+  "elements": [
+    {   "from": [ 6.5, 0.9, 15.4 ],
+      "to": [ 9.5, 3.1, 16 ],
+      "faces": {
+        "down":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "up":    { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "north": { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "south": { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "west":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" },
+        "east":  { "uv": [  0,  0, 16, 16 ], "texture": "#body" }
+      }
+    },
+    {   "from": [ 7, 1, 15.3 ],
+      "to": [ 7.1, 7.5, 15.4 ],
+      "faces": {
+        "down":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "up":    { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "north": { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "south": { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "west":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" },
+        "east":  { "uv": [  0,  0, 16, 16 ], "texture": "#antenna" }
+      }
+    }
+  ]
+}
diff --git a/src/main/resources/assets/pirate-radio/textures/entity/electronics-trader.png b/src/main/resources/assets/pirate-radio/textures/entity/electronics-trader.png
new file mode 100644
index 0000000..b86a4ef
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/entity/electronics-trader.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/textures/gui/radio-receiver.png b/src/main/resources/assets/pirate-radio/textures/gui/radio-receiver.png
new file mode 100644
index 0000000..b86a4ef
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/gui/radio-receiver.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/textures/item/disposable-transmitter.png b/src/main/resources/assets/pirate-radio/textures/item/disposable-transmitter.png
new file mode 100644
index 0000000..c815369
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/item/disposable-transmitter.png
Binary files differdiff --git a/src/main/resources/data/pirate-radio/pirate-radio/antenna/const.json b/src/main/resources/data/pirate-radio/pirate-radio/antenna/const.json
new file mode 100644
index 0000000..6f7a630
--- /dev/null
+++ b/src/main/resources/data/pirate-radio/pirate-radio/antenna/const.json
@@ -0,0 +1,4 @@
+{
+  "type": "pirate-radio:const",
+  "level": 1.0
+}
\ No newline at end of file
diff --git a/src/main/resources/data/pirate-radio/pirate-radio/antenna/null.json b/src/main/resources/data/pirate-radio/pirate-radio/antenna/null.json
new file mode 100644
index 0000000..6b7a819
--- /dev/null
+++ b/src/main/resources/data/pirate-radio/pirate-radio/antenna/null.json
@@ -0,0 +1,4 @@
+{
+  "type": "pirate-radio:const",
+  "level": 0.0
+}
\ No newline at end of file
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
index 71f2518..c7d859a 100644
--- a/src/main/resources/fabric.mod.json
+++ b/src/main/resources/fabric.mod.json
@@ -34,6 +34,12 @@
 			}
 		]
 	},
+	"mixins": [
+		{
+			"config": "pirate-radio.client-mixins.json",
+			"environment": "client"
+		}
+	],
 	"depends": {
 		"fabricloader": ">=0.16.10",
 		"minecraft": "~1.21.1",
diff --git a/src/main/resources/pirate-radio.client-mixins.json b/src/main/resources/pirate-radio.client-mixins.json
new file mode 100644
index 0000000..27a5861
--- /dev/null
+++ b/src/main/resources/pirate-radio.client-mixins.json
@@ -0,0 +1,14 @@
+{
+  "required": true,
+  "minVersion": "0.8",
+  "package": "space.autistic.radio.client.mixin",
+  "compatibilityLevel": "JAVA_21",
+  "mixins": [
+  ],
+  "client": [
+    "BlockStatesLoaderMixin"
+  ],
+  "injectors": {
+    "defaultRequire": 1
+  }
+}
\ No newline at end of file
diff --git a/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt b/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt
index a4dfe91..1a6446b 100644
--- a/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt
+++ b/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt
@@ -2,6 +2,8 @@ package space.autistic.radio.complex
 
 import org.joml.Vector2f
 import org.junit.jupiter.api.Assertions.*
+import space.autistic.radio.client.complex.I
+import space.autistic.radio.client.complex.cmul
 import kotlin.test.Test
 
 class ComplexKtTest {
diff --git a/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt b/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt
index 8a4862c..aaa6db3 100644
--- a/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt
+++ b/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt
@@ -1,13 +1,25 @@
 package space.autistic.radio.fmsim
 
+import space.autistic.radio.client.fmsim.FmFullConstants
+import space.autistic.radio.client.fmsim.FmFullDemodulator
+import space.autistic.radio.client.fmsim.FmFullModulator
+import java.nio.FloatBuffer
 import kotlin.test.Test
 
 class TestAsserts {
     @Test
-    fun testFmFullSim() {
-        // initialize and flush an FM modulator
-        // if anything asserts, this should catch it
+    fun testFmFullMod() {
+        // initialize and flush an FM modulator, in both mono and stereo
         val fmFullModulator = FmFullModulator()
-        fmFullModulator.flush(1f) {}
+        fmFullModulator.flush(1f, FmFullConstants.STEREO) {}
+        fmFullModulator.flush(1f, FmFullConstants.MONO) {}
+    }
+
+    @Test
+    fun testFmFullDemod() {
+        // initialize and flush an FM demodulator, in both mono and stereo
+        val fmFullDemodulator = FmFullDemodulator()
+        fmFullDemodulator.flush(FmFullConstants.STEREO) { _, _ -> }
+        fmFullDemodulator.flush(FmFullConstants.MONO) { _, _ -> }
     }
 }
\ No newline at end of file