Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement format conversion utility #46

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
include ../Makefile.inc

all: rl.cas
all: rl.bin rl.cas

# https://atari800.github.io/
run: rl.cas
Expand All @@ -10,11 +10,11 @@ run: rl.cas
retroload $< -o $@

# https://a8cas.sourceforge.net/
%.cas: %.img
%.cas: %.bin
a8cas-convert -f c -r $< $@

%.img: %.asm
%.bin: %.asm
$(65XX_ASM) $< -o $@

clean:
rm -f *.wav *.cas *.img
rm -f *.wav *.cas *.bin
Binary file added retroload-lib/examples/formats/atari_bin/rl.bin
Binary file not shown.
6 changes: 5 additions & 1 deletion retroload-lib/src/Examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const examples: ExampleDefinition[] = [
},
// Atari 800 XL
{
dir: 'atari_cas',
dir: 'atari_bin',
file: 'rl.cas',
options: {},
hash: '8d36a2a696c7e27807c4d1f058fdec34',
Expand Down Expand Up @@ -457,6 +457,10 @@ export function getLocalPath(example: ExampleDefinition): string {
return `${__dirname}/../examples/formats/${example.dir}/${example.file}`;
}

export function getLocalPathByDirAndFile(dir: string, file: string): string {
return `${__dirname}/../examples/formats/${dir}/${file}`;
}

export function getUrl(example: ExampleDefinition): string {
return `https://github.com/stefanschramm/retroload/tree/main/retroload-lib/examples/formats/${example.dir}/${example.file}`;
}
13 changes: 13 additions & 0 deletions retroload-lib/src/common/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export function calculateChecksum8Xor(ba: BufferAccess, initial = 0x00) {
return sum;
}

export function calculateChecksum8WithCarry(ba: BufferAccess) {
// 8 bit checksum with carry being added
let sum = 0;
for (let i = 0; i < ba.length(); i++) {
sum += ba.getUint8(i);
if (sum > 255) {
sum = (sum & 0xff) + 1;
}
}

return sum;
}

/**
* https://gist.github.com/chitchcock/5112270?permalink_comment_id=3834064#gistcomment-3834064
*
Expand Down
50 changes: 50 additions & 0 deletions retroload-lib/src/conversion/ConverterManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {getLocalPathByDirAndFile} from '../Examples.js';
import {BufferAccess} from '../common/BufferAccess.js';
import {type OptionValues} from '../encoding/Options.js';
import {convert, getConverters} from './ConverterManager.js';
import * as fs from 'fs';

type TestDefinition = {
name: string;
dir: string;
input: string;
expected: string;
options: OptionValues;
};

const formatTests: TestDefinition[] = [
{
name: 'ataricas',
dir: 'atari_bin',
input: 'rl.bin',
expected: 'rl.cas',
options: {},
},
{
name: 'kctap',
dir: 'kc851_tap',
input: 'rl.com',
expected: 'rl.tap',
options: {load: '0300', entry: '0300', name: 'RL', kctype: 'COM'},
},
];

test('getConverters() returns non empty list', () => {
expect(getConverters().length).toBeGreaterThan(0);
});

describe('Formats are converted correctly', () => {
it.each(formatTests.map((t: TestDefinition) => ({label: getTestLabel(t), definition: t})))(
'$label',
(test) => {
const data = BufferAccess.createFromNodeBuffer(fs.readFileSync(getLocalPathByDirAndFile(test.definition.dir, test.definition.input)));
const result = convert(data, test.definition.name, test.definition.options);
const expectedData = BufferAccess.createFromNodeBuffer(fs.readFileSync(getLocalPathByDirAndFile(test.definition.dir, test.definition.expected)));
expect(result.asHexDump()).toBe(expectedData.asHexDump());
},
);
});

function getTestLabel(test: TestDefinition): string {
return `${test.name}: ${test.dir}/${test.input} --> ${test.dir}/${test.expected}, options: ${JSON.stringify(test.options)}`;
}
21 changes: 21 additions & 0 deletions retroload-lib/src/conversion/ConverterManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {type BufferAccess} from '../common/BufferAccess.js';
import {FormatNotFoundError} from '../common/Exceptions.js';
import {OptionContainer, type OptionValues} from '../encoding/Options.js';
import {converters} from './ConverterProvider.js';
import {type PublicConverterDefinition, type ConverterDefinition} from './converter/ConverterDefinition.js';

export function getConverters(): PublicConverterDefinition[] {
return converters;
}

export function convert(data: BufferAccess, identifier: string, options: OptionValues): BufferAccess {
const chosenConverters = converters.filter((c: ConverterDefinition) => c.identifier === identifier);

if (chosenConverters.length === 0) {
throw new FormatNotFoundError(identifier);
}

const converter = chosenConverters[0];

return converter.convert(data, new OptionContainer(options));
}
8 changes: 8 additions & 0 deletions retroload-lib/src/conversion/ConverterProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {type ConverterDefinition} from './converter/ConverterDefinition.js';
import AtariCasConverter from './converter/AtariCasConverter.js';
import KcTapConverter from './converter/KcTapConverter.js';

export const converters: ConverterDefinition[] = [
AtariCasConverter,
KcTapConverter,
];
101 changes: 101 additions & 0 deletions retroload-lib/src/conversion/converter/AtariCasConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {BufferAccess} from '../../common/BufferAccess.js';
import {InvalidArgumentError} from '../../common/Exceptions.js';
import {calculateChecksum8WithCarry} from '../../common/Utils.js';
import {type OptionContainer, type ArgumentOptionDefinition} from '../../encoding/Options.js';
import {type ConverterDefinition} from './ConverterDefinition.js';

const irgLengthOption: ArgumentOptionDefinition<number | undefined> = {
name: 'irglength',
label: 'Intra record gap length',
description: 'Gap between blocks in ms (default: 250)',
common: false,
required: false,
type: 'text',
parse(value: string) {
if (value === '') {
return undefined;
}
const casted = Number(value);
if (isNaN(casted)) {
throw new InvalidArgumentError(this.name, `Option ${this.name} is expected to be a number in decimal or hexadecimal notation.`);
}

return casted;
},
};

const definition: ConverterDefinition = {
name: 'Atari .CAS-File',
identifier: 'ataricas',
options: [],
convert,
};
export default definition;

const markerByte = 0x55;
const blockTypeFull = 0xfc;
const blockTypePartial = 0xfa;
const blockTypeEndOfFile = 0xfe;
const dataBytesPerBlock = 128;

const pilotIrgLength = 20000;
const defaultIrgLength = 250; // TODO: longer for 'ENTER'-loading

function convert(data: BufferAccess, options: OptionContainer): BufferAccess {
const irgLength = options.getArgument(irgLengthOption) ?? defaultIrgLength;

const chunks = data.chunks(dataBytesPerBlock);

// FUJI-Header, baud-block, data blocks, end of file block
const outBa = BufferAccess.create(8 + 8 + (chunks.length + 1) * (8 + 132));

outBa.writeAsciiString('FUJI');
outBa.writeUint16Le(0x0000);
outBa.writeUint16Le(0x0000);

outBa.writeAsciiString('baud');
outBa.writeUint16Le(0x0000);
outBa.writeUint16Le(600); // default baud rate

for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
outBa.writeAsciiString('data');
outBa.writeUint16Le(132);
outBa.writeUint16Le(i === 0 ? pilotIrgLength : irgLength);
outBa.writeBa(createDataBlock(chunk));
}

// end of file block
outBa.writeAsciiString('data');
outBa.writeUint16Le(132);
outBa.writeUint16Le(irgLength);
outBa.writeBa(createEndBlock());

return outBa;
}

function createDataBlock(data: BufferAccess): BufferAccess {
const dataBlock = BufferAccess.create(132);
dataBlock.writeUint8(markerByte);
dataBlock.writeUint8(markerByte);
const partialBlock = data.length() !== dataBytesPerBlock;
const blockType = partialBlock ? blockTypePartial : blockTypeFull;
dataBlock.writeUint8(blockType);
dataBlock.writeBa(partialBlock ? data.chunksPadded(dataBytesPerBlock, 0x00)[0] : data);
if (partialBlock) {
dataBlock.setUint8(130, data.length());
}
dataBlock.setUint8(131, calculateChecksum8WithCarry(dataBlock));

return dataBlock;
}

function createEndBlock(): BufferAccess {
const endBlock = BufferAccess.create(132);
endBlock.writeUint8(markerByte);
endBlock.writeUint8(markerByte);
endBlock.writeUint8(blockTypeEndOfFile);
endBlock.setUint8(131, calculateChecksum8WithCarry(endBlock));

return endBlock;
}
12 changes: 12 additions & 0 deletions retroload-lib/src/conversion/converter/ConverterDefinition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {type BufferAccess} from '../../common/BufferAccess.js';
import {type OptionContainer, type PublicOptionDefinition} from '../../encoding/Options.js';

export type PublicConverterDefinition = {
readonly name: string;
readonly identifier: string;
readonly options: PublicOptionDefinition[];
};

export type ConverterDefinition = PublicConverterDefinition & {
readonly convert: (ba: BufferAccess, options: OptionContainer) => BufferAccess;
};
92 changes: 92 additions & 0 deletions retroload-lib/src/conversion/converter/KcTapConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {BufferAccess} from '../../common/BufferAccess.js';
import {InvalidArgumentError} from '../../common/Exceptions.js';
import {type ArgumentOptionDefinition, entryOption, loadOption, nameOption, type OptionContainer} from '../../encoding/Options.js';
import {type ConverterDefinition} from './ConverterDefinition.js';

const kctypeOption: ArgumentOptionDefinition<string | undefined> = {
name: 'kctype',
label: 'File type',
description: 'File type, 3 characters (default: COM)',
common: false,
required: false,
type: 'text',
parse(value: string) {
if (value === '') {
return undefined;
}
if (value.length !== 3) {
throw new InvalidArgumentError(this.name, `Option ${this.name} is expected have exactly 3 characters (example: COM).`);
}

return value;
},
};

const definition: ConverterDefinition = {
name: 'KC .TAP-File',
identifier: 'kctap',
options: [
loadOption,
entryOption,
nameOption,
],
convert,
};
export default definition;

const maxFileNameLength = 8;

function convert(data: BufferAccess, options: OptionContainer): BufferAccess {
const blocks = data.chunksPadded(128, 0x00);
const outBa = BufferAccess.create(16 + (1 + blocks.length) * 129);

const loadAddress = options.getArgument(loadOption) ?? 0x0300;
const entryAddress = options.getArgument(entryOption) ?? 0xffff; // default: no auto start
const filename = options.getArgument(nameOption) ?? '';
const filetype = options.getArgument(kctypeOption) ?? 'COM';
if (filename.length > maxFileNameLength) {
throw new InvalidArgumentError('name', `Maximum length of filename (${maxFileNameLength}) exceeded.`);
}

const header = createHeader(
filename,
filetype,
loadAddress,
loadAddress + data.length() - 1,
entryAddress,
);

// TAP header
outBa.writeUint8(0xc3);
outBa.writeAsciiString('KC-TAPE by AF. ');

// header block (FCB)
outBa.writeUint8(0);
outBa.writeBa(header);

// data blocks
for (let i = 0; i < blocks.length; i++) {
outBa.writeUint8(i === blocks.length - 1 ? 0xff : i + 1);
outBa.writeBa(blocks[i]);
}

return outBa;
}

function createHeader(name: string, fileType: string, loadAddress: number, endAddress: number, startAddress: number): BufferAccess {
const header = BufferAccess.create(128);

header.writeAsciiString(name, maxFileNameLength, 0x00);
header.writeAsciiString(fileType);
header.writeUint8(0x00); // reserved
header.writeUint8(0x00); // reserved
header.writeUint16Le(0x0000); // PSUM (block checksum(?))
header.writeUint8(0x00); // ARB (internal working cell(?))
header.writeUint8(0x03); // BLNR (block number(?)) (count?)
header.writeUint16Le(loadAddress); // AADR
header.writeUint16Le(endAddress); // EADR
header.writeUint16Le(startAddress); // SADR
header.writeUint8(0x00); // SBY (protection byte)

return header;
}
17 changes: 3 additions & 14 deletions retroload-lib/src/encoding/adapter/atari/AtariGenericAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {type OptionContainer} from '../../Options.js';
import {type RecorderInterface} from '../../recorder/RecorderInterface.js';
import {unidentifiable, type FormatIdentification} from '../AdapterDefinition.js';
import {type AdapterDefinition} from '../AdapterDefinition.js';
import {calculateChecksum8WithCarry} from '../../../common/Utils.js';

const definition: AdapterDefinition = {
name: 'Atari (Generic data)',
Expand Down Expand Up @@ -45,7 +46,7 @@ function encode(recorder: RecorderInterface, ba: BufferAccess, _options: OptionC
blockBa.setUint8(130, chunkBa.length());
}

blockBa.setUint8(131, calculateChecksum(blockBa));
blockBa.setUint8(131, calculateChecksum8WithCarry(blockBa));
e.recordIrg((blockId === 0) ? pilotIrgLength : defaultIrgLength); // TODO: create option (longer values are required for "ENTER-loading")
e.recordBytes(blockBa);
}
Expand All @@ -55,20 +56,8 @@ function encode(recorder: RecorderInterface, ba: BufferAccess, _options: OptionC
eofBlockBa.writeUint8(markerByte);
eofBlockBa.writeUint8(markerByte);
eofBlockBa.writeUint8(blockTypeEndOfFile);
eofBlockBa.setUint8(131, calculateChecksum(eofBlockBa));
eofBlockBa.setUint8(131, calculateChecksum8WithCarry(eofBlockBa));
e.recordIrg(defaultIrgLength); // TODO: create option (longer values are required for "ENTER-loading")
e.recordBytes(eofBlockBa);
}

function calculateChecksum(ba: BufferAccess) {
// 8 bit checksum with carry being added
let sum = 0;
for (let i = 0; i < ba.length(); i++) {
sum += ba.getUint8(i);
if (sum > 255) {
sum = (sum & 0xff) + 1;
}
}

return sum;
}
1 change: 1 addition & 0 deletions retroload-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {WaveRecorder} from './encoding/recorder/WaveRecorder.js';
export {Logger} from './common/logging/Logger.js';
export {BufferAccess} from './common/BufferAccess.js';
export * as AdapterManager from './encoding/AdapterManager.js';
export * as ConverterManager from './conversion/ConverterManager.js';
export * as Exception from './common/Exceptions.js';
export * as DecoderManager from './decoding/DecoderManager.js';
export {tokenizers as BasicTokenizers} from './tokenizing/TokenizerProvider.js';
Expand Down
Loading