diff --git a/.gitignore b/.gitignore index a1e29b9..1ffdb6e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ Thumbs.db /.flatpak-builder /*.flatpak /emerald-repo +/ignore/* \ No newline at end of file diff --git a/src/components/views/PckEditorView.tsx b/src/components/views/PckEditorView.tsx index 2d17287..034b0d0 100644 --- a/src/components/views/PckEditorView.tsx +++ b/src/components/views/PckEditorView.tsx @@ -1,36 +1,276 @@ -import { useState, useEffect, useRef } from "react"; -import { motion } from "framer-motion"; +import { useState, useEffect, useRef, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; - +import { PckService } from "../../services/PckService"; +import { PCKFile, PCKAsset, PCKAssetType } from "../../types/pck"; export default function PckEditorView() { const { setActiveView } = useUI(); - const { playBackSound } = useAudio(); + const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); - const [focusIndex, setFocusIndex] = useState(0); + const [pck, setPck] = useState(null); + const [selectedAssetId, setSelectedAssetId] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); const containerRef = useRef(null); + const fileInputRef = useRef(null); + const replaceInputRef = useRef(null); + const addAssetInputRef = useRef(null); + const treeData = useMemo(() => { + if (!pck) return []; + interface TempNode { + name: string; + path: string; + isFolder: boolean; + asset?: PCKAsset; + children: Record; + } + + const root: Record = {}; + pck.files.forEach(asset => { + if (searchTerm && !asset.path.toLowerCase().includes(searchTerm.toLowerCase())) return; + const parts = asset.path.split("/"); + let currentLevel = root; + let currentPath = ""; + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isLast = index === parts.length - 1; + if (!currentLevel[part]) { + currentLevel[part] = { + name: part, + path: currentPath, + isFolder: !isLast, + asset: isLast ? asset : undefined, + children: {} + }; + } + currentLevel = currentLevel[part].children; + }); + }); + + const convert = (nodes: Record): any[] => { + return Object.values(nodes) + .sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return a.name.localeCompare(b.name); + }) + .map(node => ({ + ...node, + children: convert(node.children) + })); + }; + + return convert(root); + }, [pck, searchTerm]); + + const selectedAsset = useMemo(() => { + return pck?.files.find(f => f.id === selectedAssetId) || null; + }, [pck, selectedAssetId]); + + const assetPreviewUrl = useMemo(() => { + if (!selectedAsset || ![PCKAssetType.SKIN, PCKAssetType.CAPE, PCKAssetType.TEXTURE].includes(selectedAsset.type)) return null; + const blob = new Blob([selectedAsset.data as any], { type: "image/png" }); + return URL.createObjectURL(blob); + }, [selectedAsset]); + + const toggleFolder = (path: string) => { + const next = new Set(expandedFolders); + if (next.has(path)) next.delete(path); + else next.add(path); + setExpandedFolders(next); + }; + + const renderTree = (nodes: any[], depth = 0) => { + return nodes.map(node => { + const isExpanded = expandedFolders.has(node.path) || !!searchTerm; + const isSelected = selectedAssetId === node.asset?.id; + return ( +
+
{ + if (node.isFolder) { + toggleFolder(node.path); + } else { + playPressSound(); + setSelectedAssetId(node.asset.id); + } + }} + style={{ paddingLeft: `${depth * 16 + 12}px` }} + className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${isSelected + ? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]" + : "border-transparent hover:bg-white/5 text-white" + } ${node.isFolder ? "font-bold" : ""}`} + > + {node.isFolder ? ( + + ) : ( +
+ )} + + + {node.name} + + {!node.isFolder && ( + + {(node.asset.size / 1024).toFixed(1)} KB + + )} +
+ {node.isFolder && isExpanded && ( +
+ {renderTree(node.children, depth + 1)} +
+ )} +
+ ); + }); + }; + + const handleFileLoad = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + const parsed = await PckService.readPCK(buffer); + setPck(parsed); + setSelectedAssetId(parsed.files[0]?.id || null); + setExpandedFolders(new Set()); + } catch (err) { + console.error("Failed to parse PCK", err); + } + }; + + + const showNotification = (message: string, type: "success" | "error" = "success") => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }; + + const handleExportAsset = (asset: PCKAsset) => { + playPressSound(); + const blob = new Blob([asset.data as any]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = asset.path.split("/").pop() || "asset"; + a.click(); + URL.revokeObjectURL(url); + showNotification(`Exported: ${asset.path.split("/").pop()}`); + }; + + const handleDeleteAsset = (id: string) => { + if (!pck) return; + playBackSound(); + const newFiles = pck.files.filter(f => f.id !== id); + const assetPath = pck.files.find(f => f.id === id)?.path; + setPck({ ...pck, files: newFiles }); + if (selectedAssetId === id) setSelectedAssetId(newFiles[0]?.id || null); + showNotification(`Deleted: ${assetPath?.split("/").pop()}`); + }; + + const handleReplaceAsset = async (e: React.ChangeEvent) => { + if (!pck || !selectedAssetId) return; + const file = e.target.files?.[0]; + if (!file) return; + + playPressSound(); + const buffer = await file.arrayBuffer(); + const data = new Uint8Array(buffer); + + const newFiles = pck.files.map(f => f.id === selectedAssetId ? { ...f, data, size: data.length } : f); + setPck({ ...pck, files: newFiles }); + e.target.value = ""; + showNotification("Asset Replaced"); + }; + + const handleAddAsset = async (e: React.ChangeEvent) => { + if (!pck) return; + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + const data = new Uint8Array(buffer); + const newAsset: PCKAsset = { + id: Math.random().toString(36).substr(2, 9), + path: file.name, + type: PCKAssetType.TEXTURE, //neo: this is the default btw + size: data.length, + data, + properties: [] + }; + + setPck({ ...pck, files: [...pck.files, newAsset] }); + setSelectedAssetId(newAsset.id); + e.target.value = ""; + showNotification("Asset Added"); + }; + + const handlePropertyEdit = (idx: number, newVal: string) => { + if (!pck || !selectedAssetId) return; + const newFiles = pck.files.map(f => { + if (f.id === selectedAssetId) { + const newProps = [...f.properties]; + newProps[idx] = { ...newProps[idx], value: newVal }; + return { ...f, properties: newProps }; + } + return f; + }); + setPck({ ...pck, files: newFiles }); + }; + + const handleMoveAsset = (direction: 'up' | 'down') => { + if (!pck || !selectedAssetId) return; + const idx = pck.files.findIndex(f => f.id === selectedAssetId); + if (idx === -1) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= pck.files.length) return; + playPressSound(); + const newFiles = [...pck.files]; + [newFiles[idx], newFiles[newIdx]] = [newFiles[newIdx], newFiles[idx]]; + setPck({ ...pck, files: newFiles }); + }; + + const handleSavePCK = () => { + if (!pck) return; + playPressSound(); + const buffer = PckService.serializePCK(pck); + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = pck.files.length > 0 ? "new.pck" : "empty.pck"; + a.click(); + URL.revokeObjectURL(url); + showNotification("PCK Saved Successfully"); + }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (document.activeElement?.tagName === "INPUT") return; if (e.key === "Escape" || e.key === "Backspace") { + if (isEditingProperty) { + setIsEditingProperty(null); + return; + } playBackSound(); setActiveView("devtools"); return; } - if (e.key === "Enter") { - if (focusIndex === 0) { - playBackSound(); - setActiveView("devtools"); - } - } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [playBackSound, setActiveView, focusIndex]); - - useEffect(() => { - const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement; - if (el) el.focus(); - }, [focusIndex]); + }, [playBackSound, setActiveView, isEditingProperty]); return ( -

- PCK Editor -

- -
- -

PCK Editor Coming Soon

-

- This tool will allow you to explore and modify PCK archive files. -

+
+

+ PCK Editor +

+
+ + +
+ + + + {!pck ? ( +
+ +

Open a PCK file to begin editing

+
+ ) : ( +
+
+
+ setSearchTerm(e.target.value)} + className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + /> + +
+
+ {renderTree(treeData)} +
+
+
+ + {!selectedAsset ? ( +
Select an asset
+ ) : ( + +
+

+ {selectedAsset.path.split("/").pop()} +

+
+ + +
+
+ + {assetPreviewUrl && ( +
+ +
+ )} + +
+
+
Metadata Properties
+
+ {selectedAsset.properties.map((prop, idx) => ( +
+ {prop.key} +
+ handlePropertyEdit(idx, e.target.value)} + className="w-full bg-black/40 p-2 text-white border border-[#373737] text-sm focus:border-[#FFFF55] outline-none transition-colors" + /> +
+
+ ))} + {selectedAsset.properties.length === 0 && ( +
No metadata properties available
+ )} +
+
+ +
+
+ + +
+ +
+
+
+ )} +
+
+
+ )} + + + + {notification && ( + + + {notification.message} + + + )} + ); } diff --git a/src/services/PckService.ts b/src/services/PckService.ts new file mode 100644 index 0000000..001ee39 --- /dev/null +++ b/src/services/PckService.ts @@ -0,0 +1,200 @@ +import { PCKAssetType, PCKAsset, PCKFile, PCKProperty } from "../types/pck"; +export class PckService { + private static decodeU16(buffer: ArrayBuffer, length: number, offset: number, littleEndian: boolean): string { + const uint16Array = new Uint16Array(length); + const view = new DataView(buffer); + for (let i = 0; i < length; i++) { + uint16Array[i] = view.getUint16(offset + (i * 2), littleEndian); + } + return String.fromCharCode(...uint16Array); + } + + private static encodeU16(str: string, littleEndian: boolean): Uint8Array { + const buf = new ArrayBuffer(str.length * 2); + const view = new DataView(buf); + for (let i = 0; i < str.length; i++) { + view.setUint16(i * 2, str.charCodeAt(i), littleEndian); + } + return new Uint8Array(buf); + } + + static async readPCK(buffer: ArrayBuffer): Promise { + const view = new DataView(buffer); + let offset = 0; + let version = view.getUint32(offset, true); + let littleEndian = true; + if (version > 3) { + version = view.getUint32(offset, false); + littleEndian = false; + } + offset += 4; + if (version > 3) throw new Error("Invalid PCK version"); + const propertyCount = view.getUint32(offset, littleEndian); + offset += 4; + const properties: string[] = []; + for (let i = 0; i < propertyCount; i++) { + view.getUint32(offset, littleEndian); //neo: this is propertyIndex + offset += 4; + const stringLength = view.getUint32(offset, littleEndian); + offset += 4; + const property = this.decodeU16(buffer, stringLength, offset, littleEndian); + offset += stringLength * 2; + offset += 4; + properties.push(property); + } + + let xmlSupport = properties.includes("XMLVERSION"); + if (xmlSupport) { + offset += 4; + } + + const fileCount = view.getUint32(offset, littleEndian); + offset += 4; + + const fileInfos: { size: number; type: number; path: string }[] = []; + for (let i = 0; i < fileCount; i++) { + const fileSize = view.getUint32(offset, littleEndian); + offset += 4; + const fileType = view.getUint32(offset, littleEndian); + offset += 4; + const pathLength = view.getUint32(offset, littleEndian); + offset += 4; + const path = this.decodeU16(buffer, pathLength, offset, littleEndian).replace(/\\/g, "/"); + offset += pathLength * 2; + offset += 4; + fileInfos.push({ size: fileSize, type: fileType, path }); + } + + const assets: PCKAsset[] = []; + for (const info of fileInfos) { + const assetPropertyCount = view.getUint32(offset, littleEndian); + offset += 4; + + const assetProperties: PCKProperty[] = []; + for (let j = 0; j < assetPropertyCount; j++) { + const propIdx = view.getUint32(offset, littleEndian); + offset += 4; + const valLen = view.getUint32(offset, littleEndian); + offset += 4; + const val = this.decodeU16(buffer, valLen, offset, littleEndian); + offset += valLen * 2; + offset += 4; + assetProperties.push({ key: properties[propIdx], value: val }); + } + + const data = new Uint8Array(buffer.slice(offset, offset + info.size)); + offset += info.size; + + assets.push({ + id: Math.random().toString(36).substr(2, 9), + path: info.path, + type: info.type as PCKAssetType, + size: info.size, + data, + properties: assetProperties + }); + } + + return { + version, + endianness: littleEndian ? "little" : "big", + xmlSupport, + properties, + files: assets + }; + } + + static serializePCK(pck: PCKFile): ArrayBuffer { + const littleEndian = pck.endianness === "little"; + let totalSize = 4 + 4; + const propertySet = new Set(); + if (pck.xmlSupport) propertySet.add("XMLVERSION"); + pck.files.forEach(f => f.properties.forEach(p => propertySet.add(p.key))); + const finalProperties = Array.from(propertySet); + + finalProperties.forEach((prop) => { + totalSize += 4 + 4 + (prop.length * 2) + 4; + }); + + if (pck.xmlSupport) totalSize += 4; + totalSize += 4; + pck.files.forEach(f => { + totalSize += 4 + 4 + 4 + (f.path.length * 2) + 4; + }); + + pck.files.forEach(f => { + totalSize += 4; + f.properties.forEach(p => { + totalSize += 4 + 4 + (p.value.length * 2) + 4; + }); + totalSize += f.data.length; + }); + + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + let offset = 0; + + view.setUint32(offset, pck.version, littleEndian); + offset += 4; + + view.setUint32(offset, finalProperties.length, littleEndian); + offset += 4; + + finalProperties.forEach((prop) => { + view.setUint32(offset, finalProperties.indexOf(prop), littleEndian); + offset += 4; + view.setUint32(offset, prop.length, littleEndian); + offset += 4; + const encoded = this.encodeU16(prop, littleEndian); + new Uint8Array(buffer, offset, encoded.length).set(encoded); + offset += encoded.length; + view.setUint32(offset, 0, littleEndian); + offset += 4; + }); + + if (pck.xmlSupport) { + view.setUint32(offset, 3, littleEndian); + offset += 4; + } + + view.setUint32(offset, pck.files.length, littleEndian); + offset += 4; + + pck.files.forEach(f => { + view.setUint32(offset, f.data.length, littleEndian); + offset += 4; + view.setUint32(offset, f.type, littleEndian); + offset += 4; + view.setUint32(offset, f.path.length, littleEndian); + offset += 4; + const encoded = this.encodeU16(f.path, littleEndian); + new Uint8Array(buffer, offset, encoded.length).set(encoded); + offset += encoded.length; + view.setUint32(offset, 0, littleEndian); + offset += 4; + }); + + pck.files.forEach(f => { + view.setUint32(offset, f.properties.length, littleEndian); + offset += 4; + + f.properties.forEach(p => { + const propIdx = finalProperties.indexOf(p.key); + view.setUint32(offset, propIdx, littleEndian); + offset += 4; + view.setUint32(offset, p.value.length, littleEndian); + offset += 4; + const encoded = this.encodeU16(p.value, littleEndian); + new Uint8Array(buffer, offset, encoded.length).set(encoded); + offset += encoded.length; + view.setUint32(offset, 0, littleEndian); + offset += 4; + }); + + new Uint8Array(buffer, offset, f.data.length).set(f.data); + offset += f.data.length; + }); + + return buffer; + } +} diff --git a/src/types/pck.ts b/src/types/pck.ts new file mode 100644 index 0000000..57466f0 --- /dev/null +++ b/src/types/pck.ts @@ -0,0 +1,39 @@ +export enum PCKAssetType { + SKIN = 0, + CAPE = 1, + TEXTURE = 2, + UI_DATA = 3, + INFO = 4, + TEXTURE_PACK_INFO = 5, + LOCALISATION = 6, + GAME_RULES = 7, + AUDIO_DATA = 8, + COLOUR_TABLE = 9, + GAME_RULES_HEADER = 10, + SKIN_DATA = 11, + MODELS = 12, + BEHAVIOURS = 13, + MATERIALS = 14, +} + +export interface PCKProperty { + key: string; + value: string; +} + +export interface PCKAsset { + id: string; + path: string; + type: PCKAssetType; + size: number; + data: Uint8Array; + properties: PCKProperty[]; +} + +export interface PCKFile { + version: number; + endianness: "little" | "big"; + xmlSupport: boolean; + properties: string[]; + files: PCKAsset[]; +}