diff --git a/public/images/tools/options.png b/public/images/tools/options.png new file mode 100644 index 0000000..64a7ea2 Binary files /dev/null and b/public/images/tools/options.png differ diff --git a/src/components/views/DevtoolsView.tsx b/src/components/views/DevtoolsView.tsx index 622149b..00b4fe7 100644 --- a/src/components/views/DevtoolsView.tsx +++ b/src/components/views/DevtoolsView.tsx @@ -14,7 +14,8 @@ const DEV_TOOLS: DevTool[] = [ { id: "arc", name: "ARC Editor", view: "arc-editor", comingSoon: false }, { id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false }, { id: "grf", name: "GRF Editor", view: "grf-editor", comingSoon: false }, - { id: "col", name: "COL Editor", view: "col-editor", comingSoon: false } + { id: "col", name: "COL Editor", view: "col-editor", comingSoon: false }, + { id: "options", name: "Options Editor", view: "options-editor", comingSoon: false } ]; export default function DevtoolsView() { diff --git a/src/components/views/OptionsEditorView.tsx b/src/components/views/OptionsEditorView.tsx new file mode 100644 index 0000000..830f02c --- /dev/null +++ b/src/components/views/OptionsEditorView.tsx @@ -0,0 +1,241 @@ +import { useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; +import { OptionsService } from "../../services/OptionsService"; +import { OptionsFile } from "../../types/options"; +const BUTTONS: Record = { + 0x00: "NONE", 0x01: "A", 0x02: "B", 0x03: "X", 0x04: "Y", + 0x05: "D-Pad Left", 0x06: "D-Pad Right", 0x07: "D-Pad Up", 0x08: "D-Pad Down", + 0x09: "RB", 0x0A: "LB", 0x0B: "RT", 0x0C: "LT", + 0x0D: "RS", 0x0E: "LS" +}; + +export default function OptionsEditorView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled } = useConfig(); + const [opt, setOpt] = useState(null); + const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); + const fileInputRef = useRef(null); + const [activeTab, setActiveTab] = useState<"settings" | "skins" | "actions">("settings"); + const showNotification = (message: string, type: "success" | "error" = "success") => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }; + + const handleFileLoad = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + const parsed = OptionsService.readOptions(buffer); + setOpt(parsed); + showNotification(`Loaded options.dat`); + } catch (err: any) { + console.error("Failed to parse Options", err); + showNotification(err.message || "Failed to parse Options", "error"); + } + e.target.value = ""; + }; + + const handleSaveOptions = () => { + if (!opt) return; + playPressSound(); + try { + const buffer = OptionsService.serializeOptions(opt); + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "options.dat"; + a.click(); + URL.revokeObjectURL(url); + showNotification("Options Saved"); + } catch (err: any) { + showNotification("Failed to save", "error"); + } + }; + + const updateSetting = (field: keyof OptionsFile, value: any) => { + if (!opt) return; + setOpt({ ...opt, [field]: value }); + }; + + const updateAction = (field: keyof OptionsFile["actions"], value: number) => { + if (!opt) return; + setOpt({ ...opt, actions: { ...opt.actions, [field]: value } }); + }; + + const updateFavSkin = (idx: number, value: number) => { + if (!opt) return; + const newFav = [...opt.favoriteSkins]; + newFav[idx] = value; + setOpt({ ...opt, favoriteSkins: newFav }); + }; + + return ( + + +
+
+

Options Editor

+
+
+ + +
+
+ + {!opt ? ( +
+

Open settings.dat to begin

+
+ ) : ( +
+
+ {(["settings", "skins", "actions"] as const).map(tab => ( + + ))} +
+ +
+ {activeTab === "settings" && ( +
+ {Object.keys(opt).filter(k => k !== "actions" && k !== "rawData" && k !== "chosenSkin" && k !== "playerCape" && k !== "favoriteSkins" && k !== "endianness").map((k) => { + const val = opt[k as keyof OptionsFile]; + if (typeof val === "boolean") { + return ( +
+
+ )} + +
+ +
+ + + {notification && ( + + + {notification.message} + + + )} + + + ); +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index cebf4fe..26cc858 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -12,6 +12,7 @@ import { ArcEditorView } from "../components/views/ArcEditorView"; import LocEditorView from "../components/views/LocEditorView"; import GrfEditorView from "../components/views/GrfEditorView"; import ColEditorView from "../components/views/ColEditorView"; +import OptionsEditorView from "../components/views/OptionsEditorView"; import ScreenshotsView from "../components/views/ScreenshotsView"; import SkinViewer from "../components/common/SkinViewer"; import TeamModal from "../components/modals/TeamModal"; @@ -360,6 +361,9 @@ export default function App() { {activeView === "col-editor" && ( )} + {activeView === "options-editor" && ( + + )} {activeView === "skins" && } {activeView === "screenshots" && ( diff --git a/src/services/OptionsService.ts b/src/services/OptionsService.ts new file mode 100644 index 0000000..84c3b9b --- /dev/null +++ b/src/services/OptionsService.ts @@ -0,0 +1,167 @@ +import { OptionsFile } from "../types/options"; + +export class OptionsService { + public static readOptions(buffer: ArrayBuffer, endianness: "little" | "big" = "little"): OptionsFile { + const rawData = new Uint8Array(buffer).slice(); + const view = new DataView(buffer); + const little = endianness === "little"; + + const opt: Partial = { + endianness, + rawData + }; + + opt.musicVolume = view.getUint8(0x01); + opt.soundVolume = view.getUint8(0x02); + opt.gameSensitivity = view.getUint8(0x03); + opt.gamma = view.getUint8(0x04); + opt.interfaceSensitivity = view.getUint8(0x50); + opt.interfaceOpacity = view.getUint8(0x51); + const val06 = view.getUint16(0x06, little); + opt.difficulty = val06 & 0x3; + opt.viewBobbing = (val06 & (1 << 2)) !== 0; + opt.inGameGamertags = (val06 & (1 << 3)) !== 0; + opt.invertLook = (val06 & (1 << 6)) !== 0; + opt.southpaw = (val06 & (1 << 7)) !== 0; + opt.verticalSplitscreen = (val06 & (1 << 8)) !== 0; + opt.splitscreenGamertags = (val06 & (1 << 9)) !== 0; + opt.hints = (val06 & (1 << 10)) !== 0; + opt.autosaveTimer = (val06 >> 11) & 0xF; + opt.inGameTooltips = (val06 & (1 << 15)) !== 0; + const val54 = view.getUint32(0x54, little); + opt.renderClouds = (val54 & (1 << 0)) !== 0; + opt.displayHud = (val54 & (1 << 7)) !== 0; + opt.displayHand = (val54 & (1 << 8)) !== 0; + opt.customSkinAnimation = (val54 & (1 << 9)) !== 0; + opt.deathMessages = (val54 & (1 << 10)) !== 0; + opt.hudSize = (val54 >> 11) & 0x3; + opt.hudSizeSplitscreen = (val54 >> 13) & 0x3; + opt.animatedCharacter = (val54 & (1 << 15)) !== 0; + opt.classicCrafting = (val54 & (1 << 18)) !== 0; + opt.caveSounds = (val54 & (1 << 19)) !== 0; + opt.gameChat = (val54 & (1 << 20)) !== 0; + opt.minecartSounds = (val54 & (1 << 21)) !== 0; + opt.showGlideGhost = (val54 & (1 << 22)) !== 0; + opt.autoJump = (val54 & (1 << 26)) !== 0; + opt.displayGameMessages = (val54 & (1 << 28)) !== 0; + opt.displaySaveIcon = (val54 & (1 << 29)) !== 0; + opt.flyingViewRolling = (val54 & (1 << 30)) === 0; + opt.showGlideGhostPath = (val54 & (1 << 31)) !== 0; + opt.chosenSkin = view.getUint32(0x4C, little); + opt.playerCape = view.getUint32(0x5C, little); + opt.favoriteSkins = []; + for (let i = 0; i < 10; i++) { + opt.favoriteSkins.push(view.getUint32(0x60 + i * 4, little)); + } + + opt.actions = { + jump: view.getUint8(0xA4), + use: view.getUint8(0xA5), + action: view.getUint8(0xA6), + cycleHeldItemLeft: view.getUint8(0xA7), + cycleHeldItemRight: view.getUint8(0xA8), + inventory: view.getUint8(0xA9), + drop: view.getUint8(0xAA), + sneakDismount: view.getUint8(0xAB), + crafting: view.getUint8(0xAC), + changeCameraMode: view.getUint8(0xAD), + flyLeft: view.getUint8(0xAE), + flyRight: view.getUint8(0xAF), + flyUp: view.getUint8(0xB0), + flyDown: view.getUint8(0xB1), + sprint: view.getUint8(0xB2), + pickBlock: view.getUint8(0xB3), + previousPlayer: view.getUint8(0xB4), + nextPlayer: view.getUint8(0xB5), + spectateNoise: view.getUint8(0xB6), + cancelSpectating: view.getUint8(0xB7), + confirmReady: view.getUint8(0xB8), + vote: view.getUint8(0xB9), + restartSection: view.getUint8(0xBA), + restartRace: view.getUint8(0xBB), + lookBehind: view.getUint8(0xBC) + }; + + return opt as OptionsFile; + } + + public static serializeOptions(opt: OptionsFile): ArrayBuffer { + const minSize = 0xBC + 1; + const buffer = new Uint8Array(Math.max(opt.rawData.length, minSize)); + buffer.set(opt.rawData); + const view = new DataView(buffer.buffer); + const little = opt.endianness === "little"; + view.setUint8(0x01, opt.musicVolume); + view.setUint8(0x02, opt.soundVolume); + view.setUint8(0x03, opt.gameSensitivity); + view.setUint8(0x04, opt.gamma); + view.setUint8(0x50, opt.interfaceSensitivity); + view.setUint8(0x51, opt.interfaceOpacity); + let val06 = view.getUint16(0x06, little); + val06 = (val06 & ~0x3) | (opt.difficulty & 0x3); + val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? (1 << 2) : 0); + val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? (1 << 3) : 0); + val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? (1 << 6) : 0); + val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? (1 << 7) : 0); + val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? (1 << 8) : 0); + val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? (1 << 9) : 0); + val06 = (val06 & ~(1 << 10)) | (opt.hints ? (1 << 10) : 0); + val06 = (val06 & ~(0xF << 11)) | ((opt.autosaveTimer & 0xF) << 11); + val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? (1 << 15) : 0); + view.setUint16(0x06, val06, little); + let val54 = view.getUint32(0x54, little); + val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? (1 << 0) : 0); + val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? (1 << 7) : 0); + val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? (1 << 8) : 0); + val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? (1 << 9) : 0); + val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? (1 << 10) : 0); + val54 = (val54 & ~(0x3 << 11)) | ((opt.hudSize & 0x3) << 11); + val54 = (val54 & ~(0x3 << 13)) | ((opt.hudSizeSplitscreen & 0x3) << 13); + val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? (1 << 15) : 0); + val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? (1 << 18) : 0); + val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? (1 << 19) : 0); + val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? (1 << 20) : 0); + val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? (1 << 21) : 0); + val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? (1 << 22) : 0); + val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? (1 << 26) : 0); + val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? (1 << 28) : 0); + val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? (1 << 29) : 0); + val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? (1 << 30) : 0); + val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? (1 << 31) : 0); + view.setUint32(0x54, val54, little); + view.setUint32(0x4C, opt.chosenSkin, little); + view.setUint32(0x5C, opt.playerCape, little); + for (let i = 0; i < 10; i++) { + if (opt.favoriteSkins[i] !== undefined) { + view.setUint32(0x60 + i * 4, opt.favoriteSkins[i], little); + } + } + + view.setUint8(0xA4, opt.actions.jump); + view.setUint8(0xA5, opt.actions.use); + view.setUint8(0xA6, opt.actions.action); + view.setUint8(0xA7, opt.actions.cycleHeldItemLeft); + view.setUint8(0xA8, opt.actions.cycleHeldItemRight); + view.setUint8(0xA9, opt.actions.inventory); + view.setUint8(0xAA, opt.actions.drop); + view.setUint8(0xAB, opt.actions.sneakDismount); + view.setUint8(0xAC, opt.actions.crafting); + view.setUint8(0xAD, opt.actions.changeCameraMode); + view.setUint8(0xAE, opt.actions.flyLeft); + view.setUint8(0xAF, opt.actions.flyRight); + view.setUint8(0xB0, opt.actions.flyUp); + view.setUint8(0xB1, opt.actions.flyDown); + view.setUint8(0xB2, opt.actions.sprint); + view.setUint8(0xB3, opt.actions.pickBlock); + view.setUint8(0xB4, opt.actions.previousPlayer); + view.setUint8(0xB5, opt.actions.nextPlayer); + view.setUint8(0xB6, opt.actions.spectateNoise); + view.setUint8(0xB7, opt.actions.cancelSpectating); + view.setUint8(0xB8, opt.actions.confirmReady); + view.setUint8(0xB9, opt.actions.vote); + view.setUint8(0xBA, opt.actions.restartSection); + view.setUint8(0xBB, opt.actions.restartRace); + view.setUint8(0xBC, opt.actions.lookBehind); + return buffer.buffer; + } +} diff --git a/src/types/options.ts b/src/types/options.ts new file mode 100644 index 0000000..a93b0c8 --- /dev/null +++ b/src/types/options.ts @@ -0,0 +1,69 @@ +export interface OptionsFile { + endianness: "little" | "big"; + musicVolume: number; + soundVolume: number; + gamma: number; + gameSensitivity: number; + interfaceSensitivity: number; + interfaceOpacity: number; + difficulty: number; + viewBobbing: boolean; + invertLook: boolean; + southpaw: boolean; + verticalSplitscreen: boolean; + inGameGamertags: boolean; + autosaveTimer: number; + splitscreenGamertags: boolean; + hints: boolean; + inGameTooltips: boolean; + renderClouds: boolean; + displayHud: boolean; + displayHand: boolean; + customSkinAnimation: boolean; + deathMessages: boolean; + hudSize: number; + hudSizeSplitscreen: number; + animatedCharacter: boolean; + classicCrafting: boolean; + caveSounds: boolean; + gameChat: boolean; + minecartSounds: boolean; + displayGameMessages: boolean; + displaySaveIcon: boolean; + flyingViewRolling: boolean; + showGlideGhost: boolean; + showGlideGhostPath: boolean; + autoJump: boolean; + chosenSkin: number; + playerCape: number; + favoriteSkins: number[]; + actions: { + jump: number; + use: number; + action: number; + cycleHeldItemLeft: number; + cycleHeldItemRight: number; + inventory: number; + drop: number; + sneakDismount: number; + crafting: number; + changeCameraMode: number; + flyLeft: number; + flyRight: number; + flyUp: number; + flyDown: number; + sprint: number; + pickBlock: number; + previousPlayer: number; + nextPlayer: number; + spectateNoise: number; + cancelSpectating: number; + confirmReady: number; + vote: number; + restartSection: number; + restartRace: number; + lookBehind: number; + }; + + rawData: Uint8Array; +}