summary refs log tree commit diff stats
path: root/src/main/java/ganarchy
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/ganarchy')
-rw-r--r--src/main/java/ganarchy/friendcode/FriendCode.java21
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java141
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java111
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java110
-rw-r--r--src/main/java/ganarchy/friendcode/client/I2PSamPinger.java113
-rw-r--r--src/main/java/ganarchy/friendcode/client/LanSendPing.java6
-rw-r--r--src/main/java/ganarchy/friendcode/client/SamProxyThread.java184
-rw-r--r--src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java93
-rw-r--r--src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java65
-rw-r--r--src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java17
-rw-r--r--src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java29
-rw-r--r--src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java57
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java333
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamControl.java173
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java220
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java74
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java48
17 files changed, 1795 insertions, 0 deletions
diff --git a/src/main/java/ganarchy/friendcode/FriendCode.java b/src/main/java/ganarchy/friendcode/FriendCode.java
new file mode 100644
index 0000000..8aab90e
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/FriendCode.java
@@ -0,0 +1,21 @@
+package ganarchy.friendcode;
+
+import net.fabricmc.api.ModInitializer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FriendCode implements ModInitializer {
+	// This logger is used to write text to the console and the log file.
+	// It is considered best practice to use your mod id as the logger's name.
+	// That way, it's clear which mod wrote info, warnings, and errors.
+	public static final Logger LOGGER = LoggerFactory.getLogger("friendcode");
+
+	@Override
+	public void onInitialize() {
+		// This code runs as soon as Minecraft is in a mod-load-ready state.
+		// However, some things (like resources) may still be uninitialized.
+		// Proceed with mild caution.
+
+		LOGGER.info("Hello Fabric world!");
+	}
+}
diff --git a/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java
new file mode 100644
index 0000000..38703a5
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java
@@ -0,0 +1,141 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.mixin.FriendCodeIntegratedServerExt;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.OpenToLanScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.gui.widget.CyclingButtonWidget;
+import net.minecraft.client.util.NetworkUtils;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.world.GameMode;
+
+import java.io.IOException;
+
+@Environment(EnvType.CLIENT)
+public class FriendCodeScreen extends Screen {
+    private static final Text ALLOW_COMMANDS_TEXT = Text.translatable("selectWorld.allowCommands");
+    private static final Text GAME_MODE_TEXT = Text.translatable("selectWorld.gameMode");
+    private static final Text OTHER_PLAYERS_TEXT = Text.translatable("lanServer.otherPlayers");
+    private final Screen parent;
+    private GameMode gameMode = GameMode.SURVIVAL;
+    private boolean allowCommands;
+
+    private static final Text START_SHARING = Text.translatable("friendcode.start");
+
+    public FriendCodeScreen(Screen parent) {
+        super(Text.translatable("friendcode.title"));
+        this.parent = parent;
+    }
+
+    @Override
+    protected void init() {
+        // title button allows going back to LAN screen
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 - 155,
+            50,
+            310,
+            20,
+            this.title,
+            button -> this.client.setScreen(new OpenToLanScreen(this.parent))
+        ));
+
+        // game setting buttons
+        this.addDrawableChild(
+            CyclingButtonWidget
+            .builder(GameMode::getSimpleTranslatableName)
+            .values(GameMode.SURVIVAL, GameMode.SPECTATOR, GameMode.CREATIVE, GameMode.ADVENTURE)
+            .initially(this.gameMode)
+            .build(
+                this.width / 2 - 155,
+                100,
+                150,
+                20,
+                GAME_MODE_TEXT,
+                (button, gameMode) -> {
+                    this.gameMode = gameMode;
+                }
+            )
+        );
+        this.addDrawableChild(
+            CyclingButtonWidget
+            .onOffBuilder(this.allowCommands)
+            .build(
+                this.width / 2 + 5,
+                100,
+                150,
+                20,
+                ALLOW_COMMANDS_TEXT,
+                (button, allowCommands) -> {
+                    this.allowCommands = allowCommands;
+                }
+            )
+        );
+
+        // start sharing
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 - 155,
+            this.height - 28,
+            150,
+            20,
+            START_SHARING,
+            button -> {
+                // FIXME
+                int i = NetworkUtils.findLocalPort();
+                var samPinger = openToFriends(this.client, this.gameMode, this.allowCommands, i);
+                MutableText text = samPinger != null ? Text.translatable("commands.publish.started", i) : Text.translatable("commands.publish.failed");
+                this.client.inGameHud.getChatHud().addMessage(text);
+                this.client.updateWindowTitle();
+                this.client.setScreen(new WaitingForFriendCodeScreen(samPinger));
+            }
+        ));
+
+        // go back to options
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 + 5,
+            this.height - 28,
+            150,
+            20,
+            ScreenTexts.CANCEL,
+            button -> this.client.setScreen(this.parent)
+        ));
+    }
+
+    private static I2PSamPinger openToFriends(MinecraftClient client, GameMode gameMode, boolean allowCommands, int port) {
+        try {
+            client.loadBlockList();
+            client.getServer().getNetworkIo().bind(null, port);
+            FriendCode.LOGGER.info("Started serving on {}", port);
+            ((FriendCodeIntegratedServerExt) client.getServer()).lanPort(port);
+            // reuse LAN pinger machinery instead of rolling our own
+            var lanPinger = new I2PSamPinger(client.getServer().getServerMotd(), "" + port);
+            ((FriendCodeIntegratedServerExt) client.getServer()).lanPinger(lanPinger);
+            lanPinger.start();
+            ((FriendCodeIntegratedServerExt) client.getServer()).forcedGameMode(gameMode);
+            client.getServer().getPlayerManager().setCheatsAllowed(allowCommands);
+            int i = client.getServer().getPermissionLevel(client.player.getGameProfile());
+            client.player.setClientPermissionLevel(i);
+            for (ServerPlayerEntity serverPlayerEntity : client.getServer().getPlayerManager().getPlayerList()) {
+                client.getServer().getCommandManager().sendCommandTree(serverPlayerEntity);
+            }
+            return lanPinger;
+        }
+        catch (IOException iOException) {
+            return null;
+        }
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendCodeScreen.drawCenteredText(matrices, this.textRenderer, OTHER_PLAYERS_TEXT, this.width / 2, 82, 0xFFFFFF);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java
new file mode 100644
index 0000000..53282d7
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java
@@ -0,0 +1,111 @@
+package ganarchy.friendcode.client;
+
+import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.DirectConnectScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.gui.widget.TextFieldWidget;
+import net.minecraft.client.network.ServerInfo;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.Text;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.regex.Pattern;
+
+public class FriendConnectScreen extends Screen {
+    private static final Text ENTER_CODE_TEXT = Text.translatable("friendcode.enter_code");
+    /**
+     * Pattern for old-style b32 addresses.
+     */
+    // FIXME? maybe add support for b33 addresses?
+    private static final Pattern B32_PATTERN = Pattern.compile("^[A-Za-z2-7]{52}\\.b32\\.i2p$");
+    private final Screen parent;
+    private final ServerInfo serverEntry;
+    private final BooleanConsumer callback;
+    private TextFieldWidget addressField;
+    private ButtonWidget selectServerButton;
+
+    public FriendConnectScreen(Screen parent, BooleanConsumer callback, ServerInfo server) {
+        super(Text.translatable("friendcode.connect"));
+        this.parent = parent;
+        this.serverEntry = server;
+        this.callback = callback;
+    }
+
+    @Override
+    public void tick() {
+        this.addressField.tick();
+    }
+
+    @Override
+    public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+        if (this.selectServerButton.active && this.getFocused() == this.addressField && (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER)) {
+            this.saveAndClose();
+            return true;
+        }
+        return super.keyPressed(keyCode, scanCode, modifiers);
+    }
+
+    @Override
+    protected void init() {
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 100,
+                20,
+                200,
+                20,
+                this.title,
+                button -> this.client.setScreen(new DirectConnectScreen(this.parent, this.callback, this.serverEntry))
+        ));
+        this.client.keyboard.setRepeatEvents(true);
+        this.selectServerButton = this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 96 + 12, 200, 20, Text.translatable("friendcode.select"), button -> this.saveAndClose()));
+        this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 120 + 12, 200, 20, ScreenTexts.CANCEL, button -> this.callback.accept(false)));
+        this.addressField = new TextFieldWidget(this.textRenderer, this.width / 2 - 100, 116, 200, 20, ENTER_CODE_TEXT);
+        this.addressField.setMaxLength(128);
+        this.addressField.setTextFieldFocused(true);
+        //this.addressField.setText(this.client.options.lastServer);
+        this.addressField.setChangedListener(text -> this.onAddressFieldChanged());
+        this.addSelectableChild(this.addressField);
+        this.setInitialFocus(this.addressField);
+        this.onAddressFieldChanged();
+    }
+
+    @Override
+    public void resize(MinecraftClient client, int width, int height) {
+        String string = this.addressField.getText();
+        this.init(client, width, height);
+        this.addressField.setText(string);
+    }
+
+    private void saveAndClose() {
+        this.client.setScreen(new FriendConnectingScreen(this.serverEntry, this.callback, this.addressField.getText()));
+//        this.serverEntry.address = this.addressField.getText();
+//        this.callback.accept(true);
+    }
+
+    @Override
+    public void close() {
+        this.client.setScreen(this.parent);
+    }
+
+    @Override
+    public void removed() {
+        this.client.keyboard.setRepeatEvents(false);
+        // FIXME store this.addressField.getText() somewhere.
+//        this.client.options.lastServer = this.addressField.getText();
+//        this.client.options.write();
+    }
+
+    private void onAddressFieldChanged() {
+        this.selectServerButton.active = B32_PATTERN.matcher(this.addressField.getText()).matches();
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendConnectScreen.drawTextWithShadow(matrices, this.textRenderer, ENTER_CODE_TEXT, this.width / 2 - 100, 100, 0xA0A0A0);
+        this.addressField.render(matrices, mouseX, mouseY, delta);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java
new file mode 100644
index 0000000..748801c
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java
@@ -0,0 +1,110 @@
+package ganarchy.friendcode.client;
+
+import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
+import net.minecraft.client.gui.screen.LoadingDisplay;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.network.ServerInfo;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Util;
+import org.apache.commons.codec.binary.Base32;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Locale;
+
+public class FriendConnectingScreen extends Screen {
+    private static final Text PATIENCE_TEXT = Text.translatable("friendcode.patience");
+    private final ServerInfo serverEntry;
+    private final BooleanConsumer callback;
+    private final SamProxyThread proxyThread;
+    private long startTimeMs;
+    private Text renderText = null;
+
+    public FriendConnectingScreen(ServerInfo serverEntry, BooleanConsumer callback, String text) {
+        super(Text.translatable("friendcode.connecting"));
+        this.serverEntry = serverEntry;
+        this.callback = callback;
+        this.startTimeMs = Util.getMeasuringTimeMs();
+        this.proxyThread = new SamProxyThread(text);
+    }
+
+    @Override
+    protected void init() {
+        try {
+            this.proxyThread.start();
+        } catch (IllegalThreadStateException ignored) {
+        }
+        // cancel friend code.
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 155,
+                this.height - 28,
+                310,
+                20,
+                ScreenTexts.CANCEL,
+                button -> this.close()
+        ));
+    }
+
+
+    @Override
+    public void close() {
+        super.close();
+        if (this.proxyThread.status() != SamProxyThread.Status.RUNNING) {
+            this.proxyThread.stopProxy();
+        }
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF);
+        if (this.renderText != null) {
+            FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.renderText, this.width / 2, 82, 0xFFFFFF);
+        } else {
+            FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, PATIENCE_TEXT, this.width / 2, 82, 0xFFFFFF);
+            final String string = LoadingDisplay.get(Util.getMeasuringTimeMs());
+            this.client.textRenderer.draw(matrices, string, (float) (this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float) (this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080);
+        }
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+
+    @Override
+    public void tick() {
+        super.tick();
+        if (proxyThread.status() != SamProxyThread.Status.IDLE) {
+            switch (proxyThread.status()) {
+                case CONNECTION_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.no_i2p");
+                    break;
+                }
+                case SETUP_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.failed");
+                    break;
+                }
+                case RESOLUTION_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.no_such_code");
+                    break;
+                }
+                case RUNNING -> {
+                    this.serverEntry.address = "[::1]:" + this.proxyThread.port();
+                    this.callback.accept(true);
+                    break;
+                }
+            }
+        }
+        if (!this.proxyThread.isConnecting()) {
+            startTimeMs = Util.getMeasuringTimeMs();
+        }
+        if (Util.getMeasuringTimeMs() - startTimeMs > 20 * 1000) {
+            // keep it from getting stuck.
+            // FIXME this is wrong.
+            //this.proxyThread.interrupt();
+        }
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java
new file mode 100644
index 0000000..96448db
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java
@@ -0,0 +1,113 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.sam.I2PSamControl;
+import ganarchy.friendcode.sam.I2PSamStreamForwarder;
+import net.minecraft.client.network.LanServerPinger;
+
+import java.io.*;
+
+public class I2PSamPinger extends LanServerPinger implements LanSendPing {
+    private final I2PSamControl sam;
+    private I2PSamStreamForwarder stream;
+    private final String port;
+    private volatile Status status = Status.IDLE;
+    private boolean hasWarned;
+    private volatile boolean stopSam;
+
+    public I2PSamPinger(String motd, String port) throws IOException {
+        super(motd, port);
+        // cba to mixin
+        this.port = port;
+        this.sam = new I2PSamControl(true);
+    }
+
+    @Override
+    public void run() {
+        try (final I2PSamControl sam = this.sam) {
+            if (!sam.connect()) {
+                this.status = Status.CONNECTION_FAILED;
+                // fall back to LAN server.
+                super.run();
+                return;
+            }
+
+            if (!sam.start()) {
+                this.status = Status.SETUP_FAILED;
+                // fall back to LAN server.
+                super.run();
+                return;
+            }
+
+            try (final I2PSamStreamForwarder stream = this.stream = sam.forwardStream(this.port)) {
+                if (!stream.connect()) {
+                    this.status = Status.CONNECTION_FAILED;
+                    super.run();
+                    return;
+                }
+
+                if (!stream.start()) {
+                    this.status = Status.SETUP_FAILED;
+                    super.run();
+                    return;
+                }
+
+                this.status = Status.RUNNING;
+                super.run();
+            } catch (IOException ignored) {
+            }
+        } catch (IOException ignored) {
+        }
+    }
+
+    @Override
+    public void friendcodeSendPing() {
+        if (this.stopSam) {
+            if (this.stream != null) {
+                try {
+                    this.stream.close();
+                } catch (IOException ignored) {
+                }
+            }
+            try {
+                this.sam.close();
+            } catch (IOException ignored) {
+            }
+            this.status = Status.CONNECTION_CLOSED;
+            this.hasWarned = true;
+            return;
+        }
+        if (this.sam.sendPing() < 0) {
+            this.status = Status.CONNECTION_CLOSED;
+        }
+        if (this.stream != null) {
+            if (this.stream.sendPing() < 0) {
+                this.status = Status.CONNECTION_CLOSED;
+            }
+        }
+        if (this.status == Status.CONNECTION_CLOSED && !this.hasWarned) {
+            this.hasWarned = true;
+            FriendCode.LOGGER.warn("Friend Code connection closed!");
+        }
+    }
+
+    public Status status() {
+        return this.status;
+    }
+
+    public String pubkey() {
+        return this.sam.pubkey();
+    }
+
+    public void stopSam() {
+        this.stopSam = true;
+    }
+
+    public enum Status {
+        IDLE,
+        RUNNING,
+        CONNECTION_FAILED,
+        SETUP_FAILED,
+        CONNECTION_CLOSED;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/LanSendPing.java b/src/main/java/ganarchy/friendcode/client/LanSendPing.java
new file mode 100644
index 0000000..914a8d5
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/LanSendPing.java
@@ -0,0 +1,6 @@
+package ganarchy.friendcode.client;
+
+public interface LanSendPing {
+
+    void friendcodeSendPing();
+}
diff --git a/src/main/java/ganarchy/friendcode/client/SamProxyThread.java b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java
new file mode 100644
index 0000000..c7db41f
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java
@@ -0,0 +1,184 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.sam.I2PSamControl;
+import ganarchy.friendcode.sam.I2PSamStreamConnector;
+
+import java.io.IOException;
+import java.net.Inet6Address;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+public class SamProxyThread extends Thread {
+    private final String target;
+    private volatile int localPort;
+    private volatile Status status = Status.IDLE;
+    private volatile boolean running = true;
+    private volatile boolean isConnecting = false;
+
+    public SamProxyThread(String target) {
+        this.target = target;
+        this.setDaemon(true);
+        this.setName("SAM Proxy Control Thread");
+    }
+
+    @Override
+    public void run() {
+        if (this.running) {
+            tryRun(true);
+        }
+        if (this.running) {
+            tryRun(false);
+        }
+        this.status = Status.SETUP_FAILED;
+    }
+
+    private void tryRun(final boolean zeroHop) {
+        // this is all very inefficient but we don't particularly care
+        // who cares if you have 3 threads just to connect to a minecraft server/friend code
+        // it's only 3 threads
+        // the "server" is far more efficient, only requiring one
+        try (final I2PSamControl control = new I2PSamControl(zeroHop)) {
+            if (!control.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            if (!control.start()) {
+                this.running = false;
+                this.status = Status.SETUP_FAILED;
+                return;
+            }
+            switch (control.checkName(this.target)) {
+                case UNKNOWN, INVALID -> {
+                    this.running = false;
+                    this.status = Status.RESOLUTION_FAILED;
+                    return;
+                }
+                case FAILED -> {
+                    return;
+                }
+            }
+            // we can't distinguish if we should retry the connection
+            // (e.g. because building tunnels took too long, destination is known but gateway/peer is not, etc)
+            // vs if we should try with 1hop (because we can't reach peer from our connection,
+            // e.g. ipv4-only client vs ipv6-only friend, symmetric NAT, etc)
+            // so we just try to "warm up" the connection and hope it works.
+            I2PSamStreamConnector warmup = control.connectStream(this.target);
+            if (!warmup.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            warmup.start();
+            warmup.close();
+            I2PSamStreamConnector stream = control.connectStream(this.target);
+            if (!stream.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            this.isConnecting = true;
+            if (!stream.start()) {
+                FriendCode.LOGGER.warn("[SAM error] {}", stream.getStatus());
+                this.isConnecting = false;
+                if (!zeroHop) {
+                    this.running = false;
+                    this.status = Status.SETUP_FAILED;
+                }
+                return;
+            }
+            try (
+                    Socket socket = stream.unwrap();
+                    ServerSocket server = new ServerSocket(0, 8, Inet6Address.getByAddress("localhost", new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0));
+            ) {
+                this.localPort = server.getLocalPort();
+                server.setSoTimeout(3000);
+                this.status = Status.RUNNING;
+                try (Socket single = server.accept()) {
+                    socket.setTcpNoDelay(true);
+                    single.setTcpNoDelay(true);
+                    Thread clientThread = new Thread("SAM Proxy CTS Thread") {
+                        @Override
+                        public void run() {
+                            try (
+                                var clientRead = single.getInputStream();
+                                var serverWrite = socket.getOutputStream();
+                            ) {
+                                byte[] buffer = new byte[128*1024];
+                                while (SamProxyThread.this.running) {
+                                    int read = clientRead.read(buffer);
+                                    if (read > 0) {
+                                        serverWrite.write(buffer, 0, read);
+                                    }
+                                }
+                            } catch (IOException ignored) {
+                            }
+                        }
+                    };
+                    Thread serverThread = new Thread("SAM Proxy STC Thread") {
+                        @Override
+                        public void run() {
+                            try (
+                                var serverRead = socket.getInputStream();
+                                var clientWrite = single.getOutputStream();
+                            ) {
+                                byte[] buffer = new byte[128*1024];
+                                while (SamProxyThread.this.running) {
+                                    int read = serverRead.read(buffer);
+                                    if (read > 0) {
+                                        clientWrite.write(buffer, 0, read);
+                                    }
+                                }
+                            } catch (IOException ignored) {
+                            }
+                        }
+                    };
+                    clientThread.setDaemon(true);
+                    clientThread.start();
+                    serverThread.setDaemon(true);
+                    serverThread.start();
+                    while (!this.isInterrupted() && this.running) {
+                        if (control.sendPing() < 0) {
+                            this.running = false;
+                        }
+                        try {
+                            Thread.sleep(1500L);
+                        } catch (InterruptedException ignored) {
+                        }
+                    }
+                } finally {
+                    this.running = false;
+                }
+            }
+        } catch (IOException e) {
+            // don't really need to do anything
+        }
+    }
+
+    public void stopProxy() {
+        this.running = false;
+        // FIXME this is wrong, need to actually close sockets.
+        this.interrupt();
+    }
+
+    public Status status() {
+        return this.status;
+    }
+
+    public int port() {
+        return this.localPort;
+    }
+
+    public boolean isConnecting() {
+        return this.isConnecting;
+    }
+
+    public enum Status {
+        IDLE,
+        RUNNING,
+        CONNECTION_FAILED,
+        SETUP_FAILED,
+        RESOLUTION_FAILED;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java
new file mode 100644
index 0000000..8429a41
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java
@@ -0,0 +1,93 @@
+package ganarchy.friendcode.client;
+
+import net.minecraft.client.gui.screen.LoadingDisplay;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Util;
+import org.apache.commons.codec.binary.Base32;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Locale;
+
+public class WaitingForFriendCodeScreen extends Screen {
+    private final I2PSamPinger samPinger;
+
+    public WaitingForFriendCodeScreen(I2PSamPinger samPinger) {
+        super(Text.translatable("friendcode.opening"));
+        this.samPinger = samPinger;
+    }
+
+    @Override
+    protected void init() {
+        // cancel friend code.
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 155,
+                this.height - 28,
+                310,
+                20,
+                ScreenTexts.CANCEL,
+                button -> this.close()
+        ));
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        WaitingForFriendCodeScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF);
+        final String string = LoadingDisplay.get(Util.getMeasuringTimeMs());
+        this.client.textRenderer.draw(matrices, string, (float)(this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float)(this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+
+    @Override
+    public void close() {
+        super.close();
+        this.samPinger.stopSam();
+        final MutableText text = Text.translatable("friendcode.publish.stopped");
+        this.client.inGameHud.getChatHud().addMessage(text);
+    }
+
+    @Override
+    public void tick() {
+        super.tick();
+        if (samPinger.status() != I2PSamPinger.Status.IDLE) {
+            this.client.setScreen(null);
+            switch (samPinger.status()) {
+                case CONNECTION_FAILED -> {
+                    final MutableText text = Text.translatable("friendcode.publish.no_i2p");
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+                case SETUP_FAILED, CONNECTION_CLOSED -> {
+                    final MutableText text = Text.translatable("friendcode.publish.failed");
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+                case RUNNING -> {
+                    // convert from I2P base64 to URL-safe base64 so we don't have to write our own Base64 decoder
+                    final String pubkey_b64 = samPinger.pubkey().replace('~', '_');
+                    final byte[] pubkey = Base64.getUrlDecoder().decode(pubkey_b64.getBytes(StandardCharsets.UTF_8));
+                    // a .b32.i2p is just a Base32-encoded SHA256
+                    final String b32;
+                    try {
+                        b32 = new Base32().encodeAsString(MessageDigest.getInstance("SHA-256").digest(pubkey));
+                    } catch (NoSuchAlgorithmException e) {
+                        throw new RuntimeException(e);
+                    }
+                    // but we wanna massage it a little
+                    final String b32_i2p = b32.replaceFirst("=+$", "").toLowerCase(Locale.ROOT) + ".b32.i2p";
+                    final MutableText text = Text.translatable("friendcode.publish.started", b32_i2p);
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java b/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java
new file mode 100644
index 0000000..6b3a182
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java
@@ -0,0 +1,65 @@
+package ganarchy.friendcode.mixin;
+
+import ganarchy.friendcode.client.FriendCodeScreen;
+import ganarchy.friendcode.client.FriendConnectScreen;
+import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.screen.DirectConnectScreen;
+import net.minecraft.client.gui.screen.OpenToLanScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.network.ServerInfo;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.text.Text;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(DirectConnectScreen.class)
+public abstract class DirectConnectScreenMixin extends Screen {
+    protected DirectConnectScreenMixin(Text title) {
+        super(title);
+    }
+
+    @Shadow
+    private Screen parent;
+    @Shadow
+    private BooleanConsumer callback;
+    @Shadow
+    private ServerInfo serverEntry;
+
+    @Inject(at = @At("HEAD"), method = "init()V")
+    private void init(CallbackInfo info) {
+        // title, meet button
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 100,
+                20,
+                200,
+                20,
+                this.title,
+                button -> this.client.setScreen(new FriendConnectScreen(this.parent, this.callback, this.serverEntry))
+        ));
+    }
+
+    @Redirect(
+            method = "render(Lnet/minecraft/client/util/math/MatrixStack;IIF)V",
+            at = @At(
+                    value = "INVOKE",
+                    target = "Lnet/minecraft/client/gui/screen/DirectConnectScreen;drawCenteredText(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;III)V",
+                    ordinal = 0
+            )
+    )
+    private void removeTitle(
+            MatrixStack matrices,
+            TextRenderer renderer,
+            Text text,
+            int centerX,
+            int y,
+            int color
+    ) {
+        // literally does nothing
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java b/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java
new file mode 100644
index 0000000..49d3b3f
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java
@@ -0,0 +1,17 @@
+package ganarchy.friendcode.mixin;
+
+import net.minecraft.client.network.LanServerPinger;
+import net.minecraft.server.integrated.IntegratedServer;
+import net.minecraft.world.GameMode;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(IntegratedServer.class)
+public interface FriendCodeIntegratedServerExt {
+    @Accessor("forcedGameMode")
+    void forcedGameMode(GameMode gameMode);
+    @Accessor("lanPort")
+    void lanPort(int port);
+    @Accessor("lanPinger")
+    void lanPinger(LanServerPinger lanPinger);
+}
diff --git a/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java b/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java
new file mode 100644
index 0000000..187904d
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java
@@ -0,0 +1,29 @@
+package ganarchy.friendcode.mixin;
+
+import ganarchy.friendcode.client.LanSendPing;
+import net.minecraft.client.network.LanServerPinger;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(LanServerPinger.class)
+public class LanPingerMixin implements LanSendPing {
+    @Inject(
+        method = "run()V",
+        remap = false,
+        at = @At(
+            remap = false,
+            value = "INVOKE",
+            target = "Ljava/net/InetAddress;getByName(Ljava/lang/String;)Ljava/net/InetAddress;"
+        )
+    )
+    public void onSendPing(CallbackInfo info) {
+        this.friendcodeSendPing();
+    }
+
+    @Override
+    public void friendcodeSendPing() {
+
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java b/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java
new file mode 100644
index 0000000..fd2cccb
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java
@@ -0,0 +1,57 @@
+package ganarchy.friendcode.mixin;
+
+import ganarchy.friendcode.client.FriendCodeScreen;
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.screen.OpenToLanScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.text.Text;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(OpenToLanScreen.class)
+public abstract class OpenToLanScreenMixin extends Screen {
+	protected OpenToLanScreenMixin(Text title) {
+		super(title);
+	}
+
+	@Shadow
+	private Screen parent;
+
+	@Inject(at = @At("HEAD"), method = "init()V")
+	private void init(CallbackInfo info) {
+		// title, meet button
+		this.addDrawableChild(new ButtonWidget(
+			this.width / 2 - 155,
+			50,
+			310,
+			20,
+			this.title,
+			button -> this.client.setScreen(new FriendCodeScreen(this.parent))
+		));
+	}
+
+	@Redirect(
+		method = "render(Lnet/minecraft/client/util/math/MatrixStack;IIF)V",
+		at = @At(
+			value = "INVOKE",
+			target = "Lnet/minecraft/client/gui/screen/OpenToLanScreen;drawCenteredText(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;III)V",
+			ordinal = 0
+		)
+	)
+	private void removeTitle(
+		MatrixStack matrices,
+		TextRenderer renderer,
+		Text text,
+		int centerX,
+		int y,
+		int color
+	) {
+		// literally does nothing
+	}
+}
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java
new file mode 100644
index 0000000..bed5bb8
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java
@@ -0,0 +1,333 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * An I2P SAM command.
+ *
+ * @param name The command name.
+ * @param parameters The parameters.
+ */
+record I2PSamCommand(String name, String opcode, Map<String, String> parameters) {
+    /**
+     * Creates a new I2P SAM command.
+     *
+     * @param name The command name.
+     * @param opcode The subcommand name, or null.
+     * @param parameters The parameters.
+     * @throws IllegalArgumentException If the command or parameters don't follow the expected format.
+     * @throws NullPointerException If anything (except for opcode) is null.
+     */
+    public I2PSamCommand(final String name, final String opcode, final Map<String, String> parameters) {
+        this.name = name;
+        this.opcode = opcode;
+        this.parameters = ImmutableMap.copyOf(parameters);
+
+        // command formats:
+        // COMMAND SUBCOMMAND KEY KEY= KEY="" KEY=VALUE KEY=" " KEY="\"" KEY="\\"
+        // COMMAND
+        // PING[ arbitrary text]
+        // PONG[ arbitrary text]
+        // none of these may contain newlines (\n)
+
+        // edge-case: I2PRouter treats empty values as "true", while we distinguish empty values.
+        // it's not strictly specified how they are to be handled, but anyway.
+
+        // check newlines
+        if (this.name.contains("\n") || (this.opcode != null && this.opcode.contains("\n")) || this.parameters.entrySet().stream().anyMatch(arg -> arg.getKey().contains("\n") || arg.getValue().contains("\n"))) {
+            throw new IllegalArgumentException("commands may not contain embedded newlines");
+        }
+
+        if (this.name.isEmpty()) {
+            throw new IllegalArgumentException("name must not be empty");
+        }
+
+        // check for PING/PONG
+        if (this.name.equals("PING") || this.name.equals("PONG")) {
+            // reject parameters
+            if (!this.parameters.isEmpty()) {
+                throw new IllegalArgumentException("PING/PONG does not accept parameters");
+            }
+            // skip any other validation
+            return;
+        }
+
+        // everything else is fine
+
+        // NOTE: we don't special-case I2PRouter's weirdness with \"\"COMMAND\"\" and \"\"OPCODE\"\"
+        // for one, we require name != null and !name.isEmpty(), so \"\"COMMAND\"\" would error on I2PRouter anyway,
+        // for two, single-word commands don't take a subcommand, so it doesn't even matter if you set \"\"OPCODE\"\".
+    }
+
+    /**
+     * Creates a new I2P SAM command, with no parameters.
+     *
+     * @param name The command name.
+     * @param opcode The subcommand name, or null.
+     */
+    public I2PSamCommand(final String name, final String opcode) {
+        this(name, opcode, ImmutableMap.of());
+    }
+
+    /**
+     * Builds the complete command line.
+     *
+     * @return The complete command line.
+     */
+    public String buildCommandLine() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(escape(this.name, false));
+        if (this.opcode != null) {
+            sb.append(' ');
+            if (this.name.equals("PING") || this.name.equals("PONG")) {
+                sb.append(this.opcode);
+            } else {
+                sb.append(escape(this.opcode, false));
+            }
+        }
+        for (final var argument : parameters.entrySet()) {
+            sb.append(' ');
+            sb.append(escape(argument.getKey(), false));
+            sb.append('=');
+            sb.append(escape(argument.getValue(), true));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Parses the given command line and returns an I2PSamCommand.
+     *
+     * @param commandLine The command line, without terminating newline.
+     * @return The parsed command, or null if there was an error.
+     */
+    public static I2PSamCommand parse(final String commandLine) {
+        // we're a bit more strict than I2PRouter in how we parse these, but that should be okay.
+        if (commandLine.isEmpty()) {
+            return null;
+        }
+        if (commandLine.contains("\n")) {
+            throw new IllegalArgumentException("commandLine may not contain embedded newlines");
+        }
+        final var rawParams = new StringBuilder(commandLine);
+        int index;
+        index = nextToken(rawParams);
+        final String name = rawParams.substring(0, index);
+        if (index == rawParams.length()) { // invariant: length() != 0
+            // COMMAND
+            // (includes PING/PONG with no data)
+            return new I2PSamCommand(name, null);
+        } else if (name.isEmpty() || rawParams.charAt(index) != ' ') {
+            throw new IllegalArgumentException("commandLine must start with a command");
+        }
+        rawParams.delete(0, index + 1);
+        if (name.equals("PING") || name.equals("PONG")) {
+            // PING arbitrary text
+            // PONG arbitrary text
+            return new I2PSamCommand(name, rawParams.toString());
+        }
+        // COMMAND SUBCOMMAND[ ...]
+        index = nextToken(rawParams);
+        final String opcode = rawParams.substring(0, index);
+        if (opcode.isEmpty()) { // also if length() == 0
+            // COMMAND =... or some other BS
+            // we do not currently accept trailing spaces or parameters for single-word commands coming from the bridge.
+            throw new IllegalArgumentException("expected subcommand");
+        } else if (index == rawParams.length()) { // invariant: length() != 0 (checked above)
+            // COMMAND SUBCOMMAND
+            return new I2PSamCommand(name, opcode);
+        } else if (rawParams.charAt(index) != ' ') { // index != length()
+            throw new IllegalArgumentException("expected subcommand");
+        }
+        rawParams.delete(0, index + 1);
+        // COMMAND SUBCOMMAND ...
+        final ImmutableMap.Builder<String, String> params = ImmutableMap.builder();
+        // params
+        while (!rawParams.isEmpty()) {
+            index = nextToken(rawParams);
+            String key = rawParams.substring(0, index);
+            if (key.isEmpty()) {
+                throw new IllegalArgumentException("expected parameter key");
+            } else if (index == rawParams.length()) {
+                // KEY
+                params.put(key, "");
+                rawParams.setLength(0);
+            } else if (rawParams.charAt(index) == ' ') {
+                // KEY ...
+                params.put(key, "");
+                rawParams.delete(0, index + 1);
+            } else if (rawParams.charAt(index) == '=') {
+                rawParams.delete(0, index + 1);
+                if (rawParams.length() == 0) {
+                    // KEY=
+                    params.put(key, "");
+                } else if (rawParams.charAt(0) == '"') {
+                    // KEY="...
+                    // just special-case it
+                    rawParams.deleteCharAt(0);
+                    for (; index < rawParams.length(); index++) {
+                        final int c = rawParams.charAt(index);
+                        if (c == '\r' || c == '"') {
+                            // characters that must be escaped in quotes
+                            break;
+                        } else if (c == '\\') {
+                            // skip next character
+                            rawParams.deleteCharAt(index);
+                            if (index == rawParams.length()) {
+                                throw new IllegalArgumentException("unterminated escape");
+                            }
+                        }
+                    }
+                    if (index == rawParams.length()) {
+                        // KEY="
+                        throw new IllegalArgumentException("unterminated quote");
+                    } else if (rawParams.charAt(index) == '"') {
+                        // KEY=""...
+                        final String value = rawParams.substring(0, index);
+                        params.put(key, value);
+                        if (index + 1 == rawParams.length()) {
+                            // KEY=""
+                            rawParams.delete(0, index + 1);
+                        } else if (rawParams.charAt(index + 1) == ' ') {
+                            // KEY="" ...
+                            rawParams.delete(0, index + 2);
+                        } else {
+                            throw new IllegalArgumentException("malformed quote");
+                        }
+                    } else {
+                        throw new IllegalArgumentException("malformed quote");
+                    }
+                } else {
+                    // KEY=...
+                    index = nextToken(rawParams);
+                    if (index == rawParams.length()) {
+                        // KEY=VALUE
+                        final String value = rawParams.substring(0, index);
+                        params.put(key, value);
+                        rawParams.delete(0, index);
+                    } else if (rawParams.charAt(index) == '=') {
+                        // KEY=VALUE=...
+                        // just special-case it
+                        for (; index < rawParams.length(); index++) {
+                            final int c = rawParams.charAt(index);
+                            if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '\r' || c == '"') {
+                                // characters that must (generally) be escaped
+                                break;
+                            } else if (c == '\\') {
+                                // skip next character
+                                rawParams.deleteCharAt(index);
+                                if (index == rawParams.length()) {
+                                    throw new IllegalArgumentException("unterminated escape");
+                                }
+                            }
+                        }
+                        final String value = rawParams.substring(0, index);
+                        if (index == rawParams.length()) {
+                            // KEY=VALUE=
+                            params.put(key, value);
+                            rawParams.delete(0, index);
+                        } else if (rawParams.charAt(index) == ' ') {
+                            // KEY=VALUE= ...
+                            params.put(key, value);
+                            rawParams.delete(0, index + 1);
+                        } else {
+                            throw new IllegalArgumentException("malformed parameter value");
+                        }
+                    } else if (rawParams.charAt(index) == ' ') {
+                        // KEY=VALUE ...
+                        final String value = rawParams.substring(0, index);
+                        params.put(key, value);
+                        rawParams.delete(0, index + 1);
+                    } else {
+                        throw new IllegalArgumentException("malformed parameter value");
+                    }
+                }
+            } else {
+                throw new IllegalArgumentException("expected parameter key");
+            }
+        }
+        return new I2PSamCommand(name, opcode, params.build());
+    }
+
+    @Override
+    public String toString() {
+        // The built command line is likely nicer to use when debugging.
+        return this.buildCommandLine();
+    }
+
+    /**
+     * Escapes and optionally quotes a string.
+     *
+     * @param input The input string.
+     * @param valueSection Whether this is a value section.
+     * @return The escaped and optionally quoted string.
+     */
+    private static String escape(final String input, final boolean valueSection) {
+        final StringBuilder sb = new StringBuilder(0);
+        int lastIndex = 0;
+        for (int index = 0; index < input.length(); index++) {
+            final int c = input.charAt(index);
+            final boolean escape;
+            if (c == '=') {
+                // only escaped outside of value section, but does not require quoting.
+                escape = !valueSection;
+            } else if (c == ' ' || c == '\t' || c == '\f' || c == '\b') {
+                // only escaped outside of value section. requires quoting.
+                escape = !valueSection;
+                if (valueSection) {
+                    // make sure the sb isn't empty so it actually adds quotes
+                    sb.append(input, lastIndex, index + 1);
+                    lastIndex = index + 1;
+                }
+            } else if (c == '\r' || c == '"' || c == '\\') {
+                // always escaped
+                escape = true;
+            } else {
+                // never escaped
+                escape = false;
+            }
+            if (escape) {
+                sb.append(input, lastIndex, index);
+                sb.append('\\');
+                sb.append(c);
+                lastIndex = index + 1;
+            }
+        }
+        if (!sb.isEmpty()) {
+            sb.append(input, lastIndex, input.length());
+            if (valueSection) {
+                sb.insert(0, '"');
+                sb.append('"');
+            }
+            return sb.toString();
+        } else {
+            return input;
+        }
+    }
+
+    /**
+     * Parses the next token.
+     *
+     * @param rawParams The input being processed.
+     * @return The index of the end of the next token.
+     */
+    private static int nextToken(final StringBuilder rawParams) {
+        int index;
+        for (index = 0; index < rawParams.length(); index++) {
+            final int c = rawParams.charAt(index);
+            if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '=' || c == '\r' || c == '"') {
+                // characters that must (generally) be escaped
+                break;
+            } else if (c == '\\') {
+                // skip next character
+                rawParams.deleteCharAt(index);
+                if (index == rawParams.length()) {
+                    throw new IllegalArgumentException("unterminated escape");
+                }
+            }
+        }
+        return index;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java
new file mode 100644
index 0000000..f470bb0
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java
@@ -0,0 +1,173 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+
+public class I2PSamControl extends I2PSamStateMachine {
+    private final boolean zeroHop;
+    private String pubkey;
+    private String id;
+
+    public I2PSamControl(boolean zeroHop) {
+        this.zeroHop = zeroHop;
+    }
+
+    @Override
+    public boolean connect() {
+        try {
+            // try IPv6 first
+            // there's no Inet6Address.getLoopbackAddress() which is unfortunate.
+            final Socket samSocket = new Socket();
+            samSocket.connect(new InetSocketAddress(Inet6Address.getByAddress("localhost", new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}, 0), 7656), 3000);
+            return this.connect(samSocket);
+        } catch (IOException e) {
+            try {
+                final Socket samSocket = new Socket();
+                samSocket.connect(new InetSocketAddress(Inet4Address.getByAddress("localhost", new byte[] {127,0,0,1}), 7656), 3000);
+                return this.connect(samSocket);
+            } catch (IOException ex) {
+                return false;
+            }
+        }
+    }
+
+    @Override
+    public boolean start() {
+        if (!super.start()) {
+            return false;
+        }
+        try {
+            // try to enable auth
+            this.enableAuth();
+            // FIXME "Friend Code Type: World" vs "Friend Code Type: Session"
+            // generate our keys
+            this.sendCommand(new I2PSamCommand(
+                "DEST", "GENERATE",
+                ImmutableMap.of("SIGNATURE_TYPE", "EdDSA_SHA512_Ed25519")
+            ));
+            var dest = this.getCommand("DEST", "REPLY");
+            this.pubkey = dest.parameters().get("PUB");
+            var privkey = dest.parameters().get("PRIV");
+            // setup our session
+            int i = 0;
+            String status;
+            do {
+                i++;
+                // we want a session with the given privkey and zero hops for maximum performance
+                // this isn't anonymous but then it's not meant to be - we're using I2P as a free STUN/TURN service
+                // the connecting clients handle the need for TURN by setting their hop count to 1 as needed
+                this.id = "minecraft_friendcode_" + i;
+                final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+                this.sendCommand(new I2PSamCommand(
+                    "SESSION", "CREATE",
+                    builder.put(
+                        "STYLE", "STREAM"
+                    ).put(
+                        "ID", this.id
+                    ).put(
+                        "DESTINATION", privkey
+                    ).put(
+                        "inbound.length", this.zeroHop ? "0" : "1"
+                    ).put(
+                        "outbound.length", this.zeroHop ? "0" : "1"
+                    ).put(
+                        "inbound.allowZeroHop", "true"
+                    ).put(
+                        "outbound.allowZeroHop", "true"
+                    ).put(
+                        "shouldBundleReplyInfo", "true"
+                    ).put(
+                        "i2cp.dontPublishLeaseSet", "false"
+                    ).put(
+                        "streaming.maxWindowSize", "1024"
+                    ).build()
+                ));
+            } while ("DUPLICATED_ID".equals(status = this.getCommand("SESSION", "STATUS").parameters().get("RESULT")));
+            return "OK".equals(status);
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Creates a stream forwarder.
+     * @return A stream forwarder.
+     */
+    public I2PSamStreamForwarder forwardStream(String port) {
+        return new I2PSamStreamForwarder(this.getSamBridgeAddress(), id, port);
+    }
+
+    /**
+     * Creates a stream forwarder.
+     * @return A stream connector.
+     */
+    public I2PSamStreamConnector connectStream(String b32) {
+        return new I2PSamStreamConnector(this.getSamBridgeAddress(), id, b32);
+    }
+
+    /**
+     * Returns the session pubkey.
+     */
+    public String pubkey() {
+        return this.pubkey;
+    }
+
+    /**
+     * Set up and enable auth.
+     *
+     * @throws IOException If an I/O error occurs.
+     */
+    private void enableAuth() throws IOException {
+        // enable auth (if it hasn't been explicitly disabled by the user), for the user's sake
+        // (ugh i2p you really fucked up by not making this the default)
+        // btw tor does this with a filesystem path to an auth cookie (which is much more secure) but we digress
+        this.sendCommand(new I2PSamCommand(
+            "AUTH", "ADD",
+            ImmutableMap.of("USER", "minecraft_friendcode", "PASSWORD", "friendcode")
+        ));
+        // if the user already exists, don't enable auth
+        if ("OK".equals(this.getCommand("AUTH", "STATUS").parameters().get("RESULT"))) {
+            this.sendCommand(new I2PSamCommand("AUTH", "ENABLE"));
+            // ignore the response on this one, just get it off the queue
+            this.getCommand("AUTH", "STATUS");
+        }
+    }
+
+    public NameStatus checkName(String target) {
+        try {
+            this.sendCommand(new I2PSamCommand("NAMING", "LOOKUP", ImmutableMap.of("NAME", target)));
+            I2PSamCommand result = this.getCommand("NAMING", "REPLY");
+            if (result.parameters().get("RESULT") == null) {
+                return NameStatus.FAILED;
+            }
+            switch (result.parameters().get("RESULT")) {
+                case "OK" -> {
+                    return NameStatus.OK;
+                }
+                case "INVALID_KEY" -> {
+                    return NameStatus.INVALID;
+                }
+                case "KEY_NOT_FOUND" -> {
+                    return NameStatus.UNKNOWN;
+                }
+                default -> {
+                    return NameStatus.FAILED;
+                }
+            }
+        } catch (IOException e) {
+            return NameStatus.FAILED;
+        }
+    }
+
+    public enum NameStatus {
+        OK,
+        INVALID,
+        UNKNOWN,
+        FAILED;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
new file mode 100644
index 0000000..d05717d
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
@@ -0,0 +1,220 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+import ganarchy.friendcode.FriendCode;
+import net.minecraft.util.Util;
+
+import java.io.*;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.Iterator;
+import java.util.Objects;
+
+public abstract class I2PSamStateMachine implements Closeable {
+    // FIXME user/password from config
+    private static final I2PSamCommand HELLO_MESSAGE = new I2PSamCommand(
+        "HELLO", "VERSION",
+        ImmutableMap.of(
+            "MIN", "3.2",
+            "USER", "minecraft_friendcode",
+            "PASSWORD", "friendcode"
+        )
+    );
+    private Socket samSocket;
+    private PushbackReader reader;
+    private OutputStreamWriter writer;
+    private final ArrayDeque<I2PSamCommand> queue = new ArrayDeque<>();
+    private boolean started;
+    private boolean connected;
+    private String id;
+
+    protected I2PSamStateMachine() {
+    }
+
+    /**
+     * Connects to the SAM socket.
+     *
+     * @return Whether the connection was successful.
+     */
+    public abstract boolean connect();
+
+    protected boolean connect(Socket samSocket) {
+        this.samSocket = samSocket;
+        if (this.connected) {
+            throw new IllegalStateException();
+        }
+        this.connected = true;
+        return true;
+    }
+
+    /**
+     * Starts the SAM session.
+     *
+     * @return Whether we were able to start the SAM session.
+     */
+    public boolean start() {
+        if (!this.connected) {
+            throw new IllegalStateException();
+        }
+        if (this.started) {
+            throw new IllegalStateException();
+        }
+        this.started = true;
+        try {
+            // 8 KiC limit
+            this.reader = new PushbackReader(new InputStreamReader(this.samSocket.getInputStream(), StandardCharsets.UTF_8), 8192);
+            this.writer = new OutputStreamWriter(this.samSocket.getOutputStream(), StandardCharsets.UTF_8);
+
+
+            // send HELLO
+            // FIXME let the user change the credentials
+            this.sendCommand(HELLO_MESSAGE);
+            return "OK".equals(this.getCommand("HELLO", "REPLY").parameters().get("RESULT"));
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Attempts to step the SAM session.
+     *
+     * This will generally not block, unless something went wrong.
+     */
+    public void tryStep() {
+        try {
+            if (this.reader != null && this.reader.ready()) {
+                this.step();
+            }
+        } catch (IOException ignored) {
+        }
+    }
+
+    /**
+     * Steps the SAM session.
+     *
+     * @throws IOException If an I/O error occurs.
+     */
+    public void step() throws IOException {
+        this.writer.flush();
+        final String line;
+        // 8 KiC limit
+        final var buffer = new char[8192];
+        final var builder = new StringBuilder();
+        int cursor = 0;
+        while (true) {
+            final int read = this.reader.read(buffer, cursor, buffer.length - cursor);
+            if (read < 0) {
+                line = null;
+                break;
+            }
+            builder.append(buffer, cursor, read);
+            final int target;
+            if ((target = builder.indexOf("\n", cursor) + 1) > 0) {
+                this.reader.unread(buffer, target, cursor + read - target);
+                builder.setLength(target - 1);
+                line = builder.toString();
+                break;
+            }
+            cursor += read;
+        }
+        if (line == null) {
+            throw new EOFException();
+        }
+        // NOTE \r will result in fuckery, but the bridge should not generally send them
+        FriendCode.LOGGER.debug("[SAM (RAW)] {}", line);
+        final I2PSamCommand command;
+        try {
+            command = I2PSamCommand.parse(line);
+        } catch (IllegalArgumentException e) {
+            FriendCode.LOGGER.error("[SAM Error] Closing SAM bridge.", e);
+            this.samSocket.close();
+            return;
+        }
+        if (command == null) {
+            // empty line
+            return;
+        }
+        if (command.name().equals("PING")) {
+            this.sendCommand(new I2PSamCommand("PONG", command.opcode(), command.parameters()));
+        } else {
+            queue.push(command);
+        }
+    }
+
+    /**
+     * Reads a name from the SAM session.
+     *
+     * @param name The command name.
+     * @param opcode The subcommand name.
+     * @return The name line, or null if something went wrong.
+     * @throws IOException If an I/O error occurs.
+     */
+    protected I2PSamCommand getCommand(String name, String opcode) throws IOException {
+        final boolean checkPing = name.equals("PING") || name.equals("PONG");
+        while (true) {
+            // check queue for lines
+            for (Iterator<I2PSamCommand> iterator = this.queue.iterator(); iterator.hasNext(); ) {
+                I2PSamCommand queued = iterator.next();
+                // special check for PING/PONG
+                if (queued.name().equals(name) && (checkPing || Objects.equals(queued.opcode(), opcode))) {
+                    iterator.remove();
+                    return queued;
+                }
+            }
+            // if that failed, read more lines
+            this.step();
+        }
+    }
+
+    protected void sendCommand(I2PSamCommand command) throws IOException {
+        String line = command.buildCommandLine() + "\n";
+        if (line.length() >= 8192) {
+            throw new IOException("command too long - max 8 KiC");
+        }
+        this.writer.write(line);
+    }
+
+    /**
+     * Sends a PING.
+     *
+     * @return The round-trip-time in milliseconds.
+     */
+    public long sendPing() {
+        try {
+            long time = Util.getMeasuringTimeMs();
+            this.sendCommand(new I2PSamCommand("PING", "" + time));
+            return Util.getMeasuringTimeMs() - Long.parseLong(this.getCommand("PONG", null).opcode());
+        } catch (IOException e) {
+            return -1;
+        }
+    }
+
+    /**
+     * Returns the SAM bridge address.
+     * @return The SAM bridge address.
+     */
+    protected SocketAddress getSamBridgeAddress() {
+        if (!this.connected) {
+            throw new IllegalStateException("not connected");
+        }
+        return this.samSocket.getRemoteSocketAddress();
+    }
+
+    /**
+     * Closes this connection.
+     * @throws IOException As per {@link Socket#close()}.
+     */
+    public void close() throws IOException {
+        this.samSocket.close();
+    }
+
+    protected Socket unwrap() {
+        Socket socket = this.samSocket;
+        this.samSocket = null;
+        this.reader = null;
+        this.writer = null;
+        return socket;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java
new file mode 100644
index 0000000..95b6a61
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java
@@ -0,0 +1,74 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.io.IOException;
+import java.net.*;
+
+public class I2PSamStreamConnector extends I2PSamStateMachine {
+    private final String id;
+    private final SocketAddress socketAddress;
+    private final String friendCode;
+    private boolean connected;
+    private I2PSamCommand status;
+
+    public I2PSamStreamConnector(SocketAddress socketAddress, String id, String friendCode) {
+        this.id = id;
+        this.socketAddress = socketAddress;
+        this.friendCode = friendCode;
+    }
+
+    @Override
+    public boolean connect() {
+        try {
+            Socket samSocket = new Socket();
+            samSocket.connect(this.socketAddress, 3000);
+            return this.connect(samSocket);
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    public boolean start() {
+        if (!super.start()) {
+            return false;
+        }
+        try {
+            this.sendCommand(new I2PSamCommand(
+                "STREAM", "CONNECT",
+                ImmutableMap.of(
+                        "ID", this.id,
+                        "DESTINATION", this.friendCode
+                )
+            ));
+            return this.connected = "OK".equals((this.status = this.getCommand("STREAM", "STATUS")).parameters().get("RESULT"));
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    @Override
+    protected void sendCommand(I2PSamCommand command) throws IOException {
+        if (this.connected) {
+            throw new IllegalStateException("call unwrap() instead");
+        }
+        super.sendCommand(command);
+    }
+
+    @Override
+    public void step() throws IOException {
+        if (this.connected) {
+            throw new IllegalStateException("call unwrap() instead");
+        }
+        super.step();
+    }
+
+    @Override
+    public Socket unwrap() {
+        return super.unwrap();
+    }
+
+    public I2PSamCommand getStatus() {
+        return this.status;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java
new file mode 100644
index 0000000..1813511
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java
@@ -0,0 +1,48 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.io.IOException;
+import java.net.*;
+
+public class I2PSamStreamForwarder extends I2PSamStateMachine {
+    private final String id;
+    private final String port;
+    private final SocketAddress socketAddress;
+
+    public I2PSamStreamForwarder(SocketAddress socketAddress, String id, String port) {
+        this.id = id;
+        this.port = port;
+        this.socketAddress = socketAddress;
+    }
+
+    @Override
+    public boolean connect() {
+        try {
+            Socket samSocket = new Socket();
+            samSocket.connect(this.socketAddress, 3000);
+            return this.connect(samSocket);
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    public boolean start() {
+        if (!super.start()) {
+            return false;
+        }
+        try {
+            this.sendCommand(new I2PSamCommand(
+                "STREAM", "FORWARD",
+                ImmutableMap.of(
+                    "ID", this.id,
+                    "PORT", this.port,
+                    "SILENT", "true"
+                )
+            ));
+            return "OK".equals(this.getCommand("STREAM", "STATUS").parameters().get("RESULT"));
+        } catch (IOException e) {
+            return false;
+        }
+    }
+}