summary refs log tree commit diff stats
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b3
-rw-r--r--src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c727
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/disposable-transmitter.json6
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/fm-receiver.json6
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/powerbank.json6
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/sbc.json6
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/storage-card.json6
-rw-r--r--src/main/generated/assets/pirate-radio/models/item/wire.json6
-rw-r--r--src/main/generated/data/pirate-radio/advancement/recipes/misc/disposable-transmitter.json32
-rw-r--r--src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json22
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadio.kt17
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt26
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioItems.kt38
-rw-r--r--src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt208
-rw-r--r--src/main/kotlin/space/autistic/radio/complex/Complex.kt32
-rw-r--r--src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt11
-rw-r--r--src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt36
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt109
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt4
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt176
-rw-r--r--src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt77
-rw-r--r--src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt26
-rw-r--r--src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt14
-rw-r--r--src/main/resources/assets/pirate-radio/icon.pngbin0 -> 5251 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/lang/en_us.json10
-rw-r--r--src/main/resources/assets/pirate-radio/textures/item/powerbank.pngbin0 -> 688 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/textures/item/sbc.pngbin0 -> 645 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/textures/item/storage-card.pngbin0 -> 636 bytes
-rw-r--r--src/main/resources/assets/pirate-radio/textures/item/wire.pngbin0 -> 548 bytes
-rw-r--r--src/main/resources/fabric.mod.json44
30 files changed, 928 insertions, 0 deletions
diff --git a/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b b/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b
new file mode 100644
index 0000000..072c021
--- /dev/null
+++ b/src/main/generated/.cache/4145a4ade350d062a154f42d7ad0d98fb52bf04b
@@ -0,0 +1,3 @@
+// 1.21.1	2025-02-09T00:02:42.294183715	Pirate Radio/Recipes
+84f8cd2b2d9d1afcf2a5cf000905c264a6d8267c 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
new file mode 100644
index 0000000..cf1f8c7
--- /dev/null
+++ b/src/main/generated/.cache/bd1ee27e4c10ec669c0e0894b64dd83a58902c72
@@ -0,0 +1,7 @@
+// 1.21.1	2025-02-09T00:02:42.294917543	Pirate Radio/Model Definitions
+3507512497435bf1047ebd71ae1f4881ceb67f44 assets/pirate-radio/models/item/fm-receiver.json
+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
+4ec0ecb715a1eec2f90f47221614e09a4c5b8f65 assets/pirate-radio/models/item/disposable-transmitter.json
+2d14f0908eb7b92790cb29b141e4150c2d1f4a16 assets/pirate-radio/models/item/wire.json
diff --git a/src/main/generated/assets/pirate-radio/models/item/disposable-transmitter.json b/src/main/generated/assets/pirate-radio/models/item/disposable-transmitter.json
new file mode 100644
index 0000000..5eda62b
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/disposable-transmitter.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/disposable-transmitter"
+  }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..71813c4
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/fm-receiver.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/fm-receiver"
+  }
+}
\ No newline at end of file
diff --git a/src/main/generated/assets/pirate-radio/models/item/powerbank.json b/src/main/generated/assets/pirate-radio/models/item/powerbank.json
new file mode 100644
index 0000000..90149f7
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/powerbank.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/powerbank"
+  }
+}
\ No newline at end of file
diff --git a/src/main/generated/assets/pirate-radio/models/item/sbc.json b/src/main/generated/assets/pirate-radio/models/item/sbc.json
new file mode 100644
index 0000000..caa25b1
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/sbc.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/sbc"
+  }
+}
\ No newline at end of file
diff --git a/src/main/generated/assets/pirate-radio/models/item/storage-card.json b/src/main/generated/assets/pirate-radio/models/item/storage-card.json
new file mode 100644
index 0000000..6b56c92
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/storage-card.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/storage-card"
+  }
+}
\ No newline at end of file
diff --git a/src/main/generated/assets/pirate-radio/models/item/wire.json b/src/main/generated/assets/pirate-radio/models/item/wire.json
new file mode 100644
index 0000000..8c26725
--- /dev/null
+++ b/src/main/generated/assets/pirate-radio/models/item/wire.json
@@ -0,0 +1,6 @@
+{
+  "parent": "minecraft:item/generated",
+  "textures": {
+    "layer0": "pirate-radio:item/wire"
+  }
+}
\ No newline at end of file
diff --git a/src/main/generated/data/pirate-radio/advancement/recipes/misc/disposable-transmitter.json b/src/main/generated/data/pirate-radio/advancement/recipes/misc/disposable-transmitter.json
new file mode 100644
index 0000000..fca182d
--- /dev/null
+++ b/src/main/generated/data/pirate-radio/advancement/recipes/misc/disposable-transmitter.json
@@ -0,0 +1,32 @@
+{
+  "parent": "minecraft:recipes/root",
+  "criteria": {
+    "has_sbc": {
+      "conditions": {
+        "items": [
+          {
+            "items": "pirate-radio:sbc"
+          }
+        ]
+      },
+      "trigger": "minecraft:inventory_changed"
+    },
+    "has_the_recipe": {
+      "conditions": {
+        "recipe": "pirate-radio:disposable-transmitter"
+      },
+      "trigger": "minecraft:recipe_unlocked"
+    }
+  },
+  "requirements": [
+    [
+      "has_the_recipe",
+      "has_sbc"
+    ]
+  ],
+  "rewards": {
+    "recipes": [
+      "pirate-radio:disposable-transmitter"
+    ]
+  }
+}
\ 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
new file mode 100644
index 0000000..2a1d645
--- /dev/null
+++ b/src/main/generated/data/pirate-radio/recipe/disposable-transmitter.json
@@ -0,0 +1,22 @@
+{
+  "type": "minecraft:crafting_shapeless",
+  "category": "misc",
+  "ingredients": [
+    {
+      "item": "pirate-radio:sbc"
+    },
+    {
+      "item": "pirate-radio:wire"
+    },
+    {
+      "item": "pirate-radio:powerbank"
+    },
+    {
+      "item": "pirate-radio:storage-card"
+    }
+  ],
+  "result": {
+    "count": 1,
+    "id": "pirate-radio:disposable-transmitter"
+  }
+}
\ 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
new file mode 100644
index 0000000..54d0b9f
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadio.kt
@@ -0,0 +1,17 @@
+package space.autistic.radio
+
+import net.fabricmc.api.ModInitializer
+import org.slf4j.LoggerFactory
+
+object PirateRadio : ModInitializer {
+	const val MOD_ID = "pirate-radio"
+	private 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()
+	}
+}
\ 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
new file mode 100644
index 0000000..f147394
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
@@ -0,0 +1,26 @@
+package space.autistic.radio
+
+import net.fabricmc.fabric.api.`object`.builder.v1.entity.FabricDefaultAttributeRegistry
+import net.minecraft.entity.Entity
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.SpawnGroup
+import net.minecraft.entity.mob.MobEntity
+import net.minecraft.registry.Registries
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.registry.RegistryKeys
+import net.minecraft.util.Identifier
+import space.autistic.radio.entity.ElectronicsTraderEntity
+
+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)
+
+    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() {
+        FabricDefaultAttributeRegistry.register(ELECTRONICS_TRADER, MobEntity.createMobAttributes())
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
new file mode 100644
index 0000000..490acaf
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
@@ -0,0 +1,38 @@
+package space.autistic.radio
+
+import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents
+import net.minecraft.item.Item
+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.util.Identifier
+
+object PirateRadioItems {
+    val SBC_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "sbc"))
+    val SBC = register(Item(Item.Settings()), SBC_KEY)
+    val WIRE_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "wire"))
+    val WIRE = register(Item(Item.Settings()), WIRE_KEY)
+    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)
+
+    fun register(item: Item, registryKey: RegistryKey<Item>): Item {
+        return Registry.register(Registries.ITEM, registryKey.value, item)
+    }
+
+    fun initialize() {
+        ItemGroupEvents.modifyEntriesEvent(ItemGroups.INGREDIENTS).register {
+            it.add(SBC)
+            it.add(WIRE)
+            it.add(POWERBANK)
+            it.add(STORAGE_CARD)
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
new file mode 100644
index 0000000..517957b
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
@@ -0,0 +1,208 @@
+package space.autistic.radio.cli
+
+import org.joml.Vector2f
+import space.autistic.radio.complex.cmul
+import space.autistic.radio.fmsim.FmFullConstants
+import space.autistic.radio.fmsim.FmFullModulator
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.net.URI
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+import kotlin.io.path.inputStream
+import kotlin.io.path.toPath
+import kotlin.math.min
+import kotlin.system.exitProcess
+
+fun printUsage() {
+    println("Usage: OfflineSimulator -o OUTFILE.raw {[-p POWER] [-l|-h] file:///FILE.raw}")
+    println("    file:///FILE.raw (or ./FILE.raw - the ./ is required)")
+    println("        The raw input file. 2x48kHz 32-bit float")
+    println("    -o OUTFILE.raw")
+    println("        The raw RF stream to output, 2x200kHz 32-bit float")
+    println("    -p POWER")
+    println("        The signal amplitude (power level), e.g. 1.0")
+    println("    -l")
+    println("        Simulate a partial overlap on the lower half of the tuned-into frequency.")
+    println("    -h")
+    println("        Simulate a partial overlap on the upper half of the tuned-into frequency.")
+}
+
+class SimFile(val power: Float, val band: Int, val filename: String) {
+    var closed: Boolean = false
+    val buffer: FloatBuffer = FloatBuffer.allocate(8192)
+    val modulator = FmFullModulator()
+    var stream: InputStream? = null
+}
+
+fun main(args: Array<String>) {
+    if (args.isEmpty()) {
+        printUsage()
+        exitProcess(1)
+    }
+    var hasOutput = false
+    var inArg = ""
+    var output = ""
+    var power = 1.0f
+    var band = 2
+    val files: ArrayList<SimFile> = ArrayList()
+    for (arg in args) {
+        if (!hasOutput) {
+            if (arg == "-o") {
+                hasOutput = true
+                inArg = "-o"
+            } else {
+                printUsage()
+                exitProcess(1)
+            }
+        } else {
+            when (inArg) {
+                "-o" -> {
+                    output = arg
+                    inArg = ""
+                }
+
+                "-p" -> {
+                    power = arg.toFloatOrNull() ?: run {
+                        println("Error processing -p argument: not a valid float")
+                        printUsage()
+                        exitProcess(1)
+                    }
+                    inArg = ""
+                }
+
+                "" -> {
+                    if (!arg.startsWith("-")) {
+                        files.add(SimFile(power, band, arg))
+                        inArg = ""
+                        band = 2
+                        power = 1.0f
+                    } else {
+                        when (arg) {
+                            "-p" -> inArg = "-p"
+                            "-l" -> band = 1
+                            "-h" -> band = 3
+                            else -> {
+                                println("Unknown option")
+                                printUsage()
+                                exitProcess(1)
+                            }
+                        }
+                    }
+                }
+
+                else -> throw NotImplementedError(inArg)
+            }
+        }
+    }
+
+    if (files.isEmpty()) {
+        printUsage()
+        exitProcess(1)
+    }
+
+    println(ProcessHandle.current().pid())
+
+    FileOutputStream(output).buffered().use { outputStream ->
+        for (inputFile in files) {
+            if (inputFile.filename != "file:///dev/zero") {
+                if (inputFile.filename.startsWith("./")) {
+                    inputFile.stream = FileInputStream(inputFile.filename)
+                } else {
+                    inputFile.stream = URI(inputFile.filename).toPath().inputStream()
+                }
+            }
+        }
+
+        val buffer = ByteBuffer.allocate(2 * 4 * FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+        val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+        val minus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+        while (true) {
+            // initialized to maximum buffer size, trimmed down later
+            var minBuffer = 8192
+            for (inputFile in files) {
+                val stream = inputFile.stream
+                if (stream == null) {
+                    if (inputFile.buffer.remaining() > 2 * FmFullConstants.IFFT_DATA_BLOCK_SIZE_48K_300K) {
+                        inputFile.modulator.flush(inputFile.power) {
+                            inputFile.buffer.put(it)
+                        }
+                    }
+                } else {
+                    val bytes = stream.read(buffer.array())
+                    if (bytes <= 0) {
+                        stream.close()
+                        inputFile.stream = null
+                        inputFile.closed = true
+                        inputFile.modulator.flush(inputFile.power) {
+                            inputFile.buffer.put(it)
+                        }
+                    } else {
+                        val floats = buffer.slice(0, bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
+                        var shouldFlush = true
+                        inputFile.modulator.process(floats, inputFile.power) {
+                            inputFile.buffer.put(it)
+                            shouldFlush = false
+                        }
+                        if (shouldFlush) {
+                            inputFile.modulator.flush(inputFile.power) {
+                                inputFile.buffer.put(it)
+                            }
+                        }
+                    }
+                }
+                minBuffer = min(minBuffer, inputFile.buffer.position())
+            }
+
+            val outputBuffer = ByteBuffer.allocate(minBuffer * 4)
+            val floatView = outputBuffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
+            val floatBufferLo = FloatBuffer.allocate(minBuffer)
+            val floatBufferHi = FloatBuffer.allocate(minBuffer)
+            for (inputFile in files) {
+                inputFile.buffer.flip()
+                val floatBuffer = when (inputFile.band) {
+                    1 -> floatBufferLo
+                    2 -> floatView
+                    3 -> floatBufferHi
+                    else -> throw IllegalStateException()
+                }
+                for (i in 0 until floatBuffer.capacity()) {
+                    floatBuffer.put(i, floatBuffer.get(i) + inputFile.buffer.get())
+                }
+                inputFile.buffer.compact()
+            }
+            val z = Vector2f()
+            val w = Vector2f()
+            for (i in 0 until floatBufferHi.capacity() step 2) {
+                z.x = floatBufferHi.get(i)
+                z.y = floatBufferHi.get(i + 1)
+                if (!plus100k.hasRemaining()) {
+                    plus100k.clear()
+                }
+                w.x = plus100k.get()
+                w.y = plus100k.get()
+                z.cmul(w)
+                floatView.put(i, floatView.get(i) + z.x)
+                floatView.put(i, floatView.get(i) + z.y)
+            }
+            for (i in 0 until floatBufferLo.capacity() step 2) {
+                z.x = floatBufferLo.get(i)
+                z.y = floatBufferLo.get(i + 1)
+                if (!minus100k.hasRemaining()) {
+                    minus100k.clear()
+                }
+                w.x = minus100k.get()
+                w.y = -minus100k.get()
+                z.cmul(w)
+                floatView.put(i, floatView.get(i) + z.x)
+                floatView.put(i, floatView.get(i) + z.y)
+            }
+            outputStream.write(outputBuffer.array())
+            if (files.all { it.closed }) {
+                break
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/complex/Complex.kt b/src/main/kotlin/space/autistic/radio/complex/Complex.kt
new file mode 100644
index 0000000..918dac2
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/complex/Complex.kt
@@ -0,0 +1,32 @@
+package space.autistic.radio.complex
+
+import org.joml.Vector2f
+import org.joml.Vector2fc
+
+fun Vector2f.cmul(v: Vector2fc): Vector2f {
+    return this.cmul(v, this)
+}
+
+fun Vector2f.cmul(v: Vector2fc, dest: Vector2f): Vector2f {
+    val a = this.x * v.x()
+    val b = this.y * v.y()
+    val c = (this.x() + this.y()) * (v.x() + v.y())
+    val x = a - b
+    val y = c - a - b
+    dest.x = x
+    dest.y = y
+    return dest
+}
+
+fun Vector2f.conjugate(): Vector2f {
+    return this.conjugate(this)
+}
+
+fun Vector2f.conjugate(dest: Vector2f): Vector2f {
+    dest.x = this.x()
+    dest.y = -this.y()
+    return dest
+}
+
+val I
+    get() = Vector2f(0f, 1f)
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt b/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt
new file mode 100644
index 0000000..8f86218
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt
@@ -0,0 +1,11 @@
+package space.autistic.radio.dsp
+
+class Biquad1stOrder(private val b0: Float, private val b1: Float, private val a1: Float) {
+    private var delaySlot = 0f
+
+    fun process(samp: Float): Float {
+        val out = samp * b0 + delaySlot
+        delaySlot = samp * b1 - out * a1
+        return out
+    }
+}
\ 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
new file mode 100644
index 0000000..3aa53b1
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
@@ -0,0 +1,36 @@
+package space.autistic.radio.entity
+
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.ai.goal.HoldInHandsGoal
+import net.minecraft.entity.passive.WanderingTraderEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import net.minecraft.village.TradeOffer
+import net.minecraft.village.TradedItem
+import net.minecraft.world.World
+import space.autistic.radio.PirateRadioItems
+
+class ElectronicsTraderEntity(entityType: EntityType<out ElectronicsTraderEntity>, world: World) :
+    WanderingTraderEntity(entityType, world) {
+
+    override fun initGoals() {
+        super.initGoals()
+        goalSelector.goals.removeIf { it.goal is HoldInHandsGoal<*> }
+    }
+
+    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, 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))
+    }
+
+    override fun tickMovement() {
+        if (!this.world.isClient) {
+            super.setDespawnDelay(1000)
+        }
+        super.tickMovement()
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
new file mode 100644
index 0000000..5874166
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
@@ -0,0 +1,109 @@
+package space.autistic.radio.fmsim
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+object FmFullConstants {
+    // tau = 75us, fh = 20396.25Hz
+    const val FM_PREEMPAHSIS_B0_48K = 6.7639647f
+    const val FM_PREEMPHASIS_B1_48K = -4.975628f
+
+    /* const val FM_PREEMPHASIS_A0_48K = 1f */
+    const val FM_PREEMPHASIS_A1_48K = 0.78833646f
+
+    const val FM_DEEMPAHSIS_B0_48K = 1f / FM_PREEMPAHSIS_B0_48K
+    const val FM_DEEMPHASIS_B1_48K = FM_PREEMPHASIS_A1_48K / FM_PREEMPAHSIS_B0_48K
+
+    /* const val FM_DEEMPHASIS_A0_48K = 1f */
+    const val FM_DEEMPHASIS_A1_48K = FM_PREEMPHASIS_B1_48K / FM_PREEMPAHSIS_B0_48K
+
+    val FIR_LPF_48K_15K_3K1 = floatArrayOf(
+        -0.0010006913216784596f,
+        0.001505308784544468f,
+        -2.625857350794219e-18f,
+        -0.002777613466605544f,
+        0.0030173989944159985f,
+        0.002290070755407214f,
+        -0.008225799538195133f,
+        0.004239063244313002f,
+        0.010359899140894413f,
+        -0.017650796100497246f,
+        1.510757873119297e-17f,
+        0.029305754229426384f,
+        -0.02889496460556984f,
+        -0.020366130396723747f,
+        0.07103750854730606f,
+        -0.03811456635594368f,
+        -0.10945471376180649f,
+        0.29212409257888794f,
+        0.6252123713493347f,
+        0.29212409257888794f,
+        -0.10945471376180649f,
+        -0.03811456635594368f,
+        0.07103750854730606f,
+        -0.020366130396723747f,
+        -0.02889496460556984f,
+        0.029305754229426384f,
+        1.510757873119297e-17f,
+        -0.017650796100497246f,
+        0.010359899140894413f,
+        0.004239063244313002f,
+        -0.008225799538195133f,
+        0.002290070755407214f,
+        0.0030173989944159985f,
+        -0.002777613466605544f,
+        -2.625857350794219e-18f,
+        0.001505308784544468f,
+        -0.0010006913216784596f,
+    )
+
+    // chosen such that we can easily do 38kHz mixing in frequency (750*38k/300k = shift of 95 bins, where 750 comes
+    // from the 4/25 ratio 48k/300k i.e. 120*25/4)
+    // (the theoretical optimum, as per above, would be around 180)
+    // (we could have fudged the carrier frequency a bit but we chose not to)
+    // NOTE: latency = (data block size / 48000) seconds (84 -> 1.75 ms)
+    const val FFT_SIZE_LPF_48K_15K_3K1 = 120
+    const val FFT_OVERLAP_LPF_48K_15K_3K1 = 36
+    const val FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 = FFT_SIZE_LPF_48K_15K_3K1 - FFT_OVERLAP_LPF_48K_15K_3K1
+
+    init {
+        assert(FFT_OVERLAP_LPF_48K_15K_3K1 >= FIR_LPF_48K_15K_3K1.size - 1)
+    }
+
+    const val DECIMATION_48K_300K = 4
+    const val INTERPOLATION_48K_300K = 25
+
+    const val IFFT_SIZE_48K_300K = FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
+    const val IFFT_OVERLAP_48K_300K = FFT_OVERLAP_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
+    const val IFFT_DATA_BLOCK_SIZE_48K_300K = IFFT_SIZE_48K_300K - IFFT_OVERLAP_48K_300K
+
+    // how many bins to shift for 38kHz mixing
+    // assuming FFT_SIZE_LPF_48K_15K_3K1 *bins* (complex)
+    // 19 / 150 is the ratio between 38k/300k
+    const val FREQUENCY_MIXING_BINS_38K =
+        FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K * 19 / 150
+
+    // a single cycle of a 19kHz signal takes (1/19k)/(1/300k) or 300k/19k samples.
+    // since that number isn't exact, buffer an entire 19 cycles.
+    const val BUFFER_SIZE_19K_300K = 300
+
+    val BUFFER_19K_300K = FloatArray(BUFFER_SIZE_19K_300K) {
+        0.1f * sin(2 * PI * 19000.0 * it.toDouble() / 300000.0).toFloat()
+    }
+
+    // we want a carrier deviation of +-75kHz, at a sampling rate of 300kHz
+    const val CORRECTION_FACTOR = (75000.0 / (300000.0 / (2.0 * PI))).toFloat()
+
+    // these are used for "low/high" mixing
+    const val CBUFFER_SIZE_100K_300K = 3
+
+    val CBUFFER_100K_300K = FloatArray(2 * CBUFFER_SIZE_100K_300K) {
+        val index = it / 2
+        if (it and 1 == 0) {
+            1f * sin(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat()
+        } else {
+            1f * cos(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat()
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt
new file mode 100644
index 0000000..654d50f
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt
@@ -0,0 +1,4 @@
+package space.autistic.radio.fmsim
+
+class FmFullMixer {
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
new file mode 100644
index 0000000..96ad186
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
@@ -0,0 +1,176 @@
+package space.autistic.radio.fmsim
+
+import org.joml.Vector2f
+import space.autistic.radio.complex.cmul
+import space.autistic.radio.complex.conjugate
+import space.autistic.radio.dsp.Biquad1stOrder
+import java.nio.FloatBuffer
+import java.util.function.Consumer
+import org.jtransforms.fft.FloatFFT_1D
+import kotlin.math.max
+import kotlin.math.min
+
+class FmFullModulator {
+    private val leftPlusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val leftMinusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val biquadLeft = Biquad1stOrder(
+        FmFullConstants.FM_PREEMPAHSIS_B0_48K,
+        FmFullConstants.FM_PREEMPHASIS_B1_48K,
+        FmFullConstants.FM_PREEMPHASIS_A1_48K
+    )
+    private val biquadRight = Biquad1stOrder(
+        FmFullConstants.FM_PREEMPAHSIS_B0_48K,
+        FmFullConstants.FM_PREEMPHASIS_B1_48K,
+        FmFullConstants.FM_PREEMPHASIS_A1_48K
+    )
+    private val fft48kBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val mixingBuffer = FloatBuffer.allocate(FmFullConstants.IFFT_SIZE_48K_300K)
+    private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.IFFT_DATA_BLOCK_SIZE_48K_300K)
+    private val stereoPilot = FloatBuffer.wrap(FmFullConstants.BUFFER_19K_300K)
+
+    private var cycle = -1f
+    private var lastSum = 0f
+
+    init {
+        fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1)
+        Companion.fft48k.realForward(fir48kLpf.array())
+
+        // pre-pad the buffers
+        while (leftPlusRight.position() < FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) {
+            leftPlusRight.put(0f)
+            leftMinusRight.put(0f)
+        }
+    }
+
+    /**
+     * Takes in samples at 48kHz, interleaved stereo and processes them for output.
+     *
+     * Calls consumer with processed samples in I/Q format.
+     */
+    fun process(input: FloatBuffer, power: Float, consumer: Consumer<FloatBuffer>) {
+        while (input.remaining() >= 2) {
+            while (input.remaining() >= 2 && leftPlusRight.hasRemaining()) {
+                // FIXME AGC (currently clamping/clipping)
+                val left = min(max(biquadLeft.process(input.get()), -1f), 1f)
+                val right = min(max(biquadRight.process(input.get()), -1f), 1f)
+                leftPlusRight.put(left + right)
+                leftMinusRight.put(left - right)
+            }
+            if (!leftPlusRight.hasRemaining()) {
+                // zero the mixing buffer
+                for (i in 0 until mixingBuffer.capacity()) {
+                    mixingBuffer.put(i, 0f)
+                }
+                fft48kBuffer.put(0, leftPlusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+                Companion.fft48k.realForward(fft48kBuffer.array())
+                fft48kBuffer.array().forEachIndexed { index, fl ->
+                    fft48kBuffer.put(
+                        index,
+                        0.4f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl
+                    )
+                }
+                val z = Vector2f()
+                val w = Vector2f()
+                for (i in 2 until FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 step 2) {
+                    z.x = fft48kBuffer.get(i)
+                    z.y = fft48kBuffer.get(i + 1)
+                    w.x = fir48kLpf.get(i)
+                    w.y = fir48kLpf.get(i + 1)
+                    z.cmul(w)
+                    fft48kBuffer.put(i, z.x)
+                    fft48kBuffer.put(i + 1, z.y)
+                }
+                fft48kBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0))
+                fft48kBuffer.put(1, fft48kBuffer.get(1) * fir48kLpf.get(1))
+                // copy only around 19kHz of bandwidth
+                mixingBuffer.put(0, fft48kBuffer, 0, FmFullConstants.FREQUENCY_MIXING_BINS_38K or 1)
+                // zero out nyquist frequency bucket
+                mixingBuffer.put(1, 0f)
+                fft48kBuffer.put(0, leftMinusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+                Companion.fft48k.realForward(fft48kBuffer.array())
+                fft48kBuffer.array().forEachIndexed { index, fl ->
+                    fft48kBuffer.put(
+                        index,
+                        0.2f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl
+                    )
+                }
+                for (i in 2 until FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 step 2) {
+                    z.x = fft48kBuffer.get(i)
+                    z.y = fft48kBuffer.get(i + 1)
+                    w.x = fir48kLpf.get(i)
+                    w.y = fir48kLpf.get(i + 1)
+                    z.cmul(w)
+                    fft48kBuffer.put(i, z.x)
+                    fft48kBuffer.put(i + 1, z.y)
+                }
+                fft48kBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0))
+                // (unnecessary)
+                //fft48kBuffer.put(1, fft48kBuffer.get(1) * fir48kLpf.get(1))
+                mixingBuffer.put(
+                    FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2 + 2,
+                    fft48kBuffer,
+                    2,
+                    // number of floats to copy
+                    // bins are complex, so this halves the bins (~19kHz bandwidth)
+                    // length should be even (for an exact number of complex bins)
+                    FmFullConstants.FREQUENCY_MIXING_BINS_38K and 1.inv()
+                )
+                // the actual 38k bin is at this offset, account for jt convention (buf[0 until 3] = R0,Rn,R1)
+                mixingBuffer.put(FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2, fft48kBuffer.get(0))
+                val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2
+                // phase correction factor (due to dropping 150 bins)
+                // TODO figure out if phase is correct
+                cycle = -cycle
+                // bandwidth we care about is about half of 38k, so just, well, half it
+                for (i in 2 until FmFullConstants.FREQUENCY_MIXING_BINS_38K step 2) {
+                    z.x = mixingBuffer.get(base + i)
+                    z.y = mixingBuffer.get(base + i + 1)
+                    // we also need the conjugate
+                    z.conjugate(w)
+                    mixingBuffer.put(base + i, z.y * -cycle)
+                    mixingBuffer.put(base + i + 1, z.x * cycle)
+                    mixingBuffer.put(base - i, mixingBuffer.get(base - i - 2) - w.y * cycle)
+                    mixingBuffer.put(base - i + 1, mixingBuffer.get(base - i - 1) + w.x * cycle)
+                }
+                // handle 38kHz itself
+                z.x = mixingBuffer.get(base)
+                z.y = mixingBuffer.get(base + 1)
+                mixingBuffer.put(base, z.y * -cycle)
+                mixingBuffer.put(base + 1, z.x * cycle)
+                // (don't need to handle nyquist)
+                // mark data block as processed
+                leftPlusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+                leftMinusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+                leftPlusRight.compact()
+                leftMinusRight.compact()
+                Companion.fft300k.realInverse(mixingBuffer.array(), false)
+                outputBuffer.clear()
+                var sum = lastSum
+                for (i in FmFullConstants.IFFT_OVERLAP_48K_300K until FmFullConstants.IFFT_SIZE_48K_300K) {
+                    if (!stereoPilot.hasRemaining()) {
+                        stereoPilot.clear()
+                    }
+                    val result = mixingBuffer.get(i) + stereoPilot.get()
+                    sum += result * FmFullConstants.CORRECTION_FACTOR
+                    val sin = org.joml.Math.sin(sum)
+                    outputBuffer.put(sin * power)
+                    outputBuffer.put(org.joml.Math.cos(sum) * power)
+                }
+                lastSum = sum % (2 * Math.PI).toFloat()
+                outputBuffer.clear()
+                consumer.accept(outputBuffer)
+            }
+        }
+        input.compact()
+    }
+
+    fun flush(power: Float, consumer: Consumer<FloatBuffer>) {
+        process(FloatBuffer.allocate(2 * leftPlusRight.remaining()), power, consumer)
+    }
+
+    companion object {
+        private val fft48k = FloatFFT_1D(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1.toLong())
+        private val fft300k = FloatFFT_1D(FmFullConstants.IFFT_SIZE_48K_300K.toLong())
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt b/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt
new file mode 100644
index 0000000..56fce2b
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt
@@ -0,0 +1,77 @@
+package space.autistic.radio.opus
+
+import com.dylibso.chicory.runtime.ByteBufferMemory
+import space.autistic.radio.reflection.getBuffer
+import java.nio.ByteOrder
+
+class OpusDecoder(sampleRate: Int, private val channels: Int) {
+    private val instance = OpusFactory()
+
+    init {
+        instance.export("_initialize").apply()
+    }
+
+    private val errorPtr = instance.export("malloc").apply(4)[0]
+
+    init {
+        if (errorPtr == 0L) {
+            throw IllegalStateException()
+        }
+        instance.memory().writeI32(errorPtr.toInt(), 0)
+    }
+
+    private val decoder =
+        instance.export("opus_decoder_create").apply(sampleRate.toLong(), channels.toLong(), errorPtr)[0]
+
+    init {
+        val error = instance.memory().readI32(errorPtr.toInt())
+        if (error < 0) {
+            throw IllegalStateException(
+                instance.memory().readCString(instance.export("opus_strerror").apply(error)[0].toInt())
+            )
+        }
+    }
+
+    private val opusDecodeFloat = instance.export("opus_decode_float")
+
+    private val outBuf = instance.export("malloc").apply((4 * MAX_FRAME_SIZE * channels).toLong())[0]
+
+    init {
+        if (outBuf == 0L) {
+            throw IllegalStateException()
+        }
+    }
+
+    private val cbits = instance.export("malloc").apply(MAX_PACKET_SIZE.toLong())[0]
+
+    init {
+        if (cbits == 0L) {
+            throw IllegalStateException()
+        }
+    }
+
+    private val memory = instance.memory() as ByteBufferMemory
+
+    fun decode(packet: ByteArray): FloatArray {
+        if (packet.size > MAX_PACKET_SIZE) {
+            throw IllegalArgumentException("packet too big")
+        }
+        memory.getBuffer().put(cbits.toInt(), packet)
+        val decoded =
+            opusDecodeFloat.apply(decoder, cbits, packet.size.toLong(), outBuf, MAX_FRAME_SIZE.toLong(), 0L)[0]
+        if (decoded < 0L) {
+            throw IllegalStateException(
+                instance.memory().readCString(instance.export("opus_strerror").apply(decoded)[0].toInt())
+            )
+        }
+        val out = FloatArray(decoded.toInt())
+        memory.getBuffer().slice(outBuf.toInt(), outBuf.toInt() + 4 * channels * decoded.toInt())
+            .order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(out)
+        return out
+    }
+
+    companion object {
+        const val MAX_FRAME_SIZE = 6 * 960
+        const val MAX_PACKET_SIZE = 3 * 1276
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt b/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt
new file mode 100644
index 0000000..70e0c3c
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt
@@ -0,0 +1,26 @@
+package space.autistic.radio.opus
+
+import com.dylibso.chicory.experimental.aot.AotMachineFactory
+import com.dylibso.chicory.runtime.ImportValues
+import com.dylibso.chicory.runtime.Instance
+import com.dylibso.chicory.wasm.Parser
+import net.fabricmc.loader.api.FabricLoader
+import java.io.InputStream
+
+object OpusFactory : () -> Instance {
+	private val defaultImports = ImportValues.builder().build()
+	private val module = Parser.parse(getModuleInputStream())
+	private val instanceBuilder =
+		Instance.builder(module)
+			.withMachineFactory(AotMachineFactory(module))
+			.withImportValues(defaultImports)
+
+	override fun invoke(): Instance = instanceBuilder.build()
+
+	private fun getModuleInputStream(): InputStream {
+		return FabricLoader.getInstance().getModContainer("pirate-radio").flatMap { it.findPath("opus.wasm") }
+			.map<InputStream?> { it.toFile().inputStream() }.orElseGet {
+				this.javaClass.getResourceAsStream("/opus.wasm")
+			}
+	}
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt b/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt
new file mode 100644
index 0000000..78961da
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt
@@ -0,0 +1,14 @@
+package space.autistic.radio.reflection
+
+import com.dylibso.chicory.runtime.ByteBufferMemory
+import java.lang.invoke.MethodHandles
+import java.nio.ByteBuffer
+
+fun ByteBufferMemory.getBuffer(): ByteBuffer {
+    return MemoryReflection.buffer.get(this) as ByteBuffer
+}
+
+object MemoryReflection {
+    val buffer = MethodHandles.privateLookupIn(ByteBufferMemory::class.java, MethodHandles.lookup())
+        .findVarHandle(ByteBufferMemory::class.java, "buffer", ByteBuffer::class.java)
+}
\ No newline at end of file
diff --git a/src/main/resources/assets/pirate-radio/icon.png b/src/main/resources/assets/pirate-radio/icon.png
new file mode 100644
index 0000000..62adcdd
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/icon.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/lang/en_us.json b/src/main/resources/assets/pirate-radio/lang/en_us.json
new file mode 100644
index 0000000..9627729
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/lang/en_us.json
@@ -0,0 +1,10 @@
+{
+  "item.pirate-radio.sbc": "Raspberry Pi",
+  "item.pirate-radio.wire": "Piece of Wire",
+  "item.pirate-radio.powerbank": "Powerbank",
+  "item.pirate-radio.storage-card": "SD Card",
+  "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"
+}
\ No newline at end of file
diff --git a/src/main/resources/assets/pirate-radio/textures/item/powerbank.png b/src/main/resources/assets/pirate-radio/textures/item/powerbank.png
new file mode 100644
index 0000000..0f1685f
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/item/powerbank.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/textures/item/sbc.png b/src/main/resources/assets/pirate-radio/textures/item/sbc.png
new file mode 100644
index 0000000..38a90a4
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/item/sbc.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/textures/item/storage-card.png b/src/main/resources/assets/pirate-radio/textures/item/storage-card.png
new file mode 100644
index 0000000..bf4b60b
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/item/storage-card.png
Binary files differdiff --git a/src/main/resources/assets/pirate-radio/textures/item/wire.png b/src/main/resources/assets/pirate-radio/textures/item/wire.png
new file mode 100644
index 0000000..8b5b330
--- /dev/null
+++ b/src/main/resources/assets/pirate-radio/textures/item/wire.png
Binary files differdiff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..71f2518
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -0,0 +1,44 @@
+{
+	"schemaVersion": 1,
+	"id": "pirate-radio",
+	"version": "${version}",
+	"name": "Pirate Radio",
+	"description": "This is an example description! Tell everyone what your mod is about!",
+	"authors": [
+		"Me!"
+	],
+	"contact": {
+		"homepage": "https://fabricmc.net/",
+		"sources": "https://github.com/FabricMC/fabric-example-mod"
+	},
+	"license": "LGPL-2.1-or-later",
+	"icon": "assets/pirate-radio/icon.png",
+	"environment": "*",
+	"entrypoints": {
+		"main": [
+			{
+				"value": "space.autistic.radio.PirateRadio",
+				"adapter": "kotlin"
+			}
+		],
+		"client": [
+			{
+				"value": "space.autistic.radio.client.PirateRadioClient",
+				"adapter": "kotlin"
+			}
+		],
+		"fabric-datagen": [
+			{
+				"value": "space.autistic.radio.client.PirateRadioDataGenerator",
+				"adapter": "kotlin"
+			}
+		]
+	},
+	"depends": {
+		"fabricloader": ">=0.16.10",
+		"minecraft": "~1.21.1",
+		"java": ">=21",
+		"fabric-api": "*",
+		"fabric-language-kotlin": "*"
+	}
+}
\ No newline at end of file