summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorSoniEx2 <endermoneymod@gmail.com>2025-03-04 22:45:19 -0300
committerSoniEx2 <endermoneymod@gmail.com>2025-03-04 22:45:19 -0300
commit79ff3692b9462fc79d93bd74213ce6904340fc13 (patch)
tree22055c038783b87cceffe3d2220cc2b568a4493d
First public commit
-rw-r--r--.gitattributes9
-rw-r--r--.github/workflows/build.yml37
-rw-r--r--.gitignore42
-rw-r--r--LICENSE419
-rw-r--r--build.gradle113
-rw-r--r--gradle.properties20
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 43583 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties7
-rwxr-xr-xgradlew252
-rw-r--r--gradlew.bat94
-rw-r--r--opus/.gitignore2
-rwxr-xr-xopus/build_script.sh29
-rwxr-xr-xopus/get_opus.sh4
-rw-r--r--settings.gradle10
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt35
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt62
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt18
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt19
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/AntennaModelFactory.kt7
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt13
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt97
-rw-r--r--src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt23
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt11
-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
-rw-r--r--src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt13
-rw-r--r--src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt13
55 files changed, 2277 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..097f9f9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew        text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat           text eol=crlf
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..a4e3e0b
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,37 @@
+# Automatically build the project and run any configured tests for every push
+# and submitted pull request. This can help catch issues that only occur on
+# certain platforms or Java versions, and provides a first line of defence
+# against bad commits.
+
+name: build
+on: [pull_request, push]
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        # Use these Java versions
+        java: [
+          21,    # Current Java LTS
+        ]
+    runs-on: ubuntu-22.04
+    steps:
+      - name: checkout repository
+        uses: actions/checkout@v4
+      - name: validate gradle wrapper
+        uses: gradle/actions/wrapper-validation@v4
+      - name: setup jdk ${{ matrix.java }}
+        uses: actions/setup-java@v4
+        with:
+          java-version: ${{ matrix.java }}
+          distribution: 'microsoft'
+      - name: make gradle wrapper executable
+        run: chmod +x ./gradlew
+      - name: build
+        run: ./gradlew build
+      - name: capture build artifacts
+        if: ${{ matrix.java == '21' }} # Only upload artifacts built from latest java
+        uses: actions/upload-artifact@v4
+        with:
+          name: Artifacts
+          path: build/libs/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bab5adf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+# gradle
+
+.gradle/
+build/
+out/
+classes/
+
+# eclipse
+
+*.launch
+
+# idea
+
+.idea/
+*.iml
+*.ipr
+*.iws
+
+# vscode
+
+.settings/
+.vscode/
+bin/
+.classpath
+.project
+
+# macos
+
+*.DS_Store
+
+# fabric
+
+run/
+
+# java
+
+hs_err_*.log
+replay_*.log
+*.hprof
+*.jfr
+
+/src/main/resources/opus.wasm
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9b44720
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,419 @@
+code is MIT license:
+
+Copyright (C) 2025 Soni L.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+image assets are from https://github.com/malcolmriley/unused-textures , under the following license:
+
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+     Considerations for licensors: Our public licenses are
+     intended for use by those authorized to give the public
+     permission to use material in ways otherwise restricted by
+     copyright and certain other rights. Our licenses are
+     irrevocable. Licensors should read and understand the terms
+     and conditions of the license they choose before applying it.
+     Licensors should also secure all rights necessary before
+     applying our licenses so that the public can reuse the
+     material as expected. Licensors should clearly mark any
+     material not subject to the license. This includes other CC-
+     licensed material, or material used under an exception or
+     limitation to copyright. More considerations for licensors:
+    wiki.creativecommons.org/Considerations_for_licensors
+
+     Considerations for the public: By using one of our public
+     licenses, a licensor grants the public permission to use the
+     licensed material under specified terms and conditions. If
+     the licensor's permission is not necessary for any reason--for
+     example, because of any applicable exception or limitation to
+     copyright--then that use is not regulated by the license. Our
+     licenses grant only permissions under copyright and certain
+     other rights that a licensor has authority to grant. Use of
+     the licensed material may still be restricted for other
+     reasons, including because others have copyright or other
+     rights in the material. A licensor may make special requests,
+     such as asking that all changes be marked or described.
+     Although not required by our licenses, you are encouraged to
+     respect those requests where reasonable. More considerations
+     for the public:
+    wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+  a. Adapted Material means material subject to Copyright and Similar
+     Rights that is derived from or based upon the Licensed Material
+     and in which the Licensed Material is translated, altered,
+     arranged, transformed, or otherwise modified in a manner requiring
+     permission under the Copyright and Similar Rights held by the
+     Licensor. For purposes of this Public License, where the Licensed
+     Material is a musical work, performance, or sound recording,
+     Adapted Material is always produced where the Licensed Material is
+     synched in timed relation with a moving image.
+
+  b. Adapter's License means the license You apply to Your Copyright
+     and Similar Rights in Your contributions to Adapted Material in
+     accordance with the terms and conditions of this Public License.
+
+  c. Copyright and Similar Rights means copyright and/or similar rights
+     closely related to copyright including, without limitation,
+     performance, broadcast, sound recording, and Sui Generis Database
+     Rights, without regard to how the rights are labeled or
+     categorized. For purposes of this Public License, the rights
+     specified in Section 2(b)(1)-(2) are not Copyright and Similar
+     Rights.
+
+  d. Effective Technological Measures means those measures that, in the
+     absence of proper authority, may not be circumvented under laws
+     fulfilling obligations under Article 11 of the WIPO Copyright
+     Treaty adopted on December 20, 1996, and/or similar international
+     agreements.
+
+  e. Exceptions and Limitations means fair use, fair dealing, and/or
+     any other exception or limitation to Copyright and Similar Rights
+     that applies to Your use of the Licensed Material.
+
+  f. Licensed Material means the artistic or literary work, database,
+     or other material to which the Licensor applied this Public
+     License.
+
+  g. Licensed Rights means the rights granted to You subject to the
+     terms and conditions of this Public License, which are limited to
+     all Copyright and Similar Rights that apply to Your use of the
+     Licensed Material and that the Licensor has authority to license.
+
+  h. Licensor means the individual(s) or entity(ies) granting rights
+     under this Public License.
+
+  i. Share means to provide material to the public by any means or
+     process that requires permission under the Licensed Rights, such
+     as reproduction, public display, public performance, distribution,
+     dissemination, communication, or importation, and to make material
+     available to the public including in ways that members of the
+     public may access the material from a place and at a time
+     individually chosen by them.
+
+  j. Sui Generis Database Rights means rights other than copyright
+     resulting from Directive 96/9/EC of the European Parliament and of
+     the Council of 11 March 1996 on the legal protection of databases,
+     as amended and/or succeeded, as well as other essentially
+     equivalent rights anywhere in the world.
+
+  k. You means the individual or entity exercising the Licensed Rights
+     under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+  a. License grant.
+
+       1. Subject to the terms and conditions of this Public License,
+          the Licensor hereby grants You a worldwide, royalty-free,
+          non-sublicensable, non-exclusive, irrevocable license to
+          exercise the Licensed Rights in the Licensed Material to:
+
+            a. reproduce and Share the Licensed Material, in whole or
+               in part; and
+
+            b. produce, reproduce, and Share Adapted Material.
+
+       2. Exceptions and Limitations. For the avoidance of doubt, where
+          Exceptions and Limitations apply to Your use, this Public
+          License does not apply, and You do not need to comply with
+          its terms and conditions.
+
+       3. Term. The term of this Public License is specified in Section
+          6(a).
+
+       4. Media and formats; technical modifications allowed. The
+          Licensor authorizes You to exercise the Licensed Rights in
+          all media and formats whether now known or hereafter created,
+          and to make technical modifications necessary to do so. The
+          Licensor waives and/or agrees not to assert any right or
+          authority to forbid You from making technical modifications
+          necessary to exercise the Licensed Rights, including
+          technical modifications necessary to circumvent Effective
+          Technological Measures. For purposes of this Public License,
+          simply making modifications authorized by this Section 2(a)
+          (4) never produces Adapted Material.
+
+       5. Downstream recipients.
+
+            a. Offer from the Licensor -- Licensed Material. Every
+               recipient of the Licensed Material automatically
+               receives an offer from the Licensor to exercise the
+               Licensed Rights under the terms and conditions of this
+               Public License.
+
+            b. No downstream restrictions. You may not offer or impose
+               any additional or different terms or conditions on, or
+               apply any Effective Technological Measures to, the
+               Licensed Material if doing so restricts exercise of the
+               Licensed Rights by any recipient of the Licensed
+               Material.
+
+       6. No endorsement. Nothing in this Public License constitutes or
+          may be construed as permission to assert or imply that You
+          are, or that Your use of the Licensed Material is, connected
+          with, or sponsored, endorsed, or granted official status by,
+          the Licensor or others designated to receive attribution as
+          provided in Section 3(a)(1)(A)(i).
+
+  b. Other rights.
+
+       1. Moral rights, such as the right of integrity, are not
+          licensed under this Public License, nor are publicity,
+          privacy, and/or other similar personality rights; however, to
+          the extent possible, the Licensor waives and/or agrees not to
+          assert any such rights held by the Licensor to the limited
+          extent necessary to allow You to exercise the Licensed
+          Rights, but not otherwise.
+
+       2. Patent and trademark rights are not licensed under this
+          Public License.
+
+       3. To the extent possible, the Licensor waives any right to
+          collect royalties from You for the exercise of the Licensed
+          Rights, whether directly or through a collecting society
+          under any voluntary or waivable statutory or compulsory
+          licensing scheme. In all other cases the Licensor expressly
+          reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+  a. Attribution.
+
+       1. If You Share the Licensed Material (including in modified
+          form), You must:
+
+            a. retain the following if it is supplied by the Licensor
+               with the Licensed Material:
+
+                 i. identification of the creator(s) of the Licensed
+                    Material and any others designated to receive
+                    attribution, in any reasonable manner requested by
+                    the Licensor (including by pseudonym if
+                    designated);
+
+                ii. a copyright notice;
+
+               iii. a notice that refers to this Public License;
+
+                iv. a notice that refers to the disclaimer of
+                    warranties;
+
+                 v. a URI or hyperlink to the Licensed Material to the
+                    extent reasonably practicable;
+
+            b. indicate if You modified the Licensed Material and
+               retain an indication of any previous modifications; and
+
+            c. indicate the Licensed Material is licensed under this
+               Public License, and include the text of, or the URI or
+               hyperlink to, this Public License.
+
+       2. You may satisfy the conditions in Section 3(a)(1) in any
+          reasonable manner based on the medium, means, and context in
+          which You Share the Licensed Material. For example, it may be
+          reasonable to satisfy the conditions by providing a URI or
+          hyperlink to a resource that includes the required
+          information.
+
+       3. If requested by the Licensor, You must remove any of the
+          information required by Section 3(a)(1)(A) to the extent
+          reasonably practicable.
+
+       4. If You Share Adapted Material You produce, the Adapter's
+          License You apply must not prevent recipients of the Adapted
+          Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+  a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+     to extract, reuse, reproduce, and Share all or a substantial
+     portion of the contents of the database;
+
+  b. if You include all or a substantial portion of the database
+     contents in a database in which You have Sui Generis Database
+     Rights, then the database in which You have Sui Generis Database
+     Rights (but not its individual contents) is Adapted Material; and
+
+  c. You must comply with the conditions in Section 3(a) if You Share
+     all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+  c. The disclaimer of warranties and limitation of liability provided
+     above shall be interpreted in a manner that, to the extent
+     possible, most closely approximates an absolute disclaimer and
+     waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+  a. This Public License applies for the term of the Copyright and
+     Similar Rights licensed here. However, if You fail to comply with
+     this Public License, then Your rights under this Public License
+     terminate automatically.
+
+  b. Where Your right to use the Licensed Material has terminated under
+     Section 6(a), it reinstates:
+
+       1. automatically as of the date the violation is cured, provided
+          it is cured within 30 days of Your discovery of the
+          violation; or
+
+       2. upon express reinstatement by the Licensor.
+
+     For the avoidance of doubt, this Section 6(b) does not affect any
+     right the Licensor may have to seek remedies for Your violations
+     of this Public License.
+
+  c. For the avoidance of doubt, the Licensor may also offer the
+     Licensed Material under separate terms or conditions or stop
+     distributing the Licensed Material at any time; however, doing so
+     will not terminate this Public License.
+
+  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+     License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+  a. The Licensor shall not be bound by any additional or different
+     terms or conditions communicated by You unless expressly agreed.
+
+  b. Any arrangements, understandings, or agreements regarding the
+     Licensed Material not stated herein are separate from and
+     independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+  a. For the avoidance of doubt, this Public License does not, and
+     shall not be interpreted to, reduce, limit, restrict, or impose
+     conditions on any use of the Licensed Material that could lawfully
+     be made without permission under this Public License.
+
+  b. To the extent possible, if any provision of this Public License is
+     deemed unenforceable, it shall be automatically reformed to the
+     minimum extent necessary to make it enforceable. If the provision
+     cannot be reformed, it shall be severed from this Public License
+     without affecting the enforceability of the remaining terms and
+     conditions.
+
+  c. No term or condition of this Public License will be waived and no
+     failure to comply consented to unless expressly agreed to by the
+     Licensor.
+
+  d. Nothing in this Public License constitutes or may be interpreted
+     as a limitation upon, or waiver of, any privileges and immunities
+     that apply to the Licensor or You, including from the legal
+     processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..2c2c885
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,113 @@
+plugins {
+	id 'fabric-loom' version '1.9-SNAPSHOT'
+	id 'maven-publish'
+	id "org.jetbrains.kotlin.jvm" version "2.1.10"
+}
+
+version = project.mod_version
+group = project.maven_group
+
+base {
+	archivesName = project.archives_base_name
+}
+
+repositories {
+	// Add repositories to retrieve artifacts from in here.
+	// You should only use this when depending on other mods because
+	// Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
+	// See https://docs.gradle.org/current/userguide/declaring_repositories.html
+	// for more information about repositories.
+}
+
+loom {
+	splitEnvironmentSourceSets()
+
+	mods {
+		"pirate-radio" {
+			sourceSet sourceSets.main
+			sourceSet sourceSets.client
+		}
+	}
+
+}
+
+fabricApi {
+	configureDataGeneration {
+		client = true
+	}
+}
+
+dependencies {
+	// To change the versions see the gradle.properties file
+	minecraft "com.mojang:minecraft:${project.minecraft_version}"
+	mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
+	modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
+
+	// Fabric API. This is technically optional, but you probably want it anyway.
+	modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
+	modImplementation "net.fabricmc:fabric-language-kotlin:${project.fabric_kotlin_version}"
+
+	include implementation("com.dylibso.chicory:runtime:${project.chicory_version}")
+	include implementation("com.dylibso.chicory:wasm:${project.chicory_version}")
+	include implementation("com.dylibso.chicory:aot-experimental:${project.chicory_version}")
+	include implementation("com.github.wendykierp:JTransforms:3.1:with-dependencies")
+
+	testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
+	testImplementation "org.jetbrains.kotlin:kotlin-test:${project.kotlin_test_version}"
+}
+
+test {
+	useJUnitPlatform()
+}
+
+processResources {
+	inputs.property "version", project.version
+
+	filesMatching("fabric.mod.json") {
+		expand "version": project.version
+	}
+}
+
+tasks.withType(JavaCompile).configureEach {
+	it.options.release = 21
+}
+
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
+	kotlinOptions {
+		jvmTarget = 21
+	}
+}
+
+java {
+	// Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+	// if it is present.
+	// If you remove this line, sources will not be generated.
+	withSourcesJar()
+
+	sourceCompatibility = JavaVersion.VERSION_21
+	targetCompatibility = JavaVersion.VERSION_21
+}
+
+jar {
+	from("LICENSE") {
+		rename { "${it}_${project.base.archivesName.get()}"}
+	}
+}
+
+// configure the maven publication
+publishing {
+	publications {
+		create("mavenJava", MavenPublication) {
+			artifactId = project.archives_base_name
+			from components.java
+		}
+	}
+
+	// See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+	repositories {
+		// Add repositories to publish to here.
+		// Notice: This block does NOT have the same function as the block in the top level.
+		// The repositories here will be used for publishing your artifact, not for
+		// retrieving dependencies.
+	}
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..24c6f63
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,20 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+org.gradle.parallel=true
+
+# Fabric Properties
+# check these on https://fabricmc.net/develop
+minecraft_version=1.21.1
+yarn_mappings=1.21.1+build.3
+loader_version=0.16.10
+fabric_kotlin_version=1.13.1+kotlin.2.1.10
+
+# Mod Properties
+mod_version=1.0.0
+maven_group=space.autistic.radio
+archives_base_name=pirate-radio
+
+# Dependencies
+fabric_version=0.115.0+1.21.1
+kotlin_test_version=2.1.10
+chicory_version=1.1.0
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differdiff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/opus/.gitignore b/opus/.gitignore
new file mode 100644
index 0000000..edb557c
--- /dev/null
+++ b/opus/.gitignore
@@ -0,0 +1,2 @@
+/opus-*
+/libs
diff --git a/opus/build_script.sh b/opus/build_script.sh
new file mode 100755
index 0000000..cb2e0ef
--- /dev/null
+++ b/opus/build_script.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+set -e
+
+cd opus-1.5.2
+
+s_EXPORTED_FUNCTIONS="-s EXPORTED_FUNCTIONS=$(printf '_%s,' \
+opus_decoder_create \
+opus_strerror \
+opus_decode_float \
+malloc
+)_free"
+
+#shared_flags="-O1 -fno-unroll-loops -fno-inline -flto -mnontrapping-fptoint"
+shared_flags="-Oz -flto -fno-inline -mnontrapping-fptoint"
+compile_flags="-DNDEBUG -DNO_STDIO $shared_flags"
+link_flags="$shared_flags -s MALLOC=emmalloc -s ASSERTIONS=0 -s INITIAL_MEMORY=196608 -s TOTAL_STACK=48736 -s STANDALONE_WASM=1 -s PURE_WASI=1 -s STACK_OVERFLOW_CHECK=0 -s WASM_BIGINT=1 -s ABORTING_MALLOC=0 -s MEMORY_GROWTH_GEOMETRIC_STEP=0 -s ALLOW_MEMORY_GROWTH=1"
+
+emconfigure ./configure STRIP='emstrip' CFLAGS="$compile_flags" LDFLAGS="$link_flags" --host=wasm32 --disable-dependency-tracking --disable-shared
+emmake make
+
+#emcc --no-entry $s_EXPORTED_FUNCTIONS $link_flags libmp3lame/.libs/libmp3lame.a
+#em++ --no-entry $s_EXPORTED_FUNCTIONS $link_flags $compile_flags -o pocketfft.wasm impl.cc
+
+#mkdir -p ../../src/main/resources/opus/
+#cp a.out.wasm ../../src/main/resources/opus.wasm
+#cp LICENSE COPYING ../../src/main/resources/opus/
+
+exit 0
diff --git a/opus/get_opus.sh b/opus/get_opus.sh
new file mode 100755
index 0000000..a7fd7e0
--- /dev/null
+++ b/opus/get_opus.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+wget https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
+tar -xf opus-1.5.2.tar.gz
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..75c4d72
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,10 @@
+pluginManagement {
+	repositories {
+		maven {
+			name = 'Fabric'
+			url = 'https://maven.fabricmc.net/'
+		}
+		mavenCentral()
+		gradlePluginPortal()
+	}
+}
\ 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
new file mode 100644
index 0000000..54b7640
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
@@ -0,0 +1,35 @@
+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.rendering.v1.EntityRendererRegistry
+import net.minecraft.client.MinecraftClient
+import net.minecraft.command.CommandRegistryAccess
+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.gui.FmReceiverScreen
+
+object PirateRadioClient : ClientModInitializer {
+    private val logger = LoggerFactory.getLogger(MOD_ID)
+
+    override fun onInitializeClient() {
+        EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer)
+        PirateRadioEntityModelLayers.initialize()
+        ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ ->
+            dispatcher.register(
+                ClientCommandManager.literal("fm").executes {
+                    it.source.client.send {
+                        it.source.client.setScreen(FmReceiverScreen())
+                    }
+                    0
+                }
+            )
+        }
+    }
+}
\ 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
new file mode 100644
index 0000000..b5130a1
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
@@ -0,0 +1,62 @@
+package space.autistic.radio.client
+
+import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint
+import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator
+import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput
+import net.fabricmc.fabric.api.datagen.v1.provider.FabricModelProvider
+import net.fabricmc.fabric.api.datagen.v1.provider.FabricRecipeProvider
+import net.minecraft.data.client.BlockStateModelGenerator
+import net.minecraft.data.client.ItemModelGenerator
+import net.minecraft.data.client.Models
+import net.minecraft.data.server.recipe.RecipeExporter
+import net.minecraft.data.server.recipe.RecipeProvider
+import net.minecraft.data.server.recipe.ShapelessRecipeJsonBuilder
+import net.minecraft.item.ItemStack
+import net.minecraft.recipe.Ingredient
+import net.minecraft.recipe.Recipe
+import net.minecraft.recipe.ShapelessRecipe
+import net.minecraft.recipe.book.CraftingRecipeCategory
+import net.minecraft.recipe.book.RecipeCategory
+import net.minecraft.registry.RegistryWrapper
+import net.minecraft.util.Identifier
+import net.minecraft.util.collection.DefaultedList
+import space.autistic.radio.PirateRadio.MOD_ID
+import space.autistic.radio.PirateRadioItems
+import java.util.concurrent.CompletableFuture
+
+class PirateRadioItemModelGenerator(output: FabricDataOutput) : FabricModelProvider(output) {
+    override fun generateBlockStateModels(modelGenerator: BlockStateModelGenerator) {
+    }
+
+    override fun generateItemModels(modelGenderator: ItemModelGenerator) {
+        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.STORAGE_CARD, Models.GENERATED)
+        modelGenderator.register(PirateRadioItems.DISPOSABLE_TRANSMITTER, Models.GENERATED)
+    }
+
+}
+
+class PirateRadioRecipeGenerator(
+    output: FabricDataOutput?,
+    registriesFuture: CompletableFuture<RegistryWrapper.WrapperLookup>?
+) : FabricRecipeProvider(output, registriesFuture) {
+    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)
+            .criterion("has_sbc", RecipeProvider.conditionsFromItem(PirateRadioItems.SBC)).offerTo(exporter)
+    }
+
+}
+
+object PirateRadioDataGenerator : DataGeneratorEntrypoint {
+    override fun onInitializeDataGenerator(fabricDataGenerator: FabricDataGenerator) {
+        val pack = fabricDataGenerator.createPack()
+
+        pack.addProvider(::PirateRadioItemModelGenerator)
+        pack.addProvider(::PirateRadioRecipeGenerator)
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
new file mode 100644
index 0000000..765912d
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
@@ -0,0 +1,18 @@
+package space.autistic.radio.client
+
+import net.fabricmc.fabric.api.client.rendering.v1.EntityModelLayerRegistry
+import net.minecraft.client.model.TexturedModelData
+import net.minecraft.client.render.entity.model.EntityModelLayer
+import net.minecraft.client.render.entity.model.VillagerResemblingModel
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+
+object PirateRadioEntityModelLayers {
+    val ELECTRONICS_TRADER = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main")
+
+    fun initialize() {
+        EntityModelLayerRegistry.registerModelLayer(ELECTRONICS_TRADER) {
+            TexturedModelData.of(VillagerResemblingModel.getModelData(), 64, 64)
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt
new file mode 100644
index 0000000..74a7c96
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt
@@ -0,0 +1,19 @@
+package space.autistic.radio.client.antenna
+
+import org.joml.Vector3d
+
+interface AntennaModel {
+    /**
+     * Returns the linear power level/gain to apply for a receiver at the given position. The receiver is assumed to be
+     * vertically oriented.
+     *
+     * Note: 1.0f = 0dB, 0.5f = -3dB (approx.), 0.1f = -10dB.
+     */
+    fun apply(position: Vector3d): Float
+
+    /**
+     * Returns whether to process block/material attenuation. Useful for "global" antennas (i.e. those that return a
+     * constant for all positions given to [apply]).
+     */
+    fun shouldAttenuate(): Boolean
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModelFactory.kt b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModelFactory.kt
new file mode 100644
index 0000000..33a7087
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModelFactory.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.client.antenna
+
+import org.joml.Quaterniond
+
+interface AntennaModelFactory {
+    fun create(orientation: Quaterniond): AntennaModel
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt
new file mode 100644
index 0000000..3c188b6
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt
@@ -0,0 +1,13 @@
+package space.autistic.radio.client.antenna
+
+import org.joml.Vector3d
+
+class NullModel : AntennaModel {
+    override fun apply(position: Vector3d): Float {
+        return 0f
+    }
+
+    override fun shouldAttenuate(): Boolean {
+        return false
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
new file mode 100644
index 0000000..7181e95
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
@@ -0,0 +1,97 @@
+package space.autistic.radio.client.antenna
+
+import com.dylibso.chicory.experimental.aot.AotMachineFactory
+import com.dylibso.chicory.runtime.ExportFunction
+import com.dylibso.chicory.runtime.ImportValues
+import com.dylibso.chicory.runtime.Instance
+import com.dylibso.chicory.wasm.ChicoryException
+import com.dylibso.chicory.wasm.InvalidException
+import com.dylibso.chicory.wasm.Parser
+import com.dylibso.chicory.wasm.types.MemoryLimits
+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
+
+class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
+    var failing = false
+    private val instanceBuilder = run {
+        try {
+            val module = Parser.parse(moduleBytes)
+            Instance.builder(module).withMachineFactory(AotMachineFactory(module)).withImportValues(defaultImports)
+                // capped at 1MB per antenna
+                .withMemoryLimits(MemoryLimits(0, 16))
+        } catch (e: ChicoryException) {
+            logger.log(Level.SEVERE, "Error while trying to parse antenna model.", e)
+            failing = true
+            null
+        }
+    }
+
+    override fun create(orientation: Quaterniond): AntennaModel {
+        if (failing) {
+            return NullModel()
+        }
+        try {
+            val instance = instanceBuilder!!.build()
+            // see basic module abi convention: https://github.com/WebAssembly/tool-conventions/blob/4487bbc2f5a0ad6b5ca76e233bdfa5ed4513dd8c/BasicModuleABI.md
+            var initialize: ExportFunction? = null
+            try {
+                initialize = instance.export("_initialize")
+            } catch (_: InvalidException) {
+                // export may not exist, it's fine
+            }
+            initialize?.apply()
+            // initialize antenna orientation
+            instance.export("set-orientation").apply(
+                orientation.x.toRawBits(),
+                orientation.y.toRawBits(),
+                orientation.z.toRawBits(),
+                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'"
+                )
+                failing = true
+                return NullModel()
+            }
+            val shouldAttenuate = instance.exports().global("should-attenuate").value != 0L
+            val apply = instance.export("apply")
+            return object : AntennaModel {
+                override fun apply(position: Vector3d): Float {
+                    if (failing) {
+                        return 0f
+                    }
+                    try {
+                        return Value.longToFloat(
+                            apply.apply(
+                                position.x.toRawBits(), position.y.toRawBits(), position.z.toRawBits()
+                            )[0]
+                        )
+                    } catch (e: ChicoryException) {
+                        logger.log(Level.SEVERE, "Error while trying to evaluate antenna model.", e)
+                        failing = true
+                        return 0f
+                    }
+                }
+
+                override fun shouldAttenuate(): Boolean {
+                    return shouldAttenuate
+                }
+            }
+        } catch (e: ChicoryException) {
+            logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e)
+            failing = true
+            return NullModel()
+        }
+    }
+
+    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/entity/ElectronicsTraderEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
new file mode 100644
index 0000000..91c29db
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
@@ -0,0 +1,23 @@
+package space.autistic.radio.client.entity
+
+import net.minecraft.client.render.entity.EntityRendererFactory
+import net.minecraft.client.render.entity.MobEntityRenderer
+import net.minecraft.client.render.entity.model.VillagerResemblingModel
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.client.PirateRadioEntityModelLayers
+import space.autistic.radio.entity.ElectronicsTraderEntity
+
+class ElectronicsTraderEntityRenderer(context: EntityRendererFactory.Context) :
+    MobEntityRenderer<ElectronicsTraderEntity, VillagerResemblingModel<ElectronicsTraderEntity>>(
+        context,
+        VillagerResemblingModel(context.getPart(PirateRadioEntityModelLayers.ELECTRONICS_TRADER)),
+        0.5f
+    ) {
+
+    companion object {
+        val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "electronics-trader")
+    }
+
+    override fun getTexture(entity: ElectronicsTraderEntity?): Identifier = TEXTURE
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
new file mode 100644
index 0000000..4bd4db2
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
@@ -0,0 +1,11 @@
+package space.autistic.radio.client.gui
+
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.text.Text
+
+class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
+
+    override fun init() {
+        // TODO
+    }
+}
\ No newline at end of file
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
diff --git a/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt b/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt
new file mode 100644
index 0000000..a4dfe91
--- /dev/null
+++ b/src/test/kotlin/space/autistic/radio/complex/ComplexKtTest.kt
@@ -0,0 +1,13 @@
+package space.autistic.radio.complex
+
+import org.joml.Vector2f
+import org.junit.jupiter.api.Assertions.*
+import kotlin.test.Test
+
+class ComplexKtTest {
+    @Test
+    fun testI() {
+        assertEquals(I.cmul(I), Vector2f(-1f, 0f))
+        assertNotSame(I, I)
+    }
+}
\ No newline at end of file
diff --git a/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt b/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt
new file mode 100644
index 0000000..8a4862c
--- /dev/null
+++ b/src/test/kotlin/space/autistic/radio/fmsim/TestAsserts.kt
@@ -0,0 +1,13 @@
+package space.autistic.radio.fmsim
+
+import kotlin.test.Test
+
+class TestAsserts {
+    @Test
+    fun testFmFullSim() {
+        // initialize and flush an FM modulator
+        // if anything asserts, this should catch it
+        val fmFullModulator = FmFullModulator()
+        fmFullModulator.flush(1f) {}
+    }
+}
\ No newline at end of file