Browse Source

Merge 87711d4188 into 5a08031caf

pull/8489/merge
Panagiotis Georgiadis 7 days ago committed by GitHub
parent
commit
ea3185809e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      3rdParty/Lua/CMakeLists.txt
  2. 21
      3rdParty/libfmt/CMakeLists.txt
  3. 31
      3rdParty/libfmt/fmt-12.0.0-sh4-long-double.patch
  4. 4
      CMake/Platforms.cmake
  5. 3
      CMake/functions/devilutionx_library.cmake
  6. 79
      CMake/platforms/dreamcast.cmake
  7. 67
      CMake/platforms/dreamcast.toolchain.cmake
  8. 50
      Packaging/dreamcast/.gitignore
  9. 39
      Packaging/dreamcast/README.md
  10. 82
      Packaging/dreamcast/build.sh
  11. 36
      Source/DiabloUI/diabloui.cpp
  12. 4
      Source/control/control_flasks.cpp
  13. 5
      Source/control/control_panel.cpp
  14. 7
      Source/diablo.cpp
  15. 180
      Source/effects.cpp
  16. 8
      Source/engine/assets.cpp
  17. 22
      Source/engine/dx.cpp
  18. 7
      Source/engine/palette.cpp
  19. 7
      Source/engine/render/blit_impl.hpp
  20. 9
      Source/engine/sound.cpp
  21. 27
      Source/interfac.cpp
  22. 27
      Source/levels/reencode_dun_cels.cpp
  23. 11
      Source/main.cpp
  24. 5
      Source/movie.cpp
  25. 8
      Source/options.cpp
  26. 241
      Source/pfile.cpp
  27. 5
      Source/pfile.h
  28. 13
      Source/platform/dreamcast/CMakeLists.txt
  29. 59
      Source/platform/dreamcast/dc_init.cpp
  30. 44
      Source/platform/dreamcast/dc_init.hpp
  31. 381
      Source/platform/dreamcast/dc_save_codec.cpp
  32. 31
      Source/platform/dreamcast/dc_save_codec.hpp
  33. 182
      Source/platform/dreamcast/dc_video.cpp
  34. 91
      Source/platform/dreamcast/dc_video.h
  35. 34
      Source/platform/dreamcast/posix_stubs.c
  36. 12
      Source/player.cpp
  37. 6
      Source/qol/stash.cpp
  38. 7
      Source/restrict.cpp
  39. 7
      Source/towners.cpp
  40. 11
      Source/utils/display.cpp
  41. 41
      Source/utils/file_util.cpp
  42. 5
      Source/utils/paths.cpp
  43. 34
      Source/utils/sdl2_to_1_2_backports.cpp
  44. 2
      Source/utils/stdcompat/filesystem.hpp
  45. 29
      docs/building.md
  46. 11
      docs/installing.md
  47. 22
      docs/manual/platforms/dreamcast.md

2
3rdParty/Lua/CMakeLists.txt vendored

@ -30,7 +30,7 @@ elseif(TARGET_PLATFORM STREQUAL "dos")
target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89) target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89)
elseif(ANDROID AND ("${ANDROID_ABI}" STREQUAL "armeabi-v7a" OR "${ANDROID_ABI}" STREQUAL "x86")) elseif(ANDROID AND ("${ANDROID_ABI}" STREQUAL "armeabi-v7a" OR "${ANDROID_ABI}" STREQUAL "x86"))
target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89) 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) target_compile_definitions(lua_static PUBLIC -DLUA_USE_C89)
elseif(IOS) elseif(IOS)
target_compile_definitions(lua_static PUBLIC -DLUA_USE_IOS) target_compile_definitions(lua_static PUBLIC -DLUA_USE_IOS)

21
3rdParty/libfmt/CMakeLists.txt vendored

@ -15,10 +15,23 @@ else()
set(BUILD_SHARED_LIBS ON) set(BUILD_SHARED_LIBS ON)
endif() endif()
include(FetchContent) include(FetchContent)
FetchContent_Declare_ExcludeFromAll(libfmt if(DREAMCAST)
URL https://github.com/fmtlib/fmt/releases/download/12.0.0/fmt-12.0.0.zip set(DREAMCAST_FMT_PATCH "${CMAKE_CURRENT_LIST_DIR}/fmt-12.0.0-sh4-long-double.patch")
URL_HASH SHA256=1c32293203449792bf8e94c7f6699c643887e826f2d66a80869b4f279fb07d25 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) FetchContent_MakeAvailable_ExcludeFromAll(libfmt)
# We do not use locale-specific features of libfmt and disabling them reduces the size. # We do not use locale-specific features of libfmt and disabling them reduces the size.

31
3rdParty/libfmt/fmt-12.0.0-sh4-long-double.patch vendored

@ -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<long double>::digits == 53, which doesn't match
fmt's expected values of 64 or 113 for extended precision types.
This causes the float_info<long double> template to be incomplete,
resulting in compilation errors:
error: invalid use of incomplete type 'struct float_info<long double>'
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<double> {
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<long double> : float_info<double> {};
+#endif
// An 80- or 128-bit floating point number.
template <typename T>

4
CMake/Platforms.cmake

@ -51,6 +51,10 @@ if(NINTENDO_3DS)
include(platforms/n3ds) include(platforms/n3ds)
endif() endif()
if(DREAMCAST)
include(platforms/dreamcast)
endif()
if(VITA) if(VITA)
include("$ENV{VITASDK}/share/vita.cmake" REQUIRED) include("$ENV{VITASDK}/share/vita.cmake" REQUIRED)
include(platforms/vita) include(platforms/vita)

3
CMake/functions/devilutionx_library.cmake

@ -46,8 +46,9 @@ function(add_devilutionx_library NAME)
target_compile_options(${NAME} PUBLIC -Wall -Wextra -Wno-unused-parameter) target_compile_options(${NAME} PUBLIC -Wall -Wextra -Wno-unused-parameter)
endif() 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`. # 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) add_definitions(-D_POSIX_C_SOURCE=200809L)
endif() endif()

79
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()

67
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 <LINK_FLAGS>
# 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
"<CMAKE_CXX_COMPILER> <FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> -Wl,--start-group <LINK_LIBRARIES> -lkallisti -lc -lgcc -lm -lstdc++ -Wl,--end-group")
set(CMAKE_C_LINK_EXECUTABLE
"<CMAKE_C_COMPILER> <FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> -Wl,--start-group <LINK_LIBRARIES> -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)

50
Packaging/dreamcast/.gitignore vendored

@ -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__/

39
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`

82
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"

36
Source/DiabloUI/diabloui.cpp

@ -10,6 +10,10 @@
#include <string_view> #include <string_view>
#include <vector> #include <vector>
#ifdef __DREAMCAST__
#include <unordered_map>
#endif
#ifdef USE_SDL3 #ifdef USE_SDL3
#include <SDL3/SDL_error.h> #include <SDL3/SDL_error.h>
#include <SDL3/SDL_events.h> #include <SDL3/SDL_events.h>
@ -89,6 +93,16 @@ OptionalOwnedClxSpriteList ArtCursor;
std::size_t SelectedItem = 0; std::size_t SelectedItem = 0;
#ifdef __DREAMCAST__
namespace {
struct CachedBackground {
OwnedClxSpriteList sprites;
std::array<SDL_Color, 256> palette;
};
std::unordered_map<std::string, CachedBackground> bgArtCache;
} // namespace
#endif
namespace { namespace {
OptionalOwnedClxSpriteList ArtHero; OptionalOwnedClxSpriteList ArtHero;
@ -691,6 +705,10 @@ void UiInitialize()
void UiDestroy() void UiDestroy()
{ {
UnloadFonts(); UnloadFonts();
#ifdef __DREAMCAST__
bgArtCache.clear();
bgArtCache.rehash(0);
#endif
UnloadUiGFX(); UnloadUiGFX();
} }
@ -762,10 +780,28 @@ bool UiLoadBlackBackground()
void LoadBackgroundArt(const char *pszFile, int frames) void LoadBackgroundArt(const char *pszFile, int frames)
{ {
ArtBackground = std::nullopt; 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<uint16_t>(frames), /*transparentColor=*/std::nullopt, logical_palette.data()); ArtBackground = LoadPcxSpriteList(pszFile, static_cast<uint16_t>(frames), /*transparentColor=*/std::nullopt, logical_palette.data());
if (!ArtBackground) if (!ArtBackground)
return; return;
#ifdef __DREAMCAST__
bgArtCache.emplace(std::move(key), CachedBackground { ArtBackground->clone(), logical_palette });
#endif
UpdateSystemPalette(logical_palette); UpdateSystemPalette(logical_palette);
UiOnBackgroundChange(); UiOnBackgroundChange();
} }

4
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 }); GetMainPanel().position + Displacement { offset, -rect.size.height });
// Draw the filled part of the flask over the empty part // Draw the filled part of the flask over the empty part
if (filledRows > 0) { if (filledRows > 0 && BottomBuffer) {
DrawFlaskAbovePanel(out, DrawFlaskAbovePanel(out,
BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows), BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows),
GetMainPanel().position + Displacement { offset, -rect.size.height + emptyRows }); 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 // Draw the filled part of the flask
if (drawFilledPortion && filledRows > 0) { if (drawFilledPortion && filledRows > 0 && BottomBuffer) {
DrawFlaskOnPanel(out, DrawFlaskOnPanel(out,
BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows), BottomBuffer->subregion(offset, rect.position.y + emptyRows, rect.size.width, filledRows),
GetMainPanel().position + Displacement { offset, emptyRows }); GetMainPanel().position + Displacement { offset, emptyRows });

5
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) void DrawPanelBox(const Surface &out, SDL_Rect srcRect, Point targetPosition)
{ {
if (!BottomBuffer)
return;
out.BlitFrom(*BottomBuffer, srcRect, targetPosition); out.BlitFrom(*BottomBuffer, srcRect, targetPosition);
} }
@ -434,6 +436,9 @@ void DrawMainPanel(const Surface &out)
void DrawMainPanelButtons(const Surface &out) void DrawMainPanelButtons(const Surface &out)
{ {
if (!BottomBuffer)
return;
const Point mainPanelPosition = GetMainPanel().position; const Point mainPanelPosition = GetMainPanel().position;
for (int i = 0; i < TotalSpMainPanelButtons; i++) { for (int i = 0; i < TotalSpMainPanelButtons; i++) {

7
Source/diablo.cpp

@ -883,7 +883,6 @@ void RunGameLoop(interface_mode uMsg)
#endif #endif
while (gbRunGame) { while (gbRunGame) {
#ifdef _DEBUG #ifdef _DEBUG
if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) {
InitConsole(); InitConsole();
@ -1304,6 +1303,12 @@ void DiabloSplash()
if (!gbShowIntro) if (!gbShowIntro)
return; return;
#ifdef __DREAMCAST__
// Skip movies on Dreamcast (they render with wrong palette)
UiTitleDialog();
return;
#endif
if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog)
play_movie("gendata\\logo.smk", true); play_movie("gendata\\logo.smk", true);

180
Source/effects.cpp

@ -5,11 +5,17 @@
*/ */
#include "effects.h" #include "effects.h"
#include <array>
#include <cstdint> #include <cstdint>
#include <string_view> #include <string_view>
#include <expected.hpp> #include <expected.hpp>
#include <magic_enum/magic_enum.hpp> #include <magic_enum/magic_enum.hpp>
#ifdef USE_SDL3
#include <SDL3/SDL_timer.h>
#else
#include <SDL.h>
#endif
#include "data/file.hpp" #include "data/file.hpp"
#include "data/iterators.hpp" #include "data/iterators.hpp"
@ -42,6 +48,109 @@ TSFX *sgpStreamSFX = nullptr;
/** List of all sounds, except monsters and music */ /** List of all sounds, except monsters and music */
std::vector<TSFX> sgSFX; std::vector<TSFX> 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<uint32_t, static_cast<size_t>(SfxID::LAST) + 1> SfxLoadRetryAfterMs {};
uint32_t NextDreamcastRealtimeLoadAtMs = 0;
size_t GetSfxIndex(const TSFX *sfx)
{
return static_cast<size_t>(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<size_t>(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) void StreamPlay(TSFX *pSFX, int lVolume, int lPan)
{ {
assert(pSFX); assert(pSFX);
@ -51,10 +160,24 @@ void StreamPlay(TSFX *pSFX, int lVolume, int lPan)
if (lVolume >= VOLUME_MIN) { if (lVolume >= VOLUME_MIN) {
if (lVolume > VOLUME_MAX) if (lVolume > VOLUME_MAX)
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) if (pSFX->pSnd == nullptr)
pSFX->pSnd = sound_file_load(pSFX->pszName.c_str(), AllowStreaming); pSFX->pSnd = sound_file_load(pSFX->pszName.c_str(), AllowStreaming);
if (pSFX->pSnd->DSB.IsLoaded()) if (pSFX->pSnd->DSB.IsLoaded())
pSFX->pSnd->DSB.PlayWithVolumeAndPan(lVolume, sound_get_or_set_sound_volume(1), lPan); pSFX->pSnd->DSB.PlayWithVolumeAndPan(lVolume, sound_get_or_set_sound_volume(1), lPan);
#endif
sgpStreamSFX = pSFX; sgpStreamSFX = pSFX;
} }
} }
@ -90,8 +213,31 @@ void PlaySfxPriv(TSFX *pSFX, bool loc, Point position)
return; 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) if (pSFX->pSnd == nullptr)
pSFX->pSnd = sound_file_load(pSFX->pszName.c_str()); pSFX->pSnd = sound_file_load(pSFX->pszName.c_str());
#endif
if (pSFX->pSnd == nullptr || !pSFX->pSnd->DSB.IsLoaded()) if (pSFX->pSnd == nullptr || !pSFX->pSnd->DSB.IsLoaded())
return; return;
@ -154,6 +300,9 @@ void LoadEffectsData()
reader.readString("path", item.pszName); reader.readString("path", item.pszName);
} }
sgSFX.shrink_to_fit(); sgSFX.shrink_to_fit();
#ifdef __DREAMCAST__
SfxLoadRetryAfterMs.fill(0);
#endif
} }
void PrivSoundInit(uint8_t bLoadMask) void PrivSoundInit(uint8_t bLoadMask)
@ -164,6 +313,23 @@ void PrivSoundInit(uint8_t bLoadMask)
if (sgSFX.empty()) LoadEffectsData(); 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) { for (auto &sfx : sgSFX) {
if (sfx.bFlags == 0 || sfx.pSnd != nullptr) { if (sfx.bFlags == 0 || sfx.pSnd != nullptr) {
continue; continue;
@ -258,6 +424,9 @@ void effects_cleanup_sfx(bool fullUnload)
if (fullUnload) { if (fullUnload) {
sgSFX.clear(); sgSFX.clear();
#ifdef __DREAMCAST__
SfxLoadRetryAfterMs.fill(0);
#endif
return; return;
} }
@ -323,9 +492,18 @@ void effects_play_sound(SfxID id)
int GetSFXLength(SfxID nSFX) int GetSFXLength(SfxID nSFX)
{ {
TSFX &sfx = sgSFX[static_cast<int16_t>(nSFX)]; TSFX &sfx = sgSFX[static_cast<int16_t>(nSFX)];
if (sfx.pSnd == nullptr) if (sfx.pSnd == nullptr) {
#ifdef __DREAMCAST__
music_mute();
#endif
sfx.pSnd = sound_file_load(sfx.pszName.c_str(), sfx.pSnd = sound_file_load(sfx.pszName.c_str(),
/*stream=*/AllowStreaming && (sfx.bFlags & sfx_STREAM) != 0); /*stream=*/AllowStreaming && (sfx.bFlags & sfx_STREAM) != 0);
#ifdef __DREAMCAST__
music_unmute();
#endif
}
if (sfx.pSnd == nullptr)
return 0;
return sfx.pSnd->DSB.GetLength(); return sfx.pSnd->DSB.GetLength();
} }

8
Source/engine/assets.cpp

@ -331,14 +331,16 @@ std::vector<std::string> GetMPQSearchPaths()
{ {
std::vector<std::string> paths; std::vector<std::string> paths;
paths.push_back(paths::BasePath()); paths.push_back(paths::BasePath());
#ifndef __DREAMCAST__
paths.push_back(paths::PrefPath()); paths.push_back(paths::PrefPath());
if (paths[0] == paths[1]) if (paths[0] == paths[1])
paths.pop_back(); paths.pop_back();
paths.push_back(paths::ConfigPath()); paths.push_back(paths::ConfigPath());
if (paths[0] == paths[1] || (paths.size() == 3 && (paths[0] == paths[2] || paths[1] == paths[2]))) if (paths[0] == paths[1] || (paths.size() == 3 && (paths[0] == paths[2] || paths[1] == paths[2])))
paths.pop_back(); 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 // `XDG_DATA_HOME` is usually the root path of `paths::PrefPath()`, so we only
// add `XDG_DATA_DIRS`. // add `XDG_DATA_DIRS`.
const char *xdgDataDirs = std::getenv("XDG_DATA_DIRS"); const char *xdgDataDirs = std::getenv("XDG_DATA_DIRS");
@ -491,16 +493,20 @@ void LoadModArchives(std::span<const std::string_view> modnames)
{ {
std::string targetPath; std::string targetPath;
for (const std::string_view modname : modnames) { for (const std::string_view modname : modnames) {
#ifndef __DREAMCAST__
targetPath = StrCat(paths::PrefPath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR); targetPath = StrCat(paths::PrefPath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR);
if (FileExists(targetPath)) { if (FileExists(targetPath)) {
OverridePaths.emplace_back(targetPath); OverridePaths.emplace_back(targetPath);
} }
#endif
targetPath = StrCat(paths::BasePath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR); targetPath = StrCat(paths::BasePath(), "mods" DIRECTORY_SEPARATOR_STR, modname, DIRECTORY_SEPARATOR_STR);
if (FileExists(targetPath)) { if (FileExists(targetPath)) {
OverridePaths.emplace_back(targetPath); OverridePaths.emplace_back(targetPath);
} }
} }
#ifndef __DREAMCAST__
OverridePaths.emplace_back(paths::PrefPath()); OverridePaths.emplace_back(paths::PrefPath());
#endif
int priority = 10000; int priority = 10000;
auto paths = GetMPQSearchPaths(); auto paths = GetMPQSearchPaths();

22
Source/engine/dx.cpp

@ -35,6 +35,10 @@
#include <3ds.h> #include <3ds.h>
#endif #endif
#ifdef __DREAMCAST__
#include "platform/dreamcast/dc_video.h"
#endif
namespace devilution { namespace devilution {
int refreshDelay; int refreshDelay;
@ -124,6 +128,10 @@ void dx_cleanup()
SDL_HideWindow(ghMainWnd); SDL_HideWindow(ghMainWnd);
#endif #endif
#ifdef __DREAMCAST__
dc::VideoShutdown();
#endif
PalSurface = nullptr; PalSurface = nullptr;
PinnedPalSurface = nullptr; PinnedPalSurface = nullptr;
Palette = nullptr; Palette = nullptr;
@ -164,6 +172,14 @@ void CreateBackBuffer()
// time the global `palette` is changed. No need to do anything here as // time the global `palette` is changed. No need to do anything here as
// the global `palette` doesn't have any colors set yet. // the global `palette` doesn't have any colors set yet.
#endif #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) void BltFast(SDL_Rect *srcRect, SDL_Rect *dstRect)
@ -278,6 +294,12 @@ void RenderPresent()
LimitFrameRate(); LimitFrameRate();
} }
#else #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) { if (SDL_Flip(surface) <= -1) {
ErrSdl(); ErrSdl();
} }

7
Source/engine/palette.cpp

@ -26,6 +26,9 @@
#include "headless_mode.hpp" #include "headless_mode.hpp"
#include "hwcursor.hpp" #include "hwcursor.hpp"
#include "options.h" #include "options.h"
#ifdef __DREAMCAST__
#include "platform/dreamcast/dc_video.h"
#endif
#include "utils/display.h" #include "utils/display.h"
#include "utils/palette_blending.hpp" #include "utils/palette_blending.hpp"
#include "utils/sdl_compat.h" #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)) { if (!SDLC_SetSurfaceAndPaletteColors(PalSurface, Palette.get(), system_palette.data() + first, first, ncolor)) {
ErrSdl(); ErrSdl();
} }
#ifdef __DREAMCAST__
// Update Dreamcast RGB565 palette LUT for 8bpp->16bpp conversion
dc::UpdatePaletteRange(system_palette.data() + first, first, ncolor);
#endif
} }
void palette_init() void palette_init()

7
Source/engine/render/blit_impl.hpp

@ -2,8 +2,13 @@
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
// Dreamcast's cross-compiler (sh-elf-g++) was built with _PSTL_PAR_BACKEND_TBB
// enabled, so <execution> pulls in host TBB headers that fail to compile.
// Execution policies are not useful on the single-core SH4 anyway.
#ifndef __DREAMCAST__
#include <execution> #include <execution>
#include <version> #include <version>
#endif
#include "engine/render/light_render.hpp" #include "engine/render/light_render.hpp"
#include "utils/attributes.h" #include "utils/attributes.h"
@ -11,7 +16,7 @@
namespace devilution { namespace devilution {
#if __cpp_lib_execution >= 201902L #if !defined(__DREAMCAST__) && __cpp_lib_execution >= 201902L
#define DEVILUTIONX_BLIT_EXECUTION_POLICY std::execution::unseq, #define DEVILUTIONX_BLIT_EXECUTION_POLICY std::execution::unseq,
#else #else
#define DEVILUTIONX_BLIT_EXECUTION_POLICY #define DEVILUTIONX_BLIT_EXECUTION_POLICY

9
Source/engine/sound.cpp

@ -239,7 +239,16 @@ tl::expected<std::unique_ptr<TSnd>, std::string> SoundFileLoadWithStatus(const c
std::unique_ptr<TSnd> sound_file_load(const char *path, bool stream) std::unique_ptr<TSnd> sound_file_load(const char *path, bool stream)
{ {
tl::expected<std::unique_ptr<TSnd>, std::string> result = SoundFileLoadWithStatus(path, stream); tl::expected<std::unique_ptr<TSnd>, 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()); if (!result.has_value()) app_fatal(result.error());
#endif
return std::move(result).value(); return std::move(result).value();
} }

27
Source/interfac.cpp

@ -24,6 +24,10 @@
#include "control/control.hpp" #include "control/control.hpp"
#include "controls/input.h" #include "controls/input.h"
#ifdef __DREAMCAST__
#include "DiabloUI/diabloui.h"
#include "engine/sound.h"
#endif
#include "engine/clx_sprite.hpp" #include "engine/clx_sprite.hpp"
#include "engine/dx.h" #include "engine/dx.h"
#include "engine/events.hpp" #include "engine/events.hpp"
@ -616,7 +620,15 @@ void IncProgress(uint32_t steps)
SDL_Event event; SDL_Event event;
CustomEventToSdlEvent(event, WM_PROGRESS); CustomEventToSdlEvent(event, WM_PROGRESS);
if (!SDLC_PushEvent(&event)) { 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()); LogError("Failed to send WM_PROGRESS {}", SDL_GetError());
#endif
SDL_ClearError(); SDL_ClearError();
} }
#ifdef LOAD_ON_MAIN_THREAD #ifdef LOAD_ON_MAIN_THREAD
@ -664,6 +676,21 @@ void ShowProgress(interface_mode uMsg)
BlackPalette(); 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). // 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. // This is because the MPQ archive can only be read by a single thread at a time.
LoadCutsceneBackground(uMsg); LoadCutsceneBackground(uMsg);

27
Source/levels/reencode_dun_cels.cpp

@ -250,8 +250,19 @@ void ReencodeDungeonCels(std::unique_ptr<std::byte[]> &dungeonCels, std::span<st
FormatInteger(Swap32LE(srcOffsets[Swap32LE(srcOffsets[0]) + 1]))); FormatInteger(Swap32LE(srcOffsets[Swap32LE(srcOffsets[0]) + 1])));
const size_t outSize = GetReencodedSize(srcData, frames); const size_t outSize = GetReencodedSize(srcData, frames);
#ifdef __DREAMCAST__
// Allocate destination buffer, process, then swap.
// outSize is smaller than the original, so we can fit both briefly.
std::byte *workBuffer = new (std::nothrow) std::byte[outSize];
if (!workBuffer) {
return;
}
auto *const resultPtr = reinterpret_cast<uint8_t *>(workBuffer);
#else
std::unique_ptr<std::byte[]> result { new std::byte[outSize] }; std::unique_ptr<std::byte[]> result { new std::byte[outSize] };
auto *const resultPtr = reinterpret_cast<uint8_t *>(result.get()); auto *const resultPtr = reinterpret_cast<uint8_t *>(result.get());
#endif
WriteLE32(resultPtr, static_cast<uint32_t>(frames.size())); WriteLE32(resultPtr, static_cast<uint32_t>(frames.size()));
uint8_t *lookup = resultPtr + 4; uint8_t *lookup = resultPtr + 4;
uint8_t *out = resultPtr + (2 + frames.size()) * 4; // number of frames, frame offsets, file size uint8_t *out = resultPtr + (2 + frames.size()) * 4; // number of frames, frame offsets, file size
@ -299,7 +310,23 @@ void ReencodeDungeonCels(std::unique_ptr<std::byte[]> &dungeonCels, std::span<st
FormatInteger(Swap32LE(dstOffsets[Swap32LE(dstOffsets[0]) + 1])), FormatInteger(Swap32LE(dstOffsets[Swap32LE(dstOffsets[0]) + 1])),
FormatInteger(numFoliage)); FormatInteger(numFoliage));
#ifdef __DREAMCAST__
// Free the original buffer FIRST, then allocate the final result.
// This ensures we never have both old and new buffers in memory simultaneously.
dungeonCels.reset();
std::byte *finalBuf = new (std::nothrow) std::byte[outSize];
if (finalBuf == nullptr) {
// Restore dungeonCels from workBuffer so the renderer has something
dungeonCels.reset(workBuffer);
return;
}
std::memcpy(finalBuf, workBuffer, outSize);
dungeonCels.reset(finalBuf);
delete[] workBuffer;
#else
dungeonCels = std::move(result); dungeonCels = std::move(result);
#endif
} }
std::vector<std::pair<uint16_t, uint16_t>> ComputeCelBlockAdjustments(std::span<std::pair<uint16_t, DunFrameInfo>> frames) std::vector<std::pair<uint16_t, uint16_t>> ComputeCelBlockAdjustments(std::span<std::pair<uint16_t, DunFrameInfo>> frames)

11
Source/main.cpp

@ -23,6 +23,9 @@
#ifdef GPERF_HEAP_MAIN #ifdef GPERF_HEAP_MAIN
#include <gperftools/heap-profiler.h> #include <gperftools/heap-profiler.h>
#endif #endif
#ifdef __DREAMCAST__
#include "platform/dreamcast/dc_init.hpp"
#endif
#include "diablo.h" #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) extern "C" int main(int argc, char **argv)
{ {
#ifdef __DREAMCAST__
if (!devilution::dc::InitDreamcast()) {
// Fall back to loose file loading
}
#endif
#ifdef __SWITCH__ #ifdef __SWITCH__
switch_romfs_init(); switch_romfs_init();
switch_enable_network(); switch_enable_network();
@ -60,6 +68,9 @@ extern "C" int main(int argc, char **argv)
const int result = devilution::DiabloMain(argc, argv); const int result = devilution::DiabloMain(argc, argv);
#ifdef GPERF_HEAP_MAIN #ifdef GPERF_HEAP_MAIN
HeapProfilerStop(); HeapProfilerStop();
#endif
#ifdef __DREAMCAST__
devilution::dc::ShutdownDreamcast();
#endif #endif
return result; return result;
} }

5
Source/movie.cpp

@ -37,6 +37,11 @@ void play_movie(const char *pszMovie, bool userCanClose)
{ {
if (demo::IsRunning()) if (demo::IsRunning())
return; 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; movie_playing = true;

8
Source/options.cpp

@ -789,14 +789,22 @@ GraphicsOptions::GraphicsOptions()
, brightness("Brightness Correction", OptionEntryFlags::Invisible, "Brightness Correction", "Brightness correction level.", 0) , brightness("Brightness Correction", OptionEntryFlags::Invisible, "Brightness Correction", "Brightness correction level.", 0)
, zoom("Zoom", OptionEntryFlags::None, N_("Zoom"), N_("Zoom on when enabled."), false) , 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) , 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) , 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) , 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) #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()) , 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) , 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 }) , 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 #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) , showFPS("Show FPS", OptionEntryFlags::None, N_("Show FPS"), N_("Displays the FPS in the upper left corner of the screen."), false)
#endif
{ {
} }
std::vector<OptionEntryBase *> GraphicsOptions::GetEntries() std::vector<OptionEntryBase *> GraphicsOptions::GetEntries()

241
Source/pfile.cpp

@ -5,6 +5,7 @@
*/ */
#include "pfile.h" #include "pfile.h"
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <string_view> #include <string_view>
@ -43,6 +44,10 @@
#ifdef UNPACKED_SAVES #ifdef UNPACKED_SAVES
#include "utils/file_util.h" #include "utils/file_util.h"
#ifdef __DREAMCAST__
#include "platform/dreamcast/dc_init.hpp"
#include "platform/dreamcast/dc_save_codec.hpp"
#endif
#else #else
#include "mpq/mpq_reader.hpp" #include "mpq/mpq_reader.hpp"
#endif #endif
@ -61,8 +66,116 @@ namespace {
/** List of character names for the character selection screen. */ /** List of character names for the character selection screen. */
char hero_names[MAX_CHARACTERS][PlayerNameLength]; char hero_names[MAX_CHARACTERS][PlayerNameLength];
#ifdef __DREAMCAST__
constexpr uint32_t DreamcastMaxSaveSlots = 8;
#endif
uint32_t GetPlatformSaveSlotCount()
{
#ifdef __DREAMCAST__
return std::min<uint32_t>(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<uint8_t>(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 = {}) 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, return StrCat(paths::PrefPath(), savePrefix,
gbIsSpawn gbIsSpawn
? (gbIsMultiplayer ? "share_" : "spawn_") ? (gbIsMultiplayer ? "share_" : "spawn_")
@ -74,10 +187,17 @@ std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {})
gbIsHellfire ? ".hsv" : ".sv" gbIsHellfire ? ".hsv" : ".sv"
#endif #endif
); );
#endif
} }
std::string GetStashSavePath() 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(), return StrCat(paths::PrefPath(),
gbIsSpawn ? "stash_spawn" : "stash", gbIsSpawn ? "stash_spawn" : "stash",
#ifdef UNPACKED_SAVES #ifdef UNPACKED_SAVES
@ -86,6 +206,7 @@ std::string GetStashSavePath()
gbIsHellfire ? ".hsv" : ".sv" gbIsHellfire ? ".hsv" : ".sv"
#endif #endif
); );
#endif
} }
bool GetSaveNames(uint8_t index, std::string_view prefix, char *out) bool GetSaveNames(uint8_t index, std::string_view prefix, char *out)
@ -243,8 +364,18 @@ bool ArchiveContainsGame(SaveReader &hsArchive)
std::optional<SaveReader> CreateSaveReader(std::string &&path) std::optional<SaveReader> CreateSaveReader(std::string &&path)
{ {
#ifdef UNPACKED_SAVES #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)) if (!FileExists(path))
return std::nullopt; return std::nullopt;
#endif
return SaveReader(std::move(path)); return SaveReader(std::move(path));
#else #else
std::int32_t error; std::int32_t error;
@ -536,11 +667,76 @@ void RemoveAllInvalidItems(Player &player)
} // namespace } // namespace
#ifdef UNPACKED_SAVES #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<std::byte[]> SaveReader::ReadFile(const char *filename, std::size_t &fileSize, int32_t &error) std::unique_ptr<std::byte[]> SaveReader::ReadFile(const char *filename, std::size_t &fileSize, int32_t &error)
{ {
std::unique_ptr<std::byte[]> result; std::unique_ptr<std::byte[]> result;
error = 0; error = 0;
const std::string path = dir_ + filename; 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<size_t>(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; uintmax_t size;
if (!GetFileSize(path.c_str(), &size)) { if (!GetFileSize(path.c_str(), &size)) {
error = 1; error = 1;
@ -560,11 +756,46 @@ std::unique_ptr<std::byte[]> SaveReader::ReadFile(const char *filename, std::siz
} }
std::fclose(file); std::fclose(file);
return result; return result;
#endif
} }
bool SaveWriter::WriteFile(const char *filename, const std::byte *data, size_t size) 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; 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"); FILE *file = OpenFile(path.c_str(), "wb");
if (file == nullptr) { if (file == nullptr) {
return false; return false;
@ -575,6 +806,7 @@ bool SaveWriter::WriteFile(const char *filename, const std::byte *data, size_t s
} }
std::fclose(file); std::fclose(file);
return true; return true;
#endif
} }
void SaveWriter::RemoveHashEntries(bool (*fnGetName)(uint8_t, char *)) 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)); 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<SaveReader> archive = OpenSaveArchive(i); std::optional<SaveReader> archive = OpenSaveArchive(i);
if (archive) { if (archive) {
PlayerPack pkplr; PlayerPack pkplr;
@ -687,9 +919,11 @@ bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *))
Player &player = Players[0]; Player &player = Players[0];
UnPackPlayer(pkplr, player); UnPackPlayer(pkplr, player);
#ifndef __DREAMCAST__
LoadHeroItems(player); LoadHeroItems(player);
RemoveAllInvalidItems(player); RemoveAllInvalidItems(player);
CalcPlrInv(player, false); CalcPlrInv(player, false);
#endif
Game2UiPlayer(player, &uihero, hasSaveGame); Game2UiPlayer(player, &uihero, hasSaveGame);
uiAddHeroInfo(&uihero); 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 pfile_ui_get_first_unused_save_num()
{ {
uint32_t saveNum; 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') if (hero_names[saveNum][0] == '\0')
break; break;
} }
@ -724,7 +959,7 @@ bool pfile_ui_save_create(_uiheroinfo *heroinfo)
PlayerPack pkplr; PlayerPack pkplr;
const uint32_t saveNum = heroinfo->saveNumber; const uint32_t saveNum = heroinfo->saveNumber;
if (saveNum >= MAX_CHARACTERS) if (saveNum >= GetPlatformSaveSlotCount())
return false; return false;
heroinfo->saveNumber = saveNum; heroinfo->saveNumber = saveNum;

5
Source/pfile.h

@ -40,10 +40,7 @@ struct SaveReader {
std::unique_ptr<std::byte[]> ReadFile(const char *filename, std::size_t &fileSize, int32_t &error); std::unique_ptr<std::byte[]> ReadFile(const char *filename, std::size_t &fileSize, int32_t &error);
bool HasFile(const char *path) bool HasFile(const char *path);
{
return ::devilution::FileExists((dir_ + path).c_str());
}
private: private:
std::string dir_; std::string dir_;

13
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
)

59
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 <cstdio>
#include <dc/maple.h>
#include <dc/maple/vmu.h>
#include <kos.h>
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

44
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__

381
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 <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <zlib.h>
#include <dc/fs_vmu.h>
#include <dc/vmu_pkg.h>
#include <kos/fs.h>
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<const uint8_t *>(data);
return static_cast<uint32_t>(bytes[0])
| (static_cast<uint32_t>(bytes[1]) << 8)
| (static_cast<uint32_t>(bytes[2]) << 16)
| (static_cast<uint32_t>(bytes[3]) << 24);
}
void WriteU32LE(std::byte *destination, uint32_t value)
{
auto *bytes = reinterpret_cast<uint8_t *>(destination);
bytes[0] = static_cast<uint8_t>(value & 0xFF);
bytes[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
bytes[2] = static_cast<uint8_t>((value >> 16) & 0xFF);
bytes[3] = static_cast<uint8_t>((value >> 24) & 0xFF);
}
bool IsSaveContainer(const std::byte *data, size_t size)
{
if (size < SaveHeaderSize)
return false;
const auto *bytes = reinterpret_cast<const char *>(data);
return bytes[0] == SaveMagic[0]
&& bytes[1] == SaveMagic[1]
&& bytes[2] == SaveMagic[2]
&& bytes[3] == SaveMagic[3]
&& static_cast<uint8_t>(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<size_t>(std::numeric_limits<uLong>::max()))
return false;
if (outputCapacity > static_cast<size_t>(std::numeric_limits<uLong>::max()))
return false;
uLongf size = static_cast<uLongf>(outputCapacity);
const int rc = uncompress(
reinterpret_cast<Bytef *>(output),
&size,
reinterpret_cast<const Bytef *>(input),
static_cast<uLong>(inputSize));
if (rc != Z_OK)
return false;
if (size != outputCapacity)
return false;
decodedSize = static_cast<size_t>(size);
return true;
}
std::unique_ptr<std::byte[]> 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<size_t>(std::numeric_limits<uLong>::max()))
return nullptr;
SaveCodec codec = SaveCodec::Raw;
size_t payloadSize = size;
std::unique_ptr<std::byte[]> compressed;
uLongf compressedSize = compressBound(static_cast<uLong>(size));
if (compressedSize > 0) {
compressed = std::make_unique<std::byte[]>(compressedSize);
const int rc = compress2(
reinterpret_cast<Bytef *>(compressed.get()),
&compressedSize,
reinterpret_cast<const Bytef *>(data),
static_cast<uLong>(size),
Z_BEST_SPEED);
if (rc == Z_OK && compressedSize < size) {
codec = SaveCodec::Zlib;
payloadSize = static_cast<size_t>(compressedSize);
}
}
if (payloadSize > std::numeric_limits<uint32_t>::max())
return nullptr;
if (size > std::numeric_limits<uint32_t>::max())
return nullptr;
const size_t containerSize = SaveHeaderSize + payloadSize;
auto container = std::make_unique<std::byte[]>(containerSize);
auto *bytes = reinterpret_cast<char *>(container.get());
bytes[0] = SaveMagic[0];
bytes[1] = SaveMagic[1];
bytes[2] = SaveMagic[2];
bytes[3] = SaveMagic[3];
bytes[4] = static_cast<char>(SaveFormatVersion);
bytes[5] = static_cast<char>(codec);
bytes[6] = 0;
bytes[7] = 0;
WriteU32LE(container.get() + 8, static_cast<uint32_t>(payloadSize));
WriteU32LE(container.get() + 12, static_cast<uint32_t>(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<std::byte[]> 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<uint8_t>(reinterpret_cast<const char *>(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<std::byte[]>(originalSize);
if (codec == static_cast<uint8_t>(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<uint8_t>(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<std::byte[]> &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<size_t>(fileLen);
buffer = std::make_unique<std::byte[]>(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<uint32_t>::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<std::byte[]> ReadCompressedFile(const char *path, size_t &outSize)
{
std::unique_ptr<std::byte[]> 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<ssize_t>(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<std::byte[]> 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<size_t>(-1) || total < sizeof(uint32_t)) {
fs_close(fd);
return nullptr;
}
auto rawBuf = std::make_unique<std::byte[]>(total);
const ssize_t bytesRead = fs_read(fd, rawBuf.get(), total);
fs_close(fd);
if (bytesRead < static_cast<ssize_t>(sizeof(uint32_t)))
return nullptr;
return DecodeSaveContainer(rawBuf.get(), static_cast<size_t>(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__

31
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 <cstddef>
#include <memory>
namespace devilution {
namespace dc {
bool WriteCompressedFile(const char *path, const std::byte *data, size_t size);
std::unique_ptr<std::byte[]> 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<std::byte[]> ReadFromVmu(const char *vmuPath, const char *filename,
size_t &outSize);
bool VmuFileExists(const char *vmuPath, const char *filename);
} // namespace dc
} // namespace devilution
#endif // __DREAMCAST__

182
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 <kos.h>
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<uint32_t>(rgb565) << 16;
#else
palette565FirstWord[index] = static_cast<uint32_t>(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<uint32_t *>(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<uint16_t *>(reinterpret_cast<uint8_t *>(dst) + y * dstPitch);
const bool canUsePackedPath = (reinterpret_cast<uintptr_t>(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<const uint8_t *>(src->pixels);
uint16_t *dstPixels = static_cast<uint16_t *>(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__

91
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 <SDL.h>
#include <cstdint>
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<uint16_t>(
((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

34
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 <stdio.h>
#include <unistd.h>
// 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__ */

12
Source/player.cpp

@ -2188,12 +2188,24 @@ void InitPlayerGFX(Player &player)
return; 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<player_graphic>::value; i++) { for (size_t i = 0; i < enum_size<player_graphic>::value; i++) {
auto graphic = static_cast<player_graphic>(i); auto graphic = static_cast<player_graphic>(i);
if (graphic == player_graphic::Death) if (graphic == player_graphic::Death)
continue; continue;
LoadPlrGFX(player, graphic); LoadPlrGFX(player, graphic);
} }
#endif
} }
void ResetPlayerGFX(Player &player) void ResetPlayerGFX(Player &player)

6
Source/qol/stash.cpp

@ -28,6 +28,7 @@
#include "minitext.h" #include "minitext.h"
#include "stores.h" #include "stores.h"
#include "utils/display.h" #include "utils/display.h"
#include "utils/log.hpp"
#include "utils/format_int.hpp" #include "utils/format_int.hpp"
#include "utils/language.h" #include "utils/language.h"
#include "utils/sdl_compat.h" #include "utils/sdl_compat.h"
@ -281,6 +282,11 @@ void FreeStashGFX()
void InitStash() 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) { if (!HeadlessMode) {
StashPanelArt = LoadClx("data\\stash.clx"); StashPanelArt = LoadClx("data\\stash.clx");
StashNavButtonArt = LoadClx("data\\stashnavbtns.clx"); StashNavButtonArt = LoadClx("data\\stashnavbtns.clx");

7
Source/restrict.cpp

@ -22,6 +22,13 @@ namespace devilution {
void ReadOnlyTest() 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"; const std::string path = paths::PrefPath() + "Diablo1ReadOnlyTest.foo";
SDL_IOStream *file = SDL_IOFromFile(path.c_str(), "w"); SDL_IOStream *file = SDL_IOFromFile(path.c_str(), "w");
if (file == nullptr) { if (file == nullptr) {

7
Source/towners.cpp

@ -759,7 +759,9 @@ void InitTowners()
TownerLongNames.try_emplace(entry.type, entry.name); TownerLongNames.try_emplace(entry.type, entry.name);
} }
#ifndef __DREAMCAST__
CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128)); CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128));
#endif
Towners.clear(); Towners.clear();
Towners.reserve(TownersDataEntries.size()); Towners.reserve(TownersDataEntries.size());
@ -767,6 +769,11 @@ void InitTowners()
for (const auto &entry : TownersDataEntries) { for (const auto &entry : TownersDataEntries) {
if (!IsTownerPresent(entry.type)) if (!IsTownerPresent(entry.type))
continue; 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); auto behaviorIt = TownerBehaviors.find(entry.type);
if (behaviorIt == TownerBehaviors.end() || behaviorIt->second == nullptr) if (behaviorIt == TownerBehaviors.end() || behaviorIt->second == nullptr)

11
Source/utils/display.cpp

@ -63,6 +63,10 @@
#endif #endif
#endif #endif
#ifdef __DREAMCAST__
#include <SDL_dreamcast.h>
#endif
namespace devilution { namespace devilution {
extern SDLSurfaceUniquePtr RendererTextureSurface; /** defined in dx.cpp */ extern SDLSurfaceUniquePtr RendererTextureSurface; /** defined in dx.cpp */
@ -485,6 +489,13 @@ void SetVideoModeToPrimary(bool fullscreen, int width, int height)
#ifdef __3DS__ #ifdef __3DS__
flags &= ~SDL_FULLSCREEN; flags &= ~SDL_FULLSCREEN;
flags |= Get3DSScalingFlag(*GetOptions().Graphics.fitToScreen, width, height); 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 #endif
SetVideoMode(width, height, SDL1_VIDEO_MODE_BPP, flags); SetVideoMode(width, height, SDL1_VIDEO_MODE_BPP, flags);
if (OutputRequiresScaling()) if (OutputRequiresScaling())

41
Source/utils/file_util.cpp

@ -34,7 +34,7 @@
#endif #endif
#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 #define DVL_HAS_POSIX_2001
#endif #endif
@ -165,9 +165,15 @@ bool DirectoryExists(const char *path)
#elif defined(DVL_HAS_FILESYSTEM) #elif defined(DVL_HAS_FILESYSTEM)
std::error_code error; std::error_code error;
return std::filesystem::is_directory(reinterpret_cast<const char8_t *>(path), error); return std::filesystem::is_directory(reinterpret_cast<const char8_t *>(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; struct ::stat statResult;
return ::stat(path, &statResult) == 0 && S_ISDIR(statResult.st_mode); 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 #endif
} }
@ -240,7 +246,14 @@ bool GetFileSize(const char *path, std::uintmax_t *size)
bool CreateDir(const char *path) 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::error_code error;
std::filesystem::create_directory(reinterpret_cast<const char8_t *>(path), error); std::filesystem::create_directory(reinterpret_cast<const char8_t *>(path), error);
if (error) { if (error) {
@ -281,7 +294,11 @@ bool CreateDir(const char *path)
void RecursivelyCreateDir(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::error_code error;
std::filesystem::create_directories(reinterpret_cast<const char8_t *>(path), error); std::filesystem::create_directories(reinterpret_cast<const char8_t *>(path), error);
if (error) { if (error) {
@ -361,6 +378,11 @@ bool ResizeFile(const char *path, std::uintmax_t size)
return true; return true;
#elif defined(DVL_HAS_POSIX_2001) #elif defined(DVL_HAS_POSIX_2001)
return ::truncate(path, static_cast<off_t>(size)) == 0; return ::truncate(path, static_cast<off_t>(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 #else
static_assert(false, "truncate not implemented for the current platform"); static_assert(false, "truncate not implemented for the current platform");
#endif #endif
@ -508,7 +530,7 @@ std::vector<std::string> ListDirectories(const char *path)
dirs.push_back(folder); dirs.push_back(folder);
} while (FindNextFileA(hFind, &findData)); } while (FindNextFileA(hFind, &findData));
FindClose(hFind); 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); DIR *d = ::opendir(path);
if (d != nullptr) { if (d != nullptr) {
struct dirent *dir; struct dirent *dir;
@ -520,6 +542,9 @@ std::vector<std::string> ListDirectories(const char *path)
} }
::closedir(d); ::closedir(d);
} }
#elif defined(__DREAMCAST__)
// KOS doesn't have opendir/readdir - return empty list
(void)path;
#else #else
static_assert(false, "ListDirectories not implemented for the current platform"); static_assert(false, "ListDirectories not implemented for the current platform");
#endif #endif
@ -553,7 +578,7 @@ std::vector<std::string> ListFiles(const char *path)
files.push_back(file); files.push_back(file);
} while (FindNextFileA(hFind, &findData)); } while (FindNextFileA(hFind, &findData));
FindClose(hFind); 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); DIR *d = ::opendir(path);
if (d != nullptr) { if (d != nullptr) {
struct dirent *dir; struct dirent *dir;
@ -565,6 +590,10 @@ std::vector<std::string> ListFiles(const char *path)
} }
::closedir(d); ::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 #else
static_assert(false, "ListFiles not implemented for the current platform"); static_assert(false, "ListFiles not implemented for the current platform");
#endif #endif

5
Source/utils/paths.cpp

@ -152,6 +152,11 @@ const std::string &AssetsPath()
assetsPath.emplace("D:\\assets\\"); assetsPath.emplace("D:\\assets\\");
#elif defined(__3DS__) || defined(__SWITCH__) #elif defined(__3DS__) || defined(__SWITCH__)
assetsPath.emplace("romfs:/"); 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) #elif defined(__APPLE__) && defined(USE_SDL1)
// In `Info.plist` we have // In `Info.plist` we have
// //

34
Source/utils/sdl2_to_1_2_backports.cpp

@ -5,6 +5,10 @@
#include <cstring> #include <cstring>
#include <string_view> #include <string_view>
#ifdef __DREAMCAST__
#include <kos/fs.h>
#endif
#if defined(_WIN32) #if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN
#define NOMINMAX 1 #define NOMINMAX 1
@ -800,6 +804,19 @@ extern "C" char *SDL_GetBasePath()
retval = SDL_strdup("file:sdmc:/3ds/devilutionx/"); retval = SDL_strdup("file:sdmc:/3ds/devilutionx/");
#elif defined(__amigaos__) #elif defined(__amigaos__)
retval = SDL_strdup("PROGDIR:"); 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 #else
/* is a Linux-style /proc filesystem available? */ /* 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__) #elif defined(__amigaos__)
retval = SDL_strdup("PROGDIR:"); retval = SDL_strdup("PROGDIR:");
return retval; 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 #endif
if (!app) { if (!app) {

2
Source/utils/stdcompat/filesystem.hpp

@ -6,7 +6,7 @@
|| (defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && __IPHONE_OS_VERSION_MIN_REQUIRED < 130000) || (defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && __IPHONE_OS_VERSION_MIN_REQUIRED < 130000)
#define DVL_NO_FILESYSTEM #define DVL_NO_FILESYSTEM
#endif #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)) || (defined(WINVER) && WINVER <= 0x0500 && (!defined(_WIN32_WINNT) || _WIN32_WINNT == 0))
#define DVL_NO_FILESYSTEM #define DVL_NO_FILESYSTEM
#endif #endif

29
docs/building.md

@ -410,6 +410,35 @@ cmake --build build
[PlayStation Vita manual](/docs/manual/platforms/vita.md) [PlayStation Vita manual](/docs/manual/platforms/vita.md)
</details> </details>
<a id="dreamcast"></a>
<details><summary>Dreamcast</summary>
### 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)
</details>
<details><summary>Haiku</summary> <details><summary>Haiku</summary>

11
docs/installing.md

@ -175,6 +175,17 @@ If you'd like to use this option, scan the QR code below.
</details> </details>
<details><summary>Dreamcast</summary>
- 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).
</details>
<details><summary>ClockworkPi GameShell</summary> <details><summary>ClockworkPi GameShell</summary>
- 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. - 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.

22
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
Loading…
Cancel
Save