mirror of
https://github.com/shadps4-emu/shadPS4-launcher.git
synced 2026-01-31 00:55:20 +01:00
feat: apply cheat game
This commit is contained in:
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: install Rust stable
|
||||
- name: install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "shadps4-launcher"
|
||||
version = "1.0.0"
|
||||
description = "shadPS4 Launcher"
|
||||
authors = ["Vinicius Rangel <me@viniciusrangel.dev>"]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
|
||||
@@ -5,7 +5,7 @@ use anyhow::anyhow;
|
||||
use anyhow_tauri::bail;
|
||||
use anyhow_tauri::IntoTAResult;
|
||||
use log::{debug, error};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::ffi::OsString;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri_plugin_fs::FilePath;
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ impl GameProcess {
|
||||
|
||||
async fn handle_events(
|
||||
mut c: Child,
|
||||
app_handle: AppHandle,
|
||||
_app_handle: AppHandle,
|
||||
callback: impl Fn(GameEvent) + Send + 'static,
|
||||
data: ProcessData,
|
||||
) -> (Sender<String>, Sender<InnerCommand>) {
|
||||
|
||||
@@ -8,12 +8,14 @@ import { createAbort } from "@/lib/utils/events";
|
||||
import {
|
||||
atomCheatsEnabled,
|
||||
type CheatFileFormat,
|
||||
type CheatFileMod,
|
||||
type CheatRepository,
|
||||
cheatRepositories,
|
||||
} from "@/store/cheats-and-patches";
|
||||
import type { CUSAVersion } from "@/store/common";
|
||||
import type { GameEntry } from "@/store/db";
|
||||
import { type GameEntry, isSameGame } from "@/store/db";
|
||||
import { atomCheatPath } from "@/store/paths";
|
||||
import { atomRunningGames } from "@/store/running-games";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import {
|
||||
@@ -28,14 +30,14 @@ import { ScrollArea } from "./ui/scroll-area";
|
||||
export function CheatPanel({ gameData }: { gameData: GameEntry }) {
|
||||
const store = useStore();
|
||||
|
||||
const cheatKey: CUSAVersion = `${gameData.cusa}_${gameData.version}`;
|
||||
const gameKey: CUSAVersion = `${gameData.cusa}_${gameData.version}`;
|
||||
|
||||
const cheatFolderPath = useAtomValue(atomCheatPath);
|
||||
const [availableCheats, setAvailableCheats] = useState<
|
||||
Partial<Record<CheatRepository, string[]>>
|
||||
Partial<Record<CheatRepository, CheatFileMod[]>>
|
||||
>({});
|
||||
const [allActiveCheats, setAllActiveCheats] = useAtom(atomCheatsEnabled);
|
||||
const activeCheats = allActiveCheats[cheatKey] || {};
|
||||
const activeCheats = allActiveCheats[gameKey] || {};
|
||||
|
||||
const [_cheatList, refreshCheatList] = useReducer(() => ({}), {});
|
||||
|
||||
@@ -47,7 +49,7 @@ export function CheatPanel({ gameData }: { gameData: GameEntry }) {
|
||||
const cheatFile = await join(
|
||||
cheatFolderPath,
|
||||
repo,
|
||||
`${cheatKey}.json`,
|
||||
`${gameKey}.json`,
|
||||
);
|
||||
if (!(await exists(cheatFile))) {
|
||||
return;
|
||||
@@ -58,26 +60,47 @@ export function CheatPanel({ gameData }: { gameData: GameEntry }) {
|
||||
const { mods } = JSON.parse(
|
||||
await readTextFile(cheatFile),
|
||||
) as CheatFileFormat;
|
||||
const modNames = mods.map((e) => e.name);
|
||||
setAvailableCheats((prev) => ({ ...prev, [repo]: modNames }));
|
||||
setAvailableCheats((prev) => ({ ...prev, [repo]: mods }));
|
||||
});
|
||||
return abort;
|
||||
}, [cheatKey, cheatFolderPath, _cheatList]);
|
||||
}, [gameKey, cheatFolderPath, _cheatList]);
|
||||
|
||||
const toggleModActive = (
|
||||
repo: CheatRepository,
|
||||
modName: string,
|
||||
mod: CheatFileMod,
|
||||
enable: boolean,
|
||||
) => {
|
||||
const modName = mod.name;
|
||||
setAllActiveCheats((prev) => ({
|
||||
...prev,
|
||||
[cheatKey]: {
|
||||
...prev[cheatKey],
|
||||
[gameKey]: {
|
||||
...prev[gameKey],
|
||||
[repo]: enable
|
||||
? [...(prev[cheatKey]?.[repo] || []), modName]
|
||||
: prev[cheatKey]?.[repo]?.filter((e) => e !== modName),
|
||||
? [...(prev[gameKey]?.[repo] || []), modName]
|
||||
: prev[gameKey]?.[repo]?.filter((e) => e !== modName),
|
||||
},
|
||||
}));
|
||||
const runningGame = store
|
||||
.get(atomRunningGames)
|
||||
.find((e) => isSameGame(e.game, gameData));
|
||||
if (
|
||||
runningGame &&
|
||||
store
|
||||
.get(runningGame.atomCapabilities)
|
||||
.includes("ENABLE_MEMORY_PATCH")
|
||||
) {
|
||||
const isOffset = !mod.hint;
|
||||
for (const mem of mod.memory) {
|
||||
runningGame.process.send_patch_memory(
|
||||
mod.name,
|
||||
mem.offset,
|
||||
enable ? mem.on : mem.off,
|
||||
"",
|
||||
"",
|
||||
isOffset,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,36 +127,36 @@ export function CheatPanel({ gameData }: { gameData: GameEntry }) {
|
||||
{(
|
||||
Object.entries(availableCheats) as [
|
||||
CheatRepository,
|
||||
string[],
|
||||
CheatFileMod[],
|
||||
][]
|
||||
).map(([repo, mods]) => (
|
||||
<Fragment key={repo}>
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
{repo}
|
||||
</span>
|
||||
{mods.map((modName) => (
|
||||
{mods.map((mod) => (
|
||||
<div
|
||||
className="flex gap-2 hover:bg-muted/50"
|
||||
key={modName}
|
||||
key={mod.name}
|
||||
>
|
||||
<Checkbox
|
||||
checked={activeCheats[
|
||||
repo as CheatRepository
|
||||
]?.includes(modName)}
|
||||
id={`mod_${repo}_${modName}`}
|
||||
]?.includes(mod.name)}
|
||||
id={`mod_${repo}_${mod.name}`}
|
||||
onCheckedChange={(v) => {
|
||||
toggleModActive(
|
||||
repo,
|
||||
modName,
|
||||
mod,
|
||||
v === true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
className="flex items-center gap-4"
|
||||
htmlFor={`mod_${repo}_${modName}`}
|
||||
htmlFor={`mod_${repo}_${mod.name}`}
|
||||
>
|
||||
<span>{modName}</span>
|
||||
<span>{mod.name}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -188,7 +188,7 @@ export function RunningGameDialog({
|
||||
className={cn("flex flex-col gap-4", {
|
||||
"h-[calc(100vh-100px)] p-10 md:max-w-[800px]":
|
||||
!maximized,
|
||||
"h-screen w-screen max-w-full": maximized,
|
||||
"h-screen w-screen max-w-full sm:max-w-full": maximized,
|
||||
})}
|
||||
>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -224,10 +224,12 @@ export function downloadCheats(repo: CheatRepository, store: JotaiStore) {
|
||||
return errAsync("invalid key line: " + line);
|
||||
}
|
||||
return fetchSafe(createUrl(key))
|
||||
.map((e) => e.bytes())
|
||||
.map((e) => e.arrayBuffer())
|
||||
.map(async (modData) => {
|
||||
const path = await join(cheatPath, key);
|
||||
await writeFile(path, modData, { create: true });
|
||||
await writeFile(path, new Uint8Array(modData), {
|
||||
create: true,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dirname, join } from "@tauri-apps/api/path";
|
||||
import { exists, mkdir } from "@tauri-apps/plugin-fs";
|
||||
import { exists, mkdir, readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { ok, safeTry } from "neverthrow";
|
||||
import { toast } from "sonner";
|
||||
import { GameProcess } from "@/lib/native/game-process";
|
||||
@@ -8,10 +8,14 @@ import { errWarning, stringifyError, WarningError } from "@/lib/utils/error";
|
||||
import type { JotaiStore } from "@/store";
|
||||
import {
|
||||
atomAvailablePatches,
|
||||
atomCheatsEnabled,
|
||||
atomPatchRepoEnabledByGame,
|
||||
type CheatFileFormat,
|
||||
type CheatFileMod,
|
||||
} from "@/store/cheats-and-patches";
|
||||
import type { CUSAVersion } from "@/store/common";
|
||||
import type { GameEntry } from "@/store/db";
|
||||
import { atomEmuUserPath, atomPatchPath } from "@/store/paths";
|
||||
import { atomCheatPath, atomEmuUserPath, atomPatchPath } from "@/store/paths";
|
||||
import {
|
||||
createGameProcesState,
|
||||
type GameProcessState,
|
||||
@@ -20,11 +24,48 @@ import {
|
||||
import { atomSelectedVersion } from "@/store/version-manager";
|
||||
import { handleGameProcess } from "./game-process";
|
||||
|
||||
async function getCheatMods(
|
||||
gameKey: CUSAVersion,
|
||||
store: JotaiStore,
|
||||
): Promise<CheatFileMod[]> {
|
||||
const cheatFolderPath = await store.get(atomCheatPath);
|
||||
|
||||
const enabledCheats = store.get(atomCheatsEnabled)[gameKey];
|
||||
if (!enabledCheats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mods: CheatFileMod[] = [];
|
||||
const entries = Object.entries(enabledCheats);
|
||||
for (const [repo, enabledMods] of entries) {
|
||||
const cheatFilePath = await join(
|
||||
cheatFolderPath,
|
||||
repo,
|
||||
`${gameKey}.json`,
|
||||
);
|
||||
if (!(await exists(cheatFilePath))) {
|
||||
continue;
|
||||
}
|
||||
const cheatFile = JSON.parse(
|
||||
await readTextFile(cheatFilePath),
|
||||
) as CheatFileFormat;
|
||||
for (const mod of cheatFile.mods) {
|
||||
if (enabledMods.includes(mod.name)) {
|
||||
mods.push(mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mods;
|
||||
}
|
||||
|
||||
export async function startGame(
|
||||
store: JotaiStore,
|
||||
game: GameEntry,
|
||||
): Promise<GameProcessState | null> {
|
||||
const result = await safeTry(async function* () {
|
||||
const gameKey: CUSAVersion = `${game.cusa}_${game.version}`;
|
||||
|
||||
const emu = store.get(atomSelectedVersion);
|
||||
if (!emu) {
|
||||
return errWarning("No emulator selected");
|
||||
@@ -80,13 +121,29 @@ export async function startGame(
|
||||
const state = createGameProcesState(game, process, store);
|
||||
|
||||
const { onEmuRun } = handleGameProcess(state);
|
||||
// FIXME Remove this 1e6
|
||||
yield* await withTimeout(onEmuRun, 5000 * 1e6).orTee(() => {
|
||||
yield* await withTimeout(onEmuRun, 5000).orTee(() => {
|
||||
removeRunningGame(state);
|
||||
});
|
||||
|
||||
const capabilities = store.get(state.atomCapabilities);
|
||||
|
||||
if (state.hasIpc) {
|
||||
// TODO Send Memory Patches here
|
||||
if (capabilities.includes("ENABLE_MEMORY_PATCH")) {
|
||||
const mods = await getCheatMods(gameKey, store);
|
||||
for (const mod of mods) {
|
||||
const isOffset = !mod.hint;
|
||||
for (const mem of mod.memory) {
|
||||
process.send_patch_memory(
|
||||
mod.name,
|
||||
mem.offset,
|
||||
mem.on,
|
||||
"",
|
||||
"",
|
||||
isOffset,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.send("START");
|
||||
}
|
||||
|
||||
@@ -48,20 +48,22 @@ export const atomCheatsEnabled = atomWithTauriStore<CheatEnabledByGame>(
|
||||
},
|
||||
);
|
||||
|
||||
export type CheatFileMod = {
|
||||
name: string;
|
||||
hint: boolean | null;
|
||||
type: string;
|
||||
memory: {
|
||||
offset: string;
|
||||
on: string;
|
||||
off: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type CheatFileFormat = {
|
||||
name: string;
|
||||
id: CUSA; // CUSA
|
||||
version: string;
|
||||
process: string;
|
||||
credits: string[];
|
||||
mods: {
|
||||
name: string;
|
||||
hint: boolean | null;
|
||||
type: string;
|
||||
memory: {
|
||||
offset: string;
|
||||
on: string;
|
||||
off: string;
|
||||
}[];
|
||||
}[];
|
||||
mods: CheatFileMod[];
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GameEntry } from "./db";
|
||||
|
||||
export type CUSA = "N/A" | `N/A - ${string}` | `CUSA${string}`;
|
||||
|
||||
export type Version = `${number}.${number}`;
|
||||
export type Version = "N/A" | `${number}.${number}`;
|
||||
|
||||
export type CUSAVersion = `${CUSA}_${Version}`;
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export type GameEntry = {
|
||||
error?: Error; // This is not stored in the database
|
||||
};
|
||||
|
||||
export function isSameGame(g1: GameEntry, g2: GameEntry): boolean {
|
||||
return g1.cusa === g2.cusa && g1.version === g2.version;
|
||||
}
|
||||
|
||||
export const db = {
|
||||
conn,
|
||||
async listGames(): Promise<GameEntry[]> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { stringifyError } from "@/lib/utils/error";
|
||||
import { atomWithTauriStore } from "@/lib/utils/jotai/tauri-store";
|
||||
import type { Callback } from "@/lib/utils/types";
|
||||
import { defaultStore } from ".";
|
||||
import type { CUSA } from "./common";
|
||||
import type { CUSA, Version } from "./common";
|
||||
import { db, type GameEntry } from "./db";
|
||||
import { atomGamesPath } from "./paths";
|
||||
|
||||
@@ -60,7 +60,7 @@ async function loadGameData(path: string): Promise<GameEntry> {
|
||||
path: path,
|
||||
cusa: (e.TITLE_ID?.Text || base) as CUSA,
|
||||
title: e.TITLE?.Text || "Unknown",
|
||||
version: e.APP_VER?.Text || "UNK",
|
||||
version: (e.APP_VER?.Text as Version) || "N/A",
|
||||
fw_version: fw_version || "UNK",
|
||||
sfo,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user