Internal architecture

DirectDraw (src/directdraw/DirectDraw.cpp)

All implementation classes live in an anonymous namespace and are never exposed in public headers. The public API shape (virtual class declarations in ddraw.h) is satisfied by concrete private subclasses:

Public interfaceImplementation classKey members
IDirectDraw DirectDrawImpl hwnd_, sdlWindow_, renderer_, frame throttle state
IDirectDrawSurface DirectDrawSurfaceImpl pixels_ (CPU buffer), texture_ (SDL_Texture, primary only), palette_, clipper_, color key
IDirectDrawPalette DirectDrawPaletteImpl entries_[256] (PALETTEENTRY array)
IDirectDrawClipper DirectDrawClipperImpl hwnd_ (stored, not actively clipping)

Surface pixel buffer model

Every surface — primary or offscreen — owns a std::vector<uint8_t> allocated immediately on creation. Size = width × height × (bpp / 8).

  • 8-bit surfaces: one byte per pixel = palette index.
  • 32-bit surfaces: four bytes per pixel in R, G, B, A order.
  • Primary surface: also owns one SDL_Texture (streaming, RGBA32) that is created lazily on the first present and reused every frame.
  • Dirty flag: dirty_ is set whenever Blt, BltFast, or FillColor writes to the primary surface. PresentPrimary skips upload if the flag is clear.

Presentation pipeline (PresentPrimary)

DirectDrawImpl::PresentPrimary is the single code path for all frame output. It is called either from Flip() or automatically from Blt/ BltFast when the destination is the primary surface (and Flip has never been used).

  1. Frame throttle: compares SDL_GetTicksNS() against lastPresentNs_.
  2. Dirty check: skips if primary.dirty_ == false.
  3. Palette expansion: for 8-bit surfaces, palette indices → RGBA32 into paletteConvertBuffer_ (reused each frame, no heap allocation).
  4. SDL_UpdateTexture uploads to the streaming texture.
  5. SDL_RenderClear + SDL_RenderTexture + SDL_RenderPresent.
  6. Logical presentation (letterbox) is set once on the first present via SDL_SetRenderLogicalPresentation.

DirectSound (src/directsound/DirectSound.cpp)

A singleton SharedAudioDevice opens the SDL audio device once and reference-counts it. Each DirectSoundBufferImpl creates one SDL_AudioStream (lazily on first Play()) with the source format from PCMWAVEFORMAT and the device format from SDL_GetAudioDeviceFormat. SDL3 handles sample rate and format conversion.

DirectPlay (src/directplay/DirectPlay.cpp)

Minimal stub classes only. DirectPlayImpl::QueryInterface allocates a new DirectPlay2AImpl regardless of the requested IID — this is a known hack to satisfy code that queries for IID_IDirectPlay2A without implementing proper COM dispatch.

Diagnostics system

The file src/diagnostics/Diagnostics.hpp declares a compile-time-gated counter system. When FREE_DIRECT_DIAGNOSTICS is defined (CMake option), atomic counters track live object counts, cumulative creates/destroys, pixel buffer capacities, and per-second blit/present rates.

Enabling diagnostics

# CMake
set(FREE_DIRECT_DIAGNOSTICS ON CACHE BOOL "")

# Environment (runtime enable of heartbeat output)
export FREE_DIRECT_DIAGNOSTICS=1

Available counters

Counter nameWhat it tracks
ddInstancesLive IDirectDraw objects
ddSurfacesLive IDirectDrawSurface objects
ddPalettesLive IDirectDrawPalette objects
ddClippersLive IDirectDrawClipper objects
sdlTexturesLive SDL_Texture handles
sdlAudioStreamsLive SDL_AudioStream handles
dsBuffersLive IDirectSoundBuffer objects
bltCallsTotalCumulative Blt() calls
bltFastCallsTotalCumulative BltFast() calls
flipCallsTotalCumulative Flip() calls
presentCallsTotalCumulative PresentPrimary() calls
ddSurfacePixelCapacityBytesTotal live pixel buffer capacity (bytes)

The heartbeat emits a one-line SDL_Log summary approximately once per second when FREE_DIRECT_DIAGNOSTICS=1 is set in the environment. It is triggered from PresentPrimary via FREE_DIRECT_DIAG_HEARTBEAT().

Debug environment flags reference

VariableEffectTypical use
FREE_DIRECT_DEBUG_DDRAW=1 Logs every DirectDraw API call (surface create/destroy, Blt, Lock, etc.) Tracing initialization problems
FREE_DIRECT_DEBUG_PRESENTATION=1 Logs every PresentPrimary call including throttle/dirty outcomes Frame pacing and visual output debugging
FREE_DIRECT_DEBUG_COLORKEY=1 Logs color key values and per-blit copied/skipped pixel counts Transparency not working as expected
FREE_DIRECT_DEBUG_PERF=1 Prints per-second summary: presents/s, throttled, uploads, blts Performance profiling
FREE_DIRECT_DEBUG_DSOUND=1 Logs DirectSound buffer create, play, stop, lock operations Audio not playing
FREE_DIRECT_DEBUG_DSOUND_FORMAT=1 Detailed PCM format dump (bits, channels, rate, SDL format code) Wrong audio format / silence despite no errors
FREE_DIRECT_DEBUG_PRIMARY_CLEAR=1 Renders a solid blue clear on startup to confirm the SDL renderer is alive Black screen — is it a renderer problem or a game rendering problem?
FREE_DIRECT_TARGET_FPS=N Sets the frame throttle target (default 60) Reduce throttling for benchmarking; increase for slow machines
FREE_DIRECT_ENABLE_VSYNC=0 Disables SDL_SetRenderVSync Benchmarking; measuring raw blit throughput

How to extend free-direct

Adding a missing DirectDraw method

  1. Add the method signature to the appropriate virtual class in include/ddraw.h.
  2. Add the override declaration in the corresponding Impl class inside src/directdraw/DirectDraw.cpp.
  3. Implement the method. All SDL3 calls must stay inside .cpp files — never include SDL headers in ddraw.h.
  4. Document the status (IMPLEMENTED, PARTIAL, or STUB) in a @note comment.

Adding audio looping

The Play() stub checks dwFlags & 0x1 (DSBPLAY_LOOPING). To implement looping:

  1. Store a bool looping_ flag in DirectSoundBufferImpl.
  2. After SDL_PutAudioStreamData, set up an SDL audio callback or a monitor thread that checks SDL_GetAudioStreamQueued() == 0 and re-feeds the buffer.
  3. Alternatively, pre-duplicate the PCM data (e.g. N repetitions) before feeding it, accepting a fixed loop duration.

Adding accurate stereo panning

The pan gain is already calculated in dsPanToGains() but the applyGain() function currently voids the left/right values. To apply them:

  1. Implement a per-sample callback using SDL_SetAudioStreamPutCallback.
  2. In the callback, scale channel 0 by left and channel 1 by right before writing output samples.
  3. Remove the (void)left; (void)right; no-ops in applyGain().

Adding real DirectPlay networking

Replace the stub classes in src/directplay/DirectPlay.cpp with real implementations backed by a networking library (SDL_Net, ENet, raw POSIX sockets, etc.). The public interface in include/dplay.h is already correct; only the .cpp file needs to change.

Adding a new surface type

Add a new value to the DirectDrawSurfaceImpl::SurfaceType enum and handle it in CreateSurface, PresentPrimary, and anywhere else surface type affects behavior.

Testing strategy

Observational testing against the target game

The primary test is running Speedy Blupi or Planet Blupi and observing:

  • Correct visual output (sprites, backgrounds, UI).
  • Correct transparency (color key).
  • Correct palette colors on 8-bit surfaces.
  • Audio playback on expected events.
  • No crashes over multiple game sessions.

Demo application (src/Main.cpp)

The included demo exercises the key code paths: primary + offscreen + 8-bit surfaces, palette, color key, Lock/Unlock pixel writing, Blt color fill, BltFast, Blt scaling, clipper, and Flip. Run it after any change to verify no regressions in the core paths.

Comparing against original DirectX behavior

For behavior comparison, run the same game binary:

  1. On original Windows with real DirectX — the reference.
  2. Under Wine (which implements its own DirectDraw) — a secondary reference.
  3. Under free-direct — the target.

Enable FREE_DIRECT_DEBUG_COLORKEY=1 and compare skipped pixel counts. Enable FREE_DIRECT_DEBUG_PERF=1 and compare frame rates.

Known issues to watch for

  • Black screen: set FREE_DIRECT_DEBUG_PRIMARY_CLEAR=1 to confirm the renderer works, then check whether PresentPrimary is being called (FREE_DIRECT_DEBUG_PRESENTATION=1).
  • Wrong colors: check that the surface BPP matches the pixel data (use FREE_DIRECT_DEBUG_DDRAW=1 to see BPP at surface creation). For 8-bit surfaces, verify the palette is attached before present.
  • Transparent areas showing background color: check color key values via FREE_DIRECT_DEBUG_COLORKEY=1 and confirm the key was set on the source surface (not the destination).
  • No audio: enable FREE_DIRECT_DEBUG_DSOUND_FORMAT=1 to confirm the buffer format was parsed correctly. Check that SDL_OpenAudioDevice succeeded (look for DSERR_NODRIVER).
  • CPU spinning at 100%: the PeekMessageA implementation in free-api calls SDL_Delay(1) when the queue is empty to prevent busy-spin. If you see high CPU, check the game's main loop for a tight spin without yielding.

Code style and conventions

  • C++20, no extensions (CMAKE_CXX_EXTENSIONS OFF).
  • SDL3 headers are only included in .cpp files, never in public headers.
  • All implementation classes are in anonymous namespaces in their respective .cpp files.
  • Use std::atomic<ULONG> for reference counts to be safe with any threading model.
  • Status annotations in comments: @note Status: IMPLEMENTED / PARTIAL / STUB.
  • Debug logs use the SDL_Log redirect defined inside each .cpp file; do not use printf or std::cout in implementation code.

Known issues and TODOs (from source)

  • Looping audio (DSBPLAY_LOOPING) — TODO in DirectSound.cpp.
  • Accurate stereo panning — TODO in applyGain().
  • DDBLT_ROTATIONANGLE — flag accepted, rotation not performed.
  • WM_CLOSEDestroyWindowWM_DESTROYPostQuitMessage flow is not fully correct in the demo's window proc (calls PostQuitMessage directly on WM_CLOSE); noted in TODO.md.
  • QueryInterface on all objects always returns DDERR_UNSUPPORTED / E_NOINTERFACE; COM aggregation is not planned.
  • Pan centibel-to-channel-map conversion is calculated but not applied; requires SDL3 per-channel gain API or a custom callback.