Implement caching system with IndexedDB support and add getAll method to EJS_STORAGE

- Added `getAll` method to `EJS_STORAGE` for retrieving all stored items.
- Enabled database usage in `index.html` by setting `EJS_disableDatabases` to false.
- Introduced a comprehensive caching system in `CACHING.md`, detailing architecture, operations, and best practices.
- Created `EJS_Cache` class for managing cached files, including size and age constraints.
- Implemented `EJS_CacheItem` and `EJS_FileItem` classes for structured cache items.
- Added `test_rom_cache.html` for testing ROM caching functionality.
This commit is contained in:
Michael Green 2025-10-30 14:48:05 +11:00
parent 957cc87b28
commit 9aa8fbc706
8 changed files with 896 additions and 218 deletions

250
CACHING.md Normal file
View File

@ -0,0 +1,250 @@
# EmulatorJS Caching System
## Overview
EmulatorJS implements a sophisticated multi-layer caching system designed to optimize performance by minimizing redundant downloads and decompression operations. The system combines browser-native HTTP caching with a custom decompression cache to provide fast loading times for emulator cores, ROMs, BIOS files, and other assets.
## High-Level Architecture
### Two-Layer Caching Strategy
1. **Browser HTTP Cache (Layer 1)**
- Handles file-level caching using standard HTTP cache headers
- Caches compressed files (ZIP, 7Z, RAR) and uncompressed files
- Managed automatically by the browser
- Provides conditional requests (If-Modified-Since, ETag) for cache validation
2. **Decompression Cache (Layer 2)**
- Custom IndexedDB-based cache for decompressed content
- Stores the results of expensive decompression operations
- Prevents re-decompression of previously processed archives
- Configurable size limits and expiration policies
### Cache Flow
```
Download Request → Browser Cache Check → File Downloaded/Retrieved
Decompression Required? → Decompression Cache Check → Content Extracted
File System Operations → Game Launch
```
## Detailed Implementation
### Browser Cache Integration
EmulatorJS leverages the browser's built-in HTTP caching mechanisms:
- **File Downloads**: All file downloads use standard HTTP requests that respect cache headers
- **Conditional Requests**: The system performs HEAD requests to validate cached files against server versions
- **Cache Invalidation**: Automatically detects updated files on the server through ETag/Last-Modified headers
### Decompression Cache (EJS_Cache)
#### Storage Backend
- **Technology**: IndexedDB via custom `EJS_STORAGE` wrapper
- **Database**: `EJS_decompression_cache`
- **Object Store**: `cache_items`
- **Key Structure**: `compression_{hash}_{size}` format
#### Cache Key Generation
```javascript
// Hash calculation for cache key
let hash = 0;
for (let i = 0; i < dataArray.length; i++) {
hash = ((hash << 5) - hash + dataArray[i]) & 0xffffffff;
}
const cacheKey = `compression_${hash}_${dataArray.length}`;
```
#### Cache Configuration
- **Default Size Limit**: 4GB (4,294,967,296 bytes)
- **Default Expiration**: 5 days (432,000,000 milliseconds)
- **Storage Location**: Browser's IndexedDB
- **Cleanup Policy**: LRU (Least Recently Used) with size-based eviction
#### Cache Item Structure
```javascript
class EJS_CacheItem {
constructor(key, files, timestamp, type = 'unknown', filename = null) {
this.key = key; // Unique identifier
this.files = files; // Array of EJS_FileItem objects
this.timestamp = timestamp; // Creation/access time
this.type = type; // Type of cached content (core, ROM, BIOS, etc.)
this.filename = filename; // Original filename
}
}
class EJS_FileItem {
constructor(filename, bytes) {
this.filename = filename; // Original filename in archive
this.bytes = bytes; // Uint8Array of file content
}
}
```
### File Type Handling
#### Core Files (.wasm, .js)
- **Download**: Browser cache handles file-level caching
- **Decompression**: Decompression cache stores extracted core files
- **Validation**: HEAD requests check for server updates
- **Storage**: No additional IndexedDB storage (removed in favor of browser cache)
#### ROM Files (.zip, .7z, .rar, .iso, .bin, etc.)
- **Download**: Browser cache for compressed archives
- **Decompression**: Callback-based extraction with cache storage
- **File Handling**: Multiple files extracted and cached individually
- **Game Selection**: Automatic selection of primary ROM file
#### BIOS Files
- **Download**: Browser cache for compressed/uncompressed files
- **Decompression**: Same cache mechanism as ROM files
- **Extraction**: Optional based on `dontExtractBIOS` configuration
### Cache Operations
#### checkCompression() Method
The core caching logic handles both Promise-based and callback-based decompression:
```javascript
checkCompression(data, msg, fileCbFunc) {
// Generate cache key from data hash
const cacheKey = `compression_${hash}_${dataArray.length}`;
// Check cache first
const cachedItem = await this.storageCache.get(cacheKey);
if (cachedItem) {
// Cache HIT: Return cached files
return cached_files;
}
// Cache MISS: Decompress and store
const decompressedFiles = await this.compression.decompress(data, updateMsg, callbackWrapper);
// Store in cache for future use
const cacheItem = new EJS_CacheItem(cacheKey, fileItems, Date.now(), 'decompressed', 'example-file.zip');
await this.storageCache.put(cacheItem);
return decompressedFiles;
}
```
#### Cache Management
- **Size Monitoring**: Tracks total cache size and evicts old items when limit exceeded
- **Expiration Handling**: Removes items older than configured expiration time
- **Manual Management**: UI provides cache inspection and clearing capabilities
### Performance Optimizations
#### Timing and Benchmarking
Every cache operation includes detailed timing metrics:
```javascript
// Cache HIT example
[EJS Cache] Cache HIT for 15.2MB data - Total: 23.45ms (hash: 12.1ms, cache lookup: 11.35ms)
// Cache MISS example
[EJS Cache] Cache MISS for 15.2MB data - Starting decompression (hash: 12.1ms, cache lookup: 8.2ms)
[EJS Cache] Decompression complete for 15.2MB data - Total: 1847.3ms (decompression: 1789.2ms, cache store: 58.1ms)
```
#### Memory Management
- **Streaming**: Large files processed in chunks where possible
- **Worker Threads**: Decompression operations run in web workers to prevent UI blocking
- **Cleanup**: Manual cleanup available through UI, automatic cleanup on startup and during size-based eviction
### Cache Validation and Invalidation
#### Server-Side Changes
The system detects server-side file updates through:
1. **HEAD Requests**: Check ETag and Last-Modified headers
2. **Conditional Downloads**: Only download if file has changed
3. **Cache Invalidation**: Remove stale cache entries when source files update
#### Client-Side Management
- **Manual Clearing**: Users can clear cache through settings menu
- **Selective Removal**: Individual cache items can be removed
- **Diagnostic Tools**: Cache contents viewable through management UI
### Error Handling
#### Network Failures
- **Graceful Degradation**: Falls back to cached content when network unavailable
- **Retry Logic**: Implements retry mechanisms for transient failures
- **Error Reporting**: Detailed logging for debugging cache issues
#### Cache Corruption
- **Validation**: Verifies cache item integrity before use
- **Recovery**: Automatically rebuilds corrupted cache entries
- **Fallback**: Falls back to fresh downloads when cache fails
### Migration and Compatibility
#### Storage Migration
The current implementation removes legacy storage systems:
- **Removed**: Separate IndexedDB stores for cores, ROMs, and BIOS files
- **Unified**: Single decompression cache for all compressed content
- **Backward Compatibility**: Graceful handling of existing cache data
#### Browser Compatibility
- **IndexedDB Support**: Required for decompression cache
- **HTTP Cache**: Utilizes standard browser caching mechanisms
- **Fallback**: Degrades gracefully when storage unavailable
## Configuration Options
### EJS_Cache Parameters
- `maxSize`: Maximum cache size in bytes (default: 4GB)
- `maxAge`: Item expiration time in milliseconds (default: 5 days)
- `storageKey`: IndexedDB database name
### Runtime Configuration
- `dontExtractRom`: Skip ROM extraction for certain cores
- `dontExtractBIOS`: Skip BIOS extraction when not needed
- `disableCue`: Control CUE file generation for disc-based games
## Best Practices
### For Developers
1. **Monitor Cache Size**: Regular cleanup prevents storage quota issues
2. **Handle Cache Failures**: Always provide fallback mechanisms
3. **Optimize File Sizes**: Smaller files cache more efficiently
4. **Use Appropriate Headers**: Set proper cache headers on servers
### For Users
1. **Clear Cache Periodically**: Prevents storage quota issues
2. **Monitor Network Usage**: Cache reduces bandwidth consumption
3. **Report Performance Issues**: Cache metrics help identify problems
## Troubleshooting
### Common Issues
1. **Cache Not Working**: Check IndexedDB support and storage quota
2. **Slow Loading**: Monitor cache hit/miss ratios in console
3. **Storage Full**: Clear cache or increase browser storage quota
4. **Stale Content**: Check cache expiration settings
### Debug Information
All cache operations log detailed information to the browser console:
- Cache hit/miss status
- Timing breakdowns
- File sizes and counts
- Error conditions
## Cache Manager UI
The Cache Manager provides a user interface for viewing and managing cached items. It displays a table with the following columns:
- **Filename**: Original filename of the cached content
- **Type**: Content type (core, ROM, BIOS, asset, etc.)
- **Size**: Total size of cached files
- **Last Used**: Relative time since last access (e.g., "2h ago", "3d ago")
- **Action**: Remove button to delete individual cache entries
The interface includes options to:
- **Cleanup Now**: Remove old/excess items based on age and size constraints
- **Clear All**: Remove all cached items
This comprehensive caching system significantly improves EmulatorJS performance by eliminating redundant operations while maintaining data freshness and reliability.

View File

@ -4,6 +4,7 @@
"nipplejs.js",
"shaders.js",
"storage.js",
"cache.js",
"gamepad.js",
"GameManager.js",
"socket.io.min.js",

View File

@ -98,7 +98,7 @@ class EJS_GameManager {
const invalidCharacters = /[#<$+%>!`&*'|{}/\\?"=@:^\r\n]/ig;
let name = this.EJS.config.externalFiles[key].split("/").pop().split("#")[0].split("?")[0].replace(invalidCharacters, "").trim();
if (!name) return done();
const files = await this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Assets"));
const files = await this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Assets"), null, "asset", name);
if (files["!!notCompressedData"]) {
path += name;
} else {
@ -336,7 +336,7 @@ class EJS_GameManager {
loadPpssppAssets() {
return new Promise(resolve => {
this.EJS.downloadFile("cores/ppsspp-assets.zip", null, false, { responseType: "arraybuffer", method: "GET" }).then((res) => {
this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Data")).then((pspassets) => {
this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Data"), null, "asset", "ppsspp-assets.zip").then((pspassets) => {
if (pspassets === -1) {
this.EJS.textElem.innerText = this.localization("Network Error");
this.EJS.textElem.style.color = "red";

232
data/src/cache.js Normal file
View File

@ -0,0 +1,232 @@
/**
* EJS_Cache
* Manages a cache of files using IndexedDB for storage.
*/
class EJS_Cache {
/**
* Creates an instance of EJS_Cache.
* @param {boolean} enabled - Whether caching is enabled.
* @param {EJS_STORAGE} storage - Instance of EJS_STORAGE for IndexedDB operations.
* @param {number} maxSizeMB - Maximum size of the cache in megabytes.
* @param {number} maxAgeMins - Maximum age of items (in minutes) before they are cleaned up.
*/
constructor(enabled = true, storage, maxSizeMB = 4096, maxAgeMins = 7200) {
this.enabled = enabled;
this.storage = storage;
this.maxSizeMB = maxSizeMB;
this.maxAgeMins = maxAgeMins;
this.minAgeMins = Math.max(60, maxAgeMins * 0.1); // Minimum 1 hour, or 10% of max age
console.log('Initialized EJS_Cache with settings:', {
enabled: this.enabled,
storage: this.storage,
enabledValue: enabled,
maxSizeMB: this.maxSizeMB,
maxAgeMins: this.maxAgeMins,
minAgeMins: this.minAgeMins
});
}
/**
* Retrieves an item from the cache.
* @param {*} key - The unique key identifying the cached item.
* @returns {Promise<EJS_CacheItem|null>} - The cached item or null if not found.
*/
async get(key) {
if (!this.enabled) return null;
const item = await this.storage.get(key);
// if the item exists, update its lastAccessed time and return cache item
if (item) {
item.lastAccessed = Date.now();
await this.storage.put(key, item);
}
return item ? new EJS_CacheItem(item.key, item.files, item.added, item.type, item.filename) : null;
}
/**
* Stores an item in the cache.
* @param {EJS_CacheItem} item - The cache item to store.
*/
async put(item) {
if (!this.enabled) return;
// before putting, ensure item is of type EJS_CacheItem
if (!(item instanceof EJS_CacheItem)) {
throw new Error("Item must be an instance of EJS_CacheItem");
}
// check if the item exists, if so remove the existing item
const existingItem = await this.get(item.key);
if (existingItem) {
await this.storage.remove(item.key);
}
// check that the size of item.files does not cause the cache to exceed maxSizeMB
let currentSize = 0;
const allItems = await this.storage.getAll();
for (let i = 0; i < allItems.length; i++) {
if (allItems[i] && allItems[i].files) {
for (let j = 0; j < allItems[i].files.length; j++) {
if (allItems[i].files[j] && allItems[i].files[j].bytes && typeof allItems[i].files[j].bytes.byteLength === "number") {
currentSize += allItems[i].files[j].bytes.byteLength;
}
}
}
}
if ((currentSize + item.size()) > (this.maxSizeMB * 1024 * 1024)) {
// exceeded max size, keep removing oldest items until we are under maxSizeMB + the size of the new item
const itemsToRemove = [];
let sizeToFree = (currentSize + item.size()) - (this.maxSizeMB * 1024 * 1024);
for (let i = 0; i < allItems.length; i++) {
if (allItems[i] && allItems[i].files) {
const itemSize = allItems[i].files.reduce((sum, file) => sum + (file.bytes ? file.bytes.byteLength : 0), 0);
itemsToRemove.push({ item: allItems[i], size: itemSize });
}
}
itemsToRemove.sort((a, b) => a.item.lastAccessed - b.item.lastAccessed); // oldest first
for (let i = 0; i < itemsToRemove.length; i++) {
if (sizeToFree <= 0) break;
await this.storage.remove(itemsToRemove[i].item.key);
sizeToFree -= itemsToRemove[i].size;
}
}
await this.storage.put(item.key, {
key: item.key,
files: item.files,
added: item.added,
lastAccessed: item.lastAccessed,
type: item.type,
filename: item.filename
});
}
/**
* Deletes an item from the cache.
* @param {string} key - The unique key identifying the cached item to delete.
*/
async delete(key) {
// fail silently if the key does not exist
try {
await this.storage.remove(key);
} catch (e) {
console.error("Failed to delete cache item:", e);
}
}
/**
* Clears all items from the cache.
*/
async clear() {
const allItems = await this.storage.getAll();
for (let i = 0; i < allItems.length; i++) {
await this.storage.remove(allItems[i].key);
}
}
/**
* Cleans up the cache by removing old or excess items based on size and age constraints.
*/
async cleanup() {
if (!this.enabled) return;
console.log('[EJS Cache] Starting cache cleanup...');
const cleanupStartTime = performance.now();
// get all items
const allItems = await this.storage.getAll();
const now = Date.now();
// sort items by lastAccessed (oldest first)
allItems.sort((a, b) => a.lastAccessed - b.lastAccessed);
let currentSize = 0;
let totalItems = allItems.length;
const itemsToRemove = [];
// Calculate current total size
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
const itemSize = item.files.reduce((sum, file) => sum + (file.bytes ? file.bytes.byteLength : 0), 0);
currentSize += itemSize;
const ageMins = (now - item.lastAccessed) / (1000 * 60);
// Remove if too old OR if cache is over size limit and item is old enough
if (ageMins > this.maxAgeMins || (currentSize > this.maxSizeMB * 1024 * 1024 && ageMins > this.minAgeMins)) {
itemsToRemove.push({ key: item.key, size: itemSize, age: ageMins });
currentSize -= itemSize;
}
}
// remove items from storage
for (const item of itemsToRemove) {
await this.storage.remove(item.key);
}
const cleanupTime = performance.now() - cleanupStartTime;
const currentSizeMB = (currentSize / (1024 * 1024)).toFixed(2);
const removedSizeMB = (itemsToRemove.reduce((sum, item) => sum + item.size, 0) / (1024 * 1024)).toFixed(2);
console.log(`[EJS Cache] Cleanup complete in ${cleanupTime.toFixed(2)}ms - Removed ${itemsToRemove.length}/${totalItems} items (${removedSizeMB}MB), ${currentSizeMB}MB remaining`);
}
}
/**
* EJS_CacheItem
* Represents a single cached item in the EJS_Cache system.
* Contains metadata about the cached item. This class is an internal structure used by EJS_Cache.
*/
class EJS_CacheItem {
/**
* Creates an instance of EJS_CacheItem.
* @param {string} key - Unique identifier for the cached item.
* @param {EJS_FileItem[]} files - array of EJS_FileItem objects representing the files associated with this cache item.
* @param {number} added - Timestamp (in milliseconds) when the item was added to the cache.
* @param {string} type - The type of cached content (e.g., 'core', 'ROM', 'BIOS', 'decompressed').
* @param {string} filename - The original filename of the cached content.
*/
constructor(key, files, added, type = 'unknown', filename = null) {
this.key = key;
this.files = files;
this.added = added;
this.lastAccessed = added;
this.type = type;
this.filename = filename || key; // fallback to key if no filename provided
}
/**
* Calculates the total size of all files in this cache item.
* @returns {number} - Total size in bytes.
*/
size() {
let total = 0;
for (let i = 0; i < this.files.length; i++) {
if (this.files[i] && this.files[i].bytes && typeof this.files[i].bytes.byteLength === "number") {
total += this.files[i].bytes.byteLength;
}
}
return total;
}
}
/**
* EJS_FileItem
* Represents a single file stored in the cache. This class is an internal structure used by EJS_CacheItem.
*/
class EJS_FileItem {
/**
* Creates an instance of EJS_FileItem.
* @param {string} filename - Name of the file.
* @param {Uint8Array} bytes - Byte array representing the file's data.
*/
constructor(filename, bytes) {
this.filename = filename;
this.bytes = bytes;
}
}
window.EJS_Cache = EJS_Cache;
window.EJS_CacheItem = EJS_CacheItem;
window.EJS_FileItem = EJS_FileItem;

View File

@ -329,16 +329,23 @@ class EmulatorJS {
this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (this.config.disableDatabases) {
this.storage = {
rom: new window.EJS_DUMMYSTORAGE(),
bios: new window.EJS_DUMMYSTORAGE(),
core: new window.EJS_DUMMYSTORAGE()
// Remove rom and bios storage - rely on browser cache for files and checkCompression for decompression
}
this.storageCache = new window.EJS_Cache(false, new window.EJS_DUMMYSTORAGE(), 1, 1);
} else {
this.storage = {
rom: new window.EJS_STORAGE("EmulatorJS-roms", "rom"),
bios: new window.EJS_STORAGE("EmulatorJS-bios", "bios"),
core: new window.EJS_STORAGE("EmulatorJS-core", "core")
// Remove rom and bios storage - rely on browser cache for files and checkCompression for decompression
}
this.storageCache = new window.EJS_Cache(true, new window.EJS_STORAGE("EmulatorJS-cache", "cache"), this.config.cacheMaxSizeMB || 4096, this.config.cacheMaxAgeMins || 7200);
// Run initial cleanup after cache initialization (non-blocking)
setTimeout(async () => {
try {
await this.storageCache.cleanup();
} catch (error) {
console.error('[EJS Cache] Error during startup cleanup:', error);
}
}, 5000); // 5 second delay to avoid blocking startup
}
// This is not cache. This is save data
this.storage.states = new window.EJS_STORAGE("EmulatorJS-states", "states");
@ -559,139 +566,127 @@ class EmulatorJS {
}
return text;
}
checkCompression(data, msg, fileCbFunc) {
if (!this.compression) this.compression = new window.EJS_COMPRESSION(this);
if (msg) this.textElem.innerText = msg;
// Wrap logic in a Promise so we can perform async hashing + IndexedDB operations
return new Promise((resolve, reject) => {
const input = (data instanceof Uint8Array) ? data : new Uint8Array(data);
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const hashData = async (bytes) => {
try {
if (crypto && crypto.subtle && crypto.subtle.digest) {
const digest = await crypto.subtle.digest('SHA-256', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
const arr = new Uint8Array(digest);
let hex = '';
for (let i = 0; i < arr.length; i++) hex += arr[i].toString(16).padStart(2, '0');
return hex;
}
} catch (e) {
if (this.debug) console.warn('Hash failed, skipping cache', e);
checkCompression(data, msg, fileCbFunc, type = 'decompressed', filename = null) {
return new Promise(async (resolve, reject) => {
const startTime = performance.now();
const dataSizeMB = (data.byteLength / (1024 * 1024)).toFixed(2);
try {
if (!this.compression) {
this.compression = new window.EJS_COMPRESSION(this);
}
return null; // fallback disables caching
};
const openDB = () => new Promise((res) => {
if (!('indexedDB' in window)) { res(null); return; }
const req = indexedDB.open('EJSDecompressCache', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('archives')) {
const store = db.createObjectStore('archives', { keyPath: 'hash' });
store.createIndex('lastUsed', 'lastUsed');
}
};
req.onsuccess = () => res(req.result);
req.onerror = () => res(null);
});
const getRecord = (db, hash) => new Promise((res) => {
if (!db || !hash) { res(null); return; }
const tx = db.transaction('archives', 'readwrite'); // readwrite so we can update lastUsed or delete expired immediately
const store = tx.objectStore('archives');
const getReq = store.get(hash);
getReq.onsuccess = () => {
const record = getReq.result;
if (!record) { res(null); return; }
const now = Date.now();
if ((now - record.lastUsed) > THIRTY_DAYS_MS) {
// expired remove
store.delete(hash);
res(null);
return;
}
// update lastUsed before returning
record.lastUsed = now;
store.put(record);
res(record);
};
getReq.onerror = () => res(null);
});
const putRecord = (db, hash, filesArray) => new Promise((res) => {
if (!db || !hash) { res(); return; }
try {
const now = Date.now();
const tx = db.transaction('archives', 'readwrite');
tx.objectStore('archives').put({ hash, created: now, lastUsed: now, files: filesArray });
tx.oncomplete = () => res();
tx.onerror = () => res();
} catch (_) { res(); }
});
const proceed = async () => {
let hash = null;
try {
hash = await hashData(input);
} catch (e) {
if (this.debug) console.warn('Hashing threw unexpectedly, disabling cache for this run', e);
hash = null; // disable caching this invocation
// Generate cache key based on data hash
const hashStartTime = performance.now();
const dataArray = new Uint8Array(data);
let hash = 0;
for (let i = 0; i < dataArray.length; i++) {
hash = ((hash << 5) - hash + dataArray[i]) & 0xffffffff;
}
let db = null;
if (hash) db = await openDB();
if (hash && db) {
const cached = await getRecord(db, hash);
if (cached) {
// Replay file callbacks if provided
if (typeof fileCbFunc === 'function') {
for (const f of cached.files) {
fileCbFunc(f.name, new Uint8Array(f.data));
}
}
if (msg) this.textElem.innerText = msg + ' (cached)';
// Build return object consistent with original behavior
let ret = {};
if (typeof fileCbFunc === 'function') {
for (const f of cached.files) ret[f.name] = true; // original decompress gives true when callback is used
const cacheKey = `compression_${hash}_${dataArray.length}`;
const hashTime = performance.now() - hashStartTime;
// Check if decompressed content is in cache
const cacheCheckStartTime = performance.now();
const cachedItem = await this.storageCache.get(cacheKey);
const cacheCheckTime = performance.now() - cacheCheckStartTime;
if (cachedItem && cachedItem.files && cachedItem.files.length > 0) {
const totalTime = performance.now() - startTime;
console.log(`[EJS Cache] Cache HIT for ${dataSizeMB}MB data - Total: ${totalTime.toFixed(2)}ms (hash: ${hashTime.toFixed(2)}ms, cache lookup: ${cacheCheckTime.toFixed(2)}ms)`);
if (msg) {
this.textElem.innerText = msg + " (cached)";
}
// Convert cached files back to expected format
const files = {};
for (let i = 0; i < cachedItem.files.length; i++) {
const file = cachedItem.files[i];
if (typeof fileCbFunc === "function") {
fileCbFunc(file.filename, file.bytes);
files[file.filename] = true;
} else {
for (const f of cached.files) ret[f.name] = new Uint8Array(f.data);
files[file.filename] = file.bytes;
}
resolve(ret);
return;
}
resolve(files);
return;
}
console.log(`[EJS Cache] Cache MISS for ${dataSizeMB}MB data - Starting decompression (hash: ${hashTime.toFixed(2)}ms, cache lookup: ${cacheCheckTime.toFixed(2)}ms)`);
// Not in cache, decompress and store result
if (msg) {
this.textElem.innerText = msg;
}
const decompressionStartTime = performance.now();
// If callback is provided, we need to collect files for caching while still calling the callback
const collectedFiles = {};
let callbackWrapper = null;
if (typeof fileCbFunc === "function") {
callbackWrapper = (filename, fileData) => {
// Call the original callback
fileCbFunc(filename, fileData);
// Also collect the data for caching
collectedFiles[filename] = fileData;
console.log(`[EJS Cache] Collected file for caching: ${filename} (${fileData ? fileData.byteLength || fileData.length || 'unknown size' : 'no data'} bytes)`);
};
}
const decompressedFiles = await this.compression.decompress(data, (m, appendMsg) => {
this.textElem.innerText = appendMsg ? (msg + m) : m;
}, callbackWrapper);
const decompressionTime = performance.now() - decompressionStartTime;
// Store decompressed content in cache
const cacheStoreStartTime = performance.now();
const fileItems = [];
// Use collected files if callback was used, otherwise use returned files
const filesToCache = callbackWrapper ? collectedFiles : decompressedFiles;
for (const [filename, fileData] of Object.entries(filesToCache)) {
if (fileData && fileData !== true) {
fileItems.push(new window.EJS_FileItem(filename, fileData));
console.log(`[EJS Cache] Adding file to cache: ${filename} (${fileData ? fileData.byteLength || fileData.length || 'unknown size' : 'no data'} bytes)`);
} else {
console.log(`[EJS Cache] Skipping file (invalid data): ${filename} (${typeof fileData})`);
}
}
// Cache miss or hashing disabled: decompress and capture
// Always decompress WITHOUT passing fileCbFunc so we get actual file bytes to cache.
let progressCb = (m, appendMsg) => { this.textElem.innerText = appendMsg ? (msg + m) : m; };
this.compression.decompress(input, progressCb, undefined).then(async (resultObj) => {
// Replay original callback if present
if (typeof fileCbFunc === 'function') {
Object.keys(resultObj).forEach(name => {
fileCbFunc(name, resultObj[name]);
});
if (fileItems.length > 0) {
const cacheItem = new window.EJS_CacheItem(cacheKey, fileItems, Date.now(), type, filename);
await this.storageCache.put(cacheItem);
console.log(`[EJS Cache] Stored ${fileItems.length} files in cache with key: ${cacheKey}, type: ${type}, filename: ${filename || 'N/A'}`);
} else {
console.log(`[EJS Cache] No files to cache (fileItems.length = 0)`);
}
const cacheStoreTime = performance.now() - cacheStoreStartTime;
const totalTime = performance.now() - startTime;
console.log(`[EJS Cache] Decompression complete for ${dataSizeMB}MB data - Total: ${totalTime.toFixed(2)}ms (decompression: ${decompressionTime.toFixed(2)}ms, cache store: ${cacheStoreTime.toFixed(2)}ms)`);
// Return appropriate structure based on whether callback was used
if (callbackWrapper) {
// For callback-based calls, return a structure indicating completion
const result = {};
for (const filename of Object.keys(collectedFiles)) {
result[filename] = true;
}
// Persist to cache
if (hash && db) {
try {
const filesArray = Object.keys(resultObj).map(name => ({ name, data: resultObj[name].buffer ? resultObj[name].buffer.slice(0) : resultObj[name] }));
await putRecord(db, hash, filesArray);
} catch (e) { if (this.debug) console.warn('Failed to store decompression cache', e); }
}
// Conform return shape to original behavior when fileCbFunc exists
if (typeof fileCbFunc === 'function') {
const transformed = {};
Object.keys(resultObj).forEach(k => { transformed[k] = true; });
resolve(transformed);
} else {
resolve(resultObj);
}
}).catch(err => reject(err));
};
proceed();
resolve(result);
} else {
// For promise-based calls, return the actual file data
resolve(decompressedFiles);
}
} catch (error) {
const totalTime = performance.now() - startTime;
console.error(`[EJS Cache] Error processing ${dataSizeMB}MB data after ${totalTime.toFixed(2)}ms:`, error);
reject(error);
}
});
}
checkCoreCompatibility(version) {
@ -728,21 +723,21 @@ class EmulatorJS {
console.warn("Threads is set to true, but the SharedArrayBuffer function is not exposed. Threads requires 2 headers to be set when sending you html page. See https://stackoverflow.com/a/68630724");
return;
}
const gotCore = (data) => {
const gotCore = (data, shouldCacheDecompressed = false, baseCoreId = null) => {
this.defaultCoreOpts = {};
this.checkCompression(new Uint8Array(data), this.localization("Decompress Game Core")).then((data) => {
this.checkCompression(new Uint8Array(data), this.localization("Decompress Game Core"), null, "core", this.getCore()).then(async (decompressedData) => {
let js, thread, wasm;
for (let k in data) {
for (let k in decompressedData) {
if (k.endsWith(".wasm")) {
wasm = data[k];
wasm = decompressedData[k];
} else if (k.endsWith(".worker.js")) {
thread = data[k];
thread = decompressedData[k];
} else if (k.endsWith(".js")) {
js = data[k];
js = decompressedData[k];
} else if (k === "build.json") {
this.checkCoreCompatibility(JSON.parse(new TextDecoder().decode(data[k])));
this.checkCoreCompatibility(JSON.parse(new TextDecoder().decode(decompressedData[k])));
} else if (k === "core.json") {
let core = JSON.parse(new TextDecoder().decode(data[k]));
let core = JSON.parse(new TextDecoder().decode(decompressedData[k]));
this.extensions = core.extensions;
this.coreName = core.name;
this.repository = core.repo;
@ -751,7 +746,7 @@ class EmulatorJS {
this.retroarchOpts = core.retroarchOpts;
this.saveFileExt = core.save;
} else if (k === "license.txt") {
this.license = new TextDecoder().decode(data[k]);
this.license = new TextDecoder().decode(decompressedData[k]);
}
}
@ -760,11 +755,41 @@ class EmulatorJS {
this.elements.bottomBar.loadSavFiles[0].style.display = "none";
}
// The core decompression is now handled by checkCompression which already caches the result
// No need for additional core-specific caching - this would create duplicates
console.log(`[EJS Core] Core decompression complete (cached by checkCompression)`);
this.initGameCore(js, wasm, thread);
});
}
// Helper function to generate cache key for core data
const generateCoreDataCacheKey = (data) => {
const dataArray = new Uint8Array(data);
let hash = 0;
for (let i = 0; i < dataArray.length; i++) {
hash = ((hash << 5) - hash + dataArray[i]) & 0xffffffff;
}
return `compression_${hash}_${dataArray.length}`;
};
// Helper function to check if cached core is expired
const isCoreExpired = (cachedItem) => {
if (!cachedItem) return true;
const now = Date.now();
const ageMins = (now - cachedItem.lastAccessed) / (1000 * 60);
// Use the same expiration logic as the cache (7200 minutes = 5 days)
const maxAgeMins = this.storageCache.minAgeMins || 7200;
return ageMins > maxAgeMins;
};
const report = "cores/reports/" + this.getCore() + ".json";
this.downloadFile(report, null, false, { responseType: "text", method: "GET" }).then(async rep => {
// Add cache-busting parameter periodically to ensure we get updated build versions
// This ensures that when cores are updated, we'll eventually get the new buildStart value
const cacheBustInterval = 1000 * 60 * 60; // 1 hour
const cacheBustParam = Math.floor(Date.now() / cacheBustInterval);
const reportUrl = `${report}?v=${cacheBustParam}`;
this.downloadFile(reportUrl, null, false, { responseType: "text", method: "GET" }).then(async rep => {
if (rep === -1 || typeof rep === "string" || typeof rep.data === "string") {
rep = {};
} else {
@ -792,13 +817,56 @@ class EmulatorJS {
let legacy = (this.supportsWebgl2 && this.webgl2Enabled ? "" : "-legacy");
let filename = this.getCore() + (threads ? "-thread" : "") + legacy + "-wasm.data";
if (!this.debug) {
const result = await this.storage.core.get(filename);
if (result && result.version === rep.buildStart) {
gotCore(result.data);
return;
// Check if we have the core cached in the compression cache to skip download entirely
// This leverages the existing checkCompression cache mechanism
try {
console.log(`[EJS Core] Checking for cached core...`);
// Try to download and check if it's in browser cache first
const corePath = "cores/" + filename;
const headResponse = await fetch(this.config.dataPath ? this.config.dataPath + corePath : corePath, {
method: 'HEAD',
cache: 'default'
}).catch(() => null);
if (headResponse && headResponse.status === 304) {
console.log("[EJS Core] Browser cache indicates file hasn't changed - proceeding to check decompression cache");
// File hasn't changed according to browser cache, so try a minimal download to check our cache
const quickDownload = await this.downloadFile(corePath, null, false, {
responseType: "arraybuffer",
method: "GET"
}).catch(() => null);
if (quickDownload && quickDownload.data) {
// Generate cache key the same way checkCompression does
const dataArray = new Uint8Array(quickDownload.data);
let hash = 0;
for (let i = 0; i < dataArray.length; i++) {
hash = ((hash << 5) - hash + dataArray[i]) & 0xffffffff;
}
const compressionCacheKey = `compression_${hash}_${dataArray.length}`;
const cachedDecompression = await this.storageCache.get(compressionCacheKey);
if (cachedDecompression && cachedDecompression.files && cachedDecompression.files.length > 0) {
console.log(`[EJS Core] Found cached decompression (${compressionCacheKey}) - using cached core`);
this.textElem.innerText = this.localization("Loading cached core...");
// Use the cached data directly without re-downloading
gotCore(quickDownload.data, false);
return;
}
}
}
console.log(`[EJS Core] No valid cache found or file has changed - proceeding with fresh download`);
} catch (error) {
console.warn("[EJS Core] Error checking cache, proceeding with download:", error);
}
// No valid decompressed cache found, download and rely on browser cache for the file
console.log("[EJS Core] Downloading core (browser cache will handle file-level caching)");
const corePath = "cores/" + filename;
let res = await this.downloadFile(corePath, (progress) => {
this.textElem.innerText = this.localization("Download Game Core") + progress;
@ -820,11 +888,10 @@ class EmulatorJS {
}
console.warn("File was not found locally, but was found on the emulatorjs cdn.\nIt is recommended to download the stable release from here: https://cdn.emulatorjs.org/releases/");
}
// No need for extra core-specific caching - checkCompression handles it
gotCore(res.data);
this.storage.core.put(filename, {
version: rep.buildStart,
data: res.data
});
// Note: We no longer store the compressed core in IndexedDB - relying on browser cache instead
});
}
initGameCore(js, wasm, thread) {
@ -903,7 +970,8 @@ class EmulatorJS {
this.gameManager.FS.writeFile(coreFilePath + assetUrl.split("/").pop(), new Uint8Array(input));
return resolve(assetUrl);
}
const data = await this.checkCompression(new Uint8Array(input), decompressProgressMessage);
const assetFilename = assetUrl.split("/").pop().split("#")[0].split("?")[0];
const data = await this.checkCompression(new Uint8Array(input), decompressProgressMessage, null, "BIOS", assetFilename);
for (const k in data) {
if (k === "!!notCompressedData") {
this.gameManager.FS.writeFile(coreFilePath + assetUrl.split("/").pop().split("#")[0].split("?")[0], data[k]);
@ -914,15 +982,10 @@ class EmulatorJS {
}
}
console.log(`[EJS ${type.toUpperCase()}] Downloading ${assetUrl} (browser cache will handle file-level caching)`);
this.textElem.innerText = progressMessage;
if (!this.debug) {
const res = await this.downloadFile(assetUrl, null, true, { method: "HEAD" });
const result = await this.storage.rom.get(assetUrl.split("/").pop());
if (result && result["content-length"] === res.headers["content-length"] && result.type === type) {
await gotData(result.data);
return resolve(assetUrl);
}
}
// No longer check our own storage - rely on browser cache and checkCompression cache
const res = await this.downloadFile(assetUrl, (progress) => {
this.textElem.innerText = progressMessage + progress;
}, true, { responseType: "arraybuffer", method: "GET" });
@ -938,14 +1001,8 @@ class EmulatorJS {
}
await gotData(res.data);
resolve(assetUrl);
const limit = (typeof this.config.cacheLimit === "number") ? this.config.cacheLimit : 1073741824;
if (parseFloat(res.headers["content-length"]) < limit && this.saveInBrowserSupported() && assetUrl !== "game") {
this.storage.rom.put(assetUrl.split("/").pop(), {
"content-length": res.headers["content-length"],
data: res.data,
type: type
})
}
// No longer store in ROM storage - browser cache handles file caching, checkCompression handles decompression caching
console.log(`[EJS ${type.toUpperCase()}] Download and decompression complete (cached by checkCompression)`);
});
}
downloadGamePatch() {
@ -995,6 +1052,7 @@ class EmulatorJS {
}
let fileNames = [];
const romFilename = this.getBaseFileName(true);
this.checkCompression(new Uint8Array(data), this.localization("Decompress Game Data"), (fileName, fileData) => {
if (fileName.includes("/")) {
const paths = fileName.split("/");
@ -1018,7 +1076,7 @@ class EmulatorJS {
this.gameManager.FS.writeFile(`/${fileName}`, fileData);
fileNames.push(fileName);
}
}).then(() => {
}, "ROM", romFilename).then(() => {
let isoFile = null;
let supportedFile = null;
let cueFile = null;
@ -1069,6 +1127,7 @@ class EmulatorJS {
});
}
const downloadFile = async () => {
console.log("[EJS ROM] Downloading ROM (browser cache will handle file-level caching)");
const res = await this.downloadFile(this.config.gameUrl, (progress) => {
this.textElem.innerText = this.localization("Download Game Data") + progress;
}, true, { responseType: "arraybuffer", method: "GET" });
@ -1082,28 +1141,12 @@ class EmulatorJS {
this.config.gameUrl = "game";
}
gotGameData(res.data);
const limit = (typeof this.config.cacheLimit === "number") ? this.config.cacheLimit : 1073741824;
if (parseFloat(res.headers["content-length"]) < limit && this.saveInBrowserSupported() && this.config.gameUrl !== "game") {
this.storage.rom.put(this.config.gameUrl.split("/").pop(), {
"content-length": res.headers["content-length"],
data: res.data
})
}
// No longer store in ROM storage - browser cache handles file caching, checkCompression handles decompression caching
console.log("[EJS ROM] Download and decompression complete (cached by checkCompression)");
}
if (!this.debug) {
this.downloadFile(this.config.gameUrl, null, true, { method: "HEAD" }).then(async (res) => {
const name = (typeof this.config.gameUrl === "string") ? this.config.gameUrl.split("/").pop() : "game";
const result = await this.storage.rom.get(name);
if (result && result["content-length"] === res.headers["content-length"] && name !== "game") {
gotGameData(result.data);
return;
}
downloadFile();
})
} else {
downloadFile();
}
// No longer check ROM storage - rely on browser cache and checkCompression cache
downloadFile();
})
}
downloadFiles() {
@ -2540,26 +2583,66 @@ class EmulatorJS {
}
openCacheMenu() {
(async () => {
// Run cleanup before showing cache contents
await this.storageCache.cleanup();
const list = this.createElement("table");
const thead = this.createElement("thead");
const tbody = this.createElement("tbody");
// Create header row
const headerRow = this.createElement("tr");
const nameHeader = this.createElement("th");
const typeHeader = this.createElement("th");
const sizeHeader = this.createElement("th");
const lastUsedHeader = this.createElement("th");
const actionHeader = this.createElement("th");
nameHeader.innerText = "Filename";
typeHeader.innerText = "Type";
sizeHeader.innerText = "Size";
lastUsedHeader.innerText = "Last Used";
actionHeader.innerText = "Action";
nameHeader.style.textAlign = "left";
typeHeader.style.textAlign = "left";
sizeHeader.style.textAlign = "left";
lastUsedHeader.style.textAlign = "left";
actionHeader.style.textAlign = "left";
headerRow.appendChild(nameHeader);
headerRow.appendChild(typeHeader);
headerRow.appendChild(sizeHeader);
headerRow.appendChild(lastUsedHeader);
headerRow.appendChild(actionHeader);
thead.appendChild(headerRow);
const body = this.createPopup("Cache Manager", {
"Cleanup Now": async () => {
const cleanupBtn = document.querySelector('.ejs_popup_button');
if (cleanupBtn) cleanupBtn.textContent = 'Cleaning...';
await this.storageCache.cleanup();
tbody.innerHTML = "";
// Refresh the cache list
await this.populateCacheList(tbody, getSize, getTypeName);
if (cleanupBtn) cleanupBtn.textContent = 'Cleanup Now';
},
"Clear All": async () => {
const roms = await this.storage.rom.getSizes();
for (const k in roms) {
await this.storage.rom.remove(k);
}
await this.storageCache.clear();
tbody.innerHTML = "";
},
"Close": () => {
this.closePopup();
}
});
const roms = await this.storage.rom.getSizes();
list.style.width = "100%";
list.style["padding-left"] = "10px";
list.style["text-align"] = "left";
body.appendChild(list);
list.appendChild(thead);
list.appendChild(tbody);
const getSize = function (size) {
let i = -1;
do {
@ -2567,30 +2650,91 @@ class EmulatorJS {
} while (size > 1024);
return Math.max(size, 0.1).toFixed(1) + [" kB", " MB", " GB", " TB", "PB", "EB", "ZB", "YB"][i];
}
for (const k in roms) {
const line = this.createElement("tr");
const name = this.createElement("td");
const size = this.createElement("td");
const remove = this.createElement("td");
remove.style.cursor = "pointer";
name.innerText = k;
size.innerText = getSize(roms[k]);
const a = this.createElement("a");
a.innerText = this.localization("Remove");
this.addEventListener(remove, "click", () => {
this.storage.rom.remove(k);
line.remove();
})
remove.appendChild(a);
line.appendChild(name);
line.appendChild(size);
line.appendChild(remove);
tbody.appendChild(line);
const getTypeName = function(key) {
if (key.startsWith('compression_')) return 'Decompressed Content';
if (key.startsWith('core_decompressed_')) return 'Core';
// Additional fallback logic for other types
if (key.includes('core')) return 'Core';
if (key.includes('bios')) return 'BIOS';
if (key.includes('rom')) return 'ROM';
if (key.includes('asset')) return 'Asset';
return 'Unknown';
}
await this.populateCacheList(tbody, getSize, getTypeName);
})();
}
async populateCacheList(tbody, getSize, getTypeName) {
// Get all cache items from the compression cache
const allCacheItems = await this.storageCache.storage.getAll();
for (const item of allCacheItems) {
if (!item.key || !item.files) continue;
const line = this.createElement("tr");
const name = this.createElement("td");
const type = this.createElement("td");
const size = this.createElement("td");
const lastUsed = this.createElement("td");
const remove = this.createElement("td");
remove.style.cursor = "pointer";
// Calculate total size of all files in this cache item
let totalSize = 0;
for (const file of item.files) {
if (file.bytes && file.bytes.byteLength) {
totalSize += file.bytes.byteLength;
}
}
// Use filename if available, otherwise fall back to key
const displayName = item.filename || item.key;
name.innerText = displayName.substring(0, 50) + (displayName.length > 50 ? '...' : '');
// Use the stored type if available, otherwise fall back to getTypeName
const itemType = item.type || getTypeName(item.key);
type.innerText = itemType;
size.innerText = getSize(totalSize);
// Format last accessed time
const lastAccessedTime = item.lastAccessed || item.added || Date.now();
const formatDate = (timestamp) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
// For older items, show the actual date
return date.toLocaleDateString();
};
lastUsed.innerText = formatDate(lastAccessedTime);
const a = this.createElement("a");
a.innerText = this.localization("Remove");
this.addEventListener(remove, "click", async () => {
await this.storageCache.delete(item.key);
line.remove();
})
remove.appendChild(a);
line.appendChild(name);
line.appendChild(type);
line.appendChild(size);
line.appendChild(lastUsed);
line.appendChild(remove);
tbody.appendChild(line);
}
}
getControlScheme() {
if (this.config.controlScheme && typeof this.config.controlScheme === "string") {
return this.config.controlScheme;

View File

@ -100,6 +100,20 @@ class EJS_STORAGE {
resolve(rv);
})
}
getAll() {
return new Promise(async (resolve, reject) => {
if (!window.indexedDB) return resolve([]);
const keys = await this.get("?EJS_KEYS!");
if (!keys) return resolve([]);
let rv = [];
for (let i = 0; i < keys.length; i++) {
const result = await this.get(keys[i]);
if (!result) continue;
rv.push(result);
}
resolve(rv);
});
}
}
class EJS_DUMMYSTORAGE {

View File

@ -257,7 +257,7 @@
window.EJS_pathtodata = "data/";
window.EJS_startOnLoaded = true;
window.EJS_DEBUG_XX = enableDebug;
window.EJS_disableDatabases = true;
window.EJS_disableDatabases = false;
window.EJS_threads = enableThreads;
if (browserMode) {
window.EJS_browserMode = browserMode;

37
test_rom_cache.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Test ROM Cache</title>
<script src="data/loader.js"></script>
</head>
<body>
<div style="width: 640px; height: 480px; max-width: 100%">
<div id="game"></div>
</div>
<script>
// Test with a simple ROM to see if it gets cached
EJS_player = '#game';
EJS_core = 'nes'; // Simple NES core
EJS_gameUrl = 'https://www.emulatorjs.org/roms/nes/Homebrew/PinBot_Deluxe_Demo.nes';
EJS_pathtodata = 'data/';
// Monitor console logs to see caching behavior
console.log('Starting EmulatorJS test for ROM caching...');
// Add event listener to check cache after ROM is loaded
window.addEventListener('EJS_onGameStart', () => {
console.log('Game started, checking cache...');
// Open developer tools and check the decompression cache
setTimeout(() => {
if (window.EJS_player_instance && window.EJS_player_instance.storageCache) {
console.log('Cache instance available, checking entries...');
} else {
console.log('No cache instance found');
}
}, 2000);
});
</script>
</body>
</html>