feat: TRUE support for slim skins, produce a PCK from a skin

This commit is contained in:
neoapps-dev 2026-04-15 23:11:59 +03:00
parent 4a0a329f6b
commit 912d0da76e
4 changed files with 124 additions and 51 deletions

View file

@ -1033,18 +1033,14 @@ async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: S
let username = config.username;
let _ = fs::write(instance_dir.join("username.txt"), &username);
ensure_server_list(&instance_dir, servers);
if let Some(skin_data) = config.skin_base64 {
use base64::{Engine as _, engine::general_purpose};
let base64_str = skin_data.split(',').nth(1).unwrap_or(&skin_data);
if let Ok(bytes) = general_purpose::STANDARD.decode(base64_str) {
let skin_dir = instance_dir.join("Common").join("res").join("mob");
let _ = fs::create_dir_all(&skin_dir);
let _ = fs::write(skin_dir.join("char.png"), bytes);
}
let skin_pck_path = root.join("Skin.pck");
if skin_pck_path.exists() {
let skin_dlc_dir = instance_dir.join("Windows64Media").join("DLC").join("Custom Skins");
let _ = fs::create_dir_all(&skin_dlc_dir);
let _ = fs::copy(&skin_pck_path, skin_dlc_dir.join("Skin.pck"));
}
let _ = perform_dlc_sync(&app, &instance_dir)?;
let game_exe = instance_dir.join("Minecraft.Client.exe");
if !game_exe.exists() {
return Err("Game executable not found in instance folder.".into());
@ -1344,6 +1340,13 @@ fn delete_screenshot(path: String) -> Result<(), String> {
fs::remove_file(path).map_err(|e| e.to_string())
}
#[tauri::command]
async fn save_global_skin_pck(app: AppHandle, pck_data: Vec<u8>) -> Result<(), String> {
let app_dir = get_app_dir(&app);
let _ = fs::write(app_dir.join("Skin.pck"), pck_data);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@ -1373,7 +1376,7 @@ pub fn run() {
}
}
})
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, get_screenshots, delete_screenshot])
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, get_screenshots, delete_screenshot, save_global_skin_pck])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -8,23 +8,24 @@ interface SavedSkin {
id: string;
name: string;
url: string;
isSlim?: boolean;
}
const DEFAULT_SKINS: SavedSkin[] = [
{ id: 'default', name: 'Default Steve', url: '/images/Default.png' },
{ id: 'neoapps', name: 'neoapps', url: '/Skins/neoapps.png' },
{ id: 'justneki', name: 'JustNeki', url: '/Skins/JustNeki.png' },
{ id: 'kayjann', name: 'KayJann', url: '/Skins/KayJann.png' },
{ id: 'leon', name: 'Leon', url: '/Skins/Leon.png' },
{ id: 'mr_anilex', name: 'mr_anilex', url: '/Skins/mr_anilex.png' },
{ id: 'peter', name: 'Peter', url: '/Skins/Peter.png' },
{ id: 'piebot', name: 'piebot', url: '/Skins/piebot.png' }
{ id: 'default', name: 'Default Steve', url: '/images/Default.png', isSlim: false },
{ id: 'neoapps', name: 'neoapps', url: '/Skins/neoapps.png', isSlim: false },
{ id: 'justneki', name: 'JustNeki', url: '/Skins/JustNeki.png', isSlim: false },
{ id: 'kayjann', name: 'KayJann', url: '/Skins/KayJann.png', isSlim: false },
{ id: 'leon', name: 'Leon', url: '/Skins/Leon.png', isSlim: false },
{ id: 'mr_anilex', name: 'mr_anilex', url: '/Skins/mr_anilex.png', isSlim: false },
{ id: 'peter', name: 'Peter', url: '/Skins/Peter.png', isSlim: false },
{ id: 'piebot', name: 'piebot', url: '/Skins/piebot.png', isSlim: false }
];
const SkinsView = memo(function SkinsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { skinUrl, setSkinUrl } = useSkin();
const { skinUrl, setSkinUrl, setSkinIsSlim } = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -48,30 +49,43 @@ const SkinsView = memo(function SkinsView() {
const [showImportModal, setShowImportModal] = useState(false);
const [modalFocusIndex, setModalFocusIndex] = useState(0);
const [importMode, setImportMode] = useState<'file' | 'username' | null>(null);
const [importMode, setImportMode] = useState<'file' | 'username' | 'model' | null>(null);
const [importUsername, setImportUsername] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
const [pendingSkin, setPendingSkin] = useState<{ url: string, defaultName: string } | null>(null);
const processSkinImage = (url: string, defaultName: string) => {
setPendingSkin({ url, defaultName });
setImportMode('model');
setModalFocusIndex(0);
};
const handleFinalizeImport = (isSlim: boolean) => {
if (!pendingSkin) return;
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
const cvs = document.createElement("canvas");
cvs.width = 64;
cvs.height = 32;
cvs.width = img.width;
cvs.height = img.height;
const ctx = cvs.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
ctx.drawImage(img, 0, 0);
const base64String = cvs.toDataURL("image/png");
const newId = Date.now().toString();
const newSkin = { id: newId, name: defaultName, url: base64String };
const newSkin = { id: newId, name: pendingSkin.defaultName, url: base64String, isSlim };
setSavedSkins(prev => [...prev, newSkin]);
setSkinUrl(base64String);
setSkinIsSlim(isSlim);
setActiveSkinId(newId);
}
};
img.src = url;
img.src = pendingSkin.url;
setShowImportModal(false);
setImportMode(null);
setPendingSkin(null);
};
const handleFetchUsername = async () => {
@ -83,10 +97,6 @@ const SkinsView = memo(function SkinsView() {
const [base64Raw, exactName] = await TauriService.fetchSkin(importUsername.trim());
const skinBase64 = `data:image/png;base64,${base64Raw}`;
processSkinImage(skinBase64, exactName.substring(0, 16));
setShowImportModal(false);
setImportMode(null);
setImportUsername('');
} catch (e: any) {
setImportError(typeof e === 'string' ? e : (e.message || 'Failed to fetch skin'));
} finally {
@ -130,7 +140,7 @@ const SkinsView = memo(function SkinsView() {
setShowImportModal(false);
setModalFocusIndex(0);
}
} else {
} else if (importMode === 'username') {
if (modalFocusIndex === 0 || modalFocusIndex === 1) handleFetchUsername();
else if (modalFocusIndex === 2) {
playBackSound();
@ -139,6 +149,15 @@ const SkinsView = memo(function SkinsView() {
setImportError('');
setModalFocusIndex(0);
}
} else if (importMode === 'model') {
if (modalFocusIndex === 0) handleFinalizeImport(false);
else if (modalFocusIndex === 1) handleFinalizeImport(true);
else if (modalFocusIndex === 2) {
playBackSound();
setImportMode(null);
setPendingSkin(null);
setModalFocusIndex(0);
}
}
}
return;
@ -212,14 +231,13 @@ const SkinsView = memo(function SkinsView() {
};
reader.readAsDataURL(file);
e.target.value = '';
setShowImportModal(false);
setImportMode(null);
};
const handleSkinSelect = (skin: SavedSkin) => {
playPressSound();
setActiveSkinId(skin.id);
setSkinUrl(skin.url);
setSkinIsSlim(skin.isSlim || false);
};
const isDefaultSkin = (id: string | null) => DEFAULT_SKINS.some(d => d.id === id);
@ -230,6 +248,7 @@ const SkinsView = memo(function SkinsView() {
const updatedSkins = savedSkins.filter(s => s.id !== activeSkinId);
setSavedSkins(updatedSkins);
setSkinUrl('/images/Default.png');
setSkinIsSlim(false);
setActiveSkinId('default');
};
@ -296,8 +315,9 @@ const SkinsView = memo(function SkinsView() {
const isFocused = focusIndex === idx;
return (
<div key={skin.id} data-index={idx} tabIndex={0} onMouseEnter={() => setFocusIndex(idx)} className="flex flex-col items-center gap-1 w-32 outline-none">
<div className="h-4">
<div className="h-4 flex items-center justify-center gap-1">
{isActive && <span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">Active</span>}
{skin.isSlim && <span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">Slim</span>}
</div>
<div
onClick={() => handleSkinSelect(skin)}
@ -352,7 +372,7 @@ const SkinsView = memo(function SkinsView() {
From Username
</button>
</div>
) : (
) : importMode === 'username' ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<input
type="text"
@ -377,7 +397,27 @@ const SkinsView = memo(function SkinsView() {
{isImporting ? 'Fetching...' : 'Fetch Skin'}
</button>
</div>
)}
) : importMode === 'model' ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<span className="text-white/80 text-sm text-center mb-2 mc-text-shadow">Choose the player model type for this skin:</span>
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => { playPressSound(); handleFinalizeImport(false); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
Normal (Wide Arms)
</button>
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={() => { playPressSound(); handleFinalizeImport(true); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
Slim (Thin Arms)
</button>
</div>
) : null}
<button
onMouseEnter={() => setModalFocusIndex(2)}

View file

@ -1,5 +1,7 @@
import { useState, useEffect } from "react";
import { useLocalStorage } from "./useLocalStorage";
import { PckService } from "../services/PckService";
import { TauriService } from "../services/TauriService";
interface Edition {
id: string;
@ -12,39 +14,61 @@ interface UseSkinSyncProps {
}
export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
const [skinUrl, setSkinUrl] = useLocalStorage(
"lce-skin",
"/images/Default.png",
);
const [skinUrl, setSkinUrl] = useLocalStorage("lce-skin", "/images/Default.png");
const [skinIsSlim, setSkinIsSlim] = useLocalStorage("lce-skin-slim", false);
const [skinBase64, setSkinBase64] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!skinUrl) return;
const edition = editions.find((e) => e.id === profile);
const supportsSlim = edition?.supportsSlimSkins ?? false;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
img.onload = async () => {
if (cancelled) return;
const cvs = document.createElement("canvas");
if (supportsSlim) {
cvs.width = img.width;
cvs.height = img.height;
} else {
cvs.width = 64;
cvs.height = 32;
}
cvs.width = img.width;
cvs.height = img.height;
const ctx = cvs.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0);
setSkinBase64(cvs.toDataURL("image/png"));
const b64 = cvs.toDataURL("image/png");
setSkinBase64(b64);
try {
const res = await fetch(b64);
const buf = await res.arrayBuffer();
const animValue = (supportsSlim && skinIsSlim) ? (1 << 19) : 0;
const pckBuf = PckService.serializePCK({
version: 2,
endianness: "little",
xmlSupport: false,
properties: ["ANIM"],
files: [{
id: "dlcskin00000001",
path: "dlcskin00000001.png",
type: 0,
size: buf.byteLength,
data: new Uint8Array(buf),
properties: [{
key: "ANIM",
value: animValue.toString(10)
}, {
key: "DISPLAYNAME",
value: "Custom Skin"
}, {
key: "THEMENAME",
value: "Emerald Launcher"
}]
}]
});
await TauriService.saveGlobalSkinPck(new Uint8Array(pckBuf));
} catch (e) {
console.error("Failed to generate and save Skin PCK", e);
}
}
};
img.src = skinUrl;
return () => {
cancelled = true;
};
@ -53,6 +77,8 @@ export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
return {
skinUrl,
setSkinUrl,
skinIsSlim,
setSkinIsSlim,
skinBase64,
};
}

View file

@ -169,4 +169,8 @@ export class TauriService {
static async fetchSkin(username: string): Promise<[string, string]> {
return invoke("fetch_skin", { username });
}
static async saveGlobalSkinPck(pckData: Uint8Array): Promise<void> {
return invoke("save_global_skin_pck", { pckData: Array.from(pckData) });
}
}