commit ee139eba9bbc239f540bd9c9ed575e2204cc2df7 Author: Cedric Beust Date: Sat Aug 22 11:59:50 2020 -0700 First commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..33d738c --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# chip8 +## A [Chip-8](http://www.cs.columbia.edu/~sedwards/classes/2016/4840-spring/designs/Chip8.pdf) emulator written in Kotlin + +

+ +

+ +# How to run + +``` +$ ./gradlew run +``` + +The emulator will load with Space Invaders by default (press 5 to start the game, then 4/5/6 to move around and shoot). Open a new rom by clicking on the "Open rom..." button. + +You can pause the emulator at any time (key '`p`'), which will update the disassembly window to show the next instructions about to be executed. You can also adjust the clock speed to make the emulator go slower or faster. + +# Architecture + +The game creates a [`Computer`](https://github.com/cbeust/chip8/blob/master/src/main/kotlin/com/beust/chip8/Computer.kt) object which is made of a `Display`, `Keyboard`, `FrameBuffer` and `Cpu`. + +## Cpu + +The CPU reads a new instruction (the next two bytes extracted at the program counter location) at a fixed rate +which defines the clock speed. Two timers are needed: one for the CPU and one for the device timer register, +called `DT`, which needs to tick at 60 Hz according to [the spec](http://www.cs.columbia.edu/~sedwards/classes/2016/4840-spring/designs/Chip8.pdf). Since there is no specific definition for the CPU clock, I used the timing diagram from the document +to set it at around 500Hz: + +```kotlin +// CPU clock: around 500 Hz by default +cpuFuture = executor.scheduleAtFixedRate(cpuTick, 0, 1_000_000L / cpuClockHz, TimeUnit.MICROSECONDS) + +// Delay Timer: 60 Hz by spec +timerFuture = executor.scheduleAtFixedRate(timerTick, 0, 16L, TimeUnit.MILLISECONDS) +``` + +

+ +

+ +The next two bytes are then masked and turned into instructions. All the op codes can be found in the `[Ops.kt](https://github.com/cbeust/chip8/blob/master/src/main/kotlin/com/beust/chip8/Ops.kt)` file. Here is an example: + +```kotlin +/** + * 7xkk + * Set Vx = Vx + kk + */ +class Add(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = unsigned(cpu.V[x] + kk) } + override fun toString() = "ADD V$x, $kk" +} +``` + +## Display + +The `Display` is a simple interface which allows multiple strategies to render the frame buffer: + +```kotlin +interface Display { + val pane: Pane + fun draw(frameBuffer: IntArray) + fun clear(frameBuffer: IntArray) +} +``` + +For example, here is a text based renderer: + +

+ +

+ +The emulator window will resize gracefully: + +

+ +

+ +You can also easily alter other aspects of the renderer: + +

+ +

+ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c45e553 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,89 @@ +@file:Suppress("MayBeConstant") + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +buildscript { + repositories { + jcenter() + mavenCentral() + maven { setUrl("https://plugins.gradle.org/m2") } + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72") + } +} + +plugins { + java + id("org.jetbrains.kotlin.jvm") version "1.3.72" + application + id("com.github.johnrengelman.shadow") version "5.2.0" + id("org.openjfx.javafxplugin") version "0.0.8" +} + +repositories { + jcenter() + mavenCentral() + maven { setUrl("https://plugins.gradle.org/m2") } +} + +object This { + val version = "0.1" + val groupId = "com.beust" + val artifactId = "chip8" + val description = "A CHIP8 emulator" + val url = "https://github.com/cbeust/chip8" + val scm = "github.com/cbeust/chip8.git" + + // Should not need to change anything below + val issueManagementUrl = "https://$scm/issues" + val isSnapshot = version.contains("SNAPSHOT") +} + +allprojects { + group = This.groupId + version = This.version +} + +dependencies { + listOf(kotlin("stdlib"), "com.beust:jcommander:1.72").forEach { + implementation(it) + } + + listOf(kotlin("test"), "org.testng:testng:7.0.0", "org.assertj:assertj-core:3.10.0").forEach { + testImplementation(it) + } +} + +val test by tasks.getting(Test::class) { + useTestNG() +} + +application { + mainClassName = "com.beust.chip8.MainKt" +} + +tasks { + named("shadowJar") { + archiveBaseName.set(This.artifactId) + mergeServiceFiles() +// excludes = listOf("META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.SF") + manifest { + attributes(mapOf( + "Implementation-Title" to rootProject.name, + "Implementation-Version" to rootProject.version, + "Implementation-Vendor-Id" to rootProject.group, + // attributes "Build-Time": ZonedDateTime.now(ZoneId.of("UTC")) + // .format(DateTimeFormatter.ISO_ZONED_DATE_TIME) +// "Built-By" to java.net.InetAddress.localHost.hostName, + "Created-By" to "Gradle "+ gradle.gradleVersion, + "Main-Class" to "com.beust.cedlinks.MainKt")) + } + } +} + +javafx { + version = "14" + modules = listOf("javafx.controls", "javafx.fxml") +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a5fe1cb Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b5cf9a0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/pics/breakout-1.png b/pics/breakout-1.png new file mode 100644 index 0000000..e69de29 diff --git a/pics/breakout-2.png b/pics/breakout-2.png new file mode 100644 index 0000000..b85c189 Binary files /dev/null and b/pics/breakout-2.png differ diff --git a/pics/space-invaders-1.mp4 b/pics/space-invaders-1.mp4 new file mode 100644 index 0000000..e72b29a Binary files /dev/null and b/pics/space-invaders-1.mp4 differ diff --git a/pics/space-invaders-2.gif b/pics/space-invaders-2.gif new file mode 100644 index 0000000..0f6c64c Binary files /dev/null and b/pics/space-invaders-2.gif differ diff --git a/pics/space-invaders-2.mp4 b/pics/space-invaders-2.mp4 new file mode 100644 index 0000000..7b66662 Binary files /dev/null and b/pics/space-invaders-2.mp4 differ diff --git a/pics/space-invaders-colors.png b/pics/space-invaders-colors.png new file mode 100644 index 0000000..8f7b186 Binary files /dev/null and b/pics/space-invaders-colors.png differ diff --git a/pics/space-invaders-small.png b/pics/space-invaders-small.png new file mode 100644 index 0000000..5257e2e Binary files /dev/null and b/pics/space-invaders-small.png differ diff --git a/pics/space-invaders-text.png b/pics/space-invaders-text.png new file mode 100644 index 0000000..b840862 Binary files /dev/null and b/pics/space-invaders-text.png differ diff --git a/pics/tetris-1.png b/pics/tetris-1.png new file mode 100644 index 0000000..930e8b9 Binary files /dev/null and b/pics/tetris-1.png differ diff --git a/roms/Breakout [Carmelo Cortez, 1979].ch8 b/roms/Breakout [Carmelo Cortez, 1979].ch8 new file mode 100644 index 0000000..70b50db Binary files /dev/null and b/roms/Breakout [Carmelo Cortez, 1979].ch8 differ diff --git a/roms/Brix [Andreas Gustafsson, 1990].ch8 b/roms/Brix [Andreas Gustafsson, 1990].ch8 new file mode 100644 index 0000000..ad639d9 Binary files /dev/null and b/roms/Brix [Andreas Gustafsson, 1990].ch8 differ diff --git a/roms/Chip8 Picture.ch8 b/roms/Chip8 Picture.ch8 new file mode 100644 index 0000000..74ab4bf Binary files /dev/null and b/roms/Chip8 Picture.ch8 differ diff --git a/roms/Chip8 emulator Logo [Garstyciuks].ch8 b/roms/Chip8 emulator Logo [Garstyciuks].ch8 new file mode 100644 index 0000000..c0e5923 Binary files /dev/null and b/roms/Chip8 emulator Logo [Garstyciuks].ch8 differ diff --git a/roms/Clock Program [Bill Fisher, 1981].ch8 b/roms/Clock Program [Bill Fisher, 1981].ch8 new file mode 100644 index 0000000..ec137bd Binary files /dev/null and b/roms/Clock Program [Bill Fisher, 1981].ch8 differ diff --git a/roms/Delay Timer Test [Matthew Mikolay, 2010].ch8 b/roms/Delay Timer Test [Matthew Mikolay, 2010].ch8 new file mode 100644 index 0000000..aa9ae28 Binary files /dev/null and b/roms/Delay Timer Test [Matthew Mikolay, 2010].ch8 differ diff --git a/roms/IBM Logo.ch8 b/roms/IBM Logo.ch8 new file mode 100644 index 0000000..113338e Binary files /dev/null and b/roms/IBM Logo.ch8 differ diff --git a/roms/InstructionTest.ch8 b/roms/InstructionTest.ch8 new file mode 100644 index 0000000..f540f69 Binary files /dev/null and b/roms/InstructionTest.ch8 differ diff --git a/roms/Keypad Test [Hap, 2006].ch8 b/roms/Keypad Test [Hap, 2006].ch8 new file mode 100644 index 0000000..c60558e Binary files /dev/null and b/roms/Keypad Test [Hap, 2006].ch8 differ diff --git a/roms/Maze (alt) [David Winter, 199x].ch8 b/roms/Maze (alt) [David Winter, 199x].ch8 new file mode 100644 index 0000000..0dca981 Binary files /dev/null and b/roms/Maze (alt) [David Winter, 199x].ch8 differ diff --git a/roms/Maze (alt) [David Winter, 199x].txt b/roms/Maze (alt) [David Winter, 199x].txt new file mode 100644 index 0000000..5e69419 --- /dev/null +++ b/roms/Maze (alt) [David Winter, 199x].txt @@ -0,0 +1,12 @@ +Maze, by David Winter + +Drawing a random maze like this one consists in drawing random diagonal +lines. There are two possibilities: right-to-left line, and left-to-right +line. Each line is composed of a 4*4 bitmap. As the lines must form non- +circular angles, the two bitmaps won't be '/' and '\'. The first one +(right line) will be a little bit modified. See at the end of this source. + +The maze is composed of 16 lines (as the bitmaps are 4 pixels high), each +line consists of 32 bitmaps. +Bitmaps are drawn in random mode. We choose a random value (0 or 1). +If it is 1, we draw a left line bitmap. If it is 0, we draw a right one. diff --git a/roms/Maze [David Winter, 199x].ch8 b/roms/Maze [David Winter, 199x].ch8 new file mode 100644 index 0000000..152ae7d Binary files /dev/null and b/roms/Maze [David Winter, 199x].ch8 differ diff --git a/roms/Maze [David Winter, 199x].txt b/roms/Maze [David Winter, 199x].txt new file mode 100644 index 0000000..5e69419 --- /dev/null +++ b/roms/Maze [David Winter, 199x].txt @@ -0,0 +1,12 @@ +Maze, by David Winter + +Drawing a random maze like this one consists in drawing random diagonal +lines. There are two possibilities: right-to-left line, and left-to-right +line. Each line is composed of a 4*4 bitmap. As the lines must form non- +circular angles, the two bitmaps won't be '/' and '\'. The first one +(right line) will be a little bit modified. See at the end of this source. + +The maze is composed of 16 lines (as the bitmaps are 4 pixels high), each +line consists of 32 bitmaps. +Bitmaps are drawn in random mode. We choose a random value (0 or 1). +If it is 1, we draw a left line bitmap. If it is 0, we draw a right one. diff --git a/roms/Particle Demo [zeroZshadow, 2008].ch8 b/roms/Particle Demo [zeroZshadow, 2008].ch8 new file mode 100644 index 0000000..2df9765 Binary files /dev/null and b/roms/Particle Demo [zeroZshadow, 2008].ch8 differ diff --git a/roms/Particle Demo [zeroZshadow, 2008].txt b/roms/Particle Demo [zeroZshadow, 2008].txt new file mode 100644 index 0000000..8a7f97c --- /dev/null +++ b/roms/Particle Demo [zeroZshadow, 2008].txt @@ -0,0 +1,6 @@ +This is my particledemo for the Chip-8, SuperChip and MegaChip8. +Works on real hardware as well as emulators + +Enjoy! + + zeroZshadow \ No newline at end of file diff --git a/roms/Pong (alt).ch8 b/roms/Pong (alt).ch8 new file mode 100644 index 0000000..295ce91 Binary files /dev/null and b/roms/Pong (alt).ch8 differ diff --git a/roms/Random Number Test [Matthew Mikolay, 2010].ch8 b/roms/Random Number Test [Matthew Mikolay, 2010].ch8 new file mode 100644 index 0000000..c9f79e4 Binary files /dev/null and b/roms/Random Number Test [Matthew Mikolay, 2010].ch8 differ diff --git a/roms/Sierpinski [Sergey Naydenov, 2010].ch8 b/roms/Sierpinski [Sergey Naydenov, 2010].ch8 new file mode 100644 index 0000000..e02d458 Binary files /dev/null and b/roms/Sierpinski [Sergey Naydenov, 2010].ch8 differ diff --git a/roms/Sirpinski [Sergey Naydenov, 2010].ch8 b/roms/Sirpinski [Sergey Naydenov, 2010].ch8 new file mode 100644 index 0000000..e02d458 Binary files /dev/null and b/roms/Sirpinski [Sergey Naydenov, 2010].ch8 differ diff --git a/roms/Space Invaders [David Winter].ch8 b/roms/Space Invaders [David Winter].ch8 new file mode 100644 index 0000000..3ada8df Binary files /dev/null and b/roms/Space Invaders [David Winter].ch8 differ diff --git a/roms/Stars [Sergey Naydenov, 2010].ch8 b/roms/Stars [Sergey Naydenov, 2010].ch8 new file mode 100644 index 0000000..712e83c Binary files /dev/null and b/roms/Stars [Sergey Naydenov, 2010].ch8 differ diff --git a/roms/Tetris [Fran Dachille, 1991].ch8 b/roms/Tetris [Fran Dachille, 1991].ch8 new file mode 100644 index 0000000..9f5e087 Binary files /dev/null and b/roms/Tetris [Fran Dachille, 1991].ch8 differ diff --git a/roms/Trip8 Demo (2008) [Revival Studios].ch8 b/roms/Trip8 Demo (2008) [Revival Studios].ch8 new file mode 100644 index 0000000..d88d28f Binary files /dev/null and b/roms/Trip8 Demo (2008) [Revival Studios].ch8 differ diff --git a/roms/Trip8 Demo (2008) [Revival Studios].txt b/roms/Trip8 Demo (2008) [Revival Studios].txt new file mode 100644 index 0000000..e9a5a6c --- /dev/null +++ b/roms/Trip8 Demo (2008) [Revival Studios].txt @@ -0,0 +1,49 @@ +----------------------------------------------------------------------------- + ///////////////// + ////////////////// + //// //// + //// /////////// + //// /////////// + //// //// + //// /////////// + //// ////////// + + www.revival-studios.com +----------------------------------------------------------------------------- +Title : Trip8 / SuperTrip8 demo +Author : Martijn Wenting / Revival Studios +Genre : Demo +System : Chip-8 / SuperChip8 +Date : 14/10/2008 +Product ID : RS-C8004 +----------------------------------------------------------------------------- + +All the contents of this package are (c)Copyright 2008 Revival Studios. + +The contents of the package may only be spread in its original form, and may not be +published or distributed otherwise without the written permission of the authors. + +Description: +------------ +The Trip8/SuperTrip8 demo are demo's for the Chip-8 and SuperChip8 systems. The demo consists of an intro, 3D vectorballs, and 4 randomized dot-effects. + +Writing a demo for the original Chip-8 interpreter was a real pain, since your framerate basically drops in half for every sprite you need to draw. So even clearing and redrawing a few dots will cause the framerate to drop to near zero :) Originally the demo was going to be bigger and there were much more graphical effects programmed/prototyped, but a lot of these effects turned out to be too much for the original unoptimized Chip-8 interpreters to handle. + +Running the Demo: +----------------- +Use the Megachip emulator or any other Chip-8/SuperChip compatible emulator to run the slideshow. + +Credits: +-------- +Programming/Graphics/Design by: Martijn Wenting + +Distribution: +------------- +This package can be freely distributed in its original form. +If you would like to include this slideshow in your rom package, please let me know. + +Watch out for more releases soon! + + + Martijn Wenting / Revival Studios + diff --git a/roms/Zero Demo [zeroZshadow, 2007].ch8 b/roms/Zero Demo [zeroZshadow, 2007].ch8 new file mode 100644 index 0000000..8dbe615 --- /dev/null +++ b/roms/Zero Demo [zeroZshadow, 2007].ch8 @@ -0,0 +1,11 @@ +` +ef +ghabcd` +¢xÐVp +¢~Ðfp +¢„Ðvp +¢ŠÐ†jú` +¢xÐVEaÿEa…ÐVp +¢~ÐfFbÿFb†$Ðfp +¢„ÐvGcÿGc‡4Ðvp +¢ŠÐ†HdÿHdˆDІ*ÿ 0ÀÿÿÀÀüÀÿðÌÌðÌÃ<ÃÃÃÃ< \ No newline at end of file diff --git a/roms/Zero Demo [zeroZshadow, 2007].txt b/roms/Zero Demo [zeroZshadow, 2007].txt new file mode 100644 index 0000000..4361f86 --- /dev/null +++ b/roms/Zero Demo [zeroZshadow, 2007].txt @@ -0,0 +1,3 @@ +This is my first program for the CHIP-8, a simple demo with 4 bouncing sprites. + +Enjoy! \ No newline at end of file diff --git a/roms/bc_test.ch8 b/roms/bc_test.ch8 new file mode 100644 index 0000000..99a4724 Binary files /dev/null and b/roms/bc_test.ch8 differ diff --git a/roms/blinky.ch8 b/roms/blinky.ch8 new file mode 100644 index 0000000..235cf98 Binary files /dev/null and b/roms/blinky.ch8 differ diff --git a/roms/jason.ch8 b/roms/jason.ch8 new file mode 100644 index 0000000..b3a2bc7 --- /dev/null +++ b/roms/jason.ch8 @@ -0,0 +1 @@ +`ÐaoÑ \ No newline at end of file diff --git a/src/main/kotlin/com/beust/chip8/Computer.kt b/src/main/kotlin/com/beust/chip8/Computer.kt new file mode 100644 index 0000000..4391f96 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Computer.kt @@ -0,0 +1,133 @@ +package com.beust.chip8 + +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.Clip + +/** + * For clients that want to be notified when something happens on the computer. + */ +interface ComputerListener { + fun onKey(key: Int?) + fun onPause() {} + fun onStart() {} +} + +class Computer(val display: Display = DisplayGraphics(), + val keyboard: Keyboard = Keyboard(), + val frameBuffer: FrameBuffer = FrameBuffer(), + var cpu: Cpu = Cpu(), + val sound: Boolean = true) +{ + var paused = true + var cpuClockHz: Long = 500 + set(v) { + println("New clock speed: $v") + field = v + pause() + start() + } + + private val executor = Executors.newSingleThreadScheduledExecutor() + private var cpuFuture: ScheduledFuture<*>? = null + private var timerFuture: ScheduledFuture<*>? = null + private var romFile: File? = null + private val soundInputStream by lazy { + AudioSystem.getAudioInputStream( + this::class.java.classLoader.getResource("sound.wav")) + } + private val clip by lazy { AudioSystem.getClip().apply { + open(soundInputStream) + }} + + var listener: ComputerListener? = null + + private fun unsigned(b: Byte): Int = if (b < 0) b + 0x10 else b.toInt() + + fun loadRom(romFile: File, launchTimers: Boolean = true) { + this.romFile = romFile + resetCpu() + if (launchTimers) { + start() + } + } + + private fun resetCpu() { + cpu = Cpu() + romFile?.let { + cpu.loadRom(it.readBytes()) + } + } + + fun stop() { + pause() + clip.stop() + display.clear(frameBuffer.frameBuffer) + resetCpu() + } + + fun pause() { + listener?.onPause() + paused = true + cpuFuture?.cancel(true) + timerFuture?.cancel(true) + } + + fun start() { + listener?.onStart() + paused = false + launchTimers() + } + + private fun nextInstruction(pc: Int = cpu.PC) : Instruction { + fun extract(pc: Int): Pair { + val b = cpu.memory[pc] + val b0 = unsigned(b.toInt().shr(4).toByte()) + val b1 = unsigned(b.toInt().and(0xf).toByte()) + return Pair(b0, b1) + } + val (b0, b1) = extract(pc) + val (b2, b3) = extract(pc + 1) + + return Instruction(this@Computer, b0, b1, b2, b3) + } + + private fun launchTimers() { + val cpuTick = Runnable { + nextInstruction().run() + } + + val timerTick = Runnable { + if (cpu.DT > 0) { + cpu.DT-- + } + if (cpu.ST > 0) { + if (sound && ! clip.isActive) { + clip.loop(Clip.LOOP_CONTINUOUSLY) + } + cpu.ST-- + } else { + clip.stop() + } + } + + cpuFuture = executor.scheduleAtFixedRate(cpuTick, 0, 1_000_000L / cpuClockHz, TimeUnit.MICROSECONDS) + timerFuture = executor.scheduleAtFixedRate(timerTick, 0, 16L, TimeUnit.MILLISECONDS) + } + + data class AssemblyLine(val counter: Int, val byte0: Byte, val byte1: Byte, val name: String) + + fun disassemble(p: Int = cpu.PC): List { + var pc = p + val result = arrayListOf() + repeat(30) { + val inst = nextInstruction(pc) + result.add(AssemblyLine(pc, cpu.memory[pc], cpu.memory[pc + 1], inst.toString())) + pc += 2 + } + return result + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beust/chip8/Cpu.kt b/src/main/kotlin/com/beust/chip8/Cpu.kt new file mode 100644 index 0000000..af5fda4 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Cpu.kt @@ -0,0 +1,66 @@ +package com.beust.chip8 + +import java.util.* + +@Suppress("PropertyName") +class Cpu { + private val PC_START = 0x200 + + /** Program counter */ + var PC: Int = PC_START + + /** V registers */ + val V: IntArray = IntArray(16) + + /** I register */ + var I: Int = 0 + + /** Delay timer */ + var DT: Int = 0 + + /** Sound timer */ + var ST: Int = 0 + + /** Stack pointer */ + val SP = Stack() + + val memory = ByteArray(4096) + + /** The sprites are in the ROM at address 0x000 - 0x080 */ + private val FONT_SPRITES = arrayOf( + 0xf0, 0x90, 0x90, 0x90, 0xf0, // 8 + 0x20, 0x60, 0x20, 0x20, 0x70, // 9 + 0xf0, 0x10, 0xf0, 0x80, 0xf0, // 0 + 0xf0, 0x10, 0xf0, 0x10, 0xf0, // 1 + 0x90, 0x90, 0xf0, 0x10, 0x10, // A + 0xf0, 0x80, 0xf0, 0x10, 0xf0, // B + 0xf0, 0x80, 0xf0, 0x90, 0xf0, // 2 + 0xf0, 0x10, 0x20, 0x40, 0x40, // 3 + 0xf0, 0x90, 0xf0, 0x90, 0xf0, // C + 0xf0, 0x90, 0xf0, 0x10, 0xf0, // D + 0xf0, 0x90, 0xf0, 0x90, 0x90, // 4 + 0xe0, 0x90, 0xe0, 0x90, 0xe0, // 5 + 0xf0, 0x80, 0x80, 0x80, 0xf0, // E + 0xe0, 0x90, 0x90, 0x90, 0xe0, // F + 0xf0, 0x80, 0xf0, 0x80, 0xf0, // 6 + 0xf0, 0x80, 0xf0, 0x80, 0x80 // 7 + ) + + init { + // Move the sprites at adress 0x000 + FONT_SPRITES.forEachIndexed { index, v -> + memory[index] = v.toByte() + } + } + + fun loadRom(romBytes: ByteArray = ByteArray(4096)) { + // Load the rom at 0x200 + romBytes.copyInto(memory, 0x200) + } + + override fun toString(): String { + val vs = V.filter { it != 0 }.mapIndexed { ind, v -> "v$ind=$v" } + val sp = SP.map { it.h } + return "{Cpu pc=${Integer.toHexString(PC)} i=${I.h} dt=${DT.h} st=${ST.h} sp=${sp} $vs}" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beust/chip8/Display.kt b/src/main/kotlin/com/beust/chip8/Display.kt new file mode 100644 index 0000000..2956f6e --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Display.kt @@ -0,0 +1,119 @@ +@file:Suppress("unused") + +package com.beust.chip8 + +import javafx.beans.Observable +import javafx.scene.canvas.Canvas +import javafx.scene.control.TextArea +import javafx.scene.layout.Pane +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import javafx.scene.paint.Color + +/** + * Interface for rendering on a display. + */ +interface Display { + val pane: Pane + fun draw(frameBuffer: IntArray) + fun clear(frameBuffer: IntArray) + + companion object { + const val WIDTH: Int = 64 + const val HEIGHT: Int = 32 + } + + fun index(x: Int, y: Int) = x + WIDTH * y +} + +/** + * Display the frame buffer on a TextArea. + */ +class DisplayText: Display, Pane() { + override val pane = this + + override fun draw(frameBuffer: IntArray) { + val t = StringBuffer() + repeat(32) { y -> + repeat(64) { x -> + t.append(if (frameBuffer[index(x, y)] == 0) " " else "*") + } + t.append("\n") + } + textArea.text = t.toString() + } + + override fun clear(frameBuffer: IntArray) { + textArea.clear() + } + + private val textArea = TextArea() + + init { + prefWidth = 800.0 + prefHeight = 600.0 + textArea.prefWidth = prefWidth + textArea.prefHeight = prefHeight + VBox.setVgrow(textArea, Priority.ALWAYS) + children.add(textArea) + } +} + +/** + * Display the frame buffer on a canvas. + */ +class DisplayGraphics : Display, Pane() { + private var blockWidth = 12 + private var blockHeight = 12 + + private val SPACE = 0 + private val SCREEN = IntArray(Display.WIDTH * Display.HEIGHT) + private val canvas = Canvas((Display.WIDTH + SPACE) * blockWidth.toDouble(), + (Display.HEIGHT + SPACE) * blockHeight.toDouble()) + + init { + widthProperty().addListener { e: Observable? -> + canvas.width = width + blockWidth = (canvas.width / (Display.WIDTH + SPACE)).toInt() + } + heightProperty().addListener { e: Observable? -> + canvas.height = height + blockHeight = (canvas.height / (Display.HEIGHT + SPACE)).toInt() + } + children.add(canvas) + VBox.setVgrow(this, Priority.ALWAYS) + } + + override val pane = this + + override fun clear(frameBuffer: IntArray) { + frameBuffer.fill(0) + } + + override fun draw(frameBuffer: IntArray) { + frameBuffer.copyInto(SCREEN, 0) + requestLayout() + } + + @Override + override fun layoutChildren() { + // Clear the whole canvas + val g = canvas.graphicsContext2D + g.fill = Color.WHITE + g.fillRect(0.0, 0.0, (blockWidth + SPACE) * Display.WIDTH.toDouble(), + (blockHeight + SPACE) * Display.HEIGHT.toDouble()) + + // Only draw the black blocks + g.fill = Color.BLACK + repeat(Display.WIDTH) { x -> + repeat(Display.HEIGHT) { y -> + if (SCREEN[index(x, y)] == 1) { + val xx = (blockWidth + SPACE) * x.toDouble() + val yy = (blockHeight + SPACE) * y.toDouble() + g.fillRect(xx, yy, blockWidth.toDouble(), blockHeight.toDouble()) + } + } + } + } + +} diff --git a/src/main/kotlin/com/beust/chip8/FrameBuffer.kt b/src/main/kotlin/com/beust/chip8/FrameBuffer.kt new file mode 100644 index 0000000..a0e353e --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/FrameBuffer.kt @@ -0,0 +1,34 @@ +package com.beust.chip8 + +@Suppress("PrivatePropertyName") +class FrameBuffer { + companion object { + const val WIDTH = 64 + const val HEIGHT = 32 + } + + val frameBuffer = IntArray(WIDTH * HEIGHT) + + fun pixel(x: Int, y: Int): Int = frameBuffer[y * WIDTH + x] + + fun setPixel(x: Int, y: Int, v: Int) { + frameBuffer[y * WIDTH + x] = v + } + + fun clear() { + frameBuffer.fill(0) + } + /** + * Display the frame buffer in the console (debug). + */ + @Suppress("unused") + fun show() { + repeat(HEIGHT) { y -> + repeat(WIDTH) { x -> + val c = if (pixel(x, y) == 0 ) "." else "X" + print(c) + } + println("") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beust/chip8/Instruction.kt b/src/main/kotlin/com/beust/chip8/Instruction.kt new file mode 100644 index 0000000..ed3ab17 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Instruction.kt @@ -0,0 +1,101 @@ +package com.beust.chip8 + +fun log(s: String) { +// println(s) +} + +// Specs: http://www.cs.columbia.edu/~sedwards/classes/2016/4840-spring/designs/Chip8.pdf +// Roms can be found at https://github.com/loktar00/chip8/tree/master/roms + +interface KeyListener { + fun onKey(key: Int?) +} + +/** Format to hex with leading zeros */ +val Int.h get() = String.format("%02X", this) +val Byte.h get() = String.format("%02X", this) + +class Nibbles(private val b0: Int, val b1: Int, val b2: Int, val b3: Int) { + fun val2(a: Int, b: Int) = a.shl(4) + b + fun val3(a: Int, b: Int, c: Int) = a.shl(8) + b.shl(4) + c + + override fun toString() = (b0.shl(4) + b1).h + " " + (b2.shl(4) + b3).h +} + +class Instruction(private val computer: Computer, b0: Int, b1: Int, b2: Int, b3: Int) { + private val op: Op + + init { + val n = Nibbles(b0, b1, b2, b3) + val undef = Undef(computer, n) + op = when(b0) { + 0 -> { + if (b2 == 0xe && b3 == 0) Cls(computer, n) // 00E0 + else if (b2 == 0xe && b3 == 0xe) Ret(computer, n) // 00EE + else Sys(computer, n) // 0nnn + } + 1 -> Jmp(computer, n) // 1nnn + 2 -> Call(computer, n) // 2nnn + 3 -> Se(computer, n) // 3xkk + 4 -> Sne(computer, n) // 4xkk + 5 -> SeVxVy(computer, n) // 5xy0 + 6 -> LdV(computer, n) // 6xkk + 7 -> Add(computer, n) // 7xkk + 8 -> { + when (b3) { + 0 -> Ld(computer, n) // 8xy0 + 1 -> Or(computer, n) // 8xy1 + 2 -> And(computer, n) // 8xy2 + 3 -> XorVxVy(computer, n) // 8xy3 + 4 -> AddVxVy(computer, n) // 8xy4 + 5 -> SubVxVy(computer, n) // 8xy5 + 6 -> Shr(computer, n) // 8xy6 + 7 -> SubnVxVy(computer, n) // 8xy7 + 0xe -> Shl(computer, n) // 8xyE + else -> { undef } + } + } + 9 -> SneVxVy(computer, n) // 9xy0 + 0xa -> LdI(computer, n) // Annn + 0xb -> JumpV0(computer, n) // Bnnn + 0xc -> Rnd(computer, n) // Ckk + 0xd -> Draw(computer, n) // Dxyn + 0xe -> { + if (b2 == 9 && b3 == 0xe) SkipIfPressed(computer, n) // Ex9E + else if (b2 == 0xa && b3 == 0x1) SkipIfNotPressed(computer, n) // ExA1 + else { undef } + } + 0xf -> { + if (b2 == 0 && b3 == 7) LdVDt(computer, n) // Fx07 + else if (b2 == 0 && b3 == 0xa) LdVxK(computer, n) // F0A + else if (b2 == 1 && b3 == 5) LdDt(computer, n) // Fx15 + else if (b2 == 1 && b3 == 8) LdSt(computer, n) // Fx18 + else if (b2 == 1 && b3 == 0xe) AddI(computer, n) // Fx1E + else if (b2 == 2 && b3 == 9) LdF(computer, n) // Fx29 + else if (b2 == 3 && b3 == 3) LdB(computer, n) // Fx33 + else if (b2 == 5 && b3 == 5) LdIVx(computer, n) // Fx55 + else if (b2 == 6 && b3 == 5) LdVI(computer, n) // Fx65 + else undef + } + else -> undef + } + } + + override fun toString(): String { + return op.toString() + } + + fun run() { + val cpu = computer.cpu + log("${cpu.PC.h}: ${op.nib} - $this") + op.run() + log(" $cpu") + cpu.PC += 2 +// cpu.dtCounter++ +// if (cpu.dtCounter % 3000 == 0) { +// computer.hardware.show() +// cpu.DT-- +// } + } +} + diff --git a/src/main/kotlin/com/beust/chip8/Keyboard.kt b/src/main/kotlin/com/beust/chip8/Keyboard.kt new file mode 100644 index 0000000..fbd5434 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Keyboard.kt @@ -0,0 +1,22 @@ +package com.beust.chip8 + +class Keyboard { + private val keySemaphore = Object() + var key: Int? = null + set(v) { + synchronized(keySemaphore) { + field = v + keySemaphore.notify() + } + } + + fun waitForKeyPress(): Int { + return if (key != null) key!! + else { + synchronized(keySemaphore) { + keySemaphore.wait() + key!! + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beust/chip8/Main.kt b/src/main/kotlin/com/beust/chip8/Main.kt new file mode 100644 index 0000000..f6ce501 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Main.kt @@ -0,0 +1,235 @@ +package com.beust.chip8 + +import com.beust.jcommander.JCommander +import com.beust.jcommander.Parameter +import javafx.application.Application +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleStringProperty +import javafx.event.EventHandler +import javafx.fxml.FXMLLoader +import javafx.fxml.Initializable +import javafx.scene.Scene +import javafx.scene.control.Button +import javafx.scene.control.Label +import javafx.scene.control.ScrollPane +import javafx.scene.control.TextField +import javafx.scene.input.KeyCode +import javafx.scene.input.KeyEvent +import javafx.scene.layout.AnchorPane +import javafx.scene.layout.GridPane +import javafx.scene.layout.VBox +import javafx.stage.FileChooser +import javafx.stage.Stage +import java.io.File +import java.net.URL +import java.nio.file.Paths +import java.util.* +import kotlin.system.exitProcess + +class Controller : Initializable { + var currentRomPath: SimpleStringProperty? = null + var computer: Computer? = null + var pauseButton: Button? = null + override fun initialize(location: URL?, resources: ResourceBundle?) { + println("Initializing controller") + } +} + +class MyFxApp : Application() { + private val computer = Computer() + private var pauseButton: Button? = null + private var disassembly: GridPane? = null + private val isPaused = SimpleBooleanProperty().apply { + addListener { _, _, newVal -> computer.apply { + // Computer is paused + + // Update the pause button label + pauseButton?.text = if (newVal) "Resume" else "Pause" + + // Display the disassembly + updateDisassembly(disassembly!!, computer.disassemble()) + } + }} + + /** + * Display the disassembly. + */ + private fun updateDisassembly(disassembly: GridPane, listing: List) { + disassembly.children.clear() + listing.forEachIndexed() { row, al -> + var column = 0 + Label(al.counter.h).let { label -> + GridPane.setConstraints(label, column++, row) + disassembly.children.add(label) + } + Label(al.byte0.h + " " + al.byte1.h).let { label -> + GridPane.setConstraints(label, column++, row) + disassembly.children.add(label) + } + Label(al.name).let { label -> + GridPane.setConstraints(label, column++, row) + disassembly.children.add(label) + } + } + } + + private val currentRomName = SimpleStringProperty() + private val currentRomPath = SimpleStringProperty().apply { + addListener { _, _, newVal -> computer.apply { + // Restart computer + stop() + loadRom(File(newVal)) + + // Update the rom name + val start = newVal.lastIndexOf(File.separatorChar) + val end = newVal.lastIndexOf(".ch8") + val romName = if (end == -1) newVal.substring(start + 1) + else newVal.substring(start + 1, end) + currentRomName.set(romName) + }} + } + + override fun start(primaryStage: Stage) { + val keyListener = object: KeyListener { + override fun onKey(key: Int?) { + computer.keyboard.key = key + } + } + + primaryStage.title = "CHIP-8" + val url = this::class.java.classLoader.getResource("main.fxml") + val loader = FXMLLoader(url) + val res = url.openStream() + val root = loader.load(res) + + pauseButton = root.lookup("#pause") as Button + val scrollPane = root.lookup("#disassemblyScrollPane") as ScrollPane + disassembly = scrollPane.content as GridPane + val controller = loader.getController() + + controller.let { + it.currentRomPath = currentRomPath + it.computer = computer + it.pauseButton = pauseButton + } + + computer.listener = object: ComputerListener { + override fun onKey(key: Int?) { + computer.keyboard.key = key + } + + override fun onPause() { + isPaused.set(true) + } + + override fun onStart() { + isPaused.set(false) + } + } + + val scene = Scene(root) + val bp = root.lookup("#emulator") as VBox + bp.children.add(computer.display.pane) + primaryStage.scene = scene + primaryStage.show() + + // Load rom button + (root.lookup("#loadRom") as Button).setOnAction { + computer.pause() + val romFile = FileChooser().run { + initialDirectory = File("roms") + showOpenDialog(Stage()) + } + println("Picked file $romFile") + if (romFile != null) { + currentRomPath.set(romFile.absolutePath) + } + } + + // Pause button + pauseButton?.setOnAction { + if (computer.paused) { + computer.start() + } else { + computer.pause() + } + } + + // CPU clock button + with((root.lookup("#cpuClock") as TextField)) { + fun updateClock() { + computer.cpuClockHz = text.toLong() + } + text = computer.cpuClockHz.toString() + onAction = EventHandler { e -> + updateClock() + } + focusedProperty().addListener { _, _, onFocus -> + if (!onFocus) { + updateClock() + } + } + } + + // Current ROM + val currentRomLabel = root.lookup("#currentRom") as Label + currentRomLabel.textProperty().bind(currentRomName) + scene.setOnKeyPressed { event: KeyEvent -> + when(event.code) { + KeyCode.Q -> { + primaryStage.close() + computer.stop() + exitProcess(0) + } + KeyCode.P -> { + computer.pause() + } + else -> { + if (computer.paused) computer.start() + else { + try { + val key = Integer.parseInt(event.code.char, 16) + keyListener.onKey(key) + } catch(ex: NumberFormatException) { + println("Can't parse key " + event.code.char + ", ignoring") + } + } + } + } + } + scene.setOnKeyReleased { event: KeyEvent -> + keyListener.onKey(null) + } + + val spaceInvaders = Paths.get("roms", "Space Invaders [David Winter].ch8") + val breakout = Paths.get("roms", "Breakout [Carmelo Cortez, 1979].ch8") + val file3 = Paths.get("roms", "Tetris [Fran Dachille, 1991].ch8") + val file4 = Paths.get("roms", "Clock Program [Bill Fisher, 1981].ch8") + val file5 = Paths.get("roms", "IBM Logo.ch8") + val keypad = Paths.get("roms", "Keypad Test [Hap, 2006].ch8") + currentRomPath.set(spaceInvaders.toFile().absolutePath) +// val rom = file2.toFile() // if (arg.rom != null) File(arg.rom) else file3.toFile() +// chip8.run(rom) + } +} + +fun main(args: Array) { + if (args.isNotEmpty()) { + class Arg { + @Parameter(names = arrayOf("--rom")) + var rom: String? = null + @Parameter(names = arrayOf("--start")) + var start: String? = "200" + } + val arg = Arg() + JCommander.newBuilder().addObject(arg).args(args).build() + val computer = Computer(sound = false) + val start = Integer.parseInt(arg.start!!, 16) + val listing = computer.disassemble(start) + println("=== " + arg.rom + " PC=0x" + Integer.toHexString(start)) + listing.forEach { println(it) } + } else { + Application.launch(MyFxApp::class.java) + } +} + diff --git a/src/main/kotlin/com/beust/chip8/Ops.kt b/src/main/kotlin/com/beust/chip8/Ops.kt new file mode 100644 index 0000000..583f2f4 --- /dev/null +++ b/src/main/kotlin/com/beust/chip8/Ops.kt @@ -0,0 +1,411 @@ +package com.beust.chip8 + +import kotlin.random.Random + +/** + * An Op is made of two bytes which is sliced into four nibbles. These nibbles are then used + * to determine the op and its data. + */ +sealed class Op(val computer: Computer, val nib: Nibbles) { + protected val nnn by lazy { nib.val3(nib.b1, nib.b2, nib.b3) } + protected val x by lazy { nib.b1 } + protected val y by lazy { nib.b2 } + protected val n by lazy { nib.b3 } + protected val kk by lazy { nib.val2(nib.b2, nib.b3) } + protected val cpu = computer.cpu + + open fun run() { + TODO(" NOT IMPLEMENTED $nib") + println("") + } + + protected fun unsigned(n: Int): Int = (n + 0x100) and 0xff +} + +/** + * 00E0 + * Clear screen + */ +class Cls(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + computer.frameBuffer.clear() + computer.display.draw(computer.frameBuffer.frameBuffer) + } + override fun toString() = "CLS" +} + +/** + * 00EE + * Return + */ +class Ret(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.PC = cpu.SP.pop() } + override fun toString()= "RET" +} + +/** + * 0nnn + * SYS addr (not implemented) + */ +class Sys(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.PC -= 2 } // loop in place if we reach here + override fun toString()= "SYS ${nnn.h}" +} + +/** + * 1nnn + * Jump to address + */ +class Jmp(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.PC = nnn - 2 } + override fun toString() = "JMP ${nnn.h}" +} + +/** + * 2nnn + * Call, jump to subroutine + */ +class Call(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + cpu.SP.push(cpu.PC) + cpu.PC = nnn - 2 + } + override fun toString() = "CALL ${nnn.h}" +} + +/** + * 3xkk + * Skip next instruction if Vx = kk + */ +class Se(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { if (cpu.V[x] == kk) cpu.PC += 2 } + override fun toString() = "SE V$x, $kk" +} + +/** + * 4xkk + * Skip next instruction if Vx != kk + */ +class Sne(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { if (cpu.V[x] != kk) cpu.PC += 2 } + override fun toString() = "SNE V$x, $kk" +} + +/** + * 5xy0 + * Skip next instruction if Vx = Vy + */ +class SeVxVy(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { if (cpu.V[x] == cpu.V[y]) cpu.PC += 2 } + override fun toString() = "SE V$x, V$y" +} + +/** + * 6xkk + * Set Vx = kk. + */ +class LdV(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { computer.cpu.V[x] = kk } + override fun toString() = "LD V$x, $kk" +} + +/** + * 7xkk + * Set Vx = Vx + kk + */ +class Add(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = unsigned(cpu.V[x] + kk) } + override fun toString() = "ADD V$x, $kk" +} + +/** + * 8xy0 + * Set Vx = Vy + */ +class Ld(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = cpu.V[y] } + override fun toString() = "LD V$x, V$y" +} + +/** + * 8xy1 + * Set Vx = Vx OR Vy. + */ +class Or(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = cpu.V[x] or cpu.V[y] } + override fun toString() = "OR V$x, V$y" +} + +/** + * 8xy2 + * Set Vx = Vx AND Vy. + */ +class And(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = cpu.V[x] and cpu.V[y] } + override fun toString() = "AND V$x, V$y" +} + +/** + * 8xy3 + * Set Vx = Vx XOR Vy + */ +class XorVxVy(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = cpu.V[x] xor cpu.V[y] } + override fun toString() = "XOR V$x, V$y" +} + +/** + * 8xy4 + * Set Vx = Vx + Vy, set VF = carry. + */ +class AddVxVy(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + val newValue = cpu.V[x] + cpu.V[y] + cpu.V[0xf] = if (newValue > 0xff) 1 else 0 + cpu.V[x] = unsigned(newValue) + } + override fun toString() = "ADD V$x, V$y" +} + +/** + * 8xy5 + * Set Vx = Vx - Vy, set VF = NOT borrow. + */ +class SubVxVy(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + val newValue = cpu.V[x] - cpu.V[y] + cpu.V[0xf] = if (newValue < 0) 0 else 1 + cpu.V[x] = unsigned(newValue) + } + override fun toString() = "SUB V$x, V$y" +} + +/** + * 8xy6 + * Set Vx = Vx SHR 1. I + */ +class Shr(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + cpu.V[0xf] = cpu.V[x] % 2 + cpu.V[x] = cpu.V[x] shr 1 + } + override fun toString() = "SHR V$x" +} + +/** + * 8xy7 + * Set Vx = Vy - Vx, set VF = NOT borrow. + */ +class SubnVxVy(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + val newValue = cpu.V[y] - cpu.V[x] + cpu.V[0xf] = if (newValue < 0) 0 else 1 + cpu.V[x] = unsigned(newValue) + } + override fun toString() = "SBUN V$x,V$y" +} + + +/** + * 8xyE + * Set Vx = Vx SHL 1. I + */ +class Shl(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + cpu.V[0xf] = cpu.V[x] % 2 + cpu.V[x] = cpu.V[x] shl 1 + } + override fun toString() = "SHL V$x" +} + +/** + * 9xy0 + * Skip next instruction if Vx != Vy.@*/ +class SneVxVy(c: Computer, n: Nibbles): SkipBase(c, n) { + override fun condition(key: Int?, expected: Int) = cpu.V[x] != cpu.V[y] + override fun toString() = "SNE V$x, V$y" +} + +/** + * Annn + * Set I = nnn. + */ +class LdI(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.I = nnn } + override fun toString() = "LD I, ${nnn.h}" +} + +/** + * Bnnn + * Jump to location nnn + V0. The program counter is set to nnn plus the value of V0. + */ +class JumpV0(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.PC = nnn + cpu.V[0] } + override fun toString() = "JUMP V0" +} + +/** + * Cxkk + * Set Vx = random byte AND kk. + */ +class Rnd(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = Random.nextInt() and kk } + override fun toString() = "RND V[$x], $kk" +} + +/** + * Dxyn + * Display n-byte sprite starting at memory location I at (Vx, Vy), set VF = collision. + */ +class Draw(c: Computer, nib: Nibbles): Op(c, nib) { + override fun run() { + cpu.V[0xf] = 0 + repeat(n) { byte -> + val yy = (cpu.V[y] + byte) % FrameBuffer.HEIGHT + val sprite = cpu.memory[cpu.I + byte].toInt() + repeat(8) { bit -> + val xx = (cpu.V[x] + bit) % FrameBuffer.WIDTH + val color = (sprite shr (7 - bit)) and 1 + val oldValue = computer.frameBuffer.pixel(xx, yy) + val newValue = oldValue xor color + cpu.V[0xf] = cpu.V[0xf] or (color and oldValue) + computer.frameBuffer.setPixel(xx, yy, newValue) + } + } + computer.display.draw(computer.frameBuffer.frameBuffer) + } + override fun toString() = "DRAW V$x, V$y, ${n.h}" +} + +/** + * Base class for the two ops that deal with key presses: SKP and SKNP. + */ +abstract class SkipBase(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + val key = computer.keyboard.key + val expected = computer.cpu.V[x] + if (condition(key, expected)) { + computer.cpu.PC += 2 + } + } + + abstract fun condition(key: Int?, expected: Int): Boolean +} + +/** + * Ex9E + * Skip if key is pressed + */ +class SkipIfPressed(c: Computer, n: Nibbles): SkipBase(c, n) { + override fun condition(key: Int?, expected: Int) = key != null && key == expected + override fun toString() = "SKP V$x" +} + +/** + * ExA1 + * Skip if key is not pressed + */ +class SkipIfNotPressed(c: Computer, n: Nibbles): SkipBase(c, n) { + override fun condition(key: Int?, expected: Int) = key == null || key != expected + override fun toString() = "SKNP V$x" +} + +/** + * Fx07 + * Set Vx = delay timer value. + */ +class LdVDt(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.V[x] = cpu.DT } + override fun toString() = "LD V$x, DT" +} + +/** + * Fx0a + * Wait for a key press, store the value of the key in Vx + */ +class LdVxK(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { computer.cpu.V[x] = computer.keyboard.waitForKeyPress() } + override fun toString() = "LD V$x, Keyboard" +} + +/** + * Fx15 + * Set delay timer = Vx. + */ +class LdDt(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.DT = cpu.V[x] } + override fun toString() = "LD DT, V$x" +} + +/** + * Fx18 + * Set sound timer = Vx. + */ +class LdSt(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.ST = cpu.V[x] } + override fun toString() = "LD ST, V$x" +} + +/** + * Fx1E + * Set I = I + Vx. + */ +class AddI(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.I += cpu.V[x] } + override fun toString() = "ADD I, V$x" +} + +/** + * Fx29 + * Set I = location of sprite for digit Vx. + */ +class LdF(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { cpu.I = cpu.V[x] * 5 } + override fun toString() = "LD F, V$x" +} + +/** + * Fx33 + * Store BCD representation of Vx in memory locations I, I+1, and I+2. + */ +class LdB(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + val v = cpu.V[x] + cpu.memory[cpu.I] = (v / 100).toByte() + cpu.memory[cpu.I + 1] = ((v % 100) / 10).toByte() + cpu.memory[cpu.I + 2] = (v % 10).toByte() + } + override fun toString() = "LD B, V$x" +} + +/** + * Fx55 + * Stores V0 to VX in memory starting at address I. I is then set to I + x + 1. + */ +class LdIVx(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + repeat(x + 1) { + cpu.memory[cpu.I + it] = cpu.V[it].toByte() + } + cpu.I += x + 1 + } + override fun toString() = "LD [I], V$x" +} + +/** + * Fx65 + * Fills V0 to VX with values from memory starting at address I. + */ +class LdVI(c: Computer, n: Nibbles): Op(c, n) { + override fun run() { + repeat(x + 1) { + cpu.V[it] = unsigned(cpu.memory[cpu.I + it].toInt()) + } + cpu.I += x + 1 + } + override fun toString() = "LD V$x, [I]" +} + +class Undef(c: Computer, nib: Nibbles): Op(c, nib) { + override fun toString() = "Undef($n)" +} diff --git a/src/main/resources/main.fxml b/src/main/resources/main.fxml new file mode 100644 index 0000000..f4c3db9 --- /dev/null +++ b/src/main/resources/main.fxml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/sound.wav b/src/main/resources/sound.wav new file mode 100644 index 0000000..0222b03 Binary files /dev/null and b/src/main/resources/sound.wav differ diff --git a/src/main/resources/style.css b/src/main/resources/style.css new file mode 100644 index 0000000..d9cd8c8 --- /dev/null +++ b/src/main/resources/style.css @@ -0,0 +1,40 @@ +AnchorPane { + -fx-background-color: #43e6ef; +} + +.left-side { + -fx-font-size: 20px; + -fx-font-weight: bold; + -fx-text-fill: #333333; +} + +.rom-name { + -fx-font-size: 20px; + -fx-font-weight: bold; +} + +.right-side { +} + +.emulator { + -fx-padding: 20px; + -fx-font-family: Consolas, "Courier New"; +} + +.buttons { + -fx-fill-width: true; +} + +ScrollPane, .disassembly { + -fx-background-color: #000000; +} + +Button, GridPane, Label { + -fx-max-width: 10000; + -fx-max-height: 10000; +} + +.disassembly > Label { + -fx-text-fill: green; + -fx-font-weight: bold; +} \ No newline at end of file diff --git a/src/test/kotlin/com/beust/chip8/OpTest.kt b/src/test/kotlin/com/beust/chip8/OpTest.kt new file mode 100644 index 0000000..3c3d11e --- /dev/null +++ b/src/test/kotlin/com/beust/chip8/OpTest.kt @@ -0,0 +1,54 @@ +package com.beust.chip8 + +import org.assertj.core.api.Assertions.assertThat +import org.testng.annotations.Test + +@Test +class OpTest { + private fun readPixels(c: Computer, w: Int, h: Int): List { + val result = arrayListOf() + repeat(w) { y -> + repeat(h) { x -> + result.add(c.frameBuffer.pixel(x, y)) + } + } + return result + } + + fun draw() { + val cpu = Cpu().apply { + I = 0x200 + } + val c = Computer(cpu = cpu, sound = false) + val bytes = listOf(0xf0, 0x90, 0xf0, 0x90, 0xf0) + val memory = c.cpu.memory + bytes.forEachIndexed { + index, b -> memory[index] = b.toByte() + } + + val op = Draw(c, Nibbles(0xd, 0, 0, 5)) + op.run() + val p = c.frameBuffer.pixel(3, 1) + + val expected = listOf( + 1, 1, 1, 1, 0, 0, 0, 0, + 1, 0, 0, 1, 0, 0, 0, 0, + 1, 1, 1, 1, 0, 0, 0, 0, + 1, 0, 0, 1, 0, 0, 0, 0, + 1, 1, 1, 1, 0, 0, 0, 0) + val result = readPixels(c, 5, 8) + assertThat(result).isEqualTo(expected) + assertThat(cpu.V[0xf]).isEqualTo(0) + op.run() + val expected2 = listOf( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + + assertThat(readPixels(c, 5, 8)).isEqualTo(expected2) + assertThat(cpu.V[0xf]).isEqualTo(1) + println("") + } +} \ No newline at end of file