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:
`],
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: [`