initial commit

This commit is contained in:
Martin Vrhovšek 2025-04-25 21:06:33 +02:00
commit 3652df31f4
4 changed files with 625 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/zig-out/
/.zig-cache/
/.idea/

128
build.zig Normal file
View File

@ -0,0 +1,128 @@
const std = @import("std");
// Although this function looks imperative, note that its job is to
// declaratively construct a build graph that will be executed by an external
// runner.
pub fn build(b: *std.Build) void {
// Standard target options allows the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
const raylib_dep = b.dependency("raylib_zig", .{
.target = target,
.optimize = optimize,
.shared = true,
});
const raylib = raylib_dep.module("raylib");
const raylib_artifact = raylib_dep.artifact("raylib");
// This creates a "module", which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries.
// Every executable or library we compile will be based on one or more modules.
// const lib_mod = b.createModule(.{
// // `root_source_file` is the Zig "entry point" of the module. If a module
// // only contains e.g. external object files, you can make this `null`.
// // In this case the main source file is merely a path, however, in more
// // complicated build scripts, this could be a generated file.
// .root_source_file = b.path("src/root.zig"),
// .target = target,
// .optimize = optimize,
// });
// We will also create a module for our other entry point, 'main.zig'.
const exe_mod = b.createModule(.{
// `root_source_file` is the Zig "entry point" of the module. If a module
// only contains e.g. external object files, you can make this `null`.
// In this case the main source file is merely a path, however, in more
// complicated build scripts, this could be a generated file.
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Modules can depend on one another using the `std.Build.Module.addImport` function.
// This is what allows Zig source code to use `@import("foo")` where 'foo' is not a
// file path. In this case, we set up `exe_mod` to import `lib_mod`.
// exe_mod.addImport("chip8_emulator_lib", lib_mod);
// Now, we will create a static library based on the module we created above.
// This creates a `std.Build.Step.Compile`, which is the build step responsible
// for actually invoking the compiler.
// const lib = b.addLibrary(.{
// .linkage = .static,
// .name = "chip8_emulator",
// .root_module = lib_mod,
// });
// This declares intent for the library to be installed into the standard
// location when the user invokes the "install" step (the default step when
// running `zig build`).
// b.installArtifact(lib);
// This creates another `std.Build.Step.Compile`, but this one builds an executable
// rather than a static library.
const exe = b.addExecutable(.{
.name = "chip8_emulator",
.root_module = exe_mod,
});
// This declares intent for the executable to be installed into the
// standard location when the user invokes the "install" step (the default
// step when running `zig build`).
b.installArtifact(exe);
exe.linkLibrary(raylib_artifact);
exe.root_module.addImport("raylib", raylib);
// This *creates* a Run step in the build graph, to be executed when another
// step is evaluated that depends on it. The next line below will establish
// such a dependency.
const run_cmd = b.addRunArtifact(exe);
// By making the run step depend on the install step, it will be run from the
// installation directory rather than directly from within the cache directory.
// This is not necessary, however, if the application depends on other installed
// files, this ensures they will be present and in the expected location.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
}
// This creates a build step. It will be visible in the `zig build --help` menu,
// and can be selected like this: `zig build run`
// This will evaluate the `run` step rather than the default, which is "install".
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// Creates a step for unit testing. This only builds the test executable
// but does not run it.
// const lib_unit_tests = b.addTest(.{
// .root_module = lib_mod,
// });
// const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
const exe_unit_tests = b.addTest(.{
.root_module = exe_mod,
});
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
// Similar to creating the run step earlier, this exposes a `test` step to
// the `zig build --help` menu, providing a way for the user to request
// running the unit tests.
const test_step = b.step("test", "Run unit tests");
// test_step.dependOn(&run_lib_unit_tests.step);
test_step.dependOn(&run_exe_unit_tests.step);
}

52
build.zig.zon Normal file
View File

@ -0,0 +1,52 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .chip8_emulator,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0x11aa205190765268, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.14.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
.raylib_zig = .{
.url = "git+https://github.com/Not-Nik/raylib-zig?ref=devel#0de5f8aed0565f2dbd8bc6851499c85df9e73534",
.hash = "raylib_zig-5.6.0-dev-KE8REGMrBQCs5X69dptNzjw9Z7MYM1fgdaKrnuKf8zyr",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

442
src/main.zig Normal file
View File

@ -0,0 +1,442 @@
const std = @import("std");
const rl = @import("raylib");
const WIDTH = 64;
const HEIGHT = 32;
const SCALE = 15;
const FREQUENCY = 30;
const MEMORY_SIZE = 4096;
const FONT_START = 0x050;
const PROGRAM_START = 0x200;
const PROGRAM_ALLOC = MEMORY_SIZE - PROGRAM_START - 1;
const Emulator = struct {
screen: [HEIGHT][WIDTH]u8 = clearScreen(),
memory: [MEMORY_SIZE]u8 = std.mem.zeroes([MEMORY_SIZE]u8),
stack: std.ArrayList(u16),
v: [16]u8,
i: u16,
key_pressed: u5, // 4 bits for tracking the key pressed, 1 bit for tracking if the key IS pressed
pc: u16,
delay_timer: u8,
sound_timer: u8,
legacy: bool,
legacy_memory: bool,
add_index_exception: bool,
prng: std.Random.Xoshiro256,
pub fn init(allocator: std.mem.Allocator) !Emulator {
return Emulator{ .screen = clearScreen(), .memory = try initMemory(allocator), .stack = std.ArrayList(u16).init(allocator), .v = std.mem.zeroes([16]u8), .i = 0, .key_pressed = 0, .pc = PROGRAM_START, .delay_timer = FREQUENCY, .sound_timer = 0, .legacy = true, .legacy_memory = false, .add_index_exception = false, .prng = std.Random.DefaultPrng.init(blk: {
var seed: u64 = undefined;
try std.posix.getrandom(std.mem.asBytes(&seed));
break :blk seed;
}) };
}
pub fn destroy(self: *Emulator) void {
self.stack.deinit();
}
pub fn instructionCycle(self: *Emulator) !void {
const instruction: u16 = @as(u16, self.memory[self.pc]) << 8 | @as(u16, self.memory[self.pc + 1]);
var bd: [10]u8 = undefined;
_ = try std.fmt.bufPrint(&bd, "{X:0>4}", .{instruction});
std.debug.print("Instruction: {s}\n\n", .{bd});
const first: u8 = @truncate(instruction >> 12);
const x: u8 = @truncate((instruction >> 8) & 0xF);
const y: u8 = @truncate((instruction >> 4) & 0xF);
const n: u8 = @truncate(instruction & 0xF);
const nn: u8 = @truncate(instruction & 0xFF);
const nnn: u16 = instruction & 0xFFF;
switch (first) {
0x0 => {
if (instruction == 0x00E0) {
// clear screen
self.screen = clearScreen();
} else if (instruction == 0x00EE) {
// pop the pc from stack, unless the binary is shit there should be no reason to unwrap optional
self.pc = self.stack.pop().?;
}
},
0x1 => {
// jump to specified address (if old address is identical to new one, return)
if (self.pc == nnn) return;
self.pc = nnn;
return;
},
0x2 => {
// pushes current pc onto stack and sets nnn as pc
try self.stack.append(self.pc);
self.pc = nnn;
},
0x3 => {
// skips an instruction ahead if register x equals to nn
if (self.v[x] == nn)
self.nextInstruction();
},
0x4 => {
// skips an instruction ahead if register x does not equal to nn
if (self.v[x] != nn)
self.nextInstruction();
},
0x5 => {
// skips an instruction if register x equals to register y
if (n == 0x0) { // we check only last 4 digits are zero
if (self.v[x] == self.v[y])
self.nextInstruction();
}
},
0x6 => {
// sets register x to nn
self.v[x] = nn;
},
0x7 => {
// adds nn to register x, doesn't care about overflow
const ov = @addWithOverflow(self.v[x], nn);
self.v[x] = ov[0];
},
0x8 => {
switch (n) {
0x0 => {
self.v[x] = self.v[y];
},
0x1 => {
self.v[x] |= self.v[y];
},
0x2 => {
self.v[x] &= self.v[y];
},
0x3 => {
self.v[x] ^= self.v[y];
},
0x4 => {
// adds and stores overflow bit in 0xF register
const ov = @addWithOverflow(self.v[x], self.v[y]);
self.v[0xF] = ov[1];
self.v[x] = ov[0];
},
0x5 => {
// subtracts and inverts the "usual" overflow
const ov = @subWithOverflow(self.v[x], self.v[y]);
self.v[x] = ov[0];
self.v[0xF] = ov[1] ^ 1;
},
0x6 => {
// shifting v[x] right and saving the dropped bit into V[0xF]
// if (self.legacy) {
// self.v[x] = self.v[y];
// }
self.v[0xF] = self.v[x] & 0x1;
self.v[x] >>= 1;
},
0x7 => {
// subtracts and inverts the "usual" overflow
const ov = @subWithOverflow(self.v[y], self.v[x]);
self.v[x] = ov[0];
self.v[0xF] = ov[1] ^ 1;
},
0xE => {
// shifting v[x] left and saving the dropped bit into V[0xF]
// if (self.legacy) {
// self.v[x] = self.v[y];
// }
self.v[0xF] = (self.v[x] & 0x80) >> 7;
self.v[x] <<= 1;
},
else => {},
}
},
0x9 => {
// skips an instruction if register x equals to register y
if (n == 0x0) { // we check only last 4 digits are zero
if (self.v[x] != self.v[y])
self.nextInstruction();
}
},
0xA => {
// sets index register to nnn
self.i = nnn;
},
0xB => {
// jumps to different address depending on interpreter design, legacy is default
const addr: u16 = if (self.legacy) 0x0 else x;
self.pc = nnn + addr;
return; // we do not want any further jumps
},
0xC => {
// gen random number AND with NN => v[x]
const rand = self.prng.random();
self.v[x] = rand.int(u8) & nn;
},
0xD => {
// draws the screen matrix
var x_coord = self.v[x] % WIDTH;
var y_coord = self.v[y] % HEIGHT;
self.v[0xF] = 0;
for (0..n) |it| {
const byte = self.memory[self.i + it];
var b: i4 = 7;
while (b >= 0) : (b -= 1) {
const bit_pos: u3 = @intCast(b); // loop condition ensures the number never reaches negative
const bit = (byte & (@as(u8, 1) << bit_pos));
if (bit != 0) {
const old_pixel = self.screen[y_coord][x_coord];
self.screen[y_coord][x_coord] ^= 1;
if (old_pixel != 0 and self.screen[y_coord][x_coord] == 0)
self.v[0xF] = 1;
}
x_coord += 1;
if (x_coord >= WIDTH) break;
}
x_coord = self.v[x] % WIDTH;
y_coord += 1;
if (y_coord >= HEIGHT) break;
}
},
0xE => {
// if key is pressed (5th bit)
if (self.key_pressed & 0x10 != 0) {
const key: u4 = @truncate(self.key_pressed & 0xF);
// we check for instruction and if the v[x] matches last 4 bits (1xxxx)
if (nn == 0x9E and self.v[x] == key) {
self.nextInstruction();
} else if (nn == 0xA1 and self.v[x] != key) {
self.nextInstruction();
}
}
},
0xF => {
switch (nn) {
// set delay
0x07 => self.v[x] = self.delay_timer,
0x15 => {
// set delay, adjust fps
self.delay_timer = self.v[x];
rl.setTargetFPS(self.delay_timer);
},
// set sound timer
0x18 => self.sound_timer = self.v[x],
0x1E => {
// add v[x] to index register
const ov = @addWithOverflow(self.i, self.v[x]);
self.i = ov[0];
if (self.add_index_exception)
self.v[0xF] = ov[1];
},
0x0A => {
// this instructions blocks the flow of the program until a key is pressed
if (self.key_pressed & 0x10 == 0) return;
self.v[x] = self.key_pressed & 0xF;
},
0x29 => {
// point to the starting font location of hexadecimal character
self.i = FONT_START + 5 * self.v[x];
},
0x33 => {
// split instruction into digits and save them at apppropriate address
var num = self.v[x];
var it: i3 = 2;
while (it >= 0) : (it -= 1) {
self.memory[self.i + @as(u16, @intCast(it))] = num % 10;
num /= 10;
}
},
0x55 => {
// storing registers into memory
var it: usize = 0;
while (it <= x) : (it += 1) {
self.memory[self.i + it] = self.v[it];
}
if (self.legacy_memory)
self.i = @intCast(it);
},
0x65 => {
// loading registers from the memory
var it: usize = 0;
while (it <= x) : (it += 1) {
self.v[it] = self.memory[self.i + it];
}
if (self.legacy_memory)
self.i = @intCast(it);
},
else => {},
}
},
else => {},
}
self.nextInstruction();
}
fn nextInstruction(self: *Emulator) void {
self.pc += 2;
}
fn getInput(self: *Emulator) void {
var key: u4 = 0x0;
switch (rl.getKeyPressed()) {
.one => key = 0x1,
.two => key = 0x2,
.three => key = 0x3,
.four => key = 0xC,
.q => key = 0x4,
.w => key = 0x5,
.e => key = 0x6,
.r => key = 0xD,
.a => key = 0x7,
.s => key = 0x8,
.d => key = 0x9,
.f => key = 0xE,
.z => key = 0xA,
.x => key = 0x0,
.c => key = 0xB,
.v => key = 0xF,
else => {
self.key_pressed = 0x0;
return;
},
}
self.key_pressed = 0x10 | @as(u5, key);
}
fn initMemory(allocator: std.mem.Allocator) ![MEMORY_SIZE]u8 {
var mem = std.mem.zeroes([MEMORY_SIZE]u8);
const font_data = [_]u8{
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80, // F
};
// load font data into the memory
for (font_data, FONT_START..) |value, i| {
mem[i] = value;
}
// load program into the memory
const instructions = try readFile(allocator);
// load font data into the memory
for (instructions, PROGRAM_START..) |value, i| {
mem[i] = value;
}
return mem;
}
pub fn draw(self: *Emulator) void {
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(.black);
for (0..self.screen.len) |y| {
for (0..self.screen[y].len) |x| {
if (self.screen[y][x] == 0) continue;
rl.drawRectangle(@as(i32, @intCast(x)) * SCALE, @as(i32, @intCast(y)) * SCALE, SCALE, SCALE, .white);
}
}
}
pub fn print_instructions(self: *Emulator) void {
var i: usize = PROGRAM_START;
while (i < self.memory.len) : (i += 2) {
if (i != 0 and i % 8 == 0) {
std.debug.print("\n", .{});
}
if (self.memory[i] == 0xAA and self.memory[i + 1] == 0xAA) break;
std.debug.print("{X:0>2}{X:0>2} ", .{ self.memory[i], self.memory[i + 1] });
}
std.debug.print("\n", .{});
}
fn clearScreen() [HEIGHT][WIDTH]u8 {
return std.mem.zeroes([HEIGHT][WIDTH]u8);
}
};
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var chip8 = try Emulator.init(allocator);
defer chip8.destroy();
chip8.print_instructions();
rl.initWindow(WIDTH * SCALE, HEIGHT * SCALE, "CHIP-8 Emulator");
defer rl.closeWindow();
rl.setTargetFPS(chip8.delay_timer);
var cycle_count: usize = 0;
while (!rl.windowShouldClose()) {
cycle_count += 1;
std.debug.print("Cycle count: {d}\n", .{cycle_count});
chip8.getInput();
try chip8.instructionCycle();
chip8.draw();
// todo sound timer decrease i guess
}
}
fn readFile(allocator: std.mem.Allocator) ![PROGRAM_ALLOC]u8 {
const fs = std.fs;
const filename = try getArgs(allocator);
defer allocator.free(filename);
var buffer: [PROGRAM_ALLOC]u8 = undefined;
const file = try fs.cwd().openFile(filename, .{ .mode = .read_only });
defer file.close();
const bytes_read = try file.readAll(&buffer);
if (PROGRAM_START + bytes_read > MEMORY_SIZE) {
std.debug.print("[ERROR] Instruction set size exceeds memory size ({d})!", .{MEMORY_SIZE});
return error.MemorySizeExceeded;
}
return buffer;
}
fn getArgs(allocator: std.mem.Allocator) ![]const u8 {
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) return error.InsufficientArgs;
return allocator.dupe(u8, args[1]);
}