Skip to content
Open
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
40 changes: 40 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Build

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
build:
name: Build
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup JDK 25
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.event_name == 'pull_request' }}

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build
run: ./gradlew build

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: extractor-build
path: build/libs/*.jar
if-no-files-found: error
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Extractor is a Fabric mod that extracts Minecraft data (blocks, items, entities,
- [x] Chunk Status
- [x] Game Event
- [x] Game Rule
- [x] Translation (en_us)
- [x] Translations (all languages)
- [x] Noise Parameters
- [x] Particles
- [x] Recipes
Expand Down
43 changes: 26 additions & 17 deletions src/main/kotlin/de/snowii/extractor/Extractor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import de.snowii.extractor.extractors.structures.Structures
import net.fabricmc.api.ModInitializer
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents
import net.minecraft.server.MinecraftServer
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.FileWriter
import java.io.IOException
Expand All @@ -21,11 +20,14 @@ import kotlin.system.measureTimeMillis


class Extractor : ModInitializer {
private val modID: String = "pumpkin_extractor"
private val logger: Logger = LoggerFactory.getLogger(modID)
private val logger = LoggerFactory.getLogger("pumpkin_extractor")

companion object {
const val OUTPUT_DIR = "pumpkin_extractor_output"
}

override fun onInitialize() {
logger.info("Starting Pumpkin Extractor")
logger.info(Lang.fmt("extractor.log.starting"))

val extractors = arrayOf(
Dialog(),
Expand Down Expand Up @@ -172,29 +174,36 @@ class Extractor : ModInitializer {

val outputDirectory: Path
try {
outputDirectory = Files.createDirectories(Paths.get("pumpkin_extractor_output"))
outputDirectory = Files.createDirectories(Paths.get(OUTPUT_DIR))
logger.info(Lang.fmt("extractor.log.output_dir", outputDirectory.toAbsolutePath()))
} catch (e: IOException) {
logger.info("Failed to create output directory.", e)
logger.error(Lang.fmt("extractor.log.output_dir_failed"), e)
return
}

val gson = GsonBuilder().disableHtmlEscaping().create()

ServerLifecycleEvents.SERVER_STARTED.register(ServerLifecycleEvents.ServerStarted { server: MinecraftServer ->
val timeInMillis = measureTimeMillis {
for (ext in extractors) {
try {
val out = outputDirectory.resolve(ext.fileName())
val fileWriter = FileWriter(out.toFile(), StandardCharsets.UTF_8)
gson.toJson(ext.extract(server), fileWriter)
fileWriter.close()
logger.info("Wrote " + out.toAbsolutePath())
} catch (e: java.lang.Exception) {
logger.error(("Extractor for \"" + ext.fileName()) + "\" failed.", e)
logger.info(Lang.fmt("extractor.log.server_started"))
try {
val timeInMillis = measureTimeMillis {
for (ext in extractors) {
try {
val out = outputDirectory.resolve(ext.fileName())
Files.createDirectories(out.parent)
FileWriter(out.toFile(), StandardCharsets.UTF_8).use { writer ->
gson.toJson(ext.extract(server), writer)
}
logger.info(Lang.fmt("extractor.log.wrote", out.toAbsolutePath()))
} catch (e: Exception) {
logger.error(Lang.fmt("extractor.log.extractor_failed", ext.fileName()), e)
}
}
}
logger.info(Lang.fmt("extractor.log.done", timeInMillis))
} catch (e: Throwable) {
logger.error(Lang.fmt("extractor.log.fatal_error"), e)
}
logger.info("Done, took ${timeInMillis}ms")
})
}

Expand Down
33 changes: 33 additions & 0 deletions src/main/kotlin/de/snowii/extractor/Lang.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package de.snowii.extractor

import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.InputStreamReader

/**
* Loads the mod's en_us.json language file and exposes [fmt] for formatted lookups.
* Log strings are kept in the lang file as the single source of truth;
* at runtime only the English fallback is resolved (logs are developer-facing).
*/
object Lang {
private val entries: Map<String, String> by lazy {
val stream = Lang::class.java.classLoader
.getResourceAsStream("assets/extractor/lang/en_us.json")
if (stream == null) {
emptyMap()
} else {
stream.use { s ->
Gson().fromJson(
InputStreamReader(s, Charsets.UTF_8),
object : TypeToken<Map<String, String>>() {}.type
)
}
}
}

/** Look up key and format with classic String.format semantics. */
fun fmt(key: String, vararg args: Any?): String {
val template = entries[key] ?: key
return if (args.isEmpty()) template else template.format(*args)
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,111 @@
package de.snowii.extractor.extractors.non_registry

import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import de.snowii.extractor.Extractor
import de.snowii.extractor.Lang
import net.minecraft.resources.Identifier
import net.minecraft.server.MinecraftServer
import org.slf4j.LoggerFactory
import java.io.FileWriter
import java.io.InputStream
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths

class Translations : Extractor.Extractor {
private val gson: Gson = GsonBuilder().disableHtmlEscaping().create()
private val gson = GsonBuilder().disableHtmlEscaping().create()
private val logger = LoggerFactory.getLogger("translations_extractor")

override fun fileName(): String {
return "en_us.json"
}
/** Lazily reflect into net.minecraft.client.Minecraft to avoid compile-time dependency. */
private val clientReflection by lazy { ClientReflection.resolve() }

override fun fileName() = "translations.json"

override fun extract(server: MinecraftServer): JsonElement {
val inputStream = this.javaClass.getResourceAsStream("/assets/minecraft/lang/en_us.json")
?: throw IllegalArgumentException("Could not find lang en_us.json")

return inputStream.use { stream ->
gson.fromJson(
InputStreamReader(stream, StandardCharsets.UTF_8),
JsonObject::class.java
) as JsonObject
val langDir = Paths.get(Extractor.OUTPUT_DIR, "lang")
Files.createDirectories(langDir)
val summary = JsonArray()

for (langCode in clientReflection?.languageCodes ?: listOf("en_us")) {
val stream = loadLangStream(langCode) ?: run {
logger.warn(Lang.fmt("extractor.translations.not_found", langCode))
continue
}
try {
stream.use { s ->
val raw = gson.fromJson(
InputStreamReader(s, StandardCharsets.UTF_8),
JsonObject::class.java
) as JsonObject
val sorted = JsonObject()
raw.entrySet().sortedBy { it.key }.forEach { (k, v) -> sorted.add(k, v) }
FileWriter(langDir.resolve("${langCode}_java.json").toFile(), StandardCharsets.UTF_8).use { w ->
gson.toJson(sorted, w)
}
}
summary.add(langCode)
logger.info(Lang.fmt("extractor.translations.wrote", langCode))
} catch (e: Exception) {
logger.warn(Lang.fmt("extractor.translations.failed", langCode), e)
}
}

logger.info(Lang.fmt("extractor.translations.summary", summary.size(), langDir.toAbsolutePath()))
return summary
}

private fun loadLangStream(langCode: String): InputStream? {
clientReflection?.let { ref ->
try {
val id = Identifier.fromNamespaceAndPath("minecraft", "lang/$langCode.json")
ref.openResource(id)?.let { return it }
} catch (_: Exception) { }
}
return this.javaClass.getResourceAsStream("/assets/minecraft/lang/$langCode.json")
}

/**
* Cached reflective handle to the client Minecraft instance.
* Null when running on a dedicated server (no client available).
*/
private class ClientReflection private constructor(
private val resourceManager: Any,
private val getResourceStackMethod: java.lang.reflect.Method,
val languageCodes: List<String>
) {
@Suppress("UNCHECKED_CAST")
fun openResource(identifier: Identifier): InputStream? {
val resources = getResourceStackMethod.invoke(resourceManager, identifier) as List<*>
if (resources.isEmpty()) return null
val resource = resources.first() ?: return null
return resource.javaClass.getMethod("open").invoke(resource) as InputStream
}

companion object {
fun resolve(): ClientReflection? {
return try {
val mc = Class.forName("net.minecraft.client.Minecraft")
val client = mc.getMethod("getInstance").invoke(null) ?: return null
val rm = mc.getMethod("getResourceManager").invoke(client)
val stackMethod = rm.javaClass.getMethod("getResourceStack", Identifier::class.java)

val lm = mc.getMethod("getLanguageManager").invoke(client)
@Suppress("UNCHECKED_CAST")
val languages = lm.javaClass.getMethod("getLanguages").invoke(lm) as Map<String, *>

ClientReflection(
resourceManager = rm,
getResourceStackMethod = stackMethod,
languageCodes = languages.keys.sorted()
)
} catch (_: Exception) {
null
}
}
}
}
}
}
18 changes: 18 additions & 0 deletions src/main/resources/assets/extractor/lang/en_us.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"mod.extractor.name": "Pumpkin Extractor",
"mod.extractor.description": "Extracts Minecraft data (blocks, items, entities, translations, etc.) into JSON files on server start.",

"extractor.log.starting": "Starting Pumpkin Extractor",
"extractor.log.output_dir": "Output directory: %s",
"extractor.log.output_dir_failed": "Failed to create output directory.",
"extractor.log.server_started": "Server started — running extractors...",
"extractor.log.wrote": "Wrote %s",
"extractor.log.extractor_failed": "Extractor \"%s\" failed.",
"extractor.log.done": "Done, took %dms",
"extractor.log.fatal_error": "Extraction failed with fatal error",

"extractor.translations.not_found": "Could not find lang file for: %s",
"extractor.translations.wrote": "Wrote language: %s",
"extractor.translations.failed": "Failed to write language: %s",
"extractor.translations.summary": "Extracted %d languages to %s"
}
18 changes: 18 additions & 0 deletions src/main/resources/assets/extractor/lang/zh_cn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"mod.extractor.name": "Pumpkin 数据提取器",
"mod.extractor.description": "在服务器启动时将 Minecraft 数据(方块、物品、实体、翻译等)提取为 JSON 文件。",

"extractor.log.starting": "正在启动 Pumpkin Extractor",
"extractor.log.output_dir": "输出目录:%s",
"extractor.log.output_dir_failed": "无法创建输出目录。",
"extractor.log.server_started": "服务器已启动 — 开始执行提取器...",
"extractor.log.wrote": "已写入 %s",
"extractor.log.extractor_failed": "提取器 \"%s\" 失败。",
"extractor.log.done": "完成,耗时 %dms",
"extractor.log.fatal_error": "提取过程发生致命错误",

"extractor.translations.not_found": "找不到语言文件:%s",
"extractor.translations.wrote": "已写入语言:%s",
"extractor.translations.failed": "写入语言失败:%s",
"extractor.translations.summary": "已将 %d 种语言提取到 %s"
}
4 changes: 2 additions & 2 deletions src/main/resources/fabric.mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"schemaVersion": 1,
"id": "extractor",
"version": "${version}",
"name": "pumpkin-extractor",
"description": "",
"name": "mod.extractor.name",
"description": "mod.extractor.description",
"authors": [],
"contact": {},
"license": "MIT",
Expand Down