diff --git a/package.json b/package.json
index 63c5257e01..d1c23d549c 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"linkify-html": "^4.3.2",
"motion": "^12.23.22",
"radix-ui": "^1.4.2",
+ "rcheevos": "^0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 887023c715..3f61898d52 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
radix-ui:
specifier: ^1.4.2
version: 1.4.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ rcheevos:
+ specifier: ^0.1
+ version: 0.1.0
react:
specifier: ^19.2.0
version: 19.2.0
@@ -5036,6 +5039,9 @@ packages:
'@types/react-dom':
optional: true
+ rcheevos@0.1.0:
+ resolution: {integrity: sha512-CNhv2DiRw5s8MFG05zQCVH8qhSSg89V2m/WI7EYYoyKI7oH+V3SieR2T0AUjhqlF0HTekbQkp0ybKfXgNHkMbQ==}
+
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
@@ -11319,6 +11325,8 @@ snapshots:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
+ rcheevos@0.1.0: {}
+
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
diff --git a/resources/js/common/hooks/useRcheevos.test.ts b/resources/js/common/hooks/useRcheevos.test.ts
new file mode 100644
index 0000000000..2561a3a846
--- /dev/null
+++ b/resources/js/common/hooks/useRcheevos.test.ts
@@ -0,0 +1,35 @@
+import { renderHook, waitFor } from '@/test';
+
+import { useRcheevos } from './useRcheevos';
+
+const mockRcheevosInstance = vi.hoisted(() => ({ initialized: true }) as any);
+
+vi.mock('rcheevos', () => {
+ const initialize = vi.fn(() => Promise.resolve(mockRcheevosInstance));
+
+ return {
+ RCheevos: {
+ initialize,
+ },
+ };
+});
+
+describe('Hook: useRcheevos', () => {
+ it('returns a ref initialized to null', () => {
+ // ARRANGE
+ const { result } = renderHook(() => useRcheevos());
+
+ // ASSERT
+ expect(result.current.current).toBeNull();
+ });
+
+ it('populates the ref with the initialized RCheevos instance', async () => {
+ // ARRANGE
+ const { result } = renderHook(() => useRcheevos());
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.current).toBe(mockRcheevosInstance);
+ });
+ });
+});
diff --git a/resources/js/common/hooks/useRcheevos.ts b/resources/js/common/hooks/useRcheevos.ts
new file mode 100644
index 0000000000..d8c8e9329b
--- /dev/null
+++ b/resources/js/common/hooks/useRcheevos.ts
@@ -0,0 +1,17 @@
+import { RCheevos } from 'rcheevos';
+import type { RefObject } from 'react';
+import { useEffect, useRef } from 'react';
+
+const promise = RCheevos.initialize();
+
+export function useRcheevos(): RefObject {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ promise.then((instance) => {
+ ref.current = instance;
+ });
+ }, []);
+
+ return ref;
+}
diff --git a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx
new file mode 100644
index 0000000000..7da813c597
--- /dev/null
+++ b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx
@@ -0,0 +1,104 @@
+import userEvent from '@testing-library/user-event';
+
+import { render, screen, waitFor } from '@/test';
+import { createGame, createGameHash } from '@/test/factories';
+
+import { HashCheckerSection } from './HashCheckerSection';
+
+const computeHashMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/common/hooks/useRcheevos', () => ({
+ useRcheevos: () => ({
+ current: {
+ computeHash: computeHashMock,
+ },
+ }),
+}));
+
+describe('Component: HashCheckerSection', () => {
+ beforeEach(() => {
+ computeHashMock.mockReset();
+ computeHashMock.mockReturnValue('abc123');
+ });
+
+ it('renders without crashing', () => {
+ // ARRANGE
+ const { container } = render(
+ ,
+ {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ },
+ );
+
+ // ASSERT
+ expect(container).toBeTruthy();
+ });
+
+ it('computes the hash for an uploaded file and shows a success indicator when it matches', async () => {
+ // ARRANGE
+ render(, {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ });
+
+ const fileInput = screen.getByTestId('hash-file-input') as HTMLInputElement;
+ const file = new File(['dummy-content'], 'rom.bin', {
+ type: 'application/octet-stream',
+ });
+
+ // ACT
+ await userEvent.upload(fileInput, file);
+
+ // ASSERT
+ await waitFor(() => {
+ expect(computeHashMock).toHaveBeenCalledTimes(1);
+ });
+
+ expect(computeHashMock).toHaveBeenCalledWith(
+ 5,
+ new TextEncoder().encode('dummy-content').buffer,
+ );
+ expect(screen.getByText(/got hash:/i)).toBeVisible();
+ expect(screen.getByText('abc123')).toBeVisible();
+ expect(screen.getByText('✅')).toBeVisible();
+ });
+
+ it('shows a failure indicator when the computed hash is not recognized', async () => {
+ // ARRANGE
+ computeHashMock.mockReturnValue('deadbeef');
+
+ render(, {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ });
+
+ const fileInput = screen.getByTestId('hash-file-input') as HTMLInputElement;
+ const file = new File(['other-content'], 'rom2.bin', {
+ type: 'application/octet-stream',
+ });
+
+ // ACT
+ await userEvent.upload(fileInput, file);
+
+ // ASSERT
+ await waitFor(() => {
+ expect(screen.getByText('deadbeef')).toBeVisible();
+ });
+
+ expect(computeHashMock).toHaveBeenCalledWith(
+ 3,
+ new TextEncoder().encode('other-content').buffer,
+ );
+ expect(screen.getByText('❌')).toBeVisible();
+ });
+});
diff --git a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
new file mode 100644
index 0000000000..429c4fe2c1
--- /dev/null
+++ b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
@@ -0,0 +1,49 @@
+import type { ChangeEvent, FC } from 'react';
+import { memo, useCallback, useRef, useState } from 'react';
+
+import { usePageProps } from '@/common/hooks/usePageProps';
+import { useRcheevos } from '@/common/hooks/useRcheevos';
+
+interface HashCheckerSection {
+ systemID: number;
+}
+
+export const HashCheckerSection: FC = memo(({ systemID }) => {
+ const hasher = useRcheevos();
+ const fileRef = useRef(null);
+ const [hash, setHash] = useState(null);
+ const { hashes } = usePageProps();
+
+ const onFileSelected = useCallback(
+ async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file || !hasher.current) return;
+
+ const result = hasher.current.computeHash(systemID, await file.arrayBuffer());
+
+ setHash(result);
+ },
+ [hasher, systemID],
+ );
+
+ return (
+
+
+ {hash && (
+
+
+ {'Got Hash:'} {hash}
+
+
{hashes.some((h) => h.md5 === hash) ? '✅' : '❌'}
+
+ )}
+
+ );
+});
diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
index 9a527e8b86..6c842f94e9 100644
--- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
+++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
@@ -3,6 +3,16 @@ import { createGame, createGameHash } from '@/test/factories';
import { HashesMainRoot } from './HashesMainRoot';
+vi.mock('rcheevos', () => {
+ const initialize = vi.fn(() => Promise.resolve(() => ({ initialized: true })));
+
+ return {
+ RCheevos: {
+ initialize,
+ },
+ };
+});
+
describe('Component: HashesMainRoot', () => {
it('renders without crashing', () => {
// ARRANGE
@@ -85,7 +95,9 @@ describe('Component: HashesMainRoot', () => {
});
// ASSERT
- const button = screen.queryByRole('button', { name: /other known hashes/i });
+ const button = screen.queryByRole('button', {
+ name: /other known hashes/i,
+ });
expect(button).toBeNull();
});
diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
index 8e17e461fb..240f40202f 100644
--- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
+++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
@@ -10,6 +10,7 @@ import { GameHeading } from '@/common/components/GameHeading/GameHeading';
import { InertiaLink } from '@/common/components/InertiaLink';
import { usePageProps } from '@/common/hooks/usePageProps';
+import { HashCheckerSection } from './HashCheckerSection';
import { HashesList } from './HashesList';
import { OtherHashesSection } from './OtherHashesSection';
@@ -52,7 +53,13 @@ export const HashesMainRoot: FC = memo(() => {
,
+ 1: (
+
+ ),
}}
/>
) : null}{' '}
@@ -71,6 +78,8 @@ export const HashesMainRoot: FC = memo(() => {
+ {game.system && }
+