From a826c724ee6a510fc933f3b6e7c7d3cb04995e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E6=96=87=E6=AD=A6?= Date: Sat, 19 Jul 2025 11:23:53 +0800 Subject: [PATCH] programs: Add Uxn emulator. --- data/common/File Managers/icons.ini | 3 +- data/common/File Managers/kfar.ini | 1 + data/common/settings/assoc.ini | 1 + programs/emulator/uxn/.gitignore | 6 + programs/emulator/uxn/README | 17 + programs/emulator/uxn/build.zig | 36 ++ programs/emulator/uxn/build.zig.zon | 21 + programs/emulator/uxn/src/kolibri.zig | 590 ++++++++++++++++++++++++++ programs/emulator/uxn/src/linker.ld | 42 ++ programs/emulator/uxn/src/main.zig | 417 ++++++++++++++++++ 10 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 programs/emulator/uxn/.gitignore create mode 100644 programs/emulator/uxn/README create mode 100644 programs/emulator/uxn/build.zig create mode 100644 programs/emulator/uxn/build.zig.zon create mode 100644 programs/emulator/uxn/src/kolibri.zig create mode 100644 programs/emulator/uxn/src/linker.ld create mode 100644 programs/emulator/uxn/src/main.zig diff --git a/data/common/File Managers/icons.ini b/data/common/File Managers/icons.ini index e211975f0..00acb385e 100644 --- a/data/common/File Managers/icons.ini +++ b/data/common/File Managers/icons.ini @@ -162,6 +162,7 @@ min=23 nes=23 sna=23 snes=23 +rom=23 bat=24 sh=24 sys=25 @@ -270,4 +271,4 @@ t=36 h=50 b=50 u=50 -c=50 \ No newline at end of file +c=50 diff --git a/data/common/File Managers/kfar.ini b/data/common/File Managers/kfar.ini index f9a7e8e0d..a523ad4bf 100644 --- a/data/common/File Managers/kfar.ini +++ b/data/common/File Managers/kfar.ini @@ -68,6 +68,7 @@ sna=/kolibrios/emul/e80/e80 gb=/kolibrios/emul/gameboy gbc=/kolibrios/emul/gameboy min=/kolibrios/emul/pokemini +rom=/kolibrios/emul/uxn nc=/kolibrios/utils/cnc_editor/cnc_editor kf=/sys/KF_VIEW csv=/sys/table diff --git a/data/common/settings/assoc.ini b/data/common/settings/assoc.ini index ba2bab92f..f8bf1f9bf 100644 --- a/data/common/settings/assoc.ini +++ b/data/common/settings/assoc.ini @@ -192,6 +192,7 @@ nc=/kolibrios/utils/cnc_editor/cnc_editor ch8=/kolibrios/emul/chip8/chip8 md=/kolibrios/emul/dgen/dgen gen=/kolibrios/emul/dgen/dgen +rom=/kolibrios/emul/uxn zip=$Unz 7z=$Unz diff --git a/programs/emulator/uxn/.gitignore b/programs/emulator/uxn/.gitignore new file mode 100644 index 000000000..bebe790e0 --- /dev/null +++ b/programs/emulator/uxn/.gitignore @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025 iyzsong@envs.net +# +# SPDX-License-Identifier: MPL-2.0 + +zig-out +.zig-cache diff --git a/programs/emulator/uxn/README b/programs/emulator/uxn/README new file mode 100644 index 000000000..4b95d1408 --- /dev/null +++ b/programs/emulator/uxn/README @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 iyzsong@envs.net +// +// SPDX-License-Identifier: MPL-2.0 + +Uxn/Varvara emulator for Kolibri OS + +Based on https://github.com/chmod222/zuxn + +compile: zig build --release=fast +result: zig-out/bin/uxn +run: uxn SOME.rom +control: + Up/Down/Left/Right + Ctrl/Shift/Alt/Home + F1: change scale (1x, 2x, 3x) + +TODO: file/directory stat, audio latency, open dialog? diff --git a/programs/emulator/uxn/build.zig b/programs/emulator/uxn/build.zig new file mode 100644 index 000000000..e55e79d70 --- /dev/null +++ b/programs/emulator/uxn/build.zig @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 iyzsong@envs.net +// +// SPDX-License-Identifier: MPL-2.0 + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target_query = std.Target.Query{ + .cpu_arch = std.Target.Cpu.Arch.x86, + .os_tag = std.Target.Os.Tag.freestanding, + .abi = std.Target.Abi.none, + .cpu_model = std.Target.Query.CpuModel{ .explicit = &std.Target.x86.cpu.i586 }, + }; + const target = b.resolveTargetQuery(target_query); + const optimize = b.standardOptimizeOption(.{}); + const zuxn = b.dependency("zuxn", .{ + .target = target, + .optimize = optimize, + }); + const elf = b.addExecutable(.{ + .name = "uxn.elf", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .unwind_tables = .none, + }); + elf.root_module.addImport("uxn-core", zuxn.module("uxn-core")); + elf.root_module.addImport("uxn-varvara", zuxn.module("uxn-varvara")); + elf.setLinkerScript(b.path("src/linker.ld")); + const bin = elf.addObjCopy(.{ + .format = .bin, + }); + const install_bin = b.addInstallBinFile(bin.getOutput(), "uxn"); + b.getInstallStep().dependOn(&install_bin.step); + b.installArtifact(elf); +} diff --git a/programs/emulator/uxn/build.zig.zon b/programs/emulator/uxn/build.zig.zon new file mode 100644 index 000000000..6b7d2427f --- /dev/null +++ b/programs/emulator/uxn/build.zig.zon @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025 iyzsong@envs.net +// +// SPDX-License-Identifier: MPL-2.0 + +.{ + .name = .uxn_kolibrios, + .version = "0.0.0", + .fingerprint = 0x3aef20f25c0a0218, + .minimum_zig_version = "0.14.0", + .dependencies = .{ + .zuxn = .{ + .url = "git+https://github.com/chmod222/zuxn.git#fc3a76724fa87dd08039438b56fc326ac3d51e4d", + .hash = "zuxn-0.0.1-XnoOpbqsAgD-fU6rv_AoLffA1utIzXuae2cmnHj6SzE6", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/programs/emulator/uxn/src/kolibri.zig b/programs/emulator/uxn/src/kolibri.zig new file mode 100644 index 000000000..461749b3f --- /dev/null +++ b/programs/emulator/uxn/src/kolibri.zig @@ -0,0 +1,590 @@ +// SPDX-FileCopyrightText: 2025 iyzsong@envs.net +// +// SPDX-License-Identifier: MPL-2.0 + +const std = @import("std"); + +pub const SYS = enum(i32) { + terminate_process = -1, + create_window = 0, + put_pixel = 1, + get_key = 2, + get_sys_time = 3, + draw_text = 4, + sleep = 5, + put_image = 7, + define_button = 8, + thread_info = 9, + wait_event = 10, + check_event = 11, + redraw = 12, + draw_rect = 13, + get_screen_size = 14, + get_button = 17, + system = 18, + screen_put_image = 25, + system_get = 26, + get_sys_date = 29, + mouse_get = 37, + set_events_mask = 40, + style_settings = 48, + create_thread = 51, + board = 63, + keyboard = 66, + change_window = 67, + sys_misc = 68, + file = 70, + blitter = 73, +}; + +pub const Event = enum(u32) { + none = 0, + redraw = 1, + key = 2, + button = 3, + background = 5, + mouse = 6, + ipc = 7, +}; + +pub inline fn syscall0(number: SYS) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + ); +} + +pub inline fn syscall1(number: SYS, arg1: u32) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + [arg1] "{ebx}" (arg1), + ); +} + +pub inline fn syscall2(number: SYS, arg1: u32, arg2: u32) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + [arg1] "{ebx}" (arg1), + [arg2] "{ecx}" (arg2), + ); +} + +pub inline fn syscall3(number: SYS, arg1: u32, arg2: u32, arg3: u32) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + [arg1] "{ebx}" (arg1), + [arg2] "{ecx}" (arg2), + [arg3] "{edx}" (arg3), + ); +} + +pub inline fn syscall4(number: SYS, arg1: u32, arg2: u32, arg3: u32, arg4: u32) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + [arg1] "{ebx}" (arg1), + [arg2] "{ecx}" (arg2), + [arg3] "{edx}" (arg3), + [arg4] "{esi}" (arg4), + ); +} + +pub inline fn syscall5(number: SYS, arg1: u32, arg2: u32, arg3: u32, arg4: u32, arg5: u32) u32 { + return asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (@intFromEnum(number)), + [arg1] "{ebx}" (arg1), + [arg2] "{ecx}" (arg2), + [arg3] "{edx}" (arg3), + [arg4] "{esi}" (arg4), + [arg5] "{edi}" (arg5), + ); +} + +pub fn terminateProcess() noreturn { + _ = syscall0(SYS.terminate_process); + unreachable; +} + +pub const WindowFlags = struct { + skinned: bool = true, + fixed: bool = true, + no_title: bool = false, + relative_coordinates: bool = false, + no_fill: bool = false, + unmovable: bool = false, +}; + +pub fn createWindow(x: u16, y: u16, width: u16, height: u16, bgcolor: u24, flags: WindowFlags, title: [*:0]const u8) void { + var f1: u32 = 0x00000000; + if (flags.no_fill) + f1 |= 0x40000000; + if (flags.relative_coordinates) + f1 |= 0x20000000; + if (!flags.no_title) + f1 |= 0x10000000; + if (flags.skinned) { + if (flags.fixed) { + f1 |= 0x04000000; + } else { + f1 |= 0x03000000; + } + } else { + f1 |= 0x01000000; + } + var f2: u32 = 0x00000000; + if (flags.unmovable) + f2 = 0x01000000; + _ = syscall5(SYS.create_window, @as(u32, x) * 0x10000 + width, @as(u32, y) * 0x10000 + height, f1 | @as(u32, bgcolor), f2 | @as(u32, bgcolor), @intFromPtr(title)); +} + +pub fn putPixel(x: u16, y: u16, color: u24) void { + _ = syscall3(SYS.put_pixel, x, y, color); +} + +pub fn invertPixel(x: u16, y: u16) void { + _ = syscall3(SYS.put_pixel, x, y, 0x01000000); +} + +pub const Key = packed struct(u32) { + _unused: u8 = 0, + key: u8 = 0, + scancode: u8 = 0, + empty: u8 = 1, + + pub fn pressed(self: *const Key) bool { + return self.key & 0x80 > 0; + } +}; + +pub fn getKey() Key { + return @bitCast(syscall0(SYS.get_key)); +} + +pub fn getSysTime() u24 { + return @intCast(syscall0(SYS.get_sys_time)); +} + +pub fn getButton() u32 { + return syscall0(SYS.get_button); +} + +pub fn terminateThreadId(id: u32) void { + _ = syscall2(SYS.system, 18, id); +} + +pub fn drawText(x: u16, y: u16, color: u24, text: [*:0]const u8) void { + _ = syscall5(SYS.draw_text, @as(u32, x) * 0x10000 + y, 0x80000000 | @as(u32, color), @intFromPtr(text), 0, 0); +} + +pub fn sleep(centisecond: u32) void { + _ = syscall1(SYS.sleep, centisecond); +} + +pub fn beginDraw() void { + _ = syscall1(SYS.redraw, 1); +} + +pub fn endDraw() void { + _ = syscall1(SYS.redraw, 2); +} + +pub fn putImage(image: [*]const u8, width: u16, height: u16, x: u16, y: u16) void { + _ = syscall3(SYS.put_image, @intFromPtr(image), @as(u32, width) * 0x10000 + height, @as(u32, x) * 0x10000 + y); +} + +pub fn drawRect(x: u16, y: u16, width: u16, height: u16, color: u24) void { + _ = syscall3(SYS.draw_rect, @as(u32, x) * 0x10000 + width, @as(u32, y) * 0x10000 + height, color); +} + +pub fn getScreenSize() packed struct(u32) { height: u16, width: u16 } { + return @bitCast(syscall0(SYS.get_screen_size)); +} + +pub fn waitEvent() Event { + return @enumFromInt(syscall0(SYS.wait_event)); +} + +pub fn checkEvent() Event { + return @enumFromInt(syscall0(SYS.check_event)); +} + +pub fn createThread(entry: *const fn () void, stack: []u8) u32 { + return syscall3(SYS.create_thread, 1, @intFromPtr(entry), @intFromPtr(stack.ptr) + stack.len); +} + +pub fn debugWrite(byte: u8) void { + _ = syscall2(SYS.board, 1, byte); +} + +pub fn debugWriteText(bytes: []const u8) void { + for (bytes) |byte| { + debugWrite(byte); + } +} + +pub const EventsMask = packed struct(u32) { + redraw: bool = true, // 0 + key: bool = true, + button: bool = true, + _reserved: bool = false, + background: bool = false, + mouse: bool = false, + ipc: bool = false, + network: bool = false, + debug: bool = false, + _unused: u23 = 0, +}; + +pub fn setEventsMask(mask: EventsMask) EventsMask { + return @bitCast(syscall1(SYS.set_events_mask, @bitCast(mask))); +} + +pub fn getSkinHeight() u16 { + return @intCast(syscall1(SYS.style_settings, 4)); +} + +pub fn screenPutImage(image: [*]const u32, width: u16, height: u16, x: u16, y: u16) void { + _ = syscall3(SYS.screen_put_image, @intFromPtr(image), @as(u32, width) * 0x10000 + height, @as(u32, x) * 0x10000 + y); +} + +pub fn systemGetTimeCount() u32 { + return syscall1(SYS.system_get, 9); +} + +pub fn getSysDate() u24 { + return @intCast(syscall0(SYS.get_sys_date)); +} + +pub fn mouseGetScreenPosition() packed struct(u32) { y: u16, x: u16 } { + return @bitCast(syscall1(SYS.mouse_get, 0)); +} + +pub fn mouseGetWindowPosition() packed struct(u32) { y: u16, x: u16 } { + return @bitCast(syscall1(SYS.mouse_get, 1)); +} + +pub fn loadCursorIndirect(image: *const [32 * 32]u32, spotx: u5, spoty: u5) u32 { + return syscall3(SYS.mouse_get, 4, @intFromPtr(image), 0x0002 | (@as(u32, spotx) << 24) | (@as(u32, spoty) << 16)); +} + +pub fn setCursor(cursor: u32) u32 { + return syscall2(SYS.mouse_get, 5, cursor); +} + +pub const MouseEvents = packed struct(u32) { + left_hold: bool = false, + right_hold: bool = false, + middle_hold: bool = false, + button4_hold: bool = false, + button5_hold: bool = false, + _unused0: u3 = 0, + left_pressed: bool = false, + right_pressed: bool = false, + middle_pressed: bool = false, + _unused1: u4 = 0, + vertical_scroll: bool = false, + left_released: bool = false, + right_released: bool = false, + middle_released: bool = false, + _unused2: u4 = 0, + horizontal_scroll: bool = false, + left_double_clicked: bool = false, + _unused3: u7 = 0, +}; + +pub fn mouseGetEvents() MouseEvents { + return @bitCast(syscall1(SYS.mouse_get, 3)); +} + +pub fn heapInit() u32 { + return syscall1(SYS.sys_misc, 11); +} + +pub fn memAlloc(size: u32) *anyopaque { + return @ptrFromInt(syscall2(SYS.sys_misc, 12, size)); +} + +pub fn memFree(ptr: *anyopaque) void { + _ = syscall2(SYS.sys_misc, 13, @intFromPtr(ptr)); +} + +pub fn memRealloc(size: u32, ptr: *anyopaque) *anyopaque { + return @ptrFromInt(syscall3(SYS.sys_misc, 20, size, @intFromPtr(ptr))); +} + +fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 { + _ = ctx; + _ = alignment; + _ = ret_addr; + return @ptrCast(memAlloc(len)); +} + +fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void { + _ = ctx; + _ = alignment; + _ = ret_addr; + memFree(@ptrCast(memory.ptr)); +} + +fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool { + _ = ctx; + _ = alignment; + _ = ret_addr; + _ = memRealloc(new_len, @ptrCast(memory.ptr)); + return true; +} + +fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + _ = ctx; + _ = memory; + _ = alignment; + _ = new_len; + _ = ret_addr; + return null; +} + +pub const allocator: std.mem.Allocator = .{ + .ptr = undefined, + .vtable = &.{ + .alloc = alloc, + .free = free, + .resize = resize, + .remap = remap, + }, +}; + +pub fn loadDriver(name: [*:0]const u8) u32 { + return syscall2(SYS.sys_misc, 16, @intFromPtr(name)); +} + +pub fn controlDriver(drv: u32, func: u32, in: ?[]const u32, out: ?[]const *anyopaque) u32 { + const ioctl: packed struct(u192) { + drv: u32, + func: u32, + inptr: u32, + insize: u32, + outptr: u32, + outsize: u32, + } = .{ + .drv = drv, + .func = func, + .inptr = if (in) |v| @intFromPtr(v.ptr) else 0, + .insize = if (in) |v| v.len * 4 else 0, + .outptr = if (out) |v| @intFromPtr(v.ptr) else 0, + .outsize = if (out) |v| v.len * 4 else 0, + }; + return syscall2(SYS.sys_misc, 17, @intFromPtr(&ioctl)); +} + +pub const Signal = packed struct(u192) { + kind: u32, + data0: u32, + data1: u32, + data2: u32, + data3: u32, + data4: u32, +}; + +pub fn waitSignal(sig: *Signal) void { + _ = syscall2(SYS.sys_misc, 14, @intFromPtr(sig)); +} + +pub const Sound = struct { + drv: u32, + + pub const Buffer = struct { + drv: u32, + handle: u32, + + pub fn play(self: *const Buffer, flags: u32) void { + _ = controlDriver(self.drv, 10, &.{ self.handle, flags }, null); + } + + pub fn set(self: *const Buffer, src: []u8, offset: u32) void { + _ = controlDriver(self.drv, 8, &.{ self.handle, @intFromPtr(src.ptr), offset, src.len }, null); + } + }; + + pub fn init() Sound { + return .{ + .drv = loadDriver("INFINITY"), + }; + } + + pub fn createBuffer(self: *const Sound, format: u32, size: u32) Buffer { + var ret: u32 = 0; + _ = controlDriver(self.drv, 1, &.{ format, size }, &.{&ret}); + return .{ + .drv = self.drv, + .handle = ret, + }; + } +}; + +pub const InputMode = enum(u32) { + normal = 0, + scancodes = 1, +}; + +pub fn setInputMode(mode: InputMode) void { + _ = syscall2(SYS.keyboard, 1, @intFromEnum(mode)); +} + +pub fn changeWindow(x: u32, y: u32, width: u32, height: u32) void { + _ = syscall4(SYS.change_window, x, y, width, height); +} + +pub const ControlKeys = packed struct(u32) { + left_shift: bool, + right_shift: bool, + left_ctrl: bool, + right_ctrl: bool, + left_alt: bool, + right_alt: bool, + caps_lock: bool, + num_lock: bool, + scroll_lock: bool, + left_win: bool, + right_win: bool, + _unused: u21, +}; + +pub fn getControlKeys() ControlKeys { + return @bitCast(syscall1(SYS.keyboard, 3)); +} + +const FileInfo = packed struct(u200) { + subfn: u32, + offset: u64, + size: u32, + buffer: u32, + path0: u8 = 0, + pathptr: *const u8, +}; + +pub fn readFile(pathname: [*:0]const u8, offset: u64, buffer: []u8) !u32 { + const info: FileInfo = .{ + .subfn = 0, + .offset = offset, + .size = buffer.len, + .buffer = @intFromPtr(buffer.ptr), + .pathptr = @ptrCast(pathname), + }; + const err = asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (SYS.file), + [info] "{ebx}" (&info), + ); + const size = asm volatile ("" + : [ret] "={ebx}" (-> u32), + ); + return switch (err) { + 0 => size, + 10 => error.AccessDenied, + 6 => size, + else => error.Unexpected, + }; +} + +pub fn createFile(pathname: [*:0]const u8, buffer: []u8) !u32 { + const info: FileInfo = .{ + .subfn = 2, + .offset = 0, + .size = buffer.len, + .buffer = @intFromPtr(buffer.ptr), + .pathptr = @ptrCast(pathname), + }; + const err = asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (SYS.file), + [info] "{ebx}" (&info), + ); + const size = asm volatile ("" + : [ret] "={ebx}" (-> u32), + ); + return switch (err) { + 0 => size, + 10 => error.AccessDenied, + 8 => size, + else => error.Unexpected, + }; +} + +pub fn writeFile(pathname: [*:0]const u8, offset: u64, buffer: []u8) !u32 { + const info: FileInfo = .{ + .subfn = 3, + .offset = offset, + .size = buffer.len, + .buffer = @intFromPtr(buffer.ptr), + .pathptr = @ptrCast(pathname), + }; + const err = asm volatile ("int $0x40" + : [ret] "={eax}" (-> u32), + : [number] "{eax}" (SYS.file), + [info] "{ebx}" (&info), + ); + const size = asm volatile ("" + : [ret] "={ebx}" (-> u32), + ); + return switch (err) { + 0 => size, + 10 => error.AccessDenied, + 5 => error.FileNotFound, + else => error.Unexpected, + }; +} + +pub fn deleteFile(pathname: [*:0]const u8) !void { + const info: FileInfo = .{ + .subfn = 8, + .offset = 0, + .size = 0, + .buffer = 0, + .pathptr = @ptrCast(pathname), + }; + const err = syscall1(SYS.file, @intFromPtr(&info)); + if (err != 0) + return error.Unexpected; +} + +pub const File = struct { + pathname: [*:0]const u8, + offset: u64 = 0, + + pub fn reader(file: *File) std.io.GenericReader(*File, anyerror, struct { + fn read(context: *File, buffer: []u8) !usize { + const size = try readFile(context.pathname, context.offset, buffer); + context.offset += size; + return size; + } + }.read) { + return .{ .context = file }; + } +}; + +pub const BlitterFlags = packed struct(u32) { + rop: u4 = 0, + background: bool = false, + transparent: bool = false, + reserved1: u23 = 0, + client_relative: bool = true, + reserved2: u2 = 0, +}; + +pub fn blitter(dstx: u32, dsty: u32, dstw: u32, dsth: u32, srcx: u32, srcy: u32, srcw: u32, srch: u32, src: *const u8, pitch: u32, flags: BlitterFlags) void { + _ = syscall2(SYS.blitter, @bitCast(flags), @intFromPtr(&[_]u32{ dstx, dsty, dstw, dsth, srcx, srcy, srcw, srch, @intFromPtr(src), pitch })); +} + +pub const DebugWriter = std.io.GenericWriter(void, anyerror, struct { + fn write(context: void, bytes: []const u8) !usize { + _ = context; + debugWriteText(bytes); + return bytes.len; + } +}.write); + +pub const debug_writer: DebugWriter = .{ .context = {} }; diff --git a/programs/emulator/uxn/src/linker.ld b/programs/emulator/uxn/src/linker.ld new file mode 100644 index 000000000..a83f3bf4a --- /dev/null +++ b/programs/emulator/uxn/src/linker.ld @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2025 iyzsong@envs.net + * + * SPDX-License-Identifier: MPL-2.0 + */ + +SECTIONS +{ + .text 0x00000000 : + { + /* MENUET01 header */ + LONG(0x554e454d); + LONG(0x31305445); + LONG(1); + LONG(_start); + LONG(_image_end); + LONG(_memory_end); + LONG(_stack_top); + LONG(_cmdline); + LONG(0); + *(.text) + *(.text.*) + } + .rodata : ALIGN(8) + { + *(.rodata) + *(.rodata.*) + } + .data : ALIGN(8) + { + *(.data) + } + _image_end = .; + + .bss : ALIGN(8) + { + *(.bss) + . = . + 4K; + _stack_top = .; + } + _memory_end = .; +} diff --git a/programs/emulator/uxn/src/main.zig b/programs/emulator/uxn/src/main.zig new file mode 100644 index 000000000..c516897e9 --- /dev/null +++ b/programs/emulator/uxn/src/main.zig @@ -0,0 +1,417 @@ +// SPDX-FileCopyrightText: 2025 iyzsong@envs.net +// +// SPDX-License-Identifier: MPL-2.0 + +const std = @import("std"); +const kos = @import("kolibri.zig"); +const uxn = @import("uxn-core"); +const varvara = @import("uxn-varvara"); + +const allocator = kos.allocator; +export var _cmdline: [1024]u8 = undefined; + +pub const std_options: std.Options = .{ + .log_level = .info, + .logFn = struct { + fn log(comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime format: []const u8, args: anytype) void { + _ = level; + _ = scope; + kos.debug_writer.print(format, args) catch return; + } + }.log, +}; + +const VarvaraDefault = varvara.VarvaraSystem(kos.DebugWriter, kos.DebugWriter); +const emu = struct { + var cpu: uxn.Cpu = undefined; + var sys: VarvaraDefault = undefined; + var rom: *[0x10000]u8 = undefined; + var pixels: []u8 = undefined; + var screen_width: u32 = undefined; + var screen_height: u32 = undefined; + var null_cursor: u32 = undefined; + var hide_cursor: bool = false; + var audio_thread: ?u32 = null; + var scale: u4 = 1; + + fn init(rompath: [*:0]const u8) !void { + const screen = &emu.sys.screen_device; + var rom_file = kos.File{ .pathname = rompath }; + emu.rom = try uxn.loadRom(allocator, rom_file.reader()); + emu.cpu = uxn.Cpu.init(emu.rom); + emu.sys = try VarvaraDefault.init(allocator, kos.debug_writer, kos.debug_writer); + emu.cpu.device_intercept = struct { + var file_offsets: [2]u64 = .{ 0, 0 }; + + fn bcd8(x: u8) u8 { + return (x & 0xf) + 10 * ((x & 0xf0) >> 4); + } + + fn getFilePortSlice(dev: *varvara.file.File, comptime port: comptime_int) []u8 { + const ports = varvara.file.ports; + const ptr: usize = dev.loadPort(u16, &cpu, port); + + return if (port == ports.name) + std.mem.sliceTo(cpu.mem[ptr..], 0x00) + else + return cpu.mem[ptr..ptr +| dev.loadPort(u16, &cpu, ports.length)]; + } + + pub fn intercept(self: *uxn.Cpu, addr: u8, kind: uxn.Cpu.InterceptKind, data: ?*anyopaque) !void { + _ = data; + const port: u4 = @truncate(addr & 0x0f); + if (audio_thread == null and addr >= 0x30 and addr < 0x70) { + audio_thread = kos.createThread(&audio, allocator.alloc(u8, 32 * 1024) catch unreachable); + } + switch (addr >> 4) { + 0xa, 0xb => { + if (kind != .output) + return; + + const idx = (addr >> 4) - 0xa; + const dev = &sys.file_devices[idx]; + const ports = varvara.file.ports; + switch (port) { + ports.stat + 1 => { + // TODO: file/directory stat + dev.storePort(u16, &cpu, ports.success, 0); + }, + ports.delete => { + const name_slice = getFilePortSlice(dev, ports.name); + _ = kos.deleteFile(@ptrCast(name_slice)) catch {}; + dev.storePort(u16, &cpu, ports.success, 0); + }, + ports.name + 1 => { + file_offsets[idx] = 0; + dev.storePort(u16, &cpu, ports.success, 1); + }, + ports.read + 1 => { + const name_slice = getFilePortSlice(dev, ports.name); + const data_slice = getFilePortSlice(dev, ports.read); + const ret: u32 = kos.readFile(@ptrCast(name_slice), file_offsets[idx], data_slice) catch 0; + file_offsets[idx] += ret; + dev.storePort(u16, &cpu, ports.success, @intCast(ret)); + }, + ports.write + 1 => { + const append = dev.loadPort(u8, &cpu, ports.append) == 0x01; + const pathname: [*:0]const u8 = @ptrCast(getFilePortSlice(dev, ports.name)); + const buffer = getFilePortSlice(dev, ports.write); + var ret: u32 = 0; + if (append) { + ret = kos.writeFile(pathname, file_offsets[idx], buffer) catch |err| blk: { + if (err == error.FileNotFound) { + break :blk kos.createFile(pathname, buffer) catch 0; + } else { + break :blk 0; + } + }; + } else { + ret = kos.createFile(pathname, buffer) catch 0; + } + dev.storePort(u16, &cpu, ports.success, @intCast(ret)); + }, + else => {}, + } + }, + 0xc => { + if (kind != .input) + return; + + const dev = &sys.datetime_device; + const date = kos.getSysDate(); + const time = kos.getSysTime(); + switch (port) { + 0x0, 0x1 => { + const year: u8 = bcd8(@truncate(date & 0xff)); + dev.storePort(u16, &cpu, 0x0, @as(u16, 2000) + bcd8(year)); + }, + 0x02 => { + const month: u8 = bcd8(@truncate((date & 0xff00) >> 8)); + dev.storePort(u8, &cpu, port, month); + }, + 0x03 => { + const day: u8 = bcd8(@truncate((date & 0xff0000) >> 16)); + dev.storePort(u8, &cpu, port, day); + }, + 0x04 => { + const hour: u8 = bcd8(@truncate(time & 0xff)); + dev.storePort(u8, &cpu, port, hour); + }, + 0x05 => { + const minute: u8 = bcd8(@truncate((time & 0xff00) >> 8)); + dev.storePort(u8, &cpu, port, minute); + }, + 0x06 => { + const second: u8 = bcd8(@truncate((time & 0xff0000) >> 16)); + dev.storePort(u8, &cpu, port, second); + }, + else => {}, + } + }, + else => try emu.sys.intercept(self, addr, kind), + } + } + }.intercept; + emu.cpu.output_intercepts = varvara.full_intercepts.output; + emu.cpu.input_intercepts = varvara.full_intercepts.input; + + try emu.cpu.evaluateVector(0x0100); + screen_width = screen.width * emu.scale; + screen_height = screen.height * emu.scale; + emu.pixels = try allocator.alloc(u8, @as(usize, 4) * screen_width * screen_height); + } + + fn exit() void { + if (audio_thread) |tid| { + kos.terminateThreadId(tid); + } + kos.terminateProcess(); + } + + fn audio() void { + var samples: [8192]i16 = undefined; + var sig: kos.Signal = undefined; + const buf = kos.Sound.init().createBuffer(3 | 0x10000000, 0); + buf.play(0); + while (true) { + kos.waitSignal(&sig); + if (sig.kind != 0xFF000001) continue; + @memset(&samples, 0); + for (0..samples.len / 512) |i| { + const w = samples[i * 512 .. (i + 1) * 512]; + for (&sys.audio_devices) |*poly| { + if (poly.duration <= 0) { + poly.evaluateFinishVector(&cpu) catch unreachable; + } + poly.updateDuration(); + poly.renderAudio(w); + } + } + for (0..samples.len) |i| { + samples[i] <<= 6; + } + buf.set(@ptrCast(&samples), sig.data2); + } + } + + fn update() !void { + const screen = &sys.screen_device; + const colors = &sys.system_device.colors; + if (sys.system_device.exit_code) |_| { + exit(); + } + if (screen_width != screen.width * scale or screen_height != screen.height * scale) { + const skin_height = kos.getSkinHeight(); + allocator.free(emu.pixels); + screen_width = screen.width * scale; + screen_height = screen.height * scale; + emu.pixels = try allocator.alloc(u8, @as(usize, 4) * screen_width * screen_height); + kos.changeWindow(100, 100, screen_width + 9, screen_height + skin_height + 4); + } + try screen.evaluateFrame(&cpu); + if (screen.dirty_region) |region| { + for (region.y0..region.y1) |y| { + for (region.x0..region.x1) |x| { + const idx = y * screen.width + x; + const pal = (@as(u4, screen.foreground[idx]) << 2) | screen.background[idx]; + const color = colors[if ((pal >> 2) > 0) (pal >> 2) else (pal & 0x3)]; + for (0..scale) |sy| { + for (0..scale) |sx| { + pixels[4 * ((y * scale + sy) * screen.width * scale + (x * scale + sx)) + 2] = color.r; + pixels[4 * ((y * scale + sy) * screen.width * scale + (x * scale + sx)) + 1] = color.g; + pixels[4 * ((y * scale + sy) * screen.width * scale + (x * scale + sx)) + 0] = color.b; + } + } + } + } + const ix = region.x0 * scale; + const iy = region.y0 * scale; + const iw = (region.x1 - region.x0) * scale; + const ih = (region.y1 - region.y0) * scale; + kos.blitter(ix, iy, iw, ih, ix, iy, iw, ih, @ptrCast(emu.pixels.ptr), screen.width * scale * 4, .{}); + screen.dirty_region = null; + } + } + + fn changeScale() void { + const screen = &sys.screen_device; + scale = switch (scale) { + 1 => 2, + 2 => 3, + 3 => 1, + else => 1, + }; + screen.forceRedraw(); + } +}; + +export fn _start() noreturn { + const cursor: [32 * 32]u32 = .{0} ** (32 * 32); + var counter: u32 = 0; + var last_tick = kos.systemGetTimeCount(); + + _ = kos.heapInit(); + _ = kos.setEventsMask(.{ .mouse = true }); + kos.setInputMode(.scancodes); + emu.null_cursor = kos.loadCursorIndirect(&cursor, 0, 0); + emu.init(@ptrCast(&_cmdline)) catch unreachable; + + const screen = &emu.sys.screen_device; + const controller = &emu.sys.controller_device; + + const callbacks = struct { + fn redraw() void { + const skin_height = kos.getSkinHeight(); + kos.beginDraw(); + kos.createWindow(300, 300, screen.width * emu.scale + 9, screen.height * emu.scale + skin_height + 4, 0x000000, .{ .skinned = true, .no_fill = true, .relative_coordinates = true }, "UXN"); + kos.endDraw(); + } + + fn key() void { + const symtab: [0x80]u8 = .{ + // 0x0* + 0, 27, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 8, '\t', + // 0x1* + 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\r', 0, 'a', 's', + // 0x2* + 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '`', 0, '\\', 'z', 'x', 'c', 'v', + // 0x3* + 'b', 'n', 'm', ',', '.', '/', 0, 0, 0, ' ', 0, 0, 0, 0, 0, 0, + // 0x00* + SHIFT + 0, 27, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 8, '\t', + // 0x10* + SHIFT + 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '\r', 0, 'A', 'S', + // 0x20* + SHIFT + 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"', '~', 0, '|', 'Z', 'X', 'C', 'V', + // 0x30* + SHIFT, + 'B', 'N', 'M', '<', '>', '?', 0, 0, 0, ' ', 0, 0, 0, 0, 0, 0, + }; + const scancode1 = kos.getKey().key; + const input: ?union(enum) { + button: struct { + flags: varvara.controller.ButtonFlags, + pressed: bool, + }, + key: u8, + } = switch (scancode1) { + 0xe0 => blk: { + const scancode2 = kos.getKey().key; + break :blk switch (scancode2) { + 0x1d => .{ .button = .{ .flags = .{ .ctrl = true }, .pressed = true } }, + 0x9d => .{ .button = .{ .flags = .{ .ctrl = true }, .pressed = false } }, + 0x38 => .{ .button = .{ .flags = .{ .alt = true }, .pressed = true } }, + 0xb8 => .{ .button = .{ .flags = .{ .alt = true }, .pressed = false } }, + 0x47 => .{ .button = .{ .flags = .{ .start = true }, .pressed = true } }, + 0xc7 => .{ .button = .{ .flags = .{ .start = true }, .pressed = false } }, + 0x48 => .{ .button = .{ .flags = .{ .up = true }, .pressed = true } }, + 0xc8 => .{ .button = .{ .flags = .{ .up = true }, .pressed = false } }, + 0x50 => .{ .button = .{ .flags = .{ .down = true }, .pressed = true } }, + 0xd0 => .{ .button = .{ .flags = .{ .down = true }, .pressed = false } }, + 0x4b => .{ .button = .{ .flags = .{ .left = true }, .pressed = true } }, + 0xcb => .{ .button = .{ .flags = .{ .left = true }, .pressed = false } }, + 0x4d => .{ .button = .{ .flags = .{ .right = true }, .pressed = true } }, + 0xcd => .{ .button = .{ .flags = .{ .right = true }, .pressed = false } }, + else => null, + }; + }, + 0x3b => blk: { + emu.changeScale(); + break :blk null; + }, + 0x1d => .{ .button = .{ .flags = .{ .ctrl = true }, .pressed = true } }, + 0x9d => .{ .button = .{ .flags = .{ .ctrl = true }, .pressed = false } }, + 0x38 => .{ .button = .{ .flags = .{ .alt = true }, .pressed = true } }, + 0xb8 => .{ .button = .{ .flags = .{ .alt = true }, .pressed = false } }, + 0x2a, 0x36 => .{ .button = .{ .flags = .{ .shift = true }, .pressed = true } }, + 0xaa, 0xb6 => .{ .button = .{ .flags = .{ .shift = true }, .pressed = false } }, + else => blk: { + if (scancode1 > 0x40) { + break :blk null; + } + const ctrls = kos.getControlKeys(); + const k = if (ctrls.left_shift or ctrls.right_shift) symtab[scancode1 + 0x40] else symtab[scancode1]; + break :blk if (k > 0) .{ .key = k } else null; + }, + }; + + if (input) |v| { + switch (v) { + .button => { + if (v.button.pressed) { + controller.pressButtons(&emu.cpu, v.button.flags, 0) catch unreachable; + } else { + controller.releaseButtons(&emu.cpu, v.button.flags, 0) catch unreachable; + } + }, + .key => { + controller.pressKey(&emu.cpu, v.key) catch unreachable; + }, + } + } + } + + fn button() void { + const btn = kos.getButton(); + if (btn >> 8 == 1) + emu.exit(); + } + + fn mouse() void { + const dev = &emu.sys.mouse_device; + const pos = kos.mouseGetWindowPosition(); + const events = kos.mouseGetEvents(); + const mouse_pressed: varvara.mouse.ButtonFlags = .{ + .left = events.left_pressed, + .middle = events.middle_pressed, + .right = events.right_pressed, + ._unused = 0, + }; + const mouse_released: varvara.mouse.ButtonFlags = .{ + .left = events.left_released, + .middle = events.middle_released, + .right = events.right_released, + ._unused = 0, + }; + if (emu.hide_cursor and pos.y > emu.screen_height) { + _ = kos.setCursor(0); + emu.hide_cursor = false; + } + if (!emu.hide_cursor and pos.y < emu.screen_height) { + _ = kos.setCursor(emu.null_cursor); + emu.hide_cursor = true; + } + dev.updatePosition(&emu.cpu, pos.x / emu.scale, pos.y / emu.scale) catch unreachable; + if (@as(u8, @bitCast(mouse_pressed)) > 0) { + dev.pressButtons(&emu.cpu, mouse_pressed) catch unreachable; + } + if (@as(u8, @bitCast(mouse_released)) > 0) { + dev.releaseButtons(&emu.cpu, mouse_released) catch unreachable; + } + } + }; + + callbacks.redraw(); + while (true) { + while (true) { + const event = kos.checkEvent(); + switch (event) { + .none => break, + .redraw => callbacks.redraw(), + .key => callbacks.key(), + .button => callbacks.button(), + .mouse => callbacks.mouse(), + else => {}, + } + } + const tick = kos.systemGetTimeCount(); + counter += (tick - last_tick) * 3; + last_tick = tick; + + if (counter > 5) { + counter -= 5; + emu.update() catch unreachable; + } else { + kos.sleep(1); + } + } +}