diff --git a/CMakeLists.txt b/CMakeLists.txt index df221db..3a38d31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,10 +22,10 @@ endif() if(NOT DEFINED SNESRECOMP_ROOT) set(SNESRECOMP_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/snesrecomp) endif() -if(APPLE AND NOT DEFINED SNESRECOMP_SDL_BACKEND) - # The DKC1 macOS frontend currently uses SDL2's queued-audio API. Keep - # this project on the engine's explicit compatibility backend until its - # SDL3 audio-stream equivalent is implemented and validated. +if(UNIX AND NOT DEFINED SNESRECOMP_SDL_BACKEND) + # The DKC1 SDL frontend currently uses SDL2's queued-audio API. Keep this + # project on the engine's explicit compatibility backend until its SDL3 + # audio-stream equivalent is implemented and validated. set(SNESRECOMP_SDL_BACKEND "SDL2" CACHE STRING "Desktop SDL backend (SDL3 or SDL2)") endif() @@ -85,6 +85,9 @@ target_compile_definitions(dkc1_snesrecomp_headless PRIVATE SNESRECOMP_REVERSE_DEBUG=0 SNESRECOMP_EXTERNAL_RAM_ROUTINE_GUARDS=1 SYSTEM_VOLUME_MIXER_AVAILABLE=0) +if(UNIX) + target_compile_definitions(dkc1_snesrecomp_headless PRIVATE _GNU_SOURCE) +endif() if(MSVC) # Do not use /MP here. This generated-source workload has reproduced an # MSVC race where cl.exe returns before every bank object is available to @@ -147,3 +150,43 @@ if(APPLE) MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO") endif() + +if(UNIX AND NOT APPLE) + if(NOT SNESRECOMP_SDL_BACKEND STREQUAL "SDL2") + message(FATAL_ERROR + "The DKC1 Linux frontend currently requires " + "-DSNESRECOMP_SDL_BACKEND=SDL2") + endif() + + add_executable(dkc1_snesrecomp_sdl + ${DKC1_ENGINE_SOURCES} + ${DKC1_SNESRECOMP_GEN_SOURCES} + ${DKC1_RUNNER_SOURCES} + runner/headless_host.c + runner/dkc1_haptics.c + runner/dkc1_msu1.c + runner/sdl_host.c + runner/nonapple_sdl_platform.c) + target_include_directories(dkc1_snesrecomp_sdl PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/runner + ${CMAKE_CURRENT_SOURCE_DIR}/recomp + ${SNESRECOMP_RUNNER_INCLUDE_DIRS} + ${SNESRECOMP_ROOT}/runner/src/desktop) + target_compile_definitions(dkc1_snesrecomp_sdl PRIVATE + _GNU_SOURCE + SNESRECOMP_TRACE=0 + SNESRECOMP_REVERSE_DEBUG=0 + SNESRECOMP_EXTERNAL_RAM_ROUTINE_GUARDS=1 + SYSTEM_VOLUME_MIXER_AVAILABLE=0 + DKC1_BUILD_COMMIT="${DKC1_BUILD_COMMIT}" + DKC1_BUILD_CONFIG="linux") + target_compile_options(dkc1_snesrecomp_sdl PRIVATE + -Wno-unused-function) + target_link_libraries(dkc1_snesrecomp_sdl PRIVATE + ${SNESRECOMP_RUNNER_LIBRARIES}) + find_package(Threads REQUIRED) + target_link_libraries(dkc1_snesrecomp_sdl PRIVATE Threads::Threads) + snesrecomp_target_sdl(dkc1_snesrecomp_sdl) + set_target_properties(dkc1_snesrecomp_sdl PROPERTIES + OUTPUT_NAME "DKC1Recomp") +endif() diff --git a/README.md b/README.md index d9f24b4..676444d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ framework — following the working [`DKC2Recomp`](https://github.com/Nicktendon project (same Rare engine family) as the template. **Status: playable bring-up.** The project has 100% statically generated game -code, a headless validation host, playable Windows and native macOS hosts, +code, a headless validation host, playable Windows, Linux SDL2, and native macOS hosts, continuous audio, and an opt-in 342x224 widescreen presentation path. See `docs/BRINGUP.md` for the chronological bring-up record and `docs/WIDESCREEN.md` for the widescreen architecture, ported SuperZSNES findings, validation, and limitations. @@ -100,6 +100,33 @@ wall, black past the wall, or the earlier inward clamp. The headless validator a count and supports deterministic input playback and private frame/state captures; its environment variables are documented in `docs/BRINGUP.md`. +### Linux + +Install a C compiler, CMake, Ninja, Python 3, Cargo, and the SDL2 development +package. On Debian/Ubuntu: + +```sh +sudo apt install build-essential cmake ninja-build python3 cargo libsdl2-dev +``` + +Then build both the playable SDL2 host and the headless validator. The first +build uses the verified ROM to generate private ignored C sources; the ROM is +never copied into the repository or executable. + +```sh +git submodule update --init --recursive +./build_linux.sh "/path/to/Donkey Kong Country (USA).sfc" +./build/linux/DKC1Recomp +``` + +The Linux frontend accepts the ROM path as its first argument or through +`DKC1_ROM`. It uses SDL2 for the window, keyboard/controller input, queued +audio, fullscreen, and rendering. Native macOS menus and file pickers are not +present, so Linux-only options use the existing environment variables. Keys +match the other visible hosts: arrows, Z/X/S/A, Q/W, Return, Right Shift, +F7/F8 pause and step, F9 export, F11/F12 quick save/load, Alt-Return +fullscreen, and Escape to leave fullscreen or quit. + ### macOS Install CMake, Ninja, and SDL2, then provide the same verified ROM the first diff --git a/build_linux.sh b/build_linux.sh new file mode 100755 index 0000000..d1d9db2 --- /dev/null +++ b/build_linux.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "error: build_linux.sh must run on Linux" >&2 + exit 2 +fi + +repo_dir="$(cd "$(dirname "$0")" && pwd)" +build_dir="$repo_dir/build/linux" +rom_path="${1:-${DKC1_ROM:-}}" + +for tool in cc cmake ninja sdl2-config python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: missing required tool: $tool" >&2 + echo "Install a C compiler, CMake, Ninja, Python 3, and SDL2 development files." >&2 + exit 2 + fi +done + +if [[ ! -f "$repo_dir/snesrecomp/runner/runner.cmake" ]]; then + echo "error: snesrecomp is not initialized" >&2 + echo "run: git submodule update --init --recursive" >&2 + exit 2 +fi + +if ! compgen -G "$repo_dir/generated/snesrecomp/*.c" >/dev/null; then + if [[ -z "$rom_path" ]]; then + echo "error: private generated sources are missing" >&2 + echo "usage: ./build_linux.sh '/path/to/Donkey Kong Country (USA).sfc'" >&2 + exit 2 + fi + python3 "$repo_dir/scripts/generate_snesrecomp.py" --rom "$rom_path" +fi + +cmake -S "$repo_dir" -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DSNESRECOMP_SDL_BACKEND=SDL2 \ + -DCMAKE_PREFIX_PATH="$(sdl2-config --prefix)" +cmake --build "$build_dir" \ + --target dkc1_snesrecomp_sdl dkc1_snesrecomp_headless --parallel + +echo "LINUX_BUILD_OK" +echo "$build_dir/DKC1Recomp" diff --git a/runner/nonapple_sdl_platform.c b/runner/nonapple_sdl_platform.c new file mode 100644 index 0000000..9409ab3 --- /dev/null +++ b/runner/nonapple_sdl_platform.c @@ -0,0 +1,108 @@ +#include "macos_file_picker.h" +#include "macos_metal_presenter.h" + +#include + +/* The SDL frontend is shared with macOS, where these entry points provide + * native menus, file pickers, preferences, and display-linked Metal output. + * Linux deliberately keeps the command-line/environment configuration and + * ordinary SDL renderer path, so its platform adapters are inert. */ + +char *Dkc1MacChooseRom(void) { return NULL; } +char *Dkc1MacChooseBabyKongRom(void) { return NULL; } +char *Dkc1MacSavedBabyKongRom(void) { return NULL; } +void Dkc1MacSetBabyKongRom(const char *path) { (void)path; } +int Dkc1MacSavedBabyKongEnabled(void) { return 0; } +void Dkc1MacSetBabyKongEnabled(int enabled) { (void)enabled; } + +char *Dkc1MacChooseMsu1(void) { return NULL; } +char *Dkc1MacSavedMsu1(void) { return NULL; } +void Dkc1MacClearMsu1(void) {} + +Dkc1MacFullscreenScaling Dkc1MacSavedFullscreenScaling(void) { + return kDkc1MacFullscreenSharpBilinear; +} + +void Dkc1MacSetFullscreenScaling(Dkc1MacFullscreenScaling scaling) { + (void)scaling; +} + +Dkc1EdgePolicy Dkc1MacSavedWidescreenEdge(void) { + return kDkc1EdgeGlide; +} + +void Dkc1MacSetWidescreenEdge(Dkc1EdgePolicy policy) { (void)policy; } + +void Dkc1MacInstallMenu(void) {} + +void Dkc1MacUpdateMenuState(int paused, int fullscreen, + Dkc1MacFullscreenScaling fullscreen_scaling, + Dkc1VideoAspect aspect, Dkc1EdgePolicy edge, + unsigned char layer_mask, int provenance, + int replacement_music, int baby_kong_enabled, + int baby_kong_ready) { + (void)paused; + (void)fullscreen; + (void)fullscreen_scaling; + (void)aspect; + (void)edge; + (void)layer_mask; + (void)provenance; + (void)replacement_music; + (void)baby_kong_enabled; + (void)baby_kong_ready; +} + +int Dkc1MacDisplayLinkStart(void *native_window, double preferred_fps) { + (void)native_window; + (void)preferred_fps; + return 0; +} + +int Dkc1MacDisplayLinkWait(unsigned long long after_callback_number, + double timeout_seconds, double *timestamp, + double *target_timestamp, double *duration, + unsigned long long *callback_number) { + (void)after_callback_number; + (void)timeout_seconds; + (void)timestamp; + (void)target_timestamp; + (void)duration; + (void)callback_number; + return 0; +} + +void Dkc1MacDisplayLinkStop(void) {} + +int Dkc1MacMetalPresenterStart(void *native_window, double preferred_hz, + Dkc1MacFullscreenScaling scaling, + int fullscreen) { + (void)native_window; + (void)preferred_hz; + (void)scaling; + (void)fullscreen; + return 0; +} + +void Dkc1MacMetalPresenterQueueFrame( + const uint32_t *pixels, int width, int height, int presentation_width, + const Dkc1MacPresentationFrameInfo *info) { + (void)pixels; + (void)width; + (void)height; + (void)presentation_width; + (void)info; +} + +void Dkc1MacMetalPresenterSetGeometry(int presentation_width, + int fullscreen) { + (void)presentation_width; + (void)fullscreen; +} + +void Dkc1MacMetalPresenterSetScaling(Dkc1MacFullscreenScaling scaling) { + (void)scaling; +} + +void Dkc1MacMetalPresenterSetActive(int active) { (void)active; } +void Dkc1MacMetalPresenterStop(void) {} diff --git a/runner/sdl_host.c b/runner/sdl_host.c index 3a3b4d4..2ee58cd 100644 --- a/runner/sdl_host.c +++ b/runner/sdl_host.c @@ -1,8 +1,9 @@ -/* Native macOS SDL2 frontend for DKC1Recomp. +/* Native SDL2 frontend for DKC1Recomp. * * The recompiled cartridge/runtime stays identical to the Win32 and headless * hosts. This file owns only host presentation, input, queued audio, timing, - * and user-facing save/repro shortcuts. + * and user-facing save/repro shortcuts. macOS adds native menus, file pickers, + * and Metal scanout; other Unix hosts retain the portable SDL path. */ #include "dkc1_blank_scan.h" #include "dkc1_baby_kong.h" @@ -25,12 +26,16 @@ #include "snes/snes.h" #include +#ifdef __APPLE__ #include +#endif #include #include +#ifdef __APPLE__ #include #include +#endif #include #include #include @@ -43,7 +48,15 @@ #define DKC1_BUILD_COMMIT "untracked" #endif #ifndef DKC1_BUILD_CONFIG -#define DKC1_BUILD_CONFIG "macos-dev" +#define DKC1_BUILD_CONFIG "sdl-dev" +#endif + +#ifdef __APPLE__ +#define DKC1_HOST_PLATFORM "macos" +#define DKC1_HOST_CLOCK "mach_absolute_time" +#else +#define DKC1_HOST_PLATFORM "linux" +#define DKC1_HOST_CLOCK "SDL_GetPerformanceCounter" #endif enum { @@ -193,7 +206,11 @@ static int EnvironmentEnabled(const char *name) { } static double FramePacerNow(void) { +#ifdef __APPLE__ return (double)mach_absolute_time(); +#else + return (double)SDL_GetPerformanceCounter(); +#endif } static void FramePacerCpuRelax(void) { @@ -215,13 +232,26 @@ static void FramePacerCpuRelax(void) { static void FramePacerWaitUntil(double deadline, double frequency) { const double spin_ticks = frequency * kMacFinalSpinSeconds; double now = FramePacerNow(); +#ifdef __APPLE__ if (deadline - now > spin_ticks) (void)mach_wait_until((uint64_t)(deadline - spin_ticks)); +#else + while (deadline - now > spin_ticks) { + const double coarse_ticks = deadline - now - spin_ticks; + const Uint32 coarse_ms = + (Uint32)(coarse_ticks * 1000.0 / frequency); + if (!coarse_ms) + break; + SDL_Delay(coarse_ms); + now = FramePacerNow(); + } +#endif while (FramePacerNow() < deadline) FramePacerCpuRelax(); } static void FramePacerInit(Dkc1FramePacer *pacer) { +#ifdef __APPLE__ mach_timebase_info_data_t timebase = {0, 0}; mach_timebase_info(&timebase); if (!timebase.numer || !timebase.denom) { @@ -231,6 +261,12 @@ static void FramePacerInit(Dkc1FramePacer *pacer) { memset(pacer, 0, sizeof *pacer); pacer->frequency = 1000000000.0 * (double)timebase.denom / (double)timebase.numer; +#else + memset(pacer, 0, sizeof *pacer); + pacer->frequency = (double)SDL_GetPerformanceFrequency(); + if (pacer->frequency <= 0.0) + pacer->frequency = 1000.0; +#endif pacer->ticks_per_frame = pacer->frequency / kHostPresentationFramesPerSecond; pacer->next_deadline = FramePacerNow() + pacer->ticks_per_frame; @@ -483,13 +519,13 @@ static int PacingLogWriteHeader(Dkc1PacingLog *log, ? 1.0 / display->callback_interval : kHostPresentationFramesPerSecond; fprintf(log->stream, - "{\"schema\":\"dkc1.pacing.v3\",\"platform\":\"macos\"," + "{\"schema\":\"dkc1.pacing.v3\",\"platform\":\"%s\"," "\"refresh_hz\":%.9f,\"display_hz\":%.9f," "\"clock_source\":\"%s\",\"submit_lead_ms\":%.4f," "\"audio_preroll\":%u,\"audio_ring_start_frames\":%u," "\"test_stall_frame\":%ld,\"test_stall_ms\":%u}\n", - refresh_hz, refresh_hz, - s_display_link_active ? "CADisplayLink" : "mach_absolute_time", + DKC1_HOST_PLATFORM, refresh_hz, refresh_hz, + s_display_link_active ? "CADisplayLink" : DKC1_HOST_CLOCK, kMacSubmitLeadSeconds * 1000.0, s_audio_preroll_blocks, s_audio_ring_start_threshold, log->test_stall_frame, log->test_stall_ms); @@ -779,8 +815,13 @@ static int ResolveRomPath(int argc, char **argv, char output[PATH_MAX]) { const char *candidate = argc > 1 ? argv[1] : getenv("DKC1_ROM"); char *picked = NULL; if (!candidate || !*candidate) { +#ifdef __APPLE__ picked = Dkc1MacChooseRom(); candidate = picked; +#else + fprintf(stderr, "usage: DKC1Recomp \n"); + return -1; +#endif } if (!candidate) { output[0] = 0; @@ -915,6 +956,7 @@ static bool InitVideo(void) { } static void InitDisplayLink(void) { +#ifdef __APPLE__ SDL_SysWMinfo window_info; SDL_VERSION(&window_info.version); const int have_native_window = @@ -968,6 +1010,13 @@ static void InitDisplayLink(void) { fprintf(stderr, "[display-authority] display_link=%d renderer_vsync=%d\n", s_display_link_active, s_renderer_vsync); } +#else + if (EnvironmentEnabled("DKC1_FPS_STATS")) { + fprintf(stderr, + "[display-authority] fixed_clock=1 renderer_vsync=%d\n", + s_renderer_vsync); + } +#endif } static void PreparePresentation(void) { @@ -1698,7 +1747,9 @@ static void Cleanup(uint8_t *rom) { int main(int argc, char **argv) { SDL_SetMainReady(); +#ifdef __APPLE__ (void)pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); +#endif /* A native macOS fullscreen Space constrains SDL to the panel's inset safe * area (3949x2464 on the target 4112x2658 MacBook display). Set this before * the Cocoa video backend initializes so FULLSCREEN_DESKTOP uses the full @@ -1710,9 +1761,10 @@ int main(int argc, char **argv) { } char rom_path[PATH_MAX] = {0}; - if (!ResolveRomPath(argc, argv, rom_path)) { + const int rom_path_result = ResolveRomPath(argc, argv, rom_path); + if (rom_path_result <= 0) { SDL_Quit(); - return 0; + return rom_path_result < 0 ? 2 : 0; } size_t rom_size = 0; diff --git a/tests/test_linux_sdl_port.py b/tests/test_linux_sdl_port.py new file mode 100644 index 0000000..6813c67 --- /dev/null +++ b/tests/test_linux_sdl_port.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class LinuxSdlPortTests(unittest.TestCase): + def test_cmake_exposes_linux_sdl_target(self) -> None: + cmake = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8") + self.assertIn("if(UNIX AND NOT APPLE)", cmake) + self.assertIn("add_executable(dkc1_snesrecomp_sdl", cmake) + self.assertIn("runner/nonapple_sdl_platform.c", cmake) + self.assertIn("snesrecomp_target_sdl(dkc1_snesrecomp_sdl)", cmake) + self.assertIn('OUTPUT_NAME "DKC1Recomp"', cmake) + + def test_sdl_host_has_a_portable_monotonic_clock(self) -> None: + host = (ROOT / "runner" / "sdl_host.c").read_text(encoding="utf-8") + self.assertIn("SDL_GetPerformanceCounter()", host) + self.assertIn("SDL_GetPerformanceFrequency()", host) + self.assertIn('#define DKC1_HOST_PLATFORM "linux"', host) + self.assertIn("#ifdef __APPLE__\n#include ", host) + self.assertIn('usage: DKC1Recomp ', host) + + def test_linux_build_script_builds_playable_and_headless_targets(self) -> None: + script = (ROOT / "build_linux.sh").read_text(encoding="utf-8") + self.assertIn("git submodule update --init --recursive", script) + self.assertIn("-DSNESRECOMP_SDL_BACKEND=SDL2", script) + self.assertIn("dkc1_snesrecomp_sdl dkc1_snesrecomp_headless", script) + self.assertIn('echo "$build_dir/DKC1Recomp"', script) + + +if __name__ == "__main__": + unittest.main()