[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