const crypto = require("node:crypto") const zlib = require("node:zlib") let fs, dialog, action, storage, loader, styles, cacheDir const id = "asset_browser" const name = "Asset Browser" const icon = "folder_zip" const description = "Browse the Minecraft assets from within Blockbench." const links = { website: { text: "By Ewan Howell", link: "https://ewanhowell.com/", icon: "language", colour: "#33E38E" }, discord: { text: "Discord Server", link: "https://discord.ewanhowell.com/", icon: "fab.fa-discord", colour: "#727FFF" } } const manifest = { latest: {}, types: { release: "Java Release", snapshot: "Java Snapshot", bedrock: "Bedrock Release", "bedrock-preview": "Bedrock Preview" }, versions: [] } const customIcons = { creeper: '', forge: '', neoforge: '', fabric: '' } let loadedJars = {} const ignoredExtensions = ["class", "nbt", "mcassetsroot", "mf", "sf", "dsa", "rsa", "jfc", "xml", "md", "toml", "itransformationservice", "hex", "jar"] const ignoredExtensionsRoot = ["txt", "cfg"] const ignoredExtensionsRegex = new RegExp(`\\.(${ignoredExtensions.join("|")})$|(?:^|\/)[^\/\\.]+$|(?:^|\/)\\.`, "i") const ignoredExtensionsRootRegex = new RegExp(`^[^\\/]+\\.(?:${ignoredExtensionsRoot.join("|")})$`, "i") const javaBlock = { oneOf: new Set(["parent", "elements"]), items: new Set(["parent", "textures", "elements", "ambientocclusion", "gui_light", "display", "groups", "texture_size", "overrides"]) } const item_parents = [ "item/generated", "minecraft:item/generated", "item/handheld", "minecraft:item/handheld", "item/handheld_rod", "minecraft:item/handheld_rod", "builtin/generated", "minecraft:builtin/generated" ] const titleCase = str => str.replace(/_|-/g, " ").replace(/\w\S*/g, str => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) const shaCheck = async (path, sha) => crypto.createHash("sha1").update(await fs.promises.readFile(path)).digest("hex") === sha const save = () => localStorage.setItem(id, JSON.stringify(storage)) Plugin.register(id, { title: name, icon: "icon.png", author: "Ewan Howell", description, tags: ["Minecraft", "Assets", "Browser"], version: "1.2.2", min_version: "5.0.0", variant: "desktop", creation_date: "2025-05-30", website: "https://ewanhowell.com/plugins/asset-browser/", repository: "https://github.com/ewanhowell5195/blockbenchPlugins/tree/main/asset-browser", bug_tracker: "https://github.com/ewanhowell5195/blockbenchPlugins/issues/new?title=[Asset Browser]", has_changelog: true, onload() { fs = require("fs", { message: "This permission is required to access your downloaded Minecraft versions, cache versions you open that aren’t already downloaded, and export assets to folders.", optional: false }) if (!fs) { throw new Error("fs access denied") } cacheDir = PathModule.join(SystemInfo.user_data_directory, "minecraft_assets_cache") if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }) } storage = JSON.parse(localStorage.getItem(id) ?? "{}") storage.recents ??= [] storage.recentComparisons ??= [] loadSidebar() let directory if (SystemInfo.platform === "win32") { directory = PathModule.join(SystemInfo.appdata_directory, ".minecraft") } else if (SystemInfo.platform === "darwin") { directory = PathModule.join(SystemInfo.home_directory, "Library", "Application Support", "minecraft") } else { directory = PathModule.join(SystemInfo.home_directory, ".minecraft") } new Setting("ewan_minecraft_directory", { value: directory, category: "defaults", type: "click", name: "Ewan's Plugins - Minecraft Directory", description: "The location of your .minecraft folder", icon: "folder_open", click() { const dir = Blockbench.pickDirectory({ title: "Select your .minecraft folder", startpath: settings.ewan_minecraft_directory.value }) if (dir) { settings.ewan_minecraft_directory.value = dir Settings.saveLocalStorages() } } }) dialog = new Dialog({ id, title: name, width: 816, resizable: true, buttons: [], lines: [``], component: { data: { type: storage.type ?? "release", manifest, selectedVersions: Object.fromEntries(Object.keys(manifest.types).map(k => [k, null])), version: null, versionSearch: "", recentVersions: storage.recents, downloadedVersions: [], objects: storage.objects, jar: null, loadingMessage: null, path: [], tree: {}, textureObserver: null, lastInteracted: null, shiftStartItem: null, selected: [], savedFolders: storage.savedFolders, sidebarVisible: true, navigationHistory: [], navigationFuture: [], breadcrumbsOverflowing: false, breadcrumbsResizeObserver: null, validSavedFolders: [], activeSavedFolder: null, displayType: storage.display ?? "grid", lastArrowKeyPress: 0, typeFindText: "", typeFindLastKey: 0, typeFindStart: 0, sort: "name", sortDirection: "forwards", currentFolderData: {}, searchOpen: false, searchText: "", searchTimeout: null, filesMessage: null, itemCount: 0, ready: Promise.withResolvers(), lastOpenFormat: null, progressDone: 0, progressTotal: 0, exporting: false, mode: "assets", compareType: storage.compareType ?? "release", compareSelectedVersions: Object.fromEntries(Object.keys(manifest.types).map(k => [k, null])), compareVersion: null, recentComparisons: storage.recentComparisons, suggestedComparisons: [] }, components: { "animated-texture": animatedTexureComponent(), "lazy-scroller": lazyScrollerComponent() }, watch: { loadingMessage(val) { if (!val && this.jar) { this.getValidSavedFolders() } this.$nextTick(() => { if (!val && this.jar) { this.setupBreadcrumbs() } }) }, savedFolders() { this.getValidSavedFolders() } }, beforeDestroy() { this.breadcrumbsResizeObserver.disconnect() }, computed: { currentFolderContents() { this.currentFolderData = {} let current const searchText = this.searchText.trim().toLowerCase() if (this.searchOpen && searchText) { current = {} const currentFolder = this.path.join("/") + "/" const folders = new Set for (const k of Object.keys(this.jar.files)) { folders.add(PathModule.dirname(k)) if (k.startsWith(currentFolder) || currentFolder === "/") { let relativePath = k if (currentFolder !== "/") { relativePath = k.slice(currentFolder.length) } if (relativePath.toLowerCase().includes(searchText)) { current[relativePath] = k this.$set(this.currentFolderData, relativePath, { label: this.getFileLabel(relativePath.split("/"), relativePath, k), dimensions: this.getImageDimensions(k) }) } } } for (const folder of folders) { if (folder === ".") continue if (folder.startsWith(currentFolder) || currentFolder === "/") { let relativePath = folder if (currentFolder !== "/") { relativePath = folder.slice(currentFolder.length) } if (relativePath.toLowerCase().includes(searchText)) { let content = this.tree for (const part of folder.split("/")) { content = content[part] } current[relativePath] = content this.$set(this.currentFolderData, relativePath, { label: this.getFileLabel(relativePath.split("/"), relativePath, content) }) } } } } else { current = this.tree for (const part of this.path) { current = current[part] } for (const [k, v] of Object.entries(current)) { this.$set(this.currentFolderData, k, { label: this.getFileLabel(this.path, k, v), dimensions: typeof v === "object" ? undefined : this.getImageDimensions(v) }) } } this.filesMessage = null this.itemCount = Object.keys(current).length if (this.itemCount > 4000) { this.filesMessage = "Too many results, try narrowing your search" return [] } else if (!this.itemCount) { this.filesMessage = "No results" return [] } let entries if (this.searchOpen && searchText && this.sort === "name") { entries = Object.entries(current).sort(([ka, va], [kb, vb]) => { ka = ka.toLowerCase() kb = kb.toLowerCase() const isFolderA = typeof va === "object" const isFolderB = typeof vb === "object" const extA = PathModule.extname(ka) const extB = PathModule.extname(kb) const baseA = PathModule.basename(ka, PathModule.extname(ka)) const baseB = PathModule.basename(kb, PathModule.extname(kb)) if (baseA === searchText && baseB === searchText) { if (isFolderA !== isFolderB) return isFolderA ? 1 : -1 return naturalSorter(ka, kb) } if (baseA === searchText) return -1 if (baseB === searchText) return 1 const aIndex = ka.lastIndexOf(searchText) const bIndex = kb.lastIndexOf(searchText) const slashCount = (ka.slice(aIndex + searchText.length).match(/\//g)?.length ?? 0) - (kb.slice(bIndex + searchText.length).match(/\//g)?.length ?? 0) if (slashCount !== 0) { return slashCount } const aBefore = ka.slice(0, aIndex).lastIndexOf("/") const aAfter = ka.slice(aIndex + searchText.length).indexOf("/") const aSection = PathModule.basename(ka.slice( aBefore === -1 ? 0 : aBefore + 1, aIndex + searchText.length + (aAfter === -1 ? Infinity : aAfter) ), extA) const bBefore = kb.slice(0, bIndex).lastIndexOf("/") const bAfter = kb.slice(bIndex + searchText.length).indexOf("/") const bSection = PathModule.basename(kb.slice( bBefore === -1 ? 0 : bBefore + 1, bIndex + searchText.length + (bAfter === -1 ? Infinity : bAfter) ), extB) if (aSection.startsWith(searchText)) { if (bSection.startsWith(searchText)) { const beforeSlashCount = (ka.slice(0, aIndex).match(/\//g)?.length ?? 0) - (kb.slice(0, bIndex).match(/\//g)?.length ?? 0) if (beforeSlashCount !== 0) return beforeSlashCount return naturalSorter(aSection, bSection) } return -1 } if (bSection.startsWith(searchText)) return 1 return naturalSorter(aSection, bSection) }) if (this.sortDirection === "backwards") { entries.reverse() } } else { entries = Object.entries(current).sort(([ka, va], [kb, vb]) => { ka = ka.toLowerCase() kb = kb.toLowerCase() const isFolderA = typeof va === "object" || ka.endsWith(".zip") const isFolderB = typeof vb === "object" || kb.endsWith(".zip") if (this.sort === "size") { if (isFolderA && !isFolderB) return 1 if (isFolderB && !isFolderA) return -1 } else { if (isFolderA && !isFolderB) return -1 if (isFolderB && !isFolderA) return 1 } if (this.sort === "size") { const dimsA = this.currentFolderData[ka].dimensions const dimsB = this.currentFolderData[kb].dimensions if (dimsA && !dimsB) return -1 if (dimsB && !dimsA) return 1 if (dimsA && dimsB) { const areaA = dimsA[0] * dimsA[1] const areaB = dimsB[0] * dimsB[1] if (areaA !== areaB) { return this.sortDirection === "forwards" ? areaB - areaA : areaA - areaB } } } else if (this.sort === "type") { const labelA = this.currentFolderData[ka].label const labelB = this.currentFolderData[kb].label if (labelA && !labelB) return -1 if (labelB && !labelA) return 1 if (labelA && labelB) { const sort = this.sortDirection === "forwards" ? naturalSorter(labelA, labelB) : naturalSorter(labelB, labelA) if (sort) return sort } } return this.sortDirection === "forwards" ? naturalSorter(ka, kb) : naturalSorter(kb, ka) }) } this.lastInteracted = entries[0]?.[0] this.selected = [] return entries } }, methods: { updateVersion() { if (this.selectedVersions[this.type]) { this.version = this.selectedVersions[this.type] storage.type = this.type save() } if (this.compareSelectedVersions[this.compareType]) { this.compareVersion = this.compareSelectedVersions[this.compareType] storage.compareType = this.compareType save() } }, async loadVersion(path = []) { if (this.mode === "assets") { this.loadingMessage = `Loading ${this.version}…` } else { this.loadingMessage = `Loading ${this.compareVersion} and ${this.version}…` } this.path = path this.navigationHistory = [[]] this.navigationFuture = [] this.searchOpen = false this.searchText = "" this.progressDone = 0 this.progressTotal = 0 if (this.mode === "assets") { this.jar = await getVersionJar(this.version) } else { this.jar = await getVersionComparison(this.compareVersion, this.version) } if (!Object.keys(this.jar.files).length) { this.jar = null this.loadingMessage = null Blockbench.showQuickMessage("Unable to load version. It may be corrupted") return } if (this.mode === "assets" && this.objects) { for (const [k, v] of Object.entries(await getVersionObjects(this, this.version))) { if (k.endsWith(".png") || k.endsWith(".jpg") || k.endsWith(".jpeg")) { v.content = await fs.promises.readFile(v.path) const img = new Image img.src = "data:image/png;base64," + v.content.toString("base64") v.image = img } this.$set(this.jar.files, k, v) } } if (!this.jar.optifineLoaded && this.version.includes("OptiFine")) { const folderName = this.version.replace("-OptiFine", "") const libraryFile = PathModule.join(settings.ewan_minecraft_directory.value, "libraries", "optifine", "OptiFine", folderName, `OptiFine-${folderName}.jar`) if (await exists(libraryFile)) { const zip = parseZip(await fs.promises.readFile(libraryFile).then(e => e.buffer)) for (const [k, v] of Object.entries(zip.files)) { this.$set(this.jar.files, k, v) } this.jar.optifineLoaded = true } } this.tree = {} for (let [path, value] of Object.entries(this.jar.files)) { const parts = path.split("/") if (parts[0] === "optifine") continue let current = this.tree const zip = parts.some(e => e.endsWith(".zip")) for (const [index, part] of parts.entries()) { if (!current[part]) { current[part] = index === parts.length - 1 ? path : {} } current = current[part] } } if (this.tree.resource_pack?.textures?.["flipbook_textures.json"]) { this.jar.flipbook = JSON.parse(this.jar.files[this.tree.resource_pack.textures["flipbook_textures.json"]].content.toString().replace(/\/\/.*$/gm, "")) this.jar.flipbook.push({ flipbook_texture: "textures/flame_atlas" }) } if (this.mode === "assets") { if (storage.recents.includes(this.version)) { storage.recents.splice(storage.recents.indexOf(this.version), 1) } storage.recents.unshift(this.version) if (storage.length > 20) { storage.recents.length = 20 } } else { const index = storage.recentComparisons.findIndex(e => e[0] === this.compareVersion && e[1] === this.version) if (index !== -1) { storage.recentComparisons.splice(index, 1) } storage.recentComparisons.unshift([this.compareVersion, this.version]) if (storage.length > 20) { storage.recentComparisons.length = 20 } } save() this.loadingMessage = null }, hasAnimation(file) { if (this.jar.files[file].animation === false) return if (this.jar.files[file].animation) return true if (this.jar.flipbook) { const split = file.split("/") if (split[0] === "resource_pack") { const texture = split.slice(1).join("/").slice(0, -4) const anim = this.jar.flipbook.find(e => e.flipbook_texture === texture) if (anim) { this.jar.files[file].animation = { animation: { frametime: anim.ticks_per_frame, interpolate: anim.blend_frames ?? true, frames: anim.frames } } return true } } this.jar.files[file].animation = false return } const mcmeta = this.jar.files[file + ".mcmeta"] if (mcmeta) { try { const data = JSON.parse(mcmeta.content) if (data.animation) { this.jar.files[file].animation = data return true } } catch {} this.jar.files[file].animation = false } }, select(file, value, event) { if (event.currentTarget.dataset.lastClick) { if (Date.now() - Number(event.currentTarget.dataset.lastClick) < 500) { if (typeof value === "object") { return this.openFolder(this.path.concat(file)) } else { return this.openFiles() } } } event.currentTarget.dataset.lastClick = Date.now() const keys = this.currentFolderContents.map(entry => entry[0]) if (!event.shiftKey) { this.shiftStartItem = null } if (event.shiftKey) { if (!this.shiftStartItem) { this.shiftStartItem = this.lastInteracted } const start = keys.indexOf(this.shiftStartItem) const selected = this.selected.includes(this.shiftStartItem) const end = keys.indexOf(file) const range = keys.slice(Math.min(start, end), Math.max(start, end) + 1) if (event.ctrlKey || event.metaKey) { if (selected) { this.selected = Array.from(new Set(this.selected.concat(range))) } else { this.selected = this.selected.filter(e => !range.includes(e)) } } else { this.selected = range } } else if (event.ctrlKey || event.metaKey) { const index = this.selected.indexOf(file) if (index !== -1) { this.selected.splice(index, 1) } else { this.selected.push(file) } } else { this.selected = [file] } this.lastInteracted = file }, async getFileContent(file) { const data = this.jar.files[file] ?? this.jar.zips?.[file] if (!data) return if (!data.content) { data.content = await fs.promises.readFile(data.path) } return data.content }, getFileContentSync(file) { const data = this.jar.files[file] ?? this.jar.zips?.[file] if (!data) return if (!data.content) { data.content = fs.readFileSync(data.path) } return data.content }, async openFilesCheck() { if (this.selected.length <= 16) return true if (!await confirm("Open files", `You are about to open ${this.selected.length.toLocaleString()} files. Are you sure you want to continue?`)) return if (this.selected.length > 128) { if (!await confirm("Open files", `Are you really sure? ${this.selected.length.toLocaleString()} files is a lot. Are you absolutely sure you want to continue?`)) return } return true }, async openFiles() { if (!(await this.openFilesCheck())) return const files = this.selected.map(e => { const path = this.path.concat(e).join("/") return ({ name: e, path }) }) let closeDialog const blockbenchOpen = [] await Promise.all(files.map(async file => { const content = await this.getFileContent(file.path) if (!content) return if (file.name.endsWith(".png")) { Codecs.image.load([{ content: "data:image/png;base64," + content.toString("base64") }], name) closeDialog = true } else if (file.name.endsWith(".zip")) { await this.loadZip(file.path) this.openFolder(this.path.concat(file.name)) } else if (await this.blockbenchOpenable(file.path)) { try { blockbenchOpen.push({ content: JSON.parse(content), name: file.name, path: this.path.concat(file.name).join("/"), type: this.jar.files[file.path].formatType ?? "json" }) if (this.jar.files[file.path].formatType) { closeDialog = true } } catch { blockbenchOpen.push({ content: content.toString(), name: file.name, path: this.path.concat(file.name).join("/") }) } } else { this.openExternally(file.path) } })) if (closeDialog) { dialog.close() } if (blockbenchOpen.length) { for (const file of blockbenchOpen) { if (file.type === "java") { await this.loadJavaBlockItemModel(file, blockbenchOpen.length) } else if (file.type === "bedrock") { loadModelFile({ content: JSON.stringify(file.content), path: `${Date.now()}${osfs}${file.name}` }, { externalDataLoader: data => { if (typeof data === "string") { return this.getFileContentSync(`resource_pack/${data}`) } const files = Object.keys(this.jar.files).filter(e => e.startsWith(`resource_pack/${data.dir}`)).filter(data.filter) for (const file of files) { const content = this.getFileContentSync(file).toString() const output = data.find(content) if (output) { if (data.return === "find") { return output } else { return content } } } } }) } else { const extension = PathModule.extname(file.name) || ".txt" const parent = this new Dialog({ id: id + "_text_viewer", title: file.name, width: 816, resizable: true, buttons: [], lines: [`

Icons can be from any of the following sources:

Icon Source Formatting
Google Material Icons icon_name
Font Awesome Free fa-icon-name
Blockbench icon-icon_name
`], onConfirm: result => { this.$set(folder, 1, result.name.trim() || null) this.$set(folder, 2, result.icon.trim().toLowerCase().replaceAll(" ", "_") || null) save() } }).show() } }, "_", { id: "move_up", name: "Move Up", icon: "arrow_upward", condition: this.validSavedFolders[0] !== folder, click: () => { storage.savedFolders.splice(storage.savedFolders.indexOf(folder), 1) storage.savedFolders.splice(storage.savedFolders.indexOf(this.validSavedFolders[this.validSavedFolders.indexOf(folder) - 1]), 0, folder) save() } }, { id: "move_down", name: "Move Down", icon: "arrow_downward", condition: this.validSavedFolders[this.validSavedFolders.length - 1] !== folder, click: () => { storage.savedFolders.splice(storage.savedFolders.indexOf(folder), 1) storage.savedFolders.splice(storage.savedFolders.indexOf(this.validSavedFolders[this.validSavedFolders.indexOf(folder) + 1]) + 1, 0, folder) save() } }, "_", { id: "unpin_from_sidebar", name: "Unpin from Sidebar", icon: "push_pin", click: () => { storage.savedFolders.splice(storage.savedFolders.indexOf(folder), 1) save() } }, "_", { id: "reset", name: "Reset Sidebar", icon: "replay", click: () => { loadSidebar(true) } } ], { onClose: () => this.activeSavedFolder = null }).open(event) }, sidebarContextMenu(event) { new Menu(`${id}_context_menu`, [ { id: "reset", name: "Reset Sidebar", icon: "replay", click: () => { loadSidebar(true) } } ]).show(event) }, folderContextMenu(event) { new Menu(`${id}_context_menu`, [ { id: "export_selection", name: "Export Selection", icon: "fa-file-export", condition: this.selected.length, click: () => this.exportFiles(this.selected) }, { id: "export_folder", name: "Export Folder", icon: "fa-file-export", click: () => this.exportFiles(this.currentFolderContents.map(e => e[0])) } ]).open(event) }, getFileIcon(file, value) { if (file.includes(".lang") || value.startsWith("assets/minecraft/lang/")) return "translate" if (file.endsWith(".json") || file === "pack.mcmeta") return "data_object" if (file.endsWith(".fsh") || file.endsWith(".vsh") || file.endsWith(".glsl")) return "ev_shadow" if (file.includes(".mcmeta")) return "theaters" if (file.includes(".tga")) return "image" if (file.endsWith(".ogg") || file.endsWith(".fsb") || file.endsWith(".mus")) return "volume_up" if (file.includes(".zip")) return "folder_zip" if (file.includes(".properties")) return "list_alt" if (file.includes(".txt")) return "description" return "draft" }, getFolderIcon(path, custom) { if (custom) { return Blockbench.getIconNode(custom).outerHTML } if (!Array.isArray(path)) { path = [path] } let icon for (let i = path.length - 1; i >= 0; i--) { const part = path[i] if (part === "textures") icon = "image" else if (part === "models" || part === "blocks" || part === "block") icon = "deployed_code" else if (part === "items" || part === "item") icon = "swords" else if (part === "sounds") icon = "volume_up" else if (part === "shaders") icon = "ev_shadow" else if (part === "lang") icon = "translate" else if (part === "texts") icon = "text_fields" else if (part === "particles" || part === "particle") icon = "auto_awesome" else if (part === "atlases" || part === "map") icon = "map" else if (part === "font") icon = "font_download" else if (part === "post_effect") icon = "desktop_windows" else if (part === "resourcepacks") icon = "folder_zip" else if (part === "equipment") icon = "checkroom" else if (part === "blockstates") icon = "view_in_ar" else if (part === "entities" || part === "entity" || part === "mob") icon = "creeper" else if (part === "painting") icon = "brush" else if (part === "gui" || part === "ui") icon = "call_to_action" else if (part === "environment") icon = "light_mode" else if (part === "colormap" || part === "color_palettes") icon = "palette" else if (part === "misc") icon = "help" else if (part === "trims") icon = "fa-gem" else if (part === "effect" || part === "mob_effect") icon = "auto_fix" else if (part === "optifine" || part === "mob_effect") icon = "icon-format_optifine" else if (part === "persona_thumbnails") icon = "groups" else if (part === "animations") icon = "sync" else if (part === "animation_controllers") icon = "rule_settings" else if (part === "render_controllers") icon = "visibility" else if (part === "fogs") icon = "foggy" else if (part === "attachables") icon = "electrical_services" else if (part === "ctm") icon = "extension" else if (part === "added") icon = "add" else if (part === "changed") icon = "edit" else if (part === "removed") icon = "delete" else if (part === "waypoint_style") icon = "flag" if (icon) break } if (icon in customIcons) return customIcons[icon] return Blockbench.getIconNode(icon ?? "folder").outerHTML }, openFolder(path) { if (JSON.stringify(path) !== JSON.stringify(this.path)) { path = path.flatMap(e => e.split("/")) this.changeFolder(path) this.navigationHistory.push(path.slice()) this.navigationFuture = [] } }, changeFolder(path) { this.searchOpen = false this.searchText = "" this.path = path.slice() this.getValidSavedFolders() if (this.$refs.files) { this.$refs.files.$el.scrollTop = 0 } this.$nextTick(() => { this.checkBreadcrumbsOverflow() this.$refs.files.onResize() }) }, navigationSearch(item) { this.searchOpen = true this.searchText = item.search this.path = item.path.slice() }, navigationBack() { this.navigationFuture.push(this.navigationHistory.pop()) const prev = this.navigationHistory[this.navigationHistory.length - 1] if (Array.isArray(prev)) { this.changeFolder(prev) } else { this.navigationSearch(prev) } }, navigationForward() { this.navigationHistory.push(this.navigationFuture.pop()) const next = this.navigationHistory[this.navigationHistory.length - 1] if (Array.isArray(next)) { this.changeFolder(next) } else { this.navigationSearch(next) } }, toggleObjects() { this.objects = !this.objects storage.objects = this.objects if (!this.objects) { loadedJars = {} } save() }, setupBreadcrumbs() { if (!this.breadcrumbsResizeObserver) { this.breadcrumbsResizeObserver = new ResizeObserver(() => { this.checkBreadcrumbsOverflow() }) } this.breadcrumbsResizeObserver.observe(this.$refs.breadcrumbs) this.checkBreadcrumbsOverflow() if (!this.$refs.breadcrumbs.dataset.scrollListenerAdded) { this.$refs.breadcrumbs.addEventListener("scroll", this.handleBreadcrumbsScroll) this.$refs.breadcrumbs.dataset.scrollListenerAdded = true } }, checkBreadcrumbsOverflow() { if (this.$refs.breadcrumbs) { this.breadcrumbsOverflowing = this.$refs.breadcrumbs.scrollWidth > this.$refs.breadcrumbs.clientWidth this.$refs.breadcrumbs.scrollLeft = this.$refs.breadcrumbs.scrollWidth } }, handleBreadcrumbsScroll() { if (this.$refs.breadcrumbs.scrollLeft) { this.breadcrumbsOverflowing = this.$refs.breadcrumbs.scrollWidth > this.$refs.breadcrumbs.clientWidth } else { this.breadcrumbsOverflowing = false } }, async loadZip(file) { const content = await this.getFileContent(file) const zip = parseZip(content.buffer, false) const parts = file.split("/") let current = this.tree for (let i = 0; i < parts.length - 1; i++) { current = current[parts[i]] } const lastPart = parts[parts.length - 1] this.$set(current, lastPart, {}) current = current[lastPart] for (const [key, zipFile] of Object.entries(zip.files)) { const fullPath = `${file}/${key}` this.$set(this.jar.files, fullPath, zipFile) const subParts = key.split("/") let subCurrent = current for (const [index, subPart] of subParts.entries()) { if (!subCurrent[subPart]) { this.$set(subCurrent, subPart, index === subParts.length - 1 ? fullPath : {}) } subCurrent = subCurrent[subPart] } } this.jar.zips ??= {} this.jar.zips[file] = this.jar.files[file] delete this.jar.files[file] return current }, async getValidSavedFolders() { let start = this.tree if (this.mode === "compare" && this.path.length) { start = start[this.path[0]] } this.validSavedFolders = (await Promise.all(this.savedFolders.map(async folder => { let current = start for (const segment of folder[0]) { if (typeof current === "string" && current.endsWith(".zip")) { current = await this.loadZip(current) } else if (typeof current === "object" && typeof current[segment] === "string" && segment.endsWith(".zip")) { current[segment] = await this.loadZip(current[segment]) } if (!current || typeof current !== "object" || !(segment in current)) { return null } current = current[segment] } return folder }))).filter(Boolean) }, async keydownHandler(event) { if (this.$refs.browserSearch === document.activeElement) return if (this.jar && !this.loadingMessage) { if ((event.ctrlKey || event.metaKey) && event.key === "a") { this.selected = this.currentFolderContents.map(e => e[0]) } else if (event.key === "Escape") { event.stopPropagation() this.selected = [] } else if (event.key === "Enter") { event.stopPropagation() const [selected, selectionType] = await this.getDetailedSelection() if (selectionType !== "folder") { this.openFiles() } else if (selected.length === 1) { this.path.push(selected[0].name) } } else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) { if (!this.selected.length || this.selected.length > 1) { this.selected = [this.lastInteracted] } else if (this.selected.length === 1) { const container = this.$refs.files.$refs.container const index = this.currentFolderContents.findIndex(e => e[0] === this.selected[0]) if (event.key === "ArrowLeft") { if (index > 0) { this.selected = [this.currentFolderContents[index - 1][0]] } } else if (event.key === "ArrowRight") { if (index < this.currentFolderContents.length - 1) { this.selected = [this.currentFolderContents[index + 1][0]] } } else if (event.key === "ArrowUp" || event.key === "ArrowDown") { const styles = getComputedStyle(container) const gap = parseInt(styles.rowGap) let itemsPerRow = 1 if (this.displayType === "grid") { itemsPerRow = Math.max(1, Math.floor((container.clientWidth - parseInt(styles.padding) * 2 + gap) / (container.children[1].offsetWidth + gap))) } if (event.key === "ArrowUp") { if (index >= itemsPerRow) { this.selected = [this.currentFolderContents[index - itemsPerRow][0]] } } else if (event.key === "ArrowDown") { if (index + itemsPerRow < this.currentFolderContents.length) { this.selected = [this.currentFolderContents[index + itemsPerRow][0]] } } } const selectedElement = container.children[this.currentFolderContents.findIndex(e => e[0] === this.selected[0]) + 1] const containerRect = this.$refs.files.$el.getBoundingClientRect() const elementRect = selectedElement.getBoundingClientRect() if (elementRect.top < containerRect.top || elementRect.bottom > containerRect.bottom) { const scrollTo = this.displayType === "grid" ? selectedElement : selectedElement.children[0] scrollTo.scrollIntoView({ behavior: Date.now() - this.lastArrowKeyPress > 250 ? "smooth" : undefined, block: "nearest" }) } } this.lastArrowKeyPress = Date.now() } if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) { if (Date.now() - this.typeFindLastKey > 1000) { this.typeFindText = event.key.toLowerCase() } else { this.typeFindText += event.key.toLowerCase() } this.typeFindLastKey = Date.now() const index = this.currentFolderContents.findIndex(e => e[0].toLowerCase().startsWith(this.typeFindText)) if (index !== -1) { this.selected = [this.currentFolderContents[index][0]] const container = this.$refs.files.$refs.container let scrollTo, currentItems, newItems const compare = Date.now() this.typeFindStart = compare do { currentItems = container.children.length if (container.children[index + 1]) { scrollTo = this.displayType === "grid" ? container.children[index + 1] : container.children[index + 1].children[0] } else { this.$refs.files.loadMore() await this.$nextTick() } newItems = container.children.length } while (!scrollTo && currentItems !== newItems && this.typeFindStart === compare) scrollTo?.scrollIntoView({ block: "center" }) } } } }, switchDisplay(type) { this.displayType = type storage.display = type save() this.$refs.files.$el.scrollTop = 0 this.$nextTick(() => this.$refs.files.onScroll()) }, getFileLabel(folder, file, value) { if (this.mode === "compare" && this.path.length) { folder = folder.slice(1) } const path = folder.concat(file).join("/") file = PathModule.basename(path) const ext = file.includes(".") ? file.split(".").pop() : null switch (path) { case "assets": return "Resource Pack Assets" case "data": return "Data Pack Assets" case "doc": return "Documentation" case "pack.png": return "Pack Icon" case "pack.mcmeta": return "Pack Metadata" case "version.json": return "Version Information" case "assets/icons": case "assets/minecraft/icons": return "System Icons" case "assets/icons/snapshot": return "Snapshot Icons" case "assets/minecraft": return "Minecraft Assets" case "assets/realms": return "Realms Assets" case "assets/minecraft/atlases": return "Atlas Definitions" case "assets/minecraft/equipment": return "Equipment Definitions" case "assets/minecraft/font": return "Font Definitions" case "assets/minecraft/items": return "Item Definitions" case "assets/minecraft/particles": case "resource_pack/particles": return "Particle Definitions" case "assets/minecraft/post_effect": return "Post-Processing Effects" case "assets/minecraft/resourcepacks": return "Built-in Resource Packs" case "assets/minecraft/sounds": return "Sound Files" case "assets/minecraft/sounds.json": case "resource_pack/sounds_client.json": return "Sound Definitions" case "assets/minecraft/shaders/core": return "Core Shaders" case "assets/minecraft/shaders/include": return "Include Shaders" case "assets/minecraft/shaders/post": return "Post Shaders" case "assets/minecraft/shaders/program": return "Program Shaders" case "assets/minecraft/optifine/ctm": return "Connected Textures" case "assets/minecraft/optifine/bettergrass.properties": return "Better Grass Properties" case "assets/minecraft/optifine/natural.properties": return "Natural Textures Properties" case "assets/minecraft/texts/credits.json": return "Credits" case "assets/minecraft/texts/splashes.txt": return "Splash Texts" case "assets/minecraft/texts/postcredits.txt": return "Post Credits Text" case "assets/minecraft/texts/end.txt": return "End Poem" case "assets/minecraft/textures/environment": return "Sky & Weather" case "assets/minecraft/textures/entity/enderdragon": return "Ender Dragon" case "assets/minecraft/textures/entity/enderman": return "Enderman" case "assets/minecraft/models/block": return "Block Models" case "assets/minecraft/models/item": return "Item Models" case "assets/minecraft/resourcepacks/programmer_art.zip": return "Programmer Art Resource Pack" case "assets/minecraft/resourcepacks/high_contrast.zip": return "High Contrast Resource Pack" case "assets/minecraft/lang/en_us.json": case "assets/minecraft/lang/en_us.lang": case "assets/minecraft/optifine/lang/en_us.lang": return "English (US)" case "assets/minecraft/lang/deprecated.json": return "Deprecated Language Keys" case "behaviour_pack": return "Behaviour Pack Assets" case "resource_pack": return "Resource Pack Assets" case "resource_pack/blocks.json": return "Block Definitions" case "resource_pack/biomes_client.json": return "Biome Definitions" case "resource_pack/textures/flipbook_textures.json": return "Texture Animation Definitions" case "resource_pack/textures/item_texture.json": return "Item Texture Definitions" case "resource_pack/textures/terrain_texture.json": return "Block Texture Definitions" case "resource_pack/entity": return "Entity Definitions" case "resource_pack/models/entity": case "resource_pack/models/mobs.json": return "Entity Models" case "resource_pack/texts/languages.json": return "Languages" case "resource_pack/texts/language_names.json": return "Language Names" case "resource_pack/texts/ja_JP": return "Japanese Assets" case "resource_pack/texts/zh_TW": return "Chinese (Traditional) Assets" } switch (PathModule.dirname(path)) { case "assets/minecraft/atlases": return "Atlas Definition" case "assets/minecraft/blockstates": return "Blockstate" case "assets/minecraft/equipment": return "Equipment Definition" case "assets/minecraft/items": return "Item Definition" case "assets/minecraft/particles": return "Particle Definition" case "assets/minecraft/models/block": return "Block Model" case "assets/minecraft/models/item": return "Item Model" case "assets/minecraft/models/item": return "Item Model" case "assets/minecraft/shaders/core": case "assets/minecraft/shaders/program": case "assets/minecraft/shaders/post": if (ext === "json") return "Shader Program Definition" case "doc/images": return "Template" case "resource_pack/models/entity": return "Entity Model" case "resource_pack/entity": return "Entity Definition" case "resource_pack/animations": return "Animation" case "resource_pack/animation_controllers": return "Animation Controller" case "assets/minecraft/lang": case "assets/minecraft/optifine/lang": if (this.jar.files["pack.mcmeta"]) { let content = this.jar.files["pack.mcmeta"].content if (!content) { content = fs.readFileSync(this.jar.files["pack.mcmeta"].path) this.jar.files["pack.mcmeta"].content = content } const data = JSON.parse(content) const lang = data.language?.[PathModule.basename(file, PathModule.extname(file))] if (lang) { return `${lang.name} (${lang.region})` } } return "Language File" case "resource_pack/texts": if (this.jar.files["resource_pack/texts/language_names.json"]) { let content = this.jar.files["resource_pack/texts/language_names.json"].content if (!content) { content = fs.readFileSync(this.jar.files["resource_pack/texts/language_names.json"].path) this.jar.files["resource_pack/texts/language_names.json"].content = content } const data = JSON.parse(content) const id = PathModule.basename(file, PathModule.extname(file)) const lang = data.find(e => e[0] === id) if (lang) { return lang[1] } } } switch (file) { case "lang": return "Language Files" case "gui": case "ui": return "User Interface" case "equipment": return "Equipment" case "fish": return "Fish" case "sheep": return "Sheep" case "wolf": return "Wolves" case "hud": return "Heads Up Display" case "documentation": return "Documentation" case "metadata": return "Metadata" case "manifest.json": return "Pack Metadata" case "misc": return "Miscellaneous" case "ambient": return "Ambient" case "fire": return "Fire" case "music": return "Music" case "game": return "Game" case "menu": return "Menu" } switch (ext) { case "png": return "Texture" case "jpg": return "Texture" case "tga": return "Texture" case "mcmeta": return "Texture Metadata" case "json": return "JSON File" case "txt": return "Text File" case "properties": return "Properties File" case "lang": return "Language File" case "ogg": case "fsb": return "Sound File" case "vsh": return "Vertex Shader" case "fsh": return "Fragment Shader" case "glsl": return "Shader" case "icns": return "Icon" } if (typeof value === "object") { let label = titleCase(file) if (!label.endsWith("s")) { label += "s" } label = label.replaceAll("Json", "JSON") .replaceAll("Entitys", "Entities") .replaceAll("Bodys", "Bodies") return label } }, getImageDimensions(file) { const data = this.jar.files[file] if (data.image?.width) { return [data.image.width, data.image.height] } }, changeSort(type) { if (this.sort === type) { this.sortDirection = this.sortDirection === "forwards" ? "backwards" : "forwards" } else { this.sort = type this.sortDirection = "forwards" } this.$refs.files.$el.scrollTop = 0 }, openSearch() { this.searchOpen = !this.searchOpen this.makeSearch() if (this.searchOpen) { setTimeout(() => { this.$refs.browserSearch.focus() }, 0) } }, makeSearch() { clearTimeout(this.searchTimeout) this.searchTimeout = setTimeout(() => { const searchText = this.searchOpen ? this.searchText.trim().toLowerCase() : "" const prev = this.navigationHistory[this.navigationHistory.length - 1] if (searchText) { if (!Array.isArray(prev) && prev.search === searchText) return this.navigationHistory.push({ search: searchText, path: this.path.slice() }) } else { if (Array.isArray(prev) && prev.join("/") === this.path.join("/")) return this.navigationHistory.push(this.path.slice()) } this.navigationFuture = [] }, 1000) }, truncate(file) { if (this.displayType === "grid" && file.length > 32) { if (this.searchOpen && this.searchText) { file = "…" + file.slice(-31) } else { file = file.slice(0, 31) + "…" } } return file.replace(/(_|\.|\/)/g, '$1​') }, changeMode(mode) { this.mode = mode if (this.objects && mode === "compare") { loadedJars = {} } }, openSavedFolder(folder) { if (this.mode === "assets" || !this.path.length) { this.openFolder(folder) } else { this.openFolder([this.path[0], ...folder]) } }, textureComparison(file) { const red = [238, 85, 102, 255] const green = [84, 255, 135, 255] const blue = [85, 136, 255, 255] const width = Math.max(file.oldFile.image.width, file.image.width) const height = Math.max(file.oldFile.image.height, file.image.height) const buff = new Uint8ClampedArray(width * height * 4) const w1 = file.oldFile.image.width const w2 = file.image.width const h1 = file.oldFile.image.height const h2 = file.image.height const length = width * height * 4 const tolerance = 0 for (let i = 0; i < length; i += 4) { const x = i / 4 % width const y = Math.floor(i / 4 / width) if (x >= w1 && y >= h2 || x >= w2 && y >= h1) continue else if (x >= w2 && x <= w1 || y >= h2 && y <= h1) { const a = Math.floor((x + y) % 8 / 4) const b = Math.lerp(0.7, 1, a) buff.set([red[0] * b, red[1] * b, red[2] * b, red[3]], i) } else if (y >= h1 && y <= h2 || x >= w1 && x <= w2) { const a = Math.floor((x + y) % 8 / 4) const b = Math.lerp(0.7, 1, a) buff.set([green[0] * b, green[1] * b, green[2] * b, green[3]], i) } else { const i1 = (x + y * w1) * 4 const i2 = (x + y * w2) * 4 if (Math.max(Math.abs(file.oldFile.pixels[i1] - file.pixels[i2]), Math.abs(file.oldFile.pixels[i1 + 1] - file.pixels[i2 + 1]), Math.abs(file.oldFile.pixels[i1 + 2] - file.pixels[i2 + 2]), Math.abs(file.oldFile.pixels[i1 + 3] - file.pixels[i2 + 3])) < tolerance) { buff.set(pixels.slice(i1, i1 + 3), i) buff[i + 3] = file.oldFile.pixels[i1 + 3] / 4 } else if (file.oldFile.pixels[i1 + 3] === 0 && file.pixels[i2 + 3] !== 0) buff.set(green, i) else if (file.oldFile.pixels[i1 + 3] !== 0 && file.pixels[i2 + 3] === 0) buff.set(red, i) else if (file.oldFile.pixels[i1] === file.pixels[i2] && file.oldFile.pixels[i1 + 1] === file.pixels[i2 + 1] && file.oldFile.pixels[i1 + 2] === file.pixels[i2 + 2] && file.oldFile.pixels[i1 + 3] === file.pixels[i2 + 3]) { buff.set(file.oldFile.pixels.slice(i1, i1 + 3), i) buff[i + 3] = file.oldFile.pixels[i1 + 3] / 4 } else buff.set(blue, i) } } new Dialog({ id: id + "_texture_comparison", title: `${this.compareVersion} vs ${this.version} - ${file.path}`, width: 816, buttons: [], lines: [`