mirror of
https://github.com/alula/hbpatcher.git
synced 2025-11-24 10:34:55 +00:00
Initial commit
This commit is contained in:
commit
685bfd5016
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
README.md
Normal file
21
README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# hbpatcher
|
||||
|
||||
A tool for patching Nintendo Switch homebrew (`.nro` files) to use the new ABI, resolving compatibility issues with Atmosphère 1.10.0+ and Firmware 21.0.0+.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This tool may or may not work correctly.** It is not the responsibility of the author of this tool or the author of the homebrew. Users should obtain the latest version of homebrew recompiled against the latest libnx if possible over using this tool. This tool is only an assist for unmaintained homebrew to reduce the impact of kernel changes.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Drag and drop your homebrew `.nro` file into the patcher interface.
|
||||
2. The tool will analyze the file to see if it uses the old ABI.
|
||||
3. If patching is needed, click the "Patch File" button.
|
||||
4. The patched file will be downloaded automatically.
|
||||
5. Copy the patched file to your Switch SD card.
|
||||
|
||||
## For Developers
|
||||
|
||||
**Do not rely on this tool.** Please recompile your homebrew with `libnx v4.10.0` (or newer).
|
||||
|
||||
The updated library resolves the memory conflict and stays backward compatible with older Atmosphère versions and firmwares.
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>libnx ABI Patcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "hbpatcher",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\" index.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "npm:rolldown-vite@7.2.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
2574
pnpm-lock.yaml
generated
Normal file
2574
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/App.css
Normal file
42
src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
45
src/App.tsx
Normal file
45
src/App.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { Header } from "./components/Header";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PatcherPage } from "./pages/PatcherPage";
|
||||
import { AboutPage } from "./pages/AboutPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState("patcher");
|
||||
const [appendSuffix, setAppendSuffix] = useState(false);
|
||||
|
||||
const renderPage = () => {
|
||||
switch (activeTab) {
|
||||
case "patcher":
|
||||
return <PatcherPage appendSuffix={appendSuffix} />;
|
||||
case "about":
|
||||
return <AboutPage />;
|
||||
case "settings":
|
||||
return (
|
||||
<SettingsPage
|
||||
appendSuffix={appendSuffix}
|
||||
onAppendSuffixChange={setAppendSuffix}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-switch-text font-sans flex justify-center md:overflow-hidden">
|
||||
<div className="w-full max-w-[1280px] md:h-screen flex flex-col bg-switch-bg relative">
|
||||
<Header />
|
||||
|
||||
<div className="flex flex-1 md:overflow-hidden flex-col md:flex-row">
|
||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<main className="flex-1 overflow-auto p-8 pt-12">{renderPage()}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
99
src/components/FileDropZone.tsx
Normal file
99
src/components/FileDropZone.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { FileDown } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
onFileSelected: (file: File) => void;
|
||||
}
|
||||
|
||||
export function FileDropZone({ onFileSelected }: FileDropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const nroFile = files.find((file) => file.name.endsWith(".nro"));
|
||||
|
||||
if (nroFile) {
|
||||
onFileSelected(nroFile);
|
||||
} else if (files.length > 0) {
|
||||
onFileSelected(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelected(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
border-2 border-dashed rounded-[2px] p-12 text-center cursor-pointer
|
||||
transition-all duration-200 outline-none switch-focus-ring
|
||||
${
|
||||
isDragging
|
||||
? "border-text-selected bg-switch-selected-bg shadow-[inset_0_0_20px_rgba(0,0,0,0.2)]"
|
||||
: "border-switch-line-sep bg-switch-selected-bg/50 hover:border-text-selected hover:bg-switch-selected-bg"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".nro"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="space-y-4 pointer-events-none">
|
||||
<div className="flex items-center justify-center mb-4 text-switch-text-selected">
|
||||
<FileDown size={48} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-switch-text text-xl font-normal">
|
||||
{isDragging ? "Drop file now" : "Select NRO File"}
|
||||
</h3>
|
||||
<p className="text-switch-text-info">Drag & drop or click to browse</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/FileStatusDisplay.tsx
Normal file
63
src/components/FileStatusDisplay.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import type { AnalysisResult } from "../types";
|
||||
import { Card } from "./ui/Card";
|
||||
import { formatMessage } from "../utils/localization";
|
||||
|
||||
interface FileStatusDisplayProps {
|
||||
analysis: AnalysisResult | null;
|
||||
}
|
||||
|
||||
export function FileStatusDisplay({ analysis }: FileStatusDisplayProps) {
|
||||
if (!analysis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (analysis.state) {
|
||||
case "invalid":
|
||||
return "text-switch-error";
|
||||
case "new-abi":
|
||||
return "text-switch-highlight-1";
|
||||
case "patched":
|
||||
return "text-switch-text-selected";
|
||||
case "needs-patching":
|
||||
return "text-switch-text";
|
||||
default:
|
||||
return "text-switch-text-info";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTitle = () => {
|
||||
switch (analysis.state) {
|
||||
case "invalid":
|
||||
return "Invalid File";
|
||||
case "new-abi":
|
||||
return "No Patching Required";
|
||||
case "patched":
|
||||
return "Already Patched";
|
||||
case "needs-patching":
|
||||
return "Ready to Patch";
|
||||
default:
|
||||
return "Unknown Status";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<div className="p-6 flex flex-col gap-2">
|
||||
<h3 className={`text-xl font-normal ${getStatusColor()}`}>
|
||||
{getStatusTitle()}
|
||||
</h3>
|
||||
<div className="h-px bg-switch-line-sep w-full my-2"></div>
|
||||
<p className="text-switch-text-info text-sm">
|
||||
File:{" "}
|
||||
<span className="text-switch-text font-mono ml-2">
|
||||
{analysis.fileName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-switch-text">
|
||||
{formatMessage(analysis.messageKey, analysis.messageParams)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
14
src/components/Header.tsx
Normal file
14
src/components/Header.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Drill } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="h-16 flex items-center mx-4 px-6 border-b border-switch-text bg-switch-bg shrink-0 z-20 relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<Drill size={32} />
|
||||
<h1 className="text-xl text-switch-text font-normal tracking-wide">
|
||||
libnx ABI Patcher
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
54
src/components/LogViewer.tsx
Normal file
54
src/components/LogViewer.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Terminal } from "lucide-react";
|
||||
import { Card } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: string[];
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function LogViewer({ logs, defaultExpanded = false }: LogViewerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300 overflow-hidden">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer bg-switch-popup hover:bg-switch-hover-bg transition-colors border-b border-switch-line-sep"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-switch-text">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span className="font-medium">Patch Log</span>
|
||||
<span className="text-xs text-switch-text-info px-2 py-0.5 rounded-full bg-switch-selected-bg">
|
||||
{logs.length} entries
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-4 bg-black/20 max-h-60 overflow-y-auto font-mono text-xs text-switch-text-info space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="break-all">
|
||||
<span className="text-switch-text-selected opacity-50 mr-2">
|
||||
{(index + 1).toString().padStart(2, "0")}
|
||||
</span>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
src/components/Sidebar.tsx
Normal file
45
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||
const tabs = [
|
||||
{ id: "patcher", label: "Patcher" },
|
||||
{ id: "about", label: "About" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="bg-switch-sidebar sidebar-shadow w-full md:w-80 flex flex-col border-r border-transparent py-12 pl-12 shrink-0">
|
||||
<nav className="flex-1 pr-5">
|
||||
<ul className="space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<li key={tab.id}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`
|
||||
w-full justify-start text-left text-xl pl-4 py-3 my-0
|
||||
rounded-none transition-none switch-focus-ring relative
|
||||
${
|
||||
activeTab === tab.id
|
||||
? "text-switch-text-selected bg-switch-sidebar-focus"
|
||||
: "text-switch-text hover:bg-switch-sidebar-focus/50"
|
||||
}
|
||||
`}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute left-1.5 top-1/2 -translate-y-1/2 w-[3px] h-10 bg-switch-text-selected" />
|
||||
)}
|
||||
<span className="font-normal">{tab.label}</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
101
src/components/ThemeSelector.tsx
Normal file
101
src/components/ThemeSelector.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const ThemeOption = ({
|
||||
value,
|
||||
label,
|
||||
previewColor,
|
||||
theme,
|
||||
setTheme,
|
||||
isLast,
|
||||
}: {
|
||||
value: Theme;
|
||||
label: string;
|
||||
previewColor: string;
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
isLast?: boolean;
|
||||
}) => {
|
||||
const isSelected = theme === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => setTheme(value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setTheme(value);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
group relative flex items-center justify-between py-4
|
||||
cursor-pointer outline-none transition-all duration-200
|
||||
border-t border-switch-line-sep ${isLast ? "border-b" : ""}
|
||||
switch-focus-ring hover:bg-switch-selected-bg/10
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Preview Box */}
|
||||
<div
|
||||
className="w-16 h-10 border border-switch-line-sep shadow-sm"
|
||||
style={{ backgroundColor: previewColor }}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-xl font-normal text-switch-text">{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Radio Indicator */}
|
||||
<div
|
||||
className={`
|
||||
w-6 h-6 rounded-full flex items-center justify-center
|
||||
${
|
||||
isSelected
|
||||
? "bg-switch-text-selected"
|
||||
: "border-2 border-switch-text-info"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="w-2.5 h-2.5 bg-switch-bg rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function ThemeSelector() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem("theme") as Theme | null;
|
||||
return saved || "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0" role="radiogroup" aria-label="Theme Selection">
|
||||
<ThemeOption
|
||||
value="light"
|
||||
label="Basic White"
|
||||
previewColor="#ebebeb"
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
<ThemeOption
|
||||
value="dark"
|
||||
label="Basic Black"
|
||||
previewColor="#2d2d2d"
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/ui/Button.tsx
Normal file
84
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost" | "outline";
|
||||
size?: "sm" | "md" | "lg" | "icon";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className = "",
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
isLoading,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Removed font-bold, added font-normal
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center font-normal transition-all duration-150 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none rounded-[2px]";
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-switch-text-selected text-switch-bg hover:brightness-110 active:scale-95 switch-focus-ring",
|
||||
secondary:
|
||||
"bg-switch-selected-bg text-switch-text border border-switch-line-sep hover:border-switch-highlight-1 active:scale-95 switch-focus-ring",
|
||||
ghost: "bg-transparent text-switch-text hover:text-switch-text-selected",
|
||||
outline:
|
||||
"bg-transparent border border-switch-line-sep text-switch-text hover:border-switch-highlight-1",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-8 py-3 text-lg",
|
||||
icon: "h-10 w-10",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`
|
||||
${baseStyles}
|
||||
${variants[variant]}
|
||||
${sizes[size]}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
45
src/components/ui/Card.tsx
Normal file
45
src/components/ui/Card.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`bg-switch-popup rounded-[2px] ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-6 border-b border-switch-line-sep ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-lg font-normal text-switch-text leading-none tracking-tight ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`px-6 py-4 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
22
src/components/ui/SectionHeader.tsx
Normal file
22
src/components/ui/SectionHeader.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
children,
|
||||
className = "",
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<h3
|
||||
className={
|
||||
"text-lg text-switch-text font-normal leading-none mb-4 px-2 border-l-4 border-switch-line-sep " +
|
||||
className
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/Switch.tsx
Normal file
43
src/components/ui/Switch.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SwitchProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className = "", onCheckedChange, onChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheckedChange?.(e.target.checked);
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`relative inline-flex items-center cursor-pointer switch-focus-ring rounded-[2px] ${className}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="sr-only peer"
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className="
|
||||
w-11 h-6
|
||||
bg-switch-line-sep
|
||||
peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-switch-highlight-2
|
||||
rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5
|
||||
after:transition-all
|
||||
peer-checked:bg-switch-text-selected
|
||||
"
|
||||
></div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
142
src/index.css
Normal file
142
src/index.css
Normal file
@ -0,0 +1,142 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "@fontsource/open-sans/400.css";
|
||||
@import "@fontsource/open-sans/600.css";
|
||||
@import "@fontsource/open-sans/700.css";
|
||||
|
||||
@theme {
|
||||
--color-switch-bg: var(--switch-bg);
|
||||
--color-switch-popup: var(--switch-popup);
|
||||
--color-switch-error: var(--switch-error);
|
||||
--color-switch-focus: var(--switch-focus);
|
||||
--color-switch-text: var(--switch-text);
|
||||
--color-switch-text-info: var(--switch-text-info);
|
||||
--color-switch-text-selected: var(--switch-text-selected);
|
||||
--color-switch-selected-bg: var(--switch-selected-bg);
|
||||
--color-switch-sidebar: var(--switch-sidebar);
|
||||
--color-switch-line: var(--switch-line);
|
||||
--color-switch-line-sep: var(--switch-line-sep);
|
||||
--color-switch-highlight-1: var(--switch-highlight-1);
|
||||
--color-switch-highlight-2: var(--switch-highlight-2);
|
||||
--color-switch-hover-bg: var(--switch-hover-bg);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: "Open Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark Theme (default) */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--switch-bg: #2d2d2d;
|
||||
--switch-popup: #3a3b3c;
|
||||
--switch-error: #fa5a3a;
|
||||
--switch-focus: rgba(0, 0, 0, 0.7);
|
||||
--switch-text: #ffffff;
|
||||
--switch-text-info: #a0a0a0;
|
||||
--switch-text-selected: #00ffc8;
|
||||
--switch-selected-bg: #202227;
|
||||
--switch-sidebar: #323232;
|
||||
--switch-sidebar-focus: #212227;
|
||||
--switch-line: #ffffff;
|
||||
--switch-line-sep: #707070;
|
||||
--switch-highlight-1: #1989c6;
|
||||
--switch-highlight-2: #89f0f2;
|
||||
--switch-hover-bg: #00ffc810;
|
||||
|
||||
--switch-scrollbar: #00ffc8;
|
||||
--switch-scrollbar-bg: transparent;
|
||||
--switch-progressbar: #00ffc8;
|
||||
--switch-progressbar-bg: #464646;
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme="light"] {
|
||||
--switch-bg: #ebebeb;
|
||||
--switch-popup: #f0f0f0;
|
||||
--switch-error: #fa5a3a;
|
||||
--switch-focus: rgba(211, 211, 211, 0.63);
|
||||
--switch-text: #2b2b2b;
|
||||
--switch-text-info: #707070;
|
||||
--switch-text-selected: #3250f0;
|
||||
--switch-selected-bg: #fcfcfc;
|
||||
--switch-sidebar: #f0f0f0;
|
||||
--switch-sidebar-focus: #fdfdfd;
|
||||
--switch-line: #2b2b2b;
|
||||
--switch-line-sep: #6d787a;
|
||||
--switch-highlight-1: #1989c6;
|
||||
--switch-highlight-2: #4ff4d7;
|
||||
--switch-hover-bg: #3250f010;
|
||||
|
||||
--switch-scrollbar: #b0b0b0;
|
||||
--switch-scrollbar-bg: transparent;
|
||||
--switch-progressbar: #3250f0;
|
||||
--switch-progressbar-bg: #808080;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-switch-selected-bg);
|
||||
color: var(--switch-text);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
color 0.3s;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--switch-scrollbar-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--switch-scrollbar);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes pulse-focus {
|
||||
0% {
|
||||
outline-color: var(--switch-highlight-1);
|
||||
}
|
||||
50% {
|
||||
outline-color: var(--switch-highlight-2);
|
||||
}
|
||||
100% {
|
||||
outline-color: var(--switch-highlight-1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-shadow {
|
||||
box-shadow:
|
||||
0 50px 50px -50px var(--color-switch-bg) inset,
|
||||
0 -50px 50px -50px var(--color-switch-bg) inset;
|
||||
}
|
||||
|
||||
.switch-focus-ring:focus,
|
||||
.switch-focus-within:focus-within {
|
||||
outline: 3px solid var(--switch-highlight-1);
|
||||
outline-offset: 0px;
|
||||
animation: pulse-focus 850ms infinite;
|
||||
}
|
||||
|
||||
.tab-border-selected::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
width: 3px;
|
||||
background-color: var(--switch-text-selected);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--switch-text-selected);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
174
src/pages/AboutPage.tsx
Normal file
174
src/pages/AboutPage.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
|
||||
const GITHUB_URL = "https://github.com/alula/hbpatcher";
|
||||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<div className="w-full animate-in fade-in duration-300">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<h3 className="text-base font-normal">Disclaimer</h3>
|
||||
<p className="text-switch-text-info text-sm font-bold">
|
||||
This tool may or may not work correctly. It is not the
|
||||
responsibility of the author of this tool or the author of the
|
||||
homebrew. Users should obtain the latest version of homebrew
|
||||
recompiled against the latest libnx if possible over using this
|
||||
tool. This tool is only an assist for unmaintained homebrew to
|
||||
reduce the impact of kernel changes.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<h3 className="text-base font-normal">Instructions</h3>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-switch-text-info text-sm ml-2">
|
||||
<li>
|
||||
Drag and drop your homebrew{" "}
|
||||
<span className="font-mono text-switch-text">.nro</span> file
|
||||
into the patcher tab.
|
||||
</li>
|
||||
<li>
|
||||
The tool will analyze the file to see if it uses the old ABI.
|
||||
</li>
|
||||
<li>If patching is needed, click the "Patch File" button.</li>
|
||||
<li>The patched file will be downloaded automatically.</li>
|
||||
<li>Copy the patched file to your Switch SD card.</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<h3 className="text-base font-normal">For Developers</h3>
|
||||
<div className="text-red-400 text-base font-bold">
|
||||
Do not rely on this tool. Please recompile your homebrew with{" "}
|
||||
<code>libnx v4.10.0</code> (or newer).
|
||||
</div>
|
||||
<div className="text-switch-text-info text-base leading-relaxed">
|
||||
<p>
|
||||
The updated library resolves the memory conflict and stays
|
||||
backward compatible with older Atmosphère versions and
|
||||
firmwares.
|
||||
</p>
|
||||
<p>
|
||||
You can find the source code for this tool on{" "}
|
||||
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-bold text-switch-text">TL;DR</h2>
|
||||
<p className="text-switch-text-info text-sm leading-relaxed">
|
||||
Atmosphère 1.10.0+ integrates kernel optimizations from Firmware
|
||||
21.0.0. This improves performance but breaks older homebrew,
|
||||
causing crashes or hangs on exit. If your homebrew is
|
||||
unmaintained, use this tool to patch the <code>.nro</code> files.
|
||||
If the app is still active, ask the developer to update it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-bold text-switch-text">
|
||||
How this tool works
|
||||
</h3>
|
||||
<p className="text-switch-text-info text-sm leading-relaxed">
|
||||
This tool scans your <code>.nro</code> files to see if they rely
|
||||
on the old, now-unsafe memory layout. If it detects a conflict, it
|
||||
modifies the binary to{" "}
|
||||
<a
|
||||
href="https://github.com/switchbrew/libnx/commit/cad06c006e4e0caf9c63755ac6c2a10a52333e27"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
move its Thread Local Storage (TLS)
|
||||
</a>{" "}
|
||||
from offset <code>0x108</code> to the safe offset{" "}
|
||||
<code>0x180</code>. Effectively, it mimics the result of
|
||||
recompiling the app with the latest libraries, restoring stability
|
||||
without needing the source code.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-bold text-switch-text">
|
||||
Why isn't this built into the Homebrew Loader?
|
||||
</h3>
|
||||
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
|
||||
<p>
|
||||
It might seem logical for the Homebrew Loader (<code>hbl</code>)
|
||||
to handle this automatically, but that's out of scope for a
|
||||
simple program loader. HBL’s job is to execute code as-is, not
|
||||
to dynamically repair binaries.
|
||||
</p>
|
||||
<p>
|
||||
Trying to patch memory offsets on the fly is invasive and
|
||||
fragile; if HBL guesses wrong, it could corrupt the entire
|
||||
homebrew environment. Furthermore, baking a patcher into the
|
||||
loader sets a bad precedent. The maintainers want to encourage
|
||||
proper fixes (recompilation) rather than relying on permanent
|
||||
"dirty hacks" within the OS itself.{" "}
|
||||
<a
|
||||
href="https://discord.com/channels/269333940928512010/414949821003202562/1440485257550889221"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source: Switchbrew
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-bold text-switch-text">
|
||||
Why the breakage happened
|
||||
</h3>
|
||||
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
|
||||
<p>
|
||||
This isn't a bug; it's a conflict over real estate. In Firmware
|
||||
21.0.0, Nintendo optimized the kernel to write CPU timing data
|
||||
to a specific memory address (<code>usertls + 0x108</code>) on
|
||||
every thread switch.
|
||||
</p>
|
||||
<p>
|
||||
Previous versions of <code>libnx</code> (the homebrew library)
|
||||
assumed this space was unused and stored data there. Official
|
||||
software always respected the reservation, so it wasn't
|
||||
affected. Now that the kernel—and by extension,
|
||||
Atmosphère—actively uses that space, old homebrew gets its
|
||||
memory overwritten, leading to immediate crashes.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-bold text-switch-text">
|
||||
Why Atmosphère didn't add a workaround
|
||||
</h3>
|
||||
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
|
||||
<p>
|
||||
Atmosphère aims to reimplement Nintendo's kernel logic 1:1. When
|
||||
Nintendo changes how the kernel works, Atmosphère must follow
|
||||
suit to ensure official games run correctly.
|
||||
</p>
|
||||
<p>
|
||||
While the developers have added quiet workarounds in the past
|
||||
(like the FW 19.0.0 debug flags), this specific issue was too
|
||||
risky to mask. A workaround here would require adding
|
||||
conditional logic to the kernel scheduler—the most time-critical
|
||||
part of the system. Adding checks for "broken homebrew" during
|
||||
every thread switch would add complexity, potentially degrade
|
||||
performance system-wide, and could even introduce bugs in
|
||||
official games.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/pages/PatcherPage.tsx
Normal file
144
src/pages/PatcherPage.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { FileDropZone } from "../components/FileDropZone";
|
||||
import { FileStatusDisplay } from "../components/FileStatusDisplay";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Card } from "../components/ui/Card";
|
||||
import { LogViewer } from "../components/LogViewer";
|
||||
import { analyzeFile, patchFile } from "../patcher/NROPatcher";
|
||||
import type { AnalysisResult, PatchResult } from "../types";
|
||||
import { formatMessage } from "../utils/localization";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface PatcherPageProps {
|
||||
appendSuffix: boolean;
|
||||
}
|
||||
|
||||
export function PatcherPage({ appendSuffix }: PatcherPageProps) {
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
||||
const [patchResult, setPatchResult] = useState<PatchResult | null>(null);
|
||||
const [isPatching, setIsPatching] = useState(false);
|
||||
const [patchError, setPatchError] = useState<string | null>(null);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
const handleFileSelected = async (file: File) => {
|
||||
setCurrentFile(file);
|
||||
setPatchResult(null);
|
||||
setPatchError(null);
|
||||
setLog([]);
|
||||
|
||||
try {
|
||||
const result = await analyzeFile(file);
|
||||
setAnalysis(result);
|
||||
setLog(result.log);
|
||||
} catch {
|
||||
setAnalysis({
|
||||
state: "invalid",
|
||||
fileName: file.name,
|
||||
messageKey: "error_analysis_failed",
|
||||
log: [],
|
||||
} as AnalysisResult);
|
||||
setLog([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePatchClick = async () => {
|
||||
if (!currentFile) return;
|
||||
|
||||
setIsPatching(true);
|
||||
setPatchError(null);
|
||||
|
||||
try {
|
||||
const result = await patchFile(currentFile);
|
||||
setPatchResult(result);
|
||||
setLog(result.log);
|
||||
|
||||
if (result.success && result.patchedData) {
|
||||
// Create Blob from Uint8Array for download
|
||||
const blob = new Blob([result.patchedData as unknown as BlobPart], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
||||
let downloadName = currentFile.name;
|
||||
if (appendSuffix) {
|
||||
downloadName = downloadName.replace(/\.nro$/i, "_patched.nro");
|
||||
if (downloadName === currentFile.name) downloadName += "_patched.nro";
|
||||
}
|
||||
|
||||
a.download = downloadName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else if (!result.success) {
|
||||
setPatchError(
|
||||
formatMessage(result.errorKey || "error_unknown", result.errorParams),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setPatchError(formatMessage("error_unknown", { message: errorMessage }));
|
||||
setPatchResult({
|
||||
success: false,
|
||||
errorKey: "error_unknown",
|
||||
errorParams: { message: errorMessage },
|
||||
log: [],
|
||||
});
|
||||
} finally {
|
||||
setIsPatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full animate-in fade-in duration-300">
|
||||
<div className="space-y-6">
|
||||
<FileDropZone onFileSelected={handleFileSelected} />
|
||||
|
||||
{analysis && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<FileStatusDisplay analysis={analysis} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{patchError && (
|
||||
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="p-4 text-switch-error">Error: {patchError}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{analysis && analysis.state === "needs-patching" && currentFile && (
|
||||
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-300 delay-100">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handlePatchClick}
|
||||
isLoading={isPatching}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
Patch File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{patchResult && patchResult.success && (
|
||||
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="p-6 flex items-center gap-3">
|
||||
<Check className="w-6 h-6 text-switch-text-selected" />
|
||||
<div>
|
||||
<h3 className="text-switch-text-selected font-normal text-lg">
|
||||
Success!
|
||||
</h3>
|
||||
<p className="text-switch-text">File patched and downloaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<LogViewer logs={log} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/pages/SettingsPage.tsx
Normal file
57
src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { ThemeSelector } from "../components/ThemeSelector";
|
||||
import { SectionHeader } from "../components/ui/SectionHeader";
|
||||
import { Switch } from "../components/ui/Switch";
|
||||
|
||||
interface SettingsPageProps {
|
||||
appendSuffix: boolean;
|
||||
onAppendSuffixChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SettingsRow({ title, description, children }: SettingsRowProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="group relative flex items-center justify-between py-4 border-y border-switch-line-sep switch-focus-within hover:bg-switch-selected-bg/10 transition-colors">
|
||||
<h3 className="text-xl text-switch-text font-normal">{title}</h3>
|
||||
<div className="rounded-[2px] group-focus-within:outline-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-2 text-switch-text-info text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
appendSuffix,
|
||||
onAppendSuffixChange,
|
||||
}: SettingsPageProps) {
|
||||
return (
|
||||
<div className="w-full animate-in fade-in duration-300 px-2">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SectionHeader>Themes</SectionHeader>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
|
||||
<SectionHeader>Miscellaneous</SectionHeader>
|
||||
<SettingsRow
|
||||
title="Output Filename"
|
||||
description="Automatically append '_patched' to the filename of the patched NRO."
|
||||
>
|
||||
<Switch
|
||||
checked={appendSuffix}
|
||||
onCheckedChange={onAppendSuffixChange}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/patcher/BinaryReader.ts
Normal file
72
src/patcher/BinaryReader.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export class BinaryReader {
|
||||
private view: DataView;
|
||||
private offset: number = 0;
|
||||
|
||||
constructor(buffer: ArrayBuffer | Uint8Array) {
|
||||
if (buffer instanceof Uint8Array) {
|
||||
this.view = new DataView(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength,
|
||||
);
|
||||
} else {
|
||||
this.view = new DataView(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
seek(offset: number) {
|
||||
this.offset = offset;
|
||||
}
|
||||
|
||||
skip(count: number) {
|
||||
this.offset += count;
|
||||
}
|
||||
|
||||
tell(): number {
|
||||
return this.offset;
|
||||
}
|
||||
|
||||
readU8(): number {
|
||||
const val = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return val;
|
||||
}
|
||||
|
||||
readU16(): number {
|
||||
const val = this.view.getUint16(this.offset, true); // Little Endian
|
||||
this.offset += 2;
|
||||
return val;
|
||||
}
|
||||
|
||||
readU32(): number {
|
||||
const val = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return val;
|
||||
}
|
||||
|
||||
readU64(): bigint {
|
||||
const val = this.view.getBigUint64(this.offset, true);
|
||||
this.offset += 8;
|
||||
return val;
|
||||
}
|
||||
|
||||
readBytes(length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(
|
||||
this.view.buffer,
|
||||
this.view.byteOffset + this.offset,
|
||||
length,
|
||||
);
|
||||
this.offset += length;
|
||||
// Return a copy to avoid issues if the underlying buffer changes or if we want to modify it
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
readString(length: number): string {
|
||||
const bytes = this.readBytes(length);
|
||||
const decoder = new TextDecoder();
|
||||
// Remove null termination if present
|
||||
let end = 0;
|
||||
while (end < bytes.length && bytes[end] !== 0) end++;
|
||||
return decoder.decode(bytes.subarray(0, end));
|
||||
}
|
||||
}
|
||||
193
src/patcher/NRO.ts
Normal file
193
src/patcher/NRO.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { BinaryReader } from "./BinaryReader";
|
||||
|
||||
export interface NroSection {
|
||||
offset: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface NroHeader {
|
||||
magic: string;
|
||||
version: number;
|
||||
size: number;
|
||||
flags: number;
|
||||
text: NroSection;
|
||||
rodata: NroSection;
|
||||
data: NroSection;
|
||||
bssSize: number;
|
||||
moduleId: Uint8Array;
|
||||
}
|
||||
|
||||
export interface Mod0Header {
|
||||
magic: string;
|
||||
dynamicOffset: number;
|
||||
bssStartOffset: number;
|
||||
bssEndOffset: number;
|
||||
ehFrameHdrStartOffset: number;
|
||||
ehFrameHdrEndOffset: number;
|
||||
runtimeModuleObjectOffset: number;
|
||||
// Homebrew extensions
|
||||
lny0Offset?: number; // Offset to LNY0 magic relative to MOD0 start
|
||||
lny1Offset?: number; // Offset to LNY1 magic relative to MOD0 start
|
||||
lny2Offset?: number; // Offset to LNY2 magic relative to MOD0 start
|
||||
lny2Version?: number; // Version of LNY2
|
||||
hbpAOffset?: number; // Offset to hbpA magic relative to MOD0 start
|
||||
}
|
||||
|
||||
export class NRO {
|
||||
public header: NroHeader;
|
||||
public mod0?: Mod0Header;
|
||||
public mod0Offset: number = 0;
|
||||
private buffer: Uint8Array;
|
||||
|
||||
constructor(buffer: Uint8Array) {
|
||||
this.buffer = buffer;
|
||||
this.header = this.parseHeader();
|
||||
this.parseMod0();
|
||||
}
|
||||
|
||||
private parseHeader(): NroHeader {
|
||||
const reader = new BinaryReader(this.buffer);
|
||||
|
||||
// Check magic at 0x10
|
||||
reader.seek(0x10);
|
||||
const magic = reader.readString(4);
|
||||
if (magic !== "NRO0") {
|
||||
throw new Error("Invalid NRO magic");
|
||||
}
|
||||
|
||||
const version = reader.readU32();
|
||||
const size = reader.readU32();
|
||||
const flags = reader.readU32();
|
||||
|
||||
const text = {
|
||||
offset: reader.readU32(),
|
||||
size: reader.readU32(),
|
||||
};
|
||||
|
||||
const rodata = {
|
||||
offset: reader.readU32(),
|
||||
size: reader.readU32(),
|
||||
};
|
||||
|
||||
const data = {
|
||||
offset: reader.readU32(),
|
||||
size: reader.readU32(),
|
||||
};
|
||||
|
||||
const bssSize = reader.readU32();
|
||||
/* const reserved =*/ reader.readU32();
|
||||
|
||||
const moduleId = reader.readBytes(0x20);
|
||||
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
size,
|
||||
flags,
|
||||
text,
|
||||
rodata,
|
||||
data,
|
||||
bssSize,
|
||||
moduleId,
|
||||
};
|
||||
}
|
||||
|
||||
private parseMod0() {
|
||||
// MOD0 is usually at the start of .text + 4 (after the branch instruction)
|
||||
// But we should scan for it in the first few bytes of .text just in case
|
||||
const textStart = this.header.text.offset;
|
||||
const reader = new BinaryReader(this.buffer);
|
||||
|
||||
// Look for MOD0 magic in the first 0x20 bytes of text section
|
||||
// Standard crt0 has it at offset 4 from start of text
|
||||
let mod0Found = false;
|
||||
let mod0Offset = 0;
|
||||
|
||||
for (let i = 0; i < 0x20; i += 4) {
|
||||
reader.seek(textStart + i);
|
||||
const magic = reader.readString(4);
|
||||
if (magic === "MOD0") {
|
||||
mod0Found = true;
|
||||
mod0Offset = textStart + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mod0Found) return;
|
||||
|
||||
this.mod0Offset = mod0Offset;
|
||||
reader.seek(mod0Offset + 4); // Skip magic
|
||||
|
||||
const dynamicOffset = reader.readU32();
|
||||
const bssStartOffset = reader.readU32();
|
||||
const bssEndOffset = reader.readU32();
|
||||
const ehFrameHdrStartOffset = reader.readU32();
|
||||
const ehFrameHdrEndOffset = reader.readU32();
|
||||
const runtimeModuleObjectOffset = reader.readU32();
|
||||
|
||||
const mod0: Mod0Header = {
|
||||
magic: "MOD0",
|
||||
dynamicOffset,
|
||||
bssStartOffset,
|
||||
bssEndOffset,
|
||||
ehFrameHdrStartOffset,
|
||||
ehFrameHdrEndOffset,
|
||||
runtimeModuleObjectOffset,
|
||||
};
|
||||
|
||||
// Check for LNY0
|
||||
const lny0Magic = reader.readString(4);
|
||||
if (lny0Magic === "LNY0") {
|
||||
mod0.lny0Offset = reader.tell() - 4 - mod0Offset;
|
||||
reader.skip(8); // got_start, got_end
|
||||
|
||||
// Check for LNY1
|
||||
const lny1Magic = reader.readString(4);
|
||||
if (lny1Magic === "LNY1") {
|
||||
mod0.lny1Offset = reader.tell() - 4 - mod0Offset;
|
||||
reader.skip(8); // relro_start, data_start
|
||||
|
||||
// Check for LNY2
|
||||
const lny2Magic = reader.readString(4);
|
||||
if (lny2Magic === "LNY2") {
|
||||
mod0.lny2Offset = reader.tell() - 4 - mod0Offset;
|
||||
mod0.lny2Version = reader.readU32();
|
||||
reader.skip(4); // Reserved
|
||||
|
||||
// Check for hbpA (Homebrew Patcher Applied)
|
||||
const hbpAMagic = reader.readString(4);
|
||||
if (hbpAMagic === "hbpA") {
|
||||
mod0.hbpAOffset = reader.tell() - 4 - mod0Offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.mod0 = mod0;
|
||||
}
|
||||
|
||||
get text(): Uint8Array {
|
||||
return this.buffer.subarray(
|
||||
this.header.text.offset,
|
||||
this.header.text.offset + this.header.text.size,
|
||||
);
|
||||
}
|
||||
|
||||
get rodata(): Uint8Array {
|
||||
return this.buffer.subarray(
|
||||
this.header.rodata.offset,
|
||||
this.header.rodata.offset + this.header.rodata.size,
|
||||
);
|
||||
}
|
||||
|
||||
get data(): Uint8Array {
|
||||
return this.buffer.subarray(
|
||||
this.header.data.offset,
|
||||
this.header.data.offset + this.header.data.size,
|
||||
);
|
||||
}
|
||||
|
||||
get fullBuffer(): Uint8Array {
|
||||
return this.buffer;
|
||||
}
|
||||
}
|
||||
120
src/patcher/NROPatcher.ts
Normal file
120
src/patcher/NROPatcher.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { NRO } from "./NRO";
|
||||
import type { AnalysisResult, PatchResult } from "../types";
|
||||
import { PatchContext, PATCHES } from "./patches";
|
||||
|
||||
/**
|
||||
* Analyzes an NRO file to determine its state.
|
||||
*/
|
||||
export async function analyzeFile(file: File): Promise<AnalysisResult> {
|
||||
let nro: NRO;
|
||||
|
||||
try {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
nro = new NRO(buffer);
|
||||
} catch (error) {
|
||||
console.error("Error parsing NRO:", error);
|
||||
return {
|
||||
state: "invalid",
|
||||
fileName: file.name,
|
||||
messageKey: "status_invalid_nro",
|
||||
log: [],
|
||||
};
|
||||
}
|
||||
const context = new PatchContext(nro);
|
||||
|
||||
// Check for any pattern that needs patching
|
||||
let needsPatching = false;
|
||||
for (const patch of PATCHES) {
|
||||
context._onPatchStart(patch);
|
||||
if (patch.check(context)) {
|
||||
needsPatching = true;
|
||||
}
|
||||
context._onPatchEnd();
|
||||
}
|
||||
|
||||
if (needsPatching) {
|
||||
return {
|
||||
state: "needs-patching",
|
||||
fileName: file.name,
|
||||
messageKey: "status_needs_patching",
|
||||
log: context.errorLog,
|
||||
};
|
||||
}
|
||||
|
||||
// If we are here, no patterns matched, meaning it's compatible.
|
||||
// Now we check if it was patched by THIS tool (hbpA marker) or if it's just compatible (recompiled).
|
||||
|
||||
if (nro.mod0 && nro.mod0.hbpAOffset) {
|
||||
return {
|
||||
state: "patched",
|
||||
fileName: file.name,
|
||||
messageKey: "status_already_patched",
|
||||
log: context.errorLog,
|
||||
};
|
||||
}
|
||||
|
||||
if (!nro.mod0?.lny2Offset) {
|
||||
return {
|
||||
state: "new-abi",
|
||||
fileName: file.name,
|
||||
messageKey: "status_no_pattern",
|
||||
log: context.errorLog,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "new-abi",
|
||||
fileName: file.name,
|
||||
messageKey: "status_new_abi",
|
||||
log: context.errorLog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches an NRO file to use the new ABI.
|
||||
*/
|
||||
export async function patchFile(file: File): Promise<PatchResult> {
|
||||
let log: string[] = [];
|
||||
try {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const nro = new NRO(buffer);
|
||||
const context = new PatchContext(nro);
|
||||
log = context.errorLog;
|
||||
|
||||
for (const patch of PATCHES) {
|
||||
context._onPatchStart(patch);
|
||||
if (patch.check(context)) {
|
||||
const success = patch.apply(context);
|
||||
if (success) {
|
||||
context.appliedPatches.add(patch.id);
|
||||
}
|
||||
}
|
||||
context._onPatchEnd();
|
||||
}
|
||||
|
||||
if (context.appliedPatches.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
errorKey: "error_pattern_not_found",
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
// Return the modified buffer directly
|
||||
return {
|
||||
success: true,
|
||||
patchedData: buffer,
|
||||
log,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error patching file:", error);
|
||||
return {
|
||||
success: false,
|
||||
errorKey: "error_unknown",
|
||||
errorParams: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
log,
|
||||
};
|
||||
}
|
||||
}
|
||||
286
src/patcher/patches.ts
Normal file
286
src/patcher/patches.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import { NRO } from "./NRO";
|
||||
import { nhex, PatternMatcher, type Pattern } from "./utils";
|
||||
|
||||
export class PatchContext {
|
||||
appliedPatches = new Set<string>();
|
||||
nro: NRO;
|
||||
errorLog: string[] = [];
|
||||
_currentPatch: Patch | null = null;
|
||||
|
||||
constructor(nro: NRO) {
|
||||
this.nro = nro;
|
||||
}
|
||||
|
||||
// track current patch
|
||||
_onPatchStart(patch: Patch) {
|
||||
this._currentPatch = patch;
|
||||
}
|
||||
|
||||
_onPatchEnd() {
|
||||
this._currentPatch = null;
|
||||
}
|
||||
|
||||
log(message: string) {
|
||||
if (this._currentPatch) {
|
||||
this.errorLog.push(`${this._currentPatch.id}: ${message}`);
|
||||
} else {
|
||||
this.errorLog.push(`${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Patch {
|
||||
abstract readonly id: string;
|
||||
|
||||
abstract check(context: PatchContext): boolean;
|
||||
|
||||
// When this is called, the patch can safely assume that check() was called and returned true.
|
||||
abstract apply(context: PatchContext): boolean;
|
||||
}
|
||||
|
||||
export class LegacyAbiPatch extends Patch {
|
||||
readonly id = "pattern_legacy_abi";
|
||||
|
||||
private readonly threadTlsGetOrig = PatternMatcher.parse(
|
||||
"61 D0 3B D5 " + // mrs x1, TPIDRRO_EL0;
|
||||
"20 CC 20 8B " + // add x0, x1, w0,sxtw#3;
|
||||
"00 84 40 F9 " + // ldr x0, [x0,#0x108];
|
||||
"C0 03 5F D6", // ret
|
||||
);
|
||||
|
||||
private readonly threadTlsGetPatch = PatternMatcher.parse(
|
||||
"61 D0 3B D5 " + // mrs x1, TPIDRRO_EL0;
|
||||
"20 CC 20 8B " + // add x0, x1, w0,sxtw#3;
|
||||
"00 C0 40 F9 " + // ldr x0, [x0,#0x180];
|
||||
"C0 03 5F D6", // ret
|
||||
);
|
||||
|
||||
private readonly threadTlsSetOrig = PatternMatcher.parse(
|
||||
"62 D0 3B D5 " + // mrs x2, TPIDRRO_EL0;
|
||||
"40 CC 20 8B " + // add x0, x2, w0,sxtw#3;
|
||||
"01 84 00 F9 " + // str x1, [x0,#0x108];
|
||||
"C0 03 5F D6", // ret
|
||||
);
|
||||
|
||||
private readonly threadTlsSetPatch = PatternMatcher.parse(
|
||||
"62 D0 3B D5 " + // mrs x2, TPIDRRO_EL0;
|
||||
"40 CC 20 8B " + // add x0, x2, w0,sxtw#3;
|
||||
"01 C0 00 F9 " + // str x1, [x0,#0x180];
|
||||
"C0 03 5F D6", // ret
|
||||
);
|
||||
|
||||
// all of this needs somewhat complex disassembly to patch reliably
|
||||
// though those stupid patches should work in most cases
|
||||
private readonly threadEntryOrigGCC15O2 = PatternMatcher.parse(
|
||||
"01 0C 40 F9 " + // ldr x1, [x0, #24]
|
||||
"A1 FA 00 F9 " + // str x1, [x21, #496]
|
||||
"01 00 00 90 " + // adrp x1, 0
|
||||
"A3 F6 00 F9 " + // str x3, [x21, #488]
|
||||
"B5 22 04 91 " + // add x21, x21, #0x108
|
||||
"21 00 40 F9 ", // ldr x1, [x1]
|
||||
);
|
||||
private readonly threadEntryPatchGCC15O2 = PatternMatcher.parse(
|
||||
"01 0C 40 F9 " + // ldr x1, [x0, #24]
|
||||
"A1 FA 00 F9 " + // str x1, [x21, #496]
|
||||
"01 00 00 90 " + // adrp x1, 0
|
||||
"A3 F6 00 F9 " + // str x3, [x21, #488]
|
||||
"B5 02 06 91 " + // add x21, x21, #0x180
|
||||
"21 00 40 F9 ", // ldr x1, [x1]
|
||||
);
|
||||
private readonly threadEntryOrigGCC14O2 = PatternMatcher.parse(
|
||||
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
|
||||
"64 8A 41 A9 " + // ldp x4, x2, [x19, #24]
|
||||
"A1 82 07 91 " + // add x1, x21, #0x1e0
|
||||
"A5 E6 01 B9 " + // str w5, [x21, #484]
|
||||
"B5 22 04 91", // add x21, x21, #0x108
|
||||
);
|
||||
private readonly threadEntryPatchGCC14O2 = PatternMatcher.parse(
|
||||
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
|
||||
"64 8A 41 A9 " + // ldp x4, x2, [x19, #24]
|
||||
"A1 82 07 91 " + // add x1, x21, #0x1e0
|
||||
"A5 E6 01 B9 " + // str w5, [x21, #484]
|
||||
"B5 02 06 91", // add x21, x21, #0x180
|
||||
);
|
||||
// this pattern seems consistent back to GCC 9?
|
||||
private readonly threadEntryOrigGCC13 = PatternMatcher.parse(
|
||||
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
|
||||
"A2 E2 01 B9 " + // str w2, [x21, #480]
|
||||
"A5 E6 01 B9 " + // str w5, [x21, #484]
|
||||
"A3 92 1E A9 " + // stp x3, x4, [x21, #488]
|
||||
"B5 22 04 91 ", // add x21, x21, #0x108
|
||||
);
|
||||
private readonly threadEntryPatchGCC13 = PatternMatcher.parse(
|
||||
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
|
||||
"A2 E2 01 B9 " + // str w2, [x21, #480]
|
||||
"A5 E6 01 B9 " + // str w5, [x21, #484]
|
||||
"A3 92 1E A9 " + // stp x3, x4, [x21, #488]
|
||||
"B5 02 06 91", // add x21, x21, #0x180
|
||||
);
|
||||
|
||||
// this seems consistent at -O1 level and below
|
||||
private readonly threadEntryOrigGCC15O1 = PatternMatcher.parse(
|
||||
"60 02 40 F9 " + // ldr x0, [x19]
|
||||
"B5 22 04 91 " + // add x21, x21, #0x108
|
||||
"15 10 00 F9 " + // str x21, [x0, #32]
|
||||
"60 02 40 F9 " + // ldr x0, [x19]
|
||||
"81 22 00 91 " + // add x1, x20, #0x8
|
||||
"01 18 00 F9 " + // str x1, [x0, #48]
|
||||
"61 02 40 F9 " + // ldr x1, [x19]
|
||||
"80 06 40 F9 " + // ldr x0, [x20, #8]
|
||||
"20 14 00 F9 ", // str x0, [x1, #40]
|
||||
);
|
||||
private readonly threadEntryPatchGCC15O1 = PatternMatcher.parse(
|
||||
"60 02 40 F9 " + // ldr x0, [x19]
|
||||
"B5 02 06 91 " + // add x21, x21, #0x180
|
||||
"15 10 00 F9 " + // str x21, [x0, #32]
|
||||
"60 02 40 F9 " + // ldr x0, [x19]
|
||||
"81 22 00 91 " + // add x1, x20, #0x8
|
||||
"01 18 00 F9 " + // str x1, [x0, #48]
|
||||
"61 02 40 F9 " + // ldr x1, [x19]
|
||||
"80 06 40 F9 " + // ldr x0, [x20, #8]
|
||||
"20 14 00 F9", // str x0, [x1, #40]
|
||||
);
|
||||
private readonly threadEntryOrigLibNx1 = PatternMatcher.parse(
|
||||
"84 E2 01 B9 " + // str w4, [x20, #0x1e0]
|
||||
"64 0E 40 F9 " + // ldr x4, [x19, #0x18]
|
||||
"83 F6 00 F9 " + // str x3, [x20, #0x1e8]
|
||||
"63 00 40 B9 " + // ldr w3, [x3]
|
||||
"42 40 00 D1 " + // sub x2, x2, #0x10
|
||||
"84 0A 1F A9 " + // stp x4, x2, [x20, #0x1f0]
|
||||
"94 22 04 91 " + // add x20, x20, #0x108
|
||||
"83 DE 00 B9", // str w3, [x20, #0xdc]
|
||||
);
|
||||
|
||||
private readonly threadEntryPatchLibNx1 = PatternMatcher.parse(
|
||||
"84 E2 01 B9 " + // str w4, [x20, #0x1e0]
|
||||
"64 0E 40 F9 " + // ldr x4, [x19, #0x18]
|
||||
"83 F6 00 F9 " + // str x3, [x20, #0x1e8]
|
||||
"63 00 40 B9 " + // ldr w3, [x3]
|
||||
"42 40 00 D1 " + // sub x2, x2, #0x10
|
||||
"84 0A 1F A9 " + // stp x4, x2, [x20, #0x1f0]
|
||||
"94 02 06 91 " + // add x20, x20, #0x180
|
||||
"83 DE 00 B9", // str w3, [x20, #0xdc]
|
||||
);
|
||||
|
||||
private readonly threadEntryOrigLibNx2 = PatternMatcher.parse(
|
||||
"02 02 80 d2 " + // movz x2, #0x10
|
||||
"f3 53 01 a9 " + // stp x19, x20, [sp, #0x10]
|
||||
"f3 03 00 aa " + // mov x19, x0
|
||||
"74 d0 3b d5 " + // mrs x20, tpidrro_el0
|
||||
"94 22 04 91 " + // add x20, x20, #0x108
|
||||
"03 00 40 f9 " + // ldr x3, [x0]
|
||||
"f5 13 00 f9 " + // str x21, [sp, #0x20]
|
||||
"81 da 00 b9 " + // str w1, [x20, #0xd8]
|
||||
"01 0c 40 f9 " + // ldr x1, [x0, #0x18]
|
||||
"83 72 00 f9 " + // str x3, [x20, #0xe0]
|
||||
"81 76 00 f9 " + // str x1, [x20, #0xe8]
|
||||
"?? ?? ?? ?? " +
|
||||
"?? ?? ?? ?? " +
|
||||
"21 00 40 f9 " + // ldr x1, [x1]
|
||||
"3f 40 00 f1 " + // cmp x1, #0x10
|
||||
"21 20 82 9a " + // csel x1, x1, x2, hs
|
||||
"02 10 40 f9 " + // ldr x2, [x0, #0x20]
|
||||
"?? ?? ?? ?? " +
|
||||
"?? ?? ?? ?? " +
|
||||
"41 00 01 cb " + // sub x1, x2, x1
|
||||
"81 7a 00 f9 " + // str x1, [x20, #0xf0]
|
||||
"61 00 40 b9 " + // ldr w1, [x3]
|
||||
"81 de 00 b9 ", // str w1, [x20, #0xdc]
|
||||
);
|
||||
private readonly threadEntryPatchLibNx2 = PatternMatcher.parse(
|
||||
"02 02 80 d2 " + //movz x2, #0x10
|
||||
"f3 53 01 a9 " + // stp x19, x20, [sp, #0x10]
|
||||
"f3 03 00 aa " + // mov x19, x0
|
||||
"74 d0 3b d5 " + // mrs x20, tpidrro_el0
|
||||
"94 02 06 91 " + // add x20, x20, #0x108+0x78
|
||||
"03 00 40 f9 " + // ldr x3, [x0]
|
||||
"f5 13 00 f9 " + // str x21, [sp, #0x20]
|
||||
"81 62 00 b9 " + // str w1, [x20, #0xd8-0x78]
|
||||
"01 0c 40 f9 " + // ldr x1, [x0, #0x18]
|
||||
"83 36 00 f9 " + // str x3, [x20, #0xe0-0x78]
|
||||
"81 3a 00 f9 " + // str x1, [x20, #0xe8-0x78]
|
||||
"?? ?? ?? ?? " +
|
||||
"?? ?? ?? ?? " +
|
||||
"21 00 40 f9 " + // ldr x1, [x1]
|
||||
"3f 40 00 f1 " + // cmp x1, #0x10
|
||||
"21 20 82 9a " + // csel x1, x1, x2, hs
|
||||
"02 10 40 f9 " + // ldr x2, [x0, #0x20]
|
||||
"?? ?? ?? ?? " +
|
||||
"?? ?? ?? ?? " +
|
||||
"41 00 01 cb " + // sub x1, x2, x1
|
||||
"81 3e 00 f9 " + // str x1, [x20, #0xf0-0x78]
|
||||
"61 00 40 b9 " + // ldr w1, [x3]
|
||||
"81 66 00 b9 ", // str w1, [x20, #0xdc-0x78]
|
||||
);
|
||||
|
||||
private readonly patchesOrig = [
|
||||
this.threadTlsGetOrig,
|
||||
this.threadTlsSetOrig,
|
||||
this.threadEntryOrigGCC15O2,
|
||||
this.threadEntryOrigGCC14O2,
|
||||
this.threadEntryOrigGCC13,
|
||||
this.threadEntryOrigGCC15O1,
|
||||
this.threadEntryOrigLibNx1,
|
||||
this.threadEntryOrigLibNx2,
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
private readonly patchesPatch: Array<{o: Pattern, p: Pattern, cmt: string}> = [
|
||||
{o: this.threadTlsGetOrig, p: this.threadTlsGetPatch, cmt: "threadTlsGet()"},
|
||||
{o: this.threadTlsSetOrig, p: this.threadTlsSetPatch, cmt: "threadTlsSet()"},
|
||||
{o: this.threadEntryOrigGCC15O2, p: this.threadEntryPatchGCC15O2, cmt: "threadEntry() GCC 15 -O2"},
|
||||
{o: this.threadEntryOrigGCC14O2, p: this.threadEntryPatchGCC14O2, cmt: "threadEntry() GCC 14 -O2"},
|
||||
{o: this.threadEntryOrigGCC13, p: this.threadEntryPatchGCC13, cmt: "threadEntry() GCC 13 and below -O2"},
|
||||
{o: this.threadEntryOrigGCC15O1, p: this.threadEntryPatchGCC15O1, cmt: "threadEntry() GCC 15 and below -O1"},
|
||||
{o: this.threadEntryOrigLibNx1, p: this.threadEntryPatchLibNx1, cmt: "threadEntry() LibNX patch 1"},
|
||||
{o: this.threadEntryOrigLibNx2, p: this.threadEntryPatchLibNx2, cmt: "threadEntry() LibNX patch 2"},
|
||||
];
|
||||
|
||||
check(context: PatchContext): boolean {
|
||||
return this.patchesOrig.some((pattern) => {
|
||||
if (typeof pattern === "string") {
|
||||
pattern = PatternMatcher.parse(pattern);
|
||||
}
|
||||
return PatternMatcher.find(context.nro.text, pattern) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
apply(context: PatchContext): boolean {
|
||||
let anyApplied = false;
|
||||
for (const { o, p, cmt } of this.patchesPatch) {
|
||||
const offset = PatternMatcher.find(context.nro.text, o);
|
||||
if (offset !== -1) {
|
||||
context.log(`Found pattern ${cmt} at offset ${nhex(offset)}`);
|
||||
PatternMatcher.applyPattern(context.nro.text, offset, p);
|
||||
context.appliedPatches.add(this.id);
|
||||
anyApplied = true;
|
||||
} else {
|
||||
// context.log(`Pattern ${cmt} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
return anyApplied;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: What approach should we take here to add a LNY2 marker?
|
||||
// I personally think it's wiser to just leave it as is so that hbmenu keeps the red warning visible - the whole method is a massive hack anyway.
|
||||
// There's not enough space in the MOD0 header, so the best solution I'm thinking of is to move it to the end of file if possible.
|
||||
|
||||
// export class Crt0Lny2Patch extends Patch {
|
||||
// readonly id = "pattern_crt0_lny2";
|
||||
|
||||
// check(context: PatchContext): boolean {
|
||||
// const { nro } = context;
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// apply(context: PatchContext): boolean {
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
export const PATCHES: Patch[] = [
|
||||
new LegacyAbiPatch(),
|
||||
// new Crt0Lny2Patch()
|
||||
];
|
||||
136
src/patcher/utils.ts
Normal file
136
src/patcher/utils.ts
Normal file
@ -0,0 +1,136 @@
|
||||
export function unhex(hex: string): Uint8Array {
|
||||
// Remove all spaces and parse every two hex characters as a byte
|
||||
const clean = hex.replace(/\s+/g, "");
|
||||
if (clean.length % 2 !== 0)
|
||||
throw new Error("Hex string must have even length");
|
||||
const arr = [];
|
||||
for (let i = 0; i < clean.length; i += 2) {
|
||||
arr.push(parseInt(clean.slice(i, i + 2), 16));
|
||||
}
|
||||
return new Uint8Array(arr);
|
||||
}
|
||||
|
||||
export function nhex(num: number | bigint): string {
|
||||
return `0x${num.toString(16).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
bytes: Uint8Array;
|
||||
mask: Uint8Array;
|
||||
}
|
||||
|
||||
export class PatternMatcher {
|
||||
/**
|
||||
* Parses a pattern string into a structure for matching.
|
||||
* Supports hex bytes (e.g. "AA") and wildcards ("??").
|
||||
* Spaces are ignored.
|
||||
*/
|
||||
static parse(pattern: string): Pattern {
|
||||
const cleanPattern = pattern.replace(/\s+/g, "");
|
||||
if (cleanPattern.length % 2 !== 0) {
|
||||
throw new Error("Invalid pattern length");
|
||||
}
|
||||
|
||||
const length = cleanPattern.length / 2;
|
||||
const bytes = new Uint8Array(length);
|
||||
const mask = new Uint8Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const byteStr = cleanPattern.substr(i * 2, 2);
|
||||
if (byteStr === "??") {
|
||||
bytes[i] = 0;
|
||||
mask[i] = 0;
|
||||
} else {
|
||||
bytes[i] = parseInt(byteStr, 16);
|
||||
mask[i] = 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytes, mask };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first occurrence of the pattern in the data.
|
||||
* Returns the offset or -1 if not found.
|
||||
*/
|
||||
static find(data: Uint8Array, pattern: Pattern): number {
|
||||
const { bytes, mask } = pattern;
|
||||
|
||||
// Optimization: if no wildcards, use simpler search
|
||||
let hasWildcards = false;
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
if (mask[i] !== 0xff) {
|
||||
hasWildcards = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasWildcards) {
|
||||
return this.findExact(data, bytes);
|
||||
}
|
||||
|
||||
return this.findWithWildcards(data, bytes, mask);
|
||||
}
|
||||
|
||||
private static findExact(data: Uint8Array, pattern: Uint8Array): number {
|
||||
const len = pattern.length;
|
||||
const end = data.length - len;
|
||||
|
||||
for (let i = 0; i <= end; i++) {
|
||||
let found = true;
|
||||
for (let j = 0; j < len; j++) {
|
||||
if (data[i + j] !== pattern[j]) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static findWithWildcards(
|
||||
data: Uint8Array,
|
||||
pattern: Uint8Array,
|
||||
mask: Uint8Array,
|
||||
): number {
|
||||
const len = pattern.length;
|
||||
const end = data.length - len;
|
||||
|
||||
for (let i = 0; i <= end; i++) {
|
||||
let found = true;
|
||||
for (let j = 0; j < len; j++) {
|
||||
if (mask[j] === 0xff && data[i + j] !== pattern[j]) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a patch pattern to a target buffer, skipping wildcards ('??').
|
||||
* Wildcard bytes (mask value 0x00) are not written into the data array.
|
||||
*
|
||||
* @param data Target Uint8Array to patch (will be mutated).
|
||||
* @param offset Offset in data at which to write the pattern.
|
||||
* @param pattern Patch bytes (with wildcards matching any value).
|
||||
* @param mask Uint8Array mask (0xFF for bytes to write, 0x00 for wildcards/'??').
|
||||
*/
|
||||
static applyPattern(
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
pattern: Pattern,
|
||||
): void {
|
||||
const { bytes, mask } = pattern;
|
||||
const len = Math.min(bytes.length, mask.length, data.length - offset);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (mask[i] === 0xff) {
|
||||
data[offset + i] = bytes[i];
|
||||
}
|
||||
// if mask[i] === 0x00, skip (wildcard, preserve existing byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/types.ts
Normal file
22
src/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type FileState =
|
||||
| "none"
|
||||
| "invalid"
|
||||
| "new-abi"
|
||||
| "patched"
|
||||
| "needs-patching";
|
||||
|
||||
export interface AnalysisResult {
|
||||
state: FileState;
|
||||
fileName: string;
|
||||
messageKey: string;
|
||||
messageParams?: Record<string, string | number>;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export interface PatchResult {
|
||||
success: boolean;
|
||||
patchedData?: Uint8Array;
|
||||
errorKey?: string;
|
||||
errorParams?: Record<string, string | number>;
|
||||
log: string[];
|
||||
}
|
||||
31
src/utils/localization.ts
Normal file
31
src/utils/localization.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export const MESSAGES: Record<string, string> = {
|
||||
status_invalid_nro: "This file does not appear to be a valid NRO file.",
|
||||
status_needs_patching: "This file uses the old ABI and can be patched.",
|
||||
status_new_abi:
|
||||
"This file already uses the new ABI and does not need patching.",
|
||||
status_no_pattern:
|
||||
"This file is a valid NRO but does not contain the expected ABI pattern. It may not need patching.",
|
||||
pattern_legacy_abi: "Legacy ABI",
|
||||
pattern_crt0_lny2: "Missing LNY2 (CRT0)",
|
||||
error_analysis_failed: "Failed to analyze file. Please try again.",
|
||||
error_already_patched: "File is already patched.",
|
||||
error_pattern_not_found: "Could not find the ABI pattern to patch.",
|
||||
error_unknown: "Unknown error occurred: {message}",
|
||||
};
|
||||
|
||||
export function formatMessage(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let message = MESSAGES[key] || key;
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([paramKey, paramValue]) => {
|
||||
let displayValue = String(paramValue);
|
||||
if (typeof paramValue === "string" && MESSAGES[paramValue]) {
|
||||
displayValue = MESSAGES[paramValue];
|
||||
}
|
||||
message = message.replace(`{${paramKey}}`, displayValue);
|
||||
});
|
||||
}
|
||||
return message;
|
||||
}
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user