[Scummvm-git-logs] scummvm-web master -> 0328f6825b0c6006311e8008bfe41eedc64ed69a
neuromancer
noreply at scummvm.org
Mon Oct 12 21:23:21 UTC 2026
This automated email contains information about 1 new commit which have been
pushed to the 'scummvm-web' repo located at https://api.github.com/repos/scummvm/scummvm-web .
Summary:
0328f6825b DUMPER-COMPANION: added 3do support
Commit: 0328f6825b0c6006311e8008bfe41eedc64ed69a
https://github.com/scummvm/scummvm-web/commit/0328f6825b0c6006311e8008bfe41eedc64ed69a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-12T23:17:39+02:00
Commit Message:
DUMPER-COMPANION: added 3do support
Changed paths:
A dumper-companion/src/3do/main.ts
dumper-companion/src/Dumper.tsx
public_html/html/dumper-companion.html
diff --git a/dumper-companion/src/3do/main.ts b/dumper-companion/src/3do/main.ts
new file mode 100644
index 00000000..00938bad
--- /dev/null
+++ b/dumper-companion/src/3do/main.ts
@@ -0,0 +1,217 @@
+import type { ZipDirectoryEntry } from '@zip.js/zip.js';
+import { bytesToString } from '../util';
+
+// 3DO filesystem layout, also used by ScummVM Tools:
+// https://github.com/scummvm/scummvm-tools/blob/master/engines/director/unCD-ROM.cpp
+const SECTOR_SIZE = 2048;
+const RAW_SECTOR_SIZE = 2352;
+const LABEL_SIGNATURE = [1, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 1];
+const DIRECTORY_HEADER_SIZE = 20;
+const DIRECTORY_RECORD_SIZE = 72;
+const FILE_IS_DIRECTORY = 1;
+const LAST_IN_DIRECTORY = 0x80000000;
+const LAST_IN_BLOCK = 0x40000000;
+
+type ImageLayout = {
+ offset: number;
+ sectorSize: number;
+ dataOffset: number;
+};
+
+type FileEntry = {
+ startBlock: number;
+ blockSize: number;
+ blockCount: number;
+ byteCount: number;
+ burst: number;
+ gap: number;
+};
+
+function probe(image: Uint8Array): ImageLayout | null {
+ // BIN files may include the two-second pregap before the data track.
+ for (const pregap of [0, 150]) {
+ for (const sectorSize of [SECTOR_SIZE, RAW_SECTOR_SIZE]) {
+ const offset = pregap * sectorSize;
+ const dataOffset = sectorSize === RAW_SECTOR_SIZE ? 16 : 0;
+ if (dataOffset && image[offset + 15] !== 1) {
+ continue;
+ }
+ if (LABEL_SIGNATURE.every((byte, i) => image[offset + dataOffset + i] === byte)) {
+ return {offset, sectorSize, dataOffset};
+ }
+ }
+ }
+ return null;
+}
+
+// Return false for other formats so the caller can try the Macintosh reader.
+export function dump3DO(image: Uint8Array, zipDir: ZipDirectoryEntry, stripPrefixes: boolean, log: (msg: string) => void): boolean {
+ const layout = probe(image);
+ if (!layout) {
+ return false;
+ }
+ log('Detected a 3DO disc image. Extracting files...');
+
+ const {offset: imageOffset, sectorSize, dataOffset} = layout;
+ const imageSize = Math.floor((image.length - imageOffset) / sectorSize) * SECTOR_SIZE;
+ function checkRange(offset: number, length: number): void {
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) ||
+ offset < 0 || length < 0 || offset + length > imageSize) {
+ throw new Error('The 3DO image is truncated or contains an invalid file location.');
+ }
+ }
+
+ function read(offset: number, length: number): Uint8Array {
+ checkRange(offset, length);
+ if (sectorSize === SECTOR_SIZE) {
+ return image.subarray(imageOffset + offset, imageOffset + offset + length);
+ }
+
+ // Strip raw-sector headers and error-correction data only for the
+ // requested range, without allocating a second copy of the whole disc.
+ const data = new Uint8Array(length);
+ let copied = 0;
+ while (copied < length) {
+ const sector = Math.floor(offset / SECTOR_SIZE);
+ const withinSector = offset % SECTOR_SIZE;
+ const count = Math.min(length - copied, SECTOR_SIZE - withinSector);
+ const sectorOffset = imageOffset + sector * sectorSize;
+ if (image[sectorOffset + 15] !== 1) {
+ throw new Error('The 3DO BIN contains an unsupported sector. Select a MODE1 data track.');
+ }
+ const start = sectorOffset + dataOffset + withinSector;
+ data.set(image.subarray(start, start + count), copied);
+ offset += count;
+ copied += count;
+ }
+ return data;
+ }
+
+ const labelBytes = read(0, 132);
+ const label = new DataView(labelBytes.buffer, labelBytes.byteOffset, labelBytes.byteLength);
+ const volumeBlockSize = label.getUint32(76);
+ if (!volumeBlockSize || volumeBlockSize % SECTOR_SIZE !== 0 ||
+ volumeBlockSize > imageSize || label.getUint32(96) > 7) {
+ throw new Error('Invalid 3DO volume header.');
+ }
+
+ function readFile(entry: FileEntry): Uint8Array {
+ if (entry.byteCount === 0) {
+ return new Uint8Array();
+ }
+ if (!entry.blockSize || entry.blockSize % volumeBlockSize !== 0 ||
+ entry.byteCount > entry.blockCount * entry.blockSize ||
+ (entry.gap > 0 && entry.burst === 0)) {
+ throw new Error('Invalid 3DO file block layout.');
+ }
+ const start = entry.startBlock * volumeBlockSize;
+ if (!entry.gap) {
+ return read(start, entry.byteCount);
+ }
+
+ // Interleaved files contain bursts of data separated by gap blocks.
+ const burstSize = entry.burst * entry.blockSize;
+ const gapSize = entry.gap * entry.blockSize;
+ const gaps = Math.floor((entry.byteCount - 1) / burstSize);
+ checkRange(start, entry.byteCount + gaps * gapSize);
+ const data = new Uint8Array(entry.byteCount);
+ for (let copied = 0, offset = start; copied < data.length; offset += burstSize + gapSize) {
+ const count = Math.min(burstSize, data.length - copied);
+ data.set(read(offset, count), copied);
+ copied += count;
+ }
+ return data;
+ }
+
+ const root: FileEntry = {
+ startBlock: label.getUint32(100),
+ blockSize: label.getUint32(92),
+ blockCount: label.getUint32(88),
+ byteCount: label.getUint32(92) * label.getUint32(88),
+ burst: 1,
+ gap: 0,
+ };
+ const pending = [{entry: root, target: zipDir}];
+ const visitedDirectories = new Set<number>();
+ let fileCount = 0;
+ while (pending.length) {
+ const {entry, target} = pending.pop()!;
+ if (entry.blockCount === 0) {
+ continue;
+ }
+ if (visitedDirectories.has(entry.startBlock)) {
+ throw new Error('The 3DO image contains a directory cycle.');
+ }
+ visitedDirectories.add(entry.startBlock);
+ // Directory links are block numbers within this directory file,
+ // not absolute locations on the disc.
+ const data = readFile({...entry, byteCount: entry.blockCount * entry.blockSize});
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ const visitedBlocks = new Set<number>();
+ let block = 0;
+ while (block !== -1) {
+ if (block < 0 || block >= entry.blockCount || visitedBlocks.has(block)) {
+ throw new Error('Invalid 3DO directory block chain.');
+ }
+ visitedBlocks.add(block);
+ const page = block * entry.blockSize;
+ if (entry.blockSize < DIRECTORY_HEADER_SIZE) {
+ throw new Error('Invalid 3DO directory block size.');
+ }
+ const nextBlock = view.getInt32(page);
+ const end = view.getUint32(page + 12);
+ let offset = view.getUint32(page + 16);
+ if (offset < DIRECTORY_HEADER_SIZE || offset > end || end > entry.blockSize) {
+ throw new Error('Invalid 3DO directory header.');
+ }
+ block = nextBlock;
+ while (offset < end) {
+ if (offset + DIRECTORY_RECORD_SIZE > end) {
+ throw new Error('Truncated 3DO directory entry.');
+ }
+ const record = page + offset;
+ const flags = view.getUint32(record);
+ const lastAvatar = view.getUint32(record + 64);
+ const recordSize = DIRECTORY_RECORD_SIZE + lastAvatar * 4;
+ if (offset + recordSize > end) {
+ throw new Error('Invalid 3DO file copy list.');
+ }
+ const rawName = data.subarray(record + 32, record + 64);
+ const terminator = rawName.indexOf(0);
+ const originalName = bytesToString(terminator < 0 ? rawName : rawName.subarray(0, terminator));
+ const name = stripPrefixes ? originalName.replace(/^[A-Za-z][0-9]*_/, '') : originalName;
+ if (!name || name === '.' || name === '..' || /[/\\]/.test(name)) {
+ throw new Error('Invalid filename in the 3DO image.');
+ }
+ if (stripPrefixes && target.children.some(child => child.name.toLowerCase() === name.toLowerCase())) {
+ throw new Error(`Removing 3DO prefixes produces a duplicate filename: ${name}.`);
+ }
+ const child: FileEntry = {
+ // Avatars are duplicate copies; extract the first one.
+ startBlock: view.getUint32(record + 68),
+ blockSize: view.getUint32(record + 12),
+ blockCount: view.getUint32(record + 20),
+ byteCount: view.getUint32(record + 16),
+ burst: view.getUint32(record + 24),
+ gap: view.getUint32(record + 28),
+ };
+ if (flags & FILE_IS_DIRECTORY) {
+ pending.push({entry: child, target: target.addDirectory(name)});
+ } else {
+ target.addUint8Array(name, readFile(child));
+ fileCount++;
+ }
+ offset += recordSize;
+ if (flags & LAST_IN_DIRECTORY) {
+ block = -1;
+ break;
+ }
+ if (flags & LAST_IN_BLOCK) {
+ break;
+ }
+ }
+ }
+ }
+ log(`Extracted ${fileCount} files from the 3DO image.`);
+ return true;
+}
diff --git a/dumper-companion/src/Dumper.tsx b/dumper-companion/src/Dumper.tsx
index 976a9bd8..bee1415a 100644
--- a/dumper-companion/src/Dumper.tsx
+++ b/dumper-companion/src/Dumper.tsx
@@ -1,6 +1,7 @@
import { ComponentChild } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { Volume } from './hfs/main';
+import { dump3DO } from './3do/main';
import { Language, getLanguages } from './encoding';
type DumpSettings = {
@@ -8,6 +9,7 @@ type DumpSettings = {
lang: Language;
unicode: boolean;
forceMacBinary: boolean;
+ strip3DOPrefixes: boolean;
log: (msg: ComponentChild | Error) => void;
updateDumpPercent: (dumpPercent: number) => void;
finished: () => void;
@@ -16,9 +18,6 @@ type DumpSettings = {
async function dumpVolume(file: ArrayBuffer, s: DumpSettings): Promise<void> {
s.log('Parsing volume...');
try {
- const volume = new Volume();
- volume.read(new Uint8Array(file));
-
s.updateDumpPercent(0);
const { fs } = await import(
@@ -28,8 +27,13 @@ async function dumpVolume(file: ArrayBuffer, s: DumpSettings): Promise<void> {
/* webpackPrefetch: true */
'@zip.js/zip.js');
+ const image = new Uint8Array(file);
const zipFS = new fs.FS();
- volume.dumpToZip(zipFS.root, s.lang, !s.unicode, s.forceMacBinary, s.log);
+ if (!dump3DO(image, zipFS.root, s.strip3DOPrefixes, s.log)) {
+ const volume = new Volume();
+ volume.read(image);
+ volume.dumpToZip(zipFS.root, s.lang, !s.unicode, s.forceMacBinary, s.log);
+ }
const blob = await zipFS.exportBlob({
level: 0,
onprogress: (index: number, max: number): undefined => {
@@ -65,6 +69,7 @@ export default function Dumper() {
const [lang, setLang] = useState(Language.EN);
const [unicode, setUnicode] = useState(true);
const [forceMacBinary, setForceMacBinary] = useState(false);
+ const [strip3DOPrefixes, setStrip3DOPrefixes] = useState(false);
const [progress, setProgress] = useState<Progress>({busy: false, logs: [], lastLogWasDumpPercent: false, dumpPercent: -1});
function log(msg: ComponentChild | Error): void {
@@ -133,6 +138,10 @@ export default function Dumper() {
setForceMacBinary(forceMacBinary);
}}
+ function handleStrip3DOPrefixes(e: Event): void {
+ setStrip3DOPrefixes((e.target as HTMLInputElement).checked);
+ }
+
function handleDump(): void {
if (!image.file) {
return;
@@ -146,6 +155,7 @@ export default function Dumper() {
lang,
unicode,
forceMacBinary,
+ strip3DOPrefixes,
log,
updateDumpPercent,
finished
@@ -170,7 +180,14 @@ export default function Dumper() {
<div>
<p>Select the game's disk image:</p>
- <input disabled={busy} class="file" type="file" accept=".dsk,.image,.img,.iso,.toast,.cdr,.diskcopy,.dcpy,.dc42" onInput={handleImage}/>
+ <input disabled={busy} class="file" type="file" accept=".dsk,.image,.img,.iso,.bin,.3do,.toast,.cdr,.diskcopy,.dcpy,.dc42" onInput={handleImage}/>
+ <p>For 3DO, select the data track's ISO or BIN file. Language and encoding options apply only to Macintosh images.</p>
+ </div>
+
+ <div>
+ <input disabled={busy} type="checkbox" id="strip3DOPrefixes" checked={strip3DOPrefixes} onInput={handleStrip3DOPrefixes}/>
+ <label for="strip3DOPrefixes">Remove 3DO file and folder prefixes</label>
+ <p>Removes prefixes such as a_ and a2_. Enable this for Rebel Assault before dumping.</p>
</div>
<div>
diff --git a/public_html/html/dumper-companion.html b/public_html/html/dumper-companion.html
index 71420b8d..e31dd19d 100644
--- a/public_html/html/dumper-companion.html
+++ b/public_html/html/dumper-companion.html
@@ -16,7 +16,7 @@
a format that modern operating systems can't natively handle.
</p>
<p>
- This companion program extracts game data from an HFS disk image and formats it for use in ScummVM.
+ This companion program extracts game data from HFS and 3DO disk images and formats it for use in ScummVM.
It produces a ZIP file, so it is best used on a desktop computer.
</p>
<p>THIS WORKS ENTIRELY IN YOUR WEB BROWSER. NO DATA IS TRANSFERRED TO ANY SERVER.</p>
More information about the Scummvm-git-logs
mailing list