Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ dependencies {

// Dev-environment only: real-account login for runClient (not bundled in the jar)
runtimeOnly "me.djtheredstoner:DevAuth-fabric:1.2.2"

testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

processResources {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package dev.laybalt.skyblocktranslator;

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import dev.laybalt.skyblocktranslator.config.ModConfig;
import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine;

public class SkyblockTranslatorClient implements ClientModInitializer {
public static final String MOD_ID = "skyblock-translator";
public static final Logger LOGGER = LoggerFactory.getLogger("SkyBlock Translator");

@Override
public void onInitializeClient() {
LOGGER.info("SkyBlock Translator initialized");
ModConfig config = ModConfig.get();
TranslationEngine.init();
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> TranslationEngine.get().cache().save());
LOGGER.info("SkyBlock Translator initialized (language={}, enabled={})", config.language, config.enabled);
}
}
80 changes: 80 additions & 0 deletions src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package dev.laybalt.skyblocktranslator.config;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.fabricmc.loader.api.FabricLoader;

import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient;

/**
* Mod configuration, stored as {@code config/skyblock-translator/config.json}.
* A missing file is created with defaults on first launch.
*/
public final class ModConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static ModConfig instance;

public boolean enabled = true;
/** Dictionary/cache language, matches the dict folder name. */
public String language = "ru_ru";
/** Only translate while connected to Hypixel. Turn off to test in singleplayer. */
public boolean onlyOnHypixel = true;
public boolean translateItems = true;
public boolean translateMenus = true;
/** Reserved for Phase 2 (chat / NPC dialogue translation). */
public boolean translateChat = false;
/** Append unknown templates to config/skyblock-translator/untranslated-<lang>.txt. */
public boolean dumpUntranslated = true;
/** Reserved for Phase 3 (premium cloud translation). */
public String premiumKey = "";

public static ModConfig get() {
if (instance == null) {
instance = load();
}
return instance;
}

public static Path directory() {
return FabricLoader.getInstance().getConfigDir().resolve("skyblock-translator");
}

private static Path file() {
return directory().resolve("config.json");
}

private static ModConfig load() {
Path file = file();
if (Files.exists(file)) {
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
ModConfig loaded = GSON.fromJson(reader, ModConfig.class);
if (loaded != null) {
return loaded;
}
} catch (IOException | RuntimeException e) {
SkyblockTranslatorClient.LOGGER.error("Failed to load config, using defaults", e);
}
}
ModConfig fresh = new ModConfig();
fresh.save();
return fresh;
}

public void save() {
try {
Files.createDirectories(directory());
try (Writer writer = Files.newBufferedWriter(file(), StandardCharsets.UTF_8)) {
GSON.toJson(this, writer);
}
} catch (IOException e) {
SkyblockTranslatorClient.LOGGER.error("Failed to save config", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.laybalt.skyblocktranslator.detect;

import java.util.Locale;

import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;

import dev.laybalt.skyblocktranslator.config.ModConfig;

/**
* Decides whether translation should be active right now.
*
* <p>Phase 1 gates on the server address only; SkyBlock-specific detection via
* the scoreboard title arrives in Phase 2. {@code onlyOnHypixel=false} in the
* config force-enables translation anywhere (useful for singleplayer testing).
*/
public final class HypixelDetector {
private HypixelDetector() {
}

public static boolean isTranslationActive() {
ModConfig config = ModConfig.get();
if (!config.enabled) {
return false;
}
if (!config.onlyOnHypixel) {
return true;
}
ServerData server = Minecraft.getInstance().getCurrentServer();
if (server == null || server.ip == null) {
return false;
}
String ip = server.ip.toLowerCase(Locale.ROOT);
return ip.endsWith("hypixel.net") || ip.endsWith("hypixel.io");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package dev.laybalt.skyblocktranslator.mixin;

import java.util.List;

import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
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.ModifyArg;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;

import dev.laybalt.skyblocktranslator.config.ModConfig;
import dev.laybalt.skyblocktranslator.detect.HypixelDetector;
import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine;

/**
* Render-level translation of container screens — which is where all of
* SkyBlock's UI lives (its menus are chest GUIs).
*
* <p>Item tooltips: the line list returned by {@code getTooltipFromContainerItem}
* is rebuilt every frame, so replacing entries here is a pure display change —
* the underlying ItemStack stays untouched and other mods keep seeing English.
*
* <p>Screen titles: {@code extractLabels} draws the container title and the
* inventory label through {@code GuiGraphicsExtractor.text(...)}; swapping the
* component argument translates exactly what is drawn and nothing else.
*/
@Mixin(AbstractContainerScreen.class)
public abstract class AbstractContainerScreenMixin {
@Inject(method = "getTooltipFromContainerItem", at = @At("RETURN"), cancellable = true)
private void skyblockTranslator$translateTooltip(ItemStack stack, CallbackInfoReturnable<List<Component>> cir) {
if (!ModConfig.get().translateItems || !HypixelDetector.isTranslationActive()) {
return;
}
List<Component> lines = cir.getReturnValue();
List<Component> translated = TranslationEngine.get().translateLines(lines);
if (translated != lines) {
cir.setReturnValue(translated);
}
}

@ModifyArg(
method = "extractLabels",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;IIIZ)V"
),
index = 1
)
private Component skyblockTranslator$translateLabel(Component original) {
if (!ModConfig.get().translateMenus || !HypixelDetector.isTranslationActive()) {
return original;
}
return TranslationEngine.get().translate(original);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package dev.laybalt.skyblocktranslator.pipeline;

import java.util.Map;
import java.util.Optional;

import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.network.chat.TextColor;

/**
* Converts between {@link Component} trees and "legacy" strings with §-codes.
*
* <p>Hypixel text reaches the client either as literals with embedded §-codes or as
* styled component siblings. We flatten everything into one legacy string so the
* rest of the pipeline works on plain strings. The client still renders §-codes
* inside literal components, so the translated result can be a single literal.
*/
public final class LegacyText {
private static final char SECTION = '§';

/** Legacy color table: ARGB-less RGB value -> §-code char. */
private static final Map<Integer, Character> COLOR_CODES = Map.ofEntries(
Map.entry(0x000000, '0'), Map.entry(0x0000AA, '1'), Map.entry(0x00AA00, '2'),
Map.entry(0x00AAAA, '3'), Map.entry(0xAA0000, '4'), Map.entry(0xAA00AA, '5'),
Map.entry(0xFFAA00, '6'), Map.entry(0xAAAAAA, '7'), Map.entry(0x555555, '8'),
Map.entry(0x5555FF, '9'), Map.entry(0x55FF55, 'a'), Map.entry(0x55FFFF, 'b'),
Map.entry(0xFF5555, 'c'), Map.entry(0xFF55FF, 'd'), Map.entry(0xFFFF55, 'e'),
Map.entry(0xFFFFFF, 'f'));

private LegacyText() {
}

/** Flattens a component tree into a single legacy string with §-codes. */
public static String toLegacy(Component component) {
StringBuilder out = new StringBuilder();
component.visit((style, text) -> {
out.append(codesFor(style));
out.append(text);
return Optional.empty();
}, Style.EMPTY);
return out.toString();
}

private static String codesFor(Style style) {
if (style.isEmpty()) {
return "";
}
StringBuilder codes = new StringBuilder();
TextColor color = style.getColor();
if (color != null) {
Character code = COLOR_CODES.get(color.getValue() & 0xFFFFFF);
if (code != null) {
codes.append(SECTION).append(code);
}
}
if (style.isObfuscated()) codes.append(SECTION).append('k');
if (style.isBold()) codes.append(SECTION).append('l');
if (style.isStrikethrough()) codes.append(SECTION).append('m');
if (style.isUnderlined()) codes.append(SECTION).append('n');
if (style.isItalic()) codes.append(SECTION).append('o');
return codes.toString();
}

/** Removes every §-code from a legacy string. */
public static String stripCodes(String legacy) {
if (legacy.indexOf(SECTION) < 0) {
return legacy;
}
StringBuilder out = new StringBuilder(legacy.length());
for (int i = 0; i < legacy.length(); i++) {
char c = legacy.charAt(i);
if (c == SECTION && i + 1 < legacy.length()) {
i++; // skip the code char
} else if (c != SECTION) {
out.append(c);
}
}
return out.toString();
}

/** Returns the run of §-codes at the very start of a legacy string ("§7§o" for "§7§oText"). */
public static String leadingCodes(String legacy) {
int i = 0;
while (i + 1 < legacy.length() && legacy.charAt(i) == SECTION) {
i += 2;
}
return legacy.substring(0, i);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dev.laybalt.skyblocktranslator.pipeline;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Turns concrete strings into placeholder templates and back.
*
* <p>{@code "Damage: +1,024"} becomes template {@code "Damage: +{0}"} with args
* {@code ["1,024"]}. One dictionary entry then covers every value the game can
* show, which is what makes the dictionary + cache approach viable: SkyBlock
* text is highly repetitive once numbers are factored out.
*/
public final class Normalizer {
/** Numbers incl. thousands separators, decimals and a trailing %: 1,024 / 3.5 / 100% */
private static final Pattern NUMBER = Pattern.compile("\\d[\\d,.]*%?");

private Normalizer() {
}

public record Template(String key, List<String> args) {
}

/** Extracts numbers into {0}, {1}, ... placeholders. */
public static Template normalize(String plain) {
Matcher m = NUMBER.matcher(plain);
if (!m.find()) {
return new Template(plain, List.of());
}
List<String> args = new ArrayList<>();
StringBuilder key = new StringBuilder();
int last = 0;
do {
key.append(plain, last, m.start());
key.append('{').append(args.size()).append('}');
args.add(m.group());
last = m.end();
} while (m.find());
key.append(plain, last, plain.length());
return new Template(key.toString(), List.copyOf(args));
}

/** Substitutes {n} placeholders in a translated template with the original values. */
public static String restore(String translated, List<String> args) {
if (args.isEmpty()) {
return translated;
}
StringBuilder out = new StringBuilder(translated.length());
for (int i = 0; i < translated.length(); i++) {
char c = translated.charAt(i);
if (c == '{') {
int close = translated.indexOf('}', i);
if (close > i) {
try {
int idx = Integer.parseInt(translated, i + 1, close, 10);
if (idx >= 0 && idx < args.size()) {
out.append(args.get(idx));
i = close;
continue;
}
} catch (NumberFormatException ignored) {
// not a placeholder, fall through
}
}
}
out.append(c);
}
return out.toString();
}
}
Loading