Minecraft Damage Mod

Guide

Clone the template from GitHub and checkout the 1.21 branch, which is the default one.

Create a new flake (because we are using Nix).

git clone https://github.com/FabricMC/fabric-example-mod
cd fabric-example-mod
git checkout 1.21
nix flake init

Next we will need to add a nix-shell to the flake. The flake will look like this:

{
  description = "A basic flake for my Minecraft mod";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: (
    flake-utils.lib.eachDefaultSystem
    (system: let
      pkgs = import nixpkgs {
        inherit system;

        config = {
          allowUnfree = true;
        };
      };
    in {
      packages = {
          default = {}; # TODO: Add package here
      };

      apps = {
        # TODO: Add app here
        # default = flake-utils.lib.mkApp {
        #   drv = ...;
        # };
      };

      devShells.default = pkgs.mkShell rec {
        nativeBuildInputs = [];

        buildInputs = with pkgs; [
          flite
          glfw
          gradle
          jdk21
          libGL
          openal
          alsa-lib

          pkgsCross.avr.buildPackages.gcc
          pkgsCross.avr.buildPackages.avrdude
        ];

        LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;

        shellHook = ''
          export JAVA_HOME=${pkgs.jdk21}
        '';
      };
    })
  );
}

Now we can enter the nix-shell:

nix develop --show-trace  --command fish

While this is installing let's setup the C part of the mod. I will use a makefile to build the C code.

MCU = atmega328p
F_CPU = 16000000UL
CC = avr-gcc
CFLAGS = -mmcu=$(MCU) -DF_CPU=$(F_CPU)  -Wall -Wextra -Os -std=gnu11 -g
PORT = /dev/ttyACM0  # or /dev/ttyUSB0 depending on your setup
PROGRAMMER = arduino
BAUD = 115200

main.elf: main.o
	$(CC) $(CFLAGS) -o $@ $^

main.o: main.c
	$(CC) $(CFLAGS) -c $< -o $@

main.hex: main.elf
	avr-objcopy -j .text -j .data -O ihex $< $@

upload: main.hex
	sudo avrdude -v -p $(MCU) -c $(PROGRAMMER) -P $(PORT) -b $(BAUD) -U flash:w:main.hex

clean:
	rm -f *.o *.elf *.hex

.PHONY: upload clean

To have the clang lsp working we need to add a compile_flags.txt file in the project root with the following content:

-std=gnu11
-xc
--target=avr
-mmcu=atmega328p
-I/nix/store/{path-to-avr-gcc}/avr/sys-include/

To get the path to the avr-gcc sys-include directory, you can run:

avr-gcc -print-file-name=include

First let's build the Java mod to cache it for faster development:

./gradlew runClient

We will also need to add jSerialComm to the dependencies of the mod. Add the following to the build.gradle file:

dependencies {
    ...

    implementation "com.fazecast:jSerialComm:${project.jSerialComm_version}"
}

and then in the gradle.properties file, add:

jSerialComm_version=2.11.2

Now, in my case I had to add my user to the dialout group to be able to open the serial port without sudo :

sudo usermod -aG dialout $USER

but on Nix I had to just change the configuration.nix file by adding the group.

Now let's start wokring on the mod itself. I will do everything in the client part of the mod. That is mainly because we will have the arduino connected to the computer that is playing the game.

I will add a few attributes in the ExampleModClient class that we will use to do the communication with the Arduino:

import com.fazecast.jSerialComm.SerialPort;
import java.io.PrintWriter;

private static SerialPort port;
private static PrintWriter writer;

Next let's add some methods to handle opening and closing the serial port:

private static boolean openPort(String name) {
    closePort(); // Close any previously opened port
    port = SerialPort.getCommPort(name);
    if (port.openPort()) {
        System.out.println("Port " + name + " opened successfully.");
        writer = new PrintWriter(port.getOutputStream(), true);
        return true;
    } else {
        System.err.println("Failed to open port " + name);
        port = null;
        return false;
    }
}

private static void closePort() {
    if (writer != null) {
        writer.close();
        writer = null;
    }

    if (port != null && port.isOpen()) {
        port.closePort();
        port = null;
    }
}

Lastly we will need a method to send the damage value to the Arduino:

private static void sendDamage(int damage) {
    if (writer != null) {
        writer.write(damage & 0xFF); // Send as a single byte
        writer.flush();
    } else {
        System.err.println("Port is not open. Cannot send damage.");
    }
}

Now we can modify the onInitializeClient method to open the serial port and add callbacks for player damage:

import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.client.MinecraftClient;

private float lastHealth = -1.0f;

@Override
public void onInitializeClient() {
    ClientTickEvents.END_CLIENT_TICK.register(client -> {
        if (writer == null) return;

        PlayerEntity player = MinecraftClient.getInstance().player;
        if (player == null) return;

        float currentHealth = player.getHealth();

        if (lastHealth != -1.0f && currentHealth < lastHealth) {
            float damage = lastHealth - currentHealth;
            sendDamage((int) damage);
            System.out.println("Damage sent: " + damage);
        }

        lastHealth = currentHealth;
    });

    // Open the serial port
    String portName = "/dev/ttyACM0"; // Change this to your Arduino port
    if (!openPort(portName)) {
        System.err.println("Failed to open serial port. Mod will not function correctly.");
    }
}

Now we can try to build the entire thing. Run the following command in the project root:

./gradlew runClient

Now it is time for the Arduino code. Create a new file called main.c in the project root and add the following code:

This will include the necessary headers and define the CPU frequency for the Arduino board. Make sure to adjust the F_CPU value if you are using a different board or clock speed.

#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>

We will define some magic stuff for the serial communication.

#define BAUD 9600
#define MY_UBRR F_CPU/16/BAUD-1

void uart_init(void) {
    unsigned int ubrr = MY_UBRR;
    UBRR0H = (ubrr >> 8);
    UBRR0L = ubrr;
    UCSR0B = (1 << RXEN0);
    UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}

uint8_t uart_read(void) {
    while (!(UCSR0A & (1 << RXC0)));
    return UDR0;
}

We will also use interrupts to handle everything in an "asynchronous" way.

volatile uint16_t blink_counter = 0;
volatile const uint16_t blink_delay = 500;

volatile uint16_t damage_counter = 0;
volatile uint16_t damage_delay = 100;
volatile uint8_t damage_active = 0;
volatile int damage_steps = 0;

ISR(TIMER1_COMPA_vect) {
    blink_counter++;
    if (blink_counter >= blink_delay) {
        PORTB ^= (1 << PB5);
        blink_counter = 0;
    }

    if (damage_active) {
        damage_counter++;
        if (damage_counter >= damage_delay) {
            PORTB ^= (1 << PB4);
            damage_counter = 0;
            damage_steps--;
            if (damage_steps <= 0) {
                damage_active = 0;
                PORTB &= ~(1 << PB4);
            }
        }
    }
}

void timer1_init(void) {
    TCCR1B |= (1 << WGM12);
    OCR1A = 16000;
    TIMSK1 |= (1 << OCIE1A);
    TCCR1B |= (1 << CS10);
    sei();
}

Then we need the entry point.

int main(void) {
    DDRB |= (1 << PB5) | (1 << PB4);
    uart_init();
    timer1_init();

    while (1) {
        if (UCSR0A & (1 << RXC0)) {
            uint8_t data = uart_read();
            if (data == 0) data = 1;

            damage_steps += data;
            damage_active = 1;
        }
    }
}