Skip to content

Commit ace7edf

Browse files
committed
Working on GIF generator service
1 parent 98c7dad commit ace7edf

11 files changed

Lines changed: 893 additions & 64 deletions

File tree

apps/gif-service/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

apps/gif-service/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "gif-service",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "tsx src/index.ts",
8+
"build": "echo 'Build not needed - using tsx'",
9+
"start": "tsx src/index.ts",
10+
"test": "echo 'Tests not implemented yet'"
11+
},
12+
"dependencies": {
13+
"express": "^4.21.2",
14+
"gif-encoder-2": "^1.0.5",
15+
"multer": "^1.4.5-lts.1"
16+
},
17+
"devDependencies": {
18+
"@types/express": "^5.0.0",
19+
"@types/multer": "^1.4.12",
20+
"@types/node": "^22.10.5",
21+
"tsx": "^4.19.2",
22+
"typescript": "^5.7.3"
23+
}
24+
}

apps/gif-service/src/emulator.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { readFile } from 'fs/promises';
2+
import { fileURLToPath } from 'url';
3+
import { dirname, join } from 'path';
4+
import { TAPFile } from './tap-file.js';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = dirname(__filename);
8+
9+
const FRAME_BUFFER_SIZE = 0x6600;
10+
11+
export interface EmulatorCore {
12+
memory: WebAssembly.Memory;
13+
FRAME_BUFFER: number;
14+
REGISTERS: number;
15+
TAPE_PULSES: number;
16+
TAPE_PULSES_LENGTH: number;
17+
MACHINE_MEMORY: number;
18+
AUDIO_BUFFER_LEFT: number;
19+
AUDIO_BUFFER_RIGHT: number;
20+
runFrame(): number;
21+
resumeFrame(): number;
22+
setMachineType(type: number): void;
23+
reset(): void;
24+
keyDown(row: number, mask: number): void;
25+
keyUp(row: number, mask: number): void;
26+
poke(addr: number, value: number): void;
27+
setPC(pc: number): void;
28+
setIFF1(value: number): void;
29+
setIFF2(value: number): void;
30+
setIM(value: number): void;
31+
setHalted(value: number): void;
32+
writePort(port: number, value: number): void;
33+
setTStates(tstates: number): void;
34+
setAudioSamplesPerFrame(samples: number): void;
35+
getTapePulseBufferTstateCount(): number;
36+
getTapePulseWriteIndex(): number;
37+
setTapePulseBufferState(writeIndex: number, tstateCount: number): void;
38+
setTapeTraps(enabled: number): void;
39+
}
40+
41+
export class Emulator {
42+
private core: EmulatorCore | null = null;
43+
private memoryData: Uint8Array | null = null;
44+
private frameData: Uint8Array | null = null;
45+
private registerPairs: Uint16Array | null = null;
46+
private tape: TAPFile | null = null;
47+
48+
async loadCore(): Promise<void> {
49+
const wasmPath = join(__dirname, '../../..', 'apps/web/public/dist/jsspeccy-core.wasm');
50+
const wasmBuffer = await readFile(wasmPath);
51+
const results: any = await WebAssembly.instantiate(wasmBuffer, {});
52+
this.core = results.instance.exports as unknown as EmulatorCore;
53+
this.memoryData = new Uint8Array(this.core.memory.buffer);
54+
this.frameData = this.memoryData.subarray(this.core.FRAME_BUFFER, this.core.FRAME_BUFFER + FRAME_BUFFER_SIZE);
55+
this.registerPairs = new Uint16Array(this.core.memory.buffer, this.core.REGISTERS, 12);
56+
}
57+
58+
async loadRom(romPath: string, page: number): Promise<void> {
59+
if (!this.core || !this.memoryData) {
60+
throw new Error('Core not loaded');
61+
}
62+
const romData = await readFile(romPath);
63+
this.memoryData.set(romData, this.core.MACHINE_MEMORY + page * 0x4000);
64+
}
65+
66+
async loadRoms(): Promise<void> {
67+
const romsPath = join(__dirname, '../../..', 'apps/web/public/roms');
68+
await this.loadRom(join(romsPath, '128-0.rom'), 8);
69+
await this.loadRom(join(romsPath, '128-1.rom'), 9);
70+
await this.loadRom(join(romsPath, '48.rom'), 10);
71+
await this.loadRom(join(romsPath, 'pentagon-0.rom'), 12);
72+
await this.loadRom(join(romsPath, 'trdos.rom'), 13);
73+
}
74+
75+
setMachineType(type: number): void {
76+
if (!this.core) {
77+
throw new Error('Core not loaded');
78+
}
79+
this.core.setMachineType(type);
80+
}
81+
82+
reset(): void {
83+
if (!this.core) {
84+
throw new Error('Core not loaded');
85+
}
86+
this.core.reset();
87+
}
88+
89+
setTapeTraps(enabled: boolean): void {
90+
if (!this.core) {
91+
throw new Error('Core not loaded');
92+
}
93+
this.core.setTapeTraps(enabled ? 1 : 0);
94+
}
95+
96+
loadTAPFile(tapData: Buffer): void {
97+
this.tape = new TAPFile(tapData);
98+
}
99+
100+
private trapTapeLoad(): void {
101+
if (!this.tape || !this.core || !this.registerPairs) {
102+
console.log('trapTapeLoad: missing tape, core, or registers');
103+
return;
104+
}
105+
106+
const block = this.tape.getNextLoadableBlock();
107+
if (!block) {
108+
console.log('trapTapeLoad: no block available');
109+
return;
110+
}
111+
console.log(`trapTapeLoad: loading block of ${block.length} bytes`);
112+
113+
const af_ = this.registerPairs[4];
114+
const expectedBlockType = af_ >> 8;
115+
const shouldLoad = af_ & 0x0001;
116+
let addr = this.registerPairs[8];
117+
const requestedLength = this.registerPairs[2];
118+
const actualBlockType = block[0];
119+
120+
console.log(`Tape trap details: expected type=${expectedBlockType}, actual type=${actualBlockType}, shouldLoad=${shouldLoad}, addr=0x${addr.toString(16)}, length=${requestedLength}`);
121+
122+
let success = true;
123+
if (expectedBlockType !== actualBlockType) {
124+
success = false;
125+
} else {
126+
if (shouldLoad) {
127+
let offset = 1;
128+
let loadedBytes = 0;
129+
let checksum = actualBlockType;
130+
while (loadedBytes < requestedLength) {
131+
if (offset >= block.length) {
132+
success = false;
133+
break;
134+
}
135+
const byte = block[offset++];
136+
loadedBytes++;
137+
this.core.poke(addr, byte);
138+
addr = (addr + 1) & 0xffff;
139+
checksum ^= byte;
140+
}
141+
142+
success = success && (offset < block.length);
143+
if (success) {
144+
const expectedChecksum = block[offset];
145+
success = (checksum === expectedChecksum);
146+
}
147+
} else {
148+
success = true;
149+
}
150+
}
151+
152+
if (success) {
153+
this.registerPairs[0] |= 0x0001;
154+
console.log(`Trap successful, AF=${this.registerPairs[0].toString(16)}, setting PC to 0x05e2`);
155+
} else {
156+
this.registerPairs[0] &= 0xfffe;
157+
console.log(`Trap failed, AF=${this.registerPairs[0].toString(16)}, setting PC to 0x05e2`);
158+
}
159+
this.core.setPC(0x05e2);
160+
}
161+
162+
runFrame(): Uint8Array {
163+
if (!this.core || !this.frameData) {
164+
throw new Error('Core not loaded');
165+
}
166+
this.core.setAudioSamplesPerFrame(0);
167+
let status = this.core.runFrame();
168+
let trapCount = 0;
169+
while (status) {
170+
trapCount++;
171+
switch (status) {
172+
case 1:
173+
throw new Error('Unrecognized opcode');
174+
case 2:
175+
console.log(`Tape trap #${trapCount} in this frame`);
176+
this.trapTapeLoad();
177+
break;
178+
default:
179+
throw new Error(`runFrame returned unexpected result: ${status}`);
180+
}
181+
status = this.core.resumeFrame();
182+
}
183+
return new Uint8Array(this.frameData);
184+
}
185+
186+
loadMemoryPage(page: number, data: Uint8Array): void {
187+
if (!this.core || !this.memoryData) {
188+
throw new Error('Core not loaded');
189+
}
190+
this.memoryData.set(data, this.core.MACHINE_MEMORY + page * 0x4000);
191+
}
192+
193+
async loadSnapshot(snapshot: any): Promise<void> {
194+
if (!this.core || !this.registerPairs) {
195+
throw new Error('Core not loaded');
196+
}
197+
this.core.setMachineType(snapshot.model);
198+
for (const page in snapshot.memoryPages) {
199+
this.loadMemoryPage(Number(page), snapshot.memoryPages[page]);
200+
}
201+
['AF', 'BC', 'DE', 'HL', 'AF_', 'BC_', 'DE_', 'HL_', 'IX', 'IY', 'SP', 'IR'].forEach(
202+
(r, i) => {
203+
if (this.registerPairs) {
204+
this.registerPairs[i] = snapshot.registers[r];
205+
}
206+
}
207+
);
208+
this.core.setPC(snapshot.registers.PC);
209+
this.core.setIFF1(snapshot.registers.iff1);
210+
this.core.setIFF2(snapshot.registers.iff2);
211+
this.core.setIM(snapshot.registers.im);
212+
this.core.setHalted(snapshot.halted ? 1 : 0);
213+
214+
this.core.writePort(0x00fe, snapshot.ulaState.borderColour);
215+
if (snapshot.model !== 48) {
216+
this.core.writePort(0x7ffd, snapshot.ulaState.pagingFlags);
217+
}
218+
219+
this.core.setTStates(snapshot.tstates);
220+
}
221+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const PALETTE = new Uint32Array([
2+
/* RGBA dark - stored as ABGR in little-endian */
3+
0xff000000, // black
4+
0xffdd0000, // blue
5+
0xff0000dd, // red
6+
0xffdd00dd, // magenta
7+
0xff00dd00, // green
8+
0xffdddd00, // cyan
9+
0xff00dddd, // yellow
10+
0xffdddddd, // white
11+
/* RGBA bright */
12+
0xff000000, // black
13+
0xffff0000, // bright blue
14+
0xff0000ff, // bright red
15+
0xffff00ff, // bright magenta
16+
0xff00ff00, // bright green
17+
0xffffff00, // bright cyan
18+
0xff00ffff, // bright yellow
19+
0xffffffff, // bright white
20+
]);
21+
22+
export interface FrameDecoderOptions {
23+
width?: number;
24+
height?: number;
25+
borderSize?: number;
26+
}
27+
28+
export class FrameDecoder {
29+
private width: number = 320;
30+
private height: number = 240;
31+
private flashPhase: number = 0;
32+
33+
constructor(options: FrameDecoderOptions = {}) {
34+
}
35+
36+
getWidth(): number {
37+
return this.width;
38+
}
39+
40+
getHeight(): number {
41+
return this.height;
42+
}
43+
44+
decode(frameBuffer: Uint8Array): Uint8Array {
45+
const pixels = new Uint32Array(this.width * this.height);
46+
let pixelPtr = 0;
47+
let bufferPtr = 0;
48+
49+
/* top border: 24 rows × 160 pixels, each pixel doubled horizontally */
50+
for (let y = 0; y < 24; y++) {
51+
for (let x = 0; x < 160; x++) {
52+
const border = PALETTE[frameBuffer[bufferPtr++]];
53+
pixels[pixelPtr++] = border;
54+
pixels[pixelPtr++] = border;
55+
}
56+
}
57+
58+
/* main screen: 192 rows */
59+
for (let y = 0; y < 192; y++) {
60+
/* left border: 16 pixels, doubled */
61+
for (let x = 0; x < 16; x++) {
62+
const border = PALETTE[frameBuffer[bufferPtr++]];
63+
pixels[pixelPtr++] = border;
64+
pixels[pixelPtr++] = border;
65+
}
66+
67+
/* screen data: 32 bytes of (bitmap, attribute) pairs */
68+
for (let x = 0; x < 32; x++) {
69+
let bitmap = frameBuffer[bufferPtr++];
70+
const attr = frameBuffer[bufferPtr++];
71+
72+
let ink: number, paper: number;
73+
if ((attr & 0x80) && (this.flashPhase & 0x10)) {
74+
paper = PALETTE[((attr & 0x40) >> 3) | (attr & 0x07)];
75+
ink = PALETTE[(attr & 0x78) >> 3];
76+
} else {
77+
ink = PALETTE[((attr & 0x40) >> 3) | (attr & 0x07)];
78+
paper = PALETTE[(attr & 0x78) >> 3];
79+
}
80+
81+
for (let i = 0; i < 8; i++) {
82+
pixels[pixelPtr++] = (bitmap & 0x80) ? ink : paper;
83+
bitmap <<= 1;
84+
}
85+
}
86+
87+
/* right border: 16 pixels, doubled */
88+
for (let x = 0; x < 16; x++) {
89+
const border = PALETTE[frameBuffer[bufferPtr++]];
90+
pixels[pixelPtr++] = border;
91+
pixels[pixelPtr++] = border;
92+
}
93+
}
94+
95+
/* bottom border: 24 rows × 160 pixels, each pixel doubled */
96+
for (let y = 0; y < 24; y++) {
97+
for (let x = 0; x < 160; x++) {
98+
const border = PALETTE[frameBuffer[bufferPtr++]];
99+
pixels[pixelPtr++] = border;
100+
pixels[pixelPtr++] = border;
101+
}
102+
}
103+
104+
this.flashPhase = (this.flashPhase + 1) & 0x1f;
105+
106+
return new Uint8Array(pixels.buffer);
107+
}
108+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
declare module 'gif-encoder-2' {
2+
export default class GIFEncoder {
3+
constructor(width: number, height: number, algorithm?: string);
4+
setDelay(ms: number): void;
5+
setRepeat(repeat: number): void;
6+
setQuality(quality: number): void;
7+
start(): void;
8+
addFrame(data: Uint8Array | Uint8ClampedArray): void;
9+
finish(): void;
10+
out: { getData(): Uint8Array };
11+
}
12+
}

0 commit comments

Comments
 (0)