#!/usr/bin/env -S deno run --allow-read import { TextLineStream } from "jsr:@std/streams@1.1.0"; /** * CONFIG */ const instructionLength = 4; // in bytes const rawDataInstructionLengthPrefix = "U32"; const asmFilePaths = [ "src/consts/arch.asm", "src/consts/game.asm", "src/consts/keyboard.asm", "src/consts/map.asm", "src/consts/scene.asm", "src/consts/screen.asm", "src/consts/sprites.asm", "src/init.asm", "src/main.asm", "src/lib/game.asm", "src/lib/drawing.asm", "src/lib/sprite_rotation.asm", ]; const reservedSpacePath = "src/reserved_space.asm"; const mapMatchingValues = { 0x00_00_00: 0, // Empty 0xFF_00_00: 1, // Wall 0xFF_FF_00: 2, // Coin }; const spritesMatchingValues = { 0x00_00_00: 0, // 0b000_000_00 0x00_00_FF: 3, // 0b000_000_11 0x00_FF_00: 28, // 0b000_111_00 0x99_50_00: 68, // 0b010_001_00 0x77_77_77: 110, // 0b011_011_10 0xB3_B3_B3: 146, // 0b100_100_10 0xFF_00_00: 224, // 0b111_000_00 0xFF_00_FF: 227, // 0b111_000_11 0xFF_80_00: 240, // 0b111_100_00 0xFF_FF_00: 248, // 0b111_110_00 0xFF_FF_FF: 255, // 0b111_111_11 }; const mapPath = "assets/map.ppm"; const spritesPath = { "sprite_empty": "assets/sprites/empty.ppm", "sprite_unimplemented": "assets/sprites/placeholder.ppm", "sprite_wall_end": "assets/sprites/wall_end.ppm", "sprite_wall_straight": "assets/sprites/wall_straight.ppm", "sprite_wall_corner": "assets/sprites/wall_corner.ppm", "sprite_robot": "assets/sprites/robot.ppm", "sprite_ghost": "assets/sprites/ghost.ppm", "sprite_coin": "assets/sprites/coin.ppm", }; /** * END OF CONFIG */ class Minifier { private buffer = ""; private lines: Array = []; stream; constructor(writableStream: WritableStream) { this.stream = new TransformStream({ transform: (chunk, controller) => { this.buffer += chunk; controller.enqueue(this.processChunk()); }, flush: (controller) => { this.buffer += "\n"; controller.enqueue(this.processChunk()); controller.enqueue(this.lines.join("\n") + "\n"); this.lines = []; }, }); this.stream.readable.pipeTo(writableStream); } private processChunk(): string { const newLines = this.buffer.split("\n"); this.buffer = newLines.pop() as string; // keep the last (potentially incomplete) line const removeComment = (line: string) => line.replace(/\s*;.*/, ""); const removeLeadingWhitespaces = (line: string) => line.replace(/^\s+/, ""); const removeTrailingWhitespaces = (line: string) => line.replace(/\s+$/, ""); const removeDoubleSpaces = (line: string) => line.replaceAll(/ +/g, " "); const isNotEmpty = (line: string) => line.length !== 0; const newLinesStripped = newLines .map(removeComment) .map(removeLeadingWhitespaces) .map(removeTrailingWhitespaces) .map(removeDoubleSpaces) .filter(isNotEmpty); this.lines.push(...newLinesStripped); return this.processLines(); } private processLines() { const processedLines: Array = []; while (this.lines.length >= instructionLength) { const mergeableLines = this.lines.slice(0, instructionLength); if (!mergeableLines.every((line) => line.startsWith("U8"))) { processedLines.push(this.lines.shift() as string); continue; } const bytesToMerge = mergeableLines.map((line) => parseInt((line.match(/U8\s(\d+)/) as RegExpMatchArray)[1]) ); let mergedBytes = 0; for (let i = 0; i < bytesToMerge.length; i++) { mergedBytes += bytesToMerge[i] << (instructionLength - 1 - i) * 8; } processedLines.push( `${rawDataInstructionLengthPrefix} ${mergedBytes >>> 0}`, ); this.lines.splice(0, instructionLength); } return processedLines.length === 0 ? "" : `${processedLines.join("\n")}\n`; } } async function build(output: WritableStreamDefaultWriter) { for (const path of asmFilePaths) { const file = await Deno.open(path); const readable = file.readable.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextLineStream()); await output.write(` ;/******************************* ;* ${path} ;*******************************/ `); for await (const line of readable) { if (!line.startsWith("include ")) { await output.write(`${line}\n`); } } } const asmRawRepresentations = []; const mapBytes = await getDataFromPPM(mapPath); asmRawRepresentations.push( bytesToAsmConstU8("map", mapBytes, mapMatchingValues), ); for (const [label, path] of Object.entries(spritesPath)) { const bytes = await getDataFromPPM(path); asmRawRepresentations.push( bytesToAsmConstU8(label, bytes, spritesMatchingValues), ); } await output.write(asmRawRepresentations.join("\n\n")); await output.write(` ; ; RESERVED RAM SPACE ; `); await output.write(await Deno.readTextFile(reservedSpacePath)); await output.close(); } /** * Return an ASM constant (U8) representation of the given data * * If needed, the representation will be padded to ensure the following code stays aligned on the instruction's length * * @param label The label to write before the first raw data * @param data An array of bytes to interprete as 24 bits values * @param matchingValues An associative array with a string representation for each possible 24 bit data value * * @returns The ASM representation of that raw data, pre- and post-fixed by the given label */ function bytesToAsmConstU8( label: string, data: Uint8Array, matchingValues: Record, ): string { if (data.length % 3 !== 0) { console.error( "Length of data passed to bytesToAsmConstU8 are not a multiple of 3 (it needs to, as each resulting byte need a Red, a Green and a Blue value)", ); Deno.exit(1); } const lines = [`${label}:`]; for (let i = 0; i < data.length; i += 3) { const pixelValue = (data[i] << 16) + (data[i + 1] << 8) + (data[i + 2]); if (!Object.hasOwn(matchingValues, pixelValue)) { console.error( `No matching value defined for "${ pixelValue.toString(2) }" (which is ${label}'s ${i} pixel)`, ); Deno.exit(1); } lines.push(`U8 ${matchingValues[pixelValue]}`); } lines.push(`${label}_end:`); const bytesNb = data.length / 3; const unalignedBytesNb = bytesNb % instructionLength; for (let i = 0; i < instructionLength - unalignedBytesNb; i++) { lines.push( `U8 0 ; padding to preserve ${8 * instructionLength} bits alignment`, ); } return lines.join("\n"); } /** * Return the image pixels data (excluding all other fields) from a given PPM file * * The implementation differ from the format definition (https://netpbm.sourceforge.net/doc/ppm.html) but match the file created by GIMP. */ async function getDataFromPPM(path: string): Promise { const endOfComment = [ 0x0a, 0x0d, ]; const whitespaces = [ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, ]; const numbers = [ 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, ]; const hashtag = 0x23; const content = await Deno.readFile(path); let offset = 0; // magic number while (!whitespaces.includes(content[offset])) { offset++; } offset++; // whitespace // potential comment if (content[offset] === hashtag) { while (!endOfComment.includes(content[offset])) { offset++; } offset++; // end of comment } // image width while (numbers.includes(content[offset])) { offset++; } offset++; // whitespace // image height while (numbers.includes(content[offset])) { offset++; } offset++; // whitespace // maximum color value while (numbers.includes(content[offset])) { offset++; } offset++; // whitespace return content.slice(offset, content.length); } function printUsage(into: (message: string) => void) { into(`Usage: \tbuild.ts [--minify] \t\tMerge the hardcoded files and output the result to stdout. The "--minify" option can be used to strip the result of all comments, leading/trailing double whitespaces, empty lines and use the more compact raw data representation. \tbuild.ts --help \t\tPrint this message`); } /** * SCRIPT */ if (Deno.args.length > 1) { printUsage(console.error); Deno.exit(1); } let minify = false; if (Deno.args.length === 1) { switch (Deno.args[0]) { case "--help": printUsage(console.log); Deno.exit(0); break; case "--minify": minify = true; break; default: printUsage(console.error); Deno.exit(1); break; } } const encoder = new TextEncoder(); const stdoutWritable = new WritableStream({ async write(chunk) { await Deno.stdout.write(encoder.encode(chunk)); }, }); if (minify) { const minifier = new Minifier(stdoutWritable); await build(minifier.stream.writable.getWriter()); } else { await build(stdoutWritable.getWriter()); }