Last active
March 31, 2025 15:59
-
-
Save lowercasebtw/40ee36c731672f44c5aa61fd75749132 to your computer and use it in GitHub Desktop.
Typescript version of https://gist.github.com/lowercasebtw/c107c2038d3d405cd441525b0555d26a
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { cp } from "node:fs/promises"; | |
| import process from "node:process"; | |
| type PathLike = string; | |
| class Files { | |
| static listdir(path: PathLike) { | |
| return [...Deno.readDirSync(path)].map(c => c.name); | |
| } | |
| static async makedirs(path: PathLike) { | |
| await Deno.mkdir(path, { recursive: true }); | |
| } | |
| static async rmdir(path: PathLike) { | |
| await Deno.remove(path, { recursive: true }); | |
| } | |
| static async remove(path: PathLike) { | |
| await Deno.remove(path); | |
| } | |
| static async copyTree(src: PathLike, dst: PathLike) { | |
| if (!Paths.isdir(src)) { | |
| throw new Error("Path is not a directory, couldn't copy."); | |
| } | |
| try { | |
| await cp(src, dst, { recursive: true }); | |
| } catch (_) { | |
| throw new Error("Failed to copy directory."); | |
| } | |
| } | |
| static async rmTree(path: PathLike) { | |
| await Deno.remove(path, { recursive: true }); | |
| } | |
| static async move(src: PathLike, dst: PathLike) { | |
| await Deno.rename(src, Paths.join(dst, Paths.basename(src))); | |
| } | |
| } | |
| class Paths { | |
| public static readonly FILE_SEPERATOR = process.platform != 'win32' ? '/' : '\\'; | |
| public static readonly EXT_SEPERATOR = '.'; | |
| static join(...parts: PathLike[]): PathLike { | |
| return parts.join(this.FILE_SEPERATOR); | |
| } | |
| static exists(path: PathLike) { | |
| try { | |
| Deno.lstatSync(path); | |
| return true; | |
| } catch (_) { | |
| return false; | |
| } | |
| } | |
| static isdir(path: PathLike) { | |
| try { | |
| const fileInfo = Deno.statSync(path); | |
| return fileInfo.isDirectory; | |
| } catch (_) { | |
| return false; | |
| } | |
| } | |
| static splitext(path: PathLike) { | |
| const sepIndex = path.lastIndexOf(this.FILE_SEPERATOR); | |
| const dotIndex = path.lastIndexOf(this.EXT_SEPERATOR); | |
| if (dotIndex > sepIndex) { | |
| let filenameIndex = sepIndex + 1; | |
| while (filenameIndex < dotIndex) { | |
| if (path.charAt(filenameIndex) !== this.EXT_SEPERATOR) { | |
| return [path.slice(0, dotIndex), path.slice(dotIndex)]; | |
| } | |
| filenameIndex += 1; | |
| } | |
| } | |
| return [path, '']; | |
| } | |
| static basename(path: PathLike) { | |
| return path.substring(path.lastIndexOf(this.FILE_SEPERATOR) + 1); | |
| } | |
| static dirname(path: PathLike) { | |
| const index = path.lastIndexOf(this.FILE_SEPERATOR) + 1; | |
| const head = path.slice(0, index); | |
| if (head && head !== this.FILE_SEPERATOR.repeat(head.length)) { | |
| return head.replace(new RegExp(this.FILE_SEPERATOR + '$'), ''); | |
| } else { | |
| return head; | |
| } | |
| } | |
| } | |
| function parseProperties(input: string): Record<string, string> { | |
| const dict: Record<string, string> = {}; | |
| for (const line of input.split('\n')) { | |
| const parts = line.split('='); | |
| const key = parts[0].trim(); | |
| const value = parts.slice(1, parts.length).join('=').trim(); | |
| dict[key] = value; | |
| } | |
| return dict; | |
| } | |
| // CODE | |
| const CONVERSION_SUFFIX = "_converted"; | |
| const TEXTURES_CIT_SUFFIX = "cit_textures"; | |
| const MINECRAFT_PATH = Paths.join("assets", "minecraft"); | |
| const ATLASES_PATH = Paths.join(MINECRAFT_PATH, "atlases"); | |
| const TEXTURES_PATH = Paths.join(MINECRAFT_PATH, "textures"); | |
| const OPTIFINE_PATH = Paths.join(MINECRAFT_PATH, "optifine"); | |
| const ITEMS_PATH = Paths.join(MINECRAFT_PATH, "items"); | |
| const MODELS_PATH = Paths.join(MINECRAFT_PATH, "models"); | |
| const ITEM_MODELS_PATH = Paths.join(MODELS_PATH, "item"); | |
| const CIT_PATH = Paths.join(OPTIFINE_PATH, "cit"); | |
| class NamespacedKey { | |
| constructor(public namespace: string, public path: string) { } | |
| static fromString(input: string): NamespacedKey { | |
| if (input.length == 0) { | |
| throw new Error("Invalid namespaced identifier"); | |
| } | |
| const parts = input.split(':'); | |
| if (parts.length > 2) { | |
| throw new Error("Invalid namespaced identifier"); | |
| } | |
| const is_single = parts.length == 1; | |
| return new NamespacedKey( | |
| is_single ? "minecraft" : parts[0], | |
| is_single ? parts[0] : parts[1] | |
| ); | |
| } | |
| equals(other: object) { | |
| if (other instanceof NamespacedKey) { | |
| return other.namespace == this.namespace && other.path == this.path; | |
| } else { | |
| return false; | |
| } | |
| } | |
| toString() { | |
| return `${this.namespace}:${this.path}`; | |
| } | |
| } | |
| class Optional<T> { | |
| private has_value: boolean; | |
| constructor(public value: T) { | |
| this.has_value = value != null && value != undefined; | |
| } | |
| isEmpty() { | |
| return !this.has_value; | |
| } | |
| static ofNullable<T>(value: T) { | |
| return new Optional<T>(value); | |
| } | |
| static of<T>(value: T) { | |
| if (value == null || value == undefined) { | |
| throw new Error("Value is null"); | |
| } | |
| return new Optional<T>(value); | |
| } | |
| static empty() { | |
| return Optional.ofNullable<any>(null); | |
| } | |
| } | |
| type Range = number | { | |
| min?: number; | |
| max?: number; | |
| }; | |
| function parseRange(input: string): Range { | |
| if (input.includes('-')) { | |
| const parts = input.split('-'); | |
| return { | |
| min: parseInt(parts[0]), | |
| max: parseInt(parts[1]) | |
| }; | |
| } else { | |
| return parseInt(input); | |
| } | |
| } | |
| function parseRanges(inputs: string[]): Range[] { | |
| return [...inputs].map(parseRange); | |
| } | |
| enum Hand { | |
| ANY, | |
| MAIN, | |
| OFF // ? | |
| } | |
| function handFromString(input: string) { | |
| switch (input) { | |
| case "any": return Hand.ANY; | |
| case "main": return Hand.MAIN; | |
| case "off": return Hand.OFF; | |
| default: { | |
| console.log("Recieved unknown hand type, defaulting to ANY"); | |
| return Hand.ANY; | |
| } | |
| } | |
| } | |
| class OptiFineCITItem { | |
| public name: string; | |
| public texture: string; | |
| public items: NamespacedKey[]; | |
| public enchantments: NamespacedKey[]; | |
| public enchantment_levels: Range[]; | |
| public modelPath: string | null; | |
| public damage: Range; | |
| public stackSize: Range[]; | |
| public hand: Hand; | |
| constructor(name: string, texture: string, items: NamespacedKey[]) { | |
| this.name = name; | |
| this.texture = texture; | |
| this.items = items; | |
| this.enchantments = []; | |
| this.enchantment_levels = []; | |
| this.modelPath = null; | |
| this.damage = -1; | |
| this.stackSize = []; | |
| this.hand = Hand.ANY; | |
| } | |
| static parse(path: PathLike, name: string, input: string) { | |
| const properties = parseProperties(input); | |
| if (!("items" in properties)) { | |
| console.error("Failed to parse OptiFineCITItem, missing \"items\" key"); | |
| return Optional.empty(); | |
| } | |
| if (!("type" in properties || properties.type != "item")) { | |
| console.error("Failed to parse OptiFineCITItem, not a item"); | |
| return Optional.empty(); | |
| } | |
| const citItem = new OptiFineCITItem(name, Paths.join(path, `${name}.png`), properties.items.split(' ').map(NamespacedKey.fromString)); | |
| if ("texture" in properties) { | |
| citItem.withTexture(properties.texture); | |
| } | |
| if ("enchantmentIDs" in properties) { | |
| citItem.withEnchantments(properties.enchantmentIDs); | |
| } | |
| if ("enchantmentLevels" in properties) { | |
| const levels: Range[] = []; | |
| const ranges = parseRanges(properties.enchantmentLevels.split(' ')); | |
| if (typeof ranges == "number") { | |
| levels.push(ranges); | |
| } else { | |
| levels.push(...ranges); | |
| } | |
| citItem.withEnchantmentLevels(levels); | |
| } | |
| if ("model" in properties) { | |
| citItem.withModel(properties.model); | |
| } | |
| if ("damage" in properties) { | |
| citItem.withDamage(parseRange(properties.damage)); | |
| } | |
| if ("stackSize" in properties) { | |
| citItem.withStackSizes(parseRanges(properties.stackSize.split(' '))); | |
| } | |
| if ("hand" in properties) { | |
| citItem.withHand(handFromString(properties.hand)); | |
| } | |
| return Optional.of(citItem); | |
| } | |
| // TODO: Support real syntax as this is probably totally wrong for anything else LOL | |
| withTexture(texture: string) { | |
| this.texture = texture; | |
| return this; | |
| } | |
| withEnchantments(enchantments: string) { | |
| this.enchantments = enchantments.split(' ').map(NamespacedKey.fromString); | |
| return this; | |
| } | |
| withEnchantmentLevels(levels: Range[]) { | |
| this.enchantment_levels = levels; | |
| return this | |
| } | |
| withModel(modelPath: string) { | |
| this.modelPath = modelPath; | |
| return this; | |
| } | |
| withDamage(damage: Range) { | |
| this.damage = damage; | |
| return this; | |
| } | |
| withStackSizes(stackSizes: Range[]) { | |
| this.stackSize = stackSizes; | |
| return this; | |
| } | |
| withHand(hand: Hand) { | |
| this.hand = hand; | |
| return this; | |
| } | |
| } | |
| function mapPaths(root: PathLike, paths: string[]) { | |
| return paths.map(path => Paths.join(root, path)); | |
| } | |
| async function ensurePath(path: PathLike) { | |
| if (!Paths.exists(path)) { | |
| await Files.makedirs(path); | |
| } | |
| return path; | |
| } | |
| interface VanillaItemDefenition { | |
| type: string; | |
| model?: string; | |
| predicate?: string; | |
| value?: any[]; | |
| on_false?: VanillaItemDefenition; | |
| on_true?: VanillaItemDefenition; | |
| } | |
| function generateConditionEnchantmentsBranch(itemId: NamespacedKey, entry: DuplicateRecordEntry, on_false: VanillaItemDefenition) { | |
| return { | |
| type: "minecraft:condition", | |
| property: "minecraft:component", | |
| predicate: itemId.equals(new NamespacedKey("minecraft", "enchanted_book")) ? "stored_enchantments" : "enchantments", | |
| value: entry.value, | |
| on_true: { | |
| type: "minecraft:model", | |
| model: `minecraft:item/cit/${entry.name}` | |
| }, | |
| on_false | |
| } as VanillaItemDefenition; | |
| } | |
| function generateConditionEnchantments(key: NamespacedKey, entries: DuplicateRecordEntry[]) { | |
| let dict: VanillaItemDefenition = { | |
| type: "minecraft:model", | |
| model: `${key.namespace}:item/${key.path}` | |
| }; | |
| for (const entry of entries) { | |
| dict = generateConditionEnchantmentsBranch(key, entry, dict); | |
| } | |
| return dict; | |
| } | |
| async function generateAtlas(dst: PathLike) { | |
| const NEW_ATLASES_PATH = await ensurePath(Paths.join(dst, ATLASES_PATH)); | |
| // TODO: Append to existing blocks.json if it exists | |
| await Deno.writeTextFile( | |
| Paths.join(NEW_ATLASES_PATH, "blocks.json"), | |
| JSON.stringify({ | |
| sources: [ | |
| { | |
| type: "directory", | |
| source: TEXTURES_CIT_SUFFIX, | |
| prefix: `textures/${TEXTURES_CIT_SUFFIX}/` | |
| } | |
| ] | |
| }, null, 4)); | |
| } | |
| async function filterItems(citPath: PathLike) { | |
| let failed = false; | |
| const citItems: OptiFineCITItem[] = []; | |
| for (const propertiesFile of mapPaths(citPath, Files.listdir(citPath).filter(path => path.endsWith(".properties")))) { | |
| // with open(properties_file) as file: | |
| console.log(` Converting ${propertiesFile}`); | |
| const file = await Deno.readTextFile(propertiesFile); | |
| const citOptional = OptiFineCITItem.parse(Paths.dirname(propertiesFile), Paths.splitext(Paths.basename(propertiesFile))[0], file); | |
| if (citOptional.isEmpty()) { | |
| failed = true; | |
| break; | |
| } | |
| citItems.push(citOptional.value); | |
| await Files.remove(propertiesFile); | |
| } | |
| return { | |
| result: citItems, | |
| failed | |
| }; | |
| } | |
| interface EnchantmentEntry { | |
| enchantments: string | string[]; | |
| levels?: number | { | |
| min?: number; | |
| max?: number; | |
| } | |
| } | |
| interface DuplicateRecordEntry { | |
| name: string; | |
| texture: PathLike; | |
| value: EnchantmentEntry[]; | |
| } | |
| type DuplicateRecord = Record<string, Array<DuplicateRecordEntry>>; | |
| function mapDuplicateItems(items: Array<OptiFineCITItem>) { | |
| const duplicates: DuplicateRecord = {}; | |
| for (const citItem of items) { | |
| const entry: DuplicateRecordEntry = { | |
| name: citItem.name, | |
| texture: citItem.texture, | |
| value: [] | |
| }; | |
| if (citItem.enchantments.length > 0) { | |
| // TODO: Do this smarter | |
| for (const enchantment of citItem.enchantments) { | |
| if (citItem.enchantment_levels.length > 0) { | |
| for (const level of citItem.enchantment_levels) { | |
| entry.value.push({ | |
| enchantments: enchantment.toString(), | |
| levels: level | |
| }); | |
| } | |
| } else { | |
| entry.value.push({ | |
| enchantments: enchantment.toString() | |
| }); | |
| } | |
| } | |
| } | |
| // TODO: Support multiple items | |
| if (citItem.items[0].toString() in duplicates) { | |
| duplicates[citItem.items[0].toString()].push(entry); | |
| } else { | |
| duplicates[citItem.items[0].toString()] = [entry]; | |
| } | |
| } | |
| return duplicates; | |
| } | |
| async function cleanup(dst: PathLike, citPath: PathLike) { | |
| console.log("Cleaning up..."); | |
| if (Files.listdir(citPath).length == 0) { | |
| await Files.rmdir(citPath); | |
| } | |
| const NEW_OPTIFINE_PATH = Paths.join(dst, OPTIFINE_PATH); | |
| if (Files.listdir(NEW_OPTIFINE_PATH).length == 0) { | |
| await Files.rmdir(NEW_OPTIFINE_PATH); | |
| } | |
| } | |
| async function vanillaify(src: PathLike, dst: PathLike) { | |
| const THIS_CIT_PATH = Paths.join(src, CIT_PATH); | |
| if (!Paths.exists(THIS_CIT_PATH) || !Paths.isdir(THIS_CIT_PATH)) { | |
| console.log(`${src} did not contain any OptiFine CIT, ignorning...`); | |
| return; | |
| } | |
| console.log(`Converting ${src} to vanilla CIT pack`); | |
| await Files.copyTree(src, dst); | |
| const NEW_CIT_PATH = Paths.join(dst, CIT_PATH); | |
| const NEW_ITEMS_PATH = await ensurePath(Paths.join(dst, ITEMS_PATH)); | |
| const NEW_TEXTURES_PATH = await ensurePath(Paths.join(dst, TEXTURES_PATH)); | |
| const PACK_TEXTURES_PATH = Paths.join(NEW_TEXTURES_PATH, TEXTURES_CIT_SUFFIX); | |
| await Files.makedirs(PACK_TEXTURES_PATH); | |
| await generateAtlas(dst); | |
| const { result: citItems, failed } = await filterItems(NEW_CIT_PATH); | |
| if (failed) { | |
| await Files.rmTree(dst); | |
| console.error("Failed!"); | |
| return; | |
| } | |
| // Group into singletons | |
| const CIT_ITEM_MODELS_PATH = await ensurePath(Paths.join(dst, ITEM_MODELS_PATH, "cit")); | |
| const duplicates = mapDuplicateItems(citItems); | |
| for await (const key of Object.keys(duplicates)) { | |
| // Item Models | |
| const entry = duplicates[key]; | |
| for await (const item of entry) { | |
| const itemName = item.name; // TODO: Fix, use item.texture | |
| // TODO: Possibly get original model and swap layer0 texture or whatever | |
| await Deno.writeTextFile( | |
| Paths.join(CIT_ITEM_MODELS_PATH, `${itemName}.json`), | |
| JSON.stringify( | |
| { | |
| parent: "minecraft:item/generated", // TODO: Get real parent for item type | |
| textures: { | |
| layer0: `minecraft:textures/${TEXTURES_CIT_SUFFIX}/${itemName}` | |
| } | |
| }, null, 4 | |
| ), | |
| { create: true } | |
| ); | |
| // Move Texture | |
| if (Paths.exists(item.texture)) { | |
| await Files.move(item.texture, PACK_TEXTURES_PATH); | |
| } | |
| } | |
| // Item Defenitions | |
| const namespacedKey = NamespacedKey.fromString(key); | |
| const itemPath = Paths.join(NEW_ITEMS_PATH, `${namespacedKey.path}.json`); | |
| // exists = path.exists(item_path) | |
| // TODO | |
| // if exists: | |
| // original = json.loads(file.read()) | |
| // if "model" in original: | |
| // defenition.model['on_false'] = original['model'] | |
| // file.seek(0) | |
| await Deno.writeTextFile( | |
| itemPath, | |
| JSON.stringify({ model: generateConditionEnchantments(namespacedKey, entry) }, null, 4) | |
| ); | |
| } | |
| // Cleanup | |
| await cleanup(dst, NEW_CIT_PATH); | |
| } | |
| async function main() { | |
| const folders = mapPaths('.', Files.listdir('.').filter(key => Paths.isdir(key) && !key.endsWith(CONVERSION_SUFFIX))) | |
| const promises = []; | |
| for await (const folder of folders) { | |
| promises.push(async () => { | |
| const CONVERTED_PATH_NAME = `${folder}${CONVERSION_SUFFIX}` | |
| if (Paths.exists(CONVERTED_PATH_NAME)) { | |
| await Files.rmTree(CONVERTED_PATH_NAME); | |
| console.log(`Found pre-existing conversion for ${folder}, removing...`); | |
| } | |
| const files = Files.listdir(folder); | |
| if (!files.includes("pack.mcmeta")) { | |
| console.log(`Skipping ${folder} as it is not a minecraft resourcepack`); | |
| return; | |
| } | |
| await vanillaify(folder, CONVERTED_PATH_NAME) | |
| }); | |
| } | |
| (await Promise.all(promises)).forEach((promise) => promise()); | |
| console.log("Done!"); | |
| } | |
| await main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment