dev #1

Merged
RHPZAdmin merged 9 commits from dev into master 2026-06-09 08:38:19 +00:00
187 changed files with 15982 additions and 415 deletions

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
avancee.ods
.~lock.avancee.ods#

View File

@@ -74,4 +74,9 @@ class XenForoGuard implements Guard
$this->user = $user;
}
public function logout(): void
{
redirect('/');
}
}

View File

@@ -3,21 +3,18 @@
namespace App\Auth;
use App\Services\XenforoService;
use App\XenForoDataTypes\XenForoData;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Contracts\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable;
class XenForoUser implements Authenticatable {
class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName {
use \Illuminate\Foundation\Auth\Access\Authorizable;
public ?array $permissions = null;
private XenforoService $services;
public function __construct(public readonly object $data) {
$this->services = app(XenforoService::class);
}
public function __get(string $name): mixed {
return $this->data->$name ?? null;
}
public function getAuthIdentifierName(): string
{
return 'user_id';
@@ -73,7 +70,7 @@ class XenForoUser implements Authenticatable {
return null;
}
public function can(string $permissionGroup, string $permissionName): bool
public function _can(string $permissionGroup, string $permissionName): bool
{
if( !$this->permissions ){
$this->permissions = $this->services->getPermissions($this->data->user_id, $this->data->permission_combination_id);
@@ -81,4 +78,31 @@ class XenForoUser implements Authenticatable {
return ($this->permissions[$permissionGroup][$permissionName] ?? 0) === true;
}
/* FILAMENT COMPATIBILITY */
public function canAccessPanel(Panel $panel): bool
{
return $this->is_admin === 1;
}
public function getFilamentName(): string
{
return $this->username ?? "XF";
}
public function getAttributeValue($key)
{
return $this->{$key} ?? null;
}
public function getKey()
{
return $this->data->user_id;
}
public function getKeyName()
{
return 'user_id';
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Models\Entry;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('entries:purge-rejected {--days=7}')]
#[Description('Soft Delete rejected entries older than X days')]
class DeleteRejectedEntries extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
$days = (int) $this->option('days');
$count = Entry::where('state', 'rejected')
->where('rejected_at', '<', now()->subDays($days))
->delete();
$this->info("Deleted {$count} entries");
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('hashes:import-dat {file}')]
#[Description('Import a hashes DAT file like No-Intro/Redump')]
class ImportDatFile extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
$file = $this->argument('file');
if(!file_exists($file)) {
$this->error('File not found');
return;
}
$reader = new \XMLReader();
$reader->open($file);
$this->info("Importing...");
$count = 0;
$insertBuffer = [];
$datReferenceId = null;
while ($reader->read()) {
if($reader->nodeType == \XMLReader::ELEMENT) {
if( $reader->name == 'header' ){
$node = new \SimpleXMLElement($reader->readOuterXml());
$name = (string) ($node->name . ' v.' . $node->version . ' (' . $node->homepage . ')');
DB::connection('hashes')->table('dat_reference')->insert([['name' => $name ]]);
$datReferenceId = DB::connection('hashes')->table('dat_reference')->where('name', $name)->value('id');
}
if( $reader->name == 'game' ){
if( !$datReferenceId ){
$this->error("No dat reference found");
return;
}
$node = new \SimpleXMLElement($reader->readOuterXml());
foreach ($node->rom as $rom) {
$insertBuffer[] = [
'filename' => $rom['name'],
'crc32' => $rom['crc'],
'sha1' => $rom['sha1'],
'dat_reference_id' => $datReferenceId,
];
$count++;
if( count($insertBuffer) >= 1000 ){
DB::connection('hashes')->table('hashes')->insert($insertBuffer);
$insertBuffer = [];
}
}
}
}
}
if( count($insertBuffer) >= 0 ){
DB::connection('hashes')->table('hashes')->insert($insertBuffer);
}
$reader->close();
$this->info("{$count} ROMs hashes imported.");
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Helpers;
use App\Models\Entry;
use App\Services\XenforoApiService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class EntryHelpers {
@@ -45,6 +48,8 @@ class EntryHelpers {
*/
public static function buildCompleteTitle( string $section, array $fields = [] ){
$fields = array_merge( ['entry_title' => 'Untitled', 'game_name' => '', 'languages_string' => '', 'platform_name' => ''], $fields );
return match ($section) {
'translations' => sprintf('%s (%s Translation) %s', $fields['entry_title'] ?? $fields['game_name'], $fields['languages_string'], $fields['platform_name']),
'romhacks' => sprintf('%s (%s) Romhack', $fields['entry_title'], $fields['platform_name']),
@@ -56,4 +61,33 @@ class EntryHelpers {
default => $fields['entry_title'],
};
}
public static function getLatestComments(Entry $entry, int $limit = 20): array {
if( !$entry->comments_thread_id ){
return [];
}
$cacheKey = "entry_comments_{$entry->id}";
return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) {
$service = app(XenforoApiService::class);
// Get thread infos and pagination.
$paginationInfos = $service->getThreadPosts($entry->comments_thread_id, 1);
$lastPage = $paginationInfos['pagination']['last_page'] ?? 1;
// Get last threads
$lastPageData = $lastPage > 1 ? $service->getThreadPosts($entry->comments_thread_id, $lastPage) : $paginationInfos;
$posts = $lastPageData['posts'] ?? [];
if( count( $posts ) < $limit && $lastPage > 1 ){
$previousPageData = $service->getThreadPosts($entry->comments_thread_id, $lastPage - 1 );
$posts = array_merge( $posts, $previousPageData['posts'] ?? [] );
}
return collect( $posts )->slice(-$limit)->reverse()->values()->toArray();
});
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\DB;
use stdClass;
class HashesHelpers
{
public static function findHashes(string $sha1 ): ?StdClass
{
return DB::connection('hashes')->table('hashes')->where('sha1', $sha1)->first();
}
public static function getReferenceName( int $id ): ?string
{
return DB::connection('hashes')->table('dat_reference')->where('id', $id)->first()?->name ?? null;
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Helpers;
use App\Auth\XenForoUser;
use App\Models\Entry;
use App\Services\XenforoApiService;
class XenForoHelpers {
@@ -34,4 +36,28 @@ class XenForoHelpers {
}
return self::XF_AVATAR_COLORS[ crc32( $user->data->username ) % count( self::XF_AVATAR_COLORS ) ];
}
public static function updateEntriesCount( int $userId ): void
{
$count = Entry::where('user_id', $userId)->where('state','published')->count();
$service = app(XenforoApiService::class);
$service->updateEntriesCount( $count, $userId );
}
public static function entryApproved( Entry $entry ): void
{
// 1. Update XF Entry count.
self::updateEntriesCount( $entry->user_id );
// 2. Send a private message
if( \Auth::user()->user_id === $entry->user_id ) {
return;
}
$moderator = \Auth::user()->username;
$title = "Entry approved : {$entry->title}";
$message = "Your entry {$entry->title} has been approved by {$moderator}.";
$service->createConversation([ $entry->user_id ], $title, $message, false, false);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\XenForoHelpers;
use App\Services\XenforoApiService;
use App\Services\XenforoService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DynamicLoadController extends Controller
{
public function hovercard( Request $request, int $user_id ){
$data = Cache::remember("xf_hovercard_{$user_id}", 300, function() use($user_id){
$service = app(XenforoService::class);
$user = $service->getXfUser( $user_id );
if( !$user ){
return response()->json(['error' => 'User not found'], 404);
}
return [
'user_id' => $user_id,
'username' => $user->username,
'avatar_url' => $user->getAvatarUrl(),
'avatar_color' => XenForoHelpers::getAvatarColor( $user ),
'avatar_letter' => XenForoHelpers::getAvatarLetter( $user ),
'group_name' => $service->getXfUserGroup( $user?->user_group_id ?? 0 )?->title ?? 'Guest',
'joined' => \DateTimeImmutable::createFromTimestamp( $user->register_date ?? 0 )->format('Y-m-d'),
'last_seen' => \DateTimeImmutable::createFromTimestamp( $user->last_activity ?? 0 )->format('Y-m-d'),
'message_count' => $user->message_count,
'reaction_score' => $user->reaction_score,
'trophy_points' => $user->trophy_points,
'entries_count' => $user->rhpz_entry_count,
];
});
return response()->json( ['user' => $data] );
}
public function getNotifications( Request $request ){
$service = app(XenforoApiService::class);
$data = $service->getUserAlerts(\Auth::user()->user_id);
return response()->json( $data );
}
public function markAllRead( Request $request ){
$service = app(XenforoApiService::class);
$service->markAllNotificationsRead(\Auth::user()->user_id);
return response()->json( ['success' => true] );
}
public function getConversations( Request $request ){
$service = app(XenforoApiService::class);
$data = $service->getConversations(\Auth::user()->user_id);
return response()->json( $data );
}
}

View File

@@ -2,35 +2,66 @@
namespace App\Http\Controllers;
use App\Helpers\EntryHelpers;
use App\Models\Entry;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EntryController extends Controller
{
private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ];
private const SECTION_TYPES = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials'];
public function index(): View
{
$entries = Entry::published()
->with(['game.platform', 'platform'])
->latest('published_at')
->paginate(30);
return view('entries.index');
}
return view('entries.index', compact('entries'));
public function section_redirect(string $section)
{
return redirect( databaseRoute( ['types' => [ $section ] ] ) );
}
public function show(string $section, Entry $entry): View
{
if( ! in_array($section, self::SECTION_TYPES) )
if (!in_array($section, self::SECTION_TYPES))
abort(404);
if( $entry->type !== $section )
if ($entry->type !== $section)
abort(404);
return view('entries.show', compact('entry', 'section'));
Gate::authorize('viewAny', $entry);
// Permissions.
$entryPolicy = match ($entry->state) {
'pending' => 'viewPending',
'draft' => 'viewDraft',
'rejected' => 'viewRejected',
'hidden' => 'viewHidden',
'locked' => 'viewLocked',
'published' => null,
'default' => null
};
if ($entryPolicy)
Gate::authorize($entryPolicy, $entry);
$comments = EntryHelpers::getLatestComments($entry);
return view('entries.show', compact('entry', 'section', 'comments'));
}
public function drafts(): View
{
$drafts = Entry::where('user_id', \Auth::user()->user_id )
->where('state', 'draft')
->with('game.platform', 'status')
->orderBy('updated_at', 'desc')
->paginate(20);
return view('entries.drafts', compact('drafts'));
}
}

View File

@@ -51,7 +51,8 @@ class FileServerController extends Controller {
'filepath' => $data['file_path'],
'filesize' => $data['file']['size'],
'favorite_server' => $data['favorite_server'],
'favorite_at' => time()
'favorite_at' => time(),
'state' => 'public'
], now()->addHours(2) );
$data['finished'] = true;

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\ModCP;
use App\Helpers\EntryHelpers;
use App\Http\Controllers\Controller;
use App\Models\Author;
use App\Models\Language;
use App\Rules\XfUserExists;
use App\Traits\ModCPSearch;
use Illuminate\Http\Request;
class AuthorController extends Controller
{
use ModCPSearch;
public function index()
{
$items = Author::withCount('entries')
->orderBy('name')
->tap(fn($query) => $this->applySearch($query, ['name']))
->paginate(30)
->withQueryString();
return view('modcp.authors', [
'items' => $items
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:authors,name',
'owner_user_id' => [ 'nullable', 'integer', new XfUserExists ],
'website' => 'nullable|string|max:255',
]);
Author::create([
'name' => trim( $request->name ),
'slug' => EntryHelpers::uniqueSlug( $request->name, Author::class ),
'user_id' => $request->owner_user_id,
'website' => $request->website,
]);
return back()->with('success', 'Author added.');
}
public function update(Request $request, Author $author)
{
$request->validate([
'name' => 'required|string|max:255|unique:authors,name,' . $author->id,
'owner_user_id' => [ 'nullable', 'integer', new XfUserExists ],
'website' => 'nullable|string|max:255',
]);
$author->update([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Author::class, $author->id ),
'user_id' => $request->owner_user_id,
'website' => $request->website,
]);
return back()->with('success', 'Author updated.');
}
public function destroy(Author $author)
{
$author->delete();
return back()->with('success', 'Author deleted.');
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\ModCP;
use App\Helpers\EntryHelpers;
use App\Http\Controllers\Controller;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use App\Traits\ModCPSearch;
use Illuminate\Http\Request;
class GameController extends Controller
{
use ModCPSearch;
public function index()
{
$items = Game::withCount('entries')->orderBy('name')
->tap(fn($query) => $this->applySearch($query, ['name']))
->paginate(30)->withQueryString();
$platforms = Platform::orderBy('name')->get();
$genres = Genre::orderBy('name')->get();
return view('modcp.games', [
'items' => $items,
'platforms' => $platforms,
'genres' => $genres,
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'platform_id' => 'required|integer|exists:platforms,id',
'genre_id' => 'required|integer|exists:genres,id',
]);
Game::create([
'name' => trim($request->name),
'platform_id' => $request->platform_id,
'genre_id' => $request->genre_id,
'slug' => EntryHelpers::uniqueSlug($request->name, Game::class),
]);
return back()->with('success', 'Game added.');
}
public function update(Request $request, Game $game)
{
$request->validate([
'name' => 'required|string|max:255',
'platform_id' => 'required|integer|exists:platforms,id',
'genre_id' => 'required|integer|exists:genres,id'
]);
$game->update([
'name' => trim($request->name),
'platform_id' => $request->platform_id,
'genre_id' => $request->genre_id,
'slug' => EntryHelpers::uniqueSlug($request->name, Game::class, $game->id),
]);
return back()->with('success', 'Game updated.');
}
public function destroy(Game $game)
{
$game->delete();
return back()->with('success', 'Game deleted.');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\ModCP;
use App\Helpers\EntryHelpers;
use App\Http\Controllers\Controller;
use App\Models\Genre;
use App\Models\Language;
use App\Traits\ModCPSearch;
use Illuminate\Http\Request;
class GenreController extends Controller
{
use ModCPSearch;
public function index()
{
$items = Genre::withCount('games')
->orderBy('name')
->tap(fn($query) => $this->applySearch($query, ['name']))
->paginate(30)
->withQueryString();
return view('modcp.resources', [
'items' => $items,
'title' => 'Genres',
'singular' => 'Genre',
'storeRoute' => 'modcp.genres.store',
'updateRoute' => 'modcp.genres.update',
'destroyRoute' => 'modcp.genres.destroy'
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:genres,name',
]);
Genre::create([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Genre::class ),
]);
return back()->with('success', 'Genre added.');
}
public function update(Request $request, Genre $genre)
{
$request->validate([
'name' => 'required|string|max:255|unique:genres,name,' . $genre->id,
]);
$genre->update([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Genre::class, $genre->id ),
]);
return back()->with('success', 'Genre updated.');
}
public function destroy(Genre $genre)
{
$genre->delete();
return back()->with('success', 'Genre deleted.');
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\ModCP;
use App\Helpers\EntryHelpers;
use App\Http\Controllers\Controller;
use App\Models\Language;
use App\Traits\ModCPSearch;
use Illuminate\Http\Request;
class LanguageController extends Controller
{
use ModCPSearch;
public function index()
{
$items = Language::withCount('entries')
->orderBy('name')
->tap(fn($query) => $this->applySearch($query, ['name']))
->paginate(30)
->withQueryString();
return view('modcp.resources', [
'items' => $items,
'title' => 'Languages',
'singular' => 'Language',
'storeRoute' => 'modcp.languages.store',
'updateRoute' => 'modcp.languages.update',
'destroyRoute' => 'modcp.languages.destroy'
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:languages,name',
]);
Language::create([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Language::class ),
]);
return back()->with('success', 'Language added.');
}
public function update(Request $request, Language $language)
{
$request->validate([
'name' => 'required|string|max:255|unique:languages,name,' . $language->id,
]);
$language->update([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Language::class, $language->id ),
]);
return back()->with('success', 'Language updated.');
}
public function destroy(Language $language)
{
$language->delete();
return back()->with('success', 'Language deleted.');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\ModCP;
use App\Helpers\EntryHelpers;
use App\Http\Controllers\Controller;
use App\Models\Platform;
use App\Models\Language;
use App\Traits\ModCPSearch;
use Illuminate\Http\Request;
class PlatformController extends Controller
{
use ModCPSearch;
public function index()
{
$items = Platform::withCount(['games','entries'])
->orderBy('name')
->tap(fn($query) => $this->applySearch($query, ['name']))
->paginate(30)
->withQueryString();
return view('modcp.resources', [
'items' => $items,
'title' => 'Platforms',
'singular' => 'Platform',
'storeRoute' => 'modcp.platforms.store',
'updateRoute' => 'modcp.platforms.update',
'destroyRoute' => 'modcp.platforms.destroy'
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:platforms,name',
]);
Platform::create([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Platform::class ),
]);
return back()->with('success', 'Platform added.');
}
public function update(Request $request, Platform $platform)
{
$request->validate([
'name' => 'required|string|max:255|unique:platforms,name,' . $platform->id,
]);
$platform->update([
'name' => trim($request->name),
'slug' => EntryHelpers::uniqueSlug( $request->name, Platform::class, $platform->id ),
]);
return back()->with('success', 'Platform updated.');
}
public function destroy(Platform $platform)
{
$platform->delete();
return back()->with('success', 'Platform deleted.');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\RestoreXenForoCommentsThread;
use App\Models\Entry;
use Illuminate\Http\Request;
class ModCPController extends Controller
{
public function index()
{
$stats = [
'pending' => Entry::where('state', 'pending')->count(),
'locked' => Entry::where('state', 'locked')->count(),
'total' => Entry::count()
];
if( \Auth::user()->can('is-admin') ){
$stats['draft'] = Entry::where('state', 'draft')->count();
$stats['hidden'] = Entry::where('state', 'hidden')->count();
$stats['deleted'] = Entry::where('state', 'deleted')->count();
}
$recentDeleted = Entry::onlyTrashed()->latest('deleted_at')->limit(5)->get();
return view('modcp.index', compact('stats', 'recentDeleted'));
}
public function locked()
{
$entries = Entry::where('state', 'locked')
->with(['game.platform', 'authors'])
->latest()->paginate(25);
return view('modcp.entries', compact('entries'))->with('pageTitle', "Locked Entries")->with('state', 'locked');
}
public function draft()
{
$entries = Entry::where('state', 'draft')
->with(['game.platform', 'authors'])
->latest()->paginate(25);
return view('modcp.entries', compact('entries'))->with('pageTitle', "Draft Entries")->with('state', 'draft');
}
public function hidden()
{
$entries = Entry::where('state', 'hidden')
->with(['game.platform', 'authors'])
->latest()->paginate(25);
return view('modcp.entries', compact('entries'))->with('pageTitle', "Hidden Entries")->with('state', 'hidden');
}
public function deleted()
{
$entries = Entry::onlyTrashed()
->with(['game.platform', 'authors'])
->latest('deleted_at')->paginate(25);
return view('modcp.deleted', compact('entries'));
}
public function restore(Entry $entry)
{
$entry->restore();
RestoreXenForoCommentsThread::dispatch($entry->comments_thread_id);
$entry->update(['state' => 'draft']);
return back()->with('success', "Entry restored");
}
public function destroy(Entry $entry)
{
$entry->forceDelete();
return back()->with('success', "Entry permanently deleted");
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\XenForoHelpers;
use App\Models\Entry;
use App\Services\XenforoService;
use Illuminate\Http\Request;
class QueueController extends Controller
{
public function index()
{
$entries = Entry::inQueue()
->with(['authors', 'game.platform'])
->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END")
->orderBy('created_at', 'asc')
->get();
return view('queue.index', compact('entries'));
}
public function updateComment(Request $request, Entry $entry)
{
$request->validate(['comment' => 'nullable|string|max:2000']);
$entry->update(['staff_comment' => $request->input('comment')]);
return back()->with('success', 'Comment updated');
}
public function approve(Request $request, Entry $entry)
{
// $entry->update(['state' => 'published']);
XenForoHelpers::entryApproved($entry);
return back()->with('success', 'Entry approved');
}
public function reject(Request $request, Entry $entry)
{
$request->validate(['reason' => 'nullable|string|max:2000']);
$entry->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]);
return back()->with('success', 'Entry rejected');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Models\Entry;
use Illuminate\Http\Request;
class RedirectController extends Controller
{
public function entryReportRedirect( Request $request )
{
$id = $request->input('id');
$entry = Entry::findOrFail($id);
return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent.");
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
use App\Exceptions\SubmissionException;
use App\Helpers\FormHelpers;
use App\Http\Requests\StoreDraftRequest;
use App\Http\Requests\StoreEntryRequest;
use App\Jobs\DeleteXenForoCommentsThread;
use App\Models\Author;
use App\Models\Entry;
use App\Models\EntryFile;
@@ -17,6 +19,7 @@ use App\Models\Modification;
use App\Models\Platform;
use App\Models\Status;
use App\Services\SubmissionsService;
use App\Services\XenforoApiService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
@@ -82,7 +85,22 @@ class SubmissionController extends Controller
return view('submissions.edit', $data);
}
public function store(StoreEntryRequest $request, string $section){
public function destroy(Request $request, string $section, Entry $entry)
{
if( $entry->type !== $section )
abort(404);
if( $entry->comments_thread_id)
DeleteXenForoCommentsThread::dispatch( $entry->comments_thread_id );
$entry->delete();
return redirect( route('entries.index') )->with('success', "Entry successfully deleted.");
}
public function store(Request $request, string $section){
$request = $request->input('submit-state') === 'draft' ? app(StoreDraftRequest::class) : app(StoreEntryRequest::class);
$request->validateResolved();
try {
$entry = $this->services->storeEntry($request, $section);
@@ -100,166 +118,24 @@ class SubmissionController extends Controller
}
public function update(StoreEntryRequest $request, string $section, Entry $entry)
public function update(Request $request, string $section, Entry $entry)
{
if( $entry->type !== $section ) {
abort(404);
$request = $request->input('submit-state') === 'draft' ? app(StoreDraftRequest::class) : app(StoreEntryRequest::class);
$request->validateResolved();
try {
$entry = $this->services->editEntry($request, $section, $entry);
return match ($entry->state) {
'published' => redirect()->route('entries.show', ['section' => $section, 'entry' => $entry->slug])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
} catch ( SubmissionException $e ) {
return back()->withInput()->withErrors(['error' => $e->getMessage()]);
} catch ( \Exception $e ) {
return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]);
}
$gameId = null;
if( !$request->input('game_id') ){
if( $request->input('new-game-title') && $request->input('new-game-platform') && $request->input('new-game-genre') ){
$platform = Platform::find($request->input('new-game-platform'));
$genre = Genre::find($request->input('new-game-genre'));
$game = Game::create([
'name' => $request->input('new-game-title'),
'slug' => Str::slug($request->input('new-game-title')),
'platform_id' => $platform->id,
'genre_id' => $genre->id,
]);
$gameId = $game->id;
}
} else {
$gameId = $request->input('game_id');
}
$mainImage = $entry->main_image;
if ( $request->hasFile('main-image') ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = $request->file('main-image')->store('entries/main_images', 'public');
} elseif ( $request->input('remove_main_image') === '1' ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = null;
}
$staffCredits = collect($request->input('credits', []))
->filter(fn($item) => isset($item['name']) || isset($item['description']))
->map(function ($item) {
$name = trim($item['name'] ?? '');
$description = trim($item['description'] ?? '');
if ($name === '' && $description === '') {
return null;
}
return trim($name . ($name !== '' && $description !== '' ? ' — ' : '') . $description);
})
->filter()
->implode("\n");
$fields = [
'type' => $section,
'title' => $request->input('entry_title'),
'slug' => $request->input('slug') ?? Str::slug($request->input('entry_title', '')),
'description' => $request->input('description'),
'main_image' => $mainImage,
'state' => $request->input('submit-state', 'draft'),
'game_id' => $gameId,
'status_id' => $request->input('status'),
'version' => $request->input('version'),
'release_date' => $request->input('release-date'),
'staff_credits' => $staffCredits ?: null,
'relevant_link' => $request->input('release_site'),
'youtube_link' => $request->input('youtube_video'),
];
$entry->update($fields);
$entry->hashes()->delete();
foreach ( $request->input('hashes', []) as $hash ) {
if( !isset($hash['filename'], $hash['crc32'], $hash['sha1'], $hash['verified']) ) {
continue;
}
EntryHash::create([
'entry_id' => $entry->id,
'filename' => $hash['filename'],
'hash_crc32' => $hash['crc32'],
'hash_sha1' => $hash['sha1'],
'verified' => $hash['verified'],
]);
}
$authorIds = [];
foreach ( $request->input('authors', []) as $authorId ) {
$author = Author::find($authorId);
if( $author ) {
$authorIds[] = $author->id;
}
}
foreach( $request->input('new-authors', []) as $authorName ) {
$authorName = trim($authorName);
if ($authorName === '') continue;
$author = Author::firstOrCreate(
['slug' => Str::slug($authorName)],
['name' => $authorName],
);
$authorIds[] = $author->id;
}
$entry->authors()->sync(array_values(array_unique($authorIds)));
if( section_must_be( 'romhacks', $section ) ){
$entry->modifications()->sync($request->input('modifications', []));
} else {
$entry->modifications()->sync([]);
}
$entry->languages()->sync($request->input('languages', []));
$existingFileUuids = $request->input('existing_file_ids', []);
if (!is_array($existingFileUuids)) {
$existingFileUuids = [];
}
$entry->files()->whereNotIn('file_uuid', $existingFileUuids)->delete();
foreach ( $request->input('file_ids', []) as $file_uuid ) {
$fileData = Cache::pull("uploaded_file_{$file_uuid}");
if( ! $fileData ) {
continue;
}
EntryFile::create([
'entry_id' => $entry->id,
'file_uuid' => $fileData['uuid'],
'filename' => $fileData['filename'],
'filepath' => $fileData['filepath'],
'favorite_server' => $fileData['favorite_server'],
'favorite_at' => \DateTimeImmutable::createFromTimestamp( $fileData['favorite_at'] ),
'filesize' => $fileData['filesize'],
'state' => 'public'
]);
}
$existingGalleryIds = $request->input('existing_gallery_ids', []);
if (!is_array($existingGalleryIds)) {
$existingGalleryIds = [];
}
$entry->gallery()->whereNotIn('id', $existingGalleryIds)->get()->each(function ($gallery) {
if ($gallery->image) {
Storage::disk('public')->delete($gallery->image);
}
$gallery->delete();
});
foreach ( $request->file('gallery', [] ) as $galleryFile ){
if( !$galleryFile->isValid() ){
continue;
}
$path = $galleryFile->store('entries/gallery/' . $entry->id, 'public');
EntryGallery::create([
'entry_id' => $entry->id,
'image' => $path
]);
}
return match( $entry->state ){
'published' => redirect()->route('entries.show', [ 'section' => $section, 'entry' => $entry->slug ])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ToolsController extends Controller
{
public function patcher()
{
$patches = [
'file' => 'ZELDA.ips',
'name' => "Meltin",
'description' => 'Blablabla',
'outputName' => 'Game...'
];
return view('tools.patcher', compact('patches'));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
public function XenForoNewPost(Request $request)
{
if( $request->header('XF-Webhook-Secret') !== env('WEBHOOK_SECRET') )
abort(403);
$threadId = $request->input('data.thread_id');
if( $threadId ){
$entry = Entry::where('comments_thread_id', $threadId)->first();
if( $entry ){
Cache::forget("entry_comments_{$entry->id}");
}
}
return response()->json(['success' => true]);
}
}

View File

@@ -24,7 +24,7 @@ class CheckXenForoPermissions
foreach ($permissions as $permissionStr) {
[$group, $permission] = explode('.', $permissionStr);
if( !\Auth::user()->can($group, $permission) )
if( !\Auth::user()->_can($group, $permission) )
return $this->deny($request, $permission);
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
class StoreDraftRequest extends StoreEntryRequest
{
public function rules(): array
{
$rules = parent::rules();
$rules['submit-state'] = 'required|string|in:draft';
$rules = array_map(function($rule){
if( is_array($rule) ){
return array_map( fn($r) => $r === 'required' ? 'nullable' : $r, $rule);
}
return preg_replace(
['/\brequired_without\S*/', '/required_with\S*/', '/\brequired\b/'],
['nullable', 'nullable', 'nullable'],
$rule
);
}, $rules );
return $rules;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Rules\PublicFileExists;
use App\Rules\XfUserExists;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
@@ -14,8 +15,11 @@ class StoreEntryRequest extends FormRequest
*/
public function authorize(): bool
{
// TODO: Change it by role.
return true;
$entry = $this->route('entry');
if( $entry )
return $this->user()->can('update', $entry);
return $this->user()->can('create', '\App\Models\Entry');
}
/**
@@ -46,12 +50,20 @@ class StoreEntryRequest extends FormRequest
*/
public function rules(): array
{
$isEdit = (bool) $this->route('entry');
$rules = [];
$section = $this->route('section');
$rules['files_uuid'] = 'array|required|min:1';
$rules['files_uuid.*'] = 'string';
if( $isEdit ){
$rules['files_state'] = 'array|required|min:1';
$rules['files_state.*'] = 'string|in:public,private,archived';
}
if( section_must_not_be( 'translations', $section ) ){
$rules['entry_title'] = "required|string|max:255";
} else {
@@ -75,7 +87,6 @@ class StoreEntryRequest extends FormRequest
$rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id';
}
$rules['hashes'] = 'array|required|min:1';
$rules['hashes.*.filename'] = 'required|string|max:512';
$rules['hashes.*.hash_crc32'] = 'required|string|max:512';
@@ -98,7 +109,27 @@ class StoreEntryRequest extends FormRequest
$rules['release_site'] = 'nullable|url|max:500';
$rules['youtube_video'] = 'nullable|url|max:500';
$rules['submit-state'] = 'required|string|in:draft,pending,published';
if( $isEdit ){
$ss = 'draft,pending,published';
if( \Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-hidden', $this->route('entry')) )
$ss .= ',hidden';
if(\Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-locked', $this->route('entry')) )
$ss .= ',locked';
$rules['submit-state'] = 'required|in:' . $ss;
} else {
if( $this->user()->can('skip-queue', '\App\Models\Entry') ){
$rules['submit-state'] = 'required|string|in:draft,pending,published';
} else {
$rules['submit-state'] = 'required|string|in:draft,pending';
}
}
if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){
$rules['staff_comment'] = 'nullable|string';
$rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ];
$rules['comments_thread_id'] = 'nullable|integer';
$rules['featured'] = 'nullable|boolean';
}
return $rules;
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Services\TemporaryFileService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
@@ -12,7 +13,7 @@ class TemporaryFileUploadRequest extends FormRequest
*/
public function authorize(): bool
{
return true;
return $this->user()->can('create', TemporaryFileService::class );
}
/**

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Models\Entry;
use App\Services\XenforoApiService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CreateXenForoCommentsThread implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
protected Entry $entry
)
{
//
}
/**
* Execute the job.
*/
public function handle(XenforoApiService $service): void
{
$service->createCommentsThread($this->entry);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Models\Entry;
use App\Services\XenforoApiService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class DeleteXenForoCommentsThread implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
protected int $threadId,
)
{
//
}
/**
* Execute the job.
*/
public function handle(XenforoApiService $service): void
{
$service->deleteThreadWithEntry($this->threadId);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Models\Entry;
use App\Services\XenforoApiService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RestoreXenForoCommentsThread implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
protected int $threadId,
)
{
//
}
/**
* Execute the job.
*/
public function handle(XenforoApiService $service): void
{
$service->restoreThreadWithEntry($this->threadId);
}
}

272
app/Livewire/Database.php Normal file
View File

@@ -0,0 +1,272 @@
<?php
namespace App\Livewire;
use App\Models\Author;
use App\Models\Entry;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Language;
use App\Models\Modification;
use App\Models\Platform;
use App\Models\Status;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Database extends Component
{
use WithPagination;
/**
* entry_title search
* @var string
*/
#[Url(as: 's',except: '')]
public string $search = '';
/**
* type filter.
* @var array
*/
#[Url(except:[])]
public array $types = [];
/**
* Games IDs filter.
* @var array
*/
#[Url(except:[])]
public array $games = [];
/**
* Current game search
* @var string
*/
public string $gameSearch = '';
/**
* Platform IDs filter.
* @var array
*/
#[Url(except:[])]
public array $platforms = [];
/**
* Genre IDs filter.
* @var array
*/
#[Url(except:[])]
public array $genres = [];
/**
* Status IDs filter.
* @var array
*/
#[Url(except:[])]
public array $statuses = [];
/**
* Authors IDs filter.
* @var array
*/
#[Url(except:[])]
public array $authors = [];
/**
* Authors mode and/or.
* @var string
*/
#[Url(except:'or')]
public string $authorsMode = 'or';
/**
* Current author search.
* @var string
*/
public string $authorSearch = '';
/**
* Languages IDs filter.
* @var array
*/
#[Url(except:[])]
public array $languages = [];
/**
* Languages mode and/or
* @var string
*/
#[Url(except:'or')]
public string $languagesMode = 'or';
/**
* Modifications IDs filter.
* @var array
*/
#[Url(except:[])]
public array $modifications = [];
/**
* Modifications mode and/or.
* @var string
*/
#[Url(except:'or')]
public string $modificationsMode = 'or';
/**
* Sort by field.
* @var string
*/
#[Url(as: 'sort',except: 'created_at')]
public string $sortBy = 'created_at';
/**
* asc/desc
* @var string
*/
#[Url(as: 'dir',except: 'desc')]
public string $sortDir = 'desc';
/**
* Translation of sort options key.
*/
public const array SORT_OPTIONS = [
'created_at' => 'Date added',
'release_date' => 'Release date',
'title' => 'Title'
];
/**
* Translation of entries key.
*/
public const array ENTRY_TYPES = [
'translations' => 'Translations',
'romhacks' => 'Romhacks',
'homebrew' => 'Homebrew',
'utilities' => 'Utilities',
'documents' => 'Documents',
'lua-scripts' => 'Lua Scripts',
'tutorials' => 'Tutorials',
];
public const int PAGINATION = 30;
public function updatedSearch(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedTypes(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedGames(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedPlatforms(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedGenres(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedStatuses(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedAuthors(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedAuthorsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedLanguages(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedLanguagesMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedModifications(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedModificationsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function clearFilters(): void
{
$this->reset([
'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode'
]);
$this->resetPage();
}
public function setSort(string $field): void
{
if( $this->sortBy === $field ) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDir = 'asc';
}
$this->resetPage();
$this->dispatch('filters-updated');
}
private function buildQuery()
{
$query = Entry::query()->published()->with([
'game.platform', 'game.genre', 'status', 'authors', 'languages'
]);
if( $this->search ) {
$query->where(function($q) {
$q->where('title', 'like', '%'.$this->search.'%');
$q->orWhere('complete_title', 'like', '%'.$this->search.'%');
});
}
if( $this->types ) {
$query->whereIn('type', $this->types);
}
if( $this->platforms ) {
$query->where(function($q) {
$q->whereIn('platform_id', $this->platforms)
->orWhereHas('game', fn($q2) => $q2->whereIn('platform_id', $this->platforms) );
});
}
if( $this->genres ) {
$query->where(function($q) {
$q->whereHas('game', fn($q2) => $q2->whereIn('genre_id', $this->genres) );
});
}
if( $this->games ){
$query->whereIn('game_id', $this->games);
}
if( $this->statuses ) {
$query->whereIn('status_id', $this->statuses);
}
if( $this->authors ) {
if( $this->authorsMode === 'and' ) {
foreach ( $this->authors as $authorId ) {
$query->whereHas('authors', fn($q) => $q->where('authors.id', $authorId));
}
} else {
$query->whereHas('authors', fn($q) => $q->whereIn('authors.id', $this->authors));
}
}
if( $this->languages ) {
if( $this->languagesMode === 'and' ) {
foreach ( $this->languages as $langId ) {
$query->whereHas('languages', fn($q) => $q->where('languages.id', $langId));
}
} else {
$query->whereHas('languages', fn($q) => $q->whereIn('languages.id', $this->languages));
}
}
if( $this->modifications ) {
if( $this->modificationsMode === 'and' ) {
foreach ( $this->modifications as $modificationId ) {
$query->whereHas('modifications', fn($q) => $q->where('modifications.id', $modificationId));
}
} else {
$query->whereHas('modifications', fn($q) => $q->whereIn('modifications.id', $this->modifications));
}
}
return $query->orderBy($this->sortBy, $this->sortDir);
}
public function render()
{
return view('livewire.database', [
'entries' => $this->buildQuery()->paginate(self::PAGINATION),
'allGames' => Game::orderBy('name')->get(),
'allPlatforms' => Platform::orderBy('name')->get(),
'allGenres' => Genre::orderBy('name')->get(),
'allStatuses' => Status::orderBy('name')->get(),
'allAuthors' => Author::orderBy('name')->get(),
'allLanguages' => Language::orderBy('name')->get(),
'allModifications' => Modification::orderBy('name')->get(),
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Livewire;
use App\Models\Entry;
use Livewire\Component;
class EntryFilesModal extends Component
{
public bool $open = false;
public ?int $entryId = null;
protected $listeners = [
'entryOpenFilesModal' => 'openModal'
];
public function openModal( int $entryId ): void
{
$this->entryId = $entryId;
$this->open = true;
$this->dispatch('modal:opened');
}
public function close(): void
{
$this->open = false;
$this->entryId = null;
}
public function render()
{
$files = $this->entryId ? Entry::find($this->entryId)?->files : collect();
return view('livewire.entry-files-modal', compact('files'));
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Helpers\HashesHelpers;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
@@ -50,12 +51,28 @@ class HashesUpload extends Component
*/
public function addHash(string $filename, string $crc32, string $sha1, ?string $verified = null): void
{
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified ?? "No-Intro" // TODO: Change it.
];
if( $verified !== null && $verified !== "No" )
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified
];
else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null )
$this->hashes[] = [
'filename' => $hash->filename,
'hash_crc32' => $hash->crc32,
'hash_sha1' => $hash->sha1,
'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id )
];
else
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => "No"
];
}
/**

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class XfUserSelector extends Component
{
private const int MIN_CHARS = 3;
public string $search = '';
public ?int $selected = null;
public ?string $selectedUsername = null;
public function mount( ?int $initialUserId = null ){
if( $initialUserId ){
$user = DB::connection('xenforo')->table('user')->where('user_id', $initialUserId)->first();
if( $user ){
$this->selected = $user->user_id;
$this->selectedUsername = $user->username;
$this->search = $user->username;
}
}
}
public function updatedSearch(): void
{
if( $this->selected && $this->search !== $this->selectedUsername) {
$this->selected = null;
$this->selectedUsername = null;
}
}
public function selectUser( int $userId, string $username ): void
{
$this->selected = $userId;
$this->selectedUsername = $username;
$this->search = $username;
}
public function getResultsProperty(): array
{
if( strlen($this->search) < self::MIN_CHARS || $this->selected ) {
return [];
}
return DB::connection('xenforo')
->table('user')
->where('username', 'like', '%' . $this->search . '%')
->orderBy('username')
->limit(10)
->get(['user_id','username'])
->toArray();
}
public function render()
{
return view('livewire.xf-user-selector');
}
}

View File

@@ -2,11 +2,29 @@
namespace App\Models;
use App\Auth\XenForoUser;
use App\Services\XenforoService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Author extends Model
{
protected $fillable = [
'name', 'slug', 'user_id', 'website'
];
public function entries(): BelongsToMany
{
return $this->belongsToMany(Entry::class, 'entry_authors');
}
public function user(): ?XenForoUser
{
if( !$this->user_id )
return null;
return app(XenforoService::class)->getXfUser($this->user_id);
}
}

View File

@@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Entry extends Model
{
use SoftDeletes;
/**
* @var string[]
*/
@@ -32,6 +35,9 @@ class Entry extends Model
'youtube_link',
'user_id',
'complete_title',
'comments_thread_id',
'staff_comment',
'rejected_at',
];
/**
@@ -40,12 +46,21 @@ class Entry extends Model
protected $casts = [
'featured' => 'boolean',
'release_date' => 'date',
'rejected_at' => 'datetime',
];
public function scopePublished( Builder $query ): Builder {
return $query->where( 'state', 'published' );
}
public function scopeInQueue( Builder $query, int $daysRejected = 7 ): Builder {
return $query->withTrashed()->where(function($q) use($daysRejected) {
$q->where('state', 'pending')->whereNull('deleted_at');
})->orWhere(function($q) use($daysRejected) {
$q->where('state', 'rejected')->whereNotNull('rejected_at')->where('rejected_at', '>=', now()->subDays($daysRejected) );
});
}
/**
* Return game link.
* @return BelongsTo
@@ -90,4 +105,18 @@ class Entry extends Model
return $this->hasMany(EntryHash::class);
}
public function parseStaffCredits(): array {
return json_decode( $this->staff_credits ?? "", true );
}
public function getYoutubeVideoId(): ?string {
if( !$this->youtube_link )
return null;
$pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i';
preg_match($pattern, $this->youtube_link, $matches);
return $matches[1] ?? null;
}
}

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntryFile extends Model
{
protected $fillable = [
'entry_id', 'filename', 'filepath', 'favorite_server', 'favorite_at', 'filesize', 'state', 'file_uuid'
'entry_id', 'filename', 'filepath', 'favorite_server', 'favorite_at', 'filesize', 'state', 'file_uuid', 'online_patcher', 'secondary_online_patcher',
];
protected $casts = [

View File

@@ -21,4 +21,9 @@ class Game extends Model
{
return $this->belongsTo(Genre::class);
}
public function entries(): Game|\Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Entry::class);
}
}

View File

@@ -3,8 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Genre extends Model
{
protected $fillable = [ 'name', 'slug' ];
public function games(): HasMany
{
return $this->hasMany(Game::class);
}
}

View File

@@ -3,8 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Language extends Model
{
protected $fillable = [ 'name', 'slug' ];
public function entries(): BelongsToMany
{
return $this->belongsToMany(Entry::class, 'entry_languages');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Platform extends Model
{
@@ -14,4 +15,13 @@ class Platform extends Model
'name', 'slug', 'short_name'
];
public function games(): HasMany
{
return $this->hasMany(Game::class);
}
public function entries(): HasMany
{
return $this->hasMany(Entry::class);
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Policies;
use App\Models\Entry;
use App\Auth\XenForoUser as User;
use Illuminate\Auth\Access\Response;
class EntryPolicy
{
public function viewAny(User $user): bool
{
if( $user->_can( 'romhackplaza', 'view' ) )
return true;
return false;
}
public function viewPending(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canModerateEntries' );
}
public function viewDraft(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' );
}
public function viewRejected(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' );
}
public function viewHidden(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canSeeHiddenEntries' );
}
public function viewLocked(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can('romhackplaza', 'canSeeLockedEntries' );
}
public function create(User $user, ?Entry $entry = null ): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntry' );
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Entry $entry): bool
{
if( $entry->state === 'published' ){
// Staff editors
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
return false;
} else if( $entry->state === 'pending' ){
// Staff moderation.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'draft' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'rejected' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'locked' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) )
return true;
return false;
} else if( $entry->state === 'hidden' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) )
return true;
return false;
}
return false;
}
public function skipQueue(User $user, ?Entry $entry = null): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' );
}
public function updateComment(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function manageButtonsInQueue(User $user, Entry $entry): bool
{
if( $entry->state === 'rejected' ){
return $this->viewRejected( $user, $entry );
}
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function approve(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function reject(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function moderate(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Policies;
use App\Auth\XenForoUser as User;
class TempFilePolicy
{
public function create(User $user)
{
return $user->_can( 'romhackplaza', 'canSubmitTempFile' );
}
}

View File

@@ -3,6 +3,10 @@
namespace App\Providers;
use App\Auth\XenForoGuard;
use App\Auth\XenForoUser;
use App\Policies\TempFilePolicy;
use App\Services\TemporaryFileService;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -20,8 +24,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
\Auth::extend('xenforo', function ($app, $name, array $config) {
return new XenForoGuard($app['request']);
});
\Gate::policy(TemporaryFileService::class, TempFilePolicy::class );
Gate::define('is-admin', function (XenForoUser $user) {
return $user->is_admin === 1;
});
Gate::define('is-mod', function (XenForoUser $user) {
return $user->is_moderator === 1;
});
}
}

View File

@@ -27,7 +27,7 @@ class ManagePanelProvider extends PanelProvider
->default()
->id('manage')
->path('manage')
->login()
->authGuard('xenforo')
->colors([
'primary' => Color::Amber,
])

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Translation\PotentiallyTranslatedString;
class XfUserExists implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$user = DB::connection('xenforo')->table('user')->where('user_id', $value)->first();
if( !$user ){
$fail("The user ID {$value} does not exist.");
}
}
}

View File

@@ -111,7 +111,7 @@ class FileServersService {
'current_chunk' => $currentChunk,
'total_chunks' => $totalChunks,
// TODO : Must replace User ID
'zeus' => $this->generateZeusToken( 0, $server['base_url'], "Uploadchunk" ),
'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ),
]);
if (!$response->successful()) {

View File

@@ -4,7 +4,9 @@ namespace App\Services;
use App\Exceptions\SubmissionException;
use App\Helpers\EntryHelpers;
use App\Helpers\XenForoHelpers;
use App\Http\Requests\StoreEntryRequest;
use App\Jobs\CreateXenForoCommentsThread;
use App\Models\Author;
use App\Models\Entry;
use App\Models\EntryFile;
@@ -15,6 +17,7 @@ use App\Models\Genre;
use App\Models\Language;
use App\Models\Modification;
use App\Models\Platform;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -26,9 +29,9 @@ class SubmissionsService {
/**
* Request for store/edit.
* @var StoreEntryRequest|null
* @var Request|null
*/
private ?StoreEntryRequest $request = null;
private ?Request $request = null;
/**
* Section for store/edit.
@@ -36,6 +39,12 @@ class SubmissionsService {
*/
private ?string $section = null;
/**
* Entry for edit.
* @var Entry|null
*/
private ?Entry $entry = null;
/**
* @return list<FSFileData>
*/
@@ -63,7 +72,10 @@ class SubmissionsService {
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
'uuid' => $uuid,
'state' => $file->state,
'meta_online_patcher' => $file->online_patcher,
'meta_secondary_online_patcher' => $file->secondary_online_patcher,
];
$file = Cache::get("uploaded_file_{$uuid}");
@@ -76,7 +88,10 @@ class SubmissionsService {
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
'uuid' => $uuid,
'state' => $file['state'],
'meta_online_patcher' => false,
'meta_secondary_online_patcher' => false,
];
return null;
@@ -92,13 +107,13 @@ class SubmissionsService {
* @throws SubmissionException
* @throws \Throwable
*/
public function storeEntry( StoreEntryRequest $request, string $section ){
public function storeEntry( Request $request, string $section ){
// STEP 1 : Prepare basic fields.
$this->request = $request;
$this->section = $section;
$user_id = 0; // TODO: Replace that.
$user_id = \Auth::user()->user_id;
$entry = DB::transaction(function () use ( $user_id ) {
@@ -174,22 +189,42 @@ class SubmissionsService {
$this->Step12b_MoveMainImage( $entry );
$this->Step12c_SaveGalleryImages( $entry );
// Step 13: Try to create the comments section.
$this->Step13_CreateCommentsThread( $entry );
// Step 14: Refresh XF count.
if( $entry->state !== 'draft')
XenForoHelpers::updateEntriesCount( $entry->user_id );
return $entry;
}
/**
* @return int
* @return null|int
*
* @throws SubmissionException
*/
private function Step2_CreateAndReturnGameId(): int {
private function Step2_CreateAndReturnGameId(): ?int {
// Already existing game.
if( $this->request->input('game_id') )
return $this->request->input('game_id');
// No fields like a draft.
if( !$this->request->input('new-game-title') &&
!$this->request->input('new-game-platform') &&
!$this->request->input('new-game-genre') )
return null;
// Need to create a game.
$game = $this->createGameFromFormFields();
return $game->id;
}
private function createGameFromFormFields(): Game
{
if( !$this->request->input('new-game-title') || !$this->request->input('new-game-platform') || !$this->request->input('new-game-genre') )
throw new SubmissionException( "New game informations is missing" );
@@ -201,14 +236,12 @@ class SubmissionsService {
$gameSlug = EntryHelpers::uniqueSlug( $this->request->input('new-game-title'), Game::class );
$game = Game::create([
return Game::create([
'name' => trim( $this->request->input('new-game-title') ),
'slug' => $gameSlug,
'platform_id' => $platform->id,
'genre_id' => $genre->id,
]);
return $game->id;
}
/**
@@ -223,14 +256,14 @@ class SubmissionsService {
$fields['entry_title'] = $this->request->input('entry_title') ?? null;
if( section_must_be( [ 'homebrew', 'translations' ], $this->section ) && $gameId ){
$fields['game_name'] = Game::find( $gameId )->name;
$fields['game_name'] = $gameId ? Game::find( $gameId )->name : null;
}
if( section_must_be( 'translations', $this->section ) ) {
$fields['languages_string'] = Language::whereIn('id', $this->request->input('languages', []))->pluck('name')->implode(', ');
}
if( section_must_be(['romhacks', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) {
if( section_must_be(['romhacks', 'translations', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) {
// TODO: Add single platform ID compatibility.
$fields['platform_name'] = Game::find( $gameId )->platform->name;
$fields['platform_name'] = $gameId ? Game::find( $gameId )->platform->name : null;
}
return EntryHelpers::buildCompleteTitle( $this->section, $fields );
@@ -242,12 +275,15 @@ class SubmissionsService {
* @return void
* @throws SubmissionException
*/
private function Step7_SaveEntryFiles( int $entryId ): void
private function Step7_SaveEntryFiles( int $entryId, ?array $uuidData = null ): void
{
foreach ( $this->request->input('files_uuid', [] ) as $uuid ) {
if( !$uuidData )
$uuidData = $this->request->input('files_uuid', [] );
foreach ( $uuidData ?? [] as $uuid ) {
$fileData = Cache::pull("uploaded_file_{$uuid}");
if( !$fileData )
throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry." );
throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all your new files and retry." );
EntryFile::create([
'entry_id' => $entryId,
@@ -270,7 +306,7 @@ class SubmissionsService {
*/
private function Step8_SaveHashes( int $entryId ): void
{
foreach ( $this->request->input('hashes', [] ) as $hash ) {
foreach ( $this->request->input('hashes', [] ) ?? [] as $hash ) {
if( !isset($hash['filename'], $hash['hash_crc32'], $hash['hash_sha1'], $hash['verified']) ) {
continue;
}
@@ -293,8 +329,10 @@ class SubmissionsService {
*/
private function Step9_SaveAuthors( Entry $entry ): void
{
// TODO: Code fragment to be replaced by edit version.
// Existing authors.
foreach ( $this->request->input('authors', [] ) as $authorId ) {
foreach ( $this->request->input('authors', [] ) ?? [] as $authorId ) {
$author = Author::find( $authorId );
if( !$author )
throw new SubmissionException( "Author {$authorId} does not exist." );
@@ -302,7 +340,7 @@ class SubmissionsService {
}
// New Authors
foreach ( $this->request->input('new-authors', [] ) as $authorName ) {
foreach ( $this->request->input('new-authors', [] ) ?? [] as $authorName ) {
$authorName = trim( $authorName );
if( $authorName === '' )
continue;
@@ -323,7 +361,10 @@ class SubmissionsService {
*/
private function Step10_SaveRomhacksModifications( Entry $entry ): void
{
foreach ( $this->request->input('modifications', [] ) as $modificationId ) {
// TODO: Replace by edit version
foreach ( $this->request->input('modifications', [] ) ?? [] as $modificationId ) {
$modification = Modification::find( $modificationId );
if( !$modification )
throw new SubmissionException( "Modification {$modificationId} does not exist." );
@@ -339,7 +380,9 @@ class SubmissionsService {
*/
private function Step11_SaveLanguages( Entry $entry ): void
{
foreach ( $this->request->input('languages', [] ) as $languageId ) {
// TODO: Replace by edit version.
foreach ( $this->request->input('languages', [] ) ?? [] as $languageId ) {
$language = Language::find( $languageId );
if( !$language )
throw new SubmissionException( "Language {$languageId} does not exist." );
@@ -350,7 +393,7 @@ class SubmissionsService {
private function Step12a_PrepareGalleryImages( Entry $entry ): void
{
foreach ( $this->request->input('gallery', [] ) as $imagePath ) {
foreach ( $this->request->input('gallery', [] ) ?? [] as $imagePath ) {
EntryGallery::create([
'entry_id' => $entry->id,
'image' => $imagePath,
@@ -365,6 +408,10 @@ class SubmissionsService {
*/
private function Step12b_MoveMainImage( Entry $entry ): void {
$mainImage = $entry->main_image;
if( !$mainImage )
return;
$newPath = 'entries/main-images/' . basename($mainImage);
if( !Storage::disk('public')->move($mainImage, $newPath) )
@@ -375,7 +422,7 @@ class SubmissionsService {
private function Step12c_SaveGalleryImages( Entry $entry ): void
{
foreach ( $entry->gallery as $galleryItem ) {
foreach ( $entry->gallery ?? [] as $galleryItem ) {
$newPath = 'entries/gallery-images/' . $entry->id . '/' . basename($galleryItem->image);
if( !Storage::disk('public')->move($galleryItem->image, $newPath) )
@@ -385,4 +432,367 @@ class SubmissionsService {
}
}
public function editEntry(Request $request, string $section, Entry $entry ): Entry
{
// STEP 1: Prepare basic fields and keep in save some others fields.
$this->request = $request;
$this->section = $section;
$this->entry = $entry;
if( \Auth::user()->can('moderate', $entry) ){
$user_id = $this->request->input('owner_user_id');
$oldUserId = $this->entry->user_id;
} else {
$user_id = \Auth::user()->user_id;
$oldUserId = null;
}
$oldMainImage = $entry->main_image;
$galleryPaths = [];
$entry = DB::transaction( function() use ( $user_id, &$galleryPaths ){
// STEP 2: Create game if different.
$gameId = null;
if( section_must_be( ['romhacks', 'translations' ], $this->section ) ){
$gameId = $this->eStep2_VerifyCreateAndEditGameId();
}
// STEP 3: Recreate complete title and refresh slug if needed.
$completeTitle = $this->Step3_BuildCompleteTitle( $gameId );
if( $completeTitle !== $this->entry->complete_title ) {
$this->entry->complete_title = $completeTitle;
$this->entry->slug = EntryHelpers::uniqueSlug( $completeTitle, Entry::class, $this->entry->id );
}
// STEP 4: Regenerate entry title.
if( section_must_be( 'translations', $this->section ) &&
!$this->request->input('entry_title') ){
$this->entry->title = Game::find($gameId)->name;
} else {
$this->entry->title = $this->request->input('entry_title');
}
// STEP 5: Update entry fields.
$fields = [
'type' => $this->section,
'title' => $this->entry->title, // Useless, I know.
'slug' => $this->entry->slug,
'description' => $this->request->input('description'),
'main_image' => $this->request->input('main-image'),
'state' => $this->request->input('submit-state'),
'game_id' => $gameId,
'status_id' => $this->request->input('status'),
'version' => $this->request->input('version'),
'release_date' => $this->request->input('release-date'),
'staff_credits' => $this->request->input('staff_credits'),
'relevant_link' => $this->request->input('release_site'),
'youtube_link' => $this->request->input('youtube_video'),
'user_id' => $user_id,
'complete_title' => $completeTitle,
'comments_thread_id' => $this->request->input('comments_thread_id'),
'featured' => $this->request->input('featured'),
];
if( \Auth::user()->can('moderate', $this->entry) ){
$fields['staff_comment'] = $this->request->input('staff_comment');
}
$this->entry->update( $fields );
// STEP 6: Update entry files.
$this->eStep6_UpdateEntryFiles( $this->entry->id );
// STEP 7: Update hashes.
$this->eStep7_UpdateHashes( $this->entry->id );
// STEP 8: Update Authors.
$this->eStep8_UpdateAuthors();
// STEP 9: Update romhacks modifications.
if( section_must_be( 'romhacks', $this->section ) ) {
$this->eStep9_UpdateRomhacksModifications();
}
// STEP 10: Update Languages.
$this->eStep10_UpdateLanguages();
// STEP 11: Prepare new gallery images and prepare deletion of others ones.
$galleryPaths = $this->eStep11a_UpdateGalleryImages();
return $this->entry;
});
// STEP 11 : Update main image if needed.
$this->eStep11b_UpdateMainImage( $oldMainImage );
// STEP 11 : Update gallery storage.
$this->eStep11c_UpdateGalleryImages( $galleryPaths );
// STEP 12: Refresh XF count.
if( $entry->state !== 'draft' ) {
if ($oldUserId)
XenForoHelpers::updateEntriesCount($oldUserId);
XenForoHelpers::updateEntriesCount($entry->user_id);
}
// STEP 13: Try to create comments area if it doesn't exist.
$this->Step13_CreateCommentsThread( $this->entry );
return $entry;
}
/**
* @throws SubmissionException
*/
private function eStep2_VerifyCreateAndEditGameId(): int
{
// Already existing game.
if( $this->request->input('game_id') ){
if( $this->entry->game_id == $this->request->input('game_id') ){
return $this->entry->game_id; // No changes.
} else { // Change in game but already exist.
$game = Game::find( $this->request->input('game_id') );
if( !$game )
throw new SubmissionException( "Game {$this->request->input('game_id')} does not exist." );
$this->entry->game_id = $game->id;
return $this->entry->game_id;
}
}
// In draft.
if( !$this->request->input('new-game-title') &&
!$this->request->input('new-game-platform') &&
!$this->request->input('new-game-genre') )
return $this->entry->game_id;
// Need to create a game.
$game = $this->createGameFromFormFields();
$this->entry->game_id = $game->id;
return $this->entry->game_id;
}
/**
* @throws SubmissionException
*/
private function eStep6_UpdateEntryFiles(int $entryId ): void
{
$requestUuids = $this->request->input('files_uuid', []) ?? [];
$requestStates = $this->request->input('files_state', []) ?? [];
$existingUuids = EntryFile::where( 'entry_id', $entryId )->pluck('file_uuid')->toArray();
$needDeletion = array_diff( $existingUuids, $requestUuids );
if( !empty( $needDeletion ) ){
EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->delete();
}
$needAddition = array_diff( $requestUuids, $existingUuids );
if( !empty( $needAddition ) ){
$this->Step7_SaveEntryFiles( $this->entry->id, $needAddition ); // Same code.
}
$stateMap = array_combine( $requestUuids, $requestStates );
foreach( $stateMap as $uuid => $state ){
EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->update(['state' => $state]);
}
}
private function eStep7_UpdateHashes(int $entryId): void
{
$requestHashes = collect( $this->request->input('hashes', [] ) )
->filter( fn($h) => isset( $h['filename'], $h['hash_crc32'], $h['hash_sha1'], $h['verified'] ) )
->keyBy( 'hash_sha1' )
->toArray();
;
$existingHashes = EntryHash::where( 'entry_id', $entryId )->get()->keyBy( 'hash_sha1' );
$hashsToDelete = array_diff( $existingHashes->keys()->toArray(), array_keys( $requestHashes ) );
if( !empty( $hashsToDelete ) ){
EntryHash::where( 'entry_id', $entryId )->whereIn('hash_sha1', $hashsToDelete)->delete();
}
foreach( $requestHashes as $sha1 => $hash ){
if( $existingHashes->has( $sha1 ) ){
$existingHashes->get( $sha1 )->update([
'filename' => $hash['filename'],
'hash_crc32' => $hash['hash_crc32'],
'hash_sha1' => $hash['hash_sha1'],
'verified' => $hash['verified'],
]);
} else {
EntryHash::create([
'entry_id' => $entryId,
'filename' => $hash['filename'],
'hash_crc32' => $hash['hash_crc32'],
'hash_sha1' => $hash['hash_sha1'],
'verified' => $hash['verified'],
]);
}
}
}
/**
* @return void
* @throws SubmissionException
*/
private function eStep8_UpdateAuthors(): void
{
$syncAuthorsId = [];
$requestAuthorsId = $this->request->input('authors', [] ) ?? [];
if( !empty( $requestAuthorsId ) ){
$valid = Author::whereIn( 'id', $requestAuthorsId )->pluck('id')->toArray();
if( count( $valid ) !== count( $requestAuthorsId ) ){
throw new SubmissionException( "One of the authors doesn't exist." );
}
$syncAuthorsId = array_merge( $syncAuthorsId, $requestAuthorsId );
}
foreach ( $this->request->input('new-authors', [] ) ?? [] as $authorName ) {
$authorName = trim($authorName);
if ($authorName === '')
continue;
$author = Author::firstOrCreate(
['slug' => EntryHelpers::uniqueSlug($authorName, Author::class)],
['name' => $authorName]
);
$syncAuthorsId[] = $author->id;
}
$this->entry->authors()->sync( $syncAuthorsId );
}
/**
* @return void
* @throws SubmissionException
*/
private function eStep9_UpdateRomhacksModifications(): void
{
$requestModifications = $this->request->input('modifications', [] ) ?? [];
if( !empty( $requestModifications ) ){
$valid = Modification::whereIn( 'id', $requestModifications )->pluck('id')->toArray();
if( count( $valid ) !== count( $requestModifications ) ){
throw new SubmissionException( "One of the modifications doesn't exist." );
}
}
$this->entry->modifications()->sync( $requestModifications );
}
/**
* @return void
* @throws SubmissionException
*/
private function eStep10_UpdateLanguages(): void
{
$requestLanguages = $this->request->input('languages', [] ) ?? [];
if( !empty( $requestLanguages ) ){
$valid = Language::whereIn( 'id', $requestLanguages )->pluck('id')->toArray();
if( count( $valid ) !== count( $requestLanguages ) ){
throw new SubmissionException( "One of the languages doesn't exist." );
}
}
$this->entry->languages()->sync( $requestLanguages );
}
private function eStep11a_UpdateGalleryImages(): array
{
$requestGallery = $this->request->input('gallery', [] ) ?? [];
$existingGalleryPaths = $this->entry->gallery->pluck('image')->toArray();
$needDeletion = array_diff( $existingGalleryPaths, $requestGallery );
if( !empty( $needDeletion ) ){
EntryGallery::where('entry_id', $this->entry->id)->whereIn('image', $needDeletion )->delete();
}
$needAddition = array_diff( $requestGallery, $existingGalleryPaths );
$images = [];
foreach( $needAddition as $imagePath ){
$images[] = EntryGallery::create([
'entry_id' => $this->entry->id,
'image' => $imagePath,
]);
}
return [ 'addition' => $images, 'deletion' => $needDeletion ];
}
private function eStep11b_UpdateMainImage( ?string $oldMainImagePath ): void
{
$currentMainImagePath = $this->entry->main_image;
if( $currentMainImagePath === $oldMainImagePath )
return;
if( !$currentMainImagePath ) {
if( $oldMainImagePath && Storage::disk('public')->exists($oldMainImagePath) )
Storage::disk('public')->delete($oldMainImagePath);
return;
}
$newPath = 'entries/main-images/' . basename( $currentMainImagePath );
if( !Storage::disk('public')->move( $currentMainImagePath, $newPath ) ){
$this->entry->update(['main_image' => $oldMainImagePath]);
return;
}
$this->entry->update(['main_image' => $newPath]);
if( $oldMainImagePath && Storage::disk('public')->exists($oldMainImagePath) )
Storage::disk('public')->delete($oldMainImagePath);
}
private function eStep11c_UpdateGalleryImages( array $pathsChanges ): void
{
foreach ( $pathsChanges['deletion'] as $deletePath ){
if( Storage::disk('public')->exists($deletePath) )
Storage::disk('public')->delete($deletePath);
}
foreach ( $pathsChanges['addition'] as $galleryItem ){
$newPath = 'entries/gallery-images/' . $this->entry->id . '/' . basename( $galleryItem->image );
if( !Storage::disk('public')->move( $galleryItem->image, $newPath ) ){
continue;
}
$galleryItem->update(['image' => $newPath]);
}
}
private function Step13_CreateCommentsThread( Entry $entry ): void
{
if( $entry->state !== 'published' )
return;
if( !$entry->comments_thread_id )
CreateXenForoCommentsThread::dispatch( $entry );
// app(XenforoApiService::class)->createCommentsThread( $entry );
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Services;
use App\Models\Entry;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class XenforoApiService {
private string $apiKey;
private int $superUserId;
private string $apiUrl;
public function __construct(){
$this->apiKey = config('services.xf_api.key');
$this->superUserId = config('services.xf_api.user');
$this->apiUrl = config('services.xf_api.url');
}
/**
* @throws ConnectionException
*/
private function get(string $endpoint, ?int $customUserId = null ): mixed
{
$response = Http::withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->get("{$this->apiUrl}/{$endpoint}");
if( !$response->ok() )
return null;
return $response->json();
}
private function post(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed
{
$response = Http::withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->post("{$this->apiUrl}/{$endpoint}", $data);
if( !$response->ok() )
return null;
return $response->json();
}
private function delete(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed
{
$response = Http::withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->delete("{$this->apiUrl}/{$endpoint}", $data);
if( !$response->ok() )
return null;
return $response->json();
}
public function getUserAlerts(int $userId): mixed
{
if( app(XenforoService::class)->getXfUser($userId)?->alerts_unviewed > 0 ) {
Cache::forget("xf_alerts_{$userId}");
}
return Cache::remember("xf_alerts_{$userId}", 60, function() use($userId) {
return $this->get("alerts?page=1&cutoff=7days", $userId);
});
}
public function markAllNotificationsRead(int $userId): void
{
Cache::forget("xf_alerts_{$userId}");
$this->post("alerts/marl-all", $userId );
}
public function getConversations(int $userId): mixed
{
return Cache::remember("xf_conversations_{$userId}", 60, function() use($userId) {
return $this->get("conversations?page=1&receiver_id={$userId}", $userId);
});
}
public function createConversation( array $userIdList, string $title, string $message, bool $conversationOpen, bool $openInvite ): bool
{
$response = $this->post("conversations", data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen] );
return $response['success'] ?? false;
}
public function createCommentsThread( Entry $entry ): bool
{
if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){
$data = [
'node_id' => config('xenforo.comments_node_id'),
'title' => $entry->complete_title,
'message' => $entry->description,
'prefix_id' => config('xenforo.comments_prefixes')[$entry->type] ?? 1,
'custom_fields' => [ 'entry_id' => $entry->id ],
'discussion_open' => true,
];
// TODO: Flag must be removed.
$response = $this->post("threads?api_bypass_permissions=true", config('xenforo.bot_user_id'), $data );
if( $response['success'] === true ){
$commentsThreadId = $response['thread']['thread_id'];
$entry->update(['comments_thread_id' => $commentsThreadId]);
return true;
}
}
return false;
}
/**
* @throws ConnectionException
*/
public function getThreadPosts(int $threadId, int $page = 1 ): array
{
$response = $this->get("threads/{$threadId}/posts?page=$page");
if( !isset( $response['posts'] ) || $response['posts'] === [] )
return [ 'posts' => [], 'pagination' => null ];
return $response;
}
public function updateEntriesCount(int $entryCount, int $userId): bool
{
$response = $this->post("romhackplaza_entry/update_entry_count", data: ['count' => $entryCount, 'user_id' => $userId] );
return $response['success'] ?? false;
}
public function deleteThreadWithEntry(int $threadId): bool
{
return (bool) $this->delete( "threads/{$threadId}", data: ['reason' => "Deletion with entry." ] );
}
public function restoreThreadWithEntry(int $threadId): bool
{
return (bool) $this->post("threads/{$threadId}/undelete" );
}
}

View File

@@ -2,13 +2,65 @@
namespace App\Services;
use App\Auth\XenForoUser;
use App\XenForoDataTypes\XenForoUserGroup;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;
class XenforoService {
private const array PERMISSIONS_KEPT = [ 'general', 'romhackplaza' ];
private const int TTL_PERMISSIONS = 300;
private const int TTL_ROUTES = 86400;
/**
* Get specific XenForo user.
*
* @param int $xfUserId
*
* @return XenForoUser|null
*/
public function getXfUser( int $xfUserId ): ?XenForoUser {
$xfUser = \DB::connection('xenforo')
->table('user')
->where('user_id', $xfUserId)
->first();
if(!$xfUser)
return null;
return new XenForoUser($xfUser);
}
/**
* Get specific XenForo user group.
*
* @param int $xfUserGroupId
*
* @return XenForoUserGroup|null
*/
public function getXfUserGroup( int $xfUserGroupId ): ?XenForoUserGroup {
$xfUserGroup = \DB::connection('xenforo')
->table('user_group')
->where('user_group_id', $xfUserGroupId)
->first();
if(!$xfUserGroup)
return null;
return new XenForoUserGroup($xfUserGroup);
}
/**
* Get permissions for a specific user ID.
*
* @param int $userId
* @param int $permissionCombinationId
*
* @return array
*/
public function getPermissions(int $userId, int $permissionCombinationId): array {
return Cache::remember("xf_permissions_{$userId}", self::TTL_PERMISSIONS, function() use($permissionCombinationId) {
@@ -29,8 +81,132 @@ class XenforoService {
}
/**
* Clear user data.
*
* @param int $userId
*
* @return void
*/
public function clearUserData(int $userId): void
{
Cache::forget("xf_permissions_{$userId}");
}
/**
*
*
* @param string $routeName <prefix>.<secname>
*
* @return string
*/
public function getRoute( string $routeName, array $arguments ): string {
$routes = Cache::remember("xf_routes", self::TTL_ROUTES, function(){
return \DB::connection('xenforo')
->table('route')
->where('route_type', 'public' )
->get(['route_prefix', 'sub_name', 'format'])
->map(fn($r) => (array) $r )
->toArray();
});
$baseUrl = config('app.forum_url');
try {
[$prefix, $subName] = explode('.', $routeName, 2);
$route = collect($routes)->first(function ($r) use ($prefix, $subName) {
return $r['route_prefix'] === $prefix && $r['sub_name'] === $subName;
});
if( !$route )
return $baseUrl . '/' . $prefix;
$path = $this->buildRoutePath((array)$route, $arguments);
return rtrim($baseUrl, '/') . '/' . $path;
} catch (\Throwable $th) {
$prefix = $routeName;
$route = collect($routes)->first(function ($r) use ($prefix) {
return $r['route_prefix'] === $prefix;
});
if( !$route )
return $baseUrl . '/' . $prefix;
$path = $this->buildRoutePath((array)$route, $arguments);
return rtrim($baseUrl, '/') . '/' . $path;
}
}
private function buildRoutePath(array $route, array $arguments): string {
$prefix = $route['route_prefix'];
$format = $route['format'];
$subName = $route['sub_name'];
if (!$format) {
return $subName
? $prefix . '-/' . $subName
: $prefix;
}
if (str_starts_with($format, '-/')) {
return $prefix . $format;
}
$built = preg_replace_callback(
'/:\+?(\w+)(?:_\w+)?(?:<([^>]+)>)?/',
function(array $m) use ($arguments): string {
$type = $m[1];
$keys = isset($m[2])
? explode(',', $m[2])
: [];
return match(true) {
$type === 'page' => isset($params['page']) && $params['page'] > 1
? 'page-' . $params['page']
: '',
$type === 'str_int' && count($keys) >= 2 => implode('.', array_filter([
$params[$keys[0]] ?? null,
$params[$keys[1]] ?? null,
])),
$type === 'int' && count($keys) >= 1 => (string) ($params[$keys[0]] ?? ''),
in_array($type, ['str', 'any']) && count($keys) >= 1
=> (string) ($params[$keys[0]] ?? ''),
default => isset($params[$type]) ? (string) $params[$type] : '',
};
},
$format
);
$built = preg_replace('/\/+/', '/', $prefix . '/' . $built);
$built = rtrim($built, '/');
return $built;
}
private function hashCSRFToken( string $token ): string
{
return hash_hmac('md5', $token . time(), config('app.xf_salt') );
}
public function getCSRFToken(): string
{
$token = Cookie::get('xf_csrf');
if( !$token ){
$token = Str::random(16);
Cookie::queue('xf_csrf', $token, 0, '/', config('session.domain'), 0, false, false );
}
return time() . ',' . $this->hashCSRFToken($token);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
trait ModCPSearch
{
protected function applySearch(Builder $query, array $columns = ['name'] ): Builder
{
$search = request('s', '');
if( !$search )
return $query;
return $query->where(function ($query) use ($columns, $search) {
foreach ($columns as $i => $column) {
$method = $i === 0 ? 'where' : 'orWhere';
$query->{$method}($column, 'LIKE', "%{$search}%");
}
});
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DatabaseFilterWithMode extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $title,
public $items,
public string $model,
public string $modeModel,
public string $selectedMode,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.database-filter-with-mode');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DatabaseFilterWithModeSearch extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $title,
public $items,
public string $model,
public string $modeModel,
public string $selectedMode,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.database-filter-with-mode-search');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DatabaseFilterWithoutMode extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $title,
public $items,
public string $model,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.database-filter-without-mode');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DatabaseFilterWithoutModeSearch extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $title,
public $items,
public string $model,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.database-filter-without-mode-search');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\View\Components;
use App\Models\Entry;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class EntryCard extends Component
{
/**
* Acronym for entry badge.
* TODO: Add in common.css other colors.
*/
public const array ENTRY_TYPES_BADGE = [
'translations' => "Trans",
'romhacks' => 'Hack',
'homebrew' => 'HBrew',
'utilities' => 'Util',
'documents' => 'Doc',
'lua-scripts' => 'Lua',
'tutorials' => 'Tuto'
];
/**
* Create a new component instance.
*/
public function __construct(
public Entry $entry,
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.entry-card');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ModCPSearch extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $placeholder = "Search...",
public string $param = 's'
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.modcp-search');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\View\Components;
use App\Models\Entry;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
@@ -12,20 +13,49 @@ class SubmitEntryStatus extends Component
* Create a new component instance.
*/
public function __construct(
public string $section
public string $section,
public bool $isEdit,
public ?string $currentState,
public null|string|Entry $entry = 'App\Models\Entry',
)
{
//
if( $this->entry === null )
$this->entry = 'App\Models\Entry';
}
public function availableStates(): array
{
// TODO: Change for permissions.
return [
'draft' => "Save into my drafts",
'pending' => "Add to submissions queue",
'published' => "Publish it now"
$labelPublished = $this->isEdit && $this->currentState === 'published' ? "Keep it published" : "Publish it now";
$labelPending = $this->isEdit && $this->currentState === 'pending' ? "Keep it to submissions queue" : "Add to submissions queue";
$labelDraft = $this->isEdit && $this->currentState === 'draft' ? "Keep it into my drafts" : "Save into my drafts";
$labelHidden = $this->isEdit && $this->currentState === 'hidden' ? "Keep it hidden" : "Hide this entry";
$labelLocked = $this->isEdit && $this->currentState === 'locked' ? "Keep it locked" : "Lock this entry";
$states = [
'draft' => $labelDraft,
'pending' => $labelPending,
];
if( $this->isEdit ) {
$isAuthor = $this->entry->user_id === \Auth::user()->user_id;
if( $isAuthor && ( \Auth::user()->can('skip-queue', $this->entry) || $this->currentState === 'published' ) )
$states['published'] = $labelPublished;
else if( \Auth::user()->can('moderate', $this->entry) )
$states['published'] = $labelPublished;
if( \Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-hidden', $this->entry) )
$states['hidden'] = $labelHidden;
if(\Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-locked', $this->entry) )
$states['locked'] = $labelLocked;
} else {
if( \Auth::user()->can('skip-queue', $this->entry ) ){
$states['published'] = $labelPublished;
}
}
return $states;
}
public function defaultState(): string

View File

@@ -3,6 +3,7 @@
namespace App\View\Components;
use App\Auth\XenForoUser;
use App\Services\XenforoService;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
@@ -13,11 +14,13 @@ class XenForoAvatar extends Component
* Create a new component instance.
*/
public function __construct(
public ?XenForoUser $user = null,
public null|int|XenForoUser $user = null,
)
{
if( $this->user === null )
$this->user = \Auth::user();
else if( is_int( $this->user ) )
$this->user = app(XenforoService::class)->getXfUser( $this->user );
}
/**

View File

@@ -0,0 +1,38 @@
<?php
namespace App\View\Components;
use App\Auth\XenForoUser;
use App\Services\XenforoService;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class XfUsernameLink extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public ?XenForoUser $user = null,
public ?int $userId = null
)
{
if( $this->user === null && $this->userId !== null ){
$this->user = app(XenforoService::class)->getXfUser($this->userId);
if( $this->user === null ){
$this->user = app(XenforoService::class)->getXfUser(config('xenforo.bot_user_id'));
}
}
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.xf-username-link');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\XenForoDataTypes;
use App\Services\XenforoService;
class XenForoData {
protected XenforoService $services;
public function __construct(public readonly object $data) {
$this->services = app(XenforoService::class);
}
public function __get(string $name): mixed {
return $this->data->$name ?? null;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\XenForoDataTypes;
class XenForoUserGroup extends XenForoData {
}

View File

@@ -24,3 +24,40 @@ if( !function_exists( 'section_must_not_be' ) ){
}
}
if( !function_exists( 'databaseRoute' ) ){
function databaseRoute( array $params = [] ): string
{
$defaults = [
'types' => [],
'platforms' => [],
'genres' => [],
'games' => [],
'statuses' => [],
'authors' => [],
'authorsMode' => 'or',
'languages' => [],
'languagesMode' => 'or',
'modifications' => [],
'modificationsMode' => 'or',
'sort' => 'created_at',
'dir' => 'desc',
's' => ''
];
$query = array_filter(
array_merge($defaults, $params),
fn($v,$k) => match(true){
is_array($v) => !empty($v),
in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode']) => $v !== 'or',
$k === 'sort' => $v !== 'created_at',
$k === 'dir' => $v !== 'desc',
default => $v !== '',
},
ARRAY_FILTER_USE_BOTH
);
return route('entries.index', $query );
}
}

19
app/xenforo.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
if( !function_exists( 'xfRoute' ) ){
function xfRoute( string $routeName, array $arguments = [] ): string {
return app(\App\Services\XenforoService::class)->getRoute( $routeName, $arguments );
}
}
if( !function_exists( 'xfCsrfToken') ){
function xfCsrfToken(): string {
return app(\App\Services\XenforoService::class)->getCSRFToken();
}
}
if( !function_exists( 'xfStyleVariationUrl' ) ){
function xfStyleVariationUrl( string $variation ): string {
return config('app.forum_url') . '/misc/style-variation?variation=' . $variation . "&t=" . xfCsrfToken();
}
}

View File

@@ -3,18 +3,24 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
api: __DIR__.'/../routes/api.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['xf_session','xf_user']);
$middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','theme','entries_per_page']);
$middleware->alias([
'xf.auth' => \App\Http\Middleware\CheckXenForoPermissions::class,
]);
$middleware->redirectGuestsTo(function(Request $request): void {
if( $request->is('manage*'))
abort(403);
});
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -12,7 +12,9 @@
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.3",
"predis/predis": "^3.4"
"predis/predis": "^3.4",
"ext-xmlreader": "*",
"ext-simplexml": "*"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^4.2",
@@ -28,7 +30,8 @@
},
"autoload": {
"files": [
"app/helpers.php"
"app/helpers.php",
"app/xenforo.php"
],
"psr-4": {
"App\\": "app/",

View File

@@ -106,6 +106,8 @@ return [
),
],
'xf_salt' => env('XF_GLOBAL_SALT'),
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver

View File

@@ -45,6 +45,7 @@ return [
],
'xenforo' => [
'driver' => 'xenforo',
'provider' => 'users',
]
],

View File

@@ -124,6 +124,13 @@ return [
'prefix' => 'xf_'
],
'hashes' => [
'driver' => 'sqlite',
'database' => storage_path('hashes.sqlite'),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
]
],
/*

105
config/menu.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
/**
* Categories : <id> => ['name', 'items']
* Items: ['name','icon','route' or 'xf_route']
*/
return [
'website' => [
'name' => 'Website',
'items' => [
[
'name' => 'Home',
'icon' => 'home',
'route' => 'home',
],
[
'name' => 'Database',
'icon' => 'database',
'route' => 'entries.index'
],
[
'name' => "Submissions queue",
'icon' => 'gavel',
'route' => 'queue.index'
],
[
'name' => "My Drafts",
'icon' => 'scissors',
'route' => 'entries.drafts',
'condition' => (fn() => !\Auth::guest())
]
]
],
'community' => [
'name' => 'Community',
'items' => [
[
'name' => 'Forum',
'icon' => 'message-circle',
'xf_route' => ''
],
[
'name' => 'Clubs',
'icon' => 'balloon',
'xf_route' => 'clubs'
],
[
'name' => 'Discord',
'icon' => 'messages-square',
'route' => 'home'
],
[
'name' => 'Members',
'icon' => 'users',
'xf_route' => 'members'
],
]
],
'tools' => [
'name' => 'Tools',
'items' => [
[
'name' => 'ROM Patcher',
'icon' => 'stamp',
'route' => 'home'
],
[
'name' => 'ROM Hasher',
'icon' => 'hash',
'route' => 'home'
],
[
'name' => 'ROM Checker',
'icon' => 'check',
'route' => 'home'
]
]
],
'pages' => [
'name' => 'Pages',
'items' => [
[
'name' => 'Learn Romhacking',
'icon' => 'graduation-cap',
'route' => 'home'
],
[
'name' => 'About',
'icon' => 'info',
'route' => 'home'
],
[
'name' => 'Contact Us',
'icon' => 'at-sign',
'xf_route' => 'misc/contact'
],
[
'name' => 'Legal pages',
'icon' => 'scale',
'route' => 'home'
]
]
]
];

View File

@@ -35,4 +35,10 @@ return [
],
],
'xf_api' => [
'user' => env('XF_API_USER'),
'key' => env('XF_API_KEY'),
'url' => env('XF_API_URL'),
]
];

18
config/xenforo.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
return [
'bot_user_id' => 1,
'comments_node_id' => 4,
'comments_prefixes' => [
'translations' => 1,
'romhacks' => 2,
'homebrew' => 3,
'utilities' => 4,
'documents' => 5,
'lua-scripts' => 6,
'tutorials' => 7,
'news' => 8
],
];

View File

@@ -24,7 +24,7 @@ return new class extends Migration
$table->string( 'main_image' )->nullable();
// TODO: Replace it by state.
$table->enum( 'status', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft');
$table->enum( 'state', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft');
$table->boolean('featured')->default(false);
// FK

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->softDeletes();
$table->text("staff_comment")->nullable()->after("state");
$table->timestamp("rejected_at")->nullable()->after("staff_comment");
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->dropSoftDeletes();
$table->dropColumn(['staff_comment', 'rejected_at']);
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE entries MODIFY state ENUM('draft','pending','published','locked','rejected','hidden') NOT NULL DEFAULT 'draft'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('entries', function (Blueprint $table) {
//
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->string('title')->nullable()->change();
$table->longText('description')->nullable()->change();
$table->string('slug')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entry_files', function (Blueprint $table) {
$table->boolean('online_patcher')->default(false);
$table->boolean('secondary_online_patcher')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('entry_files', function (Blueprint $table) {
$table->dropColumn('online_patcher');
$table->dropColumn('secondary_online_patcher');
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::connection('hashes')->create('dat_reference', function (Blueprint $table) {
$table->id();
$table->string('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::connection('hashes')->dropIfExists('dat_reference');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::connection('hashes')->create('hashes', function (Blueprint $table) {
$table->id();
$table->string('filename')->nullable();
$table->string('crc32')->nullable()->index();
$table->string('sha1')->nullable()->index();
$table->foreignId('dat_reference_id')->constrained('dat_reference')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::connection('hashes')->dropIfExists('hashes');
}
};

1508
extra.less

File diff suppressed because it is too large Load Diff

10
package-lock.json generated
View File

@@ -6,6 +6,7 @@
"": {
"dependencies": {
"easymde": "^2.21.0",
"js-cookie": "^3.0.7",
"lucide": "^1.14.0"
},
"devDependencies": {
@@ -971,6 +972,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-cookie": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",

View File

@@ -15,6 +15,7 @@
},
"dependencies": {
"easymde": "^2.21.0",
"js-cookie": "^3.0.7",
"lucide": "^1.14.0"
}
}

BIN
public/ZELDA.ips Normal file

Binary file not shown.

View File

@@ -0,0 +1,412 @@
/*
* Rom Patcher JS core
* A ROM patcher/builder made in JavaScript, can be implemented as a webapp or a Node.JS CLI tool
* By Marc Robledo https://www.marcrobledo.com
* Sourcecode: https://github.com/marcrobledo/RomPatcher.js
* License:
*
* MIT License
*
* Copyright (c) 2016-2025 Marc Robledo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const RomPatcher = (function () {
const TOO_BIG_ROM_SIZE = 67108863;
const HEADERS_INFO = [
{ extensions: ['nes'], size: 16, romSizeMultiple: 1024, name: 'iNES' }, /* https://www.nesdev.org/wiki/INES */
{ extensions: ['fds'], size: 16, romSizeMultiple: 65500, name: 'fwNES' }, /* https://www.nesdev.org/wiki/FDS_file_format */
{ extensions: ['lnx'], size: 64, romSizeMultiple: 1024, name: 'LNX' },
{ extensions: ['sfc', 'smc', 'swc', 'fig'], size: 512, romSizeMultiple: 262144, name: 'SNES copier' },
];
const GAME_BOY_NINTENDO_LOGO = [
0xce, 0xed, 0x66, 0x66, 0xcc, 0x0d, 0x00, 0x0b, 0x03, 0x73, 0x00, 0x83, 0x00, 0x0c, 0x00, 0x0d,
0x00, 0x08, 0x11, 0x1f, 0x88, 0x89, 0x00, 0x0e, 0xdc, 0xcc, 0x6e, 0xe6, 0xdd, 0xdd, 0xd9, 0x99
];
const _getRomSystem = function (binFile) {
/* to-do: add more systems */
const extension = binFile.getExtension().trim();
if (binFile.fileSize > 0x0200 && binFile.fileSize % 4 === 0) {
if ((extension === 'gb' || extension === 'gbc') && binFile.fileSize % 0x4000 === 0) {
binFile.seek(0x0104);
var valid = true;
for (var i = 0; i < GAME_BOY_NINTENDO_LOGO.length && valid; i++) {
if (GAME_BOY_NINTENDO_LOGO[i] !== binFile.readU8())
valid = false;
}
if (valid)
return 'gb';
} else if (extension === 'md' || extension === 'bin') {
binFile.seek(0x0100);
if (/SEGA (GENESIS|MEGA DR)/.test(binFile.readString(12)))
return 'smd';
} else if (extension === 'z64' && binFile.fileSize >= 0x400000) {
return 'n64'
}
} else if (extension === 'fds' && binFile.fileSize % 65500 === 0) {
return 'fds'
}
return null;
}
const _getRomAdditionalChecksum = function (binFile) {
/* to-do: add more systems */
const romSystem = _getRomSystem(binFile);
if (romSystem === 'n64') {
binFile.seek(0x3c);
const cartId = binFile.readString(3);
binFile.seek(0x10);
const crc = binFile.readBytes(8).reduce(function (hex, b) {
if (b < 16)
return hex + '0' + b.toString(16);
else
return hex + b.toString(16);
}, '');
return cartId + ' (' + crc + ')';
}
return null;
}
return {
parsePatchFile: function (patchFile) {
if (!(patchFile instanceof BinFile))
throw new Error('Patch file is not an instance of BinFile');
patchFile.littleEndian = false;
patchFile.seek(0);
var header = patchFile.readString(8);
var patch = null;
if (header.startsWith(IPS.MAGIC)) {
patch = IPS.fromFile(patchFile);
} else if (header.startsWith(UPS.MAGIC)) {
patch = UPS.fromFile(patchFile);
} else if (header.startsWith(APS.MAGIC)) {
patch = APS.fromFile(patchFile);
} else if (header.startsWith(APSGBA.MAGIC)) {
patch = APSGBA.fromFile(patchFile);
} else if (header.startsWith(BPS.MAGIC)) {
patch = BPS.fromFile(patchFile);
} else if (header.startsWith(RUP.MAGIC)) {
patch = RUP.fromFile(patchFile);
} else if (header.startsWith(PPF.MAGIC)) {
patch = PPF.fromFile(patchFile);
} else if (header.startsWith(BDF.MAGIC)) {
patch = BDF.fromFile(patchFile);
} else if (header.startsWith(PMSR.MAGIC)) {
patch = PMSR.fromFile(patchFile);
} else if (header.startsWith(VCDIFF.MAGIC)) {
patch = VCDIFF.fromFile(patchFile);
}
if (patch)
patch._originalPatchFile = patchFile;
return patch;
},
validateRom: function (romFile, patch, skipHeaderSize) {
if (!(romFile instanceof BinFile))
throw new Error('ROM file is not an instance of BinFile');
else if (typeof patch !== 'object')
throw new Error('Unknown patch format');
if (typeof skipHeaderSize !== 'number' || skipHeaderSize < 0)
skipHeaderSize = 0;
if (
typeof patch.validateSource === 'function' && !patch.validateSource(romFile, skipHeaderSize)
) {
return false;
}
return true;
},
applyPatch: function (romFile, patch, optionsParam) {
if (!(romFile instanceof BinFile))
throw new Error('ROM file is not an instance of BinFile');
else if (typeof patch !== 'object')
throw new Error('Unknown patch format');
const options = {
requireValidation: false,
removeHeader: false,
addHeader: false,
fixChecksum: false,
outputSuffix: true
};
if (typeof optionsParam === 'object') {
if (typeof optionsParam.requireValidation !== 'undefined')
options.requireValidation = !!optionsParam.requireValidation;
if (typeof optionsParam.removeHeader !== 'undefined')
options.removeHeader = !!optionsParam.removeHeader;
if (typeof optionsParam.addHeader !== 'undefined')
options.addHeader = !!optionsParam.addHeader;
if (typeof optionsParam.fixChecksum !== 'undefined')
options.fixChecksum = !!optionsParam.fixChecksum;
if (typeof optionsParam.outputSuffix !== 'undefined')
options.outputSuffix = !!optionsParam.outputSuffix;
}
var extractedHeader = false;
var fakeHeaderSize = 0;
if (options.removeHeader) {
const headerInfo = RomPatcher.isRomHeadered(romFile);
if (headerInfo) {
const splitData = RomPatcher.removeHeader(romFile);
extractedHeader = splitData.header;
romFile = splitData.rom;
}
} else if (options.addHeader) {
const headerInfo = RomPatcher.canRomGetHeader(romFile);
if (headerInfo) {
fakeHeaderSize = headerInfo.fileSize;
romFile = RomPatcher.addFakeHeader(romFile);
}
}
if (options.requireValidation && !RomPatcher.validateRom(romFile, patch)) {
throw new Error('Invalid input ROM checksum');
}
var patchedRom = patch.apply(romFile);
if (extractedHeader) {
/* reinsert header */
if (options.fixChecksum)
RomPatcher.fixRomHeaderChecksum(patchedRom);
const patchedRomWithHeader = new BinFile(extractedHeader.fileSize + patchedRom.fileSize);
patchedRomWithHeader.fileName = patchedRom.fileName;
patchedRomWithHeader.fileType = patchedRom.fileType;
extractedHeader.copyTo(patchedRomWithHeader, 0, extractedHeader.fileSize);
patchedRom.copyTo(patchedRomWithHeader, 0, patchedRom.fileSize, extractedHeader.fileSize);
patchedRom = patchedRomWithHeader;
} else if (fakeHeaderSize) {
/* remove fake header */
const patchedRomWithoutFakeHeader = patchedRom.slice(fakeHeaderSize);
if (options.fixChecksum)
RomPatcher.fixRomHeaderChecksum(patchedRomWithoutFakeHeader);
patchedRom = patchedRomWithoutFakeHeader;
} else if (options.fixChecksum) {
RomPatcher.fixRomHeaderChecksum(patchedRom);
}
if (options.outputSuffix) {
patchedRom.fileName = romFile.fileName.replace(/\.([^\.]*?)$/, ' (patched).$1');
if(patchedRom.unpatched)
patchedRom.fileName = patchedRom.fileName.replace(' (patched)', ' (unpatched)');
} else if (patch._originalPatchFile) {
patchedRom.fileName = patch._originalPatchFile.fileName.replace(/\.\w+$/i, (/\.\w+$/i.test(romFile.fileName) ? romFile.fileName.match(/\.\w+$/i)[0] : ''));
} else {
patchedRom.fileName = romFile.fileName;
}
return patchedRom;
},
createPatch: function (originalFile, modifiedFile, format, metadata) {
if (!(originalFile instanceof BinFile))
throw new Error('Original ROM file is not an instance of BinFile');
else if (!(modifiedFile instanceof BinFile))
throw new Error('Modified ROM file is not an instance of BinFile');
if (typeof format === 'string')
format = format.trim().toLowerCase();
else if (typeof format === 'undefined')
format = 'ips';
var patch;
if (format === 'ips') {
patch = IPS.buildFromRoms(originalFile, modifiedFile);
} else if (format === 'bps') {
patch = BPS.buildFromRoms(originalFile, modifiedFile, (originalFile.fileSize <= 4194304));
} else if (format === 'ppf') {
patch = PPF.buildFromRoms(originalFile, modifiedFile);
} else if (format === 'ups') {
patch = UPS.buildFromRoms(originalFile, modifiedFile);
} else if (format === 'aps') {
patch = APS.buildFromRoms(originalFile, modifiedFile);
} else if (format === 'rup') {
patch = RUP.buildFromRoms(originalFile, modifiedFile, metadata && metadata.Description? metadata.Description : null);
} else if (format === 'ebp') {
patch = IPS.buildFromRoms(originalFile, modifiedFile, metadata);
} else {
throw new Error('Invalid patch format');
}
if (
!(format === 'ppf' && originalFile.fileSize > modifiedFile.fileSize) && //skip verification if PPF and PPF+modified size>original size
modifiedFile.hashCRC32() !== patch.apply(originalFile).hashCRC32()
) {
//throw new Error('Unexpected error: verification failed. Patched file and modified file mismatch. Please report this bug.');
}
return patch;
},
/* check if ROM can inject a fake header (for patches that require a headered ROM) */
canRomGetHeader: function (romFile) {
if (romFile.fileSize <= 0x600000) {
const compatibleHeader = HEADERS_INFO.find(headerInfo => headerInfo.extensions.indexOf(romFile.getExtension()) !== -1 && romFile.fileSize % headerInfo.romSizeMultiple === 0);
if (compatibleHeader) {
return {
name: compatibleHeader.name,
size: compatibleHeader.size
};
}
}
return null;
},
/* check if ROM has a known header */
isRomHeadered: function (romFile) {
if (romFile.fileSize <= 0x600200 && romFile.fileSize % 1024 !== 0) {
const compatibleHeader = HEADERS_INFO.find(headerInfo => headerInfo.extensions.indexOf(romFile.getExtension()) !== -1 && (romFile.fileSize - headerInfo.size) % headerInfo.romSizeMultiple === 0);
if (compatibleHeader) {
return {
name: compatibleHeader.name,
size: compatibleHeader.size
};
}
}
return null;
},
/* remove ROM header */
removeHeader: function (romFile) {
const headerInfo = RomPatcher.isRomHeadered(romFile);
if (headerInfo) {
return {
header: romFile.slice(0, headerInfo.size),
rom: romFile.slice(headerInfo.size)
}
}
return null;
},
/* add fake ROM header */
addFakeHeader: function (romFile) {
const headerInfo = RomPatcher.canRomGetHeader(romFile);
if (headerInfo) {
const romWithFakeHeader = new BinFile(headerInfo.size + romFile.fileSize);
romWithFakeHeader.fileName = romFile.fileName;
romWithFakeHeader.fileType = romFile.fileType;
romFile.copyTo(romWithFakeHeader, 0, romFile.fileSize, headerInfo.size);
//add a correct FDS header
if (_getRomSystem(romWithFakeHeader) === 'fds') {
romWithFakeHeader.seek(0);
romWithFakeHeader.writeBytes([0x46, 0x44, 0x53, 0x1a, romFile.fileSize / 65500]);
}
romWithFakeHeader.fakeHeader = true;
return romWithFakeHeader;
}
return null;
},
/* get ROM internal checksum, if possible */
fixRomHeaderChecksum: function (romFile) {
const romSystem = _getRomSystem(romFile);
if (romSystem === 'gb') {
/* get current checksum */
romFile.seek(0x014d);
const currentChecksum = romFile.readU8();
/* calculate checksum */
var newChecksum = 0x00;
romFile.seek(0x0134);
for (var i = 0; i <= 0x18; i++) {
newChecksum = ((newChecksum - romFile.readU8() - 1) >>> 0) & 0xff;
}
/* fix checksum */
if (currentChecksum !== newChecksum) {
console.log('fixed Game Boy checksum');
romFile.seek(0x014d);
romFile.writeU8(newChecksum);
return true;
}
} else if (romSystem === 'smd') {
/* get current checksum */
romFile.seek(0x018e);
const currentChecksum = romFile.readU16();
/* calculate checksum */
var newChecksum = 0x0000;
romFile.seek(0x0200);
while (!romFile.isEOF()) {
newChecksum = ((newChecksum + romFile.readU16()) >>> 0) & 0xffff;
}
/* fix checksum */
if (currentChecksum !== newChecksum) {
console.log('fixed Megadrive/Genesis checksum');
romFile.seek(0x018e);
romFile.writeU16(newChecksum);
return true;
}
}
return false;
},
/* get ROM additional checksum info, if possible */
getRomAdditionalChecksum: function (romFile) {
return _getRomAdditionalChecksum(romFile);
},
/* check if ROM is too big */
isRomTooBig: function (romFile) {
return romFile && romFile.fileSize > TOO_BIG_ROM_SIZE;
}
}
}());
if (typeof module !== 'undefined' && module.exports) {
module.exports = RomPatcher;
IPS = require('./modules/RomPatcher.format.ips');
UPS = require('./modules/RomPatcher.format.ups');
APS = require('./modules/RomPatcher.format.aps_n64');
APSGBA = require('./modules/RomPatcher.format.aps_gba');
BPS = require('./modules/RomPatcher.format.bps');
RUP = require('./modules/RomPatcher.format.rup');
PPF = require('./modules/RomPatcher.format.ppf');
BDF = require('./modules/RomPatcher.format.bdf');
PMSR = require('./modules/RomPatcher.format.pmsr');
VCDIFF = require('./modules/RomPatcher.format.vcdiff');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
/* Rom Patcher JS v20250922 - Marc Robledo 2016-2025 - http://www.marcrobledo.com/license */
self.importScripts(
'./RomPatcher.js',
'./modules/BinFile.js',
'./modules/HashCalculator.js',
'./modules/RomPatcher.format.ips.js',
'./modules/RomPatcher.format.aps_n64.js',
'./modules/RomPatcher.format.aps_gba.js',
'./modules/RomPatcher.format.ups.js',
'./modules/RomPatcher.format.bps.js',
'./modules/RomPatcher.format.rup.js',
'./modules/RomPatcher.format.ppf.js',
'./modules/RomPatcher.format.bdf.js',
'./modules/RomPatcher.format.pmsr.js',
'./modules/RomPatcher.format.vcdiff.js'
);
self.onmessage = event => { // listen for messages from the main thread
const romFile=new BinFile(event.data.romFileU8Array);
romFile.fileName=event.data.romFileName;
//romFile.fileType.event.data.romFileType;
const patchFile=new BinFile(event.data.patchFileU8Array);
patchFile.fileName=event.data.patchFileName;
const patch=RomPatcher.parsePatchFile(patchFile);
var errorMessage=false;
var patchedRom;
if(patch){
try{
patchedRom=RomPatcher.applyPatch(romFile, patch, event.data.options);
}catch(evt){
errorMessage=evt.message;
}
}else{
errorMessage='Invalid patch file';
}
//console.log('postMessage');
if(patchedRom){
/* set custom output name if embeded patch */
const patchExtraInfo=event.data.patchExtraInfo;
if(patchExtraInfo){
if(typeof patchExtraInfo.outputName === 'string')
patchedRom.setName(patchExtraInfo.outputName);
if(typeof patchExtraInfo.outputExtension === 'string')
patchedRom.setExtension(patchExtraInfo.outputExtension);
}
self.postMessage(
{
success: !!errorMessage,
romFileU8Array:event.data.romFileU8Array,
patchFileU8Array:event.data.patchFileU8Array,
patchedRomU8Array:patchedRom._u8array,
patchedRomFileName:patchedRom.fileName,
errorMessage:errorMessage
},
[
event.data.romFileU8Array.buffer,
event.data.patchFileU8Array.buffer,
patchedRom._u8array.buffer
]
);
}else{
self.postMessage(
{
success: false,
romFileU8Array:event.data.romFileU8Array,
patchFileU8Array:event.data.patchFileU8Array,
errorMessage:errorMessage
},
[
event.data.romFileU8Array.buffer,
event.data.patchFileU8Array.buffer
]
);
}
};

View File

@@ -0,0 +1,26 @@
/* Rom Patcher JS v20240302 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */
self.importScripts(
'./RomPatcher.js',
'./modules/BinFile.js',
'./modules/HashCalculator.js'
);
self.onmessage = event => { // listen for messages from the main thread
const binFile=new BinFile(event.data.u8array);
binFile.fileName=event.data.fileName;
const startOffset=typeof event.data.checksumStartOffset==='number'? event.data.checksumStartOffset : 0;
self.postMessage(
{
action: event.data.action,
crc32:binFile.hashCRC32(startOffset),
md5:binFile.hashMD5(startOffset),
checksumStartOffset: startOffset,
rom:RomPatcher.getRomAdditionalChecksum(binFile),
u8array:event.data.u8array
},
[event.data.u8array.buffer]
);
};

View File

@@ -0,0 +1,37 @@
/* Rom Patcher JS v20240302 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */
self.importScripts(
'./RomPatcher.js',
'./modules/BinFile.js',
'./modules/HashCalculator.js',
'./modules/RomPatcher.format.ips.js',
'./modules/RomPatcher.format.aps_n64.js',
'./modules/RomPatcher.format.ups.js',
'./modules/RomPatcher.format.bps.js',
'./modules/RomPatcher.format.rup.js',
'./modules/RomPatcher.format.ppf.js'
);
self.onmessage = event => { // listen for messages from the main thread
const originalFile=new BinFile(event.data.originalRomU8Array);
const modifiedFile=new BinFile(event.data.modifiedRomU8Array);
const format=event.data.format;
const metadata=event.data.metadata;
const patch=RomPatcher.createPatch(originalFile, modifiedFile, format, metadata);
const patchFile=patch.export('my_patch');
self.postMessage(
{
originalRomU8Array:event.data.originalRomU8Array,
modifiedRomU8Array:event.data.modifiedRomU8Array,
patchFileU8Array:patchFile._u8array
},
[
event.data.originalRomU8Array.buffer,
event.data.modifiedRomU8Array.buffer,
patchFile._u8array.buffer
]
);
};

View File

@@ -0,0 +1,483 @@
/*
* BinFile.js (last update: 2024-08-21)
* by Marc Robledo, https://www.marcrobledo.com
*
* a JS class for reading/writing sequentially binary data from/to a file
* that allows much more manipulation than simple DataView
* compatible with both browsers and Node.js
*
* MIT License
*
* Copyright (c) 2014-2024 Marc Robledo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
function BinFile(source, onLoad) {
this.littleEndian = false;
this.offset = 0;
this._lastRead = null;
this._offsetsStack = [];
if (
BinFile.RUNTIME_ENVIROMENT === 'browser' && (
source instanceof File ||
source instanceof FileList ||
(source instanceof HTMLElement && source.tagName === 'INPUT' && source.type === 'file')
)
) {
if (source instanceof HTMLElement)
source = source.files;
if (source instanceof FileList)
source = source[0];
this.fileName = source.name;
this.fileType = source.type;
this.fileSize = source.size;
if (typeof window.FileReader !== 'function')
throw new Error('Incompatible browser');
this._fileReader = new FileReader();
this._fileReader.addEventListener('load', function () {
this.binFile._u8array = new Uint8Array(this.result);
if (typeof onLoad === 'function')
onLoad(this.binFile);
}, false);
this._fileReader.binFile = this;
this._fileReader.readAsArrayBuffer(source);
} else if (BinFile.RUNTIME_ENVIROMENT === 'node' && typeof source === 'string') {
if (!nodeFs.existsSync(source))
throw new Error(source + ' does not exist');
const arrayBuffer = nodeFs.readFileSync(source);
this.fileName = nodePath.basename(source);
this.fileType = nodeFs.statSync(source).type;
this.fileSize = arrayBuffer.byteLength;
this._u8array = new Uint8Array(arrayBuffer);
if (typeof onLoad === 'function')
onLoad(this);
} else if (source instanceof BinFile) { /* if source is another BinFile, clone it */
this.fileName = source.fileName;
this.fileType = source.fileType;
this.fileSize = source.fileSize;
this._u8array = new Uint8Array(source._u8array.buffer.slice());
if (typeof onLoad === 'function')
onLoad(this);
} else if (source instanceof ArrayBuffer) {
this.fileName = 'file.bin';
this.fileType = 'application/octet-stream';
this.fileSize = source.byteLength;
this._u8array = new Uint8Array(source);
if (typeof onLoad === 'function')
onLoad(this);
} else if (ArrayBuffer.isView(source)) { /* source is TypedArray */
this.fileName = 'file.bin';
this.fileType = 'application/octet-stream';
this.fileSize = source.buffer.byteLength;
this._u8array = new Uint8Array(source.buffer);
if (typeof onLoad === 'function')
onLoad(this);
} else if (typeof source === 'number') { /* source is integer, create new empty file */
this.fileName = 'file.bin';
this.fileType = 'application/octet-stream';
this.fileSize = source;
this._u8array = new Uint8Array(new ArrayBuffer(source));
if (typeof onLoad === 'function')
onLoad(this);
} else {
throw new Error('invalid BinFile source');
}
}
BinFile.RUNTIME_ENVIROMENT = (function () {
if (typeof window === 'object' && typeof window.document === 'object')
return 'browser';
else if (typeof WorkerGlobalScope === 'function' && self instanceof WorkerGlobalScope)
return 'webworker';
else if (typeof require === 'function' && typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node === 'string')
return 'node';
else
return null;
}());
BinFile.DEVICE_LITTLE_ENDIAN = (function () { /* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView#Endianness */
var buffer = new ArrayBuffer(2);
new DataView(buffer).setInt16(0, 256, true /* littleEndian */);
// Int16Array uses the platform's endianness.
return new Int16Array(buffer)[0] === 256;
})();
BinFile.prototype.push = function () {
this._offsetsStack.push(this.offset);
}
BinFile.prototype.pop = function () {
this.seek(this._offsetsStack.pop());
}
BinFile.prototype.seek = function (offset) {
this.offset = offset;
}
BinFile.prototype.skip = function (nBytes) {
this.offset += nBytes;
}
BinFile.prototype.isEOF = function () {
return !(this.offset < this.fileSize)
}
BinFile.prototype.slice = function (offset, len, doNotClone) {
if (typeof offset !== 'number' || offset < 0)
offset = 0;
else if (offset >= this.fileSize)
throw new Error('out of bounds slicing');
else
offset = Math.floor(offset);
if (typeof len !== 'number' || offset < 0 || (offset + len) >= this.fileSize.length)
len = this.fileSize - offset;
else if (len === 0)
throw new Error('zero length provided for slicing');
else
len = Math.floor(len);
if (offset === 0 && len === this.fileSize && doNotClone)
return this;
var newFile = new BinFile(this._u8array.buffer.slice(offset, offset + len));
newFile.fileName = this.fileName;
newFile.fileType = this.fileType;
newFile.littleEndian = this.littleEndian;
return newFile;
}
BinFile.prototype.prependBytes = function (bytes) {
var newFile = new BinFile(this.fileSize + bytes.length);
newFile.seek(0);
newFile.writeBytes(bytes);
this.copyTo(newFile, 0, this.fileSize, bytes.length);
this.fileSize = newFile.fileSize;
this._u8array = newFile._u8array;
return this;
}
BinFile.prototype.removeLeadingBytes = function (nBytes) {
this.seek(0);
var oldData = this.readBytes(nBytes);
var newFile = this.slice(nBytes.length);
this.fileSize = newFile.fileSize;
this._u8array = newFile._u8array;
return oldData;
}
BinFile.prototype.copyTo = function (target, offsetSource, len, offsetTarget) {
if (!(target instanceof BinFile))
throw new Error('target is not a BinFile object');
if (typeof offsetTarget !== 'number')
offsetTarget = offsetSource;
len = len || (this.fileSize - offsetSource);
for (var i = 0; i < len; i++) {
target._u8array[offsetTarget + i] = this._u8array[offsetSource + i];
}
}
BinFile.prototype.save = function () {
if (BinFile.RUNTIME_ENVIROMENT === 'browser') {
var fileBlob = new Blob([this._u8array], { type: this.fileType });
var blobUrl = URL.createObjectURL(fileBlob);
var a = document.createElement('a');
a.href = blobUrl;
a.download = this.fileName;
document.body.appendChild(a);
a.dispatchEvent(new MouseEvent('click'));
URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
} else if (BinFile.RUNTIME_ENVIROMENT === 'node') {
nodeFs.writeFileSync(this.fileName, Buffer.from(this._u8array.buffer));
} else {
throw new Error('invalid runtime environment, can\'t save file');
}
}
BinFile.prototype.getExtension = function () {
var ext = this.fileName ? this.fileName.toLowerCase().match(/\.(\w+)$/) : '';
return ext ? ext[1] : '';
}
BinFile.prototype.getName = function () {
return this.fileName.replace(new RegExp('\\.' + this.getExtension() + '$', 'i'), '');
}
BinFile.prototype.setExtension = function (newExtension) {
return (this.fileName = this.getName() + '.' + newExtension);
}
BinFile.prototype.setName = function (newName) {
return (this.fileName = newName + '.' + this.getExtension());
}
BinFile.prototype.readU8 = function () {
this._lastRead = this._u8array[this.offset++];
return this._lastRead
}
BinFile.prototype.readU16 = function () {
if (this.littleEndian)
this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8);
else
this._lastRead = (this._u8array[this.offset] << 8) + this._u8array[this.offset + 1];
this.offset += 2;
return this._lastRead >>> 0
}
BinFile.prototype.readU24 = function () {
if (this.littleEndian)
this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16);
else
this._lastRead = (this._u8array[this.offset] << 16) + (this._u8array[this.offset + 1] << 8) + this._u8array[this.offset + 2];
this.offset += 3;
return this._lastRead >>> 0
}
BinFile.prototype.readU32 = function () {
if (this.littleEndian)
this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16) + (this._u8array[this.offset + 3] << 24);
else
this._lastRead = (this._u8array[this.offset] << 24) + (this._u8array[this.offset + 1] << 16) + (this._u8array[this.offset + 2] << 8) + this._u8array[this.offset + 3];
this.offset += 4;
return this._lastRead >>> 0
}
BinFile.prototype.readU64 = function () {
if (this.littleEndian)
this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16) + (this._u8array[this.offset + 3] << 24) + (this._u8array[this.offset + 4] << 32) + (this._u8array[this.offset + 5] << 40) + (this._u8array[this.offset + 6] << 48) + (this._u8array[this.offset + 7] << 56);
else
this._lastRead = (this._u8array[this.offset] << 56) + (this._u8array[this.offset + 1] << 48) + (this._u8array[this.offset + 2] << 40) + (this._u8array[this.offset + 3] << 32) + (this._u8array[this.offset + 4] << 24) + (this._u8array[this.offset + 5] << 16) + (this._u8array[this.offset + 6] << 8) + this._u8array[this.offset + 7];
this.offset += 8;
return this._lastRead >>> 0
}
BinFile.prototype.readBytes = function (len) {
this._lastRead = new Array(len);
for (var i = 0; i < len; i++) {
this._lastRead[i] = this._u8array[this.offset + i];
}
this.offset += len;
return this._lastRead
}
BinFile.prototype.readString = function (len) {
this._lastRead = '';
for (var i = 0; i < len && (this.offset + i) < this.fileSize && this._u8array[this.offset + i] > 0; i++)
this._lastRead = this._lastRead + String.fromCharCode(this._u8array[this.offset + i]);
this.offset += len;
return this._lastRead
}
BinFile.prototype.writeU8 = function (u8) {
this._u8array[this.offset++] = u8;
}
BinFile.prototype.writeU16 = function (u16) {
if (this.littleEndian) {
this._u8array[this.offset] = u16 & 0xff;
this._u8array[this.offset + 1] = u16 >> 8;
} else {
this._u8array[this.offset] = u16 >> 8;
this._u8array[this.offset + 1] = u16 & 0xff;
}
this.offset += 2;
}
BinFile.prototype.writeU24 = function (u24) {
if (this.littleEndian) {
this._u8array[this.offset] = u24 & 0x0000ff;
this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8;
this._u8array[this.offset + 2] = (u24 & 0xff0000) >> 16;
} else {
this._u8array[this.offset] = (u24 & 0xff0000) >> 16;
this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8;
this._u8array[this.offset + 2] = u24 & 0x0000ff;
}
this.offset += 3;
}
BinFile.prototype.writeU32 = function (u32) {
if (this.littleEndian) {
this._u8array[this.offset] = u32 & 0x000000ff;
this._u8array[this.offset + 1] = (u32 & 0x0000ff00) >> 8;
this._u8array[this.offset + 2] = (u32 & 0x00ff0000) >> 16;
this._u8array[this.offset + 3] = (u32 & 0xff000000) >> 24;
} else {
this._u8array[this.offset] = (u32 & 0xff000000) >> 24;
this._u8array[this.offset + 1] = (u32 & 0x00ff0000) >> 16;
this._u8array[this.offset + 2] = (u32 & 0x0000ff00) >> 8;
this._u8array[this.offset + 3] = u32 & 0x000000ff;
}
this.offset += 4;
}
BinFile.prototype.writeBytes = function (a) {
for (var i = 0; i < a.length; i++)
this._u8array[this.offset + i] = a[i]
this.offset += a.length;
}
BinFile.prototype.writeString = function (str, len) {
len = len || str.length;
for (var i = 0; i < str.length && i < len; i++)
this._u8array[this.offset + i] = str.charCodeAt(i);
for (; i < len; i++)
this._u8array[this.offset + i] = 0x00;
this.offset += len;
}
BinFile.prototype.swapBytes = function (swapSize, newFile) {
if (typeof swapSize !== 'number') {
swapSize = 4;
}
if (this.fileSize % swapSize !== 0) {
throw new Error('file size is not divisible by ' + swapSize);
}
var swappedFile = new BinFile(this.fileSize);
this.seek(0);
while (!this.isEOF()) {
swappedFile.writeBytes(
this.readBytes(swapSize).reverse()
);
}
if (newFile) {
swappedFile.fileName = this.fileName;
swappedFile.fileType = this.fileType;
return swappedFile;
} else {
this._u8array = swappedFile._u8array;
return this;
}
}
BinFile.prototype.hashSHA1 = async function (start, len) {
if (typeof HashCalculator !== 'object' || typeof HashCalculator.sha1 !== 'function')
throw new Error('no Hash object found or missing sha1 function');
return HashCalculator.sha1(this.slice(start, len, true)._u8array.buffer);
}
BinFile.prototype.hashMD5 = function (start, len) {
if (typeof HashCalculator !== 'object' || typeof HashCalculator.md5 !== 'function')
throw new Error('no Hash object found or missing md5 function');
return HashCalculator.md5(this.slice(start, len, true)._u8array.buffer);
}
BinFile.prototype.hashCRC32 = function (start, len) {
if (typeof HashCalculator !== 'object' || typeof HashCalculator.crc32 !== 'function')
throw new Error('no Hash object found or missing crc32 function');
return HashCalculator.crc32(this.slice(start, len, true)._u8array.buffer);
}
BinFile.prototype.hashAdler32 = function (start, len) {
if (typeof HashCalculator !== 'object' || typeof HashCalculator.adler32 !== 'function')
throw new Error('no Hash object found or missing adler32 function');
return HashCalculator.adler32(this.slice(start, len, true)._u8array.buffer);
}
BinFile.prototype.hashCRC16 = function (start, len) {
if (typeof HashCalculator !== 'object' || typeof HashCalculator.crc16 !== 'function')
throw new Error('no Hash object found or missing crc16 function');
return HashCalculator.crc16(this.slice(start, len, true)._u8array.buffer);
}
if (BinFile.RUNTIME_ENVIROMENT === 'node' && typeof module !== 'undefined' && module.exports) {
module.exports = BinFile;
HashCalculator = require('./HashCalculator');
nodePath = require('path');
nodeFs = require('fs');
}

View File

@@ -0,0 +1,179 @@
/*
* HashCalculator.js (last update: 2021-08-15)
* by Marc Robledo, https://www.marcrobledo.com
*
* data hash calculator (CRC32, MD5, SHA1, ADLER-32, CRC16)
*
* MIT License
*
* Copyright (c) 2016-2021 Marc Robledo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const HashCalculator = (function () {
const HEX_CHR = '0123456789abcdef'.split('');
/* MD5 helpers */
const _add32 = function (a, b) { return (a + b) & 0xffffffff }
const _md5cycle = function (x, k) { var a = x[0], b = x[1], c = x[2], d = x[3]; a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586); c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330); a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426); c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983); a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417); c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162); a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -40341101); c = ff(c, d, a, b, k[14], 17, -1502002290); b = ff(b, c, d, a, k[15], 22, 1236535329); a = gg(a, b, c, d, k[1], 5, -165796510); d = gg(d, a, b, c, k[6], 9, -1069501632); c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302); a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083); c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848); a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690); c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501); a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784); c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734); a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463); c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556); a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353); c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640); a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222); c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189); a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835); c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651); a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415); c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055); a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606); c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799); a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744); c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649); a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379); c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551); x[0] = _add32(a, x[0]); x[1] = _add32(b, x[1]); x[2] = _add32(c, x[2]); x[3] = _add32(d, x[3]) }
const _md5blk = function (d) { var md5blks = [], i; for (i = 0; i < 64; i += 4)md5blks[i >> 2] = d[i] + (d[i + 1] << 8) + (d[i + 2] << 16) + (d[i + 3] << 24); return md5blks }
const _cmn = function (q, a, b, x, s, t) { a = _add32(_add32(a, q), _add32(x, t)); return _add32((a << s) | (a >>> (32 - s)), b) }
const ff = function (a, b, c, d, x, s, t) { return _cmn((b & c) | ((~b) & d), a, b, x, s, t) }
const gg = function (a, b, c, d, x, s, t) { return _cmn((b & d) | (c & (~d)), a, b, x, s, t) }
const hh = function (a, b, c, d, x, s, t) { return _cmn(b ^ c ^ d, a, b, x, s, t) }
const ii = function (a, b, c, d, x, s, t) { return _cmn(c ^ (b | (~d)), a, b, x, s, t) }
/* CRC32 helpers */
const CRC32_TABLE = (function () {
var c, crcTable = [];
for (var n = 0; n < 256; n++) {
c = n;
for (var k = 0; k < 8; k++)
c = ((c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1));
crcTable[n] = c;
}
return crcTable;
}());
/* Adler-32 helpers */
const ADLER32_MOD = 0xfff1;
const generateUint8Array = function (arrayBuffer, offset, len) {
if (typeof offset !== 'number' || offset < 0)
offset = 0;
else if (offset < arrayBuffer.byteLength)
offset = Math.floor(offset);
else
throw new Error('out of bounds slicing');
if (typeof len !== 'number' || len < 0 || (offset + len) >= arrayBuffer.byteLength.length)
len = arrayBuffer.byteLength - offset;
else if (len > 0)
len = Math.floor(len);
else
throw new Error('zero length provided for slicing');
return new Uint8Array(arrayBuffer, offset, len);
}
return {
/* SHA-1 using WebCryptoAPI */
sha1: async function sha1(arrayBuffer, offset, len) {
if(typeof window === 'undefined' || typeof window.crypto === 'undefined')
throw new Error('Web Crypto API is not available');
const u8array = generateUint8Array(arrayBuffer, offset, len);
if (u8array.byteLength !== arrayBuffer.byteLength) {
arrayBuffer = arrayBuffer.slice(u8array.byteOffset, u8array.byteOffset + u8array.byteLength);
}
const hash = await window.crypto.subtle.digest('SHA-1', arrayBuffer);
const bytes = new Uint8Array(hash);
let hexString = '';
for (let i = 0; i < bytes.length; i++)
hexString += bytes[i] < 16 ? '0' + bytes[i].toString(16) : bytes[i].toString(16);
return hexString;
},
/* MD5 - from Joseph's Myers - http://www.myersdaily.org/joseph/javascript/md5.js */
md5: function (arrayBuffer, offset, len) {
let u8array = generateUint8Array(arrayBuffer, offset, len);
var n = u8array.byteLength, state = [1732584193, -271733879, -1732584194, 271733878], i;
for (i = 64; i <= u8array.byteLength; i += 64)
_md5cycle(state, _md5blk(u8array.slice(i - 64, i)));
u8array = u8array.slice(i - 64);
var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (i = 0; i < u8array.byteLength; i++)
tail[i >> 2] |= u8array[i] << ((i % 4) << 3);
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
if (i > 55) {
_md5cycle(state, tail);
for (i = 0; i < 16; i++)tail[i] = 0;
}
tail[14] = n * 8;
tail[15] = Math.floor(n / 536870912) >>> 0; //if file is bigger than 512Mb*8, value is bigger than 32 bits, so it needs two words to store its length
_md5cycle(state, tail);
for (var i = 0; i < state.length; i++) {
var s = '', j = 0;
for (; j < 4; j++)
s += HEX_CHR[(state[i] >> (j * 8 + 4)) & 0x0f] + HEX_CHR[(state[i] >> (j * 8)) & 0x0f];
state[i] = s;
}
return state.join('')
},
/* CRC32 - from Alex - https://stackoverflow.com/a/18639999 */
crc32: function (arrayBuffer, offset, len) {
const u8array = generateUint8Array(arrayBuffer, offset, len);
var crc = 0 ^ (-1);
for (var i = 0; i < u8array.byteLength; i++)
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ u8array[i]) & 0xff];
return ((crc ^ (-1)) >>> 0);
},
/* Adler-32 - https://en.wikipedia.org/wiki/Adler-32#Example_implementation */
adler32: function (arrayBuffer, offset, len) {
const u8array = generateUint8Array(arrayBuffer, offset, len);
var a = 1, b = 0;
for (var i = 0; i < u8array.byteLength; i++) {
a = (a + u8array[i]) % ADLER32_MOD;
b = (b + a) % ADLER32_MOD;
}
return ((b << 16) | a) >>> 0;
},
/* CRC16/CCITT-FALSE */
crc16: function (arrayBuffer, offset, len) {
const u8array = generateUint8Array(arrayBuffer, offset, len);
var crc = 0xffff;
var offset = 0;
for (var i = 0; i < u8array.byteLength; i++) {
crc ^= u8array[offset++] << 8;
for (j = 0; j < 8; ++j) {
crc = (crc & 0x8000) >>> 0 ? (crc << 1) ^ 0x1021 : crc << 1;
}
}
return crc & 0xffff;
}
}
}());
if (typeof module !== 'undefined' && module.exports) {
module.exports = HashCalculator;
}

View File

@@ -0,0 +1,114 @@
/* APS (GBA) module for Rom Patcher JS v20230331 - Marc Robledo 2017-2023 - http://www.marcrobledo.com/license */
/* File format specification: https://github.com/btimofeev/UniPatcher/wiki/APS-(GBA) */
const APS_GBA_MAGIC='APS1';
const APS_GBA_BLOCK_SIZE=0x010000; //64Kb
const APS_GBA_RECORD_SIZE=4 + 2 + 2 + APS_GBA_BLOCK_SIZE;
if(typeof module !== "undefined" && module.exports){
module.exports = APSGBA;
}
function APSGBA(){
this.sourceSize=0;
this.targetSize=0;
this.records=[];
}
APSGBA.prototype.addRecord=function(offset, sourceCrc16, targetCrc16, xorBytes){
this.records.push({
offset:offset,
sourceCrc16:sourceCrc16,
targetCrc16:targetCrc16,
xorBytes:xorBytes}
);
}
APSGBA.prototype.toString=function(){
var s='Total records: '+this.records.length;
s+='\nInput file size: '+this.sourceSize;
s+='\nOutput file size: '+this.targetSize;
return s
}
APSGBA.prototype.validateSource=function(sourceFile){
if(sourceFile.fileSize!==this.sourceSize)
return false;
for(var i=0; i<this.records.length; i++){
sourceFile.seek(this.records[i].offset);
var bytes=sourceFile.readBytes(APS_GBA_BLOCK_SIZE);
if(sourceFile.hashCRC16(this.records[i].offset, APS_GBA_BLOCK_SIZE) !== this.records[i].sourceCrc16)
return false
}
return true
}
APSGBA.prototype.export=function(fileName){
var patchFileSize=12 + (this.records.length * APS_GBA_RECORD_SIZE);
tempFile=new BinFile(patchFileSize);
tempFile.littleEndian=true;
tempFile.fileName=fileName+'.aps';
tempFile.writeString(APS_GBA_MAGIC, APS_GBA_MAGIC.length);
tempFile.writeU32(this.sourceSize);
tempFile.writeU32(this.targetSize);
for(var i=0; i<this.records.length; i++){
tempFile.writeU32(this.records[i].offset);
tempFile.writeU16(this.records[i].sourceCrc16);
tempFile.writeU16(this.records[i].targetCrc16);
tempFile.writeBytes(this.records[i].xorBytes);
}
return tempFile
}
APSGBA.prototype.apply=function(romFile, validate){
if(validate && !this.validateSource(romFile)){
throw new Error('Source ROM checksum mismatch');
}
tempFile=new BinFile(this.targetSize);
romFile.copyTo(tempFile, 0, romFile.fileSize);
for(var i=0; i<this.records.length; i++){
romFile.seek(this.records[i].offset);
tempFile.seek(this.records[i].offset);
for(var j=0; j<APS_GBA_BLOCK_SIZE; j++){
tempFile.writeU8(romFile.readU8() ^ this.records[i].xorBytes[j]);
}
if(validate && tempFile.hashCRC16(this.records[i].offset, APS_GBA_BLOCK_SIZE)!==this.records[i].targetCrc16){
throw new Error('Target ROM checksum mismatch');
}
}
return tempFile
}
APSGBA.MAGIC=APS_GBA_MAGIC;
APSGBA.fromFile=function(patchFile){
patchFile.seek(0);
patchFile.littleEndian=true;
if(
patchFile.readString(APS_GBA_MAGIC.length)!==APS_GBA_MAGIC ||
patchFile.fileSize < (12 + APS_GBA_RECORD_SIZE) ||
(patchFile.fileSize-12)%APS_GBA_RECORD_SIZE!==0
)
return null;
var patch=new APSGBA();
patch.sourceSize=patchFile.readU32();
patch.targetSize=patchFile.readU32();
while(!patchFile.isEOF()){
var offset=patchFile.readU32();
var sourceCrc16=patchFile.readU16();
var targetCrc16=patchFile.readU16();
var xorBytes=patchFile.readBytes(APS_GBA_BLOCK_SIZE);
patch.addRecord(offset, sourceCrc16, targetCrc16, xorBytes);
}
return patch;
}

View File

@@ -0,0 +1,211 @@
/* APS (N64) module for Rom Patcher JS v20180930 - Marc Robledo 2017-2018 - http://www.marcrobledo.com/license */
/* File format specification: https://github.com/btimofeev/UniPatcher/wiki/APS-(N64) */
const APS_N64_MAGIC='APS10';
const APS_RECORD_RLE=0x0000;
const APS_RECORD_SIMPLE=0x01;
const APS_N64_MODE=0x01;
if(typeof module !== "undefined" && module.exports){
module.exports = APS;
}
function APS(){
this.records=[];
this.headerType=0;
this.encodingMethod=0;
this.description='no description';
this.header={};
}
APS.prototype.addRecord=function(o, d){
this.records.push({offset:o, type:APS_RECORD_SIMPLE, data:d})
}
APS.prototype.addRLERecord=function(o, b, l){
this.records.push({offset:o, type:APS_RECORD_RLE, length:l, byte:b})
}
APS.prototype.toString=function(){
var s='Total records: '+this.records.length;
s+='\nHeader type: '+this.headerType;
if(this.headerType===APS_N64_MODE){
s+=' (N64)';
}
s+='\nEncoding method: '+this.encodingMethod;
s+='\nDescription: '+this.description;
s+='\nHeader: '+JSON.stringify(this.header);
return s
}
APS.prototype.validateSource=function(sourceFile){
if(this.headerType===APS_N64_MODE){
sourceFile.seek(0x3c);
if(sourceFile.readString(3)!==this.header.cartId)
return false;
sourceFile.seek(0x10);
var crc=sourceFile.readBytes(8);
for(var i=0; i<8; i++){
if(crc[i]!==this.header.crc[i])
return false
}
}
return true
}
APS.prototype.getValidationInfo=function(){
if(this.headerType===APS_N64_MODE){
return this.header.cartId + ' (' + this.header.crc.reduce(function(hex, b){
if(b<16)
return hex + '0' + b.toString(16);
else
return hex + b.toString(16);
}, '') + ')';
}
return null;
}
APS.prototype.export=function(fileName){
var patchFileSize=61;
if(this.headerType===APS_N64_MODE)
patchFileSize+=17;
for(var i=0; i<this.records.length; i++){
if(this.records[i].type===APS_RECORD_RLE)
patchFileSize+=7;
else
patchFileSize+=5+this.records[i].data.length; //offset+length+data
}
tempFile=new BinFile(patchFileSize);
tempFile.littleEndian=true;
tempFile.fileName=fileName+'.aps';
tempFile.writeString(APS_N64_MAGIC, APS_N64_MAGIC.length);
tempFile.writeU8(this.headerType);
tempFile.writeU8(this.encodingMethod);
tempFile.writeString(this.description, 50);
if(this.headerType===APS_N64_MODE){
tempFile.writeU8(this.header.originalN64Format);
tempFile.writeString(this.header.cartId, 3);
tempFile.writeBytes(this.header.crc);
tempFile.writeBytes(this.header.pad);
}
tempFile.writeU32(this.header.sizeOutput);
for(var i=0; i<this.records.length; i++){
var rec=this.records[i];
tempFile.writeU32(rec.offset);
if(rec.type===APS_RECORD_RLE){
tempFile.writeU8(0x00);
tempFile.writeU8(rec.byte);
tempFile.writeU8(rec.length);
}else{
tempFile.writeU8(rec.data.length);
tempFile.writeBytes(rec.data);
}
}
return tempFile
}
APS.prototype.apply=function(romFile, validate){
if(validate && !this.validateSource(romFile)){
throw new Error('Source ROM checksum mismatch');
}
tempFile=new BinFile(this.header.sizeOutput);
romFile.copyTo(tempFile, 0, tempFile.fileSize);
for(var i=0; i<this.records.length; i++){
tempFile.seek(this.records[i].offset);
if(this.records[i].type===APS_RECORD_RLE){
for(var j=0; j<this.records[i].length; j++)
tempFile.writeU8(this.records[i].byte);
}else{
tempFile.writeBytes(this.records[i].data);
}
}
return tempFile
}
APS.MAGIC=APS_N64_MAGIC;
APS.fromFile=function(patchFile){
var patch=new APS();
patchFile.littleEndian=true;
patchFile.seek(5);
patch.headerType=patchFile.readU8();
patch.encodingMethod=patchFile.readU8();
patch.description=patchFile.readString(50);
var seek;
if(patch.headerType===APS_N64_MODE){
patch.header.originalN64Format=patchFile.readU8();
patch.header.cartId=patchFile.readString(3);
patch.header.crc=patchFile.readBytes(8);
patch.header.pad=patchFile.readBytes(5);
}
patch.header.sizeOutput=patchFile.readU32();
while(!patchFile.isEOF()){
var offset=patchFile.readU32();
var length=patchFile.readU8();
if(length===APS_RECORD_RLE)
patch.addRLERecord(offset, patchFile.readU8(seek), patchFile.readU8(seek+1));
else
patch.addRecord(offset, patchFile.readBytes(length));
}
return patch;
}
APS.buildFromRoms=function(original, modified){
var patch=new APS();
if(original.readU32()===0x80371240){ //is N64 ROM
patch.headerType=APS_N64_MODE;
patch.header.originalN64Format=/\.v64$/i.test(original.fileName)?0:1;
original.seek(0x3c);
patch.header.cartId=original.readString(3);
original.seek(0x10);
patch.header.crc=original.readBytes(8);
patch.header.pad=[0,0,0,0,0];
}
patch.header.sizeOutput=modified.fileSize;
original.seek(0);
modified.seek(0);
while(!modified.isEOF()){
var b1=original.isEOF()?0x00:original.readU8();
var b2=modified.readU8();
if(b1!==b2){
var RLERecord=true;
var differentBytes=[];
var offset=modified.offset-1;
while(b1!==b2 && differentBytes.length<0xff){
differentBytes.push(b2);
if(b2!==differentBytes[0])
RLERecord=false;
if(modified.isEOF() || differentBytes.length===0xff)
break;
b1=original.isEOF()?0x00:original.readU8();
b2=modified.readU8();
}
if(RLERecord && differentBytes.length>2){
patch.addRLERecord(offset, differentBytes[0], differentBytes.length);
}else{
patch.addRecord(offset, differentBytes);
}
}
}
return patch
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,466 @@
/* BPS module for Rom Patcher JS v20240821 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */
/* File format specification: https://www.romhacking.net/documents/746/ */
const BPS_MAGIC='BPS1';
const BPS_ACTION_SOURCE_READ=0;
const BPS_ACTION_TARGET_READ=1;
const BPS_ACTION_SOURCE_COPY=2;
const BPS_ACTION_TARGET_COPY=3;
if(typeof module !== "undefined" && module.exports){
module.exports = BPS;
}
function BPS(){
this.sourceSize=0;
this.targetSize=0;
this.metaData='';
this.actions=[];
this.sourceChecksum=0;
this.targetChecksum=0;
this.patchChecksum=0;
}
BPS.prototype.toString=function(){
var s='Source size: '+this.sourceSize;
s+='\nTarget size: '+this.targetSize;
s+='\nMetadata: '+this.metaData;
s+='\n#Actions: '+this.actions.length;
return s
}
BPS.prototype.calculateFileChecksum = function () {
var patchFile = this.export();
return patchFile.hashCRC32(0, patchFile.fileSize - 4);
}
BPS.prototype.validateSource=function(romFile,headerSize){return this.sourceChecksum===romFile.hashCRC32(headerSize)}
BPS.prototype.getValidationInfo=function(){
return {
'type':'CRC32',
'value':this.sourceChecksum
}
}
BPS.prototype.apply=function(romFile, validate){
if(validate && !this.validateSource(romFile)){
throw new Error('Source ROM checksum mismatch');
}
tempFile=new BinFile(this.targetSize);
//patch
var sourceRelativeOffset=0;
var targetRelativeOffset=0;
for(var i=0; i<this.actions.length; i++){
var action=this.actions[i];
if(action.type===BPS_ACTION_SOURCE_READ){
romFile.copyTo(tempFile, tempFile.offset, action.length);
tempFile.skip(action.length);
}else if(action.type===BPS_ACTION_TARGET_READ){
tempFile.writeBytes(action.bytes);
}else if(action.type===BPS_ACTION_SOURCE_COPY){
sourceRelativeOffset+=action.relativeOffset;
var actionLength=action.length;
while(actionLength--){
tempFile.writeU8(romFile._u8array[sourceRelativeOffset]);
sourceRelativeOffset++;
}
}else if(action.type===BPS_ACTION_TARGET_COPY){
targetRelativeOffset+=action.relativeOffset;
var actionLength=action.length;
while(actionLength--) {
tempFile.writeU8(tempFile._u8array[targetRelativeOffset]);
targetRelativeOffset++;
}
}
}
if(validate && this.targetChecksum!==tempFile.hashCRC32()){
throw new Error('Target ROM checksum mismatch');
}
return tempFile
}
BPS.MAGIC=BPS_MAGIC;
BPS.fromFile=function(file){
file.readVLV=BPS_readVLV;
file.littleEndian=true;
var patch=new BPS();
file.seek(4); //skip BPS1
patch.sourceSize=file.readVLV();
patch.targetSize=file.readVLV();
var metaDataLength=file.readVLV();
if(metaDataLength){
patch.metaData=file.readString(metaDataLength);
}
var endActionsOffset=file.fileSize-12;
while(file.offset<endActionsOffset){
var data=file.readVLV();
var action={type: data & 3, length: (data >> 2)+1};
if(action.type===BPS_ACTION_TARGET_READ){
action.bytes=file.readBytes(action.length);
}else if(action.type===BPS_ACTION_SOURCE_COPY || action.type===BPS_ACTION_TARGET_COPY){
var relativeOffset=file.readVLV();
action.relativeOffset=(relativeOffset & 1? -1 : +1) * (relativeOffset >> 1)
}
patch.actions.push(action);
}
//file.seek(endActionsOffset);
patch.sourceChecksum=file.readU32();
patch.targetChecksum=file.readU32();
patch.patchChecksum=file.readU32();
if (patch.patchChecksum !== patch.calculateFileChecksum()) {
throw new Error('Patch checksum mismatch');
}
return patch;
}
function BPS_readVLV(){
var data=0, shift=1;
while(true){
var x = this.readU8();
data += (x & 0x7f) * shift;
if(x & 0x80)
break;
shift <<= 7;
data += shift;
}
this._lastRead=data;
return data;
}
function BPS_writeVLV(data){
while(true){
var x = data & 0x7f;
data >>= 7;
if(data === 0){
this.writeU8(0x80 | x);
break;
}
this.writeU8(x);
data--;
}
}
function BPS_getVLVLen(data){
var len=0;
while(true){
var x = data & 0x7f;
data >>= 7;
if(data === 0){
len++;
break;
}
len++;
data--;
}
return len;
}
BPS.prototype.export=function(fileName){
var patchFileSize=BPS_MAGIC.length;
patchFileSize+=BPS_getVLVLen(this.sourceSize);
patchFileSize+=BPS_getVLVLen(this.targetSize);
patchFileSize+=BPS_getVLVLen(this.metaData.length);
patchFileSize+=this.metaData.length;
for(var i=0; i<this.actions.length; i++){
var action=this.actions[i];
patchFileSize+=BPS_getVLVLen(((action.length-1)<<2) + action.type);
if(action.type===BPS_ACTION_TARGET_READ){
patchFileSize+=action.length;
}else if(action.type===BPS_ACTION_SOURCE_COPY || action.type===BPS_ACTION_TARGET_COPY){
patchFileSize+=BPS_getVLVLen((Math.abs(action.relativeOffset)<<1)+(action.relativeOffset<0?1:0));
}
}
patchFileSize+=12;
var patchFile=new BinFile(patchFileSize);
patchFile.fileName=fileName+'.bps';
patchFile.littleEndian=true;
patchFile.writeVLV=BPS_writeVLV;
patchFile.writeString(BPS_MAGIC);
patchFile.writeVLV(this.sourceSize);
patchFile.writeVLV(this.targetSize);
patchFile.writeVLV(this.metaData.length);
patchFile.writeString(this.metaData, this.metaData.length);
for(var i=0; i<this.actions.length; i++){
var action=this.actions[i];
patchFile.writeVLV(((action.length-1)<<2) + action.type);
if(action.type===BPS_ACTION_TARGET_READ){
patchFile.writeBytes(action.bytes);
}else if(action.type===BPS_ACTION_SOURCE_COPY || action.type===BPS_ACTION_TARGET_COPY){
patchFile.writeVLV((Math.abs(action.relativeOffset)<<1)+(action.relativeOffset<0?1:0));
}
}
patchFile.writeU32(this.sourceChecksum);
patchFile.writeU32(this.targetChecksum);
patchFile.writeU32(this.patchChecksum);
return patchFile;
}
function BPS_Node(){
this.offset=0;
this.next=null;
};
BPS_Node.prototype.delete=function(){
if(this.next)
delete this.next;
}
BPS.buildFromRoms=function(original, modified, deltaMode){
var patch=new BPS();
patch.sourceSize=original.fileSize;
patch.targetSize=modified.fileSize;
if(deltaMode){
patch.actions=createBPSFromFilesDelta(original, modified);
}else{
patch.actions=createBPSFromFilesLinear(original, modified);
}
patch.sourceChecksum=original.hashCRC32();
patch.targetChecksum=modified.hashCRC32();
patch.patchChecksum = patch.calculateFileChecksum();
return patch;
}
/* delta implementation from https://github.com/chiya/beat/blob/master/nall/beat/linear.hpp */
function createBPSFromFilesLinear(original, modified){
var patchActions=[];
/* references to match original beat code */
var sourceData=original._u8array;
var targetData=modified._u8array;
var sourceSize=original.fileSize;
var targetSize=modified.fileSize;
var Granularity=1;
var targetRelativeOffset=0;
var outputOffset=0;
var targetReadLength=0;
function targetReadFlush(){
if(targetReadLength){
//encode(TargetRead | ((targetReadLength - 1) << 2));
var action={type:BPS_ACTION_TARGET_READ, length:targetReadLength, bytes:[]};
patchActions.push(action);
var offset = outputOffset - targetReadLength;
while(targetReadLength){
//write(targetData[offset++]);
action.bytes.push(targetData[offset++]);
targetReadLength--;
}
}
};
while(outputOffset < targetSize) {
var sourceLength = 0;
for(var n = 0; outputOffset + n < Math.min(sourceSize, targetSize); n++) {
if(sourceData[outputOffset + n] != targetData[outputOffset + n]) break;
sourceLength++;
}
var rleLength = 0;
for(var n = 1; outputOffset + n < targetSize; n++) {
if(targetData[outputOffset] != targetData[outputOffset + n]) break;
rleLength++;
}
if(rleLength >= 4) {
//write byte to repeat
targetReadLength++;
outputOffset++;
targetReadFlush();
//copy starting from repetition byte
//encode(TargetCopy | ((rleLength - 1) << 2));
var relativeOffset = (outputOffset - 1) - targetRelativeOffset;
//encode(relativeOffset << 1);
patchActions.push({type:BPS_ACTION_TARGET_COPY, length:rleLength, relativeOffset:relativeOffset});
outputOffset += rleLength;
targetRelativeOffset = outputOffset - 1;
} else if(sourceLength >= 4) {
targetReadFlush();
//encode(SourceRead | ((sourceLength - 1) << 2));
patchActions.push({type:BPS_ACTION_SOURCE_READ, length:sourceLength});
outputOffset += sourceLength;
} else {
targetReadLength += Granularity;
outputOffset += Granularity;
}
}
targetReadFlush();
return patchActions;
}
/* delta implementation from https://github.com/chiya/beat/blob/master/nall/beat/delta.hpp */
function createBPSFromFilesDelta(original, modified){
var patchActions=[];
/* references to match original beat code */
var sourceData=original._u8array;
var targetData=modified._u8array;
var sourceSize=original.fileSize;
var targetSize=modified.fileSize;
var Granularity=1;
var sourceRelativeOffset=0;
var targetRelativeOffset=0;
var outputOffset=0;
var sourceTree=new Array(65536);
var targetTree=new Array(65536);
for(var n=0; n<65536; n++){
sourceTree[n]=null;
targetTree[n]=null;
}
//source tree creation
for(var offset=0; offset<sourceSize; offset++) {
var symbol = sourceData[offset + 0];
//sourceChecksum = crc32_adjust(sourceChecksum, symbol);
if(offset < sourceSize - 1)
symbol |= sourceData[offset + 1] << 8;
var node=new BPS_Node();
node.offset=offset;
node.next=sourceTree[symbol];
sourceTree[symbol] = node;
}
var targetReadLength=0;
function targetReadFlush(){
if(targetReadLength) {
//encode(TargetRead | ((targetReadLength - 1) << 2));
var action={type:BPS_ACTION_TARGET_READ, length:targetReadLength, bytes:[]};
patchActions.push(action);
var offset = outputOffset - targetReadLength;
while(targetReadLength){
//write(targetData[offset++]);
action.bytes.push(targetData[offset++]);
targetReadLength--;
}
}
};
while(outputOffset<modified.fileSize){
var maxLength = 0, maxOffset = 0, mode = BPS_ACTION_TARGET_READ;
var symbol = targetData[outputOffset + 0];
if(outputOffset < targetSize - 1) symbol |= targetData[outputOffset + 1] << 8;
{ //source read
var length = 0, offset = outputOffset;
while(offset < sourceSize && offset < targetSize && sourceData[offset] == targetData[offset]) {
length++;
offset++;
}
if(length > maxLength) maxLength = length, mode = BPS_ACTION_SOURCE_READ;
}
{ //source copy
var node = sourceTree[symbol];
while(node) {
var length = 0, x = node.offset, y = outputOffset;
while(x < sourceSize && y < targetSize && sourceData[x++] == targetData[y++]) length++;
if(length > maxLength) maxLength = length, maxOffset = node.offset, mode = BPS_ACTION_SOURCE_COPY;
node = node.next;
}
}
{ //target copy
var node = targetTree[symbol];
while(node) {
var length = 0, x = node.offset, y = outputOffset;
while(y < targetSize && targetData[x++] == targetData[y++]) length++;
if(length > maxLength) maxLength = length, maxOffset = node.offset, mode = BPS_ACTION_TARGET_COPY;
node = node.next;
}
//target tree append
node = new BPS_Node();
node.offset = outputOffset;
node.next = targetTree[symbol];
targetTree[symbol] = node;
}
{ //target read
if(maxLength < 4) {
maxLength = Math.min(Granularity, targetSize - outputOffset);
mode = BPS_ACTION_TARGET_READ;
}
}
if(mode != BPS_ACTION_TARGET_READ) targetReadFlush();
switch(mode) {
case BPS_ACTION_SOURCE_READ:
//encode(BPS_ACTION_SOURCE_READ | ((maxLength - 1) << 2));
patchActions.push({type:BPS_ACTION_SOURCE_READ, length:maxLength});
break;
case BPS_ACTION_TARGET_READ:
//delay write to group sequential TargetRead commands into one
targetReadLength += maxLength;
break;
case BPS_ACTION_SOURCE_COPY:
case BPS_ACTION_TARGET_COPY:
//encode(mode | ((maxLength - 1) << 2));
var relativeOffset;
if(mode == BPS_ACTION_SOURCE_COPY) {
relativeOffset = maxOffset - sourceRelativeOffset;
sourceRelativeOffset = maxOffset + maxLength;
} else {
relativeOffset = maxOffset - targetRelativeOffset;
targetRelativeOffset = maxOffset + maxLength;
}
//encode((relativeOffset < 0) | (abs(relativeOffset) << 1));
patchActions.push({type:mode, length:maxLength, relativeOffset:relativeOffset});
break;
}
outputOffset += maxLength;
}
targetReadFlush();
return patchActions;
}

View File

@@ -0,0 +1,282 @@
/* IPS module for Rom Patcher JS v20250430 - Marc Robledo 2016-2025 - http://www.marcrobledo.com/license */
/* File format specification: http://www.smwiki.net/wiki/IPS_file_format */
/* This file also acts as EBP (EarthBound Patch) module */
/* EBP is actually just IPS with some JSON metadata stuck on the end (implementation: https://github.com/Lyrositor/EBPatcher) */
const IPS_MAGIC='PATCH';
const IPS_MAX_ROM_SIZE=0x1000000; //16 megabytes
const IPS_RECORD_RLE=0x0000;
const IPS_RECORD_SIMPLE=0x01;
if(typeof module !== "undefined" && module.exports){
module.exports = IPS;
}
function IPS(){
this.records=[];
this.truncate=false;
this.EBPmetadata=null;
}
IPS.prototype.addSimpleRecord=function(o, d){
this.records.push({offset:o, type:IPS_RECORD_SIMPLE, length:d.length, data:d})
}
IPS.prototype.addRLERecord=function(o, l, b){
this.records.push({offset:o, type:IPS_RECORD_RLE, length:l, byte:b})
}
IPS.prototype.setEBPMetadata=function(metadataObject){
if(typeof metadataObject !== 'object')
throw new TypeError('metadataObject must be an object');
for(var key in metadataObject){
if(typeof metadataObject[key] !== 'string')
throw new TypeError('metadataObject values must be strings');
}
/* EBPatcher (linked above) expects the "patcher" field to be EBPatcher to read the metadata */
/* CoilSnake (EB modding tool) inserts this manually too */
/* So we also add it here for compatibility purposes */
this.EBPmetadata={patcher:'EBPatcher', ...metadataObject};
}
IPS.prototype.getDescription=function(){
if(this.EBPmetadata){
var description='';
for(var key in this.EBPmetadata){
if(key==='patcher')
continue;
const keyPretty=key.charAt(0).toUpperCase() + key.slice(1);
description+=keyPretty+': '+this.EBPmetadata[key]+'\n';
}
return description.trim();
}
return null;
}
IPS.prototype.toString=function(){
nSimpleRecords=0;
nRLERecords=0;
for(var i=0; i<this.records.length; i++){
if(this.records[i].type===IPS_RECORD_RLE)
nRLERecords++;
else
nSimpleRecords++;
}
var s='Simple records: '+nSimpleRecords;
s+='\nRLE records: '+nRLERecords;
s+='\nTotal records: '+this.records.length;
if(this.truncate && !this.EBPmetadata)
s+='\nTruncate at: 0x'+this.truncate.toString(16);
else if(this.EBPmetadata)
s+='\nEBP Metadata: '+JSON.stringify(this.EBPmetadata);
return s
}
IPS.prototype.export=function(fileName){
var patchFileSize=5; //PATCH string
for(var i=0; i<this.records.length; i++){
if(this.records[i].type===IPS_RECORD_RLE)
patchFileSize+=(3+2+2+1); //offset+0x0000+length+RLE byte to be written
else
patchFileSize+=(3+2+this.records[i].data.length); //offset+length+data
}
patchFileSize+=3; //EOF string
if(this.truncate && !this.EBPmetadata)
patchFileSize+=3; //truncate
else if(this.EBPmetadata)
patchFileSize+=JSON.stringify(this.EBPmetadata).length;
tempFile=new BinFile(patchFileSize);
tempFile.fileName=fileName+(this.EBPmetadata? '.ebp' : '.ips');
tempFile.writeString(IPS_MAGIC);
for(var i=0; i<this.records.length; i++){
var rec=this.records[i];
tempFile.writeU24(rec.offset);
if(rec.type===IPS_RECORD_RLE){
tempFile.writeU16(0x0000);
tempFile.writeU16(rec.length);
tempFile.writeU8(rec.byte);
}else{
tempFile.writeU16(rec.data.length);
tempFile.writeBytes(rec.data);
}
}
tempFile.writeString('EOF');
if(this.truncate && !this.EBPmetadata)
tempFile.writeU24(this.truncate);
else if(this.EBPmetadata)
tempFile.writeString(JSON.stringify(this.EBPmetadata));
return tempFile
}
IPS.prototype.apply=function(romFile){
if(this.truncate && !this.EBPmetadata){
if(this.truncate>romFile.fileSize){ //expand (discussed here: https://github.com/marcrobledo/RomPatcher.js/pull/46)
tempFile=new BinFile(this.truncate);
romFile.copyTo(tempFile, 0, romFile.fileSize, 0);
}else{ //truncate
tempFile=romFile.slice(0, this.truncate);
}
}else{
//calculate target ROM size, expanding it if any record offset is beyond target ROM size
var newFileSize=romFile.fileSize;
for(var i=0; i<this.records.length; i++){
var rec=this.records[i];
if(rec.type===IPS_RECORD_RLE){
if(rec.offset+rec.length>newFileSize){
newFileSize=rec.offset+rec.length;
}
}else{
if(rec.offset+rec.data.length>newFileSize){
newFileSize=rec.offset+rec.data.length;
}
}
}
if(newFileSize===romFile.fileSize){
tempFile=romFile.slice(0, romFile.fileSize);
}else{
tempFile=new BinFile(newFileSize);
romFile.copyTo(tempFile,0);
}
}
romFile.seek(0);
for(var i=0; i<this.records.length; i++){
tempFile.seek(this.records[i].offset);
if(this.records[i].type===IPS_RECORD_RLE){
for(var j=0; j<this.records[i].length; j++)
tempFile.writeU8(this.records[i].byte);
}else{
tempFile.writeBytes(this.records[i].data);
}
}
return tempFile
}
IPS.MAGIC=IPS_MAGIC;
IPS.fromFile=function(file){
var patchFile=new IPS();
file.seek(5);
while(!file.isEOF()){
var offset=file.readU24();
if(offset===0x454f46){ /* EOF */
if(file.isEOF()){
break;
}else if((file.offset+3)===file.fileSize){
patchFile.truncate=file.readU24();
break;
}else if (file.readU8()==='{'.charCodeAt(0)) {
file.skip(-1);
patchFile.setEBPMetadata(JSON.parse(file.readString(file.fileSize-file.offset)));
break;
}
}
var length=file.readU16();
if(length===IPS_RECORD_RLE){
patchFile.addRLERecord(offset, file.readU16(), file.readU8());
}else{
patchFile.addSimpleRecord(offset, file.readBytes(length));
}
}
return patchFile;
}
IPS.buildFromRoms=function(original, modified, asEBP=false){
var patch=new IPS();
if(!asEBP && modified.fileSize<original.fileSize){
patch.truncate=modified.fileSize;
}else if(asEBP){
patch.setEBPMetadata(typeof asEBP==='object'? asEBP : {
'Author':'Unknown',
'Title':'Untitled',
'Description':'No description',
});
}
//solucion: guardar startOffset y endOffset (ir mirando de 6 en 6 hacia atrás)
var previousRecord={type:0xdeadbeef,startOffset:0,length:0};
while(!modified.isEOF()){
var b1=original.isEOF()?0x00:original.readU8();
var b2=modified.readU8();
if(b1!==b2){
var RLEmode=true;
var differentData=[];
var startOffset=modified.offset-1;
while(b1!==b2 && differentData.length<0xffff){
differentData.push(b2);
if(b2!==differentData[0])
RLEmode=false;
if(modified.isEOF() || differentData.length===0xffff)
break;
b1=original.isEOF()?0x00:original.readU8();
b2=modified.readU8();
}
//check if this record is near the previous one
var distance=startOffset-(previousRecord.offset+previousRecord.length);
if(
previousRecord.type===IPS_RECORD_SIMPLE &&
distance<6 && (previousRecord.length+distance+differentData.length)<0xffff
){
if(RLEmode && differentData.length>6){
// separate a potential RLE record
original.seek(startOffset);
modified.seek(startOffset);
previousRecord={type:0xdeadbeef,startOffset:0,length:0};
}else{
// merge both records
while(distance--){
previousRecord.data.push(modified._u8array[previousRecord.offset+previousRecord.length]);
previousRecord.length++;
}
previousRecord.data=previousRecord.data.concat(differentData);
previousRecord.length=previousRecord.data.length;
}
}else{
if(startOffset>=IPS_MAX_ROM_SIZE){
throw new Error(`Files are too big for ${patch.EBPmetadata? 'EBP' : 'IPS'} format`);
return null;
}
if(RLEmode && differentData.length>2){
patch.addRLERecord(startOffset, differentData.length, differentData[0]);
}else{
patch.addSimpleRecord(startOffset, differentData);
}
previousRecord=patch.records[patch.records.length-1];
}
}
}
if(modified.fileSize>original.fileSize){
var lastRecord=patch.records[patch.records.length-1];
var lastOffset=lastRecord.offset+lastRecord.length;
if(lastOffset<modified.fileSize){
patch.addSimpleRecord(modified.fileSize-1, [0x00]);
}
}
return patch
}

View File

@@ -0,0 +1,97 @@
/* PMSR (Paper Mario Star Rod) module for Rom Patcher JS v20240721 - Marc Robledo 2020-2024 - http://www.marcrobledo.com/license */
/* File format specification: http://origami64.net/attachment.php?aid=790 (dead link) */
const PMSR_MAGIC='PMSR';
const YAY0_MAGIC='Yay0';
const PAPER_MARIO_USA10_CRC32=0xa7f5cd7e;
const PAPER_MARIO_USA10_FILE_SIZE=41943040;
if(typeof module !== "undefined" && module.exports){
module.exports = PMSR;
}
function PMSR(){
this.targetSize=0;
this.records=[];
}
PMSR.prototype.addRecord=function(offset, data){
this.records.push({offset:offset, data:data})
}
PMSR.prototype.toString=function(){
var s='Star Rod patch';
s+='\nTarget file size: '+this.targetSize;
s+='\n#Records: '+this.records.length;
return s;
}
PMSR.prototype.validateSource=function(romFile){
return romFile.fileSize===PAPER_MARIO_USA10_FILE_SIZE && romFile.hashCRC32()===PAPER_MARIO_USA10_CRC32;
}
PMSR.prototype.getValidationInfo=function(){
return {
'type':'CRC32',
'value':PAPER_MARIO_USA10_CRC32
}
}
PMSR.prototype.apply=function(romFile, validate){
if(validate && !this.validateSource(romFile)){
throw new Error('Source ROM checksum mismatch');
}
console.log('a');
if(this.targetSize===romFile.fileSize){
tempFile=romFile.slice(0, romFile.fileSize);
}else{
tempFile=new BinFile(this.targetSize);
romFile.copyTo(tempFile,0);
}
console.log('b');
for(var i=0; i<this.records.length; i++){
tempFile.seek(this.records[i].offset);
tempFile.writeBytes(this.records[i].data);
}
return tempFile;
}
PMSR.MAGIC=PMSR_MAGIC;
PMSR.fromFile=function(file){
var patch=new PMSR();
/*file.seek(0);
if(file.readString(YAY0_MAGIC.length)===YAY0_MAGIC){
file=PMSR.YAY0_decode(file);
}*/
patch.targetSize=PAPER_MARIO_USA10_FILE_SIZE;
file.seek(4);
var nRecords=file.readU32();
for(var i=0; i<nRecords; i++){
var offset=file.readU32();
var length=file.readU32();
patch.addRecord(offset, file.readBytes(length));
if((offset+length)>patch.targetSize)
patch.targetSize=offset+length;
}
return patch;
}
/* to-do */
//PMSR.prototype.export=function(fileName){return null}
//PMSR.buildFromRoms=function(original, modified){return null}
/* https://github.com/pho/WindViewer/wiki/Yaz0-and-Yay0 */
PMSR.YAY0_decode=function(file){
/* to-do */
}

View File

@@ -0,0 +1,269 @@
/* PPF module for Rom Patcher JS v20200221 - Marc Robledo 2019-2020 - http://www.marcrobledo.com/license */
/* File format specification: https://www.romhacking.net/utilities/353/ */
const PPF_MAGIC='PPF';
const PPF_IMAGETYPE_BIN=0x00;
const PPF_IMAGETYPE_GI=0x01;
const PPF_BEGIN_FILE_ID_DIZ_MAGIC='@BEG';//@BEGIN_FILE_ID.DIZ
if(typeof module !== "undefined" && module.exports){
module.exports = PPF;
}
function PPF(){
this.version=3;
this.imageType=PPF_IMAGETYPE_BIN;
this.blockCheck=false;
this.undoData=false;
this.records=[];
}
PPF.prototype.addRecord=function(offset, data, undoData){
if(this.undoData){
this.records.push({offset:offset, data:data, undoData:undoData});
}else{
this.records.push({offset:offset, data:data});
}
}
PPF.prototype.toString=function(){
var s=this.description;
s+='\nPPF version: '+this.version;
s+='\n#Records: '+this.records.length;
s+='\nImage type: '+this.imageType;
s+='\nBlock check: '+!!this.blockCheck;
s+='\nUndo data: '+this.undoData;
if(this.fileIdDiz)
s+='\nFILE_ID.DIZ: '+this.fileIdDiz;
return s
}
PPF.prototype.export=function(fileName){
var patchFileSize=5+1+50; //PPFx0
for(var i=0; i<this.records.length; i++){
patchFileSize+=4+1+this.records[i].data.length;
if(this.version===3)
patchFileSize+=4; //offsets are u64
}
if(this.version===3 || this.version===2){
patchFileSize+=4;
}
if(this.blockCheck){
patchFileSize+=1024;
}
if(this.fileIdDiz){
patchFileSize+=18+this.fileIdDiz.length+16+4;
}
tempFile=new BinFile(patchFileSize);
tempFile.fileName=fileName+'.ppf';
tempFile.writeString(PPF_MAGIC);
tempFile.writeString((this.version*10).toString());
tempFile.writeU8(parseInt(this.version)-1);
tempFile.writeString(this.description, 50);
if(this.version===3){
tempFile.writeU8(this.imageType);
tempFile.writeU8(this.blockCheck?0x01:0x00);
tempFile.writeU8(this.undoData?0x01:0x00);
tempFile.writeU8(0x00); //dummy
}else if(this.version===2){
tempFile.writeU32(this.inputFileSize);
}
if(this.blockCheck){
tempFile.writeBytes(this.blockCheck);
}
tempFile.littleEndian=true;
for(var i=0; i<this.records.length; i++){
tempFile.writeU32(this.records[i].offset & 0xffffffff);
if(this.version===3){
var offset2=this.records[i].offset;
for(var j=0; j<32; j++)
offset2=parseInt((offset2/2)>>>0);
tempFile.writeU32(offset2);
}
tempFile.writeU8(this.records[i].data.length);
tempFile.writeBytes(this.records[i].data);
if(this.undoData)
tempFile.writeBytes(this.records[i].undoData);
}
if(this.fileIdDiz){
tempFile.writeString('@BEGIN_FILE_ID.DIZ');
tempFile.writeString(this.fileIdDiz);
tempFile.writeString('@END_FILE_ID.DIZ');
tempFile.writeU16(this.fileIdDiz.length);
tempFile.writeU16(0x00);
}
return tempFile
}
PPF.prototype.apply=function(romFile){
var newFileSize=romFile.fileSize;
for(var i=0; i<this.records.length; i++){
if(this.records[i].offset+this.records[i].data.length>newFileSize)
newFileSize=this.records[i].offset+this.records[i].data.length;
}
if(newFileSize===romFile.fileSize){
tempFile=romFile.slice(0, romFile.fileSize);
}else{
tempFile=new BinFile(newFileSize);
romFile.copyTo(tempFile,0);
}
//check if undoing
var undoingData=false;
if(this.undoData){
tempFile.seek(this.records[0].offset);
var originalBytes=tempFile.readBytes(this.records[0].data.length);
var foundDifferences=false;
for(var i=0; i<originalBytes.length && !foundDifferences; i++){
if(originalBytes[i]!==this.records[0].data[i]){
foundDifferences=true;
}
}
if(!foundDifferences){
undoingData=true;
}
}
for(var i=0; i<this.records.length; i++){
tempFile.seek(this.records[i].offset);
if(undoingData){
tempFile.writeBytes(this.records[i].undoData);
}else{
tempFile.writeBytes(this.records[i].data);
}
}
return tempFile
}
PPF.MAGIC=PPF_MAGIC;
PPF.fromFile=function(patchFile){
var patch=new PPF();
patchFile.seek(3);
var version1=parseInt(patchFile.readString(2))/10;
var version2=patchFile.readU8()+1;
if(version1!==version2 || version1>3){
throw new Error('invalid PPF version');
}
patch.version=version1;
patch.description=patchFile.readString(50).replace(/ +$/,'');
if(patch.version===3){
patch.imageType=patchFile.readU8();
if(patchFile.readU8())
patch.blockCheck=true;
if(patchFile.readU8())
patch.undoData=true;
patchFile.skip(1);
}else if(patch.version===2){
patch.blockCheck=true;
patch.inputFileSize=patchFile.readU32();
}
if(patch.blockCheck){
patch.blockCheck=patchFile.readBytes(1024);
}
patchFile.littleEndian=true;
while(!patchFile.isEOF()){
if(patchFile.readString(4)===PPF_BEGIN_FILE_ID_DIZ_MAGIC){
patchFile.skip(14);
//console.log('found file_id.diz begin');
patch.fileIdDiz=patchFile.readString(3072);
patch.fileIdDiz=patch.fileIdDiz.substr(0, patch.fileIdDiz.indexOf('@END_FILE_ID.DIZ'));
break;
}
patchFile.skip(-4);
var offset;
if(patch.version===3){
var u64_1=patchFile.readU32();
var u64_2=patchFile.readU32();
offset=u64_1+(u64_2*0x100000000);
}else
offset=patchFile.readU32();
var len=patchFile.readU8();
var data=patchFile.readBytes(len);
var undoData=false;
if(patch.undoData){
undoData=patchFile.readBytes(len);
}
patch.addRecord(offset, data, undoData);
}
return patch;
}
PPF.buildFromRoms=function(original, modified){
var patch=new PPF();
patch.description='Patch description';
if(original.fileSize>modified.fileSize){
var expandedModified=new BinFile(original.fileSize);
modified.copyTo(expandedModified,0);
modified=expandedModified;
}
original.seek(0);
modified.seek(0);
while(!modified.isEOF()){
var b1=original.isEOF()?0x00:original.readU8();
var b2=modified.readU8();
if(b1!==b2){
var differentData=[];
var offset=modified.offset-1;
while(b1!==b2 && differentData.length<0xff){
differentData.push(b2);
if(modified.isEOF() || differentData.length===0xff)
break;
b1=original.isEOF()?0x00:original.readU8();
b2=modified.readU8();
}
patch.addRecord(offset, differentData);
}
}
if(original.fileSize<modified.fileSize){
modified.seek(modified.fileSize-1);
if(modified.readU8()===0x00)
patch.addRecord(modified.fileSize-1, [0x00]);
}
return patch
}

View File

@@ -0,0 +1,396 @@
/* RUP module for Rom Patcher JS v20250430 - Marc Robledo 2018-2025 - http://www.marcrobledo.com/license */
/* File format specification: http://www.romhacking.net/documents/288/ */
const RUP_MAGIC='NINJA2';
const RUP_COMMAND_END=0x00;
const RUP_COMMAND_OPEN_NEW_FILE=0x01;
const RUP_COMMAND_XOR_RECORD=0x02;
const RUP_ROM_TYPES=['raw','nes','fds','snes','n64','gb','sms','mega','pce','lynx'];
if(typeof module !== "undefined" && module.exports){
module.exports = RUP;
}
function RUP(){
this.author='';
this.version='';
this.title='';
this.genre='';
this.language='';
this.date='';
this.web='';
this.description='';
this.files=[];
}
RUP.prototype.toString=function(){
var s='Author: '+this.author;
s+='\nVersion: '+this.version;
s+='\nTitle: '+this.title;
s+='\nGenre: '+this.genre;
s+='\nLanguage: '+this.language;
s+='\nDate: '+this.date;
s+='\nWeb: '+this.web;
s+='\nDescription: '+this.description;
for(var i=0; i<this.files.length; i++){
var file=this.files[i];
s+='\n---------------';
s+='\nFile '+i+':';
s+='\nFile name: '+file.fileName;
s+='\nRom type: '+RUP_ROM_TYPES[file.romType];
s+='\nSource file size: '+file.sourceFileSize;
s+='\nTarget file size: '+file.targetFileSize;
s+='\nSource MD5: '+file.sourceMD5;
s+='\nTarget MD5: '+file.targetMD5;
if(file.overflowMode==='A'){
s+='\nOverflow mode: Append ' + file.overflowData.length + ' bytes';
}else if(file.overflowMode==='M'){
s+='\nOverflow mode: Minify ' + file.overflowData.length + ' bytes';
}
s+='\n#records: '+file.records.length;
}
return s
}
RUP.prototype.validateSource=function(romFile,headerSize){
var md5string=romFile.hashMD5(headerSize);
for(var i=0; i<this.files.length; i++){
if(this.files[i].sourceMD5===md5string || this.files[i].targetMD5===md5string){
return {
file:this.files[i],
undo:this.files[i].targetMD5===md5string
};
}
}
return false;
}
RUP.prototype.getValidationInfo=function(){
var values=[];
for(var i=0; i<this.files.length; i++){
values.push(this.files[i].sourceMD5);
}
return {
'type':'MD5',
'value':values
};
}
RUP.prototype.getDescription=function(){
return this.description? this.description : null;
}
RUP.prototype.apply=function(romFile, validate){
var validFile;
if(validate){
validFile=this.validateSource(romFile);
if(!validFile)
throw new Error('Source ROM checksum mismatch');
}else{
validFile={
file:this.files[0],
undo:this.files[0].targetMD5===romFile.hashMD5()
};
}
var undo=validFile.undo;
var patch=validFile.file;
tempFile=new BinFile(!undo? patch.targetFileSize : patch.sourceFileSize);
/* copy original file */
romFile.copyTo(tempFile, 0);
for(var i=0; i<patch.records.length; i++){
var offset=patch.records[i].offset;
romFile.seek(offset);
tempFile.seek(offset);
for(var j=0; j<patch.records[i].xor.length; j++){
tempFile.writeU8(
(romFile.isEOF()?0x00:romFile.readU8()) ^ patch.records[i].xor[j]
);
}
}
/* add overflow data if needed */
if(patch.overflowMode==='A' && !undo){ /* append */
tempFile.seek(patch.sourceFileSize);
tempFile.writeBytes(patch.overflowData.map((byte) => byte ^ 0xff));
}else if(patch.overflowMode==='M' && undo){ /* minify */
tempFile.seek(patch.targetFileSize);
tempFile.writeBytes(patch.overflowData.map((byte) => byte ^ 0xff));
}
if(
validate &&
(
(!undo && tempFile.hashMD5()!==patch.targetMD5) ||
(undo && tempFile.hashMD5()!==patch.sourceMD5)
)
){
throw new Error('Target ROM checksum mismatch');
}
if(undo)
tempFile.unpatched=true;
return tempFile
}
RUP.MAGIC=RUP_MAGIC;
RUP.padZeroes=function(intVal, nBytes){
var hexString=intVal.toString(16);
while(hexString.length<nBytes*2)
hexString='0'+hexString;
return hexString
};
RUP.fromFile=function(file){
var patch=new RUP();
file.readVLV=RUP_readVLV;
file.seek(RUP_MAGIC.length);
patch.textEncoding=file.readU8();
patch.author=file.readString(84);
patch.version=file.readString(11);
patch.title=file.readString(256);
patch.genre=file.readString(48);
patch.language=file.readString(48);
patch.date=file.readString(8);
patch.web=file.readString(512);
patch.description=file.readString(1074).replace(/\\n/g,'\n');
file.seek(0x800);
var nextFile;
while(!file.isEOF()){
var command=file.readU8();
if(command===RUP_COMMAND_OPEN_NEW_FILE){
if(nextFile)
patch.files.push(nextFile)
nextFile={
records:[]
};
nextFile.fileName=file.readString(file.readVLV());
nextFile.romType=file.readU8();
nextFile.sourceFileSize=file.readVLV();
nextFile.targetFileSize=file.readVLV();
nextFile.sourceMD5='';
for(var i=0; i<16; i++)
nextFile.sourceMD5+=RUP.padZeroes(file.readU8(),1);
nextFile.targetMD5='';
for(var i=0; i<16; i++)
nextFile.targetMD5+=RUP.padZeroes(file.readU8(),1);
if(nextFile.sourceFileSize!==nextFile.targetFileSize){
nextFile.overflowMode=file.readString(1); // 'M' (source>target) or 'A' (source<target)
if(nextFile.overflowMode!=='M' && nextFile.overflowMode!=='A')
throw new Error('RUP: invalid overflow mode');
nextFile.overflowData=file.readBytes(file.readVLV());
}
}else if(command===RUP_COMMAND_XOR_RECORD){
nextFile.records.push({
offset:file.readVLV(),
xor:file.readBytes(file.readVLV())
});
}else if(command===RUP_COMMAND_END){
if(nextFile)
patch.files.push(nextFile);
break;
}else{
throw new Error('invalid RUP command');
}
}
return patch;
}
function RUP_readVLV(){
var nBytes=this.readU8();
var data=0;
for(var i=0; i<nBytes; i++){
data+=this.readU8() << i*8;
}
return data;
}
function RUP_writeVLV(data){
var len=RUP_getVLVLen(data)-1;
this.writeU8(len);
while(data){
this.writeU8(data & 0xff);
data>>=8;
}
}
function RUP_getVLVLen(data){
var ret=1;
while(data){
ret++;
data>>=8;
}
return ret;
}
RUP.prototype.export=function(fileName){
var patchFileSize=2048;
for(var i=0; i<this.files.length; i++){
var file=this.files[i];
patchFileSize++; //command 0x01
patchFileSize+=RUP_getVLVLen(file.fileName.length);
patchFileSize+=file.fileName.length;
patchFileSize++; //rom type
patchFileSize+=RUP_getVLVLen(file.sourceFileSize);
patchFileSize+=RUP_getVLVLen(file.targetFileSize);
patchFileSize+=32; //MD5s
if(file.sourceFileSize!==file.targetFileSize){
patchFileSize++; // M or A
patchFileSize+=RUP_getVLVLen(file.overflowData.length);
patchFileSize+=file.overflowData.length;
}
for(var j=0; j<file.records.length; j++){
patchFileSize++; //command 0x01
patchFileSize+=RUP_getVLVLen(file.records[j].offset);
patchFileSize+=RUP_getVLVLen(file.records[j].xor.length);
patchFileSize+=file.records[j].xor.length;
}
}
patchFileSize++; //command 0x00
var patchFile=new BinFile(patchFileSize);
patchFile.fileName=fileName+'.rup';
patchFile.writeVLV=RUP_writeVLV;
patchFile.writeString(RUP_MAGIC);
patchFile.writeU8(this.textEncoding);
patchFile.writeString(this.author, 84);
patchFile.writeString(this.version, 11);
patchFile.writeString(this.title, 256);
patchFile.writeString(this.genre, 48);
patchFile.writeString(this.language, 48);
patchFile.writeString(this.date, 8);
patchFile.writeString(this.web, 512);
patchFile.writeString(this.description.replace(/\n/g,'\\n'), 1074);
for(var i=0; i<this.files.length; i++){
var file=this.files[i];
patchFile.writeU8(RUP_COMMAND_OPEN_NEW_FILE);
patchFile.writeVLV(file.fileName);
patchFile.writeU8(file.romType);
patchFile.writeVLV(file.sourceFileSize);
patchFile.writeVLV(file.targetFileSize);
for(var j=0; j<16; j++)
patchFile.writeU8(parseInt(file.sourceMD5.substr(j*2,2), 16));
for(var j=0; j<16; j++)
patchFile.writeU8(parseInt(file.targetMD5.substr(j*2,2), 16));
if(file.sourceFileSize!==file.targetFileSize){
patchFile.writeString(file.sourceFileSize>file.targetFileSize?'M':'A');
patchFile.writeVLV(file.overflowData.length);
patchFile.writeBytes(file.overflowData);
}
for(var j=0; j<file.records.length; j++){
patchFile.writeU8(RUP_COMMAND_XOR_RECORD);
patchFile.writeVLV(file.records[j].offset);
patchFile.writeVLV(file.records[j].xor.length);
patchFile.writeBytes(file.records[j].xor);
}
}
patchFile.writeU8(RUP_COMMAND_END);
return patchFile;
}
RUP.buildFromRoms=function(original, modified, description){
var patch=new RUP();
var today=new Date();
patch.date=(today.getYear()+1900)+RUP.padZeroes(today.getMonth()+1, 1)+RUP.padZeroes(today.getDate(), 1);
if(description)
patch.description=description;
var file={
fileName:'',
romType:0,
sourceFileSize:original.fileSize,
targetFileSize:modified.fileSize,
sourceMD5:original.hashMD5(),
targetMD5:modified.hashMD5(),
overflowMode:null,
overflowData:[],
records:[]
};
if(file.sourceFileSize<file.targetFileSize){
modified.seek(file.sourceFileSize);
file.overflowMode='A';
file.overflowData=modified.readBytes(file.targetFileSize-file.sourceFileSize).map((byte) => byte ^ 0xff);
modified=modified.slice(0, file.sourceFileSize);
}else if(file.sourceFileSize>file.targetFileSize){
original.seek(file.targetFileSize);
file.overflowMode='M';
file.overflowData=original.readBytes(file.sourceFileSize-file.targetFileSize).map((byte) => byte ^ 0xff);
original=original.slice(0, file.targetFileSize);
}
original.seek(0);
modified.seek(0);
while(!modified.isEOF()){
var b1=original.isEOF()?0x00:original.readU8();
var b2=modified.readU8();
if(b1!==b2){
var originalOffset=modified.offset-1;
var xorDifferences=[];
while(b1!==b2){
xorDifferences.push(b1^b2);
if(modified.isEOF())
break;
b1=original.isEOF()?0x00:original.readU8();
b2=modified.readU8();
}
file.records.push({offset:originalOffset, xor:xorDifferences});
}
}
patch.files.push(file);
return patch
}

View File

@@ -0,0 +1,224 @@
/* UPS module for Rom Patcher JS v20240721 - Marc Robledo 2017-2024 - http://www.marcrobledo.com/license */
/* File format specification: http://www.romhacking.net/documents/392/ */
const UPS_MAGIC='UPS1';
if(typeof module !== "undefined" && module.exports){
module.exports = UPS;
}
function UPS(){
this.records=[];
this.sizeInput=0;
this.sizeOutput=0;
this.checksumInput=0;
this.checksumOutput=0;
}
UPS.prototype.addRecord=function(relativeOffset, d){
this.records.push({offset:relativeOffset, XORdata:d})
}
UPS.prototype.toString=function(){
var s='Records: '+this.records.length;
s+='\nInput file size: '+this.sizeInput;
s+='\nOutput file size: '+this.sizeOutput;
s+='\nInput file checksum: '+this.checksumInput.toString(16);
s+='\nOutput file checksum: '+this.checksumOutput.toString(16);
return s
}
UPS.prototype.export=function(fileName){
var patchFileSize=UPS_MAGIC.length;//UPS1 string
patchFileSize+=UPS_getVLVLength(this.sizeInput); //input file size
patchFileSize+=UPS_getVLVLength(this.sizeOutput); //output file size
for(var i=0; i<this.records.length; i++){
patchFileSize+=UPS_getVLVLength(this.records[i].offset);
patchFileSize+=this.records[i].XORdata.length+1;
}
patchFileSize+=12; //input/output/patch checksums
tempFile=new BinFile(patchFileSize);
tempFile.writeVLV=UPS_writeVLV;
tempFile.fileName=fileName+'.ups';
tempFile.writeString(UPS_MAGIC);
tempFile.writeVLV(this.sizeInput);
tempFile.writeVLV(this.sizeOutput);
for(var i=0; i<this.records.length; i++){
tempFile.writeVLV(this.records[i].offset);
tempFile.writeBytes(this.records[i].XORdata);
tempFile.writeU8(0x00);
}
tempFile.littleEndian=true;
tempFile.writeU32(this.checksumInput);
tempFile.writeU32(this.checksumOutput);
tempFile.writeU32(tempFile.hashCRC32(0, tempFile.fileSize - 4));
return tempFile
}
UPS.prototype.validateSource=function(romFile,headerSize){return romFile.hashCRC32(headerSize)===this.checksumInput}
UPS.prototype.getValidationInfo=function(){
return {
'type':'CRC32',
'value':this.checksumInput
};
}
UPS.prototype.apply=function(romFile, validate){
if(validate && !this.validateSource(romFile)){
throw new Error('Source ROM checksum mismatch');
}
/* fix the glitch that cut the end of the file if it's larger than the changed file patch was originally created with */
/* more info: https://github.com/marcrobledo/RomPatcher.js/pull/40#issuecomment-1069087423 */
sizeOutput = this.sizeOutput;
sizeInput = this.sizeInput;
if(!validate && sizeInput < romFile.fileSize){
sizeInput = romFile.fileSize;
if(sizeOutput < sizeInput){
sizeOutput = sizeInput;
}
}
/* copy original file */
tempFile=new BinFile(sizeOutput);
romFile.copyTo(tempFile, 0, sizeInput);
romFile.seek(0);
var nextOffset=0;
for(var i=0; i<this.records.length; i++){
var record=this.records[i];
tempFile.skip(record.offset);
romFile.skip(record.offset);
for(var j=0; j<record.XORdata.length; j++){
tempFile.writeU8((romFile.isEOF()?0x00:romFile.readU8()) ^ record.XORdata[j]);
}
tempFile.skip(1);
romFile.skip(1);
}
if(validate && tempFile.hashCRC32()!==this.checksumOutput){
throw new Error('Target ROM checksum mismatch');
}
return tempFile
}
UPS.MAGIC=UPS_MAGIC;
/* encode/decode variable length values, used by UPS file structure */
function UPS_writeVLV(data){
while(1){
var x=data & 0x7f;
data=data>>7;
if(data===0){
this.writeU8(0x80 | x);
break;
}
this.writeU8(x);
data=data-1;
}
}
function UPS_readVLV(){
var data=0;
var shift=1;
while(1){
var x=this.readU8();
if(x==-1)
throw new Error('Can\'t read UPS VLV at 0x'+(this.offset-1).toString(16));
data+=(x&0x7f)*shift;
if((x&0x80)!==0)
break;
shift=shift<<7;
data+=shift;
}
return data
}
function UPS_getVLVLength(data){
var len=0;
while(1){
var x=data & 0x7f;
data=data>>7;
len++;
if(data===0){
break;
}
data=data-1;
}
return len;
}
UPS.fromFile=function(file){
var patch=new UPS();
file.readVLV=UPS_readVLV;
file.seek(UPS_MAGIC.length);
patch.sizeInput=file.readVLV();
patch.sizeOutput=file.readVLV();
var nextOffset=0;
while(file.offset<(file.fileSize-12)){
var relativeOffset=file.readVLV();
var XORdifferences=[];
while(file.readU8()){
XORdifferences.push(file._lastRead);
}
patch.addRecord(relativeOffset, XORdifferences);
}
file.littleEndian=true;
patch.checksumInput=file.readU32();
patch.checksumOutput=file.readU32();
if(file.readU32()!==file.hashCRC32(0, file.fileSize - 4)){
throw new Error('Patch checksum mismatch');
}
file.littleEndian=false;
return patch;
}
UPS.buildFromRoms=function(original, modified){
var patch=new UPS();
patch.sizeInput=original.fileSize;
patch.sizeOutput=modified.fileSize;
var previousSeek=1;
while(!modified.isEOF()){
var b1=original.isEOF()?0x00:original.readU8();
var b2=modified.readU8();
if(b1!==b2){
var currentSeek=modified.offset;
var XORdata=[];
while(b1!==b2){
XORdata.push(b1 ^ b2);
if(modified.isEOF())
break;
b1=original.isEOF()?0x00:original.readU8();
b2=modified.readU8();
}
patch.addRecord(currentSeek-previousSeek, XORdata);
previousSeek=currentSeek+XORdata.length+1;
}
}
patch.checksumInput=original.hashCRC32();
patch.checksumOutput=modified.hashCRC32();
return patch
}

View File

@@ -0,0 +1,382 @@
/* VCDIFF module for RomPatcher.js v20181021 - Marc Robledo 2018 - http://www.marcrobledo.com/license */
/* File format specification: https://tools.ietf.org/html/rfc3284 */
/*
Mostly based in:
https://github.com/vic-alexiev/TelerikAcademy/tree/master/C%23%20Fundamentals%20II/Homework%20Assignments/3.%20Methods/000.%20MiscUtil/Compression/Vcdiff
some code and ideas borrowed from:
https://hack64.net/jscripts/libpatch.js?6
*/
//const VCDIFF_MAGIC=0xd6c3c400;
const VCDIFF_MAGIC='\xd6\xc3\xc4';
/*
const XDELTA_014_MAGIC='%XDELTA';
const XDELTA_018_MAGIC='%XDZ000';
const XDELTA_020_MAGIC='%XDZ001';
const XDELTA_100_MAGIC='%XDZ002';
const XDELTA_104_MAGIC='%XDZ003';
const XDELTA_110_MAGIC='%XDZ004';
*/
if(typeof module !== "undefined" && module.exports){
module.exports = VCDIFF;
BinFile = require("./BinFile");
}
function VCDIFF(patchFile){
this.file=patchFile;
}
VCDIFF.prototype.toString=function(){
return 'VCDIFF patch'
}
VCDIFF.prototype.apply=function(romFile, validate){
//romFile._u8array=new Uint8Array(romFile._dataView.buffer);
//var t0=performance.now();
var parser=new VCDIFF_Parser(this.file);
//read header
parser.seek(4);
var headerIndicator=parser.readU8();
if(headerIndicator & VCD_DECOMPRESS){
//has secondary decompressor, read its id
var secondaryDecompressorId=parser.readU8();
if(secondaryDecompressorId!==0)
throw new Error('not implemented: secondary decompressor');
}
if(headerIndicator & VCD_CODETABLE){
var codeTableDataLength=parser.read7BitEncodedInt();
if(codeTableDataLength!==0)
throw new Error('not implemented: custom code table'); // custom code table
}
if(headerIndicator & VCD_APPHEADER){
// ignore app header data
var appDataLength=parser.read7BitEncodedInt();
parser.skip(appDataLength);
}
var headerEndOffset=parser.offset;
//calculate target file size
var newFileSize=0;
while(!parser.isEOF()){
var winHeader=parser.decodeWindowHeader();
newFileSize+=winHeader.targetWindowLength;
parser.skip(winHeader.addRunDataLength + winHeader.addressesLength + winHeader.instructionsLength);
}
tempFile=new BinFile(newFileSize);
parser.seek(headerEndOffset);
var cache = new VCD_AdressCache(4,3);
var codeTable = VCD_DEFAULT_CODE_TABLE;
var targetWindowPosition = 0; //renombrar
while(!parser.isEOF()){
var winHeader = parser.decodeWindowHeader();
var addRunDataStream = new VCDIFF_Parser(this.file, parser.offset);
var instructionsStream = new VCDIFF_Parser(this.file, addRunDataStream.offset + winHeader.addRunDataLength);
var addressesStream = new VCDIFF_Parser(this.file, instructionsStream.offset + winHeader.instructionsLength);
var addRunDataIndex = 0;
cache.reset(addressesStream);
var addressesStreamEndOffset = addressesStream.offset;
while(instructionsStream.offset<addressesStreamEndOffset){
/*
var instructionIndex=instructionsStream.readS8();
if(instructionIndex===-1){
break;
}
*/
var instructionIndex = instructionsStream.readU8();
for(var i=0; i<2; i++){
var instruction=codeTable[instructionIndex][i];
var size=instruction.size;
if(size===0 && instruction.type!==VCD_NOOP){
size=instructionsStream.read7BitEncodedInt()
}
if(instruction.type===VCD_NOOP){
continue;
}else if(instruction.type===VCD_ADD){
addRunDataStream.copyToFile2(tempFile, addRunDataIndex+targetWindowPosition, size);
addRunDataIndex += size;
}else if(instruction.type===VCD_COPY){
var addr = cache.decodeAddress(addRunDataIndex+winHeader.sourceLength, instruction.mode);
var absAddr = 0;
// source segment and target segment are treated as if they're concatenated
var sourceData = null;
if(addr < winHeader.sourceLength){
absAddr = winHeader.sourcePosition + addr;
if(winHeader.indicator & VCD_SOURCE){
sourceData = romFile;
}else if(winHeader.indicator & VCD_TARGET){
sourceData = tempFile;
}
}else{
absAddr = targetWindowPosition + (addr - winHeader.sourceLength);
sourceData = tempFile;
}
while(size--){
tempFile._u8array[targetWindowPosition + addRunDataIndex++]=sourceData._u8array[absAddr++];
//targetU8[targetWindowPosition + targetWindowOffs++] = copySourceU8[absAddr++];
}
//to-do: test
//sourceData.copyToFile2(tempFile, absAddr, size, targetWindowPosition + addRunDataIndex);
//addRunDataIndex += size;
}else if(instruction.type===VCD_RUN){
var runByte = addRunDataStream.readU8();
var offset = targetWindowPosition + addRunDataIndex;
for(var j=0; j<size; j++){
tempFile._u8array[offset+j]=runByte;
}
addRunDataIndex += size;
}else{
throw new Error('invalid instruction type found');
}
}
}
if(validate && winHeader.adler32 && (winHeader.adler32 !== adler32(tempFile, targetWindowPosition, winHeader.targetWindowLength))){
throw new Error('Target ROM checksum mismatch');
}
parser.skip(winHeader.addRunDataLength + winHeader.addressesLength + winHeader.instructionsLength);
targetWindowPosition += winHeader.targetWindowLength;
}
//console.log((performance.now()-t0)/1000);
return tempFile;
}
VCDIFF.MAGIC=VCDIFF_MAGIC;
VCDIFF.fromFile=function(file){
return new VCDIFF(file);
}
function VCDIFF_Parser(binFile, offset)
{
this.fileSize=binFile.fileSize;
this._u8array=binFile._u8array;
this.offset=offset || 0;
/* reimplement readU8, readU32 and skip from BinFile */
/* in web implementation, there are no guarantees BinFile will be dynamically loaded before this one */
/* so we cannot rely on cloning BinFile.prototype */
this.readU8 = binFile.readU8;
this.readU32 = binFile.readU32;
this.skip = binFile.skip;
this.isEOF = binFile.isEOF;
this.seek = binFile.seek;
}
VCDIFF_Parser.prototype.read7BitEncodedInt=function(){
var num=0, bits = 0;
do {
bits = this.readU8();
num = (num << 7) + (bits & 0x7f);
} while(bits & 0x80);
return num;
}
VCDIFF_Parser.prototype.decodeWindowHeader=function(){
var windowHeader={
indicator:this.readU8(),
sourceLength:0,
sourcePosition:0,
adler32:false
};
if(windowHeader.indicator & (VCD_SOURCE | VCD_TARGET)){
windowHeader.sourceLength = this.read7BitEncodedInt();
windowHeader.sourcePosition = this.read7BitEncodedInt();
}
windowHeader.deltaLength = this.read7BitEncodedInt();
windowHeader.targetWindowLength = this.read7BitEncodedInt();
windowHeader.deltaIndicator = this.readU8(); // secondary compression: 1=VCD_DATACOMP,2=VCD_INSTCOMP,4=VCD_ADDRCOMP
if(windowHeader.deltaIndicator!==0){
throw new Error('unimplemented windowHeader.deltaIndicator:'+windowHeader.deltaIndicator);
}
windowHeader.addRunDataLength = this.read7BitEncodedInt();
windowHeader.instructionsLength = this.read7BitEncodedInt();
windowHeader.addressesLength = this.read7BitEncodedInt();
if(windowHeader.indicator & VCD_ADLER32){
windowHeader.adler32 = this.readU32();
}
return windowHeader;
}
VCDIFF_Parser.prototype.copyToFile2=function(target, targetOffset, len){
for(var i=0; i<len; i++){
target._u8array[targetOffset+i]=this._u8array[this.offset+i];
}
//this.file.copyToFile(target, this.offset, len, targetOffset);
this.skip(len);
}
//------------------------------------------------------
// hdrIndicator
const VCD_DECOMPRESS = 0x01;
const VCD_CODETABLE = 0x02;
const VCD_APPHEADER = 0x04; // nonstandard?
// winIndicator
const VCD_SOURCE = 0x01;
const VCD_TARGET = 0x02;
const VCD_ADLER32 = 0x04;
function VCD_Instruction(instruction, size, mode){
this.type=instruction;
this.size=size;
this.mode=mode;
}
/*
build the default code table (used to encode/decode instructions) specified in RFC 3284
heavily based on
https://github.com/vic-alexiev/TelerikAcademy/blob/master/C%23%20Fundamentals%20II/Homework%20Assignments/3.%20Methods/000.%20MiscUtil/Compression/Vcdiff/CodeTable.cs
*/
const VCD_NOOP=0;
const VCD_ADD=1;
const VCD_RUN=2;
const VCD_COPY=3;
const VCD_DEFAULT_CODE_TABLE=(function(){
var entries=[];
var empty = {type: VCD_NOOP, size: 0, mode: 0};
// 0
entries.push([{type: VCD_RUN, size: 0, mode: 0}, empty]);
// 1,18
for(var size=0; size<18; size++){
entries.push([{type: VCD_ADD, size: size, mode: 0}, empty]);
}
// 19,162
for(var mode=0; mode<9; mode++){
entries.push([{type: VCD_COPY, size: 0, mode: mode}, empty]);
for(var size=4; size<19; size++){
entries.push([{type: VCD_COPY, size: size, mode: mode}, empty]);
}
}
// 163,234
for(var mode=0; mode<6; mode++){
for(var addSize=1; addSize<5; addSize++){
for(var copySize=4; copySize<7; copySize++){
entries.push([{type: VCD_ADD, size: addSize, mode: 0},
{type: VCD_COPY, size: copySize, mode: mode}]);
}
}
}
// 235,246
for(var mode=6; mode<9; mode++){
for(var addSize=1; addSize<5; addSize++){
entries.push([{type: VCD_ADD, size: addSize, mode: 0},
{type: VCD_COPY, size: 4, mode: mode}]);
}
}
// 247,255
for(var mode=0; mode<9; mode++){
entries.push([{type: VCD_COPY, size: 4, mode: mode},
{type: VCD_ADD, size: 1, mode: 0}]);
}
return entries;
})();
/*
ported from https://github.com/vic-alexiev/TelerikAcademy/tree/master/C%23%20Fundamentals%20II/Homework%20Assignments/3.%20Methods/000.%20MiscUtil/Compression/Vcdiff
by Victor Alexiev (https://github.com/vic-alexiev)
*/
const VCD_MODE_SELF=0;
const VCD_MODE_HERE=1;
function VCD_AdressCache(nearSize, sameSize){
this.nearSize=nearSize;
this.sameSize=sameSize;
this.near=new Array(nearSize);
this.same=new Array(sameSize*256);
}
VCD_AdressCache.prototype.reset=function(addressStream){
this.nextNearSlot=0;
this.near.fill(0);
this.same.fill(0);
this.addressStream=addressStream;
}
VCD_AdressCache.prototype.decodeAddress=function(here, mode){
var address=0;
if(mode===VCD_MODE_SELF){
address=this.addressStream.read7BitEncodedInt();
}else if(mode===VCD_MODE_HERE){
address=here-this.addressStream.read7BitEncodedInt();
}else if(mode-2<this.nearSize){ //near cache
address=this.near[mode-2]+this.addressStream.read7BitEncodedInt();
}else{ //same cache
var m=mode-(2+this.nearSize);
address=this.same[m*256+this.addressStream.readU8()];
}
this.update(address);
return address;
}
VCD_AdressCache.prototype.update=function(address){
if(this.nearSize>0){
this.near[this.nextNearSlot]=address;
this.nextNearSlot=(this.nextNearSlot+1)%this.nearSize;
}
if(this.sameSize>0){
this.same[address%(this.sameSize*256)]=address;
}
}

View File

@@ -0,0 +1,19 @@
Copyright 2019 SheetJS LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More