A lot of things.

This commit is contained in:
2026-06-08 16:25:52 +02:00
parent 6f6d6b9b84
commit f529f74823
94 changed files with 9178 additions and 107 deletions

View File

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

View File

@@ -4,10 +4,13 @@ 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 extends XenForoData implements Authenticatable, Authorizable {
class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName {
use \Illuminate\Foundation\Auth\Access\Authorizable;
@@ -75,4 +78,31 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable {
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

@@ -48,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']),

View File

@@ -22,6 +22,7 @@ class DynamicLoadController extends Controller
}
return [
'user_id' => $user_id,
'username' => $user->username,
'avatar_url' => $user->getAvatarUrl(),
'avatar_color' => XenForoHelpers::getAvatarColor( $user ),

View File

@@ -18,6 +18,11 @@ class EntryController extends Controller
return view('entries.index');
}
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))
@@ -48,4 +53,15 @@ class EntryController extends Controller
}
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

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

@@ -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,8 +118,11 @@ class SubmissionController extends Controller
}
public function update(StoreEntryRequest $request, string $section, Entry $entry)
public function update(Request $request, string $section, Entry $entry)
{
$request = $request->input('submit-state') === 'draft' ? app(StoreDraftRequest::class) : app(StoreEntryRequest::class);
$request->validateResolved();
try {
$entry = $this->services->editEntry($request, $section, $entry);

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

@@ -127,6 +127,8 @@ class StoreEntryRequest extends FormRequest
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

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

View File

@@ -5,10 +5,12 @@ 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;
@@ -20,18 +22,21 @@ class Database extends Component
* 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 = [];
/**
@@ -44,24 +49,35 @@ class Database extends Component
* 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';
/**
@@ -74,36 +90,42 @@ class Database extends Component
* 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';
/**
@@ -134,6 +156,7 @@ class Database extends Component
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'); }
@@ -145,7 +168,7 @@ class Database extends Component
public function clearFilters(): void
{
$this->reset([
'search', 'types', 'platforms', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode'
'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode'
]);
$this->resetPage();
}
@@ -165,7 +188,7 @@ class Database extends Component
private function buildQuery()
{
$query = Entry::query()->published()->with([
'game.platform', 'status', 'authors', 'languages'
'game.platform', 'game.genre', 'status', 'authors', 'languages'
]);
if( $this->search ) {
@@ -186,6 +209,12 @@ class Database extends Component
});
}
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);
}
@@ -233,6 +262,7 @@ class Database extends Component
'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(),

View File

@@ -14,7 +14,7 @@ class XfUserSelector extends Component
public ?int $selected = null;
public ?string $selectedUsername = null;
public function mount( ?int $initialUserId ){
public function mount( ?int $initialUserId = null ){
if( $initialUserId ){
$user = DB::connection('xenforo')->table('user')->where('user_id', $initialUserId)->first();
if( $user ){

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

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

@@ -130,7 +130,7 @@ class EntryPolicy
return false;
}
public function skipQueue(User $user, Entry $entry): bool
public function skipQueue(User $user, ?Entry $entry = null): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' );
}

View File

@@ -3,8 +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
@@ -28,5 +30,12 @@ class AppServiceProvider extends ServiceProvider
});
\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

@@ -17,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;
@@ -28,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.
@@ -72,7 +73,9 @@ class SubmissionsService {
'done' => true,
'error' => null,
'uuid' => $uuid,
'state' => $file->state
'state' => $file->state,
'meta_online_patcher' => $file->online_patcher,
'meta_secondary_online_patcher' => $file->secondary_online_patcher,
];
$file = Cache::get("uploaded_file_{$uuid}");
@@ -86,7 +89,9 @@ class SubmissionsService {
'done' => true,
'error' => null,
'uuid' => $uuid,
'state' => $file['state']
'state' => $file['state'],
'meta_online_patcher' => false,
'meta_secondary_online_patcher' => false,
];
return null;
@@ -102,7 +107,7 @@ 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.
@@ -188,23 +193,30 @@ class SubmissionsService {
$this->Step13_CreateCommentsThread( $entry );
// Step 14: Refresh XF count.
XenForoHelpers::updateEntriesCount( $entry->user_id );
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();
@@ -244,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', '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 );
@@ -268,7 +280,7 @@ class SubmissionsService {
if( !$uuidData )
$uuidData = $this->request->input('files_uuid', [] );
foreach ( $uuidData as $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. If it's an edition, delete all your new files and retry." );
@@ -294,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;
}
@@ -320,7 +332,7 @@ class SubmissionsService {
// 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." );
@@ -328,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;
@@ -352,7 +364,7 @@ class SubmissionsService {
// TODO: Replace by edit version
foreach ( $this->request->input('modifications', [] ) as $modificationId ) {
foreach ( $this->request->input('modifications', [] ) ?? [] as $modificationId ) {
$modification = Modification::find( $modificationId );
if( !$modification )
throw new SubmissionException( "Modification {$modificationId} does not exist." );
@@ -370,7 +382,7 @@ class SubmissionsService {
{
// TODO: Replace by edit version.
foreach ( $this->request->input('languages', [] ) as $languageId ) {
foreach ( $this->request->input('languages', [] ) ?? [] as $languageId ) {
$language = Language::find( $languageId );
if( !$language )
throw new SubmissionException( "Language {$languageId} does not exist." );
@@ -381,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,
@@ -396,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) )
@@ -406,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) )
@@ -416,7 +432,7 @@ class SubmissionsService {
}
}
public function editEntry( StoreEntryRequest $request, string $section, Entry $entry ): Entry
public function editEntry(Request $request, string $section, Entry $entry ): Entry
{
// STEP 1: Prepare basic fields and keep in save some others fields.
@@ -477,6 +493,8 @@ class SubmissionsService {
'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) ){
@@ -505,9 +523,6 @@ class SubmissionsService {
// STEP 11: Prepare new gallery images and prepare deletion of others ones.
$galleryPaths = $this->eStep11a_UpdateGalleryImages();
// STEP 13: Try to create comments area if it doesn't exist.
$this->Step13_CreateCommentsThread( $this->entry );
return $this->entry;
});
@@ -519,9 +534,14 @@ class SubmissionsService {
$this->eStep11c_UpdateGalleryImages( $galleryPaths );
// STEP 12: Refresh XF count.
if( $oldUserId )
XenForoHelpers::updateEntriesCount( $oldUserId );
XenForoHelpers::updateEntriesCount( $entry->user_id );
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;
}
@@ -550,6 +570,12 @@ class SubmissionsService {
}
// 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();
@@ -562,8 +588,8 @@ class SubmissionsService {
*/
private function eStep6_UpdateEntryFiles(int $entryId ): void
{
$requestUuids = $this->request->input('files_uuid', []);
$requestStates = $this->request->input('files_state', []);
$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 );
@@ -626,7 +652,7 @@ class SubmissionsService {
private function eStep8_UpdateAuthors(): void
{
$syncAuthorsId = [];
$requestAuthorsId = $this->request->input('authors', [] );
$requestAuthorsId = $this->request->input('authors', [] ) ?? [];
if( !empty( $requestAuthorsId ) ){
$valid = Author::whereIn( 'id', $requestAuthorsId )->pluck('id')->toArray();
@@ -638,7 +664,7 @@ class SubmissionsService {
$syncAuthorsId = array_merge( $syncAuthorsId, $requestAuthorsId );
}
foreach ( $this->request->input('new-authors', [] ) as $authorName ) {
foreach ( $this->request->input('new-authors', [] ) ?? [] as $authorName ) {
$authorName = trim($authorName);
if ($authorName === '')
continue;
@@ -660,7 +686,7 @@ class SubmissionsService {
*/
private function eStep9_UpdateRomhacksModifications(): void
{
$requestModifications = $this->request->input('modifications', [] );
$requestModifications = $this->request->input('modifications', [] ) ?? [];
if( !empty( $requestModifications ) ){
$valid = Modification::whereIn( 'id', $requestModifications )->pluck('id')->toArray();
@@ -681,7 +707,7 @@ class SubmissionsService {
*/
private function eStep10_UpdateLanguages(): void
{
$requestLanguages = $this->request->input('languages', [] );
$requestLanguages = $this->request->input('languages', [] ) ?? [];
if( !empty( $requestLanguages ) ){
$valid = Language::whereIn( 'id', $requestLanguages )->pluck('id')->toArray();
if( count( $valid ) !== count( $requestLanguages ) ){
@@ -695,7 +721,7 @@ class SubmissionsService {
private function eStep11a_UpdateGalleryImages(): array
{
$requestGallery = $this->request->input('gallery', [] );
$requestGallery = $this->request->input('gallery', [] ) ?? [];
$existingGalleryPaths = $this->entry->gallery->pluck('image')->toArray();
$needDeletion = array_diff( $existingGalleryPaths, $requestGallery );
@@ -723,6 +749,12 @@ class SubmissionsService {
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 ) ){
@@ -755,6 +787,9 @@ class SubmissionsService {
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

@@ -48,6 +48,19 @@ class XenforoApiService {
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 ) {
@@ -121,4 +134,14 @@ class XenforoApiService {
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

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

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

View File

@@ -3,6 +3,7 @@
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(
@@ -16,6 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
$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

@@ -23,6 +23,12 @@ return [
'icon' => 'gavel',
'route' => 'queue.index'
],
[
'name' => "My Drafts",
'icon' => 'scissors',
'route' => 'entries.drafts',
'condition' => (fn() => !\Auth::guest())
]
]
],
'community' => [
@@ -33,6 +39,11 @@ return [
'icon' => 'message-circle',
'xf_route' => ''
],
[
'name' => 'Clubs',
'icon' => 'balloon',
'xf_route' => 'clubs'
],
[
'name' => 'Discord',
'icon' => 'messages-square',

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

@@ -37,6 +37,8 @@ ul {
list-style-type: square;
}
[x-cloak] {display: none !important;}
/* File: resources/css/base/variables.css */
:root {
@@ -267,6 +269,13 @@ ul {
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
}
.\$block-success {
background-color: var(--success);
border: 1px solid var(--success);
color: var(--text);
padding: 20px;
margin-bottom: 20px;
}
.\$block-error {
background-color: var(--error);
border: 1px solid var(--error);
@@ -381,6 +390,12 @@ ul {
animation: spin 1s infinite linear;
}
.\$search-button {
background: none;
border: none;
cursor: pointer;
}
/* File: resources/css/components/database.css */
.\$filter-bar {
@@ -1359,6 +1374,75 @@ ul {
color: var(--text2);
white-space: nowrap;
}
.\$upload-item-actions {
display: flex;
flex-direction: row;
gap: 15px;
}
.file-state-icon { width: 18px; height: 18px; }
.file-state-icon--public { color: var(--success); }
.file-state-icon--private { color: var(--text2); }
.file-state-icon--archived { color: var(--error); }
.upload-item-state { display: flex; align-items: center; gap: 8px; }
.author-search { position: relative; }
.\$author-search-input {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg);
border: 1px solid var(--border);
padding: 8px 12px;
}
.\$author-search-input .\$form-input {
border: none;
padding: 0;
background: none;
flex: 1;
}
.\$author-search-selected {
display: flex;
align-items: center;
gap: 5px;
color: var(--success);
font-size: 0.85rem;
white-space: nowrap;
}
.\$author-search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: var(--bg2);
border: 1px solid var(--border);
border-top: none;
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.\$author-search-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 9px 12px;
background: none;
border: none;
color: var(--text);
font-size: 0.88rem;
cursor: pointer;
text-align: left;
font-family: var(--typography);
transition: background-color 0.1s;
}
.author-search-item:hover { background-color: var(--bg3); }
/* File: resources/css/components/grid.css */
@@ -1721,6 +1805,196 @@ ul {
}
/* File: resources/css/components/queue.css */
.\$queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 80px 20px;
color: var(--text2);
font-size: 0.95rem;
}
.\$queue-item {
background-color: var(--bg2);
border: 1px solid var(--border);
border-left-width: 4px;
margin-bottom: 20px;
padding: 20px;
}
.\$queue-item--pending {
border-left-color: var(--rhpz-orange);
}
.\$queue-item--rejected {
position: relative;
overflow: hidden;
border-left-color: var(--error);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background-color: var(--error);
width: var(--reject-progress, 100%);
opacity: 0.5;
transition: width 0.3s;
}
}
.\$queue-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 15px;
margin-bottom: 20px;
}
.\$queue-item-title {
font-size: 1.15rem;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.\$queue-item-meta {
font-size: 0.85rem;
color: var(--text2);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.\$queue-item-actions-header {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.\$timeline-container {
padding: 15px 20px;
background-color: var(--bg);
border: 1px solid var(--border);
margin-bottom: 20px;
}
.\$timeline {
display: flex;
justify-content: space-between;
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 30px;
right: 30px;
height: 2px;
background-color: var(--border);
z-index: 0;
}
}
.\$timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
width: 100px;
}
.\$timeline-dot {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--bg);
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
color: var(--text2);
margin-bottom: 10px;
transition: all 0.2s;
}
.\$timeline-step--active .\$timeline-dot {
border-color: var(--rhpz-orange);
background-color: var(--rhpz-orange);
color: #111;
}
.\$timeline-step--validated .\$timeline-dot {
border-color: var(--success);
background-color: var(--success);
color: #111;
}
.\$timeline-step--rejected .\$timeline-dot {
border-color: var(--error);
background-color: var(--error);
color: #fff;
}
.\$timeline-label {
font-size: 0.78rem;
text-align: center;
color: var(--text2);
font-weight: 500;
}
.timeline-step--active .timeline-label { color: var(--rhpz-orange); font-weight: 600; }
.timeline-step--validated .timeline-label { color: var(--success); }
.timeline-step--rejected .timeline-label { color: var(--error); }
.\$queue-reject-reason {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 15px;
background-color: rgba(229, 115, 115, 0.08);
border-left: 2px solid var(--error);
color: var(--text);
font-size: 0.88rem;
margin-bottom: 15px;
line-height: 1.5;
}
.\$queue-mod-zone {
border-top: 1px solid var(--border);
padding-top: 15px;
margin-top: 5px;
display: flex;
flex-direction: column;
gap: 15px;
}
.\$queue-mod-separator {
border-top: 1px solid var(--border);
}
.\$queue-mod-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 10px;
}
.\$queue-reject-form {
margin-top: 12px;
padding: 15px;
background-color: var(--bg3);
border: 1px solid var(--border);
}
/* File: resources/css/components/settings.css */
.\$settings-dropdown {
position: absolute;
@@ -1946,7 +2220,7 @@ ul {
/* File: resources/css/layout/entry.css */
#entry-container {
#entry-container, #comments-section, #reviews-section {
background-color: var(--bg2);
border: 1px solid var(--border);
display: flex;
@@ -2054,6 +2328,7 @@ ul {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.\$entry-gallery-item {
@@ -2064,10 +2339,81 @@ ul {
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s;
&:hover {
border-color: var(--rhpz-orange);
img {
transform: scale(1.05);
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
}
}
.\$gallery-modal {
position: fixed;
z-index: 3000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
.\$gallery-modal-content {
max-width: 90%;
max-height: 90%;
position: relative;
img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border: 2px solid var(--border);
background-color: var(--bg);
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
}
}
.\$gallery-modal-close {
position: absolute;
top: 20px;
right: 30px;
color: #fff;
font-size: 40px;
font-weight: bold;
cursor: pointer;
user-select: none;
&:hover {
color: var(--rhpz-orange);
}
}
.\$gallery-modal-video {
width: 90%;
max-width: 960px;
aspect-ratio: 16/9;
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
border: 1px solid var(--border);
background-color: #000;
iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
}
}
@@ -2077,6 +2423,186 @@ ul {
color: var( --text2 );
}
.\$comment-block {
display: flex;
gap: 16px;
padding: 20px 0;
border-bottom: 1px solid var(--border);
&:last-child {
border-bottom: none;
}
.\$comment-avatar {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
background-color: var(--bg4);
border: 1px solid var(--border);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.\$comment-content {
flex: 1;
min-width: 0;
.\$comment-meta {
font-size: 0.88rem;
color: var(--text2);
margin-bottom: 6px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
.\$comment-author {
font-weight: 600;
color: var(--text);
text-decoration: none;
transition: color 0.2s;
&:hover {
color: var(--rhpz-orange);
}
}
.\$comment-date {
color: var(--text2);
}
.\$comment-separator {
color: var(--border);
user-select: none;
}
}
.\$comment-body {
font-size: 0.95rem;
color: var(--text);
line-height: 1.5;
word-wrap: break-word;
p {
margin-bottom: 10px;
&:last-child { margin-bottom: 0; }
}
a {
color: var(--rhpz-orange);
&:hover {
color: var(--rhpz-orange-hover);
text-decoration: underline;
}
}
blockquote, .\$bbCodeBlock-blockquote {
background-color: var(--bg);
border-left: 3px solid var(--info);
padding: 12px 16px;
margin: 12px 0;
font-style: italic;
color: var(--text2);
}
code {
font-family: monospace;
background-color: var(--bg3);
border: 1px solid var(--border);
padding: 2px 5px;
font-size: 0.9rem;
}
}
}
}
.\$comments-empty {
text-align: center;
padding: 40px 20px;
color: var(--text2);
font-style: italic;
background-color: var(--bg);
border: 1px dashed var(--border);
}
.\$entry-video-section {
margin-bottom: 30px;
}
.\$video-thumbnail-wrapper {
position: relative;
width: 100%;
max-width: 500px;
aspect-ratio: 16/9;
background-color: #000;
border: 1px solid var(--border);
cursor: pointer;
overflow: hidden;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.8;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.\$play-trigger {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 65px;
height: 65px;
background-color: rgba(0, 0, 0, 0.7);
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.5rem;
transition: background-color 0.2s, transform 0.2s ease-out;
i { margin-left: 4px; }
}
&:hover {
img {
transform: scale(1.03);
opacity: 1;
}
.\$play-trigger {
background-color: var(--rhpz-orange);
border-color: var(--rhpz-orange);
transform: translate(-50%, -50%) scale(1.1);
box-shadow: 0 0 15px rgba(255, 115, 0, 0.5);
}
}
}
.\$entry-submission-byline {
font-size: 0.85rem;
color: var(--text2);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 15px;
i {
vertical-align: middle;
margin-right: 4px;
}
}
/* File: resources/css/layout/menu.css */
#menu {

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.

View File

@@ -0,0 +1 @@
/* code has been moved to RomPatcher.format.bdf.js because of incompatibility between webapp, web worker and CLI */

View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2013, Gildas Lormeau
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
/* jshint worker:true */
!function(c){"use strict";if(c.zWorkerInitialized)throw new Error("z-worker.js should be run only once");c.zWorkerInitialized=!0,addEventListener("message",function(t){var e,r,c=t.data,n=c.type,s=c.sn,p=o[n];if(p)try{p(c)}catch(t){e={type:n,sn:s,error:(r=t,{message:r.message,stack:r.stack})},postMessage(e)}});var o={importScripts:function(t){t.scripts&&0<t.scripts.length&&importScripts.apply(void 0,t.scripts);postMessage({type:"importScripts"})},newTask:h,append:t,flush:t},f={};function h(t){var e=c[t.codecClass],r=t.sn;if(f[r])throw Error("duplicated sn");f[r]={codec:new e(t.options),crcInput:"input"===t.crcType,crcOutput:"output"===t.crcType,crc:new n},postMessage({type:"newTask",sn:r})}var l=c.performance?c.performance.now.bind(c.performance):Date.now;function t(t){var e=t.sn,r=t.type,c=t.data,n=f[e];!n&&t.codecClass&&(h(t),n=f[e]);var s,p="append"===r,o=l();if(p)try{s=n.codec.append(c,function(t){postMessage({type:"progress",sn:e,loaded:t})})}catch(t){throw delete f[e],t}else delete f[e],s=n.codec.flush();var a=l()-o;o=l(),c&&n.crcInput&&n.crc.append(c),s&&n.crcOutput&&n.crc.append(s);var i=l()-o,u={type:r,sn:e,codecTime:a,crcTime:i},d=[];s&&(u.data=s,d.push(s.buffer)),p||!n.crcInput&&!n.crcOutput||(u.crc=n.crc.get());try{postMessage(u,d)}catch(t){postMessage(u)}}function n(){this.crc=-1}function e(){}n.prototype.append=function(t){for(var e=0|this.crc,r=this.table,c=0,n=0|t.length;c<n;c++)e=e>>>8^r[255&(e^t[c])];this.crc=e},n.prototype.get=function(){return~this.crc},n.prototype.table=function(){var t,e,r,c=[];for(t=0;t<256;t++){for(r=t,e=0;e<8;e++)1&r?r=r>>>1^3988292384:r>>>=1;c[t]=r}return c}(),(c.NOOP=e).prototype.append=function(t,e){return t},e.prototype.flush=function(){}}(this);

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,9 @@
@import './components/notifications.css';
@import './components/settings.css';
@import './components/queue.css';
@import './components/drafts.css';
@import './components/modcp.css';
@import './components/tools.css';
@import './components/easymde.css';

View File

@@ -182,3 +182,9 @@
.spin {
animation: spin 1s infinite linear;
}
.search-button {
background: none;
border: none;
cursor: pointer;
}

View File

@@ -0,0 +1,160 @@
.drafts-count {
font-size: 0.85rem;
color: var(--text2);
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.drafts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 80px 20px;
background-color: var(--bg2);
border: 1px dashed var(--border);
text-align: center;
color: var(--text2);
h3 {
font-size: 1.1rem;
color: var(--text);
margin: 0;
}
p {
font-size: 0.9rem;
margin: 0;
}
}
.drafts-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.drafts-item {
display: flex;
gap: 20px;
background-color: var(--bg2);
border: 1px solid var(--border);
border-left: 3px solid var(--rhpz-orange);
padding: 20px;
transition: border-color 0.15s;
&:hover {
border-color: var(--rhpz-orange);
}
}
.drafts-cover {
width: 80px;
height: 80px;
flex-shrink: 0;
background-color: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.drafts-cover-placeholder {
color: var(--border);
}
.drafts-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.drafts-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 15px;
}
.drafts-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.drafts-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.drafts-dates {
display: flex;
flex-direction: column;
gap: 4px;
text-align: right;
font-size: 0.78rem;
color: var(--text2);
flex-shrink: 0;
span {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
}
.drafts-progress {
display: flex;
align-items: center;
gap: 10px;
}
.drafts-progress-bar {
flex: 1;
height: 4px;
background-color: var(--bg4);
overflow: hidden;
}
.drafts-progress-fill {
height: 100%;
background-color: var(--rhpz-orange);
transition: width 0.3s ease;
.complete {
background-color: var(--success);
}
}
.drafts-progress-label {
font-size: 0.75rem;
color: var(--text2);
white-space: nowrap;
}
.drafts-actions {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
flex-shrink: 0;
.btn {
white-space: nowrap;
}
}

View File

@@ -0,0 +1,320 @@
.modcp-wrapper {
display: flex;
gap: 0;
align-items: flex-start;
min-height: calc(100vh - 60px);
}
.modcp-sidebar {
width: 220px;
flex-shrink: 0;
background-color: var(--bg2);
border: 1px solid var(--border);
position: sticky;
top: 0;
align-self: flex-start;
margin-right: 15px;
}
.modcp-sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
font-weight: 600;
font-size: 0.88rem;
color: var(--text);
border-bottom: 1px solid var(--border);
background-color: var(--bg3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modcp-nav { padding: 8px 0; }
.modcp-nav-group { margin-bottom: 4px; }
.modcp-nav-label {
display: block;
padding: 8px 16px 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text2);
}
.modcp-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 16px;
font-size: 0.88rem;
color: var(--text);
text-decoration: none;
border-left: 3px solid transparent;
transition: background-color 0.1s, border-color 0.1s;
&:hover {
background-color: var(--bg3);
text-decoration: none;
}
.active {
background-color: var(--bg3);
border-left-color: var(--rhpz-orange);
color: var(--text);
font-weight: 600;
}
}
.modcp-nav-badge {
margin-left: auto;
background-color: var(--rhpz-orange);
color: #111;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
}
.modcp-content {
flex: 1;
min-width: 0;
background-color: var(--bg2);
border: 1px solid var(--border);
padding: 25px;
}
.modcp-page-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.3rem;
font-weight: 600;
color: var(--text);
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
}
.modcp-count {
margin-left: auto;
font-size: 0.85rem;
font-weight: normal;
color: var(--text2);
}
.modcp-section-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.7px;
color: var(--text2);
margin-bottom: 12px;
}
.modcp-stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 25px;
}
.modcp-stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background-color: var(--bg3);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
text-decoration: none;
transition: border-color 0.15s, background-color 0.15s;
color: var(--text);
&:hover {
background-color: var(--bg4);
text-decoration: none;
}
}
.modcp-stat-card--orange { border-left-color: var(--rhpz-orange); }
.modcp-stat-card--danger { border-left-color: var(--error); }
.modcp-stat-card--muted { cursor: default; }
.modcp-stat-icon { color: var(--text2); }
.modcp-stat-card--orange .modcp-stat-icon { color: var(--rhpz-orange); }
.modcp-stat-card--danger .modcp-stat-icon { color: var(--error); }
.modcp-stat-info { display: flex; flex-direction: column; }
.modcp-stat-value { font-size: 1.4rem; font-weight: 700; color: var(--text); line-height: 1; }
.modcp-stat-label { font-size: 0.75rem; color: var(--text2); margin-top: 3px; }
.modcp-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 25px;
}
.modcp-quick-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
background-color: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.85rem;
text-decoration: none;
transition: background-color 0.1s, border-color 0.1s;
&:hover {
background-color: var(--bg4);
border-color: var(--rhpz-orange);
text-decoration: none;
}
}
.modcp-list { display: flex; flex-direction: column; }
.modcp-list-item {
display: flex;
align-items: center;
gap: 15px;
padding: 12px 15px;
border-bottom: 1px solid var(--border);
transition: background-color 0.1s;
}
.modcp-list-item:last-child { border-bottom: none; }
.modcp-list-item:hover { background-color: var(--bg3); }
.modcp-list-item--deleted { opacity: 0.8; }
.modcp-list-item-cover {
width: 44px;
height: 44px;
flex-shrink: 0;
background-color: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--border);
}
.modcp-list-item-cover img {
width: 100%;
height: 100%;
object-fit: contain;
}
.modcp-list-item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.modcp-list-item-title {
font-size: 0.92rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modcp-list-item-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text2);
}
.modcp-list-item-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.modcp-list-item-edit {
display: flex;
gap: 6px;
flex: 1;
align-items: center;
}
.modcp-list-item-edit .form-input {
flex: 1;
padding: 5px 10px;
font-size: 0.88rem;
}
.modcp-list-see-all {
display: block;
text-align: center;
padding: 10px;
font-size: 0.85rem;
color: var(--rhpz-orange);
border-top: 1px solid var(--border);
text-decoration: none;
}
.modcp-add-form {
background-color: var(--bg3);
border: 1px solid var(--border);
padding: 15px;
margin-bottom: 20px;
}
.modcp-add-form-inner {
display: flex;
gap: 8px;
align-items: center;
}
.modcp-add-form-inner .form-input { flex: 1; }
.modcp-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 50px 20px;
color: var(--text2);
text-align: center;
}
.mod-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
margin-bottom: 20px;
font-size: 0.88rem;
border: 1px solid;
}
.mod-alert--success {
background-color: rgba(129, 199, 132, 0.08);
border-color: rgba(129, 199, 132, 0.3);
color: var(--success);
}
.modcp-list-item-edit--game {
flex-wrap: wrap;
gap: 6px;
}
.modcp-list-item-edit--game .form-input { min-width: 180px; flex: 2; }
.modcp-list-item-edit--game .form-select { flex: 1; min-width: 120px; }

View File

@@ -0,0 +1,83 @@
.patcher-container {
background-color: var(--bg2);
border: 1px solid var(--border);
padding: 25px;
margin-bottom: 20px;
}
.patcher-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.patcher-grid {
grid-template-columns: 1fr;
}
}
.patcher-dropzone {
border: 2px dashed var(--border);
background-color: var(--bg3);
padding: 55px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.patcher-dropzone:hover, .patcher-dropzone.dragover {
border-color: var(--rhpz-orange);
background-color: var(--bg4);
}
.patcher-dropzone.has-file {
border-color: var(--success);
background-color: rgba(129, 199, 132, 0.02);
}
.patcher-status-box {
margin-top: 20px;
padding: 15px;
border: 1px solid var(--border);
background-color: var(--bg3);
font-size: 0.95rem;
line-height: 1.4;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background-color: var(--bg3);
border-color: var(--border);
color: var(--text2);
}
.embed-patch-box {
border: 1px solid var(--border);
background-color: var(--bg3);
padding: 25px;
height: 85%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 15px;
}
.embed-patch-box-icon {
display: flex;
align-items: center;
gap: 15px;
}
.embed-patch-box-icon-block {
width: 48px;
height: 48px;
background-color: var(--bg2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -23,24 +23,6 @@
cursor: pointer;
}
.search-bar {
display: flex;
align-items: center;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 2px;
padding: 5px 10px;
width: 300px;
input {
background: none;
border: none;
color: var(--text);
outline: none;
margin-left: 8px;
width: 100%;
}
}
.topbar-actions {
display: flex;
gap: 8px;
@@ -53,6 +35,24 @@
}
.search-bar {
display: flex;
align-items: center;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 2px;
padding: 5px 10px;
width: 300px;
input {
background: none;
border: none;
color: var(--text);
outline: none;
margin-left: 8px;
width: 100%;
}
}
#content {
flex-grow: 1;
padding: 30px;

View File

@@ -14,8 +14,8 @@
gap: 30px;
.entry-cover {
width: 200px;
height: 280px;
width: 220px;
height: 220px;
background-color: var(--bg);
border: 1px solid var(--border);
display: flex;
@@ -29,7 +29,8 @@
img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
padding: 8px;
}
}

114
resources/js/RomPatcher.js Normal file
View File

@@ -0,0 +1,114 @@
export function RomPatcher( initialPatches = {} ) {
let patchesArray = [];
if (initialPatches) {
patchesArray = Array.isArray(initialPatches) ? initialPatches : [initialPatches];
}
patchesArray = patchesArray.filter(p => p && p.file);
return {
/**
* @type {string}
*/
romFileName: '',
/**
* @type {string}
*/
patchFileName: '',
/**
* @type {boolean}
*/
isRomDragOver: false,
/**
* @type {boolean}
*/
isPatchDragOver: false,
/**
* @type {boolean}
*/
showStatusBox: false,
/**
* @type {object}
*/
patchesData: patchesArray,
hasEmbedded: patchesArray.length > 0,
init() {
const CONFIG = {language: 'en', requireValidation: false};
if (!RomPatcherWeb.isInitialized()){
if (this.hasEmbedded) {
RomPatcherWeb.initialize(CONFIG, ...this.patchesData);
} else {
RomPatcherWeb.initialize(CONFIG);
}
}
const STAT_BOX = document.getElementById('patcher-status');
const OBSERVER = new MutationObserver(() => {
const CRC = document.getElementById('rom-patcher-span-crc32').textContent;
const DESC = document.getElementById('rom-patcher-patch-description').textContent;
const PATCH = document.getElementById('rom-patcher-patch-requirements-value').textContent;
this.showStatusBox = CRC.trim().length > 0 || DESC.trim().length > 0 || PATCH.trim().length > 0;
});
OBSERVER.observe(STAT_BOX, { childList: true, subtree: true, characterData: true });
},
/**
*
* @param {string} id
*/
triggerFileInput(id){
const I = document.getElementById(id);
if( !I.disabled )
I.click();
},
/**
*
* @param {Event} e
* @param {string} type
*/
handleInputChange(e, type){
const file = e.target.files[0];
if(file){
if( type === 'rom' ) this.romFileName = file.name;
if( type === 'patch' ) this.patchFileName = file.name;
}
},
/**
*
* @param {Event} e
* @param {string} type
*/
handleDrop(e, type ){
const file = e.dataTransfer.files[0];
if( !file )
return;
const ID = type === 'rom' ? 'rom-patcher-input-file-rom' : 'rom-patcher-input-file-patch';
const I = document.getElementById(ID);
if( I.disabled )
return;
I.files = e.dataTransfer.files;
if( type === 'rom' ) this.romFileName = file.name;
if( type === 'patch' ) this.patchFileName = file.name;
I.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}

View File

@@ -66,6 +66,16 @@ export function FSFileData(name, totalChunks, rawFile ) {
*/
state: 'public',
/**
* If the online patcher is enabled
*/
meta_online_patcher: false,
/**
* If this patch is a secondary patch.
*/
meta_secondary_online_patcher: false,
/**
* Look if this file is currently uploading.
* @returns {boolean}

View File

@@ -7,6 +7,7 @@ import hovercard from "./hovercard.js";
import notifications from "./notifications.js";
import conversations from "./conversations.js";
import settings from "./settings.js";
import {RomPatcher} from "./RomPatcher.js";
/**
* Get config defined in meta.blade.php
@@ -43,3 +44,6 @@ Alpine.store('conversations', conversations() );
// Settings
Alpine.store('settings', settings() );
// ROMPatcher
window.RomPatcher = RomPatcher;

View File

@@ -372,6 +372,12 @@ window.Submission = function(){
this.errorKey = null; // Reset.
this.duringSubmissionProcess = true;
const STATE = document.querySelector('select[name="submit-state"]')?.value;
if( STATE === 'draft' ){
e.target.submit();
return;
}
if( !this.verifyForm() ){
this.scrollToError();

View File

@@ -63,10 +63,10 @@
</div>
<div class="hovercard-actions">
<a href="#" class="btn" title="View profile">
<a :href="`{{ xfRoute('members') }}/${$store.hovercard.data.user_id}/`" class="btn" title="View profile">
<i data-lucide="user" size="14"></i>
</a>
<a href="#" class="btn" title="Send message">
<a :href="`{{ xfRoute('direct-messages/add') }}?to=${$store.hovercard.data.username.replace(' ', '+')}`" class="btn" title="Send message">
<i data-lucide="mail" size="14"></i>
</a>
</div>

View File

@@ -15,10 +15,12 @@
<div class="menu-group">
<div class="menu-group-title">{{ $menu['name'] }}</div>
@foreach( $menu['items'] as $item )
<a href="{{ isset($item['xf_route']) ? xfRoute($item['xf_route']) : route($item['route']) }}"
@class(['menu-item', 'active' => request()->routeIs( $item['route'] ?? '' )]) >
<i data-lucide="{{ $item['icon'] }}"></i><span>{{ $item['name'] }}</span>
</a>
@if( !isset( $item['condition'] ) || $item['condition']() )
<a href="{{ isset($item['xf_route']) ? xfRoute($item['xf_route']) : route($item['route']) }}"
@class(['menu-item', 'active' => request()->routeIs( $item['route'] ?? '' )]) >
<i data-lucide="{{ $item['icon'] }}"></i><span>{{ $item['name'] }}</span>
</a>
@endif
@endforeach
</div>
@endforeach

View File

@@ -0,0 +1,6 @@
<form class="search-bar" style="margin-bottom: 15px;">
<input type="text" name="{{ $param }}" placeholder="{{ $placeholder }}" value="{{ request($param) }}" autocomplete="off" />
<button type="submit" class="search-button">
<i data-lucide="search" size="18" color="var(--text2)"></i>
</button>
</form>

View File

@@ -1,6 +1,7 @@
<div class="submit-level" x-data="{
nsfw: null,
state: '{{ old('submit-state', $defaultState) }}',
deleteOpen: false,
init(){
this.$watch('nsfw', (val) => {
if( val && this.state === 'published' ) {
@@ -9,6 +10,13 @@
});
}
}" x-init="init()">
@if($isEdit)
<div>
<button type="button" class="btn danger" @click="deleteOpen = true; $dispatch('modal:opened')">
<i data-lucide="trash-2" size="13"></i> Delete
</button>
</div>
@endif
<div>
@if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) && !$isEdit )
<label class="nsfw-label"><input id="nsfw-checkbox" type="checkbox" name="nsfw-entry" x-model="nsfw" style="transform: scale(1.5)"> NSFW</label>
@@ -25,4 +33,49 @@
@endif
@endforeach
</select>
@if($isEdit)
<template x-teleport="body">
<div
class="modal-overlay"
x-cloak
x-show="deleteOpen"
x-transition.opacity
@click.self="deleteOpen = false"
@keydown.escape.window="deleteOpen = false"
@modal:opened.window="refreshIcons($el)"
>
<div class="modal-window" x-show="deleteOpen" x-transition>
<div class="modal-header">
<span class="modal-title">Delete entry</span>
<button type="button" class="modal-close" @click="deleteOpen = false">
<i data-lucide="x" size="20"></i>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 1.5rem; color: var(--text, #333);">
Are you sure you want to delete this entry? This action cannot be undone.
</p>
<form action="{{ route('submit.destroy', ['section' => $section, 'entry' => $entry ]) }}" method="POST">
@csrf
@method('DELETE')
<div class="queue-mod-actions">
<button type="button" class="btn" @click="deleteOpen = false">
Cancel
</button>
<button type="submit" class="btn danger">
<i data-lucide="trash-2" size="14"></i>
Confirm deletion
</button>
</div>
</form>
</div>
</div>
</div>
</template>
@endif
</div>

View File

@@ -4,14 +4,16 @@
<i data-lucide="menu"></i>
</button>
<div class="search-bar">
<i data-lucide="search" size="18" color="var(--text2)"></i>
<input type="text">Search</input>
</div>
<form class="search-bar" action="{{ route('entries.index') }}">
<input type="text" name="s" placeholder="Search" />
<button type="submit" class="search-button">
<i data-lucide="search" size="18" color="var(--text2)"></i>
</button>
</form>
<div class="topbar-actions">
@if( !\Auth::guest() && \Auth::user()->is_admin === 1 )
@can('is-admin')
@php $topbarAdminSeparator = true; @endphp
<a href="{{ config('app.forum_url') . '/admin.php' }}" class="btn">
<i data-lucide="landmark" size="18"></i>
@@ -19,15 +21,15 @@
<a href="{{ config('app.url') . '/manage' }}" class="btn">
<i data-lucide="shield-cog" size="18"></i>
</a>
@endif
@endcan
@if( $topbarAdminSeparator )
<div class="vertical-separator"></div>
@endif
@if( !\Auth::guest() && \Auth::user()->is_moderator === 1 )
@can('is-mod')
@php $topbarModSeparator = true; @endphp
<a href="#" class="btn">
<a href="{{ route('modcp.index') }}" class="btn">
<i data-lucide="siren" size="18"></i>
</a>
<a href="{{ xfRoute('approval-queue') }}" class="btn">
@@ -36,7 +38,7 @@
<a href="{{ xfRoute('reports') }}" class="btn">
<i data-lucide="triangle-alert" size="18"></i>
</a>
@endif
@endcan
@if( $topbarModSeparator )
<div class="vertical-separator"></div>

View File

@@ -0,0 +1,54 @@
<div class="drafts-item">
<div class="drafts-cover">
@if($entry->main_image)
<img src="{{ Storage::url($entry->main_image) }}">
@else
<div class="drafts-cover-placeholder">
<i data-lucide="image" size="24"></i>
</div>
@endif
</div>
<div class="drafts-info">
<div class="drafts-top">
<div>
<h3 class="drafts-title">
{{ $entry->complete_title }}
</h3>
<div class="drafts-meta">
<span class="badge {{ $entry->type }}">
{{ \App\Livewire\Database::ENTRY_TYPES[$entry->type] }}
</span>
@if( $entry->getRealPlatform() )
<span class="badge">{{ $entry->getRealPlatform()->name }}</span>
@endif
@if( $entry->version )
<span class="badge">{{ $entry->version }}</span>
@endif
</div>
</div>
<div class="drafts-dates">
<span>
<i data-lucide="pencil" size="12"></i>
Last edited {{ $draft->updated_at->diffForHumans() }}
</span>
<span>
<i data-lucide="calendar" size="12"></i>
Created {{ $draft->created_at->format('d M Y') }}
</span>
</div>
</div>
<div class="drafts-actions">
<a href="{{ route('submit.edit', ['section' =>$entry->type, 'entry' => $entry]) }}" class="btn primary">
<i data-lucide="pen" size="13"></i>
Continue editing
</a>
<a href="{{ route('entries.show', ['section' => $entry->type, 'entry' => $entry ] ) }}" class="btn" target="_blank">
<i data-lucide="eye" size="13"></i>
Preview
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
@extends('layouts.app')
@section('page-title', "My Drafts - " . config('app.name'))
@section('content')
<div class="page-title">
My Drafts
</div>
@if($drafts->isEmpty())
<div class="drafts-empty">
<i data-lucide="pen" size="48"></i>
<p>No drafts here</p>
</div>
@else
<div class="drafts-count">
<span>{{ $drafts->total() }} draft{{ $drafts->total() > 1 ? 's' : '' }}</span>
</div>
<div class="drafts-list">
@foreach($drafts as $draft)
@include('entries.draft_item', ['entry' => $draft])
@endforeach
</div>
{{ $drafts->links() }}
@endif
@endsection

View File

@@ -83,28 +83,28 @@
</div>
<div class="entry-meta-grid">
@if( $entry->game )
<x-entry-meta-item label="Game Name" value="{{ $entry->game->name }}" />
<x-entry-meta-item label="Game Name" value="{{ $entry->game->name }}" route="{!! databaseRoute( [ 'games' => [ $entry->game->id ], 'platforms' => [ $entry->getRealPlatform()?->id ] ] ) !!}" />
@endif
@if( $entry->getRealPlatform() )
<x-entry-meta-item label="Platform" value="{{ ($entry->getRealPlatform())->name }}" />
<x-entry-meta-item label="Platform" value="{{ ($entry->getRealPlatform())->name }}" route="{!! databaseRoute( ['platforms' => [ $entry->getRealPlatform()->id ] ] ) !!}" />
@endif
@if( $entry->game && $entry->game->genre )
<x-entry-meta-item label="Genre" value="{{ $entry->game->genre->name }}" />
<x-entry-meta-item label="Genre" value="{{ $entry->game->genre->name }}" route="{!! databaseRoute( ['genres' => [ $entry->game->genre->id ] ]) !!}" />
@endif
@if( $entry->languages->isNotEmpty() )
<x-entry-meta-item label="Language" value="{{ $entry->languages->pluck('name')->implode(', ') }}" route="none" />
<x-entry-meta-item label="Language" value="{{ $entry->languages->pluck('name')->implode(', ') }}" route="{!! databaseRoute( [ 'languages' => $entry->languages->pluck('id')->toArray() ]) !!}" />
@endif
@if( $entry->status_id )
<x-entry-meta-item label="Status" value="{{ $entry->status->name }}" />
<x-entry-meta-item label="Status" value="{{ $entry->status->name }}" route="{!! databaseRoute( ['statuses' => [ $entry->status->id ] ]) !!}" />
@endif
@if( $entry->modifications->isNotEmpty() )
<x-entry-meta-item label="Type of hack" value="{{ $entry->modifications->pluck('name')->implode(', ') }}" route="{!! databaseRoute( [ 'modifications' => $entry->modifications->pluck('id')->toArray() ] ) !!}" />
@endif
@if( $entry->version )
<x-entry-meta-item label="Version" value="{{ $entry->version }}" route="none" />
@endif
@if( $entry->release_date )
<x-entry-meta-item label="Release Date" value="{{ $entry->release_date }}" />
@endif
@if( $entry->modifications->isNotEmpty() )
<x-entry-meta-item label="Type of hack" value="{{ $entry->modifications->pluck('name')->implode(', ') }}" route="none" />
<x-entry-meta-item label="Release Date" value="{{ $entry->release_date->format('Y-m-d') }}" route="none" />
@endif
</div>
<div class="hack-actions" style="display:flex;gap:10px;">
@@ -206,7 +206,7 @@
@endforeach
</div>
@endif
@if( $entry->staff_credits )
@if( $entry->parseStaffCredits() )
<x-entry-section-title label="Staff Credits" icon="users-round" />
<div class="entry-description">
<ul>

View File

@@ -0,0 +1,95 @@
@extends('layouts.app')
@section('content')
<div class="modcp-wrapper">
<aside class="modcp-sidebar">
<div class="modcp-sidebar-header">
<i data-lucide="shield" size="16"></i>
Mod CP
</div>
<nav class="modcp-nav">
<div class="modcp-nav-group">
<span class="modcp-nav-label">Overview</span>
<a href="{{ route('modcp.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.index') ? 'active' : '' }}>
<i data-lucide="layout-dashboard" size="15"></i>
Dashboard
</a>
<a href="{{ route('queue.index') }}" class="modcp-nav-item" {{ request()->routeIs('queue.index') ? 'active' : '' }}>
<i data-lucide="gavel" size="15"></i>
Submissions Queue
@if(( $pending = \App\Models\Entry::where('state','pending')->count() ) > 0)
<span class="modcp-nav-badge">{{ $pending }}</span>
@endif
</a>
</div>
<div class="modcp-nav-group">
<span class="modcp-nav-label">Content</span>
<a href="{{ route('modcp.locked') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.locked') ? 'active' : '' }}>
<i data-lucide="lock" size="15"></i>
Locked entries
</a>
@can('is-admin')
<a href="{{ route('modcp.draft') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.draft') ? 'active' : '' }}>
<i data-lucide="scissors" size="15"></i>
Draft entries
</a>
<a href="{{ route('modcp.hidden') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.hidden') ? 'active' : '' }}>
<i data-lucide="eye-off" size="15"></i>
Hidden entries
</a>
<a href="{{ route('modcp.deleted') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.deleted') ? 'active' : '' }}>
<i data-lucide="trash-2" size="15"></i>
Deleted entries
</a>
@endcan
</div>
<div class="modcp-nav-group">
<span class="modcp-nav-label">Resources</span>
<a href="{{ route('modcp.games.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.games.*') ? 'active' : '' }}">
<i data-lucide="gamepad-2" size="15"></i>
Games
</a>
<a href="{{ route('modcp.languages.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.languages.*') ? 'active' : '' }}">
<i data-lucide="languages" size="15"></i>
Languages
</a>
<a href="{{ route('modcp.authors.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.authors.*') ? 'active' : '' }}">
<i data-lucide="users" size="15"></i>
Authors
</a>
@can('is-admin')
<a href="{{ route('modcp.platforms.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.platforms.*') ? 'active' : '' }}">
<i data-lucide="gamepad-directional" size="15"></i>
Platforms
</a>
<a href="{{ route('modcp.genres.index') }}" class="modcp-nav-item" {{ request()->routeIs('modcp.genres.*') ? 'active' : '' }}">
<i data-lucide="box" size="15"></i>
Genres
</a>
@endcan
</div>
<div class="modcp-nav-group">
<span class="modcp-nav-label">Community</span>
<a href="{{ xfRoute('reports') }}" class="modcp-nav-item">
<i data-lucide="triangle-alert" size="15"></i>
Reports
</a>
<a href="{{ xfRoute('approval-queue') }}" class="modcp-nav-item">
<i data-lucide="message-circle-check" size="15"></i>
Approval Queue
</a>
</div>
</nav>
</aside>
<div class="modcp-content">
@yield('modcp-content')
</div>
</div>
@endsection

View File

@@ -37,6 +37,9 @@
{{-- Platforms --}}
<x-database-filter-without-mode title="Platform" :items="$allPlatforms" model="platforms"/>
{{-- Genres --}}
<x-database-filter-without-mode title="Genre" :items="$allGenres" model="genres"/>
{{-- Statuses --}}
<x-database-filter-without-mode title="Status" :items="$allStatuses" model="statuses"/>

View File

@@ -0,0 +1,89 @@
@extends('layouts.modcp')
@section('modcp-content')
<div class="modcp-page-title">
Authors
<span class="modcp-count">{{ $items->total() }}</span>
</div>
<x-mod-c-p-search placeholder="Search an author..."/>
<div class="modcp-add-form">
<form action="{{ route('modcp.authors.store') }}" method="POST">
@csrf
<div class="modcp-add-form-inner">
<input type="text" name="name" class="form-input"
placeholder="Author name..." required>
<div style="width:20%">
<livewire:xf-user-selector />
</div>
<input type="text" name="website" class="form-input"
placeholder="Website">
<button type="submit" class="btn primary">
<i data-lucide="plus" size="14"></i> Add
</button>
</div>
</form>
</div>
<div class="modcp-list">
@forelse($items as $author)
<div class="modcp-list-item" x-data="{ editing: false }">
<div class="modcp-list-item-info" x-show="!editing">
<span class="modcp-list-item-title">{{ $author->name }}</span>
<span class="modcp-list-item-meta">
<span class="badge">{{ $author->website ?? '—' }}</span>
@if(($xfUser = $author->user()) !== null )
<span class="badge">
<x-xf-username-link :user="$xfUser" />
</span>
@endif
· {{ $author->entries_count }} {{ Str::plural('entry', $author->entries_count) }}
</span>
</div>
<form action="{{ route('modcp.authors.update', $author) }}" method="POST"
class="modcp-list-item-edit modcp-list-item-edit--game"
x-show="editing" x-cloak>
@csrf @method('PATCH')
<input type="text" name="name" class="form-input"
placeholder="Author name..." value="{{ $author->name }}" required>
<div style="width:20%">
<livewire:xf-user-selector :initial-user-id="$author->user_id" />
</div>
<input type="text" name="website" class="form-input"
placeholder="Website" value="{{ $author->website }}">
<button type="submit" class="btn primary">
<i data-lucide="check" size="13"></i>
</button>
<button type="button" class="btn" @click="editing = false">
<i data-lucide="x" size="13"></i>
</button>
</form>
<div class="modcp-list-item-actions" x-show="!editing">
<button type="button" class="btn" @click="editing = true">
<i data-lucide="pen" size="13"></i>
</button>
<form action="{{ route('modcp.authors.destroy', $author) }}" method="POST"
style="display:inline"
onsubmit="return confirm('Delete {{ addslashes($author->name) }}?')">
@csrf @method('DELETE')
<button type="submit" class="btn danger"
{{ $author->entries_count > 0 ? 'disabled title=Has entries' : '' }}>
<i data-lucide="trash-2" size="13"></i>
</button>
</form>
</div>
</div>
@empty
<div class="modcp-empty"><p>No authors yet.</p></div>
@endforelse
</div>
{{ $items->links() }}
@endsection

View File

@@ -0,0 +1,61 @@
@extends('layouts.modcp')
@section('page-title', 'Deleted entries - ' . config('app.name') )
@section('modcp-content')
<div class="modcp-page-title">
<i data-lucide="trash-2" size="20"></i>
Deleted entries
<span class="modcp-count">{{ $entries->total() }}</span>
</div>
@if($entries->isEmpty())
<div class="modcp-empty">
<i data-lucide="check-circle" size="36"></i>
<p>No deleted entries.</p>
</div>
@else
<div class="modcp-list">
@foreach($entries as $entry)
<div class="modcp-list-item modcp-list-item--deleted">
<div class="modcp-list-item-cover">
@if($entry->main_image)
<img src="{{ Storage::url($entry->main_image) }}" alt="">
@else
<i data-lucide="image" size="20"></i>
@endif
</div>
<div class="modcp-list-item-info">
<span class="modcp-list-item-title">{{ $entry->complete_title ?? $entry->title }}</span>
<span class="modcp-list-item-meta">
<span class="badge {{ $entry->type }}">{{ $entry->type }}</span>
@php $daysLeft = max(0, 7 - (int) now()->diffInDays($entry->deleted_at)) @endphp
<span style="color: var(--error)">
Deleted {{ $entry->deleted_at->diffForHumans() }}
@if($daysLeft > 0) · purged in {{ $daysLeft }}d @endif
</span>
</span>
</div>
<div class="modcp-list-item-actions">
<form action="{{ route('modcp.restore', $entry->id) }}" method="POST" style="display:inline">
@csrf @method('PATCH')
<button type="submit" class="btn success">
<i data-lucide="rotate-ccw" size="13"></i> Restore
</button>
</form>
<form action="{{ route('modcp.destroy', $entry->id) }}" method="POST" style="display:inline"
@submit="if (!confirm('Permanently delete this entry?')) $event.preventDefault()">
@csrf @method('DELETE')
<button type="submit" class="btn danger">
<i data-lucide="trash-2" size="13"></i> Purge
</button>
</form>
</div>
</div>
@endforeach
</div>
{{ $entries->links() }}
@endif
@endsection

View File

@@ -0,0 +1,52 @@
@extends('layouts.modcp')
@section('page-title', $pageTitle . ' - ' . config('app.name') )
@section('modcp-content')
<div class="modcp-page-title">
{{ $pageTitle }}
<span class="modcp-count">{{ $entries->count() }}</span>
</div>
@if($entries->isEmpty())
<div class="modcp-empty">
<i data-lucide="check-circle" size="36"></i>
<p>No {{ $state }} entries.</p>
</div>
@else
<div class="modcp-list">
@foreach($entries as $entry)
<div class="modcp-list-item">
<div class="modcp-list-item-cover">
@if($entry->main_image)
<img src="{{ Storage::url($entry->main_image) }}" alt="">
@else
<i data-lucide="image" size="20"></i>
@endif
</div>
<div class="modcp-list-item-info">
<span class="modcp-list-item-title">{{ $entry->complete_title }}</span>
<span class="modcp-list-item-meta">
<span class="badge {{ $entry->type }}">{{ \App\Livewire\Database::ENTRY_TYPES[$entry->type] }}</span>
@if($entry->getRealPlatform())
<span class="badge">{{ $entry->getRealPlatform()->name }}</span>
@endif
Added {{ $entry->created_at->format('d M Y') }} by<x-xf-username-link :user-id="$entry->user_id" />
</span>
</div>
<div class="modcp-list-item-actions">
<a href="{{ route('entries.show', ['section' => $entry->type, 'entry' => $entry]) }}"
class="btn" target="_blank">
<i data-lucide="eye" size="13"></i> View
</a>
<a href="{{ route('submit.edit', [$entry->type, $entry->id]) }}"
class="btn">
<i data-lucide="pen" size="13"></i> Edit
</a>
</div>
</div>
@endforeach
</div>
{{ $entries->links() }}
@endif
@endsection

View File

@@ -0,0 +1,103 @@
@extends('layouts.modcp')
@section('modcp-content')
<div class="modcp-page-title">
Games
<span class="modcp-count">{{ $items->total() }}</span>
</div>
<x-mod-c-p-search placeholder="Search a game..." />
<div class="modcp-add-form">
<form action="{{ route('modcp.games.store') }}" method="POST">
@csrf
<div class="modcp-add-form-inner">
<input type="text" name="name" class="form-input"
placeholder="Game name..." required>
<select name="platform_id" class="form-select" required style="width: 20%">
<option value="" disabled selected>Platform...</option>
@foreach($platforms as $platform)
<option value="{{ $platform->id }}">{{ $platform->name }}</option>
@endforeach
</select>
<select name="genre_id" class="form-select" required style="width: 20%">
<option value="" disabled selected>Genre...</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}">{{ $genre->name }}</option>
@endforeach
</select>
<button type="submit" class="btn primary">
<i data-lucide="plus" size="14"></i> Add
</button>
</div>
</form>
</div>
<div class="modcp-list">
@forelse($items as $game)
<div class="modcp-list-item" x-data="{ editing: false }">
<div class="modcp-list-item-info" x-show="!editing">
<span class="modcp-list-item-title">{{ $game->name }}</span>
<span class="modcp-list-item-meta">
<span class="badge">{{ $game->platform->name ?? '—' }}</span>
<span class="badge">{{ $game->genre->name ?? '—' }}</span>
· {{ $game->entries_count }} {{ Str::plural('entry', $game->entries_count) }}
</span>
</div>
<form action="{{ route('modcp.games.update', $game) }}" method="POST"
class="modcp-list-item-edit modcp-list-item-edit--game"
x-show="editing" x-cloak>
@csrf @method('PATCH')
<input type="text" name="name" class="form-input"
value="{{ $game->name }}" required>
<select name="platform_id" class="form-select form-select--small" required>
@foreach($platforms as $platform)
<option value="{{ $platform->id }}"
{{ $game->platform_id == $platform->id ? 'selected' : '' }}>
{{ $platform->name }}
</option>
@endforeach
</select>
<select name="genre_id" class="form-select form-select--small" required>
@foreach($genres as $genre)
<option value="{{ $genre->id }}"
{{ $game->genre_id == $genre->id ? 'selected' : '' }}>
{{ $genre->name }}
</option>
@endforeach
</select>
<button type="submit" class="btn primary">
<i data-lucide="check" size="13"></i>
</button>
<button type="button" class="btn" @click="editing = false">
<i data-lucide="x" size="13"></i>
</button>
</form>
<div class="modcp-list-item-actions" x-show="!editing">
<button type="button" class="btn" @click="editing = true">
<i data-lucide="pen" size="13"></i>
</button>
<form action="{{ route('modcp.games.destroy', $game) }}" method="POST"
style="display:inline"
onsubmit="return confirm('Delete {{ addslashes($game->name) }}?')">
@csrf @method('DELETE')
<button type="submit" class="btn danger"
{{ $game->entries_count > 0 ? 'disabled title=Has entries' : '' }}>
<i data-lucide="trash-2" size="13"></i>
</button>
</form>
</div>
</div>
@empty
<div class="modcp-empty"><p>No games yet.</p></div>
@endforelse
</div>
{{ $items->links() }}
@endsection

View File

@@ -0,0 +1,91 @@
@extends('layouts.modcp')
@section('page-title', "Dashboard - " . config('app.name') )
@section('modcp-content')
<div class="modcp-page-title">
Dashboard
</div>
<div class="modcp-stats">
<a href="{{ route('queue.index') }}" class="modcp-stat-card modcp-stat-card--orange">
<div class="modcp-stat-icon"><i data-lucide="clipboard-list" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['pending'] }}</span>
<span class="modcp-stat-label">In queue</span>
</div>
</a>
<a href="{{ route('modcp.locked') }}" class="modcp-stat-card">
<div class="modcp-stat-icon"><i data-lucide="lock" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['locked'] }}</span>
<span class="modcp-stat-label">Locked</span>
</div>
</a>
@can('is-admin')
<a href="{{ route('modcp.draft') }}" class="modcp-stat-card">
<div class="modcp-stat-icon"><i data-lucide="scissors" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['draft'] }}</span>
<span class="modcp-stat-label">Draft</span>
</div>
</a>
<a href="{{ route('modcp.hidden') }}" class="modcp-stat-card">
<div class="modcp-stat-icon"><i data-lucide="eye-off" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['hidden'] }}</span>
<span class="modcp-stat-label">Hidden</span>
</div>
</a>
<a href="{{ route('modcp.deleted') }}" class="modcp-stat-card modcp-stat-card--danger">
<div class="modcp-stat-icon"><i data-lucide="trash-2" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['deleted'] }}</span>
<span class="modcp-stat-label">Deleted</span>
</div>
</a>
@endcan
<div class="modcp-stat-card modcp-stat-card--muted">
<div class="modcp-stat-icon"><i data-lucide="database" size="22"></i></div>
<div class="modcp-stat-info">
<span class="modcp-stat-value">{{ $stats['total'] }}</span>
<span class="modcp-stat-label">Total entries</span>
</div>
</div>
</div>
@if($recentDeleted->isNotEmpty())
<div class="modcp-section-title" style="margin-top: 25px;">Recently deleted</div>
<div class="modcp-list">
@foreach($recentDeleted as $entry)
<div class="modcp-list-item">
<div class="modcp-list-item-info">
<span class="modcp-list-item-title">{{ $entry->complete_title ?? $entry->title }}</span>
<span class="modcp-list-item-meta">
<span class="badge {{ $entry->type }}">{{ $entry->type }}</span>
Deleted {{ $entry->deleted_at->diffForHumans() }}
</span>
</div>
<div class="modcp-list-item-actions">
<form action="{{ route('modcp.restore', $entry->id) }}" method="POST" style="display:inline">
@csrf @method('PATCH')
<button type="submit" class="btn success">
<i data-lucide="rotate-ccw" size="13"></i> Restore
</button>
</form>
<form action="{{ route('modcp.destroy', $entry->id) }}" method="POST" style="display:inline"
onsubmit="return confirm('Permanently delete?')">
@csrf @method('DELETE')
<button type="submit" class="btn danger">
<i data-lucide="trash-2" size="13"></i> Purge
</button>
</form>
</div>
</div>
@endforeach
<a href="{{ route('modcp.deleted') }}" class="modcp-list-see-all">
See all deleted entries
</a>
</div>
@endif
@endsection

View File

@@ -0,0 +1,76 @@
@extends('layouts.modcp')
@section('page-title', $title . ' - ' . config('app.name'))
@section('modcp-content')
<div class="modcp-page-title">
{{ $title }}
<span class="modcp-count">{{ $items->total() }}</span>
</div>
<x-mod-c-p-search placeholder="Search a {{ $singular }}..." />
<div class="modcp-add-form">
<form action="{{ route($storeRoute) }}" method="POST" class="modcp-add-form-inner">
@csrf
<input type="text" name="name" class="form-input" placeholder="Add new {{ strtolower($singular) }}..." required>
@if(isset($extraFields))
@foreach($extraFields as $field)
<input type="text" name="{{ $field['name'] }}" class="form-input" placeholder="{{ $field['placeholder'] }}">
@endforeach
@endif
<button type="submit" class="btn primary">
<i data-lucide="plus" size="14"></i> Add
</button>
</form>
</div>
<div class="modcp-list">
@forelse($items as $item)
<div class="modcp-list-item" x-data="{ editing: false }">
<div class="modcp-list-item-info" x-show="!editing">
<span class="modcp-list-item-title">{{ $item->name }}</span>
<span class="modcp-list-item-meta">
slug: {{ $item->slug }}
@isset($item->entries_count)
· {{ $item->entries_count }} {{ Str::plural('entry', $item->entries_count) }}
@endisset
</span>
</div>
<form action="{{ route($updateRoute, $item) }}" method="POST"
class="modcp-list-item-edit" x-show="editing" x-cloak>
@csrf @method('PATCH')
<input type="text" name="name" class="form-input" value="{{ $item->name }}">
<button type="submit" class="btn primary">
<i data-lucide="check" size="13"></i>
</button>
<button type="button" class="btn" @click="editing = false">
<i data-lucide="x" size="13"></i>
</button>
</form>
<div class="modcp-list-item-actions" x-show="!editing">
<button type="button" class="btn" @click="editing = true">
<i data-lucide="pen" size="13"></i>
</button>
<form action="{{ route($destroyRoute, $item) }}" method="POST" style="display:inline"
onsubmit="return confirm('Delete {{ $item->name }}?')">
@csrf @method('DELETE')
<button type="submit" class="btn danger">
<i data-lucide="trash-2" size="13"></i>
</button>
</form>
</div>
</div>
@empty
<div class="modcp-empty">
<p>No {{ strtolower($title) }} yet.</p>
</div>
@endforelse
</div>
{{ $items->links() }}
@endsection

View File

@@ -137,7 +137,7 @@
@endif
<x-form-group-title label="{{ $words['attachments'] }}" icon="paperclip" />
<x-main-image-field :old-path="old('main-image', $entry->main_image ?? '')" />
<x-main-image-field :old-path="old('main-image', $entry->main_image ?? '') ?? ''" />
<x-gallery-field :old-paths="old('gallery', $entry->gallery->pluck('image')->toArray() ?? [] )"/>
@error('gallery')
<x-form-error-text message="{{ $message }}" />
@@ -181,6 +181,18 @@
<livewire:xf-user-selector :initial-user-id="$entry->user_id" />
</div>
</div>
<div class="form-group grid-c2">
<div>
<x-form-field-title name="XenForo Comments Thread ID" />
<input type="text" name="comments_thread_id" class="form-input" value="{{ old('comments_thread_id', $entry->comments_thread_id) }}">
</div>
</div>
<div class="form-group">
<x-form-field-title name="Metadata" required="true" />
<div class="form-type-of-checkboxes form-group level" id="entry-metadata">
<label><input class="form-checkbox" type="checkbox" name="featured" value="1" {{ old('featured', $entry->featured ) ? 'checked' : '' }}>Featured entry</label>
</div>
</div>
@endcan
@cannot('moderate', $entry)

View File

@@ -36,21 +36,18 @@
'upload-item-done': file.done,
'upload-item-error': file.error
}">
<template x-if="!file.done && !file.error">
<i data-lucide="loader-2" class="spin"></i>
</template>
<template x-if="file.error">
<i data-lucide="alert-circle"></i>
</template>
<template x-if="file.done && file.state === 'public'">
<i data-lucide="eye" class="file-state-icon file-state-icon--public"></i>
</template>
<template x-if="file.done && file.state === 'private'">
<i data-lucide="eye-off" class="file-state-icon file-state-icon--private"></i>
</template>
<template x-if="file.done && file.state === 'archived'">
<i data-lucide="archive" class="file-state-icon file-state-icon--archived"></i>
</template>
<div class="file-status-icons">
<i x-show="!file.done && !file.error" data-lucide="loader-2" class="spin"></i>
<i x-show="file.error" data-lucide="alert-circle"></i>
<i x-show="file.done && file.state === 'public'" data-lucide="eye" class="file-state-icon file-state-icon--public"></i>
<i x-show="file.done && file.state === 'private'" data-lucide="eye-off" class="file-state-icon file-state-icon--private"></i>
<i x-show="file.done && file.state === 'archived'" data-lucide="archive" class="file-state-icon file-state-icon--archived"></i>
</div>
<div class="upload-item-info">
<span class="upload-item-name" x-text="file.name"></span>
@@ -103,13 +100,46 @@
</div>
</div>
@endif
<div class="upload-item-actions">
<div class="upload-item-actions" x-data="{ showMetadata: false }">
<button type="button" class="btn" x-show="file.error" @click="handleRetryFile(i)">
<i data-lucide="refresh-cw"></i>
</button>
@if($isEdit)
<button type="button" class="btn" x-show="file.done" @click="showMetadata = true">
<i data-lucide="settings"></i>
</button>
@endif
<button type="button" class="btn" x-show="file.done || file.error" @click="handleRemoveFile(i)">
<i data-lucide="x"></i>
</button>
<template x-teleport="body">
<div class="modal-overlay"
x-cloak
x-show="showMetadata"
x-transition.opacity.duration.300ms
@click.self="showMetadata = false"
@keydown.escape.window="showMetadata = false">
<div class="modal-window" x-show="showMetadata" x-transition>
<div class="modal-header">
<span class="modal-title" style="display: flex; align-items: center; gap: 8px;">
File Settings: <span x-text="file.name" style="color: var(--rhpz-orange);"></span>
</span>
<button type="button" class="modal-close" @click="showMetadata = false">
<i data-lucide="x" size="20"></i>
</button>
</div>
<div class="modal-content">
<div class="form-group">
<x-form-group-title label="Online patcher" />
<label><input type="checkbox" class="form-checkbox" :name="'files_metadata[' + file.uuid + '][online_patcher]'" x-model="file.meta_online_patcher"> Enable it</label>
<label x-show="file.meta_online_patcher"><input type="checkbox" class="form-checkbox" :name="'files_metadata[' + file.meta_secondary_online_patcher + '][secondary_online_patcher]'" x-model="file.meta_secondary_online_patcher"> Mark as a secondary patch</label>
</div>
</div>
</div>
</div>
</template>
</div>
<input type="hidden" name="files_uuid[]" :value="file.uuid" x-show="file.done">
@if($isEdit)

View File

@@ -0,0 +1,92 @@
@extends('layouts.app')
@section('page-title', "ROM Patcher - " . config('app.name'))
@push('scripts')
<script type="text/javascript" src="{{ asset('rom-patcher-js/RomPatcher.webapp.js') }}"></script>
@endpush
@section('content')
<div class="page-title">
<span>ROM Patcher</span>
</div>
<div id="rom-patcher-container" class="patcher-container" x-data="RomPatcher({{ Js::from($patches ?? []) }})">
<div class="patcher-grid">
<div class="form-group">
<label class="form-label">1. Original ROM</label>
<div class="patcher-dropzone" id="rom-dropzone"
:class="{ 'dragover': isRomDragOver, 'has-file': romFileName !== '' }"
@click="triggerFileInput('rom-patcher-input-file-rom')"
@dragover.prevent="isRomDragOver = true"
@dragleave.prevent="isRomDragOver = false"
@drop.prevent="isRomDragOver = false; handleDrop($event, 'rom')">
<input type="file" id="rom-patcher-input-file-rom" style="display: none;" @change="handleInputChange($event, 'rom')" disabled />
<i data-lucide="gamepad-2" size="40" :style="romFileName ? 'color: var(--rhpz-orange)' : 'color: var(--text2)'"></i>
<span style="color: var(--text); font-weight: 500;" x-text="romFileName ? romFileName : 'Drag n\'drop your file here or click'"></span>
</div>
</div>
<div class="form-group">
<label class="form-label">2. Patch file</label>
<div class="patcher-dropzone" id="patch-dropzone"
x-show="!hasEmbedded"
:class="{ 'dragover': isPatchDragOver, 'has-file': patchFileName !== '' }"
@click="triggerFileInput('rom-patcher-input-file-patch')"
@dragover.prevent="isPatchDragOver = true"
@dragleave.prevent="isPatchDragOver = false"
@drop.prevent="isPatchDragOver = false; handleDrop($event, 'patch')">
<input type="file" id="rom-patcher-input-file-patch" style="display: none;" @change="handleInputChange($event, 'patch')" disabled />
<i data-lucide="file-archive" size="40" :style="patchFileName ? 'color: var(--rhpz-orange)' : 'color: var(--text2)'"></i>
<span style="color: var(--text); font-weight: 500;" x-text="patchFileName ? patchFileName : 'Drag n\'drop your file here or click'"></span>
</div>
<div x-show="hasEmbedded" class="embed-patch-box">
<div class="embed-patch-box-icon">
<div class="embed-patch-box-icon-block">
<i data-lucide="package" size="24" color="var(--rhpz-orange)"></i>
</div>
<div>
<div style="font-weight: 600; color: var(--text); font-size: 1.1rem;">Patch file</div>
<div style="font-size: 0.85rem; color: var(--text2);">Select if there is multiple patch files</div>
</div>
</div>
<div class="rom-patcher-container-input" style="margin-top: 10px;">
<select id="rom-patcher-select-patch" class="form-select" style="width: 100%; cursor: pointer;"></select>
</div>
</div>
</div>
</div>
<div class="patcher-status-box" id="patcher-status" x-show="showStatusBox" x-transition x-cloak style="margin-top: 20px;">
<div class="rom-patcher-row" style="color: var(--text2);">
<div style="color: var(--rhpz-orange); font-weight: bold;">Checksums:</div>
<ul style="margin-bottom: 0">
<li>CRC32: <span id="rom-patcher-span-crc32"></span></li>
<li>MD5: <span id="rom-patcher-span-md5"></span></li>
<li>SHA-1: <span id="rom-patcher-span-sha1"></span></li>
</ul>
</div>
<span id="rom-patcher-span-rom-info"></span>
<div class="rom-patcher-row margin-bottom" id="rom-patcher-row-patch-description">
<div style="color: var(--rhpz-orange); font-weight: bold;">Description:</div>
<div id="rom-patcher-patch-description"></div>
</div>
<div class="rom-patcher-row margin-bottom" id="rom-patcher-row-patch-requirements">
<div id="rom-patcher-patch-requirements-type" style="color: var(--rhpz-orange); font-weight: bold;">ROM requirements:</div>
<div id="rom-patcher-patch-requirements-value"></div>
</div>
</div>
<div style="margin-top: 25px; border-top: 1px solid var(--border); padding-top: 20px; text-align: right;">
<button type="button" class="btn primary" id="rom-patcher-button-apply" disabled>
<i data-lucide="wrench" size="16"></i> Apply patch
</button>
</div>
</div>
@endsection

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\EntryController;
use App\Http\Controllers\ModCP\LanguageController;
use App\Http\Controllers\WebhookController;
use Illuminate\Routing\RedirectController;
use Illuminate\Support\Facades\Route;
@@ -12,6 +13,9 @@ Route::get('/', [ \App\Http\Controllers\HomeController::class, 'index' ] )->name
Route::name('entries.')->controller(EntryController::class)->group(function () {
Route::get('/database', 'index' )->name('index');
Route::get('/{section}', 'section_redirect' )->name('section_redirect')
->where(['section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials']);
Route::get('/{section}/{entry:slug}', 'show' )->name('show')->where(
[
'section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials',
@@ -19,6 +23,8 @@ Route::name('entries.')->controller(EntryController::class)->group(function () {
]
);
Route::get('/my-drafts', 'drafts' )->middleware('xf.auth')->name('drafts');
});
// SubmissionController.
@@ -34,6 +40,8 @@ Route::name('submit.')->prefix('/edit')->controller(\App\Http\Controllers\Submis
->where([ 'section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials', 'entry' => '[0-9\-]+' ]);
Route::post('/{section}/{entry:id}', 'update' )->name('update')
->where([ 'section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials', 'entry' => '[0-9\-]+' ]);
Route::delete('/{section}/{entry:id}', 'destroy' )->name('destroy')
->where([ 'section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials', 'entry' => '[0-9\-]+' ]);
});
// QueueController
@@ -56,6 +64,30 @@ Route::name('queue.')->prefix('/queue')->controller(\App\Http\Controllers\QueueC
->name('reject');
});
// ToolsController
Route::name('tools.')->controller(\App\Http\Controllers\ToolsController::class)->group(function () {
Route::get('/patch', 'patcher' )->name('patcher');
});
// ModeratorCPController
Route::name('modcp.')->prefix('/modcp')->controller(\App\Http\Controllers\ModCPController::class)->middleware(['xf.auth','can:is-mod'])->group(function () {
Route::get('/', 'index' )->name('index');
Route::get('/locked-entries', 'locked' )->name('locked');
Route::get('/draft-entries', 'draft' )->name('draft')->middleware('can:is-admin');
Route::get('/hidden-entries', 'hidden' )->name('hidden')->middleware('can:is-admin');
Route::get('/deleted-entries', 'deleted' )->name('deleted')->middleware('can:is-admin');
Route::patch('/restore/{entry}', 'restore' )->name('restore')->where(['entry' => '[0-9\-]+'])->withTrashed()->middleware('can:is-admin');
Route::delete('/purge/{entry}', 'destroy' )->name('destroy')->where(['entry' => '[0-9\-]+'])->withTrashed()->middleware('can:is-admin');
Route::resource('games', \App\Http\Controllers\ModCP\GameController::class)->only(['index', 'store','update','destroy']);
Route::resource('languages', LanguageController::class)->only(['index', 'store','update','destroy']);
Route::resource('authors', \App\Http\Controllers\ModCP\AuthorController::class)->only(['index', 'store','update','destroy']);
Route::resource('platforms', \App\Http\Controllers\ModCP\PlatformController::class )->middleware('can:is-admin')->only(['index', 'store','update','destroy']);
Route::resource('genres', \App\Http\Controllers\ModCP\GenreController::class )->middleware('can:is-admin')->only(['index', 'store','update','destroy']);
});
// RedirectController
Route::name('redirect.')->controller(\App\Http\Controllers\RedirectController::class)->group(function () {
Route::get('/entry/report_redirect', 'entryReportRedirect' )->name('entry_report');

View File

@@ -22,8 +22,8 @@ export default defineConfig({
ignored: ['**/storage/framework/views/**'],
},
https: {
cert: '/mnt/01D9BE39AF0FC580/romhackplaza/rhpz.local+1.pem',
key: '/mnt/01D9BE39AF0FC580/romhackplaza/rhpz.local+1-key.pem',
cert: '/mnt/ssd-data/sites/romhackplaza/rhpz.local+1.pem',
key: '/mnt/ssd-data/sites/romhackplaza/rhpz.local+1-key.pem',
},
},
});