Developer Notes
Internal architecture, debug tools, how to extend the implementation, and testing strategy.
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 interface | Implementation class | Key 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 wheneverBlt,BltFast, orFillColorwrites to the primary surface.PresentPrimaryskips 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).
- Frame throttle: compares
SDL_GetTicksNS()againstlastPresentNs_. - Dirty check: skips if
primary.dirty_ == false. - Palette expansion: for 8-bit surfaces, palette indices → RGBA32 into
paletteConvertBuffer_(reused each frame, no heap allocation). SDL_UpdateTextureuploads to the streaming texture.SDL_RenderClear+SDL_RenderTexture+SDL_RenderPresent.- 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 name | What it tracks |
|---|---|
ddInstances | Live IDirectDraw objects |
ddSurfaces | Live IDirectDrawSurface objects |
ddPalettes | Live IDirectDrawPalette objects |
ddClippers | Live IDirectDrawClipper objects |
sdlTextures | Live SDL_Texture handles |
sdlAudioStreams | Live SDL_AudioStream handles |
dsBuffers | Live IDirectSoundBuffer objects |
bltCallsTotal | Cumulative Blt() calls |
bltFastCallsTotal | Cumulative BltFast() calls |
flipCallsTotal | Cumulative Flip() calls |
presentCallsTotal | Cumulative PresentPrimary() calls |
ddSurfacePixelCapacityBytes | Total 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
| Variable | Effect | Typical 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
- Add the method signature to the appropriate virtual class in
include/ddraw.h. - Add the override declaration in the corresponding
Implclass insidesrc/directdraw/DirectDraw.cpp. - Implement the method. All SDL3 calls must stay inside
.cppfiles — never include SDL headers inddraw.h. - Document the status (
IMPLEMENTED,PARTIAL, orSTUB) in a@notecomment.
Adding audio looping
The Play() stub checks dwFlags & 0x1 (DSBPLAY_LOOPING).
To implement looping:
- Store a
bool looping_flag inDirectSoundBufferImpl. - After
SDL_PutAudioStreamData, set up an SDL audio callback or a monitor thread that checksSDL_GetAudioStreamQueued() == 0and re-feeds the buffer. - 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:
- Implement a per-sample callback using
SDL_SetAudioStreamPutCallback. - In the callback, scale channel 0 by
leftand channel 1 byrightbefore writing output samples. - Remove the
(void)left; (void)right;no-ops inapplyGain().
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:
- On original Windows with real DirectX — the reference.
- Under Wine (which implements its own DirectDraw) — a secondary reference.
- 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=1to confirm the renderer works, then check whetherPresentPrimaryis being called (FREE_DIRECT_DEBUG_PRESENTATION=1). -
Wrong colors: check that the surface BPP matches the pixel data
(use
FREE_DIRECT_DEBUG_DDRAW=1to 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=1and confirm the key was set on the source surface (not the destination). -
No audio: enable
FREE_DIRECT_DEBUG_DSOUND_FORMAT=1to confirm the buffer format was parsed correctly. Check thatSDL_OpenAudioDevicesucceeded (look forDSERR_NODRIVER). -
CPU spinning at 100%: the
PeekMessageAimplementation in free-api callsSDL_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
.cppfiles, never in public headers. - All implementation classes are in anonymous namespaces in their respective
.cppfiles. - 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_Logredirect defined inside each.cppfile; do not useprintforstd::coutin implementation code.
Known issues and TODOs (from source)
- Looping audio (
DSBPLAY_LOOPING) — TODO inDirectSound.cpp. - Accurate stereo panning — TODO in
applyGain(). DDBLT_ROTATIONANGLE— flag accepted, rotation not performed.WM_CLOSE→DestroyWindow→WM_DESTROY→PostQuitMessageflow is not fully correct in the demo's window proc (callsPostQuitMessagedirectly onWM_CLOSE); noted inTODO.md.QueryInterfaceon all objects always returnsDDERR_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.