diff --git a/3rdParty/Lua/CMakeLists.txt b/3rdParty/Lua/CMakeLists.txt index 04147a54d..7b9b9e4d0 100644 --- a/3rdParty/Lua/CMakeLists.txt +++ b/3rdParty/Lua/CMakeLists.txt @@ -30,7 +30,7 @@ elseif(TARGET_PLATFORM STREQUAL "dos") target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89) elseif(ANDROID AND ("${ANDROID_ABI}" STREQUAL "armeabi-v7a" OR "${ANDROID_ABI}" STREQUAL "x86")) target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89) -elseif(NINTENDO_3DS OR VITA OR NINTENDO_SWITCH OR NXDK) +elseif(NINTENDO_3DS OR VITA OR NINTENDO_SWITCH OR NXDK OR DREAMCAST) target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89) elseif(IOS) target_compile_definitions(lua_static PUBLIC -DLUA_USE_IOS) diff --git a/3rdParty/libfmt/CMakeLists.txt b/3rdParty/libfmt/CMakeLists.txt index 2f5be03b5..a78d49d26 100644 --- a/3rdParty/libfmt/CMakeLists.txt +++ b/3rdParty/libfmt/CMakeLists.txt @@ -15,10 +15,23 @@ else() set(BUILD_SHARED_LIBS ON) endif() include(FetchContent) -FetchContent_Declare_ExcludeFromAll(libfmt - URL https://github.com/fmtlib/fmt/releases/download/12.0.0/fmt-12.0.0.zip - URL_HASH SHA256=1c32293203449792bf8e94c7f6699c643887e826f2d66a80869b4f279fb07d25 -) +if(DREAMCAST) + set(DREAMCAST_FMT_PATCH "${CMAKE_CURRENT_LIST_DIR}/fmt-12.0.0-sh4-long-double.patch") + if(NOT EXISTS "${DREAMCAST_FMT_PATCH}") + message(FATAL_ERROR "Dreamcast fmt patch not found: ${DREAMCAST_FMT_PATCH}") + endif() + find_program(DREAMCAST_PATCH_EXECUTABLE patch REQUIRED) + FetchContent_Declare_ExcludeFromAll(libfmt + URL https://github.com/fmtlib/fmt/releases/download/12.0.0/fmt-12.0.0.zip + URL_HASH SHA256=1c32293203449792bf8e94c7f6699c643887e826f2d66a80869b4f279fb07d25 + PATCH_COMMAND "${DREAMCAST_PATCH_EXECUTABLE}" -p1 -N -i "${DREAMCAST_FMT_PATCH}" + ) +else() + FetchContent_Declare_ExcludeFromAll(libfmt + URL https://github.com/fmtlib/fmt/releases/download/12.0.0/fmt-12.0.0.zip + URL_HASH SHA256=1c32293203449792bf8e94c7f6699c643887e826f2d66a80869b4f279fb07d25 + ) +endif() FetchContent_MakeAvailable_ExcludeFromAll(libfmt) # We do not use locale-specific features of libfmt and disabling them reduces the size. diff --git a/3rdParty/libfmt/fmt-12.0.0-sh4-long-double.patch b/3rdParty/libfmt/fmt-12.0.0-sh4-long-double.patch new file mode 100644 index 000000000..2d984f624 --- /dev/null +++ b/3rdParty/libfmt/fmt-12.0.0-sh4-long-double.patch @@ -0,0 +1,31 @@ +From: KOS Dreamcast Port +Subject: Fix libfmt 12.0.0 long double support on SH4 + +On SH4 with -m4-single, long double is IEEE 754 binary64 (same as double) +but std::numeric_limits::digits == 53, which doesn't match +fmt's expected values of 64 or 113 for extended precision types. + +This causes the float_info template to be incomplete, +resulting in compilation errors: + error: invalid use of incomplete type 'struct float_info' + +This patch adds an explicit specialization for long double when it has +binary64 characteristics (53 mantissa bits, 8 bytes). + +--- a/include/fmt/format.h ++++ b/include/fmt/format.h +@@ -1462,6 +1462,14 @@ template <> struct float_info { + static const int shorter_interval_tie_lower_threshold = -77; + static const int shorter_interval_tie_upper_threshold = -77; + }; ++ ++// SH4/Dreamcast fix: long double is IEEE 754 binary64 on this platform. ++// Provide explicit specialization when long double has 53 mantissa bits. ++#if defined(__LDBL_MANT_DIG__) && (__LDBL_MANT_DIG__ == 53) && \ ++ defined(__SIZEOF_LONG_DOUBLE__) && (__SIZEOF_LONG_DOUBLE__ == 8) ++template <> ++struct float_info : float_info {}; ++#endif + + // An 80- or 128-bit floating point number. + template diff --git a/CMake/Platforms.cmake b/CMake/Platforms.cmake index 44f6826e8..45ea6f13a 100644 --- a/CMake/Platforms.cmake +++ b/CMake/Platforms.cmake @@ -51,6 +51,10 @@ if(NINTENDO_3DS) include(platforms/n3ds) endif() +if(DREAMCAST) + include(platforms/dreamcast) +endif() + if(VITA) include("$ENV{VITASDK}/share/vita.cmake" REQUIRED) include(platforms/vita) diff --git a/CMake/functions/devilutionx_library.cmake b/CMake/functions/devilutionx_library.cmake index f40fa6a09..de221abbe 100644 --- a/CMake/functions/devilutionx_library.cmake +++ b/CMake/functions/devilutionx_library.cmake @@ -46,8 +46,9 @@ function(add_devilutionx_library NAME) target_compile_options(${NAME} PUBLIC -Wall -Wextra -Wno-unused-parameter) endif() - if(NOT WIN32 AND NOT APPLE AND NOT ${CMAKE_SYSTEM_NAME} STREQUAL FreeBSD) + if(NOT WIN32 AND NOT APPLE AND NOT ${CMAKE_SYSTEM_NAME} STREQUAL FreeBSD AND NOT DREAMCAST) # Enable POSIX extensions such as `readlink` and `ftruncate`. + # Excluded for Dreamcast/KOS which doesn't have full POSIX support. add_definitions(-D_POSIX_C_SOURCE=200809L) endif() diff --git a/CMake/platforms/dreamcast.cmake b/CMake/platforms/dreamcast.cmake new file mode 100644 index 000000000..74ea3c64c --- /dev/null +++ b/CMake/platforms/dreamcast.cmake @@ -0,0 +1,79 @@ +# Not automatically defined by sh-elf-gcc +add_compile_definitions(__DREAMCAST__) + +set(CMAKE_BUILD_TYPE MinSizeRel CACHE STRING "" FORCE) + +set(ASAN OFF) +set(UBSAN OFF) +set(BUILD_TESTING OFF) +set(NONET ON) +set(USE_SDL1 ON) +set(NOEXIT ON) +set(PREFILL_PLAYER_NAME ON) +set(DISABLE_DEMOMODE ON) +set(DEVILUTIONX_SYSTEM_LIBSODIUM OFF) +set(DEVILUTIONX_SYSTEM_SDL_IMAGE OFF) +set(UNPACKED_MPQS OFF) + +# CD reads via MPQ are too slow for real-time audio +set(DISABLE_STREAMING_SOUNDS ON) +set(DEFAULT_AUDIO_BUFFER_SIZE 2048) +set(DEFAULT_AUDIO_SAMPLE_RATE 22050) +set(DEFAULT_AUDIO_RESAMPLING_QUALITY 0) + +set(DEFAULT_PER_PIXEL_LIGHTING 0) +set(DEVILUTIONX_PALETTE_TRANSPARENCY_BLACK_16_LUT ON CACHE BOOL "" FORCE) + +# GPF SDL supports 8bpp with hardware palette - lets SDL handle the +# palette-to-RGB conversion internally via PVR DMA, avoiding the +# manual 8bpp->16bpp conversion in dc_video.cpp entirely. +set(SDL1_VIDEO_MODE_BPP 8) +set(SDL1_VIDEO_MODE_FLAGS SDL_DOUBLEBUF|SDL_HWSURFACE) + +set(DEFAULT_WIDTH 640) +set(DEFAULT_HEIGHT 480) + +# VMU saves use flat files with zlib compression +set(UNPACKED_SAVES ON) + +set(DEVILUTIONX_GAMEPAD_TYPE Generic) + +# GPF SDL button IDs (from SDL_dreamcast.h SDL_DC_button enum) +set(JOY_BUTTON_A 2) +set(JOY_BUTTON_B 1) +set(JOY_BUTTON_X 5) +set(JOY_BUTTON_Y 6) +set(JOY_BUTTON_START 3) + +set(JOY_HAT_DPAD_UP_HAT 0) +set(JOY_HAT_DPAD_DOWN_HAT 0) +set(JOY_HAT_DPAD_LEFT_HAT 0) +set(JOY_HAT_DPAD_RIGHT_HAT 0) +set(JOY_HAT_DPAD_UP 1) +set(JOY_HAT_DPAD_DOWN 4) +set(JOY_HAT_DPAD_LEFT 8) +set(JOY_HAT_DPAD_RIGHT 2) + +set(JOY_AXIS_LEFTX 0) +set(JOY_AXIS_LEFTY 1) + +list(APPEND DEVILUTIONX_PLATFORM_SUBDIRECTORIES platform/dreamcast) +list(APPEND DEVILUTIONX_PLATFORM_LINK_LIBRARIES libdevilutionx_dreamcast) + +# FindZLIB may not create this target during cross-compilation. +if(NOT TARGET ZLIB::ZLIB) + add_library(ZLIB::ZLIB STATIC IMPORTED) + set_target_properties(ZLIB::ZLIB PROPERTIES + IMPORTED_LOCATION "${ZLIB_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${ZLIB_INCLUDE_DIR}" + ) +endif() + +# FindBZip2 may not create this target during cross-compilation +if(NOT TARGET BZip2::BZip2) + add_library(BZip2::BZip2 STATIC IMPORTED) + set_target_properties(BZip2::BZip2 PROPERTIES + IMPORTED_LOCATION "${BZIP2_LIBRARIES}" + INTERFACE_INCLUDE_DIRECTORIES "${BZIP2_INCLUDE_DIR}" + ) +endif() diff --git a/CMake/platforms/dreamcast.toolchain.cmake b/CMake/platforms/dreamcast.toolchain.cmake new file mode 100644 index 000000000..a08c024e0 --- /dev/null +++ b/CMake/platforms/dreamcast.toolchain.cmake @@ -0,0 +1,67 @@ +# DevilutionX Dreamcast Toolchain +# We extend the official KOS toolchain to add project-specific settings. + +if(NOT DEFINED ENV{KOS_CMAKE_TOOLCHAIN}) + message(FATAL_ERROR "KOS_CMAKE_TOOLCHAIN not defined. Please source environ.sh.") +endif() + +# 1. Load the official KOS toolchain +# This sets up compilers, flags (-m4-single), and system paths correctly. +include("$ENV{KOS_CMAKE_TOOLCHAIN}") + +# 2. Set DevilutionX specific platform flag +# This triggers loading CMake/platforms/dreamcast.cmake +set(DREAMCAST ON) + +# 3. Add our include paths +# KOS ports often hide headers in subdirectories +# GPF SDL installs to KOS_BASE/addons/include/dreamcast/SDL/ +list(APPEND CMAKE_INCLUDE_PATH + "$ENV{KOS_PORTS}/include/zlib" + "$ENV{KOS_PORTS}/include/bzlib" + "$ENV{KOS_BASE}/addons/include/dreamcast/SDL" +) + +# 4. Force libraries (The "Nuclear Option" for sub-projects) +# Standard find modules struggle with KOS layout, so we force them. +set(ZLIB_INCLUDE_DIR "$ENV{KOS_PORTS}/include/zlib" CACHE PATH "ZLIB Include Dir" FORCE) +set(ZLIB_LIBRARY "$ENV{KOS_PORTS}/lib/libz.a" CACHE FILEPATH "ZLIB Library" FORCE) +set(BZIP2_INCLUDE_DIR "$ENV{KOS_PORTS}/include/bzlib" CACHE PATH "BZip2 Include Dir" FORCE) +set(BZIP2_LIBRARIES "$ENV{KOS_PORTS}/lib/libbz2.a" CACHE FILEPATH "BZip2 Library" FORCE) + +# 5. Force SDL1 - GPF SDL (SDL-dreamhal--GLDC) for DMA video and hardware palette +# GPF SDL installs via its Makefile.dc to KOS_BASE/addons/{lib,include}/dreamcast/ +set(SDL_INCLUDE_DIR "$ENV{KOS_BASE}/addons/include/dreamcast/SDL" CACHE PATH "SDL Include Dir" FORCE) +set(SDL_LIBRARY "$ENV{KOS_BASE}/addons/lib/dreamcast/libSDL.a" CACHE FILEPATH "SDL Library" FORCE) + +# 6. Fix libfmt and magic_enum compilation +# Disable long double support because sh4-gcc's long double (64-bit) confuses libfmt +# Disable magic_enum asserts because GCC 15's stricter parsing breaks them in comma expressions +add_compile_definitions(FMT_USE_LONG_DOUBLE=0 FMT_USE_FLOAT128=0 MAGIC_ENUM_NO_ASSERT) + +# 7. KOS doesn't have pthreads as a separate library - threading is built into libkallisti +set(CMAKE_THREAD_LIBS_INIT "" CACHE STRING "" FORCE) +set(CMAKE_HAVE_THREADS_LIBRARY 1) +set(Threads_FOUND TRUE) + +# 8. Remove host system library paths that sneak in +set(CMAKE_EXE_LINKER_FLAGS "" CACHE STRING "" FORCE) +set(CMAKE_SHARED_LINKER_FLAGS "" CACHE STRING "" FORCE) + +# 8b. Ignore host system paths (homebrew, etc.) to prevent finding wrong libraries +set(CMAKE_IGNORE_PATH "/opt/homebrew;/opt/homebrew/include;/opt/homebrew/lib;/usr/local;/usr/local/include;/usr/local/lib" CACHE STRING "" FORCE) + +# 8c. Force using bundled magic_enum (not system/homebrew version) +set(DEVILUTIONX_SYSTEM_MAGIC_ENUM OFF CACHE BOOL "" FORCE) + +# 9. Override Link Rule to use Grouping +# KOS toolchain already adds -T, -nodefaultlibs, and -L paths via +# We just need to add --start-group/--end-group for circular dependency resolution +# NOTE: kos_add_romdisk() in CMakeLists.txt handles -lromdiskbase with --whole-archive +set(CMAKE_CXX_LINK_EXECUTABLE + " -o -Wl,--start-group -lkallisti -lc -lgcc -lm -lstdc++ -Wl,--end-group") +set(CMAKE_C_LINK_EXECUTABLE + " -o -Wl,--start-group -lkallisti -lc -lgcc -lm -lstdc++ -Wl,--end-group") + +# 10. Skip compiler checks (saves time and avoids romdisk symbol issues) +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) diff --git a/Packaging/dreamcast/.gitignore b/Packaging/dreamcast/.gitignore new file mode 100644 index 000000000..54690efba --- /dev/null +++ b/Packaging/dreamcast/.gitignore @@ -0,0 +1,50 @@ +# Build artifacts +*.iso +*.cdi +*.bin + +# Disc staging directories +cd_root/ +flycast_disc/ +romdisk/ +objects/ +towners/ +items/ +levels/ +monsters/ +music/ +nlevels/ +plrgfx/ +sfx/ +speech/ +ui_art/ +data/ +fonts/ +gendata/ +arena/ + +# Extracted game data (copyrighted) +diabdat/ +DIABDAT.MPQ +spawn.mpq + +# Extracted file dumps (from MPQ tools) +File[0-9]*.pcx +File[0-9]*.wav +File[0-9]*.xxx +File[0-9]*.gif +File[0-9]*.smk + +# GPF SDL source (cloned by build.sh) +SDL-gpf/ + +# Build tool output +*.o + +# Listfiles (generated) +*_listfile*.txt + +# Python / IDE +.venv/ +.qodo/ +__pycache__/ diff --git a/Packaging/dreamcast/README.md b/Packaging/dreamcast/README.md new file mode 100644 index 000000000..eed2aaea9 --- /dev/null +++ b/Packaging/dreamcast/README.md @@ -0,0 +1,39 @@ +# Dreamcast Build + +This folder contains the Dreamcast packaging flow for DevilutionX. + +## Prerequisites + +- [KallistiOS](https://kos-docs.dreamcast.wiki/) (KOS) with kos-ports (zlib, bzip2) +- [`mkdcdisc`](https://gitlab.com/simulant/mkdcdisc) for CDI disc image creation + +SDL (GPF SDL with DMA video) and Lua are built from source automatically by `build.sh`. +No external `fmt` patch is required. The build applies a bundled SH4 `libfmt` patch automatically. + +## Game Data (Required) + +- You must provide your own MPQ files. Diablo data files are proprietary assets owned by Blizzard. +- Copy `DIABDAT.MPQ` to `Packaging/dreamcast/cd_root/`. +- `spawn.mpq` is optional for shareware mode. +- For extraction methods, see [Extracting MPQs from the GoG installer](https://github.com/diasurgical/devilutionX/wiki/Extracting-MPQs-from-the-GoG-installer). + +## Build + +From repo root: + +```sh +./Packaging/dreamcast/build.sh +``` + +## Output + +- Intermediate ELF: `build-dreamcast/devilutionx.elf` +- Bootable CDI: `Packaging/dreamcast/devilutionx-playable.cdi` + +## Notes + +- `build.sh` deletes and recreates `build-dreamcast/`. +- Override tool paths with env vars if needed: + - `KOS_BASE` + - `KOS_ENV` + - `MKDCDISC` diff --git a/Packaging/dreamcast/build.sh b/Packaging/dreamcast/build.sh new file mode 100755 index 000000000..acf8859e4 --- /dev/null +++ b/Packaging/dreamcast/build.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +BUILD_DIR="${ROOT_DIR}/build-dreamcast" +CD_ROOT="${SCRIPT_DIR}/cd_root" + +ELF_NAME="devilutionx.elf" +STRIPPED_NAME="devilutionx-stripped.elf" +BIN_NAME="1ST_READ.BIN" + +KOS_BASE="${KOS_BASE:-/opt/toolchains/dc/kos}" +KOS_ENV="${KOS_ENV:-${KOS_BASE}/environ.sh}" +MKDCDISC="${MKDCDISC:-/opt/toolchains/dc/antiruins/tools/mkdcdisc}" + +if [ -f "${KOS_ENV}" ]; then + # shellcheck disable=SC1090 + # Temporarily disable -u because KOS environ may have unbound variables + set +u + source "${KOS_ENV}" + set -u +else + echo "Error: KOS environ not found at ${KOS_ENV}" + exit 1 +fi + +if [ ! -x "${MKDCDISC}" ]; then + echo "Error: mkdcdisc not found at ${MKDCDISC}" + exit 1 +fi + +GPF_SDL_DIR="${SCRIPT_DIR}/SDL-gpf" +GPF_SDL_LIB="${KOS_BASE}/addons/lib/dreamcast/libSDL.a" +GPF_SDL_HEADER="${KOS_BASE}/addons/include/dreamcast/SDL/SDL_dreamcast.h" + +if [ ! -f "${GPF_SDL_HEADER}" ]; then + echo "Building GPF SDL (SDL-dreamhal--GLDC) for DMA video..." + if [ ! -d "${GPF_SDL_DIR}" ]; then + git clone --depth 1 -b SDL-dreamhal--GLDC \ + https://github.com/GPF/SDL-1.2 "${GPF_SDL_DIR}" + fi + make -C "${GPF_SDL_DIR}" -f Makefile.dc -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + echo "GPF SDL installed to ${GPF_SDL_LIB}" +else + echo "GPF SDL already installed at ${GPF_SDL_LIB}" +fi + +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}" + +cmake_args=( + -S "${ROOT_DIR}" + -B "${BUILD_DIR}" + -DCMAKE_TOOLCHAIN_FILE="${ROOT_DIR}/CMake/platforms/dreamcast.toolchain.cmake" + -DDEVILUTIONX_SYSTEM_LUA=OFF +) + +if command -v ninja >/dev/null 2>&1; then + cmake_args+=(-G Ninja) +fi + +cmake "${cmake_args[@]}" +cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + +if [ ! -f "${BUILD_DIR}/${ELF_NAME}" ]; then + echo "Error: ${BUILD_DIR}/${ELF_NAME} not found after build" + exit 1 +fi + +sh-elf-strip -s "${BUILD_DIR}/${ELF_NAME}" -o "${BUILD_DIR}/${STRIPPED_NAME}" + +mkdir -p "${CD_ROOT}" +cp "${BUILD_DIR}/${STRIPPED_NAME}" "${CD_ROOT}/${BIN_NAME}" +cp "${BUILD_DIR}/devilutionx.mpq" "${CD_ROOT}/devilutionx.mpq" + +"${MKDCDISC}" -e "${CD_ROOT}/${BIN_NAME}" \ + -D "${CD_ROOT}" \ + -o "${SCRIPT_DIR}/devilutionx-playable.cdi" \ + -n "DevilutionX" + +echo "CDI: ${SCRIPT_DIR}/devilutionx-playable.cdi" diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index f85599af4..7082837c3 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -10,6 +10,10 @@ #include #include +#ifdef __DREAMCAST__ +#include +#endif + #ifdef USE_SDL3 #include #include @@ -89,6 +93,16 @@ OptionalOwnedClxSpriteList ArtCursor; std::size_t SelectedItem = 0; +#ifdef __DREAMCAST__ +namespace { +struct CachedBackground { + OwnedClxSpriteList sprites; + std::array palette; +}; +std::unordered_map bgArtCache; +} // namespace +#endif + namespace { OptionalOwnedClxSpriteList ArtHero; @@ -691,6 +705,10 @@ void UiInitialize() void UiDestroy() { UnloadFonts(); +#ifdef __DREAMCAST__ + bgArtCache.clear(); + bgArtCache.rehash(0); +#endif UnloadUiGFX(); } @@ -762,10 +780,28 @@ bool UiLoadBlackBackground() void LoadBackgroundArt(const char *pszFile, int frames) { ArtBackground = std::nullopt; + +#ifdef __DREAMCAST__ + // Cache backgrounds to avoid repeated slow CD reads on screen transitions. + std::string key(pszFile); + auto it = bgArtCache.find(key); + if (it != bgArtCache.end()) { + ArtBackground = it->second.sprites.clone(); + logical_palette = it->second.palette; + UpdateSystemPalette(logical_palette); + UiOnBackgroundChange(); + return; + } +#endif + ArtBackground = LoadPcxSpriteList(pszFile, static_cast(frames), /*transparentColor=*/std::nullopt, logical_palette.data()); if (!ArtBackground) return; +#ifdef __DREAMCAST__ + bgArtCache.emplace(std::move(key), CachedBackground { ArtBackground->clone(), logical_palette }); +#endif + UpdateSystemPalette(logical_palette); UiOnBackgroundChange(); } diff --git a/Source/control/control_flasks.cpp b/Source/control/control_flasks.cpp index ad5449f70..13cf6c10f 100644 --- a/Source/control/control_flasks.cpp +++ b/Source/control/control_flasks.cpp @@ -47,7 +47,7 @@ void DrawFlaskUpper(const Surface &out, const Surface &sourceBuffer, int offset, GetMainPanel().position + Displacement { offset, -rect.size.height }); // Draw the filled part of the flask over the empty part - if (filledRows > 0) { + if (filledRows > 0 && BottomBuffer) { DrawFlaskAbovePanel(out, BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows), GetMainPanel().position + Displacement { offset, -rect.size.height + emptyRows }); @@ -90,7 +90,7 @@ void DrawFlaskLower(const Surface &out, const Surface &sourceBuffer, int offset, } // Draw the filled part of the flask - if (drawFilledPortion && filledRows > 0) { + if (drawFilledPortion && filledRows > 0 && BottomBuffer) { DrawFlaskOnPanel(out, BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows), GetMainPanel().position + Displacement { offset, emptyRows }); diff --git a/Source/control/control_panel.cpp b/Source/control/control_panel.cpp index dd33e6160..124eb0511 100644 --- a/Source/control/control_panel.cpp +++ b/Source/control/control_panel.cpp @@ -346,6 +346,8 @@ Point GetPanelPosition(UiPanels panel, Point offset) void DrawPanelBox(const Surface &out, SDL_Rect srcRect, Point targetPosition) { + if (!BottomBuffer) + return; out.BlitFrom(*BottomBuffer, srcRect, targetPosition); } @@ -434,6 +436,9 @@ void DrawMainPanel(const Surface &out) void DrawMainPanelButtons(const Surface &out) { + if (!BottomBuffer) + return; + const Point mainPanelPosition = GetMainPanel().position; for (int i = 0; i < TotalSpMainPanelButtons; i++) { diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b14da41cb..f91ac8d28 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -883,7 +883,6 @@ void RunGameLoop(interface_mode uMsg) #endif while (gbRunGame) { - #ifdef _DEBUG if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { InitConsole(); @@ -1304,6 +1303,12 @@ void DiabloSplash() if (!gbShowIntro) return; +#ifdef __DREAMCAST__ + // Skip movies on Dreamcast (they render with wrong palette) + UiTitleDialog(); + return; +#endif + if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) play_movie("gendata\\logo.smk", true); diff --git a/Source/effects.cpp b/Source/effects.cpp index eb4841a39..30a63e8b7 100644 --- a/Source/effects.cpp +++ b/Source/effects.cpp @@ -5,11 +5,17 @@ */ #include "effects.h" +#include #include #include #include #include +#ifdef USE_SDL3 +#include +#else +#include +#endif #include "data/file.hpp" #include "data/iterators.hpp" @@ -42,6 +48,109 @@ TSFX *sgpStreamSFX = nullptr; /** List of all sounds, except monsters and music */ std::vector sgSFX; +#ifdef __DREAMCAST__ +constexpr uint32_t DreamcastMissingLoadRetryMs = 2000; +constexpr uint32_t DreamcastDeferredLoadRetryMs = 250; +constexpr uint32_t DreamcastLateLoadThresholdMs = 20; +constexpr uint32_t DreamcastRealtimeLoadIntervalMs = 500; + +std::array(SfxID::LAST) + 1> SfxLoadRetryAfterMs {}; +uint32_t NextDreamcastRealtimeLoadAtMs = 0; + +size_t GetSfxIndex(const TSFX *sfx) +{ + return static_cast(sfx - sgSFX.data()); +} + +bool ShouldAttemptSfxLoadNow(const TSFX *sfx) +{ + const size_t index = GetSfxIndex(sfx); + if (index >= SfxLoadRetryAfterMs.size()) + return true; + return SDL_GetTicks() >= SfxLoadRetryAfterMs[index]; +} + +void DeferSfxLoad(const TSFX *sfx, uint32_t delayMs) +{ + const size_t index = GetSfxIndex(sfx); + if (index >= SfxLoadRetryAfterMs.size()) + return; + SfxLoadRetryAfterMs[index] = SDL_GetTicks() + delayMs; +} + +bool TryLoadSfxForPlayback(TSFX *sfx, bool stream, bool allowBlockingLoad, bool *loadedLate) +{ + if (loadedLate != nullptr) + *loadedLate = false; + if (sfx->pSnd != nullptr) + return true; + if (!ShouldAttemptSfxLoadNow(sfx)) + return false; + if (!allowBlockingLoad) { + DeferSfxLoad(sfx, DreamcastDeferredLoadRetryMs); + return false; + } + + const uint32_t startedAt = SDL_GetTicks(); + sfx->pSnd = sound_file_load(sfx->pszName.c_str(), stream); + if (sfx->pSnd == nullptr) { + DeferSfxLoad(sfx, DreamcastMissingLoadRetryMs); + return false; + } + + if (loadedLate != nullptr && SDL_GetTicks() - startedAt > DreamcastLateLoadThresholdMs) + *loadedLate = true; + return true; +} + +void PreloadDreamcastSfx(SfxID id) +{ + const size_t index = static_cast(id); + if (index >= sgSFX.size()) + return; + + TSFX &sfx = sgSFX[index]; + if (sfx.pSnd != nullptr || (sfx.bFlags & sfx_STREAM) != 0) + return; + if (!ShouldAttemptSfxLoadNow(&sfx)) + return; + + sfx.pSnd = sound_file_load(sfx.pszName.c_str(), /*stream=*/false); + if (sfx.pSnd == nullptr) + DeferSfxLoad(&sfx, DreamcastMissingLoadRetryMs); +} + +/** + * Evict non-playing sounds to free memory for new sound loading. + * @param exclude Sound to skip during eviction (the one being loaded) + * @param streamOnly If true, only count/evict sounds with sfx_STREAM flag + * @param maxLoaded If loaded count reaches this, start evicting + * @param targetLoaded Evict until loaded count drops below this + */ +void EvictSoundsIfNeeded(TSFX *exclude, bool streamOnly, int maxLoaded, int targetLoaded) +{ + int loaded = 0; + for (const auto &sfx : sgSFX) { + if (sfx.pSnd != nullptr && (!streamOnly || (sfx.bFlags & sfx_STREAM) != 0)) + ++loaded; + } + if (loaded < maxLoaded) + return; + for (auto &sfx : sgSFX) { + if (&sfx == exclude) + continue; + if (sfx.pSnd == nullptr || sfx.pSnd->isPlaying()) + continue; + if (streamOnly && (sfx.bFlags & sfx_STREAM) == 0) + continue; + sfx.pSnd = nullptr; + --loaded; + if (loaded < targetLoaded) + break; + } +} +#endif + void StreamPlay(TSFX *pSFX, int lVolume, int lPan) { assert(pSFX); @@ -51,10 +160,24 @@ void StreamPlay(TSFX *pSFX, int lVolume, int lPan) if (lVolume >= VOLUME_MIN) { if (lVolume > VOLUME_MAX) lVolume = VOLUME_MAX; +#ifdef __DREAMCAST__ + if (pSFX->pSnd == nullptr) { + music_mute(); + EvictSoundsIfNeeded(pSFX, /*streamOnly=*/true, /*maxLoaded=*/8, /*targetLoaded=*/4); + bool loadedLate = false; + const bool loaded = TryLoadSfxForPlayback(pSFX, AllowStreaming, /*allowBlockingLoad=*/true, &loadedLate); + music_unmute(); + if (!loaded || loadedLate) + return; + } + if (pSFX->pSnd != nullptr && pSFX->pSnd->DSB.IsLoaded()) + pSFX->pSnd->DSB.PlayWithVolumeAndPan(lVolume, sound_get_or_set_sound_volume(1), lPan); +#else if (pSFX->pSnd == nullptr) pSFX->pSnd = sound_file_load(pSFX->pszName.c_str(), AllowStreaming); if (pSFX->pSnd->DSB.IsLoaded()) pSFX->pSnd->DSB.PlayWithVolumeAndPan(lVolume, sound_get_or_set_sound_volume(1), lPan); +#endif sgpStreamSFX = pSFX; } } @@ -90,8 +213,31 @@ void PlaySfxPriv(TSFX *pSFX, bool loc, Point position) return; } +#ifdef __DREAMCAST__ + if (pSFX->pSnd == nullptr) { + music_mute(); + EvictSoundsIfNeeded(pSFX, /*streamOnly=*/false, /*maxLoaded=*/20, /*targetLoaded=*/15); + bool loadedLate = false; + const uint32_t now = SDL_GetTicks(); + const bool canDoRealtimeLoad = !loc || now >= NextDreamcastRealtimeLoadAtMs; + bool loaded = TryLoadSfxForPlayback(pSFX, /*stream=*/false, /*allowBlockingLoad=*/canDoRealtimeLoad, &loadedLate); + if (loc && canDoRealtimeLoad) + NextDreamcastRealtimeLoadAtMs = SDL_GetTicks() + DreamcastRealtimeLoadIntervalMs; + // For non-positional (menu/UI) sounds, one eviction+retry is acceptable. + if (!loaded && !loc) { + EvictSoundsIfNeeded(nullptr, /*streamOnly=*/false, /*maxLoaded=*/0, /*targetLoaded=*/0); + ClearDuplicateSounds(); + DeferSfxLoad(pSFX, 0); + loaded = TryLoadSfxForPlayback(pSFX, /*stream=*/false, /*allowBlockingLoad=*/true, &loadedLate); + } + music_unmute(); + if (!loaded || loadedLate) + return; + } +#else if (pSFX->pSnd == nullptr) pSFX->pSnd = sound_file_load(pSFX->pszName.c_str()); +#endif if (pSFX->pSnd == nullptr || !pSFX->pSnd->DSB.IsLoaded()) return; @@ -154,6 +300,9 @@ void LoadEffectsData() reader.readString("path", item.pszName); } sgSFX.shrink_to_fit(); +#ifdef __DREAMCAST__ + SfxLoadRetryAfterMs.fill(0); +#endif } void PrivSoundInit(uint8_t bLoadMask) @@ -164,6 +313,23 @@ void PrivSoundInit(uint8_t bLoadMask) if (sgSFX.empty()) LoadEffectsData(); +#ifdef __DREAMCAST__ + // Free non-playing sounds during level transitions to reclaim RAM. + for (auto &sfx : sgSFX) { + if (sfx.pSnd != nullptr && !sfx.pSnd->isPlaying()) { + sfx.pSnd = nullptr; + } + } + // Keep high-frequency sounds resident to avoid CD reads in combat. + for (const SfxID id : { SfxID::Walk, SfxID::Swing, SfxID::Swing2, SfxID::ShootBow, SfxID::CastSpell, + SfxID::CastFire, SfxID::SpellFireHit, SfxID::ItemPotion, SfxID::ItemGold, SfxID::GrabItem, + SfxID::DoorOpen, SfxID::DoorClose, SfxID::ChestOpen, SfxID::MenuMove, SfxID::MenuSelect }) { + PreloadDreamcastSfx(id); + } + (void)bLoadMask; + return; +#endif + for (auto &sfx : sgSFX) { if (sfx.bFlags == 0 || sfx.pSnd != nullptr) { continue; @@ -258,6 +424,9 @@ void effects_cleanup_sfx(bool fullUnload) if (fullUnload) { sgSFX.clear(); +#ifdef __DREAMCAST__ + SfxLoadRetryAfterMs.fill(0); +#endif return; } @@ -323,9 +492,18 @@ void effects_play_sound(SfxID id) int GetSFXLength(SfxID nSFX) { TSFX &sfx = sgSFX[static_cast(nSFX)]; - if (sfx.pSnd == nullptr) + if (sfx.pSnd == nullptr) { +#ifdef __DREAMCAST__ + music_mute(); +#endif sfx.pSnd = sound_file_load(sfx.pszName.c_str(), /*stream=*/AllowStreaming && (sfx.bFlags & sfx_STREAM) != 0); +#ifdef __DREAMCAST__ + music_unmute(); +#endif + } + if (sfx.pSnd == nullptr) + return 0; return sfx.pSnd->DSB.GetLength(); } diff --git a/Source/engine/assets.cpp b/Source/engine/assets.cpp index d51164e17..4e6253abe 100644 --- a/Source/engine/assets.cpp +++ b/Source/engine/assets.cpp @@ -331,14 +331,16 @@ std::vector GetMPQSearchPaths() { std::vector paths; paths.push_back(paths::BasePath()); +#ifndef __DREAMCAST__ paths.push_back(paths::PrefPath()); if (paths[0] == paths[1]) paths.pop_back(); paths.push_back(paths::ConfigPath()); if (paths[0] == paths[1] || (paths.size() == 3 && (paths[0] == paths[2] || paths[1] == paths[2]))) paths.pop_back(); +#endif -#if (defined(__unix__) || defined(__APPLE__)) && !defined(__ANDROID__) && !defined(__DJGPP__) +#if (defined(__unix__) || defined(__APPLE__)) && !defined(__ANDROID__) && !defined(__DJGPP__) && !defined(__DREAMCAST__) // `XDG_DATA_HOME` is usually the root path of `paths::PrefPath()`, so we only // add `XDG_DATA_DIRS`. const char *xdgDataDirs = std::getenv("XDG_DATA_DIRS"); @@ -491,16 +493,20 @@ void LoadModArchives(std::span modnames) { std::string targetPath; for (const std::string_view modname : modnames) { +#ifndef __DREAMCAST__ targetPath = StrCat(paths::PrefPath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR); if (FileExists(targetPath)) { OverridePaths.emplace_back(targetPath); } +#endif targetPath = StrCat(paths::BasePath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR); if (FileExists(targetPath)) { OverridePaths.emplace_back(targetPath); } } +#ifndef __DREAMCAST__ OverridePaths.emplace_back(paths::PrefPath()); +#endif int priority = 10000; auto paths = GetMPQSearchPaths(); diff --git a/Source/engine/dx.cpp b/Source/engine/dx.cpp index 60c792ec8..668c18516 100644 --- a/Source/engine/dx.cpp +++ b/Source/engine/dx.cpp @@ -35,6 +35,10 @@ #include <3ds.h> #endif +#ifdef __DREAMCAST__ +#include "platform/dreamcast/dc_video.h" +#endif + namespace devilution { int refreshDelay; @@ -124,6 +128,10 @@ void dx_cleanup() SDL_HideWindow(ghMainWnd); #endif +#ifdef __DREAMCAST__ + dc::VideoShutdown(); +#endif + PalSurface = nullptr; PinnedPalSurface = nullptr; Palette = nullptr; @@ -164,6 +172,14 @@ void CreateBackBuffer() // time the global `palette` is changed. No need to do anything here as // the global `palette` doesn't have any colors set yet. #endif + +#ifdef __DREAMCAST__ + // Initialize Dreamcast 8bpp->16bpp video converter + // On DC, output is always 16bpp but we render to 8bpp PalSurface + if (!RenderDirectlyToOutputSurface) { + dc::VideoInit(gnScreenWidth, gnScreenHeight); + } +#endif } void BltFast(SDL_Rect *srcRect, SDL_Rect *dstRect) @@ -278,6 +294,12 @@ void RenderPresent() LimitFrameRate(); } #else +#ifdef __DREAMCAST__ + // Dreamcast: Convert 8bpp PalSurface to 16bpp output surface before flip + if (!RenderDirectlyToOutputSurface && dc::IsInitialized()) { + dc::ConvertAndUpload(PalSurface, surface); + } +#endif if (SDL_Flip(surface) <= -1) { ErrSdl(); } diff --git a/Source/engine/palette.cpp b/Source/engine/palette.cpp index bdbb1cab2..d9875393f 100644 --- a/Source/engine/palette.cpp +++ b/Source/engine/palette.cpp @@ -26,6 +26,9 @@ #include "headless_mode.hpp" #include "hwcursor.hpp" #include "options.h" +#ifdef __DREAMCAST__ +#include "platform/dreamcast/dc_video.h" +#endif #include "utils/display.h" #include "utils/palette_blending.hpp" #include "utils/sdl_compat.h" @@ -165,6 +168,10 @@ void SystemPaletteUpdated(int first, int ncolor) if (!SDLC_SetSurfaceAndPaletteColors(PalSurface, Palette.get(), system_palette.data() + first, first, ncolor)) { ErrSdl(); } +#ifdef __DREAMCAST__ + // Update Dreamcast RGB565 palette LUT for 8bpp->16bpp conversion + dc::UpdatePaletteRange(system_palette.data() + first, first, ncolor); +#endif } void palette_init() diff --git a/Source/engine/render/blit_impl.hpp b/Source/engine/render/blit_impl.hpp index d8a7468cd..ed3f85383 100644 --- a/Source/engine/render/blit_impl.hpp +++ b/Source/engine/render/blit_impl.hpp @@ -2,8 +2,13 @@ #include #include +// Dreamcast's cross-compiler (sh-elf-g++) was built with _PSTL_PAR_BACKEND_TBB +// enabled, so pulls in host TBB headers that fail to compile. +// Execution policies are not useful on the single-core SH4 anyway. +#ifndef __DREAMCAST__ #include #include +#endif #include "engine/render/light_render.hpp" #include "utils/attributes.h" @@ -11,7 +16,7 @@ namespace devilution { -#if __cpp_lib_execution >= 201902L +#if !defined(__DREAMCAST__) && __cpp_lib_execution >= 201902L #define DEVILUTIONX_BLIT_EXECUTION_POLICY std::execution::unseq, #else #define DEVILUTIONX_BLIT_EXECUTION_POLICY diff --git a/Source/engine/sound.cpp b/Source/engine/sound.cpp index ac13b862b..0556e7705 100644 --- a/Source/engine/sound.cpp +++ b/Source/engine/sound.cpp @@ -239,7 +239,16 @@ tl::expected, std::string> SoundFileLoadWithStatus(const c std::unique_ptr sound_file_load(const char *path, bool stream) { tl::expected, std::string> result = SoundFileLoadWithStatus(path, stream); +#ifdef __DREAMCAST__ + // On Dreamcast, sound loading failures are non-fatal. + // The 16MB RAM limit means some sounds may fail to load. + if (!result.has_value()) { + SDL_Log("sound_file_load: skipping %s (%s)", path, result.error().c_str()); + return nullptr; + } +#else if (!result.has_value()) app_fatal(result.error()); +#endif return std::move(result).value(); } diff --git a/Source/interfac.cpp b/Source/interfac.cpp index 0ca8a58b2..e6b57d412 100644 --- a/Source/interfac.cpp +++ b/Source/interfac.cpp @@ -24,6 +24,10 @@ #include "control/control.hpp" #include "controls/input.h" +#ifdef __DREAMCAST__ +#include "DiabloUI/diabloui.h" +#include "engine/sound.h" +#endif #include "engine/clx_sprite.hpp" #include "engine/dx.h" #include "engine/events.hpp" @@ -616,7 +620,15 @@ void IncProgress(uint32_t steps) SDL_Event event; CustomEventToSdlEvent(event, WM_PROGRESS); if (!SDLC_PushEvent(&event)) { +#ifdef __DREAMCAST__ + static bool loggedDreamcastProgressPushFailure = false; + if (!loggedDreamcastProgressPushFailure) { + LogError("Failed to send WM_PROGRESS {}", SDL_GetError()); + loggedDreamcastProgressPushFailure = true; + } +#else LogError("Failed to send WM_PROGRESS {}", SDL_GetError()); +#endif SDL_ClearError(); } #ifdef LOAD_ON_MAIN_THREAD @@ -664,6 +676,21 @@ void ShowProgress(interface_mode uMsg) BlackPalette(); +#ifdef __DREAMCAST__ + // Free ALL UI assets before heavy level loading to reclaim ~2-3MB RAM. + // Dreamcast only has 16MB and level loading needs every byte we can get. + music_stop(); + + ArtBackground = std::nullopt; + ArtBackgroundWidescreen = std::nullopt; + ArtLogo = std::nullopt; + ArtCursor = std::nullopt; + DifficultyIndicator = std::nullopt; + for (auto &focus : ArtFocus) { + focus = std::nullopt; + } +#endif + // Always load the background (even if we end up skipping rendering it). // This is because the MPQ archive can only be read by a single thread at a time. LoadCutsceneBackground(uMsg); diff --git a/Source/levels/reencode_dun_cels.cpp b/Source/levels/reencode_dun_cels.cpp index a8244df98..e2e785e36 100644 --- a/Source/levels/reencode_dun_cels.cpp +++ b/Source/levels/reencode_dun_cels.cpp @@ -250,8 +250,19 @@ void ReencodeDungeonCels(std::unique_ptr &dungeonCels, std::span(workBuffer); +#else std::unique_ptr result { new std::byte[outSize] }; auto *const resultPtr = reinterpret_cast(result.get()); +#endif WriteLE32(resultPtr, static_cast(frames.size())); uint8_t *lookup = resultPtr + 4; uint8_t *out = resultPtr + (2 + frames.size()) * 4; // number of frames, frame offsets, file size @@ -299,7 +310,23 @@ void ReencodeDungeonCels(std::unique_ptr &dungeonCels, std::span> ComputeCelBlockAdjustments(std::span> frames) diff --git a/Source/main.cpp b/Source/main.cpp index 492ba7919..5e18a1c5a 100644 --- a/Source/main.cpp +++ b/Source/main.cpp @@ -23,6 +23,9 @@ #ifdef GPERF_HEAP_MAIN #include #endif +#ifdef __DREAMCAST__ +#include "platform/dreamcast/dc_init.hpp" +#endif #include "diablo.h" @@ -35,6 +38,11 @@ extern "C" const char *__asan_default_options() // NOLINT(bugprone-reserved-iden extern "C" int main(int argc, char **argv) { +#ifdef __DREAMCAST__ + if (!devilution::dc::InitDreamcast()) { + // Fall back to loose file loading + } +#endif #ifdef __SWITCH__ switch_romfs_init(); switch_enable_network(); @@ -60,6 +68,9 @@ extern "C" int main(int argc, char **argv) const int result = devilution::DiabloMain(argc, argv); #ifdef GPERF_HEAP_MAIN HeapProfilerStop(); +#endif +#ifdef __DREAMCAST__ + devilution::dc::ShutdownDreamcast(); #endif return result; } diff --git a/Source/movie.cpp b/Source/movie.cpp index 7ed76f206..7df79bfce 100644 --- a/Source/movie.cpp +++ b/Source/movie.cpp @@ -37,6 +37,11 @@ void play_movie(const char *pszMovie, bool userCanClose) { if (demo::IsRunning()) return; +#ifdef __DREAMCAST__ + // Dreamcast has no SMK video decoder and the mode switch to + // 320x240 causes a visible flicker. Skip all movie playback. + return; +#endif movie_playing = true; diff --git a/Source/options.cpp b/Source/options.cpp index 52e2730ed..1fe1c2253 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -789,14 +789,22 @@ GraphicsOptions::GraphicsOptions() , brightness("Brightness Correction", OptionEntryFlags::Invisible, "Brightness Correction", "Brightness correction level.", 0) , zoom("Zoom", OptionEntryFlags::None, N_("Zoom"), N_("Zoom on when enabled."), false) , perPixelLighting("Per-pixel Lighting", OptionEntryFlags::None, N_("Per-pixel Lighting"), N_("Subtile lighting for smoother light gradients."), DEFAULT_PER_PIXEL_LIGHTING) +#ifdef __DREAMCAST__ + , colorCycling("Color Cycling", OptionEntryFlags::None, N_("Color Cycling"), N_("Color cycling effect used for water, lava, and acid animation."), false) +#else , colorCycling("Color Cycling", OptionEntryFlags::None, N_("Color Cycling"), N_("Color cycling effect used for water, lava, and acid animation."), true) +#endif , alternateNestArt("Alternate nest art", OptionEntryFlags::OnlyHellfire | OptionEntryFlags::CantChangeInGame, N_("Alternate nest art"), N_("The game will use an alternative palette for Hellfire’s nest tileset."), false) #if SDL_VERSION_ATLEAST(2, 0, 0) , hardwareCursor("Hardware Cursor", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor"), N_("Use a hardware cursor"), HardwareCursorDefault()) , hardwareCursorForItems("Hardware Cursor For Items", OptionEntryFlags::CantChangeInGame | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor For Items"), N_("Use a hardware cursor for items."), false) , hardwareCursorMaxSize("Hardware Cursor Maximum Size", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor Maximum Size"), N_("Maximum width / height for the hardware cursor. Larger cursors fall back to software."), 128, { 0, 64, 128, 256, 512 }) #endif +#ifdef __DREAMCAST__ + , showFPS("Show FPS", OptionEntryFlags::None, N_("Show FPS"), N_("Displays the FPS in the upper left corner of the screen."), true) +#else , showFPS("Show FPS", OptionEntryFlags::None, N_("Show FPS"), N_("Displays the FPS in the upper left corner of the screen."), false) +#endif { } std::vector GraphicsOptions::GetEntries() diff --git a/Source/pfile.cpp b/Source/pfile.cpp index ec96c6608..9208209ec 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -5,6 +5,7 @@ */ #include "pfile.h" +#include #include #include #include @@ -43,6 +44,10 @@ #ifdef UNPACKED_SAVES #include "utils/file_util.h" +#ifdef __DREAMCAST__ +#include "platform/dreamcast/dc_init.hpp" +#include "platform/dreamcast/dc_save_codec.hpp" +#endif #else #include "mpq/mpq_reader.hpp" #endif @@ -61,8 +66,116 @@ namespace { /** List of character names for the character selection screen. */ char hero_names[MAX_CHARACTERS][PlayerNameLength]; +#ifdef __DREAMCAST__ +constexpr uint32_t DreamcastMaxSaveSlots = 8; +#endif + +uint32_t GetPlatformSaveSlotCount() +{ +#ifdef __DREAMCAST__ + return std::min(MAX_CHARACTERS, DreamcastMaxSaveSlots); +#else + return MAX_CHARACTERS; +#endif +} + +#ifdef __DREAMCAST__ +bool IsRamSavePath(std::string_view path) +{ + return path.size() >= 5 && path.substr(0, 5) == "/ram/"; +} + +uint64_t HashSaveEntryKey(std::string_view key) +{ + // FNV-1a 64-bit hash for deterministic, compact VMU file keys. + uint64_t hash = 14695981039346656037ULL; + for (const char ch : key) { + hash ^= static_cast(ch); + hash *= 1099511628211ULL; + } + return hash; +} + +std::string MakeMappedVmuFilename(std::string_view saveDir, std::string_view filename) +{ + constexpr char HexDigits[] = "0123456789abcdef"; + const std::string key = StrCat(saveDir, "|", filename); + uint64_t hash = HashSaveEntryKey(key); + + std::string mapped(12, '0'); + for (int i = 11; i >= 0; --i) { + mapped[i] = HexDigits[hash & 0xF]; + hash >>= 4; + } + return mapped; +} + +std::string MakeLegacyVmuFilename(std::string_view saveDir, std::string_view filename) +{ + if (!IsRamSavePath(saveDir)) + return {}; + const std::string legacy = StrCat(saveDir.substr(5), filename); + if (legacy.size() > 12) + return {}; + return legacy; +} + +bool WriteBytesToPath(const std::string &path, const std::byte *data, size_t size) +{ + FILE *file = OpenFile(path.c_str(), "wb"); + if (file == nullptr) + return false; + const bool ok = std::fwrite(data, size, 1, file) == 1; + std::fclose(file); + return ok; +} + +bool RestoreRamFileFromVmu(const std::string &saveDir, const char *filename) +{ + if (!IsRamSavePath(saveDir) || !dc::IsVmuAvailable()) + return false; + + const std::string localPath = saveDir + filename; + const auto tryRestore = [&](const std::string &vmuFilename) { + if (vmuFilename.empty() || !dc::VmuFileExists(dc::GetVmuPath(), vmuFilename.c_str())) + return false; + + size_t size = 0; + auto data = dc::ReadFromVmu(dc::GetVmuPath(), vmuFilename.c_str(), size); + if (!data || size == 0) { + LogError("[DC Save] VMU read failed for {}", vmuFilename); + return false; + } + if (!WriteBytesToPath(localPath, data.get(), size)) { + LogError("[DC Save] Cannot restore {} to {}", vmuFilename, localPath); + return false; + } + + LogVerbose("[DC Save] Restored {} bytes from VMU {} to {}", size, vmuFilename, localPath); + return true; + }; + + if (tryRestore(MakeMappedVmuFilename(saveDir, filename))) + return true; + + // Backward compatibility for old short VMU names (e.g. dvx_s0_hero). + return tryRestore(MakeLegacyVmuFilename(saveDir, filename)); +} +#endif + std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) { +#ifdef __DREAMCAST__ + // Dreamcast keeps active saves in /ram/ with flat file prefixes. + // VMU persistence uses mapped 12-char keys (see MakeMappedVmuFilename). + return StrCat(paths::PrefPath(), "dvx_", savePrefix, + gbIsSpawn + ? (gbIsMultiplayer ? "sh" : "sp") // share/spawn shortened for VMU + : (gbIsMultiplayer ? "m" : "s"), // multi/single shortened + saveNum, + gbIsHellfire ? "h_" : "_" // hellfire indicator + separator + ); +#else return StrCat(paths::PrefPath(), savePrefix, gbIsSpawn ? (gbIsMultiplayer ? "share_" : "spawn_") @@ -74,10 +187,17 @@ std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) gbIsHellfire ? ".hsv" : ".sv" #endif ); +#endif } std::string GetStashSavePath() { +#ifdef __DREAMCAST__ + // Flat file for stash on Dreamcast + return StrCat(paths::PrefPath(), "dvx_", + gbIsSpawn ? "stash_sp" : "stash", + gbIsHellfire ? "h_" : "_"); +#else return StrCat(paths::PrefPath(), gbIsSpawn ? "stash_spawn" : "stash", #ifdef UNPACKED_SAVES @@ -86,6 +206,7 @@ std::string GetStashSavePath() gbIsHellfire ? ".hsv" : ".sv" #endif ); +#endif } bool GetSaveNames(uint8_t index, std::string_view prefix, char *out) @@ -243,8 +364,18 @@ bool ArchiveContainsGame(SaveReader &hsArchive) std::optional CreateSaveReader(std::string &&path) { #ifdef UNPACKED_SAVES +#ifdef __DREAMCAST__ + if (path.empty()) + return std::nullopt; + SaveReader reader(std::move(path)); + if (!reader.HasFile("hero")) + return std::nullopt; + return reader; +#else + // For other platforms, path is a directory if (!FileExists(path)) return std::nullopt; +#endif return SaveReader(std::move(path)); #else std::int32_t error; @@ -536,11 +667,76 @@ void RemoveAllInvalidItems(Player &player) } // namespace #ifdef UNPACKED_SAVES +bool SaveReader::HasFile(const char *path) +{ + const std::string filePath = dir_ + path; +#ifdef __DREAMCAST__ + if (IsRamSavePath(dir_) && !FileExists(filePath.c_str())) + return RestoreRamFileFromVmu(dir_, path); +#endif + return FileExists(filePath.c_str()); +} + std::unique_ptr SaveReader::ReadFile(const char *filename, std::size_t &fileSize, int32_t &error) { std::unique_ptr result; error = 0; const std::string path = dir_ + filename; + +#ifdef __DREAMCAST__ + if (IsRamSavePath(dir_)) { + FILE *file = OpenFile(path.c_str(), "rb"); + if (file == nullptr) { + if (!RestoreRamFileFromVmu(dir_, filename)) { + LogError("[DC Save] Cannot open {} for reading", path); + error = 1; + return nullptr; + } + + file = OpenFile(path.c_str(), "rb"); + if (file == nullptr) { + LogError("[DC Save] Cannot open {} after VMU restore", path); + error = 1; + return nullptr; + } + } + std::fseek(file, 0, SEEK_END); + long fileLen = std::ftell(file); + std::fseek(file, 0, SEEK_SET); + if (fileLen <= 0) { + LogError("[DC Save] Empty or invalid file {}: ftell={}", path, fileLen); + std::fclose(file); + error = 1; + return nullptr; + } + size_t size = static_cast(fileLen); + fileSize = size; + result.reset(new (std::nothrow) std::byte[size]); + if (!result) { + LogError("[DC Save] Allocation failed for {} bytes from {}", size, path); + std::fclose(file); + error = 1; + return nullptr; + } + size_t bytesRead = std::fread(result.get(), 1, size, file); + std::fclose(file); + if (bytesRead != size) { + LogError("[DC Save] Short read {}: expected {} got {}", path, size, bytesRead); + error = 1; + return nullptr; + } + return result; + } + // VMU path - use zlib save containers + size_t decompressedSize = 0; + result = dc::ReadCompressedFile(path.c_str(), decompressedSize); + if (!result) { + error = 1; + return nullptr; + } + fileSize = decompressedSize; + return result; +#else uintmax_t size; if (!GetFileSize(path.c_str(), &size)) { error = 1; @@ -560,11 +756,46 @@ std::unique_ptr SaveReader::ReadFile(const char *filename, std::siz } std::fclose(file); return result; +#endif } bool SaveWriter::WriteFile(const char *filename, const std::byte *data, size_t size) { +#ifdef __DREAMCAST__ + if (dir_.empty()) { + return false; + } +#endif const std::string path = dir_ + filename; + +#ifdef __DREAMCAST__ + if (IsRamSavePath(dir_)) { + FILE *file = OpenFile(path.c_str(), "wb"); + if (file == nullptr) { + LogError("[DC Save] WriteFile: cannot open {} for writing", path); + return false; + } + if (std::fwrite(data, size, 1, file) != 1) { + LogError("[DC Save] WriteFile: fwrite failed for {} ({} bytes)", path, size); + std::fclose(file); + return false; + } + std::fclose(file); + LogVerbose("[DC Save] WriteFile: wrote {} bytes to {}", size, path); + + // Mirror RAM saves to VMU so saves survive emulator/console restarts. + if (dc::IsVmuAvailable()) { + const std::string vmuFilename = MakeMappedVmuFilename(dir_, filename); + if (!dc::WriteToVmu(dc::GetVmuPath(), vmuFilename.c_str(), data, size)) { + LogError("[DC Save] VMU persist failed for {} ({})", path, vmuFilename); + return false; + } + } + return true; + } + // VMU path - use zlib save containers + return dc::WriteCompressedFile(path.c_str(), data, size); +#else FILE *file = OpenFile(path.c_str(), "wb"); if (file == nullptr) { return false; @@ -575,6 +806,7 @@ bool SaveWriter::WriteFile(const char *filename, const std::byte *data, size_t s } std::fclose(file); return true; +#endif } void SaveWriter::RemoveHashEntries(bool (*fnGetName)(uint8_t, char *)) @@ -672,7 +904,7 @@ bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *)) { memset(hero_names, 0, sizeof(hero_names)); - for (uint32_t i = 0; i < MAX_CHARACTERS; i++) { + for (uint32_t i = 0; i < GetPlatformSaveSlotCount(); i++) { std::optional archive = OpenSaveArchive(i); if (archive) { PlayerPack pkplr; @@ -687,9 +919,11 @@ bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *)) Player &player = Players[0]; UnPackPlayer(pkplr, player); +#ifndef __DREAMCAST__ LoadHeroItems(player); RemoveAllInvalidItems(player); CalcPlrInv(player, false); +#endif Game2UiPlayer(player, &uihero, hasSaveGame); uiAddHeroInfo(&uihero); @@ -712,7 +946,8 @@ void pfile_ui_set_class_stats(HeroClass playerClass, _uidefaultstats *classStats uint32_t pfile_ui_get_first_unused_save_num() { uint32_t saveNum; - for (saveNum = 0; saveNum < MAX_CHARACTERS; saveNum++) { + const uint32_t saveSlotCount = GetPlatformSaveSlotCount(); + for (saveNum = 0; saveNum < saveSlotCount; saveNum++) { if (hero_names[saveNum][0] == '\0') break; } @@ -724,7 +959,7 @@ bool pfile_ui_save_create(_uiheroinfo *heroinfo) PlayerPack pkplr; const uint32_t saveNum = heroinfo->saveNumber; - if (saveNum >= MAX_CHARACTERS) + if (saveNum >= GetPlatformSaveSlotCount()) return false; heroinfo->saveNumber = saveNum; diff --git a/Source/pfile.h b/Source/pfile.h index df10fca08..9e98c698d 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -40,10 +40,7 @@ struct SaveReader { std::unique_ptr ReadFile(const char *filename, std::size_t &fileSize, int32_t &error); - bool HasFile(const char *path) - { - return ::devilution::FileExists((dir_ + path).c_str()); - } + bool HasFile(const char *path); private: std::string dir_; diff --git a/Source/platform/dreamcast/CMakeLists.txt b/Source/platform/dreamcast/CMakeLists.txt new file mode 100644 index 000000000..7a4882cfb --- /dev/null +++ b/Source/platform/dreamcast/CMakeLists.txt @@ -0,0 +1,13 @@ +include(functions/devilutionx_library) + +add_devilutionx_object_library(libdevilutionx_dreamcast + posix_stubs.c + dc_video.cpp + dc_init.cpp + dc_save_codec.cpp +) + +target_link_libraries(libdevilutionx_dreamcast PUBLIC + DevilutionX::SDL + ZLIB::ZLIB +) diff --git a/Source/platform/dreamcast/dc_init.cpp b/Source/platform/dreamcast/dc_init.cpp new file mode 100644 index 000000000..2fc8685c6 --- /dev/null +++ b/Source/platform/dreamcast/dc_init.cpp @@ -0,0 +1,59 @@ +#ifdef __DREAMCAST__ + +#include "dc_init.hpp" +#include "dc_video.h" +#include "utils/paths.h" +#include +#include +#include +#include + +namespace devilution { +namespace dc { + +static bool g_vmuAvailable = false; +static char g_vmuPath[32] = "/vmu/a1/"; + +static bool CheckVmuAvailable() +{ + maple_device_t *vmu = maple_enum_type(0, MAPLE_FUNC_MEMCARD); + if (vmu) { + snprintf(g_vmuPath, sizeof(g_vmuPath), "/vmu/%c%d/", + 'a' + vmu->port, vmu->unit); + g_vmuAvailable = true; + return true; + } + g_vmuAvailable = false; + return false; +} + +bool IsVmuAvailable() +{ + return g_vmuAvailable; +} + +const char *GetVmuPath() +{ + return g_vmuPath; +} + +bool InitDreamcast() +{ + paths::SetBasePath("/cd/"); + CheckVmuAvailable(); + // Saves use /ram/ for fast in-session access. + // Save entries are mirrored to VMU and restored on demand at load time. + paths::SetPrefPath("/ram/"); + paths::SetConfigPath("/ram/"); + + return true; +} + +void ShutdownDreamcast() +{ +} + +} // namespace dc +} // namespace devilution + +#endif diff --git a/Source/platform/dreamcast/dc_init.hpp b/Source/platform/dreamcast/dc_init.hpp new file mode 100644 index 000000000..034829ff9 --- /dev/null +++ b/Source/platform/dreamcast/dc_init.hpp @@ -0,0 +1,44 @@ +/** + * @file dc_init.hpp + * @brief Dreamcast-specific initialization API + */ +#pragma once + +#ifdef __DREAMCAST__ + +namespace devilution { +namespace dc { + +/** + * Initialize Dreamcast subsystems (video, VMU, etc.) + * Call from main() before game initialization. + * @return true on success + */ +bool InitDreamcast(); + +/** + * Shutdown Dreamcast subsystems. + * Call before program exit. + */ +void ShutdownDreamcast(); + +/** + * Check if VMU (Visual Memory Unit) is available for saves. + * VMU is the Dreamcast's memory card, accessed via /vmu/[port][unit]/ + * + * @return true if a VMU was detected + */ +bool IsVmuAvailable(); + +/** + * Get the VMU filesystem path (e.g., "/vmu/a1/") + * Only valid if IsVmuAvailable() returns true. + * + * @return Path string to VMU root + */ +const char *GetVmuPath(); + +} // namespace dc +} // namespace devilution + +#endif // __DREAMCAST__ diff --git a/Source/platform/dreamcast/dc_save_codec.cpp b/Source/platform/dreamcast/dc_save_codec.cpp new file mode 100644 index 000000000..65417afd2 --- /dev/null +++ b/Source/platform/dreamcast/dc_save_codec.cpp @@ -0,0 +1,381 @@ +/** + * @file dc_save_codec.cpp + * @brief Dreamcast save compression with zlib + */ + +#ifdef __DREAMCAST__ + +#include "dc_save_codec.hpp" + +#include "utils/log.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace devilution { +namespace dc { + +namespace { + +constexpr size_t MaxSaveDataSize = 512 * 1024; +constexpr size_t SaveHeaderSize = 16; +constexpr uint8_t SaveFormatVersion = 1; +constexpr char SaveMagic[4] = { 'D', 'X', 'Z', '1' }; + +enum class SaveCodec : uint8_t { + Raw = 0, + Zlib = 1, +}; + +uint32_t ReadU32LE(const std::byte *data) +{ + const auto *bytes = reinterpret_cast(data); + return static_cast(bytes[0]) + | (static_cast(bytes[1]) << 8) + | (static_cast(bytes[2]) << 16) + | (static_cast(bytes[3]) << 24); +} + +void WriteU32LE(std::byte *destination, uint32_t value) +{ + auto *bytes = reinterpret_cast(destination); + bytes[0] = static_cast(value & 0xFF); + bytes[1] = static_cast((value >> 8) & 0xFF); + bytes[2] = static_cast((value >> 16) & 0xFF); + bytes[3] = static_cast((value >> 24) & 0xFF); +} + +bool IsSaveContainer(const std::byte *data, size_t size) +{ + if (size < SaveHeaderSize) + return false; + + const auto *bytes = reinterpret_cast(data); + return bytes[0] == SaveMagic[0] + && bytes[1] == SaveMagic[1] + && bytes[2] == SaveMagic[2] + && bytes[3] == SaveMagic[3] + && static_cast(bytes[4]) == SaveFormatVersion; +} + +bool DecodeZlib( + const std::byte *input, + size_t inputSize, + std::byte *output, + size_t outputCapacity, + size_t &decodedSize) +{ + if (input == nullptr || output == nullptr || outputCapacity == 0) + return false; + if (inputSize > static_cast(std::numeric_limits::max())) + return false; + if (outputCapacity > static_cast(std::numeric_limits::max())) + return false; + + uLongf size = static_cast(outputCapacity); + const int rc = uncompress( + reinterpret_cast(output), + &size, + reinterpret_cast(input), + static_cast(inputSize)); + if (rc != Z_OK) + return false; + if (size != outputCapacity) + return false; + + decodedSize = static_cast(size); + return true; +} + +std::unique_ptr BuildSaveContainer(const std::byte *data, size_t size, size_t &outSize) +{ + outSize = 0; + if (data == nullptr || size == 0 || size > MaxSaveDataSize) + return nullptr; + if (size > static_cast(std::numeric_limits::max())) + return nullptr; + + SaveCodec codec = SaveCodec::Raw; + size_t payloadSize = size; + std::unique_ptr compressed; + uLongf compressedSize = compressBound(static_cast(size)); + + if (compressedSize > 0) { + compressed = std::make_unique(compressedSize); + const int rc = compress2( + reinterpret_cast(compressed.get()), + &compressedSize, + reinterpret_cast(data), + static_cast(size), + Z_BEST_SPEED); + if (rc == Z_OK && compressedSize < size) { + codec = SaveCodec::Zlib; + payloadSize = static_cast(compressedSize); + } + } + + if (payloadSize > std::numeric_limits::max()) + return nullptr; + if (size > std::numeric_limits::max()) + return nullptr; + + const size_t containerSize = SaveHeaderSize + payloadSize; + auto container = std::make_unique(containerSize); + + auto *bytes = reinterpret_cast(container.get()); + bytes[0] = SaveMagic[0]; + bytes[1] = SaveMagic[1]; + bytes[2] = SaveMagic[2]; + bytes[3] = SaveMagic[3]; + bytes[4] = static_cast(SaveFormatVersion); + bytes[5] = static_cast(codec); + bytes[6] = 0; + bytes[7] = 0; + WriteU32LE(container.get() + 8, static_cast(payloadSize)); + WriteU32LE(container.get() + 12, static_cast(size)); + + if (codec == SaveCodec::Zlib) { + std::memcpy(container.get() + SaveHeaderSize, compressed.get(), payloadSize); + LogVerbose("[DC Save] zlib compressed {} -> {} bytes ({:.1f}%)", + size, payloadSize, 100.0f * payloadSize / size); + } else { + std::memcpy(container.get() + SaveHeaderSize, data, payloadSize); + LogVerbose("[DC Save] Stored {} bytes as raw payload", size); + } + + outSize = containerSize; + return container; +} + +std::unique_ptr DecodeSaveContainer( + const std::byte *data, + size_t size, + size_t &outSize, + const char *sourceTag) +{ + outSize = 0; + if (!IsSaveContainer(data, size)) { + LogError("[DC Save] Invalid save container format in {}", sourceTag); + return nullptr; + } + + const uint8_t codec = static_cast(reinterpret_cast(data)[5]); + const uint32_t payloadSize = ReadU32LE(data + 8); + const uint32_t originalSize = ReadU32LE(data + 12); + + if (originalSize == 0 || originalSize > MaxSaveDataSize) { + LogError("[DC Save] Invalid container original size {} in {}", originalSize, sourceTag); + return nullptr; + } + if (payloadSize > size - SaveHeaderSize) { + LogError("[DC Save] Invalid container payload size {} in {}", payloadSize, sourceTag); + return nullptr; + } + + const std::byte *payload = data + SaveHeaderSize; + auto decoded = std::make_unique(originalSize); + + if (codec == static_cast(SaveCodec::Raw)) { + if (payloadSize < originalSize) { + LogError("[DC Save] Raw payload too small in {}", sourceTag); + return nullptr; + } + std::memcpy(decoded.get(), payload, originalSize); + outSize = originalSize; + return decoded; + } + + if (codec == static_cast(SaveCodec::Zlib)) { + size_t decodedSize = 0; + if (!DecodeZlib(payload, payloadSize, decoded.get(), originalSize, decodedSize)) { + LogError("[DC Save] zlib decode failed for {}", sourceTag); + return nullptr; + } + outSize = decodedSize; + return decoded; + } + + LogError("[DC Save] Unknown container codec {} in {}", codec, sourceTag); + return nullptr; +} + +bool ReadFileBytes(const char *path, std::unique_ptr &buffer, size_t &size) +{ + buffer.reset(); + size = 0; + + FILE *file = std::fopen(path, "rb"); + if (file == nullptr) + return false; + + if (std::fseek(file, 0, SEEK_END) != 0) { + std::fclose(file); + return false; + } + const long fileLen = std::ftell(file); + if (fileLen <= 0) { + std::fclose(file); + return false; + } + if (std::fseek(file, 0, SEEK_SET) != 0) { + std::fclose(file); + return false; + } + + size = static_cast(fileLen); + buffer = std::make_unique(size); + if (std::fread(buffer.get(), size, 1, file) != 1) { + std::fclose(file); + buffer.reset(); + size = 0; + return false; + } + + std::fclose(file); + return true; +} + +} // namespace + +bool WriteCompressedFile(const char *path, const std::byte *data, size_t size) +{ + if (data == nullptr || size == 0 || size > MaxSaveDataSize || size > std::numeric_limits::max()) { + LogError("[DC Save] Refusing to write invalid save payload ({} bytes) to {}", size, path); + return false; + } + + size_t containerSize = 0; + auto container = BuildSaveContainer(data, size, containerSize); + if (!container || containerSize == 0) { + LogError("[DC Save] Failed to create save container for {}", path); + return false; + } + + FILE *file = std::fopen(path, "wb"); + if (file == nullptr) { + LogError("[DC Save] Failed to open {} for writing", path); + return false; + } + + if (std::fwrite(container.get(), containerSize, 1, file) != 1) { + LogError("[DC Save] Failed to write container data to {}", path); + std::fclose(file); + return false; + } + + std::fclose(file); + return true; +} + +std::unique_ptr ReadCompressedFile(const char *path, size_t &outSize) +{ + std::unique_ptr fileBytes; + size_t fileSize = 0; + if (!ReadFileBytes(path, fileBytes, fileSize)) { + outSize = 0; + return nullptr; + } + return DecodeSaveContainer(fileBytes.get(), fileSize, outSize, path); +} + +// Blank 32x32 icon (4bpp = 512 bytes) for VMU file display. +static uint8_t g_blankIcon[512] = { 0 }; + +bool WriteToVmu(const char *vmuPath, const char *filename, + const std::byte *data, size_t size) +{ + if (data == nullptr || size == 0 || size > MaxSaveDataSize) + return false; + + size_t payloadSize = 0; + auto payload = BuildSaveContainer(data, size, payloadSize); + if (!payload || payloadSize == 0) { + LogError("[DC VMU] Failed to create save container for {}", filename); + return false; + } + + vmu_pkg_t pkg; + std::memset(&pkg, 0, sizeof(pkg)); + std::strncpy(pkg.desc_short, "DevilutionX", sizeof(pkg.desc_short) - 1); + std::strncpy(pkg.desc_long, "DevilutionX Save Data", sizeof(pkg.desc_long) - 1); + std::strncpy(pkg.app_id, "DevilutionX", sizeof(pkg.app_id) - 1); + pkg.icon_cnt = 1; + pkg.icon_anim_speed = 0; + pkg.icon_data = g_blankIcon; + pkg.eyecatch_type = VMUPKG_EC_NONE; + + const std::string fullPath = std::string(vmuPath) + filename; + fs_unlink(fullPath.c_str()); + + file_t fd = fs_open(fullPath.c_str(), O_WRONLY); + if (fd == FILEHND_INVALID) { + LogError("[DC VMU] Cannot open {} for writing", fullPath); + return false; + } + + fs_vmu_set_header(fd, &pkg); + const ssize_t written = fs_write(fd, payload.get(), payloadSize); + const int closeRet = fs_close(fd); + + if (written < 0 || written != static_cast(payloadSize)) { + LogError("[DC VMU] Short write to {}: {} of {}", fullPath, written, payloadSize); + return false; + } + if (closeRet < 0) { + LogError("[DC VMU] Close failed for {} (VMU full?)", fullPath); + return false; + } + + LogVerbose("[DC VMU] Saved {} ({} -> {} bytes on VMU)", fullPath, size, payloadSize); + return true; +} + +std::unique_ptr ReadFromVmu(const char *vmuPath, const char *filename, + size_t &outSize) +{ + outSize = 0; + const std::string fullPath = std::string(vmuPath) + filename; + + file_t fd = fs_open(fullPath.c_str(), O_RDONLY); + if (fd == FILEHND_INVALID) + return nullptr; + + size_t total = fs_total(fd); + if (total == static_cast(-1) || total < sizeof(uint32_t)) { + fs_close(fd); + return nullptr; + } + + auto rawBuf = std::make_unique(total); + const ssize_t bytesRead = fs_read(fd, rawBuf.get(), total); + fs_close(fd); + + if (bytesRead < static_cast(sizeof(uint32_t))) + return nullptr; + + return DecodeSaveContainer(rawBuf.get(), static_cast(bytesRead), outSize, fullPath.c_str()); +} + +bool VmuFileExists(const char *vmuPath, const char *filename) +{ + const std::string fullPath = std::string(vmuPath) + filename; + file_t fd = fs_open(fullPath.c_str(), O_RDONLY); + if (fd == FILEHND_INVALID) + return false; + fs_close(fd); + return true; +} + +} // namespace dc +} // namespace devilution + +#endif // __DREAMCAST__ diff --git a/Source/platform/dreamcast/dc_save_codec.hpp b/Source/platform/dreamcast/dc_save_codec.hpp new file mode 100644 index 000000000..9486cf3f6 --- /dev/null +++ b/Source/platform/dreamcast/dc_save_codec.hpp @@ -0,0 +1,31 @@ +/** + * @file dc_save_codec.hpp + * @brief Dreamcast save container helpers + * + * Save reads and writes use the zlib-based container format. + */ + +#pragma once + +#ifdef __DREAMCAST__ + +#include +#include + +namespace devilution { +namespace dc { + +bool WriteCompressedFile(const char *path, const std::byte *data, size_t size); +std::unique_ptr ReadCompressedFile(const char *path, size_t &outSize); + +bool WriteToVmu(const char *vmuPath, const char *filename, + const std::byte *data, size_t size); +std::unique_ptr ReadFromVmu(const char *vmuPath, const char *filename, + size_t &outSize); + +bool VmuFileExists(const char *vmuPath, const char *filename); + +} // namespace dc +} // namespace devilution + +#endif // __DREAMCAST__ diff --git a/Source/platform/dreamcast/dc_video.cpp b/Source/platform/dreamcast/dc_video.cpp new file mode 100644 index 000000000..22bad106a --- /dev/null +++ b/Source/platform/dreamcast/dc_video.cpp @@ -0,0 +1,182 @@ +/** + * @file dc_video.cpp + * @brief Dreamcast video conversion implementation + * + * The "Inner Loop" - this code runs 307,200 times per frame (640x480). + * Every cycle counts! + * + * Optimization techniques used: + * 1. Palette LUT fits in L1 cache (512 bytes for 256 RGB565 entries) + * 2. Process 16 pixels at a time for throughput + */ + +#ifdef __DREAMCAST__ + +#include "dc_video.h" + +#include + +namespace devilution { +namespace dc { + +namespace { + +// RGB565 palette lookup table (256 entries x 2 bytes = 512 bytes) +// Aligned to 32 bytes for cache efficiency +alignas(32) uint16_t palette565[256]; +// 32-bit word lookup tables used by the packed conversion path. +alignas(32) uint32_t palette565FirstWord[256]; +alignas(32) uint32_t palette565SecondWord[256]; + +bool initialized = false; + +inline void UpdatePaletteEntry(int index, uint16_t rgb565) +{ + palette565[index] = rgb565; +#if SDL_BYTEORDER == SDL_LIL_ENDIAN + palette565FirstWord[index] = rgb565; + palette565SecondWord[index] = static_cast(rgb565) << 16; +#else + palette565FirstWord[index] = static_cast(rgb565) << 16; + palette565SecondWord[index] = rgb565; +#endif +} + +/** + * @brief Convert 16 pixels from 8bpp to 16bpp + * + * This is the innermost loop - fully unrolled for speed. + */ +inline void Convert16PixelsScalar(const uint8_t *src, uint16_t *dst) +{ + dst[0] = palette565[src[0]]; + dst[1] = palette565[src[1]]; + dst[2] = palette565[src[2]]; + dst[3] = palette565[src[3]]; + dst[4] = palette565[src[4]]; + dst[5] = palette565[src[5]]; + dst[6] = palette565[src[6]]; + dst[7] = palette565[src[7]]; + dst[8] = palette565[src[8]]; + dst[9] = palette565[src[9]]; + dst[10] = palette565[src[10]]; + dst[11] = palette565[src[11]]; + dst[12] = palette565[src[12]]; + dst[13] = palette565[src[13]]; + dst[14] = palette565[src[14]]; + dst[15] = palette565[src[15]]; +} + +/** + * @brief Convert 16 pixels using packed 32-bit writes (2 pixels per store) + * + * SH4 is efficient at aligned 32-bit loads/stores, so this path halves + * the number of destination stores compared to scalar 16-bit writes. + */ +inline void Convert16PixelsPacked(const uint8_t *src, uint16_t *dst) +{ +#if defined(__SH4__) || defined(__sh__) + // Pull upcoming source bytes into cache early on SH4. + __builtin_prefetch(src + 32, 0, 3); + __builtin_prefetch(src + 64, 0, 3); +#endif + uint32_t *dst32 = reinterpret_cast(dst); + dst32[0] = palette565FirstWord[src[0]] | palette565SecondWord[src[1]]; + dst32[1] = palette565FirstWord[src[2]] | palette565SecondWord[src[3]]; + dst32[2] = palette565FirstWord[src[4]] | palette565SecondWord[src[5]]; + dst32[3] = palette565FirstWord[src[6]] | palette565SecondWord[src[7]]; + dst32[4] = palette565FirstWord[src[8]] | palette565SecondWord[src[9]]; + dst32[5] = palette565FirstWord[src[10]] | palette565SecondWord[src[11]]; + dst32[6] = palette565FirstWord[src[12]] | palette565SecondWord[src[13]]; + dst32[7] = palette565FirstWord[src[14]] | palette565SecondWord[src[15]]; +} + +void ConvertFrame(const uint8_t *src, uint16_t *dst, int width, int height, int srcPitch, int dstPitch) +{ + for (int y = 0; y < height; y++) { + const uint8_t *srcRow = src + y * srcPitch; + uint16_t *dstRow = reinterpret_cast(reinterpret_cast(dst) + y * dstPitch); + const bool canUsePackedPath = (reinterpret_cast(dstRow) & (alignof(uint32_t) - 1)) == 0; + + int x = 0; + if (canUsePackedPath) { + for (; x + 16 <= width; x += 16) { + Convert16PixelsPacked(srcRow + x, dstRow + x); + } + } else { + for (; x + 16 <= width; x += 16) { + Convert16PixelsScalar(srcRow + x, dstRow + x); + } + } + + for (; x < width; x++) { + dstRow[x] = palette565[srcRow[x]]; + } + } +} + +} // anonymous namespace + +bool VideoInit([[maybe_unused]] int width, [[maybe_unused]] int height) +{ + for (int i = 0; i < 256; i++) { + UpdatePaletteEntry(i, RGB888toRGB565(i, i, i)); + } + + initialized = true; + return true; +} + +void VideoShutdown() +{ + initialized = false; +} + +void UpdatePalette(const SDL_Palette *palette) +{ + if (!palette || !palette->colors) + return; + + UpdatePaletteRange(palette->colors, 0, palette->ncolors); +} + +void UpdatePaletteRange(const SDL_Color *colors, int firstColor, int nColors) +{ + if (!colors) + return; + + if (firstColor + nColors > 256) + nColors = 256 - firstColor; + + for (int i = 0; i < nColors; i++) { + const SDL_Color &c = colors[i]; + UpdatePaletteEntry(firstColor + i, RGB888toRGB565(c.r, c.g, c.b)); + } +} + +void ConvertAndUpload(const SDL_Surface *src, SDL_Surface *dst) +{ + if (!initialized || !src || !dst) + return; + + const uint8_t *srcPixels = static_cast(src->pixels); + uint16_t *dstPixels = static_cast(dst->pixels); + + if (!srcPixels || !dstPixels) + return; + + const int width = src->w < dst->w ? src->w : dst->w; + const int height = src->h < dst->h ? src->h : dst->h; + + ConvertFrame(srcPixels, dstPixels, width, height, src->pitch, dst->pitch); +} + +bool IsInitialized() +{ + return initialized; +} + +} // namespace dc +} // namespace devilution + +#endif // __DREAMCAST__ diff --git a/Source/platform/dreamcast/dc_video.h b/Source/platform/dreamcast/dc_video.h new file mode 100644 index 000000000..394165a2f --- /dev/null +++ b/Source/platform/dreamcast/dc_video.h @@ -0,0 +1,91 @@ +/** + * @file dc_video.h + * @brief Dreamcast-specific video conversion layer + * + * DevilutionX renders to an 8bpp paletted surface. Dreamcast SDL only + * supports 16bpp (RGB565). This module provides: + * + * 1. Palette management: Converts 8-bit RGB palette entries to RGB565 LUT + * 2. Frame conversion: Expands 8bpp pixels to 16bpp using the LUT + * + * Performance target: <5ms per frame at 640x480 + */ + +#ifndef DEVILUTIONX_DC_VIDEO_H +#define DEVILUTIONX_DC_VIDEO_H + +#ifdef __DREAMCAST__ + +#include +#include + +namespace devilution { +namespace dc { + +/** + * @brief Initialize the Dreamcast video conversion layer + * @param width Screen width (typically 640) + * @param height Screen height (typically 480) + * @return true on success + */ +bool VideoInit(int width, int height); + +/** + * @brief Shutdown and free video resources + */ +void VideoShutdown(); + +/** + * @brief Update the RGB565 palette lookup table + * + * Called when the game changes its palette. Converts each entry + * from RGB888 to RGB565 format. + * + * @param palette SDL palette with 256 RGB entries + */ +void UpdatePalette(const SDL_Palette *palette); + +/** + * @brief Update a range of palette entries + * @param colors Pointer to SDL_Color array + * @param firstColor Starting index (0-255) + * @param nColors Number of colors to update + */ +void UpdatePaletteRange(const SDL_Color *colors, int firstColor, int nColors); + +/** + * @brief Convert an 8bpp frame to 16bpp + * + * This is the hot path - called every frame. + * + * @param src 8bpp source surface (PalSurface) + * @param dst 16bpp destination surface (video framebuffer) + */ +void ConvertAndUpload(const SDL_Surface *src, SDL_Surface *dst); + +/** + * @brief Convert RGB888 to RGB565 + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @return Packed RGB565 value + */ +inline uint16_t RGB888toRGB565(uint8_t r, uint8_t g, uint8_t b) +{ + // RGB565: RRRRRGGGGGGBBBBB + return static_cast( + ((r & 0xF8) << 8) | // 5 bits red + ((g & 0xFC) << 3) | // 6 bits green + ((b & 0xF8) >> 3)); // 5 bits blue +} + +/** + * @brief Check if DC video layer is active + */ +bool IsInitialized(); + +} // namespace dc +} // namespace devilution + +#endif // __DREAMCAST__ +#endif // DEVILUTIONX_DC_VIDEO_H diff --git a/Source/platform/dreamcast/posix_stubs.c b/Source/platform/dreamcast/posix_stubs.c new file mode 100644 index 000000000..25ea9448b --- /dev/null +++ b/Source/platform/dreamcast/posix_stubs.c @@ -0,0 +1,34 @@ +/** + * @file posix_stubs.c + * @brief POSIX function stubs for KOS/Dreamcast + * + * KOS's newlib is missing some POSIX functions that external libraries expect. + * This file provides stub implementations. + */ + +#ifdef __DREAMCAST__ + +#include +#include + +// POSIX Thread Safety Stubs +// KOS doesn't implement these, but libfmt uses them. +// Since we are effectively single-threaded for I/O, empty stubs are safe. + +/** + * @brief Lock a stdio stream (stub - KOS is single-threaded by default) + */ +void flockfile(FILE *file) +{ + (void)file; +} + +/** + * @brief Unlock a stdio stream (stub - KOS is single-threaded by default) + */ +void funlockfile(FILE *file) +{ + (void)file; +} + +#endif /* __DREAMCAST__ */ diff --git a/Source/player.cpp b/Source/player.cpp index 1b6f14096..a681f694d 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -2188,12 +2188,24 @@ void InitPlayerGFX(Player &player) return; } +#ifdef __DREAMCAST__ + // On Dreamcast's limited 16MB RAM, only load essential animations. + // Other animations (spells, death, block) are loaded on-demand via LoadPlrGFX. + LoadPlrGFX(player, player_graphic::Stand); + LoadPlrGFX(player, player_graphic::Walk); + // In dungeons, also preload attack since it's immediately needed + if (leveltype != DTYPE_TOWN) { + LoadPlrGFX(player, player_graphic::Attack); + LoadPlrGFX(player, player_graphic::Hit); + } +#else for (size_t i = 0; i < enum_size::value; i++) { auto graphic = static_cast(i); if (graphic == player_graphic::Death) continue; LoadPlrGFX(player, graphic); } +#endif } void ResetPlayerGFX(Player &player) diff --git a/Source/qol/stash.cpp b/Source/qol/stash.cpp index 2f7d427ff..8fd3f0d7b 100644 --- a/Source/qol/stash.cpp +++ b/Source/qol/stash.cpp @@ -28,6 +28,7 @@ #include "minitext.h" #include "stores.h" #include "utils/display.h" +#include "utils/log.hpp" #include "utils/format_int.hpp" #include "utils/language.h" #include "utils/sdl_compat.h" @@ -281,6 +282,11 @@ void FreeStashGFX() void InitStash() { +#ifdef __DREAMCAST__ + // Skip stash UI loading on Dreamcast to save memory. + LogVerbose("Stash UI disabled on Dreamcast to conserve RAM"); + return; +#endif if (!HeadlessMode) { StashPanelArt = LoadClx("data\\stash.clx"); StashNavButtonArt = LoadClx("data\\stashnavbtns.clx"); diff --git a/Source/restrict.cpp b/Source/restrict.cpp index adf60aec9..b923bc4ed 100644 --- a/Source/restrict.cpp +++ b/Source/restrict.cpp @@ -22,6 +22,13 @@ namespace devilution { void ReadOnlyTest() { +#ifdef __DREAMCAST__ + // On Dreamcast, VMU filesystem has been verified in InitDreamcast(). + // SDL_IOFromFile doesn't work reliably with KOS's /vmu/ paths, + // but direct file operations do work (as shown by "VMUFS: file written"). + // Skip this test - saves will fail gracefully if VMU is unavailable. + return; +#endif const std::string path = paths::PrefPath() + "Diablo1ReadOnlyTest.foo"; SDL_IOStream *file = SDL_IOFromFile(path.c_str(), "w"); if (file == nullptr) { diff --git a/Source/towners.cpp b/Source/towners.cpp index 3c8a28d3b..0fcbe9995 100644 --- a/Source/towners.cpp +++ b/Source/towners.cpp @@ -759,7 +759,9 @@ void InitTowners() TownerLongNames.try_emplace(entry.type, entry.name); } +#ifndef __DREAMCAST__ CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128)); +#endif Towners.clear(); Towners.reserve(TownersDataEntries.size()); @@ -767,6 +769,11 @@ void InitTowners() for (const auto &entry : TownersDataEntries) { if (!IsTownerPresent(entry.type)) continue; +#ifdef __DREAMCAST__ + // Cow sprites not loaded on Dreamcast to save memory + if (entry.type == TOWN_COW) + continue; +#endif auto behaviorIt = TownerBehaviors.find(entry.type); if (behaviorIt == TownerBehaviors.end() || behaviorIt->second == nullptr) diff --git a/Source/utils/display.cpp b/Source/utils/display.cpp index 80d0aad11..85744e8c6 100644 --- a/Source/utils/display.cpp +++ b/Source/utils/display.cpp @@ -63,6 +63,10 @@ #endif #endif +#ifdef __DREAMCAST__ +#include +#endif + namespace devilution { extern SDLSurfaceUniquePtr RendererTextureSurface; /** defined in dx.cpp */ @@ -485,6 +489,13 @@ void SetVideoModeToPrimary(bool fullscreen, int width, int height) #ifdef __3DS__ flags &= ~SDL_FULLSCREEN; flags |= Get3DSScalingFlag(*GetOptions().Graphics.fitToScreen, width, height); +#endif +#ifdef __DREAMCAST__ + SDL_DC_SetVideoDriver(SDL_DC_DMA_VIDEO); + SDL_DC_VerticalWait(SDL_FALSE); + SDL_DC_ShowAskHz(SDL_FALSE); + SDL_DC_EmulateKeyboard(SDL_FALSE); + SDL_DC_EmulateMouse(SDL_FALSE); #endif SetVideoMode(width, height, SDL1_VIDEO_MODE_BPP, flags); if (OutputRequiresScaling()) diff --git a/Source/utils/file_util.cpp b/Source/utils/file_util.cpp index 9a5ac9141..ed7038fb1 100644 --- a/Source/utils/file_util.cpp +++ b/Source/utils/file_util.cpp @@ -34,7 +34,7 @@ #endif #endif -#if _POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__) || defined(__DJGPP__) +#if (_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__) || defined(__DJGPP__)) && !defined(__DREAMCAST__) #define DVL_HAS_POSIX_2001 #endif @@ -165,9 +165,15 @@ bool DirectoryExists(const char *path) #elif defined(DVL_HAS_FILESYSTEM) std::error_code error; return std::filesystem::is_directory(reinterpret_cast(path), error); -#elif (_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) && !defined(__ANDROID__) +#elif ((_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) && !defined(__ANDROID__) && !defined(__DREAMCAST__)) struct ::stat statResult; return ::stat(path, &statResult) == 0 && S_ISDIR(statResult.st_mode); +#elif defined(__DREAMCAST__) + // Dreamcast VMU uses a flat file structure - no real directories. + // KOS filesystem paths like /cd/ and /ram/ are mount points, not user directories. + // Always return false since directory checks aren't meaningful on this platform. + (void)path; + return false; #endif } @@ -240,7 +246,14 @@ bool GetFileSize(const char *path, std::uintmax_t *size) bool CreateDir(const char *path) { -#ifdef DVL_HAS_FILESYSTEM +#ifdef __DREAMCAST__ + // Dreamcast VMU filesystem doesn't support directories. + // VMU uses a flat file structure: /vmu/a1/filename + // We pretend directory creation succeeded - saves will use flat file names. + // This is similar to how dca3-game (GTA III port) handles VMU saves. + (void)path; + return true; +#elif defined(DVL_HAS_FILESYSTEM) std::error_code error; std::filesystem::create_directory(reinterpret_cast(path), error); if (error) { @@ -281,7 +294,11 @@ bool CreateDir(const char *path) void RecursivelyCreateDir(const char *path) { -#ifdef DVL_HAS_FILESYSTEM +#ifdef __DREAMCAST__ + // Dreamcast VMU uses flat file structure - no directories needed. + // All save files are written directly to /vmu/a1/ with flat names. + (void)path; +#elif defined(DVL_HAS_FILESYSTEM) std::error_code error; std::filesystem::create_directories(reinterpret_cast(path), error); if (error) { @@ -361,6 +378,11 @@ bool ResizeFile(const char *path, std::uintmax_t size) return true; #elif defined(DVL_HAS_POSIX_2001) return ::truncate(path, static_cast(size)) == 0; +#elif defined(__DREAMCAST__) + // KOS doesn't have truncate - return failure + // MPQ writer will handle this gracefully + LogError("ResizeFile: truncate not implemented for Dreamcast"); + return false; #else static_assert(false, "truncate not implemented for the current platform"); #endif @@ -508,7 +530,7 @@ std::vector ListDirectories(const char *path) dirs.push_back(folder); } while (FindNextFileA(hFind, &findData)); FindClose(hFind); -#elif (_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) +#elif ((_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) && !defined(__DREAMCAST__)) DIR *d = ::opendir(path); if (d != nullptr) { struct dirent *dir; @@ -520,6 +542,9 @@ std::vector ListDirectories(const char *path) } ::closedir(d); } +#elif defined(__DREAMCAST__) + // KOS doesn't have opendir/readdir - return empty list + (void)path; #else static_assert(false, "ListDirectories not implemented for the current platform"); #endif @@ -553,7 +578,7 @@ std::vector ListFiles(const char *path) files.push_back(file); } while (FindNextFileA(hFind, &findData)); FindClose(hFind); -#elif (_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) +#elif ((_POSIX_C_SOURCE >= 200112L || defined(_BSD_SOURCE) || defined(__APPLE__)) && !defined(__DREAMCAST__)) DIR *d = ::opendir(path); if (d != nullptr) { struct dirent *dir; @@ -565,6 +590,10 @@ std::vector ListFiles(const char *path) } ::closedir(d); } +#elif defined(__DREAMCAST__) + // KOS doesn't have opendir/readdir - return empty list + // Note: KOS has fs_dir functions but they use different types + (void)path; #else static_assert(false, "ListFiles not implemented for the current platform"); #endif diff --git a/Source/utils/paths.cpp b/Source/utils/paths.cpp index aec13ebcb..b08138867 100644 --- a/Source/utils/paths.cpp +++ b/Source/utils/paths.cpp @@ -152,6 +152,11 @@ const std::string &AssetsPath() assetsPath.emplace("D:\\assets\\"); #elif defined(__3DS__) || defined(__SWITCH__) assetsPath.emplace("romfs:/"); +#elif defined(__DREAMCAST__) + // On Dreamcast, assets are on the CD root when disc is mounted. + // For direct ELF loading (no disc), use current directory. + // With UNPACKED_MPQS, we look for directories like devilutionx/, diabdat/, etc. + assetsPath.emplace(BasePath()); #elif defined(__APPLE__) && defined(USE_SDL1) // In `Info.plist` we have // diff --git a/Source/utils/sdl2_to_1_2_backports.cpp b/Source/utils/sdl2_to_1_2_backports.cpp index c3025d64b..36ff25ed7 100644 --- a/Source/utils/sdl2_to_1_2_backports.cpp +++ b/Source/utils/sdl2_to_1_2_backports.cpp @@ -5,6 +5,10 @@ #include #include +#ifdef __DREAMCAST__ +#include +#endif + #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #define NOMINMAX 1 @@ -800,6 +804,19 @@ extern "C" char *SDL_GetBasePath() retval = SDL_strdup("file:sdmc:/3ds/devilutionx/"); #elif defined(__amigaos__) retval = SDL_strdup("PROGDIR:"); +#elif defined(__DREAMCAST__) + /* Dreamcast: base path is the CD root when disc is mounted, + otherwise use current directory (for direct ELF loading in emulator) */ + { + file_t fd = fs_open("/cd/", O_DIR | O_RDONLY); + if (fd != FILEHND_INVALID) { + fs_close(fd); + retval = SDL_strdup("/cd/"); + } else { + /* No disc mounted - use current directory (Flycast ELF loading) */ + retval = SDL_strdup("./"); + } + } #else /* is a Linux-style /proc filesystem available? */ @@ -879,6 +896,23 @@ extern "C" char *SDL_GetPrefPath(const char *org, const char *app) #elif defined(__amigaos__) retval = SDL_strdup("PROGDIR:"); return retval; +#elif defined(__DREAMCAST__) + /* Dreamcast: prefer VMU (/vmu/a1/) for persistent saves. + * InitDreamcast() may later override this path depending on VMU availability. + * If no VMU is present yet, fall back to /ram/ so runtime save operations + * can still work during the current session. + */ + { + /* Check for VMU at first port */ + file_t fd = fs_open("/vmu/a1/", O_DIR | O_RDONLY); + if (fd != FILEHND_INVALID) { + fs_close(fd); + retval = SDL_strdup("/vmu/a1/"); + } else { + retval = SDL_strdup("/ram/"); + } + } + return retval; #endif if (!app) { diff --git a/Source/utils/stdcompat/filesystem.hpp b/Source/utils/stdcompat/filesystem.hpp index 38c2f7d0b..d5304f204 100644 --- a/Source/utils/stdcompat/filesystem.hpp +++ b/Source/utils/stdcompat/filesystem.hpp @@ -6,7 +6,7 @@ || (defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && __IPHONE_OS_VERSION_MIN_REQUIRED < 130000) #define DVL_NO_FILESYSTEM #endif -#elif defined(NXDK) || (defined(_MSVC_LANG) && _MSVC_LANG < 201703L) \ +#elif defined(NXDK) || defined(__DREAMCAST__) || (defined(_MSVC_LANG) && _MSVC_LANG < 201703L) \ || (defined(WINVER) && WINVER <= 0x0500 && (!defined(_WIN32_WINNT) || _WIN32_WINNT == 0)) #define DVL_NO_FILESYSTEM #endif diff --git a/docs/building.md b/docs/building.md index c7aa9b81b..5bc7ca8d3 100644 --- a/docs/building.md +++ b/docs/building.md @@ -410,6 +410,35 @@ cmake --build build [PlayStation Vita manual](/docs/manual/platforms/vita.md) + +
Dreamcast + +### Installing dependencies + +- [KallistiOS](https://kos-docs.dreamcast.wiki/) (KOS) with kos-ports (zlib, bzip2) +- [`mkdcdisc`](https://gitlab.com/simulant/mkdcdisc) for CDI disc image creation + +SDL (GPF SDL with DMA video) and Lua are built from source automatically. +No external `fmt` patch is required. The build applies a bundled SH4 fix automatically. + +### Game data + +Copy `DIABDAT.MPQ` to `Packaging/dreamcast/cd_root/` before building. +If needed, see [Extracting MPQs from the GoG installer](https://github.com/diasurgical/devilutionX/wiki/Extracting-MPQs-from-the-GoG-installer). + +### Compiling + +```bash +./Packaging/dreamcast/build.sh +``` + +This builds the ELF, strips it, and packages a bootable CDI at `Packaging/dreamcast/devilutionx-playable.cdi`. + +Override tool paths with environment variables if needed: `KOS_BASE`, `KOS_ENV`, `MKDCDISC`. + +[Dreamcast manual](/docs/manual/platforms/dreamcast.md) +
+
Haiku diff --git a/docs/installing.md b/docs/installing.md index 9bf2144e1..c44b2f6a0 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -175,6 +175,17 @@ If you'd like to use this option, scan the QR code below.
+
Dreamcast + +- Build from source and package as a CDI image. +- Copy MPQ files to `Packaging/dreamcast/cd_root/` before building: + - Required: `DIABDAT.MPQ` + - Optional: `spawn.mpq` +- Follow [Dreamcast build instructions](building.md#dreamcast). +- See [Dreamcast packaging readme](../Packaging/dreamcast/README.md). + +
+
ClockworkPi GameShell - Copy the `__init__.py` to a newly created folder under /home/cpi/apps/Menu and run it from the menu. The folder then symbolizes the devilutionX icon. diff --git a/docs/manual/platforms/dreamcast.md b/docs/manual/platforms/dreamcast.md new file mode 100644 index 000000000..7ca860fd7 --- /dev/null +++ b/docs/manual/platforms/dreamcast.md @@ -0,0 +1,22 @@ +# devilutionX Dreamcast port + +## How To Play: + +- Build a CDI image using `Packaging/dreamcast/build.sh`. +- Copy `DIABDAT.MPQ` from your CD or GoG installation to `Packaging/dreamcast/cd_root/` before building (or [extract it from the GoG installer](https://github.com/diasurgical/devilutionX/wiki/Extracting-MPQs-from-the-GoG-installer)). +- `spawn.mpq` is optional for shareware mode. +- Use your own data files. Diablo MPQs are proprietary Blizzard assets. +- Boot the resulting CDI on hardware or in an emulator such as Flycast. + +## Building from Source + +See [building instructions](../../building.md#dreamcast). + +## Controls + +- D-pad or analog stick: move hero +- A: primary action (attack, interact, confirm) +- B: secondary action, back +- X: use item +- Y: cancel, open speedbook +- Start: menu