diff --git a/.gitignore b/.gitignore index 25062cc..8089f12 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ zig-pkg .claude CLAUDE.md *.log +*.wav diff --git a/build.zig b/build.zig index 03b5eb5..b8676ca 100644 --- a/build.zig +++ b/build.zig @@ -6,6 +6,16 @@ pub const Platform = enum { linux, macos, psp, + /// Nintendo 3DS. Builtin os tag is `.@"3ds"`, but the Zig options + /// serializer can't emit `.@"3ds"` as an enum value literal, so the + /// internal Aether tag uses a leading-letter form. + nintendo_3ds, + /// Nintendo Switch. Zig 0.16 has no `switch`/`horizon` OS tag, so the + /// canonical target is `aarch64-freestanding-none` and we can't infer + /// the platform from `target.os.tag` alone. Opt in with + /// `-Dnintendo-switch=true`; `Config.resolve` then promotes a + /// freestanding aarch64 target to this variant. + nintendo_switch, }; pub const Gfx = enum { @@ -54,14 +64,26 @@ pub const Config = struct { use_cwd: bool = false, pub fn resolve(target: std.Build.ResolvedTarget, overrides: Overrides) Config { - const plat: Platform = switch (target.result.os.tag) { - .windows => .windows, - .macos => .macos, - .linux => .linux, - .psp => .psp, - else => |t| { - std.debug.panic("Unsupported OS! {}\n", .{t}); - }, + const plat: Platform = blk: { + if (overrides.nintendo_switch == true) { + if (target.result.cpu.arch != .aarch64 or target.result.os.tag != .freestanding) { + std.debug.panic( + "-Dnintendo-switch=true requires -Dtarget=aarch64-freestanding-none (got {s}-{s})\n", + .{ @tagName(target.result.cpu.arch), @tagName(target.result.os.tag) }, + ); + } + break :blk .nintendo_switch; + } + break :blk switch (target.result.os.tag) { + .windows => .windows, + .macos => .macos, + .linux => .linux, + .psp => .psp, + .@"3ds" => .nintendo_3ds, + else => |t| { + std.debug.panic("Unsupported OS! {}\n", .{t}); + }, + }; }; const default_gfx: Gfx = switch (target.result.os.tag) { @@ -71,13 +93,7 @@ pub const Config = struct { else => .default, }; - // macOS default is `.none` because the current miniaudio build is - // bugged there. Flip back to `.default` with `-Daudio=default` once - // that's fixed. - const default_audio: Audio = switch (target.result.os.tag) { - .macos => .none, - else => .default, - }; + const default_audio: Audio = .default; return .{ .platform = plat, @@ -95,6 +111,9 @@ pub const Config = struct { psp_display_mode: ?PspDisplayMode = null, psp_mipmaps: ?bool = null, use_cwd: ?bool = null, + /// Promotes an `aarch64-freestanding-none` target to the + /// `nintendo_switch` platform. No effect when null/false. + nintendo_switch: ?bool = null, }; }; @@ -118,6 +137,12 @@ pub const ShaderPaths = struct { slang: std.Build.LazyPath, }; +const user_root_import_name = "aether_user_root"; + +pub fn userRootModule(exe: *std.Build.Step.Compile) *std.Build.Module { + return exe.root_module.import_table.get(user_root_import_name) orelse exe.root_module; +} + // Cached per-build user options. b.option panics on second declaration, so // these getters declare once and memoize. Accessed from both addGame (for // linking) and exportArtifact (for bundle packaging). Module-level mutable @@ -141,6 +166,36 @@ fn macosGlfwPath(b: *std.Build) []const u8 { return p; } +var devkitpro_path_cached: ?[]const u8 = null; +fn devkitProPath(b: *std.Build) []const u8 { + if (devkitpro_path_cached) |p| return p; + const opt = b.option([]const u8, "devkitpro-path", "3DS: devkitPro install root (default: $DEVKITPRO or /opt/devkitpro)"); + const p = opt orelse b.graph.environ_map.get("DEVKITPRO") orelse "/opt/devkitpro"; + devkitpro_path_cached = p; + return p; +} + +/// Creates a `3dslink` command for pushing an installed `.3dsx` to a +/// networked 3DS. Reuses Aether's devkitPro option/cache so downstream +/// builds do not need to redeclare `-Ddevkitpro-path`. +pub fn add3dslink(b: *std.Build, threedsx_path: []const u8) *std.Build.Step.Run { + const dkp = devkitProPath(b); + const link_cmd = b.addSystemCommand(&.{b.pathJoin(&.{ dkp, "tools/bin/3dslink" })}); + if (b.option([]const u8, "3dslink-address", "3DS: target IP for 3dslink push (default: mDNS auto-discover)")) |ip| { + link_cmd.addArgs(&.{ "-a", ip }); + } + if (b.option(u32, "3dslink-retries", "3DS: 3dslink retry count (default: 10)")) |n| { + link_cmd.addArgs(&.{ "-r", b.fmt("{d}", .{n}) }); + } + if (b.option(bool, "3dslink-server", "3DS: pass -s so 3dslink stays listening after the upload (useful for some Rosalina versions and for stdout relay)") orelse false) { + link_cmd.addArg("-s"); + } + link_cmd.addArg(threedsx_path); + link_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| link_cmd.addArgs(args); + return link_cmd; +} + /// Creates an executable with the Aether engine module and all platform /// dependencies wired up. Returns the compile step so the caller can /// further customize it (install, add run steps, etc.). @@ -156,6 +211,17 @@ fn macosGlfwPath(b: *std.Build) []const u8 { /// the actual build steps and executable. pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.Step.Compile { const config = Config.resolve(opts.target, opts.overrides); + const uses_nintendo_c_io = config.platform == .nintendo_3ds or config.platform == .nintendo_switch; + + // 3DS and Switch force ofmt=c — there's no Zig-native backend for + // either Horizon target yet, so we emit C and let an external + // toolchain (devkitARM/libctru on 3DS, devkitA64/libnx on Switch) + // compile the result. + const target = if (uses_nintendo_c_io) blk: { + var q = opts.target.query; + q.ofmt = .c; + break :blk b.resolveTargetQuery(q); + } else opts.target; const options = b.addOptions(); options.addOption(Config, "config", config); @@ -163,7 +229,8 @@ pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.S const mod = b.addModule("Aether", .{ .root_source_file = owner.path("src/root.zig"), - .target = opts.target, + .target = target, + .link_libc = if (uses_nintendo_c_io) true else null, .imports = &.{ .{ .name = "options", .module = options_module }, }, @@ -171,20 +238,23 @@ pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.S // --- platform-specific engine dependencies --- const psp_dep = if (config.platform == .psp) owner.dependency("pspsdk", .{ - .target = opts.target, + .target = target, .optimize = opts.optimize, }) else null; if (psp_dep) |pd| { mod.addImport("pspsdk", pd.module("pspsdk")); + } else if (config.platform == .nintendo_3ds or config.platform == .nintendo_switch) { + // Console SDK symbols are declared as backend-local externs and + // resolved by the export pipeline's devkitPro link step. } else { const zglfw = owner.dependency("zglfw", .{ - .target = opts.target, + .target = target, .optimize = opts.optimize, }); const glfw = owner.dependency("glfw_zig", .{ - .target = opts.target, + .target = target, .optimize = opts.optimize, }); @@ -203,15 +273,33 @@ pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.S mod.addImport("vulkan", vulkan); if (config.audio != .none) { - const zaudio_dep = owner.dependency("zaudio", .{ - .target = opts.target, + const sdl3_dep = owner.lazyDependency("sdl3", .{ + .target = target, .optimize = opts.optimize, - }); - mod.addImport("zaudio", zaudio_dep.module("root")); - mod.linkLibrary(zaudio_dep.artifact("miniaudio")); + .main = false, + .ext_image = false, + .ext_net = false, + .ext_ttf = false, + // Static SDL3 and static GLFW both embed generated Wayland + // protocol objects on Linux. Keep SDL dynamic there since + // Aether only uses its audio subsystem. + .c_sdl_preferred_linkage = @as( + std.builtin.LinkMode, + if (target.result.os.tag == .linux) .dynamic else .static, + ), + }) orelse @panic("sdl3 dependency is required when desktop audio is enabled"); + mod.addImport("sdl3", sdl3_dep.module("sdl3")); + + if (target.result.os.tag == .linux) { + mod.addRPathSpecial("$ORIGIN"); + const install_sdl3 = b.addInstallArtifact(sdl3_dep.artifact("SDL3"), .{ + .dest_dir = .{ .override = .bin }, + }); + b.getInstallStep().dependOn(&install_sdl3.step); + } } - if (opts.target.result.os.tag == .macos) { + if (target.result.os.tag == .macos) { // Link MoltenVK directly as the Vulkan ICD -- no loader. Feeds // its vkGetInstanceProcAddr into GLFW via glfwInitVulkanLoader // in platform/glfw/surface.zig so GLFW doesn't dlopen libvulkan @@ -237,25 +325,44 @@ pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.S } // --- user executable --- + const user_mod = b.createModule(.{ + .root_source_file = opts.root_source_file, + .target = target, + .optimize = opts.optimize, + .strip = if (config.platform == .psp) false else null, + .link_libc = if (uses_nintendo_c_io) true else null, + .imports = &.{ + .{ .name = "aether", .module = mod }, + }, + }); + + const root_mod = if (uses_nintendo_c_io) b.createModule(.{ + .root_source_file = owner.path(switch (config.platform) { + .nintendo_3ds => "src/platform/3ds/services.zig", + .nintendo_switch => "src/platform/switch/services.zig", + else => unreachable, + }), + .target = target, + .optimize = opts.optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "aether", .module = mod }, + .{ .name = user_root_import_name, .module = user_mod }, + .{ .name = "options", .module = options_module }, + }, + }) else user_mod; + const exe = b.addExecutable(.{ .name = opts.name, - .root_module = b.createModule(.{ - .root_source_file = opts.root_source_file, - .target = opts.target, - .optimize = opts.optimize, - .strip = if (config.platform == .psp) false else null, - .imports = &.{ - .{ .name = "aether", .module = mod }, - }, - }), + .root_module = root_mod, }); if (psp_dep) |pd| { // Inline PSP config -- pspsdk.configurePspExecutable uses // dependencyFromBuildZig on exe.step.owner which fails when // the exe is owned by a downstream builder. - if (exe.root_module.import_table.get("pspsdk") == null) { - exe.root_module.addImport("pspsdk", mod.import_table.get("pspsdk").?); + if (userRootModule(exe).import_table.get("pspsdk") == null) { + userRootModule(exe).addImport("pspsdk", mod.import_table.get("pspsdk").?); } exe.link_eh_frame_hdr = true; exe.link_emit_relocs = true; @@ -267,6 +374,14 @@ pub fn addGame(owner: *std.Build, b: *std.Build, opts: GameOptions) *std.Build.S exe.subsystem = .windows; } + if (uses_nintendo_c_io) { + // The platform shim exports C `main` itself. Keeping std/start's + // libc main wrapper disabled avoids pulling in unsupported + // freestanding libc/thread startup paths while still preserving the + // exported shim in the emitted C. + exe.entry = .disabled; + } + return exe; } @@ -285,6 +400,14 @@ pub fn addHeadless(owner: *std.Build, b: *std.Build, opts: HeadlessOptions) *std var config = Config.resolve(opts.target, opts.overrides); config.gfx = .headless; config.audio = .none; + const uses_nintendo_c_io = config.platform == .nintendo_3ds or config.platform == .nintendo_switch; + + // 3DS and Switch force ofmt=c (see addGame for details). + const target = if (uses_nintendo_c_io) blk: { + var q = opts.target.query; + q.ofmt = .c; + break :blk b.resolveTargetQuery(q); + } else opts.target; const options = b.addOptions(); options.addOption(Config, "config", config); @@ -292,14 +415,15 @@ pub fn addHeadless(owner: *std.Build, b: *std.Build, opts: HeadlessOptions) *std const mod = b.addModule("Aether", .{ .root_source_file = owner.path("src/root.zig"), - .target = opts.target, + .target = target, + .link_libc = if (uses_nintendo_c_io) true else null, .imports = &.{ .{ .name = "options", .module = options_module }, }, }); const psp_dep = if (config.platform == .psp) owner.dependency("pspsdk", .{ - .target = opts.target, + .target = target, .optimize = opts.optimize, }) else null; @@ -307,22 +431,41 @@ pub fn addHeadless(owner: *std.Build, b: *std.Build, opts: HeadlessOptions) *std mod.addImport("pspsdk", pd.module("pspsdk")); } + const user_mod = b.createModule(.{ + .root_source_file = opts.root_source_file, + .target = target, + .optimize = opts.optimize, + .strip = if (config.platform == .psp) false else null, + .link_libc = if (uses_nintendo_c_io) true else null, + .imports = &.{ + .{ .name = "aether", .module = mod }, + }, + }); + + const root_mod = if (uses_nintendo_c_io) b.createModule(.{ + .root_source_file = owner.path(switch (config.platform) { + .nintendo_3ds => "src/platform/3ds/services.zig", + .nintendo_switch => "src/platform/switch/services.zig", + else => unreachable, + }), + .target = target, + .optimize = opts.optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "aether", .module = mod }, + .{ .name = user_root_import_name, .module = user_mod }, + .{ .name = "options", .module = options_module }, + }, + }) else user_mod; + const exe = b.addExecutable(.{ .name = opts.name, - .root_module = b.createModule(.{ - .root_source_file = opts.root_source_file, - .target = opts.target, - .optimize = opts.optimize, - .strip = if (config.platform == .psp) false else null, - .imports = &.{ - .{ .name = "aether", .module = mod }, - }, - }), + .root_module = root_mod, }); if (psp_dep) |pd| { - if (exe.root_module.import_table.get("pspsdk") == null) { - exe.root_module.addImport("pspsdk", mod.import_table.get("pspsdk").?); + if (userRootModule(exe).import_table.get("pspsdk") == null) { + userRootModule(exe).addImport("pspsdk", mod.import_table.get("pspsdk").?); } exe.link_eh_frame_hdr = true; exe.link_emit_relocs = true; @@ -330,6 +473,10 @@ pub fn addHeadless(owner: *std.Build, b: *std.Build, opts: HeadlessOptions) *std exe.setLinkerScript(pd.path("tools/linkfile.ld")); } + if (uses_nintendo_c_io) { + exe.entry = .disabled; + } + return exe; } @@ -367,6 +514,20 @@ fn addSlangStep(b: *std.Build, slangc: ?std.Build.LazyPath, args: []const []cons return output; } +fn addUamStep(b: *std.Build, uam: []const u8, stage: []const u8, comptime output_name: []const u8, input: std.Build.LazyPath) std.Build.LazyPath { + const run = b.addSystemCommand(&.{ uam, "-s", stage, "-o" }); + const output = run.addOutputFileArg(output_name); + run.addFileArg(input); + return output; +} + +fn addPicassoStep(b: *std.Build, picasso: []const u8, comptime output_name: []const u8, input: std.Build.LazyPath) std.Build.LazyPath { + const run = b.addSystemCommand(&.{ picasso, "-o" }); + const output = run.addOutputFileArg(output_name); + run.addFileArg(input); + return output; +} + pub const ExportOptions = struct { /// PSP/macOS: human-readable name shown to the OS (XMB title on PSP, /// CFBundleName on macOS). Ignored elsewhere. @@ -391,8 +552,30 @@ pub const ExportOptions = struct { icon_png: ?std.Build.LazyPath = null, /// Files to install into the app bundle. On macOS they land under /// `Contents/Resources/`. On desktop non-macOS they are copied - /// alongside the exe in `zig-out/bin/`. Ignored on PSP. + /// alongside the exe in `zig-out/bin/`. Ignored on PSP and 3DS. resources: []const Resource = &.{}, + /// 3DS: SMDH long description (the second line shown in the HOME + /// menu detail panel). Falls back to "Built with Aether" when empty. + smdh_long_description: []const u8 = "", + /// 3DS: SMDH author string. Empty leaves the field blank. + smdh_author: []const u8 = "", + /// 3DS: 48x48 PNG icon embedded in the SMDH. When null, libctru's + /// `default_icon.png` is used. + smdh_icon: ?std.Build.LazyPath = null, + /// 3DS: directory (or pre-built `.romfs`) embedded into the 3DSX. + romfs: ?std.Build.LazyPath = null, + /// Switch: NACP author string (shows under the title in the HOME + /// menu). Empty falls back to "Aether". + switch_author: []const u8 = "", + /// Switch: NACP version string (e.g. "1.0.0"). Empty falls back to + /// "1.0.0". + switch_version: []const u8 = "", + /// Switch: 256x256 JPEG icon embedded in the NRO. When null, libnx's + /// `default_icon.jpg` is used. + switch_icon: ?std.Build.LazyPath = null, + /// Switch: directory embedded into the NRO as RomFS. When null, no + /// RomFS is attached. + switch_romfs: ?std.Build.LazyPath = null, pub const Resource = struct { /// Source file to copy. @@ -416,6 +599,10 @@ pub fn exportArtifact(owner: *std.Build, b: *std.Build, exe: *std.Build.Step.Com // register on the downstream project's builder. const psp_dep = owner.dependency("pspsdk", .{}); _ = pspEbootPipeline(b, exe, psp_dep, opts); + } else if (config.platform == .nintendo_3ds) { + threedsxPipeline(b, exe, opts); + } else if (config.platform == .nintendo_switch) { + switchNroPipeline(b, exe, opts); } else if (config.platform == .macos) { macosAppBundle(b, exe, opts); } else { @@ -660,6 +847,387 @@ fn pspEbootPipeline(b: *std.Build, exe: *std.Build.Step.Compile, psp_dep: *std.B return result; } +fn patch3dsGeneratedC(b: *std.Build, exe: *std.Build.Step.Compile) std.Build.LazyPath { + const patch = b.addSystemCommand(&.{ + "perl", "-e", + \\local $/; + \\my $src = <>; + \\my %align16 = (); + \\while ($src =~ /zig_static_assert\(_Alignof \(struct ([A-Za-z0-9_]+)\) == 16,/g) { + \\ $align16{$1} = 1; + \\} + \\my $pending = ""; + \\for my $line (split /(?<=\n)/, $src) { + \\ if ($pending ne "") { + \\ if ($line =~ s/^};/} __attribute__((aligned(16)));/) { + \\ $pending = ""; + \\ } + \\ } elsif ($line =~ /^struct\s+([A-Za-z0-9_]+)\s*\{/) { + \\ my $name = $1; + \\ if ($align16{$name}) { + \\ if ($line !~ s/\};/} __attribute__((aligned(16)));/) { + \\ $pending = $name; + \\ } + \\ } + \\ } + \\ print $line; + \\} + }); + patch.addArtifactArg(exe); + return patch.captureStdOut(.{ .basename = b.fmt("{s}.3ds.c", .{exe.name}) }); +} + +/// Compiles the zig-emitted C with devkitARM, links against libctru, and +/// packages the ELF (plus an SMDH and optional RomFS) into a `.3dsx` +/// homebrew bundle. Mirrors `pspEbootPipeline` for the PSP toolchain. +fn threedsxPipeline(b: *std.Build, exe: *std.Build.Step.Compile, opts: ExportOptions) void { + // Derive a sibling target for compiler_rt: same cpu/abi/endianness + // as the game (so the calling conventions and float ABI match + // libctru), but os=freestanding (sidesteps the 3DS-specific posix + // dependencies in std) and the default object format (so this + // module compiles natively to an ELF object the gcc driver can + // consume, rather than .c). devkitARM's libgcc.a doesn't ship the + // 128-bit-int compiler-rt entry points (`__multi3`/`__divti3`/etc.), + // so we provide them ourselves from zig's compiler_rt. + const game_target = exe.root_module.resolved_target.?; + var crt_query = game_target.query; + crt_query.os_tag = .freestanding; + crt_query.ofmt = null; + // Explicitly pin the cpu model to whatever the game target + // resolved to (arm.mpcore for the 3DS). Without this, swapping + // os_tag to .freestanding loses the os-derived cpu choice and + // zig falls back to a generic baseline that emits ARMv6T2+ + // instructions (e.g. `mls`) the ARMv6K MPCore doesn't decode — + // crashes show up as "undefined instruction" in compiler_rt + // helpers like `__udivmodsi4`. + crt_query.cpu_model = .{ .explicit = game_target.result.cpu.model }; + const crt_target = b.resolveTargetQuery(crt_query); + + const compiler_rt_path = b.pathJoin(&.{ + b.graph.zig_lib_directory.path orelse ".", + "compiler_rt.zig", + }); + const crt_obj = b.addObject(.{ + .name = "aether_3ds_compiler_rt", + .root_module = b.createModule(.{ + .root_source_file = .{ .cwd_relative = compiler_rt_path }, + .target = crt_target, + .optimize = .ReleaseSmall, + .strip = true, + }), + }); + + const dkp = devkitProPath(b); + + // Strip the libc-overlap symbols from the compiler_rt object. + // zig's compiler_rt re-exports `memset`/`memcpy`/`memmove` and + // their `__aeabi_*` shims as WEAK; the `__aeabi_memset` and + // `memset` versions form a recursive `bl` cycle that blows the + // stack on 32-bit ARM. Newlib has real implementations, but the + // linker won't reach for them while compiler_rt's weak version + // already resolves the reference. `--strip-symbol` drops the + // exports so the references stay unresolved at compiler_rt and + // the linker pulls newlib's strong implementations from libc.a. + const strip_libc = b.addSystemCommand(&.{ + b.pathJoin(&.{ dkp, "devkitARM/bin/arm-none-eabi-objcopy" }), + "--localize-symbol=memset", + "--localize-symbol=memcpy", + "--localize-symbol=memmove", + "--localize-symbol=memcmp", + "--localize-symbol=__memset", + "--localize-symbol=__memcpy", + "--localize-symbol=__memmove", + "--localize-symbol=__memcpy_chk", + "--localize-symbol=__aeabi_memset", + "--localize-symbol=__aeabi_memset4", + "--localize-symbol=__aeabi_memset8", + "--localize-symbol=__aeabi_memcpy", + "--localize-symbol=__aeabi_memcpy4", + "--localize-symbol=__aeabi_memcpy8", + "--localize-symbol=__aeabi_memmove", + "--localize-symbol=__aeabi_memmove4", + "--localize-symbol=__aeabi_memmove8", + "--localize-symbol=strlen", + "--localize-symbol=bcmp", + }); + strip_libc.addArtifactArg(crt_obj); + const crt_clean = strip_libc.addOutputFileArg("aether_3ds_compiler_rt.o"); + const gcc = b.pathJoin(&.{ dkp, "devkitARM/bin/arm-none-eabi-gcc" }); + const tool_3dsx = b.pathJoin(&.{ dkp, "tools/bin/3dsxtool" }); + const tool_smdh = b.pathJoin(&.{ dkp, "tools/bin/smdhtool" }); + const ctru_inc = b.pathJoin(&.{ dkp, "libctru/include" }); + const ctru_lib = b.pathJoin(&.{ dkp, "libctru/lib" }); + const default_icon = b.pathJoin(&.{ dkp, "libctru/default_icon.png" }); + const zig_h_src = b.pathJoin(&.{ b.graph.zig_lib_directory.path orelse ".", "zig.h" }); + + // zig.h hardcodes `zig_align(16)` for its `zig_i128`/`zig_u128` + // struct fallback (used when `__int128` isn't supported by the C + // compiler -- gcc on 32-bit ARM is one such target). Zig's ARM + // layout uses 8-byte alignment for those integer types, while f128 + // still needs 16-byte alignment. Patch only the integer fallback + // typedefs, then route unsupported ARM f128 through zig.h's vector + // fallback with explicit 16-byte alignment. + const patch = b.addSystemCommand(&.{"perl"}); + patch.addArgs(&.{ + "-0pe", + \\s/typedef struct \{ zig_align\(16\) uint64_t lo; uint64_t hi; \} zig_u128;/typedef struct { zig_align(8) uint64_t lo; uint64_t hi; } zig_u128;/g; + \\s/typedef struct \{ zig_align\(16\) uint64_t lo; int64_t hi; \} zig_i128;/typedef struct { zig_align(8) uint64_t lo; int64_t hi; } zig_i128;/g; + \\s/typedef struct \{ zig_align\(16\) uint64_t hi; uint64_t lo; \} zig_u128;/typedef struct { zig_align(8) uint64_t hi; uint64_t lo; } zig_u128;/g; + \\s/typedef struct \{ zig_align\(16\) int64_t hi; uint64_t lo; \} zig_i128;/typedef struct { zig_align(8) int64_t hi; uint64_t lo; } zig_i128;/g; + \\s/#if defined\(zig_darwin\) \|\| defined\(zig_aarch64\)/#if defined(zig_darwin) || defined(zig_aarch64) || defined(zig_arm)/; + \\s/typedef __attribute__\(\(__vector_size__\(2 \* sizeof\(uint64_t\)\)\)\) uint64_t zig_v2u64;/typedef __attribute__((__vector_size__(2 * sizeof(uint64_t)), aligned(16))) uint64_t zig_v2u64;/; + }); + patch.addFileArg(.{ .cwd_relative = zig_h_src }); + const patched_zig_h = patch.captureStdOut(.{ .basename = "zig.h" }); + + const include_wf = b.addWriteFiles(); + _ = include_wf.addCopyFile(patched_zig_h, "zig.h"); + + const shim_wf = b.addWriteFiles(); + const exception_shim = shim_wf.add("aether_3ds_exception.c", + \\#include <3ds.h> + \\ + \\extern void aether3dsExceptionHandler(ERRF_ExceptionInfo *excep, CpuRegisters *regs); + \\ + \\void aether3dsInstallExceptionHandler(void *stack_top) { + \\ threadOnException(aether3dsExceptionHandler, stack_top, WRITE_DATA_TO_HANDLER_STACK); + \\} + \\ + ); + + // Standard 3DS arch flags from devkitPro's template Makefile. + const arch = [_][]const u8{ + "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft", + }; + + // Single-shot compile + link via the gcc driver. 3dsx.specs pulls + // in `_3dsx_crt0` (which calls our exported `main`) and the 3DSX + // linker script. + const link = b.addSystemCommand(&.{gcc}); + link.addArgs(&arch); + link.addArgs(&.{ + "-mword-relocations", + "-ffunction-sections", + "-D__3DS__", + "-DARM11", + if (exe.root_module.optimize != .Debug or exe.root_module.optimize == .ReleaseSmall) "-O2" else if (exe.root_module.optimize == .ReleaseSmall) "-Os" else "-O0", + if (exe.root_module.optimize == .Debug or exe.root_module.optimize == .ReleaseSafe) "-g" else "-g0", + "-specs=3dsx.specs", + "-Wl,--wrap=threadCreate", + "-Wl,--no-warn-execstack", + }); + link.addArgs(&.{ + // Pin the C standard to C11. zig.h picks `[[noreturn]]` under + // C23 but emits it in attribute-list position that gcc rejects; + // C11's `_Noreturn` is what zig's emitter actually targets. + "-std=gnu11", + }); + link.addArgs(&.{ + // zig's -ofmt=c emitter treats `uintptr_t` and `uint32_t` as + // interchangeable on 32-bit ARM (they ARE the same width) but + // gcc 14+ promotes the resulting pointer-type mismatch from a + // warning to an error. Demote it and a couple of related + // chatters; we don't author this C and there's nothing + // actionable in the warnings. + "-Wno-incompatible-pointer-types", + "-Wno-int-conversion", + "-Wno-builtin-declaration-mismatch", + }); + link.addArg(b.fmt("-I{s}", .{ctru_inc})); + link.addPrefixedDirectoryArg("-I", include_wf.getDirectory()); + link.addArg("-x"); + link.addArg("c"); + link.addFileArg(patch3dsGeneratedC(b, exe)); + link.addFileArg(exception_shim); + // Reset language so gcc treats subsequent inputs by extension; the + // compiler_rt object is ELF arm and `-x c` would mis-parse it. + link.addArg("-x"); + link.addArg("none"); + link.addFileArg(crt_clean); + link.addArg(b.fmt("-L{s}", .{ctru_lib})); + link.addArgs(&.{ "-lcitro3d", "-lctru", "-lm" }); + link.addArg("-o"); + const elf = link.addOutputFileArg(b.fmt("{s}.elf", .{exe.name})); + + // SMDH metadata (HOME-menu name, description, author, icon). + const smdh_run = b.addSystemCommand(&.{ tool_smdh, "--create" }); + smdh_run.addArg(if (opts.title.len > 0) opts.title else exe.name); + smdh_run.addArg(if (opts.smdh_long_description.len > 0) + opts.smdh_long_description + else + "Built with Aether"); + smdh_run.addArg(opts.smdh_author); + if (opts.smdh_icon) |icon| + smdh_run.addFileArg(icon) + else + smdh_run.addArg(default_icon); + const smdh = smdh_run.addOutputFileArg(b.fmt("{s}.smdh", .{exe.name})); + + // ELF -> 3DSX. The smdh and (optional) romfs ride in via the + // `--smdh=` / `--romfs=` flag-form args. + const pack = b.addSystemCommand(&.{tool_3dsx}); + pack.addFileArg(elf); + const threedsx = pack.addOutputFileArg(b.fmt("{s}.3dsx", .{exe.name})); + pack.addPrefixedFileArg("--smdh=", smdh); + if (opts.romfs) |r| pack.addPrefixedDirectoryArg("--romfs=", r); + + if (opts.output_dir) |dir| { + const alloc = b.allocator; + b.getInstallStep().dependOn(&b.addInstallBinFile( + threedsx, + std.mem.concat(alloc, u8, &.{ dir, "/", exe.name, ".3dsx" }) catch @panic("OOM"), + ).step); + b.getInstallStep().dependOn(&b.addInstallBinFile( + elf, + std.mem.concat(alloc, u8, &.{ dir, "/", exe.name, ".elf" }) catch @panic("OOM"), + ).step); + } else { + b.getInstallStep().dependOn(&b.addInstallBinFile( + threedsx, + b.fmt("{s}.3dsx", .{exe.name}), + ).step); + } +} + +/// Compiles the zig-emitted C with devkitA64, links against libnx, and +/// packages the ELF (plus a NACP and optional RomFS) into a `.nro` +/// homebrew bundle. Mirrors `threedsxPipeline` for the Switch toolchain. +fn switchNroPipeline(b: *std.Build, exe: *std.Build.Step.Compile, opts: ExportOptions) void { + // aarch64 GCC supports __int128 natively, so we don't need the + // `zig.h` align(16) -> align(8) patch the 3DS pipeline applies. + // + // We do still need a compiler_rt object: zig.h calls helpers like + // `__floatunsisf` / `__floatundidf` / `__floatdisf` unconditionally, + // but devkitA64's libgcc doesn't ship them — gcc on aarch64 with + // hardware FP inlines these casts as `ucvtf`/`scvtf`, so the + // helpers are dead code in normal compilations. Zig's emitted C + // takes the slow path, so we drop in zig's own compiler_rt to + // satisfy the references. Like the 3DS pipeline we localize + // symbols that overlap newlib (memset/memcpy/...) so the linker + // pulls newlib's strong implementations. + const game_target = exe.root_module.resolved_target.?; + var crt_query = game_target.query; + crt_query.os_tag = .freestanding; + crt_query.ofmt = null; + crt_query.cpu_model = .{ .explicit = game_target.result.cpu.model }; + const crt_target = b.resolveTargetQuery(crt_query); + + const compiler_rt_path = b.pathJoin(&.{ + b.graph.zig_lib_directory.path orelse ".", + "compiler_rt.zig", + }); + const crt_obj = b.addObject(.{ + .name = "aether_switch_compiler_rt", + .root_module = b.createModule(.{ + .root_source_file = .{ .cwd_relative = compiler_rt_path }, + .target = crt_target, + .optimize = .ReleaseSmall, + .strip = true, + // Switch homebrew uses libnx's switch.specs which links + // with `-z text`. PIC is mandatory for any object that + // ends up in the read-only .text segment, otherwise the + // linker rejects the dynamic absolute relocations. + .pic = true, + }), + }); + + const dkp = devkitProPath(b); + + const strip_libc = b.addSystemCommand(&.{ + b.pathJoin(&.{ dkp, "devkitA64/bin/aarch64-none-elf-objcopy" }), + "--localize-symbol=memset", + "--localize-symbol=memcpy", + "--localize-symbol=memmove", + "--localize-symbol=memcmp", + "--localize-symbol=strlen", + "--localize-symbol=bcmp", + }); + strip_libc.addArtifactArg(crt_obj); + const crt_clean = strip_libc.addOutputFileArg("aether_switch_compiler_rt.o"); + const gcc = b.pathJoin(&.{ dkp, "devkitA64/bin/aarch64-none-elf-gcc" }); + const tool_elf2nro = b.pathJoin(&.{ dkp, "tools/bin/elf2nro" }); + const tool_nacp = b.pathJoin(&.{ dkp, "tools/bin/nacptool" }); + const libnx_inc = b.pathJoin(&.{ dkp, "libnx/include" }); + const libnx_lib = b.pathJoin(&.{ dkp, "libnx/lib" }); + const libnx_specs = b.pathJoin(&.{ dkp, "libnx/switch.specs" }); + const default_icon = b.pathJoin(&.{ dkp, "libnx/default_icon.jpg" }); + + // Standard Switch arch flags from devkitPro's switch_rules / + // example Makefiles. `-mtp=soft` matches what libnx is built + // against; mismatching the TLS access mode crashes on the first + // thread-local read. + const arch = [_][]const u8{ + "-march=armv8-a+crc+crypto", "-mtune=cortex-a57", "-mtp=soft", "-fPIE", + }; + + const link = b.addSystemCommand(&.{gcc}); + link.addArgs(&arch); + link.addArgs(&.{ + "-ffunction-sections", "-fdata-sections", + "-D__SWITCH__", "-O2", + "-g", b.fmt("-specs={s}", .{libnx_specs}), + // Pin the C standard to C11 (zig.h targets `_Noreturn`, not + // C23's `[[noreturn]]`). + "-std=gnu11", + // zig's -ofmt=c emitter has known pointer/int-conversion + // mismatches that gcc 14+ promotes to errors. We don't author + // the C, so demote them. + "-Wno-incompatible-pointer-types", + "-Wno-int-conversion", "-Wno-builtin-declaration-mismatch", + }); + link.addArg(b.fmt("-I{s}", .{libnx_inc})); + // zig's emitted C `#include "zig.h"`. The header lives in zig's + // own lib directory; point gcc at it. aarch64 GCC's __int128 + // alignment matches zig's, so no patching is needed (unlike 3DS). + link.addArg(b.fmt("-I{s}", .{b.graph.zig_lib_directory.path orelse "."})); + link.addArg("-x"); + link.addArg("c"); + link.addArtifactArg(exe); + link.addArg("-x"); + link.addArg("none"); + link.addFileArg(crt_clean); + link.addArg(b.fmt("-L{s}", .{libnx_lib})); + link.addArgs(&.{ "-ldeko3d", "-lnx", "-lm" }); + link.addArg("-o"); + const elf = link.addOutputFileArg(b.fmt("{s}.elf", .{exe.name})); + + // NACP metadata (HOME-menu title, author, version). + const nacp_run = b.addSystemCommand(&.{ tool_nacp, "--create" }); + nacp_run.addArg(if (opts.title.len > 0) opts.title else exe.name); + nacp_run.addArg(if (opts.switch_author.len > 0) opts.switch_author else "Aether"); + nacp_run.addArg(if (opts.switch_version.len > 0) opts.switch_version else "1.0.0"); + const nacp = nacp_run.addOutputFileArg(b.fmt("{s}.nacp", .{exe.name})); + + // ELF -> NRO. The icon, NACP, and (optional) romfs ride in via + // flag-form args. + const pack = b.addSystemCommand(&.{tool_elf2nro}); + pack.addFileArg(elf); + const nro = pack.addOutputFileArg(b.fmt("{s}.nro", .{exe.name})); + if (opts.switch_icon) |icon| + pack.addPrefixedFileArg("--icon=", icon) + else + pack.addArg(b.fmt("--icon={s}", .{default_icon})); + pack.addPrefixedFileArg("--nacp=", nacp); + if (opts.switch_romfs) |r| pack.addPrefixedDirectoryArg("--romfsdir=", r); + + if (opts.output_dir) |dir| { + const alloc = b.allocator; + b.getInstallStep().dependOn(&b.addInstallBinFile( + nro, + std.mem.concat(alloc, u8, &.{ dir, "/", exe.name, ".nro" }) catch @panic("OOM"), + ).step); + b.getInstallStep().dependOn(&b.addInstallBinFile( + elf, + std.mem.concat(alloc, u8, &.{ dir, "/", exe.name, ".elf" }) catch @panic("OOM"), + ).step); + } else { + b.getInstallStep().dependOn(&b.addInstallBinFile( + nro, + b.fmt("{s}.nro", .{exe.name}), + ).step); + } +} + /// Registers a shader pair for the game executable. Slang sources are /// compiled to SPIR-V (Vulkan) or GLSL (OpenGL) via slangc. On /// shaderless platforms (PSP), empty stubs are provided. @@ -670,6 +1238,86 @@ fn pspEbootPipeline(b: *std.Build, exe: *std.Build.Step.Compile, psp_dep: *std.B /// Aether.addShader(ae_dep.builder, b, exe, config, "basic", .{ ... }); /// pub fn addShader(owner: *std.Build, b: *std.Build, exe: *std.Build.Step.Compile, config: Config, comptime name: []const u8, paths: ShaderPaths) void { + const root_module = userRootModule(exe); + + if (config.platform == .nintendo_3ds and config.gfx == .default) { + const picasso = b.pathJoin(&.{ devkitProPath(b), "tools/bin/picasso" }); + const sources = b.addWriteFiles(); + const vert_src = sources.add(name ++ "_3ds.v.pica", + \\.fvec projection[4] + \\ + \\.constf myconst(0.0, 1.0, 0.0, 0.0) + \\.alias ones myconst.yyyy + \\ + \\.out outpos position + \\.out outtc0 texcoord0 + \\.out outclr color + \\ + \\.alias inpos v0 + \\.alias inuv v1 + \\.alias inclr v2 + \\ + \\.proc main + \\ mov r0.xyz, inpos + \\ mov r0.w, ones + \\ + \\ dp4 outpos.x, projection[0], r0 + \\ dp4 outpos.y, projection[1], r0 + \\ dp4 outpos.z, projection[2], r0 + \\ dp4 outpos.w, projection[3], r0 + \\ + \\ mov outtc0, inuv + \\ mov outclr, inclr + \\ end + \\.end + \\ + ); + const vert = addPicassoStep(b, picasso, name ++ ".shbin", vert_src); + const empty = b.addWriteFiles(); + const frag = empty.add(name ++ "_3ds_frag_stub", ""); + root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = vert }); + root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = frag }); + return; + } + + if (config.platform == .nintendo_switch and config.gfx == .default) { + const uam = b.pathJoin(&.{ devkitProPath(b), "tools/bin/uam" }); + const sources = b.addWriteFiles(); + const vert_src = sources.add(name ++ "_switch.vert.glsl", + \\#version 460 + \\ + \\layout (location = 0) in vec3 inPosition; + \\layout (location = 1) in vec4 inColor; + \\ + \\layout (location = 0) out vec4 outColor; + \\ + \\void main() + \\{ + \\ gl_Position = vec4(inPosition, 1.0); + \\ outColor = inColor; + \\} + \\ + ); + const frag_src = sources.add(name ++ "_switch.frag.glsl", + \\#version 460 + \\ + \\layout (location = 0) in vec4 inColor; + \\layout (location = 0) out vec4 outColor; + \\ + \\void main() + \\{ + \\ outColor = inColor; + \\} + \\ + ); + + const vert = addUamStep(b, uam, "vert", name ++ ".vert.dksh", vert_src); + const frag = addUamStep(b, uam, "frag", name ++ ".frag.dksh", frag_src); + root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = vert }); + root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = frag }); + return; + } + switch (config.gfx) { .vulkan => { const slangc = slangcPath(owner); @@ -683,8 +1331,8 @@ pub fn addShader(owner: *std.Build, b: *std.Build, exe: *std.Build.Step.Compile, "-DVULKAN", "-entry", "fragmentMain", "-stage", "fragment", }, name ++ ".frag.spv", paths.slang); - if (vert) |v| exe.root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = v }); - if (frag) |f| exe.root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = f }); + if (vert) |v| root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = v }); + if (frag) |f| root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = f }); }, .opengl => { const slangc = slangcPath(owner); @@ -698,17 +1346,17 @@ pub fn addShader(owner: *std.Build, b: *std.Build, exe: *std.Build.Step.Compile, "-profile", "glsl_450", "-entry", "fragmentMain", "-stage", "fragment", }, name ++ ".frag.glsl", paths.slang); - if (vert) |v| exe.root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = v }); - if (frag) |f| exe.root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = f }); + if (vert) |v| root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = v }); + if (frag) |f| root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = f }); }, .default, .headless => { // Provide empty stubs so @embedFile(name ++ "_vert") still compiles. const empty = b.addWriteFiles(); const stub = empty.add(name ++ "_stub", ""); - exe.root_module.addAnonymousImport(name ++ "_vert", .{ + root_module.addAnonymousImport(name ++ "_vert", .{ .root_source_file = stub, }); - exe.root_module.addAnonymousImport(name ++ "_frag", .{ + root_module.addAnonymousImport(name ++ "_frag", .{ .root_source_file = stub, }); }, @@ -723,10 +1371,11 @@ pub fn build(b: *std.Build) void { const overrides: Config.Overrides = .{ .gfx = b.option(Gfx, "gfx", "Graphics backend override (default: auto-detect from target)"), - .audio = b.option(Audio, "audio", "Audio backend override (default: .none on macOS, .default elsewhere)"), + .audio = b.option(Audio, "audio", "Audio backend override (default: platform default)"), .psp_display_mode = b.option(PspDisplayMode, "psp-display", "PSP display mode: rgba8888 (32-bit, default) or rgb565 (16-bit)"), .psp_mipmaps = b.option(bool, "psp-mipmaps", "PSP: generate mip levels for VRAM-resident textures (default: false)"), .use_cwd = b.option(bool, "use-cwd", "Force resources+data dirs to CWD (debug/CI convenience; default: false)"), + .nintendo_switch = b.option(bool, "nintendo-switch", "Build for Nintendo Switch (requires -Dtarget=aarch64-freestanding-none and devkitA64/libnx)"), }; const config = Config.resolve(target, overrides); @@ -743,22 +1392,82 @@ pub fn build(b: *std.Build) void { .slang = b.path("test/shaders/basic.slang"), }); + const nintendo_romfs = b.addWriteFiles(); + _ = nintendo_romfs.addCopyFile(b.path("test/test.png"), "test.png"); + _ = nintendo_romfs.addCopyFile(b.path("test/calm1.wav"), "calm1.wav"); + _ = nintendo_romfs.addCopyFile(b.path("test/grass1.wav"), "grass1.wav"); + exportArtifact(b, b, exe, config, .{ .title = "Aether", - .output_dir = "Aether-PSP", + .output_dir = switch (config.platform) { + .psp => "Aether-PSP", + .nintendo_3ds => "Aether-3DS", + .nintendo_switch => "Aether-Switch", + else => null, + }, + .smdh_long_description = "Aether engine test app", + .smdh_author = "Aether", + .resources = &.{ + .{ .path = b.path("test/test.png"), .name = "test.png" }, + .{ .path = b.path("test/calm1.wav"), .name = "calm1.wav" }, + .{ .path = b.path("test/grass1.wav"), .name = "grass1.wav" }, + }, + .romfs = if (config.platform == .nintendo_3ds) nintendo_romfs.getDirectory() else null, + .switch_romfs = if (config.platform == .nintendo_switch) nintendo_romfs.getDirectory() else null, }); const run_step = b.step("run", "Run the app"); - const run_cmd = b.addRunArtifact(exe); - run_step.dependOn(&run_cmd.step); + if (config.platform == .nintendo_3ds) { + // 3DS can't run natively on the host. The 3DS-side homebrew + // launcher listens for incoming .3dsx pushes on port 17491; + // `3dslink` finds it via mDNS or accepts an explicit IP. + const link_cmd = add3dslink(b, b.getInstallPath(.bin, "Aether-3DS/Aether.3dsx")); + + const link_step = b.step("3dslink", "Push the 3dsx to a networked 3DS via 3dslink"); + link_step.dependOn(&link_cmd.step); + + // `zig build run` aliases to 3dslink for 3DS so the same + // command works across host/PSP/3DS workflows. + run_step.dependOn(&link_cmd.step); + } else if (config.platform == .nintendo_switch) { + // Switch can't run natively on the host. nxlink pushes the + // .nro to nx-hbloader on a networked Switch (mDNS by default, + // explicit IP via -a). + const dkp = devkitProPath(b); + const link_cmd = b.addSystemCommand(&.{b.pathJoin(&.{ dkp, "tools/bin/nxlink" })}); + if (b.option([]const u8, "nxlink-address", "Switch: target IP for nxlink push (default: mDNS auto-discover)")) |ip| { + link_cmd.addArgs(&.{ "-a", ip }); + } + if (b.option(u32, "nxlink-retries", "Switch: nxlink retry count (default: 10)")) |n| { + link_cmd.addArgs(&.{ "-r", b.fmt("{d}", .{n}) }); + } + if (b.option(bool, "nxlink-server", "Switch: pass -s so nxlink stays listening after upload (relays stdout/stderr from nro)") orelse false) { + link_cmd.addArg("-s"); + } + link_cmd.addArg(b.getInstallPath(.bin, "Aether-Switch/Aether.nro")); + link_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + // nxlink takes nro args after a `--args` separator. + link_cmd.addArg("--args"); + link_cmd.addArgs(args); + } - run_cmd.step.dependOn(b.getInstallStep()); - if (b.args) |args| { - run_cmd.addArgs(args); + const link_step = b.step("nxlink", "Push the nro to a networked Switch via nxlink"); + link_step.dependOn(&link_cmd.step); + + // `zig build run` aliases to nxlink for Switch so the same + // command works across host/PSP/3DS/Switch workflows. + run_step.dependOn(&link_cmd.step); + } else { + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); } - // Engine unit tests (desktop only) - if (config.platform != .psp) { + // Engine unit tests (desktop only — PSP/3DS/Switch pull in symbols + // that can't be linked or analyzed under the test runner) + if (config.platform != .psp and config.platform != .nintendo_3ds and config.platform != .nintendo_switch) { const mod_tests = b.addTest(.{ .root_module = exe.root_module.import_table.get("aether").?, }); diff --git a/build.zig.zon b/build.zig.zon index c6b279c..74faae2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -76,15 +76,16 @@ .url = "git+https://github.com/Snektron/vulkan-zig#3adbeefbc833c12656791d304b37a6315b357745", .hash = "vulkan-0.0.0-r7YtxyBsAwD35FvkFhT2xruTNdCByD3TtGw44XWtfun8", }, - .zaudio = .{ - .url = "git+https://github.com/IridescentRose/zaudio?ref=update_zig_16#54e45e002005448623bff0ba06baa919bd036e03", - .hash = "zaudio-0.11.0-dev-_M-91l8yQQDTAb3Hk8V-lyP88DGgLzS9QNUkmpbCN_PK", - }, .system_sdk = .{ .url = "https://github.com/zig-gamedev/system_sdk/archive/c0dbf11cdc17da5904ea8a17eadc54dee26567ec.tar.gz", .hash = "system_sdk-0.3.0-dev-alwUNnYaaAJAtIdE2fg4NQfDqEKs7QCXy_qYukAOBfmF", .lazy = true, }, + .sdl3 = .{ + .url = "git+https://codeberg.org/7Games/zig-sdl3?ref=master#40c2e4b579aa556db37a502c936426aa1c8b5c95", + .hash = "sdl3-0.2.0-NmT1Q0mFJwBi9kZmArzh2rfJ_mFshydV0zPGULVlpACc", + .lazy = true, + }, }, .paths = .{ "build.zig", diff --git a/src/audio/audio.zig b/src/audio/audio.zig index 98cd9c2..8892733 100644 --- a/src/audio/audio.zig +++ b/src/audio/audio.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Vec3 = @import("../math/math.zig").Vec3; const platform_audio = @import("../platform/audio.zig"); +const options = @import("options"); // -- types ------------------------------------------------------------------- @@ -13,6 +14,7 @@ pub const mixer_mod = @import("mixer.zig"); pub const SoundHandle = mixer_mod.SoundHandle; pub const PlayOptions = mixer_mod.PlayOptions; pub const Priority = mixer_mod.Priority; +pub const enabled = options.config.audio != .none; // -- forwarding to the instantiated mixer ------------------------------------ diff --git a/src/core/input/input.zig b/src/core/input/input.zig index 9d23303..978655a 100644 --- a/src/core/input/input.zig +++ b/src/core/input/input.zig @@ -277,7 +277,7 @@ pub fn register_action_set(name: []const u8) !ActionSetHandle { pub fn add_action(set: ActionSetHandle, name: []const u8, kind: ActionKind) !void { const s = set_ptr(set) orelse return error.UnknownActionSet; - if (s.actions.contains(name)) return error.ActionAlreadyExists; + if (action_ptr(s, name) != null) return error.ActionAlreadyExists; try s.actions.put(alloc, name, .{ .kind = kind, .bindings = .empty, @@ -288,7 +288,7 @@ pub fn add_action(set: ActionSetHandle, name: []const u8, kind: ActionKind) !voi pub fn bind_action(set: ActionSetHandle, action_name: []const u8, b: Binding) !void { const s = set_ptr(set) orelse return error.UnknownActionSet; - const a = s.actions.getPtr(action_name) orelse return error.ActionNotFound; + const a = action_ptr(s, action_name) orelse return error.ActionNotFound; if (a.kind == .vector2 and b.component == .none) return error.Vector2BindingNeedsComponent; try a.bindings.append(alloc, b); } @@ -310,7 +310,7 @@ pub fn get_action(name: []const u8) ?ActionValue { const top = stack.top() orelse return null; const s = set_ptr(top.actions) orelse return null; if (!s.installed) return null; - const a = s.actions.getPtr(name) orelse return null; + const a = action_ptr(s, name) orelse return null; return a.current_value; } @@ -479,6 +479,16 @@ fn set_ptr_or_null(handle: ActionSetHandle) ?*ActionSet { return set_ptr(handle); } +fn action_ptr(set: *ActionSet, name: []const u8) ?*Action { + if (set.actions.getPtr(name)) |action| return action; + + var it = set.actions.iterator(); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.key_ptr.*, name)) return entry.value_ptr; + } + return null; +} + fn route_text_to_session(text: []const u8) void { const top = stack.top() orelse return; if (!top.consumes_text) return; diff --git a/src/core/paths.zig b/src/core/paths.zig index 79c488d..da9a5b9 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -24,10 +24,13 @@ //! per project style guide they go through `std.Io` / `std.process`. const std = @import("std"); -const builtin = @import("builtin"); const options = @import("options"); const Io = std.Io; +const NintendoIo = if (options.config.platform == .nintendo_3ds or options.config.platform == .nintendo_switch) + @import("../platform/c_io.zig") +else + void; /// Engine-owned directory handles. Cleared via `close()` at engine shutdown. pub const Dirs = struct { @@ -48,6 +51,8 @@ pub const Dirs = struct { if (self.resources.handle != cwd_handle) self.resources.close(io); if (self.data.handle != cwd_handle and self.data.handle != self.resources.handle) self.data.close(io); + + if (NintendoIo != void) NintendoIo.deinitAppDirs(); } }; @@ -82,13 +87,15 @@ pub fn resolve( // and debug/CI builds where state co-located with the binary is a // feature, not a bug. if (options.config.use_cwd) { + if (NintendoIo != void) NintendoIo.useCwdDirs(); return .{ .resources = Io.Dir.cwd(), .data = Io.Dir.cwd() }; } - return switch (builtin.os.tag) { + return switch (options.config.platform) { .macos => resolve_macos(io, environ_map, app_name), .windows => resolve_windows(io, environ_map, app_name), .linux => resolve_linux(io, environ_map, app_name), + .nintendo_3ds, .nintendo_switch => resolve_nintendo(io, app_name), // PSP: both dirs collapse to CWD. The EBOOT and its siblings all // live under `ms0:/PSP/GAME//`; the runtime sets CWD there // before main. No separation to enforce. @@ -100,6 +107,23 @@ pub fn resolve( }; } +fn resolve_nintendo(io: Io, app_name: []const u8) Error!Dirs { + NintendoIo.mountData(); + errdefer NintendoIo.deinitAppDirs(); + + var data_buf: [Io.Dir.max_path_bytes]u8 = undefined; + const data_path = NintendoIo.dataRoot(&data_buf, app_name) catch return error.PathTooLong; + const data = try Io.Dir.cwd().createDirPathOpen(io, data_path, .{ .open_options = .{ .iterate = true } }); + errdefer data.close(io); + + const resources = if (NintendoIo.mountResources()) + Io.Dir.openDirAbsolute(io, "romfs:/", .{}) catch data + else + data; + + return .{ .resources = resources, .data = data }; +} + // -- macOS -------------------------------------------------------------------- fn resolve_macos( diff --git a/src/engine.zig b/src/engine.zig index c94645c..1d127d0 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -87,7 +87,7 @@ pub const Engine = struct { trackers: [TRACKER_COUNT]CategoryTracker, running: bool, vsync: bool, - state: *const Core.State, + state: Core.State, dirs: Core.paths.Dirs, pub const Config = struct { @@ -127,7 +127,7 @@ pub const Engine = struct { self.io = sys_io; self.running = true; self.vsync = config.vsync; - self.state = state; + self.state = state.*; self.pool = memory.PoolAlloc.init(mem, "main"); const inner = self.pool.allocator(); @@ -150,7 +150,7 @@ pub const Engine = struct { try Platform.init(self, config.width, config.height, config.title, config.fullscreen, config.vsync, config.resizable); try Rendering.Texture.init_defaults(self.allocator(.render)); - try Core.state_machine.init(self, state); + try Core.state_machine.init(self, &self.state); } pub fn deinit(self: *Engine) void { @@ -199,27 +199,26 @@ pub const Engine = struct { } pub fn report(self: *const Engine) void { - const mib = 1024.0 * 1024.0; Util.engine_logger.info("--- memory pools ---", .{}); inline for (std.meta.fields(Pool)) |f| { const p: Pool = @enumFromInt(f.value); const used = self.pool_used(p); const budget = self.pool_budget(p); const remaining = self.pool_remaining(p); - Util.engine_logger.info(" {s}: {}/{} bytes ({d:.3}/{d:.3} MiB, {} remaining)", .{ + Util.engine_logger.info(" {s}: {}/{} bytes ({}/{} KiB, {} remaining)", .{ f.name, used, budget, - @as(f64, @floatFromInt(used)) / mib, - @as(f64, @floatFromInt(budget)) / mib, + used / 1024, + budget / 1024, remaining, }); } - Util.engine_logger.info(" total: {}/{} bytes ({d:.3}/{d:.3} MiB)", .{ + Util.engine_logger.info(" total: {}/{} bytes ({}/{} KiB)", .{ self.pool.used, self.pool.budget, - @as(f64, @floatFromInt(self.pool.used)) / mib, - @as(f64, @floatFromInt(self.pool.budget)) / mib, + self.pool.used / 1024, + self.pool.budget / 1024, }); Util.engine_logger.info("--------------------", .{}); } @@ -228,33 +227,58 @@ pub const Engine = struct { const US_PER_S: u64 = std.time.us_per_s; const NS_PER_US: i64 = 1000; - // Fixed-step rates -- PSP targets 60 Hz display - const UPDATES_HZ: u32 = if (options.config.platform == .psp) 60 else 144; + // Fixed-step rates -- handheld backends target 60 Hz displays. + const UPDATES_HZ: u32 = if (options.config.platform == .psp or options.config.platform == .nintendo_3ds) 60 else 144; const TICKS_HZ: u32 = 20; const UPDATE_US: u64 = US_PER_S / UPDATES_HZ; const TICK_US: u64 = US_PER_S / TICKS_HZ; const update_budget_ns: i64 = @as(i64, @intCast(UPDATE_US)) * NS_PER_US; - var clock = std.Io.Clock.real; + var clock = std.Io.Clock.boot; + const run_start_ns = clock.now(self.io).toNanoseconds(); + const fps_window_us: i64 = @intCast(US_PER_S); - var last_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000)); + var last_us: i64 = 0; var update_accum: i64 = 0; var tick_accum: i64 = 0; + var stale_time_frames: u32 = 0; const report_fps = options.config.gfx != .headless; var fps_count: u32 = 0; - var fps_window_end: i64 = last_us + US_PER_S; + var fps_window_end: i64 = saturatingAddI64(last_us, fps_window_us); while (self.running) { - const now_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000)); - var frame_dt_us: i64 = now_us - last_us; - last_us = now_us; + const now_us = elapsedUsSince(run_start_ns, clock.now(self.io).toNanoseconds()); + var frame_dt_us = saturatingSubI64(now_us, last_us); + var synthetic_frame_dt = false; + + if (frame_dt_us <= 0) { + frame_dt_us = 0; + if (options.config.platform == .nintendo_3ds and self.vsync) { + stale_time_frames +|= 1; + if (stale_time_frames >= 2) { + frame_dt_us = @intCast(US_PER_S / 60); + synthetic_frame_dt = true; + } + } + } else { + stale_time_frames = 0; + } if (frame_dt_us > 500_000) frame_dt_us = 500_000; + last_us = if (synthetic_frame_dt) saturatingAddI64(last_us, frame_dt_us) else now_us; - update_accum += frame_dt_us; - tick_accum += frame_dt_us; + update_accum = saturatingAddI64(update_accum, frame_dt_us); + tick_accum = saturatingAddI64(tick_accum, frame_dt_us); + + const platform_start_ns = clock.now(self.io).toNanoseconds(); + Platform.input.update(); + Platform.update(self); + Core.input.update(); + const platform_done_ns = clock.now(self.io).toNanoseconds(); + var pre_update_elapsed_ns = elapsedNsBetween(platform_start_ns, platform_done_ns); + if (!self.running) break; // ---- fixed-rate TICK steps (e.g., 20 Hz logic) ---- var is_tick_frame = false; @@ -263,35 +287,29 @@ pub const Engine = struct { while (tick_accum >= tick_us) { @branchHint(.unpredictable); is_tick_frame = true; - const tick_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds()); + const tick_start_ns = clock.now(self.io).toNanoseconds(); try Core.state_machine.tick(self); - const tick_end_ns: i64 = @truncate(clock.now(self.io).toNanoseconds()); - tick_cost_ns += tick_end_ns - tick_start_ns; + const tick_end_ns = clock.now(self.io).toNanoseconds(); + tick_cost_ns = saturatingAddI64(tick_cost_ns, elapsedNsBetween(tick_start_ns, tick_end_ns)); tick_accum -= tick_us; } - // ---- fixed-rate UPDATE steps (input update & interpolation) ---- + // ---- fixed-rate UPDATE steps (simulation & interpolation) ---- const UPDATE_DT_S: f32 = @as(f32, @floatFromInt(UPDATE_US)) / @as(f32, US_PER_S); while (update_accum >= UPDATE_US) { @branchHint(.unpredictable); - const step_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds()); - Platform.input.update(); - Platform.update(self); - Core.input.update(); - const engine_done_ns: i64 = @truncate(clock.now(self.io).toNanoseconds()); - const engine_elapsed_ns = engine_done_ns - step_start_ns; - const budget = Util.BudgetContext{ .phase_budget_ns = update_budget_ns, - .engine_elapsed_ns = engine_elapsed_ns, - .remaining_ns = update_budget_ns - engine_elapsed_ns, + .engine_elapsed_ns = pre_update_elapsed_ns, + .remaining_ns = update_budget_ns - pre_update_elapsed_ns, .is_tick_frame = is_tick_frame, .tick_cost_ns = tick_cost_ns, .safety_margin_ns = Util.BudgetContext.DEFAULT_SAFETY_MARGIN_NS, }; try Core.state_machine.update(self, UPDATE_DT_S, &budget); + pre_update_elapsed_ns = 0; update_accum -= UPDATE_US; } @@ -299,7 +317,7 @@ pub const Engine = struct { const frame_dt_s: f32 = @as(f32, @floatFromInt(frame_dt_us)) / @as(f32, US_PER_S); const drew_frame = Platform.gfx.api.start_frame(); if (drew_frame) { - const draw_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds()); + const draw_start_ns = clock.now(self.io).toNanoseconds(); // Time until next update step is due const slack_us: i64 = @as(i64, @intCast(UPDATE_US)) - @max(0, update_accum); const draw_budget_ns: i64 = if (self.vsync) @@ -326,23 +344,48 @@ pub const Engine = struct { const next_tick = @as(i64, @intCast(TICK_US)) - tick_accum; const sleep_us = @max(0, @min(next_update, next_tick)); if (sleep_us > 0) { - try std.Io.sleep(self.io, .fromMicroseconds(sleep_us), clock); + const sleep_ns = sleep_us * NS_PER_US; + try std.Io.sleep(self.io, .fromNanoseconds(@intCast(sleep_ns)), clock); } } else if (options.config.platform != .psp) { - try std.Io.sleep(self.io, .fromMilliseconds(50), clock); + try std.Io.sleep(self.io, .fromNanoseconds(50 * std.time.ns_per_ms), clock); } } // ---- FPS counting ---- if (report_fps) { if (drew_frame) fps_count += 1; - const end_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000)); + const end_us = elapsedUsSince(run_start_ns, clock.now(self.io).toNanoseconds()); if (end_us >= fps_window_end) { Util.engine_logger.info("FPS: {}", .{fps_count}); fps_count = 0; - fps_window_end = end_us + US_PER_S; + fps_window_end = saturatingAddI64(end_us, fps_window_us); } } } } }; + +fn elapsedNsBetween(start_ns: i96, end_ns: i96) i64 { + return clampI96ToI64(end_ns - start_ns); +} + +fn elapsedUsSince(start_ns: i96, end_ns: i96) i64 { + return clampI96ToI64(@divTrunc(end_ns - start_ns, std.time.ns_per_us)); +} + +fn saturatingAddI64(a: i64, b: i64) i64 { + return clampI96ToI64(@as(i96, a) + @as(i96, b)); +} + +fn saturatingSubI64(a: i64, b: i64) i64 { + return clampI96ToI64(@as(i96, a) - @as(i96, b)); +} + +fn clampI96ToI64(value: i96) i64 { + const max: i96 = std.math.maxInt(i64); + const min: i96 = std.math.minInt(i64); + if (value > max) return std.math.maxInt(i64); + if (value < min) return std.math.minInt(i64); + return @intCast(value); +} diff --git a/src/platform/3ds/3ds_audio.zig b/src/platform/3ds/3ds_audio.zig new file mode 100644 index 0000000..239ece8 --- /dev/null +++ b/src/platform/3ds/3ds_audio.zig @@ -0,0 +1,256 @@ +//! 3DS audio backend -- NDSP hardware voices. +//! +//! Each Aether mixer slot maps to one NDSP channel. The game thread refills +//! double-buffered linear-memory wave buffers from the Stream reader in +//! `update`; NDSP handles sample-rate conversion and channel mixing. + +const std = @import("std"); +const Stream = @import("../../audio/stream.zig").Stream; +const PcmFormat = @import("../../audio/stream.zig").PcmFormat; + +const NUM_SLOTS: usize = 24; +const BUFFERS_PER_SLOT: usize = 2; +const SAMPLES_PER_BUF: usize = 4096; +const MAX_CHANNELS: usize = 2; +const MAX_BYTES_PER_SAMPLE: usize = 2; +const MAX_BYTES_PER_BUF: usize = SAMPLES_PER_BUF * MAX_CHANNELS * MAX_BYTES_PER_SAMPLE; +const TOTAL_AUDIO_BYTES: usize = NUM_SLOTS * BUFFERS_PER_SLOT * MAX_BYTES_PER_BUF; + +const NDSP_OUTPUT_STEREO: c_int = 1; +const NDSP_INTERP_LINEAR: c_int = 1; +const NDSP_FORMAT_MONO_PCM16: u16 = 5; +const NDSP_FORMAT_STEREO_PCM16: u16 = 6; +const NDSP_WBUF_DONE: u8 = 3; + +const Result = c_int; + +const NdspAdpcmData = extern struct { + index: u16, + history0: i16, + history1: i16, +}; + +const NdspWaveBuf = extern struct { + data_vaddr: ?*const anyopaque, + nsamples: u32, + adpcm_data: ?*NdspAdpcmData, + offset: u32, + looping: bool, + status: u8, + sequence_id: u16, + next: ?*NdspWaveBuf, +}; + +extern fn ndspInit() Result; +extern fn ndspExit() void; +extern fn ndspSetOutputMode(mode: c_int) void; +extern fn ndspChnReset(id: c_int) void; +extern fn ndspChnSetInterp(id: c_int, interp: c_int) void; +extern fn ndspChnSetRate(id: c_int, rate: f32) void; +extern fn ndspChnSetFormat(id: c_int, format: u16) void; +extern fn ndspChnSetMix(id: c_int, mix: *[12]f32) void; +extern fn ndspChnWaveBufClear(id: c_int) void; +extern fn ndspChnWaveBufAdd(id: c_int, buf: *NdspWaveBuf) void; +extern fn DSP_FlushDataCache(address: *const anyopaque, size: u32) Result; +extern fn linearAlloc(size: usize) ?*anyopaque; +extern fn linearFree(mem: ?*anyopaque) void; + +const SlotState = enum(u8) { + inactive = 0, + pending = 1, + active = 2, + finished = 3, +}; + +const Slot = struct { + state: SlotState = .inactive, + gain: f32 = 0, + pan: f32 = 0, + stream: Stream = undefined, + format: PcmFormat = .{ .sample_rate = 44_100, .channels = 1, .bit_depth = 16 }, + wave_bufs: [BUFFERS_PER_SLOT]NdspWaveBuf = undefined, +}; + +var slots: [NUM_SLOTS]Slot = init_slots(); +var audio_alloc: std.mem.Allocator = undefined; +var audio_io: std.Io = undefined; +var audio_data: ?[*]u8 = null; + +fn init_slots() [NUM_SLOTS]Slot { + var s: [NUM_SLOTS]Slot = undefined; + for (&s) |*slot| { + slot.* = .{}; + } + return s; +} + +pub fn setup(alloc: std.mem.Allocator, io: std.Io) void { + audio_alloc = alloc; + audio_io = io; +} + +pub fn init() anyerror!void { + _ = audio_alloc; + _ = audio_io; + + audio_data = @ptrCast(linearAlloc(TOTAL_AUDIO_BYTES) orelse return error.AudioInitFailed); + + if (ndspInit() != 0) { + linearFree(audio_data); + audio_data = null; + return error.AudioInitFailed; + } + + ndspSetOutputMode(NDSP_OUTPUT_STEREO); + + for (0..NUM_SLOTS) |i| { + ndspChnReset(@intCast(i)); + slots[i] = .{}; + init_wave_bufs(i); + } +} + +pub fn deinit() void { + for (0..NUM_SLOTS) |i| { + ndspChnWaveBufClear(@intCast(i)); + ndspChnReset(@intCast(i)); + slots[i].state = .inactive; + } + + ndspExit(); + + if (audio_data) |data| { + linearFree(data); + audio_data = null; + } +} + +pub fn update() void { + if (audio_data == null) return; + + for (&slots, 0..) |*slot, i| { + switch (slot.state) { + .inactive, .finished => {}, + .pending => start_slot(slot, i) catch { + slot.state = .finished; + }, + .active => refill_done_buffers(slot, i), + } + } +} + +pub fn max_voices() u32 { + return NUM_SLOTS; +} + +pub fn play_slot(slot: u8, stream: Stream) anyerror!void { + if (slot >= NUM_SLOTS) return error.InvalidArgs; + if (!format_supported(stream.format)) return error.UnsupportedFormat; + + const i: usize = slot; + ndspChnWaveBufClear(slot); + slots[i].stream = stream; + slots[i].format = stream.format; + slots[i].state = .pending; +} + +pub fn stop_slot(slot: u8) void { + if (slot >= NUM_SLOTS) return; + ndspChnWaveBufClear(slot); + slots[slot].state = .inactive; +} + +pub fn set_slot_gain_pan(slot: u8, gain: f32, pan: f32) void { + if (slot >= NUM_SLOTS) return; + slots[slot].gain = gain; + slots[slot].pan = pan; + if (slots[slot].state == .active) apply_mix(slot, &slots[slot]); +} + +pub fn is_slot_active(slot: u8) bool { + if (slot >= NUM_SLOTS) return false; + return slots[slot].state != .inactive and slots[slot].state != .finished; +} + +fn init_wave_bufs(slot_index: usize) void { + const base = audio_data.?; + const slot_base = slot_index * BUFFERS_PER_SLOT * MAX_BYTES_PER_BUF; + + for (&slots[slot_index].wave_bufs, 0..) |*buf, b| { + buf.* = .{ + .data_vaddr = @ptrCast(base + slot_base + b * MAX_BYTES_PER_BUF), + .nsamples = SAMPLES_PER_BUF, + .adpcm_data = null, + .offset = 0, + .looping = false, + .status = NDSP_WBUF_DONE, + .sequence_id = 0, + .next = null, + }; + } +} + +fn start_slot(slot: *Slot, slot_index: usize) !void { + const id: c_int = @intCast(slot_index); + + ndspChnWaveBufClear(id); + ndspChnReset(id); + ndspChnSetInterp(id, NDSP_INTERP_LINEAR); + ndspChnSetRate(id, @floatFromInt(slot.format.sample_rate)); + ndspChnSetFormat(id, if (slot.format.channels == 1) NDSP_FORMAT_MONO_PCM16 else NDSP_FORMAT_STEREO_PCM16); + apply_mix(@intCast(slot_index), slot); + + var queued: bool = false; + for (&slot.wave_bufs) |*buf| { + if (fill_wave_buf(slot, buf)) { + ndspChnWaveBufAdd(id, buf); + queued = true; + } else break; + } + + slot.state = if (queued) .active else .finished; +} + +fn refill_done_buffers(slot: *Slot, slot_index: usize) void { + const id: c_int = @intCast(slot_index); + + for (&slot.wave_bufs) |*buf| { + if (buf.status != NDSP_WBUF_DONE) continue; + if (!fill_wave_buf(slot, buf)) { + slot.state = .finished; + return; + } + ndspChnWaveBufAdd(id, buf); + } +} + +fn fill_wave_buf(slot: *Slot, buf: *NdspWaveBuf) bool { + const byte_count = SAMPLES_PER_BUF * slot.format.frame_size(); + if (byte_count > MAX_BYTES_PER_BUF) return false; + + const raw: [*]u8 = @ptrCast(@constCast(buf.data_vaddr.?)); + const dst = raw[0..byte_count]; + + slot.stream.reader.readSliceAll(dst) catch return false; + _ = DSP_FlushDataCache(buf.data_vaddr.?, @intCast(byte_count)); + + buf.nsamples = SAMPLES_PER_BUF; + buf.offset = 0; + buf.looping = false; + buf.status = NDSP_WBUF_DONE; + buf.next = null; + return true; +} + +fn apply_mix(slot: u8, s: *const Slot) void { + const left = s.gain * std.math.clamp(1.0 - s.pan, 0.0, 1.0); + const right = s.gain * std.math.clamp(1.0 + s.pan, 0.0, 1.0); + var mix: [12]f32 = @splat(0); + mix[0] = std.math.clamp(left, 0.0, 1.0); + mix[1] = std.math.clamp(right, 0.0, 1.0); + ndspChnSetMix(slot, &mix); +} + +fn format_supported(fmt: PcmFormat) bool { + return fmt.bit_depth == 16 and (fmt.channels == 1 or fmt.channels == 2); +} diff --git a/src/platform/3ds/3ds_gfx.zig b/src/platform/3ds/3ds_gfx.zig new file mode 100644 index 0000000..f928d85 --- /dev/null +++ b/src/platform/3ds/3ds_gfx.zig @@ -0,0 +1,1061 @@ +//! Nintendo 3DS Citro3D backend. +//! +//! The top screen render target is physically 240x400 and displayed rotated. +//! This backend keeps Aether's normal landscape projection contract by +//! transforming vertices to top-screen coordinates on the CPU, then using +//! Citro3D's tilted orthographic projection for the final hardware transform. + +const std = @import("std"); +const Util = @import("../../util/util.zig"); +const Mat4 = @import("../../math/math.zig").Mat4; +const Rendering = @import("../../rendering/rendering.zig"); +const Pipeline = Rendering.Pipeline; +const Mesh = Rendering.mesh; +const Texture = Rendering.Texture; + +const C3D_AttrInfo = opaque {}; +const C3D_BufInfo = opaque {}; +const C3D_RenderTarget = extern struct { + next: ?*C3D_RenderTarget, + prev: ?*C3D_RenderTarget, + frameBuf: C3D_FrameBuf, + used: bool, + ownsColor: bool, + ownsDepth: bool, + linked: bool, + screen: c_int, + side: c_int, + transferFlags: u32, +}; +const C3D_FrameBuf = extern struct { + colorBuf: ?*anyopaque, + depthBuf: ?*anyopaque, + width: u16, + height: u16, + colorFmt: c_int, + depthFmt: c_int, + block32: bool, + masks: u8, +}; +const C3D_TexEnv = extern struct { + srcRgb: u16, + srcAlpha: u16, + opAll: u32, + funcRgb: u16, + funcAlpha: u16, + color: u32, + scaleRgb: u16, + scaleAlpha: u16, +}; +const C3D_FVec = extern struct { + w: f32, + z: f32, + y: f32, + x: f32, +}; +const C3D_Mtx = extern struct { + r: [4]C3D_FVec, +}; +const C3D_Tex = extern struct { + data: ?*anyopaque, + fmt_size: u32, + dim: u32, + param: u32, + border: u32, + lod_param: u32, +}; +const C3D_FogLut = extern struct { + data: [128]u32, +}; + +const DVLP = extern struct { + codeSize: u32, + codeData: [*]u32, + opdescSize: u32, + opcdescData: [*]u32, +}; +const DVLEConstEntry = extern struct { + typ: u16, + id: u16, + data: [4]u32, +}; +const DVLEOutEntry = extern struct { + typ: u16, + regID: u16, + mask: u8, + unk: [3]u8, +}; +const DVLEUniformEntry = extern struct { + symbolOffset: u32, + startReg: u16, + endReg: u16, +}; +const DVLE = extern struct { + typ: c_int, + mergeOutmaps: bool, + gshMode: c_int, + gshFixedVtxStart: u8, + gshVariableVtxNum: u8, + gshFixedVtxNum: u8, + dvlp: *DVLP, + mainOffset: u32, + endmainOffset: u32, + constTableSize: u32, + constTableData: [*]DVLEConstEntry, + outTableSize: u32, + outTableData: [*]DVLEOutEntry, + uniformTableSize: u32, + uniformTableData: [*]DVLEUniformEntry, + symbolTableData: [*]u8, + outmapMask: u8, + outmapData: [8]u32, + outmapMode: u32, + outmapClock: u32, +}; +const DVLB = extern struct { + numDVLE: u32, + DVLP: DVLP, + DVLE: [*]DVLE, +}; +const ShaderInstance = opaque {}; +const ShaderProgram = extern struct { + vertexShader: ?*ShaderInstance, + geometryShader: ?*ShaderInstance, + geoShaderInputPermutation: [2]u32, + geoShaderInputStride: u8, +}; + +extern fn gfxInitDefault() void; +extern fn gfxExit() void; + +extern fn C3D_Init(cmdBufSize: usize) bool; +extern fn C3D_Fini() void; +extern fn C3D_FrameBegin(flags: u8) bool; +extern fn C3D_FrameDrawOn(target: *C3D_RenderTarget) bool; +extern fn C3D_FrameEnd(flags: u8) void; +extern fn C3D_RenderTargetCreate(width: c_int, height: c_int, colorFmt: c_int, depthFmt: c_int) ?*C3D_RenderTarget; +extern fn C3D_RenderTargetDelete(target: *C3D_RenderTarget) void; +extern fn C3D_RenderTargetSetOutput(target: ?*C3D_RenderTarget, screen: c_int, side: c_int, transferFlags: u32) void; +extern fn C3D_FrameBufClear(fb: *C3D_FrameBuf, clearBits: c_int, clearColor: u32, clearDepth: u32) void; +extern fn C3D_BindProgram(program: *ShaderProgram) void; +extern fn C3D_GetAttrInfo() *C3D_AttrInfo; +extern fn AttrInfo_Init(info: *C3D_AttrInfo) void; +extern fn AttrInfo_AddLoader(info: *C3D_AttrInfo, regId: c_int, format: c_int, count: c_int) c_int; +extern fn C3D_GetBufInfo() *C3D_BufInfo; +extern fn BufInfo_Init(info: *C3D_BufInfo) void; +extern fn BufInfo_Add(info: *C3D_BufInfo, data: ?*const anyopaque, stride: isize, attribCount: c_int, permutation: u64) c_int; +extern fn C3D_GetTexEnv(id: c_int) *C3D_TexEnv; +extern fn C3D_DirtyTexEnv(env: *C3D_TexEnv) void; +extern fn C3D_CullFace(mode: c_int) void; +extern fn C3D_DepthTest(enable: bool, function: c_int, writemask: c_int) void; +extern fn C3D_AlphaBlend(colorEq: c_int, alphaEq: c_int, srcClr: c_int, dstClr: c_int, srcAlpha: c_int, dstAlpha: c_int) void; +extern fn C3D_DrawArrays(primitive: c_int, first: c_int, size: c_int) void; +extern fn C3D_TexInitWithParams(tex: *C3D_Tex, cube: ?*anyopaque, params: u64) bool; +extern fn C3D_TexLoadImage(tex: *C3D_Tex, data: ?*const anyopaque, face: c_int, level: c_int) void; +extern fn C3D_TexBind(unitId: c_int, tex: *C3D_Tex) void; +extern fn C3D_TexDelete(tex: *C3D_Tex) void; +extern fn C3D_FogGasMode(fogMode: c_int, gasMode: c_int, zFlip: bool) void; +extern fn C3D_FogColor(color: u32) void; +extern fn C3D_FogLutBind(lut: *C3D_FogLut) void; +extern fn FogLut_FromArray(lut: *C3D_FogLut, data: *const [256]f32) void; +extern fn GSPGPU_FlushDataCache(adr: ?*const anyopaque, size: u32) c_int; +extern fn Mtx_OrthoTilt(mtx: *C3D_Mtx, left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32, isLeftHanded: bool) void; +extern fn linearAlloc(size: usize) ?*anyopaque; +extern fn linearFree(mem: ?*anyopaque) void; + +extern fn DVLB_ParseFile(shbinData: [*]u32, shbinSize: u32) ?*DVLB; +extern fn DVLB_Free(dvlb: *DVLB) void; +extern fn shaderProgramInit(sp: *ShaderProgram) c_int; +extern fn shaderProgramFree(sp: *ShaderProgram) c_int; +extern fn shaderProgramSetVsh(sp: *ShaderProgram, dvle: *DVLE) c_int; +extern fn shaderInstanceGetUniformLocation(si: *ShaderInstance, name: [*:0]const u8) i8; + +extern var C3D_FVUnif: [2][C3D_FVUNIF_COUNT]C3D_FVec; +extern var C3D_FVUnifDirty: [2][C3D_FVUNIF_COUNT]bool; + +const C3D_DEFAULT_CMDBUF_SIZE = 0x40000; +const C3D_FRAME_SYNCDRAW = 1 << 0; +const C3D_CLEAR_COLOR = 1 << 0; +const C3D_CLEAR_DEPTH = 1 << 1; +const C3D_CLEAR_ALL = C3D_CLEAR_COLOR | C3D_CLEAR_DEPTH; +const C3D_FVUNIF_COUNT = 96; + +const GPU_VERTEX_SHADER = 0; +const GPU_FLOAT = 3; +const GPU_RB_RGBA8 = 0; +const GPU_RB_DEPTH24_STENCIL8 = 3; +const GPU_ALWAYS = 1; +const GPU_GEQUAL = 7; +const GPU_WRITE_COLOR = 0x0F; +const GPU_WRITE_DEPTH = 0x10; +const GPU_CULL_NONE = 0; +const GPU_CULL_BACK_CCW = 2; +const GPU_BLEND_ADD = 0; +const GPU_ZERO = 0; +const GPU_ONE = 1; +const GPU_SRC_ALPHA = 6; +const GPU_ONE_MINUS_SRC_ALPHA = 7; +const GPU_PRIMARY_COLOR = 0x00; +const GPU_TEXTURE0 = 0x03; +const GPU_REPLACE = 0x00; +const GPU_MODULATE = 0x01; +const GPU_TEVSCALE_1 = 0x0; +const GPU_TRIANGLES = 0x0000; +const GPU_NO_FOG = 0; +const GPU_FOG = 5; +const GPU_PLAIN_DENSITY = 0; +const GPU_TEX_2D = 0; +const GPU_TEXFACE_2D = 0; +const GPU_RGBA8 = 0; +const GPU_NEAREST = 0; +const GPU_LINEAR = 1; +const GPU_REPEAT = 2; + +const GFX_TOP = 0; +const GFX_LEFT = 0; +const GX_TRANSFER_FMT_RGB8 = 1; +const DISPLAY_TRANSFER_FLAGS = GX_TRANSFER_FMT_RGB8 << 12; +const TOP_SCREEN_WIDTH: f32 = 400.0; +const TOP_SCREEN_HEIGHT: f32 = 240.0; +const TEXTURE_BPP: usize = 4; +const MIN_TEXTURE_SIZE: u32 = 8; +const SMALL_TEXTURE_EXPAND_SIZE: u32 = 32; +const MAX_TEXTURE_SIZE: u32 = 1024; +const LINE_WIDTH: f32 = 1.5; +const DEBUG_UV_AS_COLOR = false; +const DEBUG_TEXTURE_ONLY = false; + +const ConvertedVertex = struct { + pos: [4]f32, + color: [4]f32, + uv: [2]f32, +}; + +const GpuVertex = extern struct { + pos: [4]f32, + uv: [2]f32, + color: [4]f32, +}; + +const PipelineData = struct { + program: ShaderProgram, + dvlb: *DVLB, + stride: usize, + position_attr: Pipeline.Attribute, + color_attr: ?Pipeline.Attribute, + uv_attr: ?Pipeline.Attribute, + projection_loc: i8, +}; + +const MeshData = struct { + pipeline: Pipeline.Handle, + ptr: ?[*]u8 = null, + len: usize = 0, + capacity: usize = 0, +}; + +const TextureData = struct { + width: u32, + height: u32, + tex_width: u16, + tex_height: u16, + uv_scale: [2]f32, + upload_data: []align(16) u8, + tex: C3D_Tex, +}; + +var render_alloc: std.mem.Allocator = undefined; +var render_io: std.Io = undefined; + +var initialized: bool = false; +var target: ?*C3D_RenderTarget = null; +var clear_color: u32 = 0x000000FF; +var vsync_enabled: bool = true; +var current_pipeline: Pipeline.Handle = 0; +var current_proj: Mat4 = Mat4.identity(); +var current_view: Mat4 = Mat4.identity(); +var uv_offset: [2]f32 = .{ 0.0, 0.0 }; +var depth_write_enabled: bool = true; +var screen_projection: C3D_Mtx = undefined; +var fog_lut: C3D_FogLut = undefined; +var white_texture: C3D_Tex = undefined; +var white_texture_ready: bool = false; +var bound_texture: Texture.Handle = 0; +var draw_vbo_raw: ?*anyopaque = null; +var draw_vbo: ?[*]GpuVertex = null; +var draw_vbo_capacity: usize = 0; + +var pipelines = Util.CircularBuffer(PipelineData, 16).init(); +var meshes = Util.CircularBuffer(MeshData, 2048).init(); +var textures = Util.CircularBuffer(TextureData, 64).init(); + +pub fn setup(alloc: std.mem.Allocator, io: std.Io) void { + render_alloc = alloc; + render_io = io; +} + +pub fn init() anyerror!void { + _ = render_io; + + gfxInitDefault(); + errdefer gfxExit(); + + if (!C3D_Init(C3D_DEFAULT_CMDBUF_SIZE)) return error.GfxInitFailed; + errdefer C3D_Fini(); + + target = C3D_RenderTargetCreate(240, 400, GPU_RB_RGBA8, GPU_RB_DEPTH24_STENCIL8); + if (target == null) return error.GfxInitFailed; + errdefer { + C3D_RenderTargetDelete(target.?); + target = null; + } + + C3D_RenderTargetSetOutput(target, GFX_TOP, GFX_LEFT, DISPLAY_TRANSFER_FLAGS); + Mtx_OrthoTilt(&screen_projection, 0.0, TOP_SCREEN_WIDTH, 0.0, TOP_SCREEN_HEIGHT, 0.0, 1.0, true); + + configure_fixed_attributes(); + configure_texture_texenv(); + try init_white_texture(); + C3D_CullFace(GPU_CULL_NONE); + apply_depth_state(); + C3D_FogGasMode(GPU_NO_FOG, GPU_PLAIN_DENSITY, false); + set_alpha_blend(true); + + initialized = true; +} + +pub fn deinit() void { + destroy_all_meshes(); + destroy_all_pipelines(); + destroy_all_textures(); + free_draw_vbo(); + current_pipeline = 0; + bound_texture = 0; + + if (white_texture_ready) { + C3D_TexDelete(&white_texture); + white_texture_ready = false; + } + + if (target) |t| { + C3D_RenderTargetDelete(t); + target = null; + } + + if (initialized) { + C3D_Fini(); + gfxExit(); + initialized = false; + } +} + +pub fn set_clear_color(r: f32, g: f32, b: f32, a: f32) void { + clear_color = (@as(u32, floatByte(r)) << 24) | + (@as(u32, floatByte(g)) << 16) | + (@as(u32, floatByte(b)) << 8) | + @as(u32, floatByte(a)); +} + +pub fn set_alpha_blend(enabled: bool) void { + if (enabled) { + C3D_AlphaBlend(GPU_BLEND_ADD, GPU_BLEND_ADD, GPU_SRC_ALPHA, GPU_ONE_MINUS_SRC_ALPHA, GPU_SRC_ALPHA, GPU_ONE_MINUS_SRC_ALPHA); + } else { + C3D_AlphaBlend(GPU_BLEND_ADD, GPU_BLEND_ADD, GPU_ONE, GPU_ZERO, GPU_ONE, GPU_ZERO); + } +} + +pub fn set_depth_write(enabled: bool) void { + depth_write_enabled = enabled; + apply_depth_state(); +} + +pub fn set_fog(enabled: bool, start: f32, end: f32, r: f32, g: f32, b: f32) void { + if (!enabled or end <= start) { + C3D_FogGasMode(GPU_NO_FOG, GPU_PLAIN_DENSITY, false); + return; + } + + var data: [256]f32 = undefined; + for (&data, 0..) |*v, i| { + const z = @as(f32, @floatFromInt(i)) / 255.0; + v.* = @max(0.0, @min(1.0, (z - start) / (end - start))); + } + + FogLut_FromArray(&fog_lut, &data); + C3D_FogColor((@as(u32, floatByte(r)) << 16) | + (@as(u32, floatByte(g)) << 8) | + @as(u32, floatByte(b))); + C3D_FogGasMode(GPU_FOG, GPU_PLAIN_DENSITY, false); + C3D_FogLutBind(&fog_lut); +} + +pub fn set_clip_planes(_: bool) void {} + +pub fn set_culling(enabled: bool) void { + C3D_CullFace(if (enabled) GPU_CULL_BACK_CCW else GPU_CULL_NONE); +} + +pub fn set_uv_offset(u: f32, v: f32) void { + uv_offset = .{ u, v }; +} + +pub fn set_proj_matrix(mat: *const Mat4) void { + current_proj = mat.*; +} + +pub fn set_view_matrix(mat: *const Mat4) void { + current_view = mat.*; +} + +pub fn start_frame() bool { + const t = target orelse return false; + if (!initialized) return false; + + const flags: u8 = if (vsync_enabled) C3D_FRAME_SYNCDRAW else 0; + if (!C3D_FrameBegin(flags)) return false; + + render_target_clear(t, C3D_CLEAR_ALL, clear_color, 0); + if (!C3D_FrameDrawOn(t)) { + C3D_FrameEnd(0); + return false; + } + + return true; +} + +pub fn end_frame() void { + if (!initialized) return; + C3D_FrameEnd(0); +} + +pub fn clear_depth() void { + if (target) |t| render_target_clear(t, C3D_CLEAR_DEPTH, clear_color, 0); +} + +pub fn set_vsync(v: bool) void { + vsync_enabled = v; +} + +pub fn create_pipeline(layout: Pipeline.VertexLayout, v_shader: ?[:0]align(4) const u8, _: ?[:0]align(4) const u8) anyerror!Pipeline.Handle { + const code = v_shader orelse return error.InvalidShader; + if (code.len == 0) return error.InvalidShader; + + const dvlb = DVLB_ParseFile(@ptrCast(@constCast(code.ptr)), @intCast(code.len)) orelse return error.InvalidShader; + errdefer DVLB_Free(dvlb); + if (dvlb.numDVLE == 0) return error.InvalidShader; + + var program: ShaderProgram = undefined; + if (shaderProgramInit(&program) != 0) return error.InvalidShader; + errdefer _ = shaderProgramFree(&program); + if (shaderProgramSetVsh(&program, &dvlb.DVLE[0]) != 0) return error.InvalidShader; + + const vertex_shader = program.vertexShader orelse return error.InvalidShader; + const projection_loc = shaderInstanceGetUniformLocation(vertex_shader, "projection"); + if (projection_loc < 0) return error.InvalidShader; + + const position_attr = find_attr(layout, .position) orelse return error.UnsupportedVertexLayout; + const data = PipelineData{ + .program = program, + .dvlb = dvlb, + .stride = layout.stride, + .position_attr = position_attr, + .color_attr = find_attr(layout, .color), + .uv_attr = find_attr(layout, .uv), + .projection_loc = projection_loc, + }; + + const handle = pipelines.add_element(data) orelse return error.OutOfPipelines; + return @intCast(handle); +} + +pub fn destroy_pipeline(handle: Pipeline.Handle) void { + const pl = get_pipeline_ptr(handle) orelse return; + _ = shaderProgramFree(&pl.program); + DVLB_Free(pl.dvlb); + _ = pipelines.remove_element(handle); + if (current_pipeline == handle) current_pipeline = 0; +} + +pub fn bind_pipeline(handle: Pipeline.Handle) void { + current_pipeline = handle; +} + +pub fn create_mesh(pipeline: Pipeline.Handle) anyerror!Mesh.Handle { + _ = get_pipeline_ptr(pipeline) orelse return error.InvalidPipeline; + const handle = meshes.add_element(.{ .pipeline = pipeline }) orelse return error.OutOfMeshes; + return @intCast(handle); +} + +pub fn destroy_mesh(handle: Mesh.Handle) void { + const mesh = get_mesh_ptr(handle) orelse return; + free_mesh_vertices(mesh); + _ = meshes.remove_element(handle); +} + +pub fn update_mesh(handle: Mesh.Handle, data: []const u8) void { + const mesh = get_mesh_ptr(handle) orelse return; + + if (data.len > mesh.capacity) { + free_mesh_vertices(mesh); + const bytes = render_alloc.alloc(u8, data.len) catch { + mesh.len = 0; + mesh.capacity = 0; + return; + }; + mesh.ptr = bytes.ptr; + mesh.capacity = bytes.len; + } + + if (mesh.ptr) |ptr| { + @memcpy(ptr[0..data.len], data); + } + mesh.len = data.len; +} + +pub fn draw_mesh(handle: Mesh.Handle, model: *const Mat4, count: usize, primitive: Mesh.Primitive) void { + if (!initialized) return; + + const mesh = get_mesh_ptr(handle) orelse return; + const ptr = mesh.ptr orelse return; + const pipeline_handle = if (current_pipeline != 0) current_pipeline else mesh.pipeline; + const pl = get_pipeline_ptr(pipeline_handle) orelse return; + const available_count = if (pl.stride == 0) 0 else mesh.len / pl.stride; + const draw_count = @min(count, available_count); + if (draw_count == 0) return; + + const view_proj = Mat4.mul(current_view, current_proj); + const mvp = Mat4.mul(model.*, view_proj); + + C3D_BindProgram(&pl.program); + configure_fixed_attributes(); + configure_texture_texenv(); + bind_current_texture_for_draw(); + upload_matrix_uniform(pl.projection_loc, &screen_projection); + + const vbo_count = switch (primitive) { + .triangles => draw_count, + .lines => (draw_count / 2) * 6, + }; + if (vbo_count == 0) return; + const out = prepare_draw_vbo(vbo_count) orelse return; + + var written_count: usize = 0; + switch (primitive) { + .triangles => { + for (0..draw_count) |i| { + const vertex = decode_mesh_vertex(ptr, i, pl.*); + out[i] = to_gpu_vertex(to_screen_vertex(vertex, &mvp)); + } + written_count = draw_count; + }, + .lines => { + var src_i: usize = 0; + var dst_i: usize = 0; + while (src_i + 1 < draw_count) : (src_i += 2) { + const a = decode_mesh_vertex(ptr, src_i, pl.*); + const b = decode_mesh_vertex(ptr, src_i + 1, pl.*); + dst_i = write_line_segment(out, dst_i, a, b, &mvp); + } + written_count = dst_i; + }, + } + if (written_count == 0) return; + + const draw_vertices = out[0..written_count]; + flush_draw_vbo(draw_vertices); + configure_draw_buffer(out.ptr); + C3D_DrawArrays(GPU_TRIANGLES, 0, @intCast(draw_vertices.len)); +} + +pub fn create_texture(width: u32, height: u32, data: []align(16) u8) anyerror!Texture.Handle { + const expand_small = width < MIN_TEXTURE_SIZE or height < MIN_TEXTURE_SIZE; + const tex_width: u16 = if (expand_small) @intCast(SMALL_TEXTURE_EXPAND_SIZE) else try texture_dim(width); + const tex_height: u16 = if (expand_small) @intCast(SMALL_TEXTURE_EXPAND_SIZE) else try texture_dim(height); + const upload_len = @as(usize, tex_width) * @as(usize, tex_height) * TEXTURE_BPP; + const upload_data = try render_alloc.alignedAlloc(u8, .fromByteUnits(16), upload_len); + errdefer render_alloc.free(upload_data); + + var tex: C3D_Tex = undefined; + if (!tex_init(&tex, tex_width, tex_height, false)) return error.TextureCreateFailed; + errdefer C3D_TexDelete(&tex); + tex_set_default_params(&tex); + + convert_texture_data(upload_data, data, width, height, tex_width, tex_height, expand_small); + tex_upload(&tex, upload_data); + + const handle = textures.add_element(.{ + .width = width, + .height = height, + .tex_width = tex_width, + .tex_height = tex_height, + .uv_scale = if (expand_small) .{ 1.0, 1.0 } else .{ + @as(f32, @floatFromInt(width)) / @as(f32, @floatFromInt(tex_width)), + @as(f32, @floatFromInt(height)) / @as(f32, @floatFromInt(tex_height)), + }, + .upload_data = upload_data, + .tex = tex, + }) orelse return error.OutOfTextures; + + return @intCast(handle); +} + +pub fn update_texture(handle: Texture.Handle, data: []align(16) u8) void { + const tex = get_texture_ptr(handle) orelse return; + const expand_small = tex.width < MIN_TEXTURE_SIZE or tex.height < MIN_TEXTURE_SIZE; + convert_texture_data(tex.upload_data, data, tex.width, tex.height, tex.tex_width, tex.tex_height, expand_small); + tex_upload(&tex.tex, tex.upload_data); +} + +pub fn bind_texture(handle: Texture.Handle) void { + bound_texture = if (get_texture_ptr(handle) != null) handle else 0; +} + +pub fn destroy_texture(handle: Texture.Handle) void { + const tex = get_texture_ptr(handle) orelse return; + C3D_TexDelete(&tex.tex); + render_alloc.free(tex.upload_data); + _ = textures.remove_element(handle); + if (bound_texture == handle) bound_texture = 0; +} + +pub fn force_texture_resident(_: Texture.Handle) void {} + +fn render_target_clear(t: *C3D_RenderTarget, bits: c_int, color: u32, depth: u32) void { + C3D_FrameBufClear(&t.frameBuf, bits, color, depth); +} + +fn apply_depth_state() void { + const depth_mask: c_int = if (depth_write_enabled) GPU_WRITE_DEPTH else 0; + const mask: c_int = GPU_WRITE_COLOR | depth_mask; + C3D_DepthTest(true, GPU_GEQUAL, mask); +} + +fn configure_fixed_attributes() void { + const attr = C3D_GetAttrInfo(); + AttrInfo_Init(attr); + _ = AttrInfo_AddLoader(attr, 0, GPU_FLOAT, 4); + _ = AttrInfo_AddLoader(attr, 1, GPU_FLOAT, 2); + _ = AttrInfo_AddLoader(attr, 2, GPU_FLOAT, 4); +} + +fn configure_draw_buffer(ptr: [*]GpuVertex) void { + const buf = C3D_GetBufInfo(); + BufInfo_Init(buf); + _ = BufInfo_Add(buf, @ptrCast(&ptr[0]), @intCast(@sizeOf(GpuVertex)), 3, 0x210); +} + +fn configure_texture_texenv() void { + const env = C3D_GetTexEnv(0); + const src = if (DEBUG_UV_AS_COLOR) + tev_sources(GPU_PRIMARY_COLOR, GPU_PRIMARY_COLOR, GPU_PRIMARY_COLOR) + else if (DEBUG_TEXTURE_ONLY) + tev_sources(GPU_TEXTURE0, GPU_TEXTURE0, GPU_TEXTURE0) + else + tev_sources(GPU_TEXTURE0, GPU_PRIMARY_COLOR, GPU_PRIMARY_COLOR); + const func = if (DEBUG_UV_AS_COLOR or DEBUG_TEXTURE_ONLY) GPU_REPLACE else GPU_MODULATE; + + env.* = .{ + .srcRgb = src, + .srcAlpha = src, + .opAll = 0, + .funcRgb = func, + .funcAlpha = func, + .color = 0xFFFFFFFF, + .scaleRgb = GPU_TEVSCALE_1, + .scaleAlpha = GPU_TEVSCALE_1, + }; + C3D_DirtyTexEnv(env); +} + +fn tev_sources(a: u16, b: u16, c: u16) u16 { + return a | (b << 4) | (c << 8); +} + +fn bind_current_texture_for_draw() void { + if (get_texture_ptr(bound_texture)) |tex| { + C3D_TexBind(0, &tex.tex); + return; + } + + if (white_texture_ready) { + C3D_TexBind(0, &white_texture); + } +} + +fn upload_matrix_uniform(loc: i8, mat: *const C3D_Mtx) void { + if (loc < 0) return; + + const base: usize = @intCast(loc); + if (base + 4 > C3D_FVUNIF_COUNT) return; + + inline for (0..4) |i| { + C3D_FVUnif[GPU_VERTEX_SHADER][base + i] = mat.r[i]; + C3D_FVUnifDirty[GPU_VERTEX_SHADER][base + i] = true; + } +} + +fn destroy_all_pipelines() void { + for (&pipelines.buffer) |*slot| { + if (slot.*) |*pl| { + _ = shaderProgramFree(&pl.program); + DVLB_Free(pl.dvlb); + slot.* = null; + } + } + pipelines.clear(); +} + +fn destroy_all_meshes() void { + for (&meshes.buffer) |*slot| { + if (slot.*) |*mesh| { + free_mesh_vertices(mesh); + slot.* = null; + } + } + meshes.clear(); +} + +fn destroy_all_textures() void { + for (&textures.buffer) |*slot| { + if (slot.*) |*tex| { + C3D_TexDelete(&tex.tex); + render_alloc.free(tex.upload_data); + slot.* = null; + } + } + textures.clear(); +} + +fn prepare_draw_vbo(count: usize) ?[]GpuVertex { + if (count > draw_vbo_capacity) { + free_draw_vbo(); + + const bytes = count * @sizeOf(GpuVertex); + const mem = linearAlloc(bytes) orelse return null; + const aligned: *align(@alignOf(GpuVertex)) anyopaque = @alignCast(mem); + const ptr: [*]GpuVertex = @ptrCast(aligned); + draw_vbo_raw = mem; + draw_vbo = ptr; + draw_vbo_capacity = count; + } + + const ptr = draw_vbo orelse return null; + return ptr[0..count]; +} + +fn flush_draw_vbo(vertices: []GpuVertex) void { + _ = GSPGPU_FlushDataCache(@ptrCast(&vertices[0]), @intCast(vertices.len * @sizeOf(GpuVertex))); +} + +fn free_draw_vbo() void { + if (draw_vbo_raw) |mem| { + linearFree(mem); + } + draw_vbo_raw = null; + draw_vbo = null; + draw_vbo_capacity = 0; +} + +fn free_mesh_vertices(mesh: *MeshData) void { + if (mesh.ptr) |ptr| { + render_alloc.free(ptr[0..mesh.capacity]); + mesh.ptr = null; + } + mesh.len = 0; + mesh.capacity = 0; +} + +fn find_attr(layout: Pipeline.VertexLayout, usage: Pipeline.AttributeUsage) ?Pipeline.Attribute { + for (layout.attributes) |attr| { + if (attr.usage == usage) return attr; + } + return null; +} + +fn decode_mesh_vertex(ptr: [*]const u8, index: usize, pl: PipelineData) ConvertedVertex { + const src = ptr[index * pl.stride ..][0..pl.stride]; + return convert_vertex(src, pl); +} + +fn convert_vertex(src: []const u8, pl: PipelineData) ConvertedVertex { + return .{ + .pos = decode_vec4(src, pl.position_attr, .{ 0.0, 0.0, 0.0, 1.0 }), + .color = if (pl.color_attr) |attr| decode_color(src, attr) else .{ 1.0, 1.0, 1.0, 1.0 }, + .uv = if (pl.uv_attr) |attr| decode_vec2(src, attr, .{ 0.0, 0.0 }) else .{ 0.0, 0.0 }, + }; +} + +fn decode_vec2(src: []const u8, attr: Pipeline.Attribute, default: [2]f32) [2]f32 { + const off = attr.offset; + return switch (attr.format) { + .f32x2, .f32x3 => .{ read_f32(src, off, default[0]), read_f32(src, off + 4, default[1]) }, + .unorm8x2, .unorm8x4 => .{ read_u8_norm(src, off, default[0]), read_u8_norm(src, off + 1, default[1]) }, + .unorm16x2, .unorm16x3 => .{ read_u16_norm(src, off, default[0]), read_u16_norm(src, off + 2, default[1]) }, + .snorm16x2, .snorm16x3 => .{ read_i16_norm(src, off, default[0]), read_i16_norm(src, off + 2, default[1]) }, + }; +} + +fn decode_vec4(src: []const u8, attr: Pipeline.Attribute, default: [4]f32) [4]f32 { + const off = attr.offset; + return switch (attr.format) { + .f32x2 => .{ read_f32(src, off, default[0]), read_f32(src, off + 4, default[1]), default[2], default[3] }, + .f32x3 => .{ read_f32(src, off, default[0]), read_f32(src, off + 4, default[1]), read_f32(src, off + 8, default[2]), default[3] }, + .unorm8x2 => .{ read_u8_norm(src, off, default[0]), read_u8_norm(src, off + 1, default[1]), default[2], default[3] }, + .unorm8x4 => .{ read_u8_norm(src, off, default[0]), read_u8_norm(src, off + 1, default[1]), read_u8_norm(src, off + 2, default[2]), read_u8_norm(src, off + 3, default[3]) }, + .unorm16x2 => .{ read_u16_norm(src, off, default[0]), read_u16_norm(src, off + 2, default[1]), default[2], default[3] }, + .unorm16x3 => .{ read_u16_norm(src, off, default[0]), read_u16_norm(src, off + 2, default[1]), read_u16_norm(src, off + 4, default[2]), default[3] }, + .snorm16x2 => .{ read_i16_norm(src, off, default[0]), read_i16_norm(src, off + 2, default[1]), default[2], default[3] }, + .snorm16x3 => .{ read_i16_norm(src, off, default[0]), read_i16_norm(src, off + 2, default[1]), read_i16_norm(src, off + 4, default[2]), default[3] }, + }; +} + +fn decode_color(src: []const u8, attr: Pipeline.Attribute) [4]f32 { + return switch (attr.format) { + .unorm8x4 => decode_vec4(src, attr, .{ 1.0, 1.0, 1.0, 1.0 }), + .f32x3 => .{ + read_f32(src, attr.offset, 1.0), + read_f32(src, attr.offset + 4, 1.0), + read_f32(src, attr.offset + 8, 1.0), + 1.0, + }, + .f32x2, .unorm8x2, .unorm16x2, .unorm16x3, .snorm16x2, .snorm16x3 => decode_vec4(src, attr, .{ 1.0, 1.0, 1.0, 1.0 }), + }; +} + +const ScreenVertex = struct { + pos: [4]f32, + color: [4]f32, + uv: [2]f32, +}; + +fn to_screen_vertex(vertex: ConvertedVertex, mvp: *const Mat4) ScreenVertex { + return .{ + .pos = clip_to_screen(transform_pos(vertex.pos, mvp)), + .color = vertex.color, + .uv = transform_uv(vertex.uv), + }; +} + +fn transform_uv(uv: [2]f32) [2]f32 { + const texture_scale = if (get_texture_ptr(bound_texture)) |tex| tex.uv_scale else .{ 1.0, 1.0 }; + return .{ + (uv[0] + uv_offset[0]) * texture_scale[0], + (uv[1] + uv_offset[1]) * texture_scale[1], + }; +} + +fn to_gpu_vertex(vertex: ScreenVertex) GpuVertex { + return .{ + .pos = vertex.pos, + .uv = vertex.uv, + .color = vertex.color, + }; +} + +fn write_line_segment(dst: []GpuVertex, index: usize, a: ConvertedVertex, b: ConvertedVertex, mvp: *const Mat4) usize { + const av = to_screen_vertex(a, mvp); + const bv = to_screen_vertex(b, mvp); + const dx = bv.pos[0] - av.pos[0]; + const dy = bv.pos[1] - av.pos[1]; + const len_sq = dx * dx + dy * dy; + if (len_sq <= 0.000001) return index; + + const inv_len = 1.0 / @sqrt(len_sq); + const nx = -dy * inv_len * (LINE_WIDTH * 0.5); + const ny = dx * inv_len * (LINE_WIDTH * 0.5); + + const a0 = offset_screen_vertex(av, nx, ny); + const a1 = offset_screen_vertex(av, -nx, -ny); + const b0 = offset_screen_vertex(bv, nx, ny); + const b1 = offset_screen_vertex(bv, -nx, -ny); + + dst[index + 0] = to_gpu_vertex(a0); + dst[index + 1] = to_gpu_vertex(a1); + dst[index + 2] = to_gpu_vertex(b0); + dst[index + 3] = to_gpu_vertex(b0); + dst[index + 4] = to_gpu_vertex(a1); + dst[index + 5] = to_gpu_vertex(b1); + return index + 6; +} + +fn offset_screen_vertex(vertex: ScreenVertex, dx: f32, dy: f32) ScreenVertex { + var out = vertex; + out.pos[0] += dx; + out.pos[1] += dy; + return out; +} + +fn transform_pos(pos: [4]f32, mat: *const Mat4) [4]f32 { + return .{ + pos[0] * mat.data[0][0] + pos[1] * mat.data[1][0] + pos[2] * mat.data[2][0] + pos[3] * mat.data[3][0], + pos[0] * mat.data[0][1] + pos[1] * mat.data[1][1] + pos[2] * mat.data[2][1] + pos[3] * mat.data[3][1], + pos[0] * mat.data[0][2] + pos[1] * mat.data[1][2] + pos[2] * mat.data[2][2] + pos[3] * mat.data[3][2], + pos[0] * mat.data[0][3] + pos[1] * mat.data[1][3] + pos[2] * mat.data[2][3] + pos[3] * mat.data[3][3], + }; +} + +fn clip_to_screen(pos: [4]f32) [4]f32 { + const inv_w: f32 = if (@abs(pos[3]) > 0.000001) 1.0 / pos[3] else 1.0; + const ndc_x = pos[0] * inv_w; + const ndc_y = pos[1] * inv_w; + const ndc_z = pos[2] * inv_w; + + return .{ + (ndc_x * 0.5 + 0.5) * TOP_SCREEN_WIDTH, + (ndc_y * 0.5 + 0.5) * TOP_SCREEN_HEIGHT, + @max(0.0, @min(1.0, ndc_z)), + 1.0, + }; +} + +fn init_white_texture() !void { + if (white_texture_ready) return; + + var data align(16) = [_]u8{0xFF} ** (MIN_TEXTURE_SIZE * MIN_TEXTURE_SIZE * TEXTURE_BPP); + if (!tex_init(&white_texture, MIN_TEXTURE_SIZE, MIN_TEXTURE_SIZE, false)) { + return error.TextureCreateFailed; + } + errdefer C3D_TexDelete(&white_texture); + + tex_set_default_params(&white_texture); + tex_upload(&white_texture, data[0..]); + white_texture_ready = true; +} + +fn tex_init(tex: *C3D_Tex, width: u16, height: u16, vram: bool) bool { + return C3D_TexInitWithParams(tex, null, tex_init_params(width, height, 0, GPU_RGBA8, GPU_TEX_2D, vram)); +} + +fn tex_upload(tex: *C3D_Tex, data: []align(16) const u8) void { + _ = GSPGPU_FlushDataCache(data.ptr, @intCast(data.len)); + C3D_TexLoadImage(tex, data.ptr, GPU_TEXFACE_2D, 0); +} + +fn tex_set_default_params(tex: *C3D_Tex) void { + tex.param &= ~(gpu_texture_mag_filter(GPU_LINEAR) | gpu_texture_min_filter(GPU_LINEAR)); + tex.param |= gpu_texture_mag_filter(GPU_NEAREST) | gpu_texture_min_filter(GPU_NEAREST); + tex.param &= ~(gpu_texture_wrap_s(3) | gpu_texture_wrap_t(3)); + tex.param |= gpu_texture_wrap_s(GPU_REPEAT) | gpu_texture_wrap_t(GPU_REPEAT); +} + +fn tex_init_params(width: u16, height: u16, max_level: u8, format: u8, tex_type: u8, vram: bool) u64 { + const flags0: u8 = (max_level & 0x0F) | ((format & 0x0F) << 4); + const flags1: u8 = (tex_type & 0x07) | (@as(u8, @intFromBool(vram)) << 3); + return @as(u64, width) | + (@as(u64, height) << 16) | + (@as(u64, flags0) << 32) | + (@as(u64, flags1) << 40); +} + +fn gpu_texture_mag_filter(v: u32) u32 { + return (v & 0x1) << 1; +} + +fn gpu_texture_min_filter(v: u32) u32 { + return (v & 0x1) << 2; +} + +fn gpu_texture_wrap_s(v: u32) u32 { + return (v & 0x3) << 12; +} + +fn gpu_texture_wrap_t(v: u32) u32 { + return (v & 0x3) << 8; +} + +fn get_texture_ptr(handle: Texture.Handle) ?*TextureData { + if (handle == 0 or handle >= textures.buffer.len) return null; + if (textures.buffer[handle]) |*tex| return tex; + return null; +} + +fn get_pipeline_ptr(handle: Pipeline.Handle) ?*PipelineData { + if (handle == 0 or handle >= pipelines.buffer.len) return null; + if (pipelines.buffer[handle]) |*pl| return pl; + return null; +} + +fn get_mesh_ptr(handle: Mesh.Handle) ?*MeshData { + if (handle == 0 or handle >= meshes.buffer.len) return null; + if (meshes.buffer[handle]) |*mesh| return mesh; + return null; +} + +fn texture_dim(value: u32) !u16 { + if (value == 0 or value > MAX_TEXTURE_SIZE) return error.InvalidTextureSize; + + var out: u32 = MIN_TEXTURE_SIZE; + while (out < value) : (out <<= 1) {} + if (out > MAX_TEXTURE_SIZE) return error.InvalidTextureSize; + return @intCast(out); +} + +fn convert_texture_data(dst: []align(16) u8, src: []const u8, width: u32, height: u32, tex_width: u16, tex_height: u16, expand_small: bool) void { + const source_len = @as(usize, width) * @as(usize, height) * TEXTURE_BPP; + if (src.len < source_len) return; + + const tw: u32 = tex_width; + const th: u32 = tex_height; + for (0..th) |y| { + const sy = if (expand_small) + @min((@as(u32, @intCast(y)) * height) / th, height - 1) + else + @min(@as(u32, @intCast(y)), height - 1); + for (0..tw) |x| { + const sx = if (expand_small) + @min((@as(u32, @intCast(x)) * width) / tw, width - 1) + else + @min(@as(u32, @intCast(x)), width - 1); + const src_off = (@as(usize, sy) * width + sx) * TEXTURE_BPP; + const dst_off = tiled_pixel_offset(@intCast(x), @intCast(y), tw) * TEXTURE_BPP; + dst[dst_off + 0] = src[src_off + 3]; + dst[dst_off + 1] = src[src_off + 2]; + dst[dst_off + 2] = src[src_off + 1]; + dst[dst_off + 3] = src[src_off + 0]; + } + } +} + +fn tiled_pixel_offset(x: u32, y: u32, width: u32) usize { + const tile_x = x & ~@as(u32, 7); + const tile_y = y & ~@as(u32, 7); + const tile_base = tile_y * width + tile_x * 8; + return @intCast(tile_base + morton8(x & 7, y & 7)); +} + +fn morton8(x: u32, y: u32) u32 { + return (x & 1) | + ((y & 1) << 1) | + ((x & 2) << 1) | + ((y & 2) << 2) | + ((x & 4) << 2) | + ((y & 4) << 3); +} + +fn read_f32(src: []const u8, offset: usize, default: f32) f32 { + if (offset + 4 > src.len) return default; + const bits = std.mem.readInt(u32, src[offset..][0..4], .little); + return @bitCast(bits); +} + +fn read_u8_norm(src: []const u8, offset: usize, default: f32) f32 { + if (offset >= src.len) return default; + return @as(f32, @floatFromInt(src[offset])) / 255.0; +} + +fn read_u16_norm(src: []const u8, offset: usize, default: f32) f32 { + if (offset + 2 > src.len) return default; + const value = std.mem.readInt(u16, src[offset..][0..2], .little); + return @as(f32, @floatFromInt(value)) / 65535.0; +} + +fn read_i16_norm(src: []const u8, offset: usize, default: f32) f32 { + if (offset + 2 > src.len) return default; + const bits = std.mem.readInt(u16, src[offset..][0..2], .little); + const value: i16 = @bitCast(bits); + return @max(-1.0, @as(f32, @floatFromInt(value)) / 32767.0); +} + +fn floatByte(v: f32) u8 { + return @intFromFloat(@max(0.0, @min(1.0, v)) * 255.0); +} diff --git a/src/platform/3ds/3ds_thread.zig b/src/platform/3ds/3ds_thread.zig new file mode 100644 index 0000000..3582702 --- /dev/null +++ b/src/platform/3ds/3ds_thread.zig @@ -0,0 +1,25 @@ +//! 3DS thread backend stub. +//! +//! Real implementation will wrap libctru's `threadCreate`/`threadJoin`. +//! Until then `spawn` returns `error.Unsupported` so callers fail fast +//! instead of silently returning a bogus handle. + +const std = @import("std"); +const api = @import("../thread_api.zig"); + +pub const Handle = u32; + +pub fn spawn(cfg: api.Config, comptime func: anytype, args: anytype) !Handle { + _ = cfg; + _ = func; + _ = args; + return error.Unsupported; +} + +pub fn join(_: Handle) void {} + +pub fn set_priority(_: Handle, _: api.Priority) anyerror!void {} + +pub fn current_priority() api.Priority { + return .normal; +} diff --git a/src/platform/3ds/input.zig b/src/platform/3ds/input.zig new file mode 100644 index 0000000..6e7d8d6 --- /dev/null +++ b/src/platform/3ds/input.zig @@ -0,0 +1,248 @@ +//! 3DS input backend. Polls libctru HID once per engine update and +//! translates button, circle-pad, C-stick, trigger, touch, and software +//! keyboard state into Aether's core input events. + +const std = @import("std"); +const core = @import("../../core/input/input.zig"); + +const Result = c_int; + +const TouchPosition = extern struct { + px: u16, + py: u16, +}; + +const CirclePosition = extern struct { + dx: i16, + dy: i16, +}; + +extern fn hidInit() Result; +extern fn hidExit() void; +extern fn hidScanInput() void; +extern fn hidKeysHeld() u32; +extern fn hidTouchRead(pos: *TouchPosition) void; +extern fn hidCircleRead(pos: *CirclePosition) void; + +extern fn swkbdInit(swkbd: *anyopaque, typ: c_int, num_buttons: c_int, max_text_length: c_int) void; +extern fn swkbdSetFeatures(swkbd: *anyopaque, features: u32) void; +extern fn swkbdSetHintText(swkbd: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdSetButton(swkbd: *anyopaque, button: c_int, text: [*:0]const u8, submit: bool) void; +extern fn swkbdSetInitialText(swkbd: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdInputText(swkbd: *anyopaque, buf: [*]u8, bufsize: usize) c_int; + +const KEY_A: u32 = 1 << 0; +const KEY_B: u32 = 1 << 1; +const KEY_SELECT: u32 = 1 << 2; +const KEY_START: u32 = 1 << 3; +const KEY_DRIGHT: u32 = 1 << 4; +const KEY_DLEFT: u32 = 1 << 5; +const KEY_DUP: u32 = 1 << 6; +const KEY_DDOWN: u32 = 1 << 7; +const KEY_R: u32 = 1 << 8; +const KEY_L: u32 = 1 << 9; +const KEY_X: u32 = 1 << 10; +const KEY_Y: u32 = 1 << 11; +const KEY_ZL: u32 = 1 << 14; +const KEY_ZR: u32 = 1 << 15; +const KEY_TOUCH: u32 = 1 << 20; +const KEY_CSTICK_RIGHT: u32 = 1 << 24; +const KEY_CSTICK_LEFT: u32 = 1 << 25; +const KEY_CSTICK_UP: u32 = 1 << 26; +const KEY_CSTICK_DOWN: u32 = 1 << 27; + +const CIRCLE_PAD_MAX: f32 = 156.0; +const MAX_TEXT_BYTES: usize = 1024; +const SWKBD_STATE_BYTES: usize = 0x1000; +const SWKBD_TYPE_NORMAL: c_int = 0; +const SWKBD_BUTTON_LEFT: c_int = 0; +const SWKBD_BUTTON_RIGHT: c_int = 2; +const SWKBD_DARKEN_TOP_SCREEN: u32 = 1 << 1; +const SWKBD_MULTILINE: u32 = 1 << 3; +const SWKBD_DEFAULT_QWERTY: u32 = 1 << 9; + +const axis_count = @typeInfo(core.Axis).@"enum".fields.len; + +var initialized: bool = false; +var prev_keys: u32 = 0; +var prev_axes: [axis_count]f32 = @splat(0.0); +var prev_touch_down: bool = false; +var prev_touch_pos: core.Vec2 = .{}; + +pub fn setup(_: std.mem.Allocator, _: std.Io) void { + initialized = false; + prev_keys = 0; + prev_axes = @splat(0.0); + prev_touch_down = false; + prev_touch_pos = .{}; +} + +pub fn init() anyerror!void { + if (hidInit() != 0) return error.InputInitFailed; + initialized = true; +} + +pub fn deinit() void { + if (!initialized) return; + hidExit(); + initialized = false; +} + +pub fn pump() void { + hidScanInput(); + const keys = hidKeysHeld(); + + diff_buttons(keys); + pump_axes(keys); + pump_touch(keys); + + prev_keys = keys; + core.signal_frame_boundary(); +} + +pub fn apply_cursor_mode(_: core.CursorMode) void {} + +pub fn begin_text_input_session(target: core.TextInputTarget, options: core.TextInputOptions) anyerror!void { + var state_buf: [SWKBD_STATE_BYTES]u8 align(8) = @splat(0); + const state: *anyopaque = @ptrCast(&state_buf); + + var initial_buf: [MAX_TEXT_BYTES:0]u8 = @splat(0); + const initial_len = copy_current_text(&initial_buf); + const initial = initial_buf[0..initial_len :0]; + + var hint_buf: [128:0]u8 = @splat(0); + const hint = copy_z(&hint_buf, target.id); + + const max_text_len = text_limit_c_int(options.max_bytes); + swkbdInit(state, SWKBD_TYPE_NORMAL, 2, max_text_len); + swkbdSetInitialText(state, initial.ptr); + swkbdSetHintText(state, hint.ptr); + swkbdSetButton(state, SWKBD_BUTTON_LEFT, "Cancel", false); + swkbdSetButton(state, SWKBD_BUTTON_RIGHT, "OK", true); + + var features = SWKBD_DARKEN_TOP_SCREEN | SWKBD_DEFAULT_QWERTY; + if (options.multiline) features |= SWKBD_MULTILINE; + swkbdSetFeatures(state, features); + + var out_buf: [MAX_TEXT_BYTES:0]u8 = @splat(0); + const out_size = output_buffer_size(options.max_bytes); + const button = swkbdInputText(state, out_buf[0..].ptr, out_size); + if (button == SWKBD_BUTTON_RIGHT) { + const len = bounded_z_len(out_buf[0..out_size]); + core.write_text_session_buffer(out_buf[0..len], .submitted); + } else { + core.write_text_session_buffer(initial_buf[0..initial_len], .cancelled); + } +} + +pub fn end_text_input_session() void {} + +fn diff_buttons(keys: u32) void { + const Pair = struct { mask: u32, button: core.Button }; + const map = [_]Pair{ + .{ .mask = KEY_A, .button = .A }, + .{ .mask = KEY_B, .button = .B }, + .{ .mask = KEY_X, .button = .X }, + .{ .mask = KEY_Y, .button = .Y }, + .{ .mask = KEY_L, .button = .LButton }, + .{ .mask = KEY_R, .button = .RButton }, + .{ .mask = KEY_SELECT, .button = .Back }, + .{ .mask = KEY_START, .button = .Start }, + .{ .mask = KEY_DUP, .button = .DpadUp }, + .{ .mask = KEY_DRIGHT, .button = .DpadRight }, + .{ .mask = KEY_DDOWN, .button = .DpadDown }, + .{ .mask = KEY_DLEFT, .button = .DpadLeft }, + }; + + inline for (map) |entry| { + const now = keys & entry.mask != 0; + const prev = prev_keys & entry.mask != 0; + if (now != prev) { + core.deliver_gamepad_button(entry.button, if (now) .pressed else .released); + } + } +} + +fn pump_axes(keys: u32) void { + var circle: CirclePosition = .{ .dx = 0, .dy = 0 }; + hidCircleRead(&circle); + + deliver_axis(.LeftX, normalize_signed(circle.dx, CIRCLE_PAD_MAX)); + deliver_axis(.LeftY, -normalize_signed(circle.dy, CIRCLE_PAD_MAX)); + deliver_axis(.RightX, digital_axis(keys, KEY_CSTICK_RIGHT, KEY_CSTICK_LEFT)); + deliver_axis(.RightY, digital_axis(keys, KEY_CSTICK_DOWN, KEY_CSTICK_UP)); + deliver_axis(.LeftTrigger, if (keys & KEY_ZL != 0) 1.0 else 0.0); + deliver_axis(.RightTrigger, if (keys & KEY_ZR != 0) 1.0 else 0.0); +} + +fn pump_touch(keys: u32) void { + const touch_down = keys & KEY_TOUCH != 0; + if (touch_down) { + var touch: TouchPosition = .{ .px = 0, .py = 0 }; + hidTouchRead(&touch); + const pos: core.Vec2 = .{ + .x = @floatFromInt(touch.px), + .y = @floatFromInt(touch.py), + }; + const delta: core.Vec2 = if (prev_touch_down) + .{ .x = pos.x - prev_touch_pos.x, .y = pos.y - prev_touch_pos.y } + else + .{}; + + core.deliver_mouse_move(pos, delta); + if (!prev_touch_down) core.deliver_mouse_button(.Left, .pressed, pos); + prev_touch_pos = pos; + } else if (prev_touch_down) { + core.deliver_mouse_button(.Left, .released, prev_touch_pos); + } + + prev_touch_down = touch_down; +} + +fn deliver_axis(axis: core.Axis, value: f32) void { + const idx = @intFromEnum(axis); + const prev = prev_axes[idx]; + if (value != 0.0 or prev != 0.0) core.deliver_gamepad_axis(axis, value); + prev_axes[idx] = value; +} + +fn normalize_signed(raw: anytype, max_value: f32) f32 { + const value = @as(f32, @floatFromInt(raw)) / max_value; + return std.math.clamp(value, -1.0, 1.0); +} + +fn digital_axis(keys: u32, positive_mask: u32, negative_mask: u32) f32 { + var value: f32 = 0.0; + if (keys & positive_mask != 0) value += 1.0; + if (keys & negative_mask != 0) value -= 1.0; + return value; +} + +fn output_buffer_size(limit: ?usize) usize { + const max = @min(limit orelse (MAX_TEXT_BYTES - 1), MAX_TEXT_BYTES - 1); + return max + 1; +} + +fn text_limit_c_int(limit: ?usize) c_int { + const max = @min(limit orelse (MAX_TEXT_BYTES - 1), MAX_TEXT_BYTES - 1); + return @intCast(@max(max, 1)); +} + +fn copy_current_text(dst: []u8) usize { + const s = core.current_text_session() orelse return 0; + const n = @min(dst.len - 1, s.buffer.items.len); + @memcpy(dst[0..n], s.buffer.items[0..n]); + dst[n] = 0; + return n; +} + +fn copy_z(dst: []u8, text: []const u8) [:0]const u8 { + const n = @min(dst.len - 1, text.len); + @memcpy(dst[0..n], text[0..n]); + dst[n] = 0; + return dst[0..n :0]; +} + +fn bounded_z_len(buf: []const u8) usize { + return std.mem.indexOfScalar(u8, buf, 0) orelse buf.len; +} diff --git a/src/platform/3ds/paths.zig b/src/platform/3ds/paths.zig new file mode 100644 index 0000000..38d1cb2 --- /dev/null +++ b/src/platform/3ds/paths.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +extern fn archiveMountSdmc() u32; +extern fn archiveUnmount(name: [*:0]const u8) u32; +extern fn romfsMountSelf(name: [*:0]const u8) u32; +extern fn romfsUnmount(name: [*:0]const u8) u32; + +pub fn mountData() bool { + return archiveMountSdmc() == 0; +} + +pub fn unmountData() void { + _ = archiveUnmount("sdmc"); +} + +pub fn mountResources() bool { + return romfsMountSelf("romfs") == 0; +} + +pub fn unmountResources() void { + _ = romfsUnmount("romfs"); +} + +pub fn dataRoot(buffer: []u8, app_name: []const u8) error{NameTooLong}![]const u8 { + return std.fmt.bufPrint(buffer, "sdmc:/3ds/{s}", .{app_name}) catch error.NameTooLong; +} diff --git a/src/platform/3ds/services.zig b/src/platform/3ds/services.zig new file mode 100644 index 0000000..2509218 --- /dev/null +++ b/src/platform/3ds/services.zig @@ -0,0 +1,216 @@ +//! 3DS system services / entry shim. +//! +//! Exports a C-callable `main` that hands control to the user's Zig +//! `main`. The allocator and baseline `std.Io` pieces of `std.process.Init` +//! are wired through newlib; deeper platform services remain TODO. +//! +//! Stack default: libctru's 32 KB is far too small for any std-using +//! Zig code path. We override `__stacksize__` (a `WEAK` symbol in +//! libctru) with a strong export. 1 MB is comfortable; bump if engine +//! frames grow. +//! +//! libctru also creates service threads internally. NDSP currently asks for +//! a 4 KB stack, which can underflow in its sound-frame worker before Aether +//! code is on the stack. The 3DS link step wraps `threadCreate`, and the +//! wrapper below raises tiny service-thread stacks to a conservative floor. + +const process_init = @import("aether").CProcessInit; +const std = @import("std"); + +pub const os = struct { + pub const PATH_MAX = 1024; + pub const NAME_MAX = 255; +}; + +fn AppRoot() type { + const root = @import("root"); + return if (@hasDecl(root, "main")) root else @import("aether_user_root"); +} + +pub const std_options = if (@hasDecl(AppRoot(), "std_options")) AppRoot().std_options else std.Options{}; +pub const std_options_debug_threaded_io = if (@hasDecl(AppRoot(), "std_options_debug_threaded_io")) AppRoot().std_options_debug_threaded_io else null; +pub const std_options_debug_io = if (@hasDecl(AppRoot(), "std_options_debug_io")) AppRoot().std_options_debug_io else std.Io.failing; +const app_std_options_cwd: ?fn () std.Io.Dir = if (@hasDecl(AppRoot(), "std_options_cwd")) AppRoot().std_options_cwd else null; +pub const std_options_cwd = app_std_options_cwd orelse @import("aether").Cio.cwd; + +const argv = [_][*:0]const u8{"Aether"}; +const min_service_thread_stack = 128 * 1024; +const exception_stack_size = 16 * 1024; +const fatal_result: c_int = -1; +const USERBREAK_PANIC = 0; + +const Thread = ?*anyopaque; +const ThreadFunc = *const fn (?*anyopaque) callconv(.c) void; +const ExceptionInfo = extern struct { + typ: c_int, + reserved: [3]u8, + fsr: u32, + far: u32, + fpexc: u32, + fpinst: u32, + fpinst2: u32, +}; +const CpuRegisters = extern struct { + r: [13]u32, + sp: u32, + lr: u32, + pc: u32, + cpsr: u32, +}; + +extern fn __real_threadCreate( + entrypoint: ThreadFunc, + arg: ?*anyopaque, + stack_size: usize, + prio: c_int, + core_id: c_int, + detached: bool, +) Thread; + +extern fn aether3dsInstallExceptionHandler(stack_top: ?*anyopaque) void; +extern fn errfInit() c_int; +extern fn ERRF_SetUserString(user_string: [*:0]const u8) c_int; +extern fn ERRF_ThrowResultWithMessage(failure: c_int, message: [*:0]const u8) c_int; +extern fn ERRF_ExceptionHandler(excep: *ExceptionInfo, regs: *CpuRegisters) noreturn; +extern fn svcBreak(break_reason: c_int) void; +extern fn svcOutputDebugString(str: [*]const u8, length: i32) c_int; + +comptime { + @export(&entry, .{ .name = "main" }); + @export(&stack_size, .{ .name = "__stacksize__" }); + @export(&threadCreateWrap, .{ .name = "__wrap_threadCreate" }); + @export(&exceptionHandler, .{ .name = "aether3dsExceptionHandler" }); +} + +var stack_size: u32 = 1 * 1024 * 1024; +var exception_stack: [exception_stack_size]u8 align(8) = undefined; +var panic_stage: u8 = 0; + +fn threadCreateWrap( + entrypoint: ThreadFunc, + arg: ?*anyopaque, + requested_stack_size: usize, + prio: c_int, + core_id: c_int, + detached: bool, +) callconv(.c) Thread { + return __real_threadCreate( + entrypoint, + arg, + @max(requested_stack_size, min_service_thread_stack), + prio, + core_id, + detached, + ); +} + +fn entry() callconv(.c) c_int { + installCrashHandlers(); + + const init = process_init.makeInit(.{ .vector = &argv }); + AppRoot().main(init) catch |err| { + fatalMainError(err, @errorReturnTrace(), @returnAddress()); + }; + return 0; +} + +fn fatalMainError(err: anyerror, maybe_trace: ?*std.builtin.StackTrace, fallback_addr: usize) noreturn { + if (maybe_trace) |trace| { + const len = @min(trace.instruction_addresses.len, trace.index); + const addrs = trace.instruction_addresses[0..@min(len, 4)]; + switch (addrs.len) { + 0 => {}, + 1 => fatal("Aether main returned error.{s} at 0x{x}", .{ @errorName(err), addrs[0] }), + 2 => fatal("Aether main returned error.{s} at 0x{x} 0x{x}", .{ @errorName(err), addrs[0], addrs[1] }), + 3 => fatal("Aether main returned error.{s} at 0x{x} 0x{x} 0x{x}", .{ @errorName(err), addrs[0], addrs[1], addrs[2] }), + else => fatal("Aether main returned error.{s} at 0x{x} 0x{x} 0x{x} 0x{x}", .{ @errorName(err), addrs[0], addrs[1], addrs[2], addrs[3] }), + } + } + + fatal("Aether main returned error.{s} at 0x{x}", .{ @errorName(err), fallback_addr }); +} + +pub fn panic(msg: []const u8, _: ?*std.builtin.StackTrace, first_trace_addr: ?usize) noreturn { + @branchHint(.cold); + + if (panic_stage != 0) { + fatalDisplay("Aether recursive panic"); + } + panic_stage = 1; + + fatal("Aether panic at 0x{x}: {s}", .{ first_trace_addr orelse @returnAddress(), msg }); +} + +fn installCrashHandlers() void { + const top: ?*anyopaque = @ptrFromInt(@intFromPtr(&exception_stack) + exception_stack.len); + aether3dsInstallExceptionHandler(top); +} + +fn exceptionHandler(excep: *ExceptionInfo, regs: *CpuRegisters) callconv(.c) noreturn { + @branchHint(.cold); + + var buf: [256:0]u8 = @splat(0); + const msg = std.fmt.bufPrintZ(&buf, + \\Aether {s} + \\PC=0x{x:0>8} LR=0x{x:0>8} SP=0x{x:0>8} + \\FAR=0x{x:0>8} FSR=0x{x:0>8} CPSR=0x{x:0>8} + \\R0=0x{x:0>8} R1=0x{x:0>8} R2=0x{x:0>8} R3=0x{x:0>8} + , .{ + exceptionName(excep.typ), + regs.pc, + regs.lr, + regs.sp, + excep.far, + excep.fsr, + regs.cpsr, + regs.r[0], + regs.r[1], + regs.r[2], + regs.r[3], + }) catch fallback: { + @memcpy(buf[0.."Aether CPU exception".len], "Aether CPU exception"); + break :fallback buf[0.."Aether CPU exception".len :0]; + }; + + debugString(msg); + debugString("\n"); + _ = errfInit(); + _ = ERRF_SetUserString(msg.ptr); + ERRF_ExceptionHandler(excep, regs); +} + +fn exceptionName(typ: c_int) []const u8 { + return switch (typ) { + 0 => "Prefetch Abort", + 1 => "Data Abort", + 2 => "Undefined Instruction", + 3 => "VFP Exception", + else => "CPU Exception", + }; +} + +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + var buf: [256:0]u8 = @splat(0); + const msg = std.fmt.bufPrintZ(&buf, fmt, args) catch fallback: { + @memcpy(buf[0.."Aether fatal error".len], "Aether fatal error"); + break :fallback buf[0.."Aether fatal error".len :0]; + }; + fatalDisplay(msg); +} + +fn fatalDisplay(message: [:0]const u8) noreturn { + debugString(message); + debugString("\n"); + + _ = errfInit(); + _ = ERRF_SetUserString(message.ptr); + _ = ERRF_ThrowResultWithMessage(fatal_result, message.ptr); + + svcBreak(USERBREAK_PANIC); + while (true) {} +} + +fn debugString(message: []const u8) void { + if (message.len == 0) return; + _ = svcOutputDebugString(message.ptr, @intCast(@min(message.len, std.math.maxInt(i32)))); +} diff --git a/src/platform/3ds/surface.zig b/src/platform/3ds/surface.zig new file mode 100644 index 0000000..f7bb7df --- /dev/null +++ b/src/platform/3ds/surface.zig @@ -0,0 +1,30 @@ +//! 3DS surface stub. +//! +//! Top screen of an O3DS is 400x240; bottom touch screen is 320x240. The +//! real backend will likely advertise the top screen here and expose the +//! bottom one separately. + +const std = @import("std"); +const Self = @This(); + +extern fn aptMainLoop() bool; + +alloc: std.mem.Allocator, + +pub fn init(_: *Self, _: u32, _: u32, _: [:0]const u8, _: bool, _: bool, _: bool) anyerror!void {} + +pub fn deinit(_: *Self) void {} + +pub fn update(_: *Self) bool { + return aptMainLoop(); +} + +pub fn draw(_: *Self) void {} + +pub fn get_width(_: *Self) u32 { + return 400; +} + +pub fn get_height(_: *Self) u32 { + return 240; +} diff --git a/src/platform/3ds/time.zig b/src/platform/3ds/time.zig new file mode 100644 index 0000000..681986e --- /dev/null +++ b/src/platform/3ds/time.zig @@ -0,0 +1,96 @@ +const std = @import("std"); + +const Timespec = extern struct { + tv_sec: i64, + tv_nsec: c_long, +}; + +extern fn clock_gettime(clock_id: c_int, tp: *Timespec) c_int; +extern fn clock_getres(clock_id: c_int, res: *Timespec) c_int; +extern fn svcSleepThread(ns: i64) void; +extern fn svcGetSystemTick() u64; + +const ns_per_s: u64 = std.time.ns_per_s; +const max_i64_ns: i64 = std.math.maxInt(i64); +const CLOCK_REALTIME: c_int = 1; +const CLOCK_MONOTONIC: c_int = 4; +const SYSCLOCK_ARM11: u128 = 268_111_856; + +pub fn now(clock: std.Io.Clock) std.Io.Timestamp { + switch (clock) { + .awake, .boot => return .fromNanoseconds(systemTickNanoseconds(svcGetSystemTick())), + else => {}, + } + + const id = clockId(clock) orelse std.debug.panic("3ds std.Io clock {s} is not implemented", .{@tagName(clock)}); + var ts: Timespec = undefined; + if (clock_gettime(id, &ts) != 0) { + std.debug.panic("3ds clock_gettime failed for std.Io clock {s}", .{@tagName(clock)}); + } + return .fromNanoseconds(timespecNanoseconds(ts)); +} + +pub fn clockResolution(clock: std.Io.Clock) std.Io.Clock.ResolutionError!std.Io.Duration { + switch (clock) { + .awake, .boot => return .fromNanoseconds(@intCast((@as(u128, ns_per_s) + SYSCLOCK_ARM11 - 1) / SYSCLOCK_ARM11)), + else => {}, + } + + const id = clockId(clock) orelse return error.ClockUnavailable; + var ts: Timespec = undefined; + if (clock_getres(id, &ts) != 0) return error.ClockUnavailable; + return .fromNanoseconds(timespecNanoseconds(ts)); +} + +fn clockId(clock: std.Io.Clock) ?c_int { + return switch (clock) { + .real => CLOCK_REALTIME, + // libctru's POSIX shim supports CLOCK_MONOTONIC for svcGetSystemTick. + // CLOCK_BOOTTIME is declared by newlib but not implemented by libctru. + .awake, .boot => CLOCK_MONOTONIC, + else => null, + }; +} + +fn systemTickNanoseconds(ticks: u64) i64 { + const ns = (@as(u128, ticks) * @as(u128, ns_per_s)) / SYSCLOCK_ARM11; + return @intCast(@min(ns, @as(u128, @intCast(max_i64_ns)))); +} + +fn timespecNanoseconds(ts: Timespec) i64 { + if (ts.tv_sec <= 0) return @max(0, @as(i64, @intCast(ts.tv_nsec))); + + const sec: i64 = @intCast(@min(ts.tv_sec, @divTrunc(max_i64_ns, @as(i64, @intCast(ns_per_s))))); + const whole_ns = sec * @as(i64, @intCast(ns_per_s)); + const fractional_ns: i64 = @max(0, @as(i64, @intCast(ts.tv_nsec))); + + if (fractional_ns > max_i64_ns - whole_ns) return max_i64_ns; + return whole_ns + fractional_ns; +} + +pub fn sleep(timeout: std.Io.Timeout) std.Io.Cancelable!void { + const ns = timeoutNanoseconds(timeout); + if (ns <= 0) return; + svcSleepThread(ns); +} + +fn timeoutNanoseconds(timeout: std.Io.Timeout) i64 { + return switch (timeout) { + .none => 0, + .duration => |duration| clampNs(duration.raw.nanoseconds), + .deadline => |deadline| deadlineNanoseconds(deadline), + }; +} + +fn clampNs(ns: i96) i64 { + if (ns > std.math.maxInt(i64)) return std.math.maxInt(i64); + if (ns < std.math.minInt(i64)) return std.math.minInt(i64); + return @intCast(ns); +} + +fn deadlineNanoseconds(deadline: std.Io.Clock.Timestamp) i64 { + const target = clampNs(deadline.raw.nanoseconds); + const current = clampNs(now(deadline.clock).nanoseconds); + if (target <= current) return 0; + return target - current; +} diff --git a/src/platform/audio.zig b/src/platform/audio.zig index 6fc0568..29c5dcd 100644 --- a/src/platform/audio.zig +++ b/src/platform/audio.zig @@ -6,12 +6,16 @@ const audio_api = @import("audio_api.zig"); const mixer_mod = @import("../audio/mixer.zig"); /// Comptime-selected audio backend module (slot-based PCM output). -/// `.none` routes to the silent backend -- used by headless builds and by -/// the macOS default while miniaudio is bugged there. +/// `.none` routes to the silent backend -- used by headless builds and +/// explicit `-Daudio=none` builds. pub const Api = if (options.config.audio == .none) @import("headless/headless_audio.zig") else if (builtin.os.tag == .psp) @import("psp/psp_audio.zig") +else if (builtin.os.tag == .@"3ds") + @import("3ds/3ds_audio.zig") +else if (options.config.platform == .nintendo_switch) + @import("switch/switch_audio.zig") else @import("glfw/audio.zig"); diff --git a/src/platform/c_io.zig b/src/platform/c_io.zig new file mode 100644 index 0000000..f31ce75 --- /dev/null +++ b/src/platform/c_io.zig @@ -0,0 +1,1121 @@ +const std = @import("std"); +const options = @import("options"); + +const Io = std.Io; +const Dir = Io.Dir; +const File = Io.File; + +const platform_time = switch (options.config.platform) { + .nintendo_3ds => @import("3ds/time.zig"), + .nintendo_switch => @import("switch/time.zig"), + else => @compileError("platform/c_io.zig is only wired for Nintendo targets"), +}; +const platform_paths = switch (options.config.platform) { + .nintendo_3ds => @import("3ds/paths.zig"), + .nintendo_switch => @import("switch/paths.zig"), + else => unreachable, +}; + +const c = std.c; + +const CDirent = extern struct { + d_ino: c_int, + d_type: u8, + d_name: [256:0]u8, +}; + +const devkit = struct { + extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int; + extern "c" fn mkdir(path: [*:0]const u8, mode: c_int) c_int; + extern "c" fn readdir(dirp: *c.DIR) ?*CDirent; + extern "c" fn __errno() *c_int; +}; +const max_path_bytes = 1024; + +const AT_FDCWD: c_int = -2; +const O_RDONLY: c_int = 0; +const O_WRONLY: c_int = 1; +const O_RDWR: c_int = 2; +const O_CREAT: c_int = 0x0200; +const O_TRUNC: c_int = 0x0400; +const O_EXCL: c_int = 0x0800; +const O_BINARY: c_int = 0x10000; +const O_CLOEXEC: c_int = 0x40000; +const O_NOFOLLOW: c_int = 0x100000; +const SEEK_SET: c_int = 0; +const SEEK_CUR: c_int = 1; +const SEEK_END: c_int = 2; + +const DT_FIFO: u8 = 1; +const DT_CHR: u8 = 2; +const DT_DIR: u8 = 4; +const DT_BLK: u8 = 6; +const DT_REG: u8 = 8; +const DT_LNK: u8 = 10; +const DT_SOCK: u8 = 12; +const DT_WHT: u8 = 14; + +var read_fd: c_int = -1; +var write_fd: c_int = -1; +var stderr_writer: File.Writer = undefined; +var stderr_writer_initialized = false; +var empty_stderr_buffer: [0]u8 = .{}; +var resources_mounted = false; +var data_mounted = false; +var atomic_counter: u64 = 0x6165_7468_6572_0000; + +const max_dynamic_dirs = 32; +const DirSlot = struct { + used: bool = false, + path: [max_path_bytes:0]u8 = @splat(0), + len: usize = 0, +}; +var dir_slots: [max_dynamic_dirs]DirSlot = [_]DirSlot{.{}} ** max_dynamic_dirs; + +const vtable: Io.VTable = blk: { + var v = Io.failing.vtable.*; + v.crashHandler = crashHandler; + v.async = Io.noAsync; + v.groupAsync = Io.noGroupAsync; + v.recancel = recancel; + v.swapCancelProtection = swapCancelProtection; + v.checkCancel = checkCancel; + v.operate = operate; + v.dirCreateDir = dirCreateDir; + v.dirCreateDirPath = dirCreateDirPath; + v.dirCreateDirPathOpen = dirCreateDirPathOpen; + v.dirOpenDir = dirOpenDir; + v.dirAccess = dirAccess; + v.dirCreateFile = dirCreateFile; + v.dirCreateFileAtomic = dirCreateFileAtomic; + v.dirOpenFile = dirOpenFile; + v.dirClose = dirClose; + v.dirRead = dirRead; + v.dirDeleteFile = dirDeleteFile; + v.dirDeleteDir = dirDeleteDir; + v.dirRename = dirRename; + v.dirRenamePreserve = dirRenamePreserve; + v.fileStat = fileStat; + v.fileLength = fileLength; + v.fileClose = fileClose; + v.fileWritePositional = fileWritePositional; + v.fileReadPositional = fileReadPositional; + v.fileSeekBy = fileSeekBy; + v.fileSeekTo = fileSeekTo; + v.fileSync = fileSync; + v.fileIsTty = fileIsTty; + v.fileEnableAnsiEscapeCodes = fileEnableAnsiEscapeCodes; + v.fileSupportsAnsiEscapeCodes = fileSupportsAnsiEscapeCodes; + v.fileSetLength = fileSetLength; + v.lockStderr = lockStderr; + v.tryLockStderr = tryLockStderr; + v.unlockStderr = unlockStderr; + v.processCurrentPath = processCurrentPath; + v.processSetCurrentPath = processSetCurrentPath; + v.now = now; + v.clockResolution = clockResolution; + v.sleep = sleep; + v.random = random; + break :blk v; +}; + +pub fn io() Io { + return .{ .userdata = null, .vtable = &vtable }; +} + +pub fn cwd() Dir { + return .{ .handle = AT_FDCWD }; +} + +pub fn mountData() void { + data_mounted = platform_paths.mountData(); +} + +pub fn mountResources() bool { + resources_mounted = platform_paths.mountResources(); + return resources_mounted; +} + +pub fn dataRoot(buffer: []u8, app_name: []const u8) error{NameTooLong}![]const u8 { + return platform_paths.dataRoot(buffer, app_name); +} + +pub fn deinitAppDirs() void { + for (&dir_slots) |*slot| slot.used = false; + if (resources_mounted) { + platform_paths.unmountResources(); + resources_mounted = false; + } + if (data_mounted) { + platform_paths.unmountData(); + data_mounted = false; + } +} + +pub fn useCwdDirs() void { + deinitAppDirs(); +} + +fn crashHandler(_: ?*anyopaque) void {} + +fn recancel(_: ?*anyopaque) void {} + +fn swapCancelProtection(_: ?*anyopaque, new: Io.CancelProtection) Io.CancelProtection { + _ = new; + return .unblocked; +} + +fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} + +fn operate(_: ?*anyopaque, operation: Io.Operation) Io.Cancelable!Io.Operation.Result { + return switch (operation) { + .file_read_streaming => |op| .{ .file_read_streaming = fileReadStreaming(op.file, op.data) }, + .file_write_streaming => |op| .{ .file_write_streaming = fileWriteStreaming(op.file, op.header, op.data, op.splat) }, + .device_io_control => unsupported("device_io_control"), + .net_receive => .{ .net_receive = .{ error.NetworkDown, 0 } }, + }; +} + +fn dirCreateDir( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + permissions: Dir.Permissions, +) Dir.CreateDirError!void { + var path_buffer: [max_path_bytes:0]u8 = undefined; + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + const mode = permissionsMode(permissions, 0o777); + if (devkit.mkdir(path.ptr, mode) == 0) return; + return createDirError(errno()); +} + +fn dirCreateDirPath( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + permissions: Dir.Permissions, +) Dir.CreateDirPathError!Dir.CreatePathStatus { + return createDirPathAt(dir, sub_path, permissions); +} + +fn dirCreateDirPathOpen( + userdata: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + permissions: Dir.Permissions, + open_options: Dir.OpenOptions, +) Dir.CreateDirPathOpenError!Dir { + _ = try dirCreateDirPath(userdata, dir, sub_path, permissions); + return dirOpenDir(userdata, dir, sub_path, open_options); +} + +fn dirOpenDir(_: ?*anyopaque, dir: Dir, sub_path: []const u8, options_arg: Dir.OpenOptions) Dir.OpenError!Dir { + if (!options_arg.access_sub_paths) unsupported("dirOpenDir without sub-path access"); + + var path_buffer: [max_path_bytes:0]u8 = undefined; + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + const stream = c.opendir(path.ptr) orelse return dirOpenError(errno()); + _ = c.closedir(stream); + + return registerDir(path); +} + +fn dirAccess(_: ?*anyopaque, dir: Dir, sub_path: []const u8, opts: Dir.AccessOptions) Dir.AccessError!void { + if (!opts.follow_symlinks) unsupported("dirAccess without symlink following"); + + var path_buffer: [max_path_bytes:0]u8 = undefined; + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + const fd = devkit.open(path.ptr, O_BINARY | O_RDONLY | O_CLOEXEC, @as(c_int, 0)); + if (fd < 0) return accessError(errno()); + _ = c.close(fd); +} + +fn dirOpenFile(_: ?*anyopaque, dir: Dir, sub_path: []const u8, flags: Dir.OpenFileOptions) File.OpenError!File { + if (flags.lock != .none) return error.FileLocksUnsupported; + if (flags.path_only) unsupported("path-only file open"); + if (!flags.allow_ctty) {} + + const role: FileRole = switch (flags.mode) { + .read_only => .read, + .write_only => .write, + .read_write => .read_write, + }; + + var path_buffer: [max_path_bytes:0]u8 = undefined; + var open_flags: c_int = O_BINARY | O_CLOEXEC | switch (flags.mode) { + .read_only => O_RDONLY, + .write_only => O_WRONLY, + .read_write => O_RDWR, + }; + if (!flags.follow_symlinks) open_flags |= O_NOFOLLOW; + + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + const fd = devkit.open(path.ptr, open_flags, @as(c_int, 0)); + if (fd < 0) return openError(errno()); + errdefer _ = c.close(fd); + return registerFile(fd, role); +} + +fn dirCreateFile(_: ?*anyopaque, dir: Dir, sub_path: []const u8, flags: Dir.CreateFileOptions) File.OpenError!File { + if (flags.lock != .none) return error.FileLocksUnsupported; + + var path_buffer: [max_path_bytes:0]u8 = undefined; + var open_flags: c_int = O_BINARY | O_CLOEXEC | if (flags.read) O_RDWR else O_WRONLY; + open_flags |= O_CREAT; + if (flags.truncate) open_flags |= O_TRUNC; + if (flags.exclusive) open_flags |= O_EXCL; + + const mode = permissionsMode(flags.permissions, 0o666); + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + const fd = devkit.open(path.ptr, open_flags, mode); + if (fd < 0) return openError(errno()); + errdefer _ = c.close(fd); + return registerFile(fd, if (flags.read) .read_write else .write); +} + +fn dirCreateFileAtomic( + userdata: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + opts: Dir.CreateFileAtomicOptions, +) Dir.CreateFileAtomicError!File.Atomic { + var target_dir = dir; + var close_target_dir = false; + var dest_sub_path = sub_path; + errdefer if (close_target_dir) target_dir.close(io()); + + if (std.fs.path.dirname(sub_path)) |parent| { + target_dir = if (opts.make_path) + dirCreateDirPathOpen(userdata, dir, parent, .default_dir, .{}) catch |err| return createFileAtomicDirError(err) + else + dirOpenDir(userdata, dir, parent, .{}) catch |err| return createFileAtomicDirError(err); + close_target_dir = true; + dest_sub_path = std.fs.path.basename(sub_path); + } else if (opts.make_path) { + _ = opts.make_path; + } + + var attempts: u8 = 0; + while (attempts < 16) : (attempts += 1) { + atomic_counter +%= 1; + const basename_hex = atomic_counter; + const tmp_sub_path = std.fmt.hex(basename_hex); + const file = dirCreateFile(userdata, target_dir, &tmp_sub_path, .{ + .read = true, + .exclusive = true, + .permissions = opts.permissions, + }) catch |err| switch (err) { + error.PathAlreadyExists => continue, + error.FileTooBig, error.IsDir, error.DeviceBusy, error.FileLocksUnsupported, error.PipeBusy => return error.Unexpected, + else => |e| return @errorCast(e), + }; + errdefer file.close(io()); + + const result: File.Atomic = .{ + .file = file, + .file_basename_hex = basename_hex, + .file_open = true, + .file_exists = true, + .dir = target_dir, + .close_dir_on_deinit = close_target_dir, + .dest_sub_path = dest_sub_path, + }; + close_target_dir = false; + return result; + } + + return error.SystemResources; +} + +fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { + for (dirs) |dir| { + if (dirSlotIndex(dir)) |i| dir_slots[i].used = false; + } +} + +fn dirRead(_: ?*anyopaque, reader: *Dir.Reader, out: []Dir.Entry) Dir.Reader.Error!usize { + const Header = extern struct { + pos: c_long, + }; + const header_end = @sizeOf(Header); + if (reader.index < header_end) { + reader.index = header_end; + reader.end = header_end; + const header: *Header = @ptrCast(@alignCast(reader.buffer.ptr)); + header.* = .{ .pos = 0 }; + } + + const header: *Header = @ptrCast(@alignCast(reader.buffer.ptr)); + if (reader.state == .reset) { + header.pos = 0; + reader.state = .reading; + } + if (reader.state == .finished) return 0; + + var path_buffer: [max_path_bytes:0]u8 = undefined; + const root = dirRoot(reader.dir); + const path = zPath(&path_buffer, if (root.len == 0) "." else root) catch return error.Unexpected; + const stream = c.opendir(path.ptr) orelse return dirReadError(errno()); + var stream_open = true; + defer if (stream_open) { + _ = c.closedir(stream); + }; + + c.seekdir(stream, header.pos); + + var count: usize = 0; + var name_end = reader.buffer.len; + while (count < out.len) { + devkit.__errno().* = 0; + const entry = devkit.readdir(stream) orelse { + if (errno() != 0) return dirReadError(errno()); + reader.state = .finished; + return count; + }; + header.pos = c.telldir(stream); + + const name = std.mem.span(@as([*:0]const u8, @ptrCast(&entry.d_name))); + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue; + if (name.len + 1 > name_end - header_end) { + if (count == 0) return error.Unexpected; + break; + } + + name_end -= name.len + 1; + @memcpy(reader.buffer[name_end..][0..name.len], name); + reader.buffer[name_end + name.len] = 0; + out[count] = .{ + .name = reader.buffer[name_end .. name_end + name.len], + .kind = direntKind(entry.d_type), + .inode = @intCast(entry.d_ino), + }; + count += 1; + } + + stream_open = false; + _ = c.closedir(stream); + return count; +} + +fn dirDeleteFile(_: ?*anyopaque, dir: Dir, sub_path: []const u8) Dir.DeleteFileError!void { + var path_buffer: [max_path_bytes:0]u8 = undefined; + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + if (c.unlink(path.ptr) == 0) return; + return deleteFileError(errno()); +} + +fn dirDeleteDir(_: ?*anyopaque, dir: Dir, sub_path: []const u8) Dir.DeleteDirError!void { + var path_buffer: [max_path_bytes:0]u8 = undefined; + const path = try rootedPathForDir(&path_buffer, dir, sub_path); + if (c.rmdir(path.ptr) == 0) return; + return deleteDirError(errno()); +} + +fn dirRename( + _: ?*anyopaque, + old_dir: Dir, + old_sub_path: []const u8, + new_dir: Dir, + new_sub_path: []const u8, +) Dir.RenameError!void { + var old_path_buffer: [max_path_bytes:0]u8 = undefined; + var new_path_buffer: [max_path_bytes:0]u8 = undefined; + const old_path = try rootedPathForDir(&old_path_buffer, old_dir, old_sub_path); + const new_path = try rootedPathForDir(&new_path_buffer, new_dir, new_sub_path); + if (c.rename(old_path.ptr, new_path.ptr) == 0) return; + return renameError(errno()); +} + +fn dirRenamePreserve( + userdata: ?*anyopaque, + old_dir: Dir, + old_sub_path: []const u8, + new_dir: Dir, + new_sub_path: []const u8, +) Dir.RenamePreserveError!void { + dirAccess(userdata, new_dir, new_sub_path, .{}) catch |err| switch (err) { + error.FileNotFound => {}, + else => |e| return @errorCast(e), + }; + if (dirAccess(userdata, new_dir, new_sub_path, .{})) |_| return error.PathAlreadyExists else |err| switch (err) { + error.FileNotFound => {}, + else => |e| return @errorCast(e), + } + return dirRename(userdata, old_dir, old_sub_path, new_dir, new_sub_path) catch |err| switch (err) { + error.DiskQuota, error.IsDir, error.LinkQuotaExceeded, error.NoDevice, error.PipeBusy, error.AntivirusInterference, error.HardwareFailure => return error.Unexpected, + else => |e| return @errorCast(e), + }; +} + +fn fileStat(_: ?*anyopaque, file: File) File.StatError!File.Stat { + return .{ + .inode = zero(File.INode), + .nlink = zero(File.NLink), + .size = try fileLength(null, file), + .permissions = .default_file, + .kind = .file, + .atime = null, + .mtime = now(null, .real), + .ctime = now(null, .real), + .block_size = 1, + }; +} + +fn fileLength(_: ?*anyopaque, file: File) File.LengthError!u64 { + const fd = fdForRegular(file); + const current = c.lseek(fd, 0, SEEK_CUR); + if (current < 0) return seekToLengthError(); + const end = c.lseek(fd, 0, SEEK_END); + if (end < 0) return seekToLengthError(); + _ = c.lseek(fd, current, SEEK_SET); + return @intCast(end); +} + +fn fileClose(_: ?*anyopaque, files: []const File) void { + for (files) |file| { + if (isStderrFile(file)) continue; + if (@sizeOf(File.Handle) != 0) { + const fd = fdFromFileHandle(file); + if (fd > 2) _ = c.close(fd); + continue; + } + if (read_fd >= 0 and read_fd == write_fd) { + _ = c.close(read_fd); + read_fd = -1; + write_fd = -1; + } else if (read_fd >= 0) { + _ = c.close(read_fd); + read_fd = -1; + } else if (write_fd >= 0) { + _ = c.close(write_fd); + write_fd = -1; + } + } +} + +fn fileReadPositional( + _: ?*anyopaque, + file: File, + data: []const []u8, + offset: u64, +) File.ReadPositionalError!usize { + const fd = fdForRead(file); + try seekToOffset(fd, offset); + + var total: usize = 0; + for (data) |buf| { + var remaining = buf; + while (remaining.len > 0) { + const n = c.read(fd, remaining.ptr, remaining.len); + if (n < 0) return readError(); + if (n == 0) return total; + const amt: usize = @intCast(n); + total += amt; + remaining = remaining[amt..]; + if (amt == 0) return total; + } + } + return total; +} + +fn fileReadStreaming(file: File, data: []const []u8) Io.Operation.FileReadStreaming.Result { + const fd = fdForRead(file); + var total: usize = 0; + for (data) |buf| { + var remaining = buf; + while (remaining.len > 0) { + const n = c.read(fd, remaining.ptr, remaining.len); + if (n < 0) return readStreamingError(); + if (n == 0) return if (total == 0) error.EndOfStream else total; + const amt: usize = @intCast(n); + total += amt; + remaining = remaining[amt..]; + } + } + return total; +} + +fn fileWritePositional( + _: ?*anyopaque, + file: File, + header: []const u8, + data: []const []const u8, + splat: usize, + offset: u64, +) File.WritePositionalError!usize { + const fd = fdForWrite(file); + try seekToOffset(fd, offset); + return writeVectors(fd, header, data, splat); +} + +fn fileWriteStreaming(file: File, header: []const u8, data: []const []const u8, splat: usize) Io.Operation.FileWriteStreaming.Result { + return writeVectors(fdForWrite(file), header, data, splat) catch |err| switch (err) { + error.Canceled => unreachable, + error.Unseekable => error.InputOutput, + else => |e| @errorCast(e), + }; +} + +fn fileSeekBy(_: ?*anyopaque, file: File, relative_offset: i64) File.SeekError!void { + if (c.lseek(fdForRegular(file), @intCast(relative_offset), SEEK_CUR) < 0) return seekError(); +} + +fn fileSeekTo(_: ?*anyopaque, file: File, absolute_offset: u64) File.SeekError!void { + try seekToOffset(fdForRegular(file), absolute_offset); +} + +fn fileSync(_: ?*anyopaque, file: File) File.SyncError!void { + if (isStderrFile(file)) return; + if (c.fsync(fdForRegular(file)) < 0) return syncError(); +} + +fn fileIsTty(_: ?*anyopaque, _: File) Io.Cancelable!bool { + return false; +} + +fn fileEnableAnsiEscapeCodes(_: ?*anyopaque, _: File) File.EnableAnsiEscapeCodesError!void { + return error.NotTerminalDevice; +} + +fn fileSupportsAnsiEscapeCodes(_: ?*anyopaque, _: File) Io.Cancelable!bool { + return false; +} + +fn fileSetLength(_: ?*anyopaque, file: File, length: u64) File.SetLengthError!void { + if (length > std.math.maxInt(c.off_t)) return error.FileTooBig; + if (c.ftruncate(fdForRegular(file), @intCast(length)) < 0) return setLengthError(); +} + +fn lockStderr(_: ?*anyopaque, terminal_mode: ?Io.Terminal.Mode) Io.Cancelable!Io.LockedStderr { + if (!stderr_writer_initialized) { + var stderr_file: File = .{ + .handle = if (@sizeOf(File.Handle) == 0) {} else @as(File.Handle, @intCast(2)), + .flags = .{ .nonblocking = true }, + }; + stderr_file.flags.nonblocking = true; + stderr_writer = stderr_file.writerStreaming(io(), &empty_stderr_buffer); + stderr_writer_initialized = true; + } + return .{ + .file_writer = &stderr_writer, + .terminal_mode = terminal_mode orelse .no_color, + }; +} + +fn tryLockStderr(userdata: ?*anyopaque, terminal_mode: ?Io.Terminal.Mode) Io.Cancelable!?Io.LockedStderr { + return try lockStderr(userdata, terminal_mode); +} + +fn unlockStderr(_: ?*anyopaque) void { + if (stderr_writer_initialized) stderr_writer.interface.flush() catch {}; +} + +fn processCurrentPath(_: ?*anyopaque, buffer: []u8) std.process.CurrentPathError!usize { + if (buffer.len == 0) return error.NameTooLong; + const ptr = c.getcwd(buffer.ptr, buffer.len) orelse return currentPathError(); + _ = ptr; + return std.mem.indexOfScalar(u8, buffer, 0) orelse error.NameTooLong; +} + +fn processSetCurrentPath(_: ?*anyopaque, path: []const u8) std.process.SetCurrentPathError!void { + var path_buffer: [max_path_bytes:0]u8 = undefined; + const z = zPath(&path_buffer, path) catch |err| switch (err) { + error.NameTooLong => return error.NameTooLong, + error.BadPathName => return error.BadPathName, + }; + if (c.chdir(z.ptr) < 0) return setCurrentPathError(); +} + +fn now(_: ?*anyopaque, clock: Io.Clock) Io.Timestamp { + return platform_time.now(clock); +} + +fn clockResolution(_: ?*anyopaque, clock: Io.Clock) Io.Clock.ResolutionError!Io.Duration { + return platform_time.clockResolution(clock); +} + +fn sleep(_: ?*anyopaque, timeout: Io.Timeout) Io.Cancelable!void { + return platform_time.sleep(timeout); +} + +fn random(_: ?*anyopaque, buffer: []u8) void { + @memset(buffer, 0); +} + +const FileRole = enum { read, write, read_write }; + +fn createDirPathAt(dir: Dir, sub_path: []const u8, permissions: Dir.Permissions) Dir.CreateDirPathError!Dir.CreatePathStatus { + if (sub_path.len == 0) return error.BadPathName; + + var path_buffer: [max_path_bytes:0]u8 = undefined; + const full = rootedPathForDir(&path_buffer, dir, sub_path) catch |err| return err; + const full_len = full.len; + const full_ptr = path_buffer[0..].ptr; + const mode = permissionsMode(permissions, 0o777); + + var status: Dir.CreatePathStatus = .existed; + const start = pathRootEnd(full); + var i = start; + while (i < full_len) : (i += 1) { + if (path_buffer[i] != '/') continue; + if (i == start) continue; + path_buffer[i] = 0; + if (try createSingleDirPath(full_ptr, mode) == .created) status = .created; + path_buffer[i] = '/'; + } + if (try createSingleDirPath(full_ptr, mode) == .created) status = .created; + return status; +} + +fn createSingleDirPath(path: [*:0]const u8, mode: c_int) Dir.CreateDirPathError!Dir.CreatePathStatus { + if (devkit.mkdir(path, mode) == 0) return .created; + switch (errno()) { + 17 => return .existed, + 1 => return error.PermissionDenied, + 2 => return error.FileNotFound, + 6 => return error.NoDevice, + 12 => return error.SystemResources, + 13 => return error.AccessDenied, + 20 => return error.NotDir, + 28 => return error.NoSpaceLeft, + 30 => return error.ReadOnlyFileSystem, + 91 => return error.NameTooLong, + 92 => return error.SymLinkLoop, + else => return error.Unexpected, + } +} + +fn registerFile(fd: c_int, role: FileRole) File { + if (@sizeOf(File.Handle) == 0) { + switch (role) { + .read => { + if (read_fd >= 0) unsupported("more than one regular read file"); + read_fd = fd; + }, + .write => { + if (write_fd >= 0) unsupported("more than one regular write file"); + write_fd = fd; + }, + .read_write => { + if (read_fd >= 0) unsupported("more than one regular read file"); + if (write_fd >= 0) unsupported("more than one regular write file"); + read_fd = fd; + write_fd = fd; + }, + } + return .{ .handle = {}, .flags = .{ .nonblocking = false } }; + } + return .{ + .handle = @intCast(fd), + .flags = .{ .nonblocking = false }, + }; +} + +fn fdForRead(file: File) c_int { + if (@sizeOf(File.Handle) != 0) return fdFromFileHandle(file); + if (read_fd < 0) unsupported("read from unopened regular file"); + return read_fd; +} + +fn fdForWrite(file: File) c_int { + if (isStderrFile(file)) return 2; + if (@sizeOf(File.Handle) != 0) return fdFromFileHandle(file); + if (write_fd < 0) unsupported("write to unopened regular file"); + return write_fd; +} + +fn fdForRegular(file: File) c_int { + if (isStderrFile(file)) unsupported("regular file operation on stderr"); + if (@sizeOf(File.Handle) != 0) return fdFromFileHandle(file); + if (read_fd >= 0) return read_fd; + if (write_fd >= 0) return write_fd; + unsupported("regular file operation with no open file"); +} + +fn fdFromFileHandle(file: File) c_int { + return @intCast(file.handle); +} + +fn fdFromDirHandle(dir: Dir) c_int { + return @intCast(dir.handle); +} + +fn permissionsMode(permissions: File.Permissions, default: c_int) c_int { + if (@bitSizeOf(File.Permissions) == 0) return default; + return @intCast(@intFromEnum(permissions)); +} + +fn isStderrFile(file: File) bool { + return file.flags.nonblocking; +} + +fn registerDir(path: []const u8) Dir.OpenError!Dir { + for (&dir_slots, 0..) |*slot, i| { + if (slot.used) continue; + if (path.len >= max_path_bytes) return error.NameTooLong; + @memcpy(slot.path[0..path.len], path); + slot.path[path.len] = 0; + slot.len = path.len; + slot.used = true; + return .{ .handle = @intCast(i + 3) }; + } + return error.SystemResources; +} + +fn dirSlotIndex(dir: Dir) ?usize { + const handle = fdFromDirHandle(dir); + if (handle < 3) return null; + const index: usize = @intCast(handle - 3); + if (index >= dir_slots.len or !dir_slots[index].used) return null; + return index; +} + +fn dirRoot(dir: Dir) []const u8 { + if (fdFromDirHandle(dir) == AT_FDCWD) return ""; + const index = dirSlotIndex(dir) orelse unsupported("closed Nintendo dir handle"); + return dir_slots[index].path[0..dir_slots[index].len]; +} + +fn rootedPath(buf: *[max_path_bytes:0]u8, path: []const u8, root: []const u8) error{ NameTooLong, BadPathName }![:0]const u8 { + if (isAbsoluteOrDevicePath(path) or root.len == 0) return zPath(buf, path); + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.BadPathName; + + const needs_sep = !std.mem.endsWith(u8, root, "/") and !std.mem.startsWith(u8, path, "/"); + const len = root.len + @intFromBool(needs_sep) + path.len; + if (len >= max_path_bytes) return error.NameTooLong; + + var i: usize = 0; + @memcpy(buf[i..][0..root.len], root); + i += root.len; + if (needs_sep) { + buf[i] = '/'; + i += 1; + } + @memcpy(buf[i..][0..path.len], path); + buf[len] = 0; + return buf[0..len :0]; +} + +fn rootedPathForDir(buf: *[max_path_bytes:0]u8, dir: Dir, path: []const u8) error{ NameTooLong, BadPathName }![:0]const u8 { + return rootedPath(buf, path, dirRoot(dir)); +} + +fn direntKind(kind: u8) File.Kind { + return switch (kind) { + DT_BLK => .block_device, + DT_CHR => .character_device, + DT_DIR => .directory, + DT_FIFO => .named_pipe, + DT_LNK => .sym_link, + DT_REG => .file, + DT_SOCK => .unix_domain_socket, + DT_WHT => .whiteout, + else => .unknown, + }; +} + +fn seekToOffset(fd: c_int, offset: u64) File.SeekError!void { + if (offset > std.math.maxInt(c.off_t)) return error.Unseekable; + if (c.lseek(fd, @intCast(offset), SEEK_SET) < 0) return seekError(); +} + +fn writeVectors(fd: c_int, header: []const u8, data: []const []const u8, splat: usize) File.WritePositionalError!usize { + var total: usize = 0; + total += try writeOne(fd, header); + for (0..splat) |_| { + for (data) |buf| { + total += try writeOne(fd, buf); + } + } + return total; +} + +fn writeOne(fd: c_int, bytes: []const u8) File.WritePositionalError!usize { + var remaining = bytes; + var total: usize = 0; + while (remaining.len > 0) { + const n = c.write(fd, remaining.ptr, remaining.len); + if (n < 0) return writeError(); + if (n == 0) return total; + const amt: usize = @intCast(n); + total += amt; + remaining = remaining[amt..]; + } + return total; +} + +fn zPath(buf: *[max_path_bytes:0]u8, path: []const u8) error{ NameTooLong, BadPathName }![:0]const u8 { + if (path.len >= max_path_bytes) return error.NameTooLong; + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.BadPathName; + @memcpy(buf[0..path.len], path); + buf[path.len] = 0; + return buf[0..path.len :0]; +} + +fn isAbsoluteOrDevicePath(path: []const u8) bool { + if (path.len == 0) return false; + if (path[0] == '/') return true; + const colon = std.mem.indexOfScalar(u8, path, ':') orelse return false; + const slash = std.mem.indexOfAny(u8, path, "/\\") orelse path.len; + return colon < slash; +} + +fn pathRootEnd(path: []const u8) usize { + if (std.mem.indexOfScalar(u8, path, ':')) |colon| { + if (colon + 1 < path.len and path[colon + 1] == '/') return colon + 2; + return colon + 1; + } + return if (path.len > 0 and path[0] == '/') 1 else 0; +} + +fn createFileAtomicDirError(err: anyerror) Dir.CreateFileAtomicError { + return switch (err) { + error.PathAlreadyExists, error.NotDir => error.NotDir, + error.FileTooBig, error.IsDir, error.DeviceBusy, error.FileLocksUnsupported => error.Unexpected, + else => @errorCast(err), + }; +} + +fn errno() c_int { + return devkit.__errno().*; +} + +fn createDirError(code: c_int) Dir.CreateDirError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 6 => error.NoDevice, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 17 => error.PathAlreadyExists, + 20 => error.NotDir, + 28 => error.NoSpaceLeft, + 30 => error.ReadOnlyFileSystem, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn accessError(code: c_int) Dir.AccessError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 5 => error.InputOutput, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 16 => error.FileBusy, + 30 => error.ReadOnlyFileSystem, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn dirOpenError(code: c_int) Dir.OpenError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 6 => error.NoDevice, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 20 => error.NotDir, + 23 => error.ProcessFdQuotaExceeded, + 24 => error.SystemFdQuotaExceeded, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn openError(code: c_int) File.OpenError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 6 => error.NoDevice, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 16 => error.DeviceBusy, + 17 => error.PathAlreadyExists, + 20 => error.NotDir, + 21 => error.IsDir, + 23 => error.SystemFdQuotaExceeded, + 24 => error.ProcessFdQuotaExceeded, + 26 => error.FileBusy, + 27 => error.FileTooBig, + 28 => error.NoSpaceLeft, + 30 => error.ReadOnlyFileSystem, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn deleteFileError(code: c_int) Dir.DeleteFileError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 16 => error.FileBusy, + 20 => error.NotDir, + 21 => error.IsDir, + 30 => error.ReadOnlyFileSystem, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn deleteDirError(code: c_int) Dir.DeleteDirError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 16 => error.FileBusy, + 20 => error.NotDir, + 30 => error.ReadOnlyFileSystem, + 39 => error.DirNotEmpty, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn renameError(code: c_int) Dir.RenameError { + return switch (code) { + 1 => error.PermissionDenied, + 2 => error.FileNotFound, + 5 => error.HardwareFailure, + 6 => error.NoDevice, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 16 => error.FileBusy, + 18 => error.CrossDevice, + 20 => error.NotDir, + 21 => error.IsDir, + 28 => error.NoSpaceLeft, + 30 => error.ReadOnlyFileSystem, + 39 => error.DirNotEmpty, + 91 => error.NameTooLong, + 92 => error.SymLinkLoop, + else => error.Unexpected, + }; +} + +fn readError() File.ReadPositionalError { + return switch (errno()) { + 5 => error.InputOutput, + 11 => error.WouldBlock, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 21 => error.IsDir, + 29 => error.Unseekable, + else => error.Unexpected, + }; +} + +fn readStreamingError() Io.Operation.FileReadStreaming.Error { + return switch (errno()) { + 5 => error.InputOutput, + 11 => error.WouldBlock, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 21 => error.IsDir, + else => error.Unexpected, + }; +} + +fn dirReadError(code: c_int) Dir.Reader.Error { + return switch (code) { + 1 => error.PermissionDenied, + 12 => error.SystemResources, + 13 => error.AccessDenied, + else => error.Unexpected, + }; +} + +fn writeError() File.WritePositionalError { + return switch (errno()) { + 5 => error.InputOutput, + 6 => error.NoDevice, + 11 => error.WouldBlock, + 12 => error.SystemResources, + 13 => error.AccessDenied, + 27 => error.FileTooBig, + 28 => error.NoSpaceLeft, + 29 => error.Unseekable, + 32 => error.BrokenPipe, + else => error.Unexpected, + }; +} + +fn seekError() File.SeekError { + return switch (errno()) { + 13 => error.AccessDenied, + 29 => error.Unseekable, + else => error.Unexpected, + }; +} + +fn seekToLengthError() File.LengthError { + return switch (errno()) { + 5 => error.Unexpected, + 13 => error.AccessDenied, + 29 => error.Streaming, + else => error.Unexpected, + }; +} + +fn syncError() File.SyncError { + return switch (errno()) { + 5 => error.InputOutput, + 12 => error.Unexpected, + 13 => error.AccessDenied, + 28 => error.NoSpaceLeft, + 132 => error.DiskQuota, + else => error.Unexpected, + }; +} + +fn setLengthError() File.SetLengthError { + return switch (errno()) { + 5 => error.InputOutput, + 13 => error.AccessDenied, + 16 => error.FileBusy, + 27 => error.FileTooBig, + 29 => error.NonResizable, + else => error.Unexpected, + }; +} + +fn currentPathError() std.process.CurrentPathError { + return switch (errno()) { + 12 => error.Unexpected, + 91 => error.NameTooLong, + else => error.CurrentDirUnlinked, + }; +} + +fn setCurrentPathError() std.process.SetCurrentPathError { + return switch (errno()) { + 2 => error.FileNotFound, + 13 => error.AccessDenied, + 20 => error.NotDir, + 91 => error.NameTooLong, + else => error.Unexpected, + }; +} + +fn zero(comptime T: type) T { + return switch (@typeInfo(T)) { + .void => {}, + .int, .comptime_int => 0, + else => @as(T, @intCast(0)), + }; +} + +fn unsupported(comptime name: []const u8) noreturn { + std.debug.panic("c std.Io baseline does not implement {s}", .{name}); +} diff --git a/src/platform/c_process_init.zig b/src/platform/c_process_init.zig new file mode 100644 index 0000000..e0f08bf --- /dev/null +++ b/src/platform/c_process_init.zig @@ -0,0 +1,88 @@ +const std = @import("std"); +const c_io = @import("c_io.zig"); + +extern fn memalign(alignment: usize, size: usize) ?*anyopaque; +extern fn free(ptr: ?*anyopaque) void; + +var arena_state: std.heap.ArenaAllocator = undefined; +var environ_map_state: std.process.Environ.Map = undefined; + +const allocator_vtable: std.mem.Allocator.VTable = .{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = dealloc, +}; + +pub fn makeInit(args: std.process.Args) std.process.Init { + const gpa = allocator(); + arena_state = std.heap.ArenaAllocator.init(gpa); + environ_map_state = std.process.Environ.Map.init(gpa); + + return .{ + .minimal = .{ + .environ = std.process.Environ.empty, + .args = args, + }, + .arena = &arena_state, + .gpa = gpa, + .io = c_io.io(), + .environ_map = &environ_map_state, + .preopens = std.process.Preopens.empty, + }; +} + +fn allocator() std.mem.Allocator { + return .{ + .ptr = undefined, + .vtable = &allocator_vtable, + }; +} + +fn alloc( + _: *anyopaque, + len: usize, + alignment: std.mem.Alignment, + _: usize, +) ?[*]u8 { + std.debug.assert(len > 0); + + const effective_alignment = @max(alignment.toByteUnits(), @sizeOf(usize)); + const ptr = memalign(effective_alignment, len) orelse return null; + std.debug.assert(alignment.check(@intFromPtr(ptr))); + return @ptrCast(ptr); +} + +fn resize( + _: *anyopaque, + memory: []u8, + _: std.mem.Alignment, + new_len: usize, + _: usize, +) bool { + std.debug.assert(memory.len > 0); + std.debug.assert(new_len > 0); + return new_len <= memory.len; +} + +fn remap( + _: *anyopaque, + memory: []u8, + _: std.mem.Alignment, + new_len: usize, + _: usize, +) ?[*]u8 { + std.debug.assert(memory.len > 0); + std.debug.assert(new_len > 0); + return if (new_len <= memory.len) memory.ptr else null; +} + +fn dealloc( + _: *anyopaque, + memory: []u8, + _: std.mem.Alignment, + _: usize, +) void { + std.debug.assert(memory.len > 0); + free(memory.ptr); +} diff --git a/src/platform/gfx.zig b/src/platform/gfx.zig index 411938b..36252e8 100644 --- a/src/platform/gfx.zig +++ b/src/platform/gfx.zig @@ -10,7 +10,12 @@ const surface_iface = @import("surface.zig"); /// `gfx.api.start_frame()` resolve to direct function calls with no /// indirection. pub const Api = switch (options.config.gfx) { - .default => @import("psp/psp_gfx_ge.zig"), + .default => if (builtin.os.tag == .@"3ds") + @import("3ds/3ds_gfx.zig") + else if (options.config.platform == .nintendo_switch) + @import("switch/switch_gfx.zig") + else + @import("psp/psp_gfx_ge.zig"), .opengl => @import("glfw/opengl/opengl_gfx.zig"), .vulkan => @import("glfw/vulkan/vulkan_gfx.zig"), .headless => @import("headless/headless_gfx.zig"), @@ -22,6 +27,10 @@ pub const Surface = if (options.config.gfx == .headless) @import("headless/surface.zig") else if (builtin.os.tag == .psp) @import("psp/surface.zig") +else if (builtin.os.tag == .@"3ds") + @import("3ds/surface.zig") +else if (options.config.platform == .nintendo_switch) + @import("switch/surface.zig") else @import("glfw/surface.zig"); diff --git a/src/platform/glfw/audio.zig b/src/platform/glfw/audio.zig index f0bbe09..cb75750 100644 --- a/src/platform/glfw/audio.zig +++ b/src/platform/glfw/audio.zig @@ -1,17 +1,19 @@ -//! Desktop audio backend -- uses zaudio (miniaudio) with a low-level device +//! Desktop audio backend -- uses SDL3 audio with an on-demand stream //! callback. The audio thread pulls PCM from each slot's Stream reader, -//! converts to float32 stereo, applies gain/pan from the mixer, and writes -//! to the output device. +//! converts to float32 stereo, applies gain/pan from the mixer, and queues +//! mixed frames to SDL. const std = @import("std"); -const zaudio = @import("zaudio"); +const sdl3 = @import("sdl3"); const Stream = @import("../../audio/stream.zig").Stream; const PcmFormat = @import("../../audio/stream.zig").PcmFormat; -const DEVICE_SAMPLE_RATE: u32 = 44_100; -const DEVICE_CHANNELS: u32 = 2; +const SDL_AUDIO_FLAGS = sdl3.InitFlags{ .audio = true }; +const DEVICE_SAMPLE_RATE: usize = 44_100; +const DEVICE_CHANNELS: usize = 2; const NUM_SLOTS: usize = 32; -/// Maximum frames the device callback will request per invocation. +const OUTPUT_FRAME_BYTES: usize = DEVICE_CHANNELS * @sizeOf(f32); +/// Maximum frames mixed per callback chunk. const MAX_PERIOD_FRAMES: usize = 1024; /// Per-slot scratch buffer: room for MAX_PERIOD_FRAMES of stereo 32-bit PCM. const READ_BUF_SIZE: usize = MAX_PERIOD_FRAMES * 2 * 4; @@ -48,35 +50,46 @@ fn init_slots() [NUM_SLOTS]Slot { // -- device ------------------------------------------------------------------ -var device: ?*zaudio.Device = null; -var audio_alloc: std.mem.Allocator = undefined; -var audio_io: std.Io = undefined; +var device_stream: ?sdl3.audio.Stream = null; +var sdl_audio_initialized = false; +var output_buf: [MAX_PERIOD_FRAMES * DEVICE_CHANNELS]f32 = undefined; -pub fn setup(alloc: std.mem.Allocator, io: std.Io) void { - audio_alloc = alloc; - audio_io = io; -} +pub fn setup(_: std.mem.Allocator, _: std.Io) void {} pub fn init() anyerror!void { - zaudio.init(audio_alloc); + try sdl3.init(SDL_AUDIO_FLAGS); + sdl_audio_initialized = true; + errdefer { + sdl3.quit(SDL_AUDIO_FLAGS); + sdl_audio_initialized = false; + } - var config = zaudio.Device.Config.init(.playback); - config.playback.format = .float32; - config.playback.channels = DEVICE_CHANNELS; - config.sample_rate = DEVICE_SAMPLE_RATE; - config.data_callback = data_callback; + const spec = sdl3.audio.Spec{ + .format = .floating_32_bit, + .num_channels = DEVICE_CHANNELS, + .sample_rate = DEVICE_SAMPLE_RATE, + }; + + const stream = try sdl3.audio.Device.default_playback.openStream(spec, anyopaque, data_callback, null); + device_stream = stream; + errdefer { + stream.deinit(); + device_stream = null; + } - device = try zaudio.Device.create(null, config); - try device.?.start(); + try stream.resumeDevice(); } pub fn deinit() void { - if (device) |d| { - d.stop() catch {}; - d.destroy(); - device = null; + if (device_stream) |stream| { + stream.pauseDevice() catch {}; + stream.deinit(); + device_stream = null; + } + if (sdl_audio_initialized) { + sdl3.quit(SDL_AUDIO_FLAGS); + sdl_audio_initialized = false; } - zaudio.deinit(); } pub fn update() void {} @@ -109,19 +122,33 @@ pub fn is_slot_active(slot: u8) bool { return state != .inactive and state != .finished; } -// -- audio thread callback --------------------------------------------------- +// -- audio stream callback --------------------------------------------------- fn data_callback( - _: *zaudio.Device, - raw_output: ?*anyopaque, - _: ?*const anyopaque, - frame_count: u32, -) callconv(.c) void { - const out: [*]f32 = @ptrCast(@alignCast(raw_output orelse return)); - const total_samples: usize = @as(usize, frame_count) * DEVICE_CHANNELS; + _: ?*anyopaque, + stream: sdl3.audio.Stream, + additional_amount: usize, + _: usize, +) void { + var bytes_remaining = additional_amount; + while (bytes_remaining > 0) { + const frames = @min( + MAX_PERIOD_FRAMES, + (bytes_remaining + OUTPUT_FRAME_BYTES - 1) / OUTPUT_FRAME_BYTES, + ); + const out = output_buf[0 .. frames * DEVICE_CHANNELS]; + fill_output(out, frames); + + const bytes = std.mem.sliceAsBytes(out); + stream.putData(bytes) catch return; + + if (bytes_remaining <= bytes.len) break; + bytes_remaining -= bytes.len; + } +} - // Start with silence. - @memset(out[0..total_samples], 0); +fn fill_output(out: []f32, frame_count: usize) void { + @memset(out, 0); for (&slots) |*slot| { const raw_state = slot.state.load(.acquire); @@ -141,7 +168,7 @@ fn data_callback( const right_gain = gain * std.math.clamp(1.0 + pan, 0.0, 1.0); const fmt = slot.stream.format; - const bytes_needed: usize = @as(usize, frame_count) * fmt.frame_size(); + const bytes_needed: usize = frame_count * @as(usize, fmt.frame_size()); if (bytes_needed > READ_BUF_SIZE) { slot.state.store(@intFromEnum(SlotState.finished), .release); @@ -171,24 +198,22 @@ fn read_f32(buf: []const u8, index: usize) f32 { } fn mix_into( - out: [*]f32, + out: []f32, buf: []const u8, fmt: PcmFormat, - frame_count: u32, + frame_count: usize, left_gain: f32, right_gain: f32, ) void { - const frames: usize = frame_count; - if (fmt.bit_depth == 16) { if (fmt.channels == 1) { - for (0..frames) |f| { + for (0..frame_count) |f| { const s = read_i16(buf, f); out[f * 2] += s * left_gain; out[f * 2 + 1] += s * right_gain; } } else { - for (0..frames) |f| { + for (0..frame_count) |f| { const l = read_i16(buf, f * 2); const r = read_i16(buf, f * 2 + 1); out[f * 2] += l * left_gain; @@ -197,13 +222,13 @@ fn mix_into( } } else if (fmt.bit_depth == 32) { if (fmt.channels == 1) { - for (0..frames) |f| { + for (0..frame_count) |f| { const s = read_f32(buf, f); out[f * 2] += s * left_gain; out[f * 2 + 1] += s * right_gain; } } else { - for (0..frames) |f| { + for (0..frame_count) |f| { out[f * 2] += read_f32(buf, f * 2) * left_gain; out[f * 2 + 1] += read_f32(buf, f * 2 + 1) * right_gain; } diff --git a/src/platform/input.zig b/src/platform/input.zig index 4fe0232..0b134b7 100644 --- a/src/platform/input.zig +++ b/src/platform/input.zig @@ -11,6 +11,10 @@ pub const Api = if (options.config.gfx == .headless) @import("headless/input.zig") else if (builtin.os.tag == .psp) @import("psp/input.zig") +else if (builtin.os.tag == .@"3ds") + @import("3ds/input.zig") +else if (options.config.platform == .nintendo_switch) + @import("switch/input.zig") else @import("glfw/input.zig"); diff --git a/src/platform/switch/input.zig b/src/platform/switch/input.zig new file mode 100644 index 0000000..397d024 --- /dev/null +++ b/src/platform/switch/input.zig @@ -0,0 +1,280 @@ +//! Switch input backend. Polls libnx's pad and touchscreen helpers once per +//! engine update and translates them into Aether core input events. + +const std = @import("std"); +const core = @import("../../core/input/input.zig"); + +const Result = u32; + +const HidAnalogStickState = extern struct { + x: i32, + y: i32, +}; + +const HidTouchState = extern struct { + delta_time: u64, + attributes: u32, + finger_id: u32, + x: u32, + y: u32, + diameter_x: u32, + diameter_y: u32, + rotation_angle: u32, + reserved: u32, +}; + +const HidTouchScreenState = extern struct { + sampling_number: u64, + count: i32, + reserved: u32, + touches: [16]HidTouchState, +}; + +const PadState = extern struct { + id_mask: u8, + active_id_mask: u8, + read_handheld: bool, + active_handheld: bool, + style_set: u32, + attributes: u32, + buttons_cur: u64, + buttons_old: u64, + sticks: [2]HidAnalogStickState, + gc_triggers: [2]u32, +}; + +extern fn hidInitialize() Result; +extern fn hidExit() void; +extern fn hidInitializeTouchScreen() void; +extern fn hidGetTouchScreenStates(states: [*]HidTouchScreenState, count: usize) usize; + +extern fn padConfigureInput(max_players: u32, style_set: u32) void; +extern fn padInitializeWithMask(pad: *PadState, mask: u64) void; +extern fn padUpdate(pad: *PadState) void; + +extern fn swkbdCreate(config: *anyopaque, max_dictwords: i32) Result; +extern fn swkbdClose(config: *anyopaque) void; +extern fn swkbdConfigMakePresetDefault(config: *anyopaque) void; +extern fn swkbdConfigSetOkButtonText(config: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdConfigSetHeaderText(config: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdConfigSetGuideText(config: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdConfigSetInitialText(config: *anyopaque, text: [*:0]const u8) void; +extern fn swkbdShow(config: *anyopaque, out_string: [*]u8, out_string_size: usize) Result; + +const HID_NPAD_STYLE_FULL_KEY: u32 = 1 << 0; +const HID_NPAD_STYLE_HANDHELD: u32 = 1 << 1; +const HID_NPAD_STYLE_JOY_DUAL: u32 = 1 << 2; +const HID_NPAD_STYLE_JOY_LEFT: u32 = 1 << 3; +const HID_NPAD_STYLE_JOY_RIGHT: u32 = 1 << 4; +const HID_NPAD_STYLE_STANDARD: u32 = HID_NPAD_STYLE_FULL_KEY | HID_NPAD_STYLE_HANDHELD | HID_NPAD_STYLE_JOY_DUAL | HID_NPAD_STYLE_JOY_LEFT | HID_NPAD_STYLE_JOY_RIGHT; + +const HID_NPAD_ID_NO1: u64 = 1 << 0; +const HID_NPAD_ID_HANDHELD: u64 = 1 << 32; +const DEFAULT_PAD_MASK: u64 = HID_NPAD_ID_NO1 | HID_NPAD_ID_HANDHELD; + +const BUTTON_A: u64 = 1 << 0; +const BUTTON_B: u64 = 1 << 1; +const BUTTON_X: u64 = 1 << 2; +const BUTTON_Y: u64 = 1 << 3; +const BUTTON_STICK_L: u64 = 1 << 4; +const BUTTON_STICK_R: u64 = 1 << 5; +const BUTTON_L: u64 = 1 << 6; +const BUTTON_R: u64 = 1 << 7; +const BUTTON_ZL: u64 = 1 << 8; +const BUTTON_ZR: u64 = 1 << 9; +const BUTTON_PLUS: u64 = 1 << 10; +const BUTTON_MINUS: u64 = 1 << 11; +const BUTTON_LEFT: u64 = 1 << 12; +const BUTTON_UP: u64 = 1 << 13; +const BUTTON_RIGHT: u64 = 1 << 14; +const BUTTON_DOWN: u64 = 1 << 15; +const BUTTON_LEFT_SL: u64 = 1 << 24; +const BUTTON_LEFT_SR: u64 = 1 << 25; +const BUTTON_RIGHT_SL: u64 = 1 << 26; +const BUTTON_RIGHT_SR: u64 = 1 << 27; + +const JOYSTICK_MAX: f32 = 32767.0; +const MAX_TEXT_BYTES: usize = 1024; +const SWKBD_CONFIG_BYTES: usize = 0x600; + +const axis_count = @typeInfo(core.Axis).@"enum".fields.len; + +var initialized: bool = false; +var pad: PadState = undefined; +var prev_buttons: u64 = 0; +var prev_axes: [axis_count]f32 = @splat(0.0); +var prev_touch_down: bool = false; +var prev_touch_pos: core.Vec2 = .{}; + +pub fn setup(_: std.mem.Allocator, _: std.Io) void { + initialized = false; + pad = std.mem.zeroes(PadState); + prev_buttons = 0; + prev_axes = @splat(0.0); + prev_touch_down = false; + prev_touch_pos = .{}; +} + +pub fn init() anyerror!void { + if (hidInitialize() != 0) return error.InputInitFailed; + hidInitializeTouchScreen(); + padConfigureInput(1, HID_NPAD_STYLE_STANDARD); + padInitializeWithMask(&pad, DEFAULT_PAD_MASK); + initialized = true; +} + +pub fn deinit() void { + if (!initialized) return; + hidExit(); + initialized = false; +} + +pub fn pump() void { + padUpdate(&pad); + + diff_buttons(pad.buttons_cur); + pump_axes(pad.buttons_cur); + pump_touch(); + + prev_buttons = pad.buttons_cur; + core.signal_frame_boundary(); +} + +pub fn apply_cursor_mode(_: core.CursorMode) void {} + +pub fn begin_text_input_session(target: core.TextInputTarget, options: core.TextInputOptions) anyerror!void { + var config_buf: [SWKBD_CONFIG_BYTES]u8 align(8) = @splat(0); + const config: *anyopaque = @ptrCast(&config_buf); + + var initial_buf: [MAX_TEXT_BYTES:0]u8 = @splat(0); + const initial_len = copy_current_text(&initial_buf); + const initial = initial_buf[0..initial_len :0]; + + var target_buf: [128:0]u8 = @splat(0); + const target_text = copy_z(&target_buf, target.id); + + if (swkbdCreate(config, 0) != 0) { + core.write_text_session_buffer(initial_buf[0..initial_len], .cancelled); + return; + } + defer swkbdClose(config); + + swkbdConfigMakePresetDefault(config); + swkbdConfigSetOkButtonText(config, "OK"); + swkbdConfigSetHeaderText(config, target_text.ptr); + swkbdConfigSetGuideText(config, target_text.ptr); + swkbdConfigSetInitialText(config, initial.ptr); + + var out_buf: [MAX_TEXT_BYTES:0]u8 = @splat(0); + const out_size = output_buffer_size(options.max_bytes); + if (swkbdShow(config, out_buf[0..].ptr, out_size) == 0) { + const len = bounded_z_len(out_buf[0..out_size]); + core.write_text_session_buffer(out_buf[0..len], .submitted); + } else { + core.write_text_session_buffer(initial_buf[0..initial_len], .cancelled); + } +} + +pub fn end_text_input_session() void {} + +fn diff_buttons(buttons: u64) void { + const Pair = struct { mask: u64, button: core.Button }; + const map = [_]Pair{ + .{ .mask = BUTTON_A, .button = .A }, + .{ .mask = BUTTON_B, .button = .B }, + .{ .mask = BUTTON_X, .button = .X }, + .{ .mask = BUTTON_Y, .button = .Y }, + .{ .mask = BUTTON_L | BUTTON_LEFT_SL | BUTTON_RIGHT_SL, .button = .LButton }, + .{ .mask = BUTTON_R | BUTTON_LEFT_SR | BUTTON_RIGHT_SR, .button = .RButton }, + .{ .mask = BUTTON_MINUS, .button = .Back }, + .{ .mask = BUTTON_PLUS, .button = .Start }, + .{ .mask = BUTTON_STICK_L, .button = .LeftThumb }, + .{ .mask = BUTTON_STICK_R, .button = .RightThumb }, + .{ .mask = BUTTON_UP, .button = .DpadUp }, + .{ .mask = BUTTON_RIGHT, .button = .DpadRight }, + .{ .mask = BUTTON_DOWN, .button = .DpadDown }, + .{ .mask = BUTTON_LEFT, .button = .DpadLeft }, + }; + + inline for (map) |entry| { + const now = buttons & entry.mask != 0; + const prev = prev_buttons & entry.mask != 0; + if (now != prev) { + core.deliver_gamepad_button(entry.button, if (now) .pressed else .released); + } + } +} + +fn pump_axes(buttons: u64) void { + const left = pad.sticks[0]; + const right = pad.sticks[1]; + + deliver_axis(.LeftX, normalize_stick(left.x)); + deliver_axis(.LeftY, -normalize_stick(left.y)); + deliver_axis(.RightX, normalize_stick(right.x)); + deliver_axis(.RightY, -normalize_stick(right.y)); + deliver_axis(.LeftTrigger, if (buttons & BUTTON_ZL != 0) 1.0 else 0.0); + deliver_axis(.RightTrigger, if (buttons & BUTTON_ZR != 0) 1.0 else 0.0); +} + +fn pump_touch() void { + var states: [1]HidTouchScreenState = undefined; + const state_count = hidGetTouchScreenStates(&states, states.len); + const touch_down = state_count > 0 and states[0].count > 0; + + if (touch_down) { + const touch = states[0].touches[0]; + const pos: core.Vec2 = .{ + .x = @floatFromInt(touch.x), + .y = @floatFromInt(touch.y), + }; + const delta: core.Vec2 = if (prev_touch_down) + .{ .x = pos.x - prev_touch_pos.x, .y = pos.y - prev_touch_pos.y } + else + .{}; + + core.deliver_mouse_move(pos, delta); + if (!prev_touch_down) core.deliver_mouse_button(.Left, .pressed, pos); + prev_touch_pos = pos; + } else if (prev_touch_down) { + core.deliver_mouse_button(.Left, .released, prev_touch_pos); + } + + prev_touch_down = touch_down; +} + +fn deliver_axis(axis: core.Axis, value: f32) void { + const idx = @intFromEnum(axis); + const prev = prev_axes[idx]; + if (value != 0.0 or prev != 0.0) core.deliver_gamepad_axis(axis, value); + prev_axes[idx] = value; +} + +fn normalize_stick(raw: i32) f32 { + const value = @as(f32, @floatFromInt(raw)) / JOYSTICK_MAX; + return std.math.clamp(value, -1.0, 1.0); +} + +fn output_buffer_size(limit: ?usize) usize { + const max = @min(limit orelse (MAX_TEXT_BYTES - 1), MAX_TEXT_BYTES - 1); + return max + 1; +} + +fn copy_current_text(dst: []u8) usize { + const s = core.current_text_session() orelse return 0; + const n = @min(dst.len - 1, s.buffer.items.len); + @memcpy(dst[0..n], s.buffer.items[0..n]); + dst[n] = 0; + return n; +} + +fn copy_z(dst: []u8, text: []const u8) [:0]const u8 { + const n = @min(dst.len - 1, text.len); + @memcpy(dst[0..n], text[0..n]); + dst[n] = 0; + return dst[0..n :0]; +} + +fn bounded_z_len(buf: []const u8) usize { + return std.mem.indexOfScalar(u8, buf, 0) orelse buf.len; +} diff --git a/src/platform/switch/paths.zig b/src/platform/switch/paths.zig new file mode 100644 index 0000000..77b14fe --- /dev/null +++ b/src/platform/switch/paths.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +extern fn fsdevMountSdmc() u32; +extern fn fsdevUnmountDevice(name: [*:0]const u8) c_int; +extern fn romfsMountSelf(name: [*:0]const u8) u32; +extern fn romfsUnmount(name: [*:0]const u8) u32; + +pub fn mountData() bool { + return fsdevMountSdmc() == 0; +} + +pub fn unmountData() void { + _ = fsdevUnmountDevice("sdmc"); +} + +pub fn mountResources() bool { + return romfsMountSelf("romfs") == 0; +} + +pub fn unmountResources() void { + _ = romfsUnmount("romfs"); +} + +pub fn dataRoot(buffer: []u8, app_name: []const u8) error{NameTooLong}![]const u8 { + return std.fmt.bufPrint(buffer, "sdmc:/switch/{s}", .{app_name}) catch error.NameTooLong; +} diff --git a/src/platform/switch/services.zig b/src/platform/switch/services.zig new file mode 100644 index 0000000..09c05fd --- /dev/null +++ b/src/platform/switch/services.zig @@ -0,0 +1,41 @@ +//! Switch system services / entry shim. +//! +//! Exports a C-callable `main` that hands control to the user's Zig +//! `main`. The allocator and baseline `std.Io` pieces of `std.process.Init` +//! are wired through newlib; deeper platform services remain TODO. +//! +//! libnx's switch.specs links with `--require-defined=main`, which +//! pulls a strong `main` from libnx's crt0 by default. We shadow it +//! with this Zig export — same name, weakness doesn't matter since +//! ld picks the first definition seen — to route the entry through +//! Aether instead of libnx's nnMain wrapper. + +const process_init = @import("aether").CProcessInit; +const std = @import("std"); + +pub const os = struct { + pub const PATH_MAX = 1024; + pub const NAME_MAX = 255; +}; + +fn AppRoot() type { + const root = @import("root"); + return if (@hasDecl(root, "main")) root else @import("aether_user_root"); +} + +pub const std_options = if (@hasDecl(AppRoot(), "std_options")) AppRoot().std_options else std.Options{}; +pub const panic = if (@hasDecl(AppRoot(), "panic")) AppRoot().panic else std.debug.no_panic; +pub const std_options_debug_threaded_io = if (@hasDecl(AppRoot(), "std_options_debug_threaded_io")) AppRoot().std_options_debug_threaded_io else null; +pub const std_options_debug_io = if (@hasDecl(AppRoot(), "std_options_debug_io")) AppRoot().std_options_debug_io else std.Io.failing; +const app_std_options_cwd: ?fn () std.Io.Dir = if (@hasDecl(AppRoot(), "std_options_cwd")) AppRoot().std_options_cwd else null; +pub const std_options_cwd = app_std_options_cwd orelse @import("aether").Cio.cwd; + +comptime { + @export(&entry, .{ .name = "main" }); +} + +fn entry(_: c_int, _: [*c][*c]u8) callconv(.c) c_int { + const init = process_init.makeInit(.{ .vector = {} }); + AppRoot().main(init) catch return 1; + return 0; +} diff --git a/src/platform/switch/surface.zig b/src/platform/switch/surface.zig new file mode 100644 index 0000000..8d49220 --- /dev/null +++ b/src/platform/switch/surface.zig @@ -0,0 +1,31 @@ +//! Switch surface stub. +//! +//! Switch's framebuffer is 1280x720 in handheld mode and 1920x1080 +//! docked. We advertise 1280x720 so the engine has a sane default; +//! a real backend will query `appletGetOperationMode` and resize on +//! dock transitions. + +const std = @import("std"); +const Self = @This(); + +extern fn appletMainLoop() bool; + +alloc: std.mem.Allocator, + +pub fn init(_: *Self, _: u32, _: u32, _: [:0]const u8, _: bool, _: bool, _: bool) anyerror!void {} + +pub fn deinit(_: *Self) void {} + +pub fn update(_: *Self) bool { + return appletMainLoop(); +} + +pub fn draw(_: *Self) void {} + +pub fn get_width(_: *Self) u32 { + return 1280; +} + +pub fn get_height(_: *Self) u32 { + return 720; +} diff --git a/src/platform/switch/switch_audio.zig b/src/platform/switch/switch_audio.zig new file mode 100644 index 0000000..35ab166 --- /dev/null +++ b/src/platform/switch/switch_audio.zig @@ -0,0 +1,260 @@ +//! Switch audio backend -- audout with software mixing. +//! +//! audout exposes one 48 kHz stereo i16 output stream, so this backend mixes +//! Aether's slots into a ring of audout buffers. A small nearest-neighbor +//! resampler keeps the existing 44.1 kHz test WAVs playable. + +const std = @import("std"); +const Stream = @import("../../audio/stream.zig").Stream; +const PcmFormat = @import("../../audio/stream.zig").PcmFormat; + +const DEVICE_SAMPLE_RATE: u32 = 48_000; +const DEVICE_CHANNELS: usize = 2; +const NUM_SLOTS: usize = 24; +const BUFFER_COUNT: usize = 3; +const SAMPLES_PER_BUF: usize = 2048; +const OUTPUT_BYTES: usize = SAMPLES_PER_BUF * DEVICE_CHANNELS * @sizeOf(i16); +const OUTPUT_BUFFER_BYTES: usize = std.mem.alignForward(usize, OUTPUT_BYTES, 0x1000); +const TOTAL_OUTPUT_BYTES: usize = BUFFER_COUNT * OUTPUT_BUFFER_BYTES; +const FP_ONE: u64 = 1 << 32; + +const Result = u32; + +const AudioOutBuffer = extern struct { + next: ?*AudioOutBuffer, + buffer: ?*anyopaque, + buffer_size: u64, + data_size: u64, + data_offset: u64, +}; + +extern fn audoutInitialize() Result; +extern fn audoutExit() void; +extern fn audoutStartAudioOut() Result; +extern fn audoutStopAudioOut() Result; +extern fn audoutAppendAudioOutBuffer(buffer: *AudioOutBuffer) Result; +extern fn audoutGetReleasedAudioOutBuffer(buffer: *?*AudioOutBuffer, released_count: *u32) Result; +extern fn memalign(alignment: usize, size: usize) ?*anyopaque; +extern fn free(ptr: ?*anyopaque) void; + +const SlotState = enum(u8) { + inactive = 0, + pending = 1, + active = 2, + finished = 3, +}; + +const Slot = struct { + state: SlotState = .inactive, + gain: f32 = 0, + pan: f32 = 0, + stream: Stream = undefined, + format: PcmFormat = .{ .sample_rate = 44_100, .channels = 1, .bit_depth = 16 }, + step_fp: u64 = FP_ONE, + phase_fp: u64 = 0, + current_left: i16 = 0, + current_right: i16 = 0, +}; + +var slots: [NUM_SLOTS]Slot = init_slots(); +var audio_alloc: std.mem.Allocator = undefined; +var audio_io: std.Io = undefined; +var output_data: ?[*]u8 = null; +var buffers: [BUFFER_COUNT]AudioOutBuffer = undefined; +var initialized: bool = false; + +fn init_slots() [NUM_SLOTS]Slot { + var s: [NUM_SLOTS]Slot = undefined; + for (&s) |*slot| { + slot.* = .{}; + } + return s; +} + +pub fn setup(alloc: std.mem.Allocator, io: std.Io) void { + audio_alloc = alloc; + audio_io = io; +} + +pub fn init() anyerror!void { + _ = audio_alloc; + _ = audio_io; + + output_data = @ptrCast(memalign(0x1000, TOTAL_OUTPUT_BYTES) orelse return error.AudioInitFailed); + @memset(output_data.?[0..TOTAL_OUTPUT_BYTES], 0); + + if (audoutInitialize() != 0) { + free_output(); + return error.AudioInitFailed; + } + + if (audoutStartAudioOut() != 0) { + audoutExit(); + free_output(); + return error.AudioInitFailed; + } + + initialized = true; + + for (&buffers, 0..) |*buf, i| { + buf.* = .{ + .next = null, + .buffer = @ptrCast(output_data.? + i * OUTPUT_BUFFER_BYTES), + .buffer_size = OUTPUT_BUFFER_BYTES, + .data_size = OUTPUT_BYTES, + .data_offset = 0, + }; + if (audoutAppendAudioOutBuffer(buf) != 0) { + _ = audoutStopAudioOut(); + audoutExit(); + initialized = false; + free_output(); + return error.AudioInitFailed; + } + } +} + +pub fn deinit() void { + if (initialized) { + _ = audoutStopAudioOut(); + audoutExit(); + initialized = false; + } + + free_output(); + + for (&slots) |*slot| { + slot.state = .inactive; + } +} + +pub fn update() void { + if (!initialized) return; + + while (true) { + var released: ?*AudioOutBuffer = null; + var released_count: u32 = 0; + if (audoutGetReleasedAudioOutBuffer(&released, &released_count) != 0) return; + if (released_count == 0 or released == null) return; + + const buf = released.?; + fill_output_buffer(buf); + _ = audoutAppendAudioOutBuffer(buf); + } +} + +pub fn max_voices() u32 { + return NUM_SLOTS; +} + +pub fn play_slot(slot: u8, stream: Stream) anyerror!void { + if (slot >= NUM_SLOTS) return error.InvalidArgs; + if (!format_supported(stream.format)) return error.UnsupportedFormat; + + const i: usize = slot; + slots[i].stream = stream; + slots[i].format = stream.format; + slots[i].step_fp = (@as(u64, stream.format.sample_rate) << 32) / DEVICE_SAMPLE_RATE; + slots[i].phase_fp = 0; + slots[i].current_left = 0; + slots[i].current_right = 0; + slots[i].state = .pending; +} + +pub fn stop_slot(slot: u8) void { + if (slot >= NUM_SLOTS) return; + slots[slot].state = .inactive; +} + +pub fn set_slot_gain_pan(slot: u8, gain: f32, pan: f32) void { + if (slot >= NUM_SLOTS) return; + slots[slot].gain = gain; + slots[slot].pan = pan; +} + +pub fn is_slot_active(slot: u8) bool { + if (slot >= NUM_SLOTS) return false; + return slots[slot].state != .inactive and slots[slot].state != .finished; +} + +fn fill_output_buffer(buf: *AudioOutBuffer) void { + const out: [*]i16 = @ptrCast(@alignCast(buf.buffer.?)); + + for (0..SAMPLES_PER_BUF) |frame| { + var left_acc: i32 = 0; + var right_acc: i32 = 0; + + for (&slots) |*slot| { + if (slot.state == .pending) { + if (read_next_sample(slot)) { + slot.state = .active; + } else { + slot.state = .finished; + } + } + + if (slot.state != .active) continue; + + const left_gain = slot.gain * std.math.clamp(1.0 - slot.pan, 0.0, 1.0); + const right_gain = slot.gain * std.math.clamp(1.0 + slot.pan, 0.0, 1.0); + const left_vol: i32 = @intFromFloat(std.math.clamp(left_gain, 0.0, 1.0) * 32768.0); + const right_vol: i32 = @intFromFloat(std.math.clamp(right_gain, 0.0, 1.0) * 32768.0); + + left_acc += (@as(i32, slot.current_left) * left_vol) >> 15; + right_acc += (@as(i32, slot.current_right) * right_vol) >> 15; + + advance_sample(slot); + } + + out[frame * 2] = clamp_i16(left_acc); + out[frame * 2 + 1] = clamp_i16(right_acc); + } + + buf.data_size = OUTPUT_BYTES; + buf.data_offset = 0; +} + +fn advance_sample(slot: *Slot) void { + slot.phase_fp +%= slot.step_fp; + while (slot.phase_fp >= FP_ONE) { + slot.phase_fp -= FP_ONE; + if (!read_next_sample(slot)) { + slot.state = .finished; + return; + } + } +} + +fn read_next_sample(slot: *Slot) bool { + var tmp: [4]u8 = undefined; + const frame_size = slot.format.frame_size(); + if (frame_size > tmp.len) return false; + + slot.stream.reader.readSliceAll(tmp[0..frame_size]) catch return false; + + if (slot.format.channels == 1) { + const s = std.mem.readInt(i16, tmp[0..2], .little); + slot.current_left = s; + slot.current_right = s; + } else { + slot.current_left = std.mem.readInt(i16, tmp[0..2], .little); + slot.current_right = std.mem.readInt(i16, tmp[2..4], .little); + } + + return true; +} + +fn clamp_i16(v: i32) i16 { + return @intCast(std.math.clamp(v, std.math.minInt(i16), std.math.maxInt(i16))); +} + +fn free_output() void { + if (output_data) |data| { + free(data); + output_data = null; + } +} + +fn format_supported(fmt: PcmFormat) bool { + return fmt.bit_depth == 16 and (fmt.channels == 1 or fmt.channels == 2); +} diff --git a/src/platform/switch/switch_gfx.zig b/src/platform/switch/switch_gfx.zig new file mode 100644 index 0000000..5bbbe46 --- /dev/null +++ b/src/platform/switch/switch_gfx.zig @@ -0,0 +1,772 @@ +//! Minimal Nintendo Switch deko3d backend. +//! +//! This is the first bring-up milestone: render Aether's current colored +//! demo mesh through deko3d and present it. Textures, matrices, and richer +//! render state are intentionally left as no-ops until the full backend pass. + +const std = @import("std"); +const Util = @import("../../util/util.zig"); +const Mat4 = @import("../../math/math.zig").Mat4; +const Rendering = @import("../../rendering/rendering.zig"); +const Pipeline = Rendering.Pipeline; +const Mesh = Rendering.mesh; +const Texture = Rendering.Texture; +const gfx = @import("../gfx.zig"); + +const DkDevice_T = opaque {}; +const DkMemBlock_T = opaque {}; +const DkCmdBuf_T = opaque {}; +const DkQueue_T = opaque {}; +const DkSwapchain_T = opaque {}; + +const DkDevice = ?*DkDevice_T; +const DkMemBlock = ?*DkMemBlock_T; +const DkCmdBuf = ?*DkCmdBuf_T; +const DkQueue = ?*DkQueue_T; +const DkSwapchain = ?*DkSwapchain_T; +const DkGpuAddr = u64; +const DkCmdList = usize; + +const DkDeviceMaker = extern struct { + userData: ?*anyopaque, + cbDebug: ?*const anyopaque, + cbAlloc: ?*const anyopaque, + cbFree: ?*const anyopaque, + flags: u32, +}; + +const DkMemBlockMaker = extern struct { + device: DkDevice, + size: u32, + flags: u32, + storage: ?*anyopaque, +}; + +const DkCmdBufMaker = extern struct { + device: DkDevice, + userData: ?*anyopaque, + cbAddMem: ?*const anyopaque, +}; + +const DkQueueMaker = extern struct { + device: DkDevice, + flags: u32, + commandMemorySize: u32, + flushThreshold: u32, + perWarpScratchMemorySize: u32, + maxConcurrentComputeJobs: u32, +}; + +const DkShaderMaker = extern struct { + codeMem: DkMemBlock, + control: ?*const anyopaque, + codeOffset: u32, + programId: u32, +}; + +const DkImageLayoutMaker = extern struct { + device: DkDevice, + type: u32, + flags: u32, + format: u32, + msMode: u32, + dimensions: [3]u32, + mipLevels: u32, + pitchStride: u32, +}; + +const DkSwapchainMaker = extern struct { + device: DkDevice, + nativeWindow: ?*anyopaque, + pImages: [*]const *const DkImage, + numImages: u32, +}; + +const DkShader = extern struct { + storage: [16]u64, +}; + +const DkImageLayout = extern struct { + storage: [16]u64, +}; + +const DkImage = extern struct { + storage: [16]u64, +}; + +const DkImageView = extern struct { + pImage: *const DkImage, + type: u32, + format: u32, + swizzle: [4]u32, + dsSource: u32, + layerOffset: u16, + layerCount: u16, + mipLevelOffset: u8, + mipLevelCount: u8, +}; + +const DkViewport = extern struct { + x: f32, + y: f32, + width: f32, + height: f32, + near: f32, + far: f32, +}; + +const DkScissor = extern struct { + x: u32, + y: u32, + width: u32, + height: u32, +}; + +const DkRasterizerState = extern struct { + bits: u32, +}; + +const DkColorState = extern struct { + bits: u32, +}; + +const DkColorWriteState = extern struct { + masks: u32, +}; + +const DkDepthStencilState = extern struct { + bits0: u32, + bits1: u32, +}; + +const DkVtxAttribState = extern struct { + bits: u32, +}; + +const DkVtxBufferState = extern struct { + stride: u32, + divisor: u32, +}; + +const DkBufExtents = extern struct { + addr: DkGpuAddr, + size: u32, +}; + +extern fn nwindowGetDefault() ?*anyopaque; + +extern fn dkDeviceCreate(maker: *const DkDeviceMaker) DkDevice; +extern fn dkDeviceDestroy(obj: DkDevice) void; + +extern fn dkMemBlockCreate(maker: *const DkMemBlockMaker) DkMemBlock; +extern fn dkMemBlockDestroy(obj: DkMemBlock) void; +extern fn dkMemBlockGetCpuAddr(obj: DkMemBlock) ?*anyopaque; +extern fn dkMemBlockGetGpuAddr(obj: DkMemBlock) DkGpuAddr; +extern fn dkMemBlockGetSize(obj: DkMemBlock) u32; +extern fn dkMemBlockFlushCpuCache(obj: DkMemBlock, offset: u32, size: u32) u32; + +extern fn dkCmdBufCreate(maker: *const DkCmdBufMaker) DkCmdBuf; +extern fn dkCmdBufDestroy(obj: DkCmdBuf) void; +extern fn dkCmdBufAddMemory(obj: DkCmdBuf, mem: DkMemBlock, offset: u32, size: u32) void; +extern fn dkCmdBufFinishList(obj: DkCmdBuf) DkCmdList; +extern fn dkCmdBufClear(obj: DkCmdBuf) void; +extern fn dkCmdBufBindShaders(obj: DkCmdBuf, stageMask: u32, shaders: [*]const *const DkShader, numShaders: u32) void; +extern fn dkCmdBufBindRenderTargets(obj: DkCmdBuf, colorTargets: [*]const *const DkImageView, numColorTargets: u32, depthTarget: ?*const DkImageView) void; +extern fn dkCmdBufBindRasterizerState(obj: DkCmdBuf, state: *const DkRasterizerState) void; +extern fn dkCmdBufBindColorState(obj: DkCmdBuf, state: *const DkColorState) void; +extern fn dkCmdBufBindColorWriteState(obj: DkCmdBuf, state: *const DkColorWriteState) void; +extern fn dkCmdBufBindDepthStencilState(obj: DkCmdBuf, state: *const DkDepthStencilState) void; +extern fn dkCmdBufBindVtxAttribState(obj: DkCmdBuf, attribs: [*]const DkVtxAttribState, numAttribs: u32) void; +extern fn dkCmdBufBindVtxBufferState(obj: DkCmdBuf, buffers: [*]const DkVtxBufferState, numBuffers: u32) void; +extern fn dkCmdBufBindVtxBuffers(obj: DkCmdBuf, firstId: u32, buffers: [*]const DkBufExtents, numBuffers: u32) void; +extern fn dkCmdBufSetViewports(obj: DkCmdBuf, firstId: u32, viewports: [*]const DkViewport, numViewports: u32) void; +extern fn dkCmdBufSetScissors(obj: DkCmdBuf, firstId: u32, scissors: [*]const DkScissor, numScissors: u32) void; +extern fn dkCmdBufClearColor(obj: DkCmdBuf, targetId: u32, clearMask: u32, clearData: *const anyopaque) void; +extern fn dkCmdBufDraw(obj: DkCmdBuf, prim: u32, vertexCount: u32, instanceCount: u32, firstVertex: u32, firstInstance: u32) void; + +extern fn dkQueueCreate(maker: *const DkQueueMaker) DkQueue; +extern fn dkQueueDestroy(obj: DkQueue) void; +extern fn dkQueueWaitIdle(obj: DkQueue) void; +extern fn dkQueueSubmitCommands(obj: DkQueue, cmds: DkCmdList) void; +extern fn dkQueueAcquireImage(obj: DkQueue, swapchain: DkSwapchain) c_int; +extern fn dkQueuePresentImage(obj: DkQueue, swapchain: DkSwapchain, imageSlot: c_int) void; + +extern fn dkShaderInitialize(obj: *DkShader, maker: *const DkShaderMaker) void; +extern fn dkShaderIsValid(obj: *const DkShader) bool; + +extern fn dkImageLayoutInitialize(obj: *DkImageLayout, maker: *const DkImageLayoutMaker) void; +extern fn dkImageLayoutGetSize(obj: *const DkImageLayout) u64; +extern fn dkImageLayoutGetAlignment(obj: *const DkImageLayout) u32; +extern fn dkImageInitialize(obj: *DkImage, layout: *const DkImageLayout, memBlock: DkMemBlock, offset: u32) void; + +extern fn dkSwapchainCreate(maker: *const DkSwapchainMaker) DkSwapchain; +extern fn dkSwapchainDestroy(obj: DkSwapchain) void; +extern fn dkSwapchainSetSwapInterval(obj: DkSwapchain, interval: u32) void; + +const FB_COUNT = 2; +const FB_WIDTH = 1280; +const FB_HEIGHT = 720; +const CODE_MEM_SIZE = 512 * 1024; +const CMD_MEM_SIZE = 64 * 1024; +const MAX_VERTEX_ATTRIBS = 32; +const MAX_VERTEX_BUFFERS = 16; + +const DK_MEMBLOCK_ALIGNMENT = 0x1000; +const DK_SHADER_CODE_ALIGNMENT = 0x100; + +const DK_MEM_CPU_UNCACHED = 1 << 0; +const DK_MEM_GPU_CACHED = 2 << 2; +const DK_MEM_CODE = 1 << 4; +const DK_MEM_IMAGE = 1 << 5; + +const DK_QUEUE_GRAPHICS = 1 << 0; +const DK_QUEUE_MEDIUM_PRIO = 0 << 2; +const DK_QUEUE_ENABLE_ZCULL = 0 << 4; +const DK_QUEUE_MIN_CMDMEM_SIZE = 0x10000; +const DK_PER_WARP_SCRATCH_MEM_ALIGNMENT = 0x200; +const DK_DEFAULT_MAX_COMPUTE_CONCURRENT_JOBS = 128; + +const DK_IMAGE_TYPE_NONE = 0; +const DK_IMAGE_TYPE_2D = 2; +const DK_IMAGE_RGBA8_UNORM = 28; +const DK_IMAGE_USAGE_RENDER = 1 << 8; +const DK_IMAGE_USAGE_PRESENT = 1 << 10; +const DK_IMAGE_HW_COMPRESSION = 1 << 2; + +const DK_STAGE_GRAPHICS_MASK = (1 << 5) - 1; +const DK_COLOR_MASK_RGBA = 0xF; + +const DK_PRIMITIVE_LINES = 1; +const DK_PRIMITIVE_TRIANGLES = 4; + +const DK_ATTR_SIZE_2X32 = 0x04; +const DK_ATTR_SIZE_3X32 = 0x02; +const DK_ATTR_SIZE_2X16 = 0x0f; +const DK_ATTR_SIZE_3X16 = 0x05; +const DK_ATTR_SIZE_2X8 = 0x18; +const DK_ATTR_SIZE_4X8 = 0x0a; + +const DK_ATTR_TYPE_SNORM = 1; +const DK_ATTR_TYPE_UNORM = 2; +const DK_ATTR_TYPE_FLOAT = 7; + +const DK_SWIZZLE_RED = 2; +const DK_SWIZZLE_GREEN = 3; +const DK_SWIZZLE_BLUE = 4; +const DK_SWIZZLE_ALPHA = 5; +const DK_DS_SOURCE_DEPTH = 0; + +const PipelineData = struct { + vertex_shader: DkShader, + fragment_shader: DkShader, + attribs: [MAX_VERTEX_ATTRIBS]DkVtxAttribState, + attrib_count: u32, + vtx_buffers: [MAX_VERTEX_BUFFERS]DkVtxBufferState, + vtx_buffer_count: u32, +}; + +const MeshData = struct { + pipeline: Pipeline.Handle, + mem_block: DkMemBlock = null, + gpu_addr: DkGpuAddr = 0, + capacity: u32 = 0, + size: u32 = 0, +}; + +var render_alloc: std.mem.Allocator = undefined; +var render_io: std.Io = undefined; + +var device: DkDevice = null; +var render_queue: DkQueue = null; +var swapchain: DkSwapchain = null; +var framebuffer_mem: DkMemBlock = null; +var framebuffers: [FB_COUNT]DkImage = undefined; +var command_mem: DkMemBlock = null; +var command_buffer: DkCmdBuf = null; +var code_mem: DkMemBlock = null; +var code_offset: u32 = 0; + +var pipelines = Util.CircularBuffer(PipelineData, 16).init(); +var meshes = Util.CircularBuffer(MeshData, 8192).init(); + +var current_pipeline: Pipeline.Handle = 0; +var current_slot: c_int = -1; +var initialized: bool = false; +var clear_color: [4]f32 = .{ 0.0, 0.0, 0.0, 1.0 }; +var vsync_enabled: bool = true; + +pub fn setup(alloc: std.mem.Allocator, io: std.Io) void { + render_alloc = alloc; + render_io = io; +} + +pub fn init() anyerror!void { + _ = render_alloc; + _ = render_io; + + var device_maker = DkDeviceMaker{ + .userData = null, + .cbDebug = null, + .cbAlloc = null, + .cbFree = null, + .flags = 0, + }; + device = dkDeviceCreate(&device_maker); + if (device == null) return error.GfxInitFailed; + errdefer { + dkDeviceDestroy(device); + device = null; + } + + try create_framebuffers(); + errdefer destroy_framebuffers(); + + try create_code_memory(); + errdefer destroy_code_memory(); + + try create_command_buffer(); + errdefer destroy_command_buffer(); + + var queue_maker = DkQueueMaker{ + .device = device, + .flags = DK_QUEUE_GRAPHICS | DK_QUEUE_MEDIUM_PRIO | DK_QUEUE_ENABLE_ZCULL, + .commandMemorySize = DK_QUEUE_MIN_CMDMEM_SIZE, + .flushThreshold = DK_QUEUE_MIN_CMDMEM_SIZE / 8, + .perWarpScratchMemorySize = 4 * DK_PER_WARP_SCRATCH_MEM_ALIGNMENT, + .maxConcurrentComputeJobs = DK_DEFAULT_MAX_COMPUTE_CONCURRENT_JOBS, + }; + render_queue = dkQueueCreate(&queue_maker); + if (render_queue == null) return error.GfxInitFailed; + + initialized = true; + set_vsync(vsync_enabled); +} + +pub fn deinit() void { + if (render_queue) |_| dkQueueWaitIdle(render_queue); + + destroy_all_meshes(); + pipelines.clear(); + current_pipeline = 0; + + if (render_queue) |_| { + dkQueueDestroy(render_queue); + render_queue = null; + } + + destroy_command_buffer(); + destroy_code_memory(); + destroy_framebuffers(); + + if (device) |_| { + dkDeviceDestroy(device); + device = null; + } + + initialized = false; +} + +pub fn set_clear_color(r: f32, g: f32, b: f32, a: f32) void { + clear_color = .{ r, g, b, a }; +} + +pub fn set_alpha_blend(_: bool) void {} +pub fn set_depth_write(_: bool) void {} +pub fn set_fog(_: bool, _: f32, _: f32, _: f32, _: f32, _: f32) void {} +pub fn set_clip_planes(_: bool) void {} +pub fn set_culling(_: bool) void {} +pub fn set_uv_offset(_: f32, _: f32) void {} +pub fn set_proj_matrix(_: *const Mat4) void {} +pub fn set_view_matrix(_: *const Mat4) void {} + +pub fn start_frame() bool { + if (!initialized or render_queue == null or swapchain == null or command_buffer == null) return false; + + // Single command-memory arena for the bring-up path. Wait before reuse so + // the GPU cannot still be reading last frame's command list. + dkQueueWaitIdle(render_queue); + + const slot = dkQueueAcquireImage(render_queue, swapchain); + if (slot < 0 or slot >= FB_COUNT) return false; + current_slot = slot; + + dkCmdBufClear(command_buffer); + dkCmdBufAddMemory(command_buffer, command_mem, 0, CMD_MEM_SIZE); + + var color_view = imageView(&framebuffers[@intCast(slot)]); + const color_targets = [_]*const DkImageView{&color_view}; + dkCmdBufBindRenderTargets(command_buffer, color_targets[0..].ptr, 1, null); + + const width = gfx.surface.get_width(); + const height = gfx.surface.get_height(); + if (width == 0 or height == 0) return false; + + var viewport = DkViewport{ + .x = 0.0, + .y = 0.0, + .width = @floatFromInt(width), + .height = @floatFromInt(height), + .near = 0.0, + .far = 1.0, + }; + var scissor = DkScissor{ + .x = 0, + .y = 0, + .width = width, + .height = height, + }; + dkCmdBufSetViewports(command_buffer, 0, @ptrCast(&viewport), 1); + dkCmdBufSetScissors(command_buffer, 0, @ptrCast(&scissor), 1); + dkCmdBufClearColor(command_buffer, 0, DK_COLOR_MASK_RGBA, &clear_color); + + bind_fixed_state(); + return true; +} + +pub fn end_frame() void { + if (!initialized or render_queue == null or swapchain == null or command_buffer == null or current_slot < 0) return; + + const list = dkCmdBufFinishList(command_buffer); + dkQueueSubmitCommands(render_queue, list); + dkQueuePresentImage(render_queue, swapchain, current_slot); + current_slot = -1; +} + +pub fn clear_depth() void {} + +pub fn set_vsync(v: bool) void { + vsync_enabled = v; + if (swapchain) |_| dkSwapchainSetSwapInterval(swapchain, @intFromBool(v)); +} + +pub fn create_pipeline(layout: Pipeline.VertexLayout, v_shader: ?[:0]align(4) const u8, f_shader: ?[:0]align(4) const u8) anyerror!Pipeline.Handle { + const vertex_code = v_shader orelse return error.InvalidShader; + const fragment_code = f_shader orelse return error.InvalidShader; + + var data = PipelineData{ + .vertex_shader = undefined, + .fragment_shader = undefined, + .attribs = @splat(.{ .bits = 0 }), + .attrib_count = 0, + .vtx_buffers = @splat(.{ .stride = 0, .divisor = 0 }), + .vtx_buffer_count = 0, + }; + + try init_layout(&data, layout); + try load_shader(&data.vertex_shader, vertex_code); + try load_shader(&data.fragment_shader, fragment_code); + + const pipeline = pipelines.add_element(data) orelse return error.OutOfPipelines; + return @intCast(pipeline); +} + +pub fn destroy_pipeline(pipeline: Pipeline.Handle) void { + _ = pipelines.remove_element(pipeline); + if (current_pipeline == pipeline) current_pipeline = 0; +} + +pub fn bind_pipeline(pipeline: Pipeline.Handle) void { + current_pipeline = pipeline; +} + +pub fn create_mesh(pipeline: Pipeline.Handle) anyerror!Mesh.Handle { + _ = pipelines.get_element(pipeline) orelse return error.InvalidPipeline; + const mesh = meshes.add_element(.{ .pipeline = pipeline }) orelse return error.OutOfMeshes; + return @intCast(mesh); +} + +pub fn destroy_mesh(handle: Mesh.Handle) void { + const mesh = meshes.get_element(handle) orelse return; + if (mesh.mem_block) |_| dkMemBlockDestroy(mesh.mem_block); + _ = meshes.remove_element(handle); +} + +pub fn update_mesh(handle: Mesh.Handle, data: []const u8) void { + var mesh = meshes.get_element(handle) orelse return; + + if (data.len == 0) { + mesh.size = 0; + meshes.update_element(handle, mesh); + return; + } + + const needed: u32 = @intCast(data.len); + if (mesh.mem_block == null or mesh.capacity < needed) { + if (mesh.mem_block) |_| dkMemBlockDestroy(mesh.mem_block); + + const alloc_size = alignForward(needed, DK_MEMBLOCK_ALIGNMENT); + var maker = memBlockMaker(alloc_size, DK_MEM_CPU_UNCACHED | DK_MEM_GPU_CACHED); + mesh.mem_block = dkMemBlockCreate(&maker); + if (mesh.mem_block == null) { + mesh.capacity = 0; + mesh.size = 0; + meshes.update_element(handle, mesh); + return; + } + mesh.capacity = dkMemBlockGetSize(mesh.mem_block); + mesh.gpu_addr = dkMemBlockGetGpuAddr(mesh.mem_block); + } + + const dst: [*]u8 = @ptrCast(dkMemBlockGetCpuAddr(mesh.mem_block) orelse return); + @memcpy(dst[0..data.len], data); + _ = dkMemBlockFlushCpuCache(mesh.mem_block, 0, needed); + + mesh.size = needed; + meshes.update_element(handle, mesh); +} + +pub fn draw_mesh(handle: Mesh.Handle, _: *const Mat4, count: usize, primitive: Mesh.Primitive) void { + if (!initialized or command_buffer == null) return; + const mesh = meshes.get_element(handle) orelse return; + if (mesh.mem_block == null or mesh.size == 0 or count == 0) return; + + const pipeline_handle = if (current_pipeline != 0) current_pipeline else mesh.pipeline; + const pl = pipelines.get_element(pipeline_handle) orelse return; + + const shaders = [_]*const DkShader{ &pl.vertex_shader, &pl.fragment_shader }; + dkCmdBufBindShaders(command_buffer, DK_STAGE_GRAPHICS_MASK, shaders[0..].ptr, shaders.len); + dkCmdBufBindVtxAttribState(command_buffer, pl.attribs[0..].ptr, pl.attrib_count); + dkCmdBufBindVtxBufferState(command_buffer, pl.vtx_buffers[0..].ptr, pl.vtx_buffer_count); + + var extents: [MAX_VERTEX_BUFFERS]DkBufExtents = undefined; + for (extents[0..pl.vtx_buffer_count]) |*extent| { + extent.* = .{ .addr = mesh.gpu_addr, .size = mesh.size }; + } + dkCmdBufBindVtxBuffers(command_buffer, 0, extents[0..].ptr, pl.vtx_buffer_count); + + dkCmdBufDraw(command_buffer, switch (primitive) { + .triangles => DK_PRIMITIVE_TRIANGLES, + .lines => DK_PRIMITIVE_LINES, + }, @intCast(count), 1, 0, 0); +} + +pub fn create_texture(_: u32, _: u32, _: []align(16) u8) anyerror!Texture.Handle { + return 0; +} + +pub fn update_texture(_: Texture.Handle, _: []align(16) u8) void {} +pub fn bind_texture(_: Texture.Handle) void {} +pub fn destroy_texture(_: Texture.Handle) void {} +pub fn force_texture_resident(_: Texture.Handle) void {} + +fn create_framebuffers() !void { + var layout_maker = DkImageLayoutMaker{ + .device = device, + .type = DK_IMAGE_TYPE_2D, + .flags = DK_IMAGE_USAGE_RENDER | DK_IMAGE_USAGE_PRESENT | DK_IMAGE_HW_COMPRESSION, + .format = DK_IMAGE_RGBA8_UNORM, + .msMode = 0, + .dimensions = .{ FB_WIDTH, FB_HEIGHT, 0 }, + .mipLevels = 1, + .pitchStride = 0, + }; + + var framebuffer_layout: DkImageLayout = undefined; + dkImageLayoutInitialize(&framebuffer_layout, &layout_maker); + + const fb_align = dkImageLayoutGetAlignment(&framebuffer_layout); + const fb_size = alignForward(@intCast(dkImageLayoutGetSize(&framebuffer_layout)), fb_align); + var mem_maker = memBlockMaker(FB_COUNT * fb_size, DK_MEM_GPU_CACHED | DK_MEM_IMAGE); + framebuffer_mem = dkMemBlockCreate(&mem_maker); + if (framebuffer_mem == null) return error.GfxInitFailed; + errdefer { + dkMemBlockDestroy(framebuffer_mem); + framebuffer_mem = null; + } + + var swapchain_images: [FB_COUNT]*const DkImage = undefined; + for (&framebuffers, 0..) |*fb, i| { + dkImageInitialize(fb, &framebuffer_layout, framebuffer_mem, @intCast(i * fb_size)); + swapchain_images[i] = fb; + } + + var swapchain_maker = DkSwapchainMaker{ + .device = device, + .nativeWindow = nwindowGetDefault(), + .pImages = swapchain_images[0..].ptr, + .numImages = FB_COUNT, + }; + swapchain = dkSwapchainCreate(&swapchain_maker); + if (swapchain == null) return error.GfxInitFailed; +} + +fn destroy_framebuffers() void { + if (swapchain) |_| { + dkSwapchainDestroy(swapchain); + swapchain = null; + } + if (framebuffer_mem) |_| { + dkMemBlockDestroy(framebuffer_mem); + framebuffer_mem = null; + } +} + +fn create_code_memory() !void { + var maker = memBlockMaker(CODE_MEM_SIZE, DK_MEM_CPU_UNCACHED | DK_MEM_GPU_CACHED | DK_MEM_CODE); + code_mem = dkMemBlockCreate(&maker); + if (code_mem == null) return error.GfxInitFailed; + code_offset = 0; +} + +fn destroy_code_memory() void { + if (code_mem) |_| { + dkMemBlockDestroy(code_mem); + code_mem = null; + } + code_offset = 0; +} + +fn create_command_buffer() !void { + var mem_maker = memBlockMaker(CMD_MEM_SIZE, DK_MEM_CPU_UNCACHED | DK_MEM_GPU_CACHED); + command_mem = dkMemBlockCreate(&mem_maker); + if (command_mem == null) return error.GfxInitFailed; + errdefer { + dkMemBlockDestroy(command_mem); + command_mem = null; + } + + var cmd_maker = DkCmdBufMaker{ + .device = device, + .userData = null, + .cbAddMem = null, + }; + command_buffer = dkCmdBufCreate(&cmd_maker); + if (command_buffer == null) return error.GfxInitFailed; +} + +fn destroy_command_buffer() void { + if (command_buffer) |_| { + dkCmdBufDestroy(command_buffer); + command_buffer = null; + } + if (command_mem) |_| { + dkMemBlockDestroy(command_mem); + command_mem = null; + } +} + +fn destroy_all_meshes() void { + for (&meshes.buffer) |*slot| { + if (slot.*) |mesh| { + if (mesh.mem_block) |_| dkMemBlockDestroy(mesh.mem_block); + slot.* = null; + } + } + meshes.clear(); +} + +fn memBlockMaker(size: u32, flags: u32) DkMemBlockMaker { + return .{ + .device = device, + .size = alignForward(size, DK_MEMBLOCK_ALIGNMENT), + .flags = flags, + .storage = null, + }; +} + +fn load_shader(shader: *DkShader, code: []const u8) !void { + if (code_mem == null) return error.GfxInitFailed; + + const offset = alignForward(code_offset, DK_SHADER_CODE_ALIGNMENT); + const end = offset + alignForward(@intCast(code.len), DK_SHADER_CODE_ALIGNMENT); + if (end > CODE_MEM_SIZE) return error.OutOfShaderMemory; + + const base: [*]u8 = @ptrCast(dkMemBlockGetCpuAddr(code_mem) orelse return error.GfxInitFailed); + @memcpy(base[offset..][0..code.len], code); + + var maker = DkShaderMaker{ + .codeMem = code_mem, + .control = null, + .codeOffset = offset, + .programId = 0, + }; + dkShaderInitialize(shader, &maker); + if (!dkShaderIsValid(shader)) return error.InvalidShader; + + code_offset = end; +} + +fn init_layout(data: *PipelineData, layout: Pipeline.VertexLayout) !void { + var max_location: u32 = 0; + var max_binding: u32 = 0; + + for (layout.attributes) |attr| { + if (attr.location >= MAX_VERTEX_ATTRIBS or attr.binding >= MAX_VERTEX_BUFFERS) { + return error.UnsupportedVertexLayout; + } + + const loc: usize = attr.location; + data.attribs[loc] = vtxAttrib(attr); + max_location = @max(max_location, attr.location + 1); + max_binding = @max(max_binding, attr.binding + 1); + } + + for (data.vtx_buffers[0..max_binding]) |*buf| { + buf.* = .{ .stride = @intCast(layout.stride), .divisor = 0 }; + } + + data.attrib_count = max_location; + data.vtx_buffer_count = @max(max_binding, 1); +} + +fn vtxAttrib(attr: Pipeline.Attribute) DkVtxAttribState { + const Format = struct { + size: u32, + kind: u32, + }; + const fmt: Format = switch (attr.format) { + .f32x2 => .{ .size = DK_ATTR_SIZE_2X32, .kind = DK_ATTR_TYPE_FLOAT }, + .f32x3 => .{ .size = DK_ATTR_SIZE_3X32, .kind = DK_ATTR_TYPE_FLOAT }, + .unorm8x2 => .{ .size = DK_ATTR_SIZE_2X8, .kind = DK_ATTR_TYPE_UNORM }, + .unorm8x4 => .{ .size = DK_ATTR_SIZE_4X8, .kind = DK_ATTR_TYPE_UNORM }, + .unorm16x2 => .{ .size = DK_ATTR_SIZE_2X16, .kind = DK_ATTR_TYPE_UNORM }, + .unorm16x3 => .{ .size = DK_ATTR_SIZE_3X16, .kind = DK_ATTR_TYPE_UNORM }, + .snorm16x2 => .{ .size = DK_ATTR_SIZE_2X16, .kind = DK_ATTR_TYPE_SNORM }, + .snorm16x3 => .{ .size = DK_ATTR_SIZE_3X16, .kind = DK_ATTR_TYPE_SNORM }, + }; + + return .{ .bits = (@as(u32, attr.binding) & 0x1F) | + ((@as(u32, @intCast(attr.offset)) & 0x3FFF) << 7) | + ((fmt.size & 0x3F) << 21) | + ((fmt.kind & 0x7) << 27) }; +} + +fn bind_fixed_state() void { + const rasterizer = DkRasterizerState{ + // rasterizer on, fill both faces, no culling, CCW front face. + .bits = 1 | (2 << 3) | (2 << 5) | (1 << 9) | (1 << 10), + }; + const color = DkColorState{ + // logicOp=Copy, alphaCompare=Always, blending disabled. + .bits = (3 << 8) | (8 << 16), + }; + const color_write = DkColorWriteState{ .masks = 0xFFFF_FFFF }; + const depth = DkDepthStencilState{ + // No depth attachment in this milestone, so keep depth/stencil off. + .bits0 = 8 << 4, + .bits1 = 0, + }; + + dkCmdBufBindRasterizerState(command_buffer, &rasterizer); + dkCmdBufBindColorState(command_buffer, &color); + dkCmdBufBindColorWriteState(command_buffer, &color_write); + dkCmdBufBindDepthStencilState(command_buffer, &depth); +} + +fn imageView(image: *const DkImage) DkImageView { + return .{ + .pImage = image, + .type = DK_IMAGE_TYPE_NONE, + .format = 0, + .swizzle = .{ DK_SWIZZLE_RED, DK_SWIZZLE_GREEN, DK_SWIZZLE_BLUE, DK_SWIZZLE_ALPHA }, + .dsSource = DK_DS_SOURCE_DEPTH, + .layerOffset = 0, + .layerCount = 0, + .mipLevelOffset = 0, + .mipLevelCount = 0, + }; +} + +fn alignForward(value: u32, alignment: u32) u32 { + return std.mem.alignForward(u32, value, alignment); +} diff --git a/src/platform/switch/switch_thread.zig b/src/platform/switch/switch_thread.zig new file mode 100644 index 0000000..b133be7 --- /dev/null +++ b/src/platform/switch/switch_thread.zig @@ -0,0 +1,25 @@ +//! Switch thread backend stub. +//! +//! Real implementation will wrap libnx's `threadCreate`/`threadStart`/ +//! `threadWaitForExit`. Until then `spawn` returns `error.Unsupported` +//! so callers fail fast instead of silently returning a bogus handle. + +const std = @import("std"); +const api = @import("../thread_api.zig"); + +pub const Handle = u32; + +pub fn spawn(cfg: api.Config, comptime func: anytype, args: anytype) !Handle { + _ = cfg; + _ = func; + _ = args; + return error.Unsupported; +} + +pub fn join(_: Handle) void {} + +pub fn set_priority(_: Handle, _: api.Priority) anyerror!void {} + +pub fn current_priority() api.Priority { + return .normal; +} diff --git a/src/platform/switch/time.zig b/src/platform/switch/time.zig new file mode 100644 index 0000000..35ea360 --- /dev/null +++ b/src/platform/switch/time.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +extern fn svcGetSystemTick() u64; +extern fn svcSleepThread(ns: i64) void; + +pub fn now(clock: std.Io.Clock) std.Io.Timestamp { + return switch (clock) { + .real, .awake, .boot => .fromNanoseconds(@intCast((@as(u128, svcGetSystemTick()) * 625) / 12)), + else => std.debug.panic("switch std.Io clock {s} is not implemented", .{@tagName(clock)}), + }; +} + +pub fn clockResolution(clock: std.Io.Clock) std.Io.Clock.ResolutionError!std.Io.Duration { + return switch (clock) { + .real, .awake, .boot => .fromNanoseconds(53), + else => error.ClockUnavailable, + }; +} + +pub fn sleep(timeout: std.Io.Timeout) std.Io.Cancelable!void { + const ns = timeoutNanoseconds(timeout); + if (ns <= 0) return; + svcSleepThread(clampNs(ns)); +} + +fn timeoutNanoseconds(timeout: std.Io.Timeout) i96 { + return switch (timeout) { + .none => 0, + .duration => |duration| duration.raw.nanoseconds, + .deadline => |deadline| deadline.raw.nanoseconds - now(deadline.clock).nanoseconds, + }; +} + +fn clampNs(ns: i96) i64 { + if (ns > std.math.maxInt(i64)) return std.math.maxInt(i64); + return @intCast(ns); +} diff --git a/src/platform/thread.zig b/src/platform/thread.zig index d043677..4475792 100644 --- a/src/platform/thread.zig +++ b/src/platform/thread.zig @@ -1,10 +1,15 @@ //! Comptime-selected thread backend. const builtin = @import("builtin"); +const options = @import("options"); const thread_api = @import("thread_api.zig"); pub const Api = if (builtin.os.tag == .psp) @import("psp/psp_thread.zig") +else if (builtin.os.tag == .@"3ds") + @import("3ds/3ds_thread.zig") +else if (options.config.platform == .nintendo_switch) + @import("switch/switch_thread.zig") else @import("std_thread.zig"); diff --git a/src/rendering/texture.zig b/src/rendering/texture.zig index d237d8c..7517c5d 100644 --- a/src/rendering/texture.zig +++ b/src/rendering/texture.zig @@ -6,6 +6,7 @@ const Platform = @import("../platform/platform.zig"); const gfx = Platform.gfx; const options = @import("options"); const psp_gfx = if (builtin.os.tag == .psp) @import("../platform/psp/psp_gfx_ge.zig") else struct {}; +const use_streaming_file_reader = options.config.platform == .nintendo_3ds or options.config.platform == .nintendo_switch; pub const Handle = u32; @@ -68,12 +69,15 @@ pub fn init_defaults(alloc: std.mem.Allocator) !void { /// `engine.dirs.data` for user-provided ones. Do not use /// `std.Io.Dir.cwd()` -- CWD is not guaranteed to be the app root /// (Finder-launched `.app` bundles give CWD = `/`). -pub fn load(io: std.Io, dir: std.Io.Dir, alloc: std.mem.Allocator, path: []const u8) !Texture { +pub fn load(io: std.Io, dir: anytype, alloc: std.mem.Allocator, path: []const u8) !Texture { var file = try dir.openFile(io, path, .{}); defer file.close(io); var temp: [4096]u8 = undefined; - var reader = file.reader(io, &temp); + var reader = if (use_streaming_file_reader) + file.readerStreaming(io, &temp) + else + file.reader(io, &temp); return load_from_reader(alloc, &reader.interface); } diff --git a/src/root.zig b/src/root.zig index 4b07796..0f81917 100644 --- a/src/root.zig +++ b/src/root.zig @@ -12,6 +12,11 @@ pub const ctx_to_self = Util.ctx_to_self; /// PSP-exclusive system utility dialogs (OSK, network configuration). /// Only available when `platform == .psp`; evaluates to `void` otherwise. pub const Psp = if (platform == .psp) @import("platform/psp/psp_dialogs.zig") else void; +pub const Cio = if (platform == .nintendo_3ds or platform == .nintendo_switch) @import("platform/c_io.zig") else void; +pub const CProcessInit = if (platform == .nintendo_3ds or platform == .nintendo_switch) @import("platform/c_process_init.zig") else void; +pub const ThreeDS = if (platform == .nintendo_3ds) struct { + pub const panic = @import("root").panic; +} else void; /// Comptime-known platform and graphics backend, resolved from build options. /// User code can switch on these for per-platform configuration without diff --git a/src/util/estimator.zig b/src/util/estimator.zig index f13d32b..fbbff92 100644 --- a/src/util/estimator.zig +++ b/src/util/estimator.zig @@ -30,12 +30,12 @@ pub const Estimator = struct { } pub fn begin(self: *Estimator, io: std.Io) void { - var clock = std.Io.Clock.real; + var clock = std.Io.Clock.boot; self.start_ns = @truncate(clock.now(io).toNanoseconds()); } pub fn end(self: *Estimator, io: std.Io) void { - var clock = std.Io.Clock.real; + var clock = std.Io.Clock.boot; const now_ns: i64 = @truncate(clock.now(io).toNanoseconds()); const elapsed = now_ns - self.start_ns; self.start_ns = 0; diff --git a/src/util/logger.zig b/src/util/logger.zig index 5fd6357..2e1b7e9 100644 --- a/src/util/logger.zig +++ b/src/util/logger.zig @@ -5,13 +5,14 @@ var log_buffer: [4096]u8 = @splat(0); var file_log: std.Io.File = undefined; var file_writer: std.Io.File.Writer = undefined; var writer: *std.Io.Writer = undefined; +var file_logging = false; /// PSP has no per-user data dir concept; the log sits at CWD (which is /// where the EBOOT lives) regardless of what `data_dir` points at. Every /// other platform routes through the engine-resolved data dir so /// Finder-launched `.app` bundles don't try to write into read-only /// bundle internals. -pub fn init(io: std.Io, data_dir: std.Io.Dir) !void { +pub fn init(io: std.Io, data_dir: anytype) !void { if (builtin.os.tag == .psp) { file_log = try std.Io.Dir.cwd().createFile(io, "ms0:/aether.log", .{ .truncate = true }); } else { @@ -19,11 +20,14 @@ pub fn init(io: std.Io, data_dir: std.Io.Dir) !void { } file_writer = file_log.writer(io, &log_buffer); writer = &file_writer.interface; + file_logging = true; } pub fn deinit(io: std.Io) void { + if (!file_logging) return; writer.flush() catch {}; file_log.close(io); + file_logging = false; } pub fn aether_log_fn( @@ -36,6 +40,6 @@ pub fn aether_log_fn( const prefix = scope_prefix ++ "[" ++ comptime level.asText() ++ "]: "; - writer.print(prefix ++ format ++ "\n", args) catch {}; + if (file_logging) writer.print(prefix ++ format ++ "\n", args) catch {}; std.debug.print(prefix ++ format ++ "\n", args); } diff --git a/src/util/util.zig b/src/util/util.zig index 700c857..183e934 100644 --- a/src/util/util.zig +++ b/src/util/util.zig @@ -21,6 +21,8 @@ comptime { pub const std_options: std.Options = .{ .log_level = if (builtin.mode == .Debug) .debug else .info, .logFn = logger.aether_log_fn, + .page_size_min = if (builtin.os.tag == .@"3ds" or builtin.os.tag == .freestanding) 4096 else null, + .page_size_max = if (builtin.os.tag == .@"3ds" or builtin.os.tag == .freestanding) 4096 else null, }; pub const engine_logger = std.log.scoped(.engine); diff --git a/test/main.zig b/test/main.zig index faf8d18..a460e9e 100644 --- a/test/main.zig +++ b/test/main.zig @@ -18,11 +18,20 @@ comptime { pub const psp_stack_size: u32 = 256 * 1024; -// PSP: override panic/IO handlers that would otherwise pull in posix symbols. -pub const panic = if (ae.platform == .psp) sdk.extra.debug.panic else std.debug.FullPanic(std.debug.defaultPanic); -pub const std_options_debug_threaded_io = if (ae.platform == .psp) null else std.Io.Threaded.global_single_threaded; -pub const std_options_debug_io = if (ae.platform == .psp) sdk.extra.Io.psp_io else std.Io.Threaded.global_single_threaded.io(); -pub const std_options_cwd = if (ae.platform == .psp) psp_cwd else null; +// PSP, 3DS, and Switch override panic/IO handlers that would otherwise +// pull in posix symbols (Io.Threaded references std.posix decls that +// don't exist for these targets). 3DS and Switch use Aether's newlib-backed +// baseline so debug prints and file IO go through the backend instead of +// dereferencing an undefined Io implementation. +const is_freestanding_console = ae.platform == .psp or ae.platform == .nintendo_3ds or ae.platform == .nintendo_switch; +// 3DS routes panics through err:f; Switch keeps `no_panic` while the debug IO +// baseline is intentionally small. +pub const panic = if (ae.platform == .psp) sdk.extra.debug.panic else if (ae.platform == .nintendo_3ds) ae.ThreeDS.panic else if (ae.platform == .nintendo_switch) std.debug.no_panic else std.debug.FullPanic(std.debug.defaultPanic); +pub const std_options_debug_threaded_io = if (is_freestanding_console) null else std.Io.Threaded.global_single_threaded; +pub const std_options_debug_io: std.Io = + if (ae.platform == .psp) sdk.extra.Io.psp_io else if (ae.platform == .nintendo_3ds or ae.platform == .nintendo_switch) ae.Cio.io() else std.Io.Threaded.global_single_threaded.io(); +pub const std_options_cwd = + if (ae.platform == .psp) psp_cwd else if (ae.platform == .nintendo_3ds or ae.platform == .nintendo_switch) ae.Cio.cwd else null; fn psp_cwd() std.Io.Dir { return .{ .handle = -1 }; } @@ -50,19 +59,22 @@ const MyState = struct { mesh: MyMesh, transform: Rendering.Transform, texture: Rendering.Texture, - music_data: []u8, + music_data: []const u8, music_reader: std.Io.Reader, - grass_data: []u8, + grass_data: []const u8, grass_readers: [MAX_GRASS_VOICES]std.Io.Reader, grass_tick: u32, grass_spawn: u32, fn load_wav(engine: *ae.Engine, path: []const u8) ![]u8 { - var file = try std.Io.Dir.cwd().openFile(engine.io, path, .{}); + var file = try engine.dirs.resources.openFile(engine.io, path, .{}); defer file.close(engine.io); var tmp: [4096]u8 = undefined; - var rdr = file.reader(engine.io, &tmp); + var rdr = if (ae.platform == .nintendo_3ds or ae.platform == .nintendo_switch) + file.readerStreaming(engine.io, &tmp) + else + file.reader(engine.io, &tmp); var riff_hdr: [8]u8 = undefined; try rdr.interface.readSliceAll(&riff_hdr); @@ -86,6 +98,7 @@ const MyState = struct { self.transform = Rendering.Transform.new(); self.texture = try Rendering.Texture.load(engine.io, engine.dirs.resources, render, "test.png"); + try self.mesh.append(render, &.{ Vertex{ .pos = .{ -16383, -16383, 0 }, .color = 0xFF0000FF, .uv = .{ 0, 32767 } }, Vertex{ .pos = .{ 16383, -16383, 0 }, .color = 0xFF00FF00, .uv = .{ 32767, 32767 } }, @@ -93,6 +106,15 @@ const MyState = struct { }); self.mesh.update(); + self.music_data = &.{}; + self.music_reader = .fixed(&.{}); + self.grass_data = &.{}; + self.grass_readers = @splat(.fixed(&.{})); + self.grass_tick = 0; + self.grass_spawn = 0; + + if (!Audio.enabled) return; + // -- background music -- self.music_data = try load_wav(engine, "calm1.wav"); self.music_reader = .fixed(self.music_data); @@ -101,9 +123,6 @@ const MyState = struct { // -- spatial SFX data -- self.grass_data = try load_wav(engine, "grass1.wav"); - self.grass_readers = @splat(.fixed(&.{})); - self.grass_tick = 0; - self.grass_spawn = 0; // Listener at origin, facing -Z Audio.set_listener(Vec3.zero(), Vec3.new(0, 0, -1), Vec3.new(0, 1, 0)); @@ -120,6 +139,8 @@ const MyState = struct { } fn tick(ctx: *anyopaque, _: *ae.Engine) anyerror!void { + if (!Audio.enabled) return; + var self = ae.ctx_to_self(MyState, ctx); self.grass_tick += 1;