feat: apply cheat game

This commit is contained in:
Vinicius Rangel
2025-08-04 23:01:03 -03:00
parent fc67cea322
commit a320a9605c
12 changed files with 134 additions and 46 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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;

View File

@@ -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>) {

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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,
});
});
}),
);

View File

@@ -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");
}

View File

@@ -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[];
};

View File

@@ -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}`;

View File

@@ -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[]> {

View File

@@ -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,
};