日本語版

ANTI-ARES

Porting a 16-year-old Windows game to iOS without touching the game code

I wrote a 3,600-line compatibility layer so that 30,716 lines of 2010 C++ would compile for iPhone unchanged.

2026 · Ogawa Akira

I had a shoot-'em-up sitting on a disk, finished in 2010 and untouched since. It was written against a library that only ever ran on Windows and DirectX, has been discontinued for years, and has no source available. The obvious move was to rebuild it in a modern engine. I did something else: I replaced the layer underneath the game and left the game itself alone.

The app now on the App Store is running that 2010 code. This is what that took.

Why it sat for 16 years

ANTI-ARES was finished in 2010, written in Visual C++ 2008. I built it as a Windows game and was satisfied with that. Handing out copies myself was the only distribution I could think of at the time, so it just stayed where it was. I did not decide against releasing it — I simply never got around to it.

Sixteen years later I decided to get it onto a phone.

The library was a dead end

The game was written against Dark GDK, a C++ game library from The Game Creators. It wrapped DirectX in a beginner-friendly API: you called functions like dbSprite() and dbPlaySound() and got a game. If you have not run into it, think of it as a procedural, C-style layer over DirectX aimed at hobbyists in the late 2000s.

It is a dead end three times over:

There is no path that carries Dark GDK itself to iOS. So there were only ever two options.

OptionLines to rewrite
ARebuild the game in an engine (Unity or similar)30,716
BReplace the layer beneath the game0

Why I ruled out rewriting it

I did consider A seriously. The art and the logic were both in hand, so rebuilding in Unity was not out of reach. Two things ruled it out.

The first is effort. Re-reading 30,000 lines while moving them into a different framework is not a spare-time job.

The second decided it: a rewrite gives you no guarantee that the game still feels the same. A shoot-'em-up lives or dies on a few pixels of hitbox, the exact step of a bullet's velocity, one frame of delay. If I rebuilt it and accumulated a hundred "close enough" decisions, and the result played differently — I would have no way to check. The only reference is my own memory of a game I wrote sixteen years ago.

A compatibility layer makes that problem disappear by construction. The code running is the same code, so the behaviour cannot drift. There is no fidelity work to do, because there is no gap to close.

So: B.

SDL3 as the floor

The replacement floor is SDL3. SDL gives you a window, a GPU-backed renderer, input, and (through SDL_mixer) audio, with the same API across Windows, macOS, Linux, iOS and Android. It is open source under the zlib licence.

SDL is not a game engine. Unlike Unity or Godot it has no sprite manager, no collision, no physics. It opens a window, draws a texture, and reads input. That was exactly what I wanted: an engine would have imposed its own structure on the game, whereas SDL is raw enough that I could build whatever structure I liked on top. The structure I wanted was "pretend to be Dark GDK."

The design: same signage, different building

The whole idea fits in one sentence. I put up the same sign Dark GDK had, with somebody else behind the counter.

src/  game30,716 lines
Untouched 2010 code. Calls dbSprite(id, x, y, img) and friends.
identical function names and signatures
shim/  compatibility layer3,600 lines
DarkGDK.h — declarations (88 functions)
DarkGDK.cpp — implemented on SDL3
TouchPad.cpp — on-screen controls for iOS
SDL API calls
SDL3 / SDL_image / SDL_mixer
Platform differences end here
macOS / iOS

shim/DarkGDK.h declares exactly the same names with exactly the same signatures. The game does #include "DarkGDK.h" and calls dbSprite(), and never finds out that DirectX became SDL3 underneath it.

Concretely: the ported game includes five headers and nothing else, and contains zero platform conditionals — no #ifdef __APPLE__ anywhere in the game code.

DarkGDK.h   Data.h   Jiki.h   Sound.h   TouchPad.h

I did not reimplement the API — only the part that gets called

Dark GDK is large. I implemented what this one game actually calls, and nothing else.

Count
db* functions declared by the shim88
— of those, reimplemented from Dark GDK81
— of those, additions of my own7
Distinct db* functions the game calls81

This is the reason 3,600 lines was enough. It is not "a reimplementation of Dark GDK"; it is the minimum set that makes one specific game run.

Where it was not a straight swap

This is the actual work. Matching names does not mean matching semantics.

Retained mode vs. immediate mode

Dark GDK is retained: you register a sprite by ID and the library keeps drawing it. SDL3 is immediate: every frame, you draw what you want in the order you want it.

So the shim keeps its own sprite table and sorts it by priority every frame before handing it to SDL. That is what makes dbSetSpritePriority() mean anything.

/* Use at() in the comparator. operator[] inserts when the key is
   missing, and inserting during a sort triggers a rehash. */
std::sort(ids.begin(), ids.end(), [](int a, int b) {
    const Sprite &sa = gSprites.at(a);
    const Sprite &sb = gSprites.at(b);
    if (sa.priority != sb.priority) return sa.priority < sb.priority;
    return a < b;
});

16-bit BMPs quietly broke colour-keyed transparency

Dark GDK does transparency with a colour key, and this game keys on pure green, (0,255,0). I handed that to SDL's colour-key support and green boxes showed up around the menu arrows on iOS.

The cause was in the assets. The BMPs are a mix of bit depths — 8, 16 and 24. In the 16-bit files, pure green had been rounded through RGB565/555 and came back as (0,252,0) or (0,248,0). An exact-match key does not catch that. On top of it, whether a colour key survives a surface conversion into alpha varies by platform and depth.

The fix was to stop delegating: convert to RGBA32, walk the pixels, and zero the alpha of anything within a tolerance of green. The game only ever uses pure green for transparency and has nothing else close to it, so there is no risk of punching holes in the art.

A function whose name meant the opposite of what it did

dbFlipSprite sounds like a vertical flip. It is a horizontal flip.

I worked this out from the title screen. The right-pointing menu arrow ▶ is built by applying dbFlipSprite() to the left-pointing ◀ sprite. ◀ is vertically symmetric, so a vertical flip would have changed nothing. The only operation that yields ▶ is a horizontal flip.

This matters beyond arrows: horizontal and vertical flips differ by a 180° rotation, so getting the axis wrong rotates every flipped terrain tile in the game.

Win32 wsprintf, caught by template

The game calls Win32's wsprintf in 91 places. That does not exist on iOS either.

template <size_t N, typename... Args>
int wsprintf(char (&buf)[N], const char *fmt, Args... args) {
    return std::snprintf(buf, N, fmt, args...);
}

Taking the buffer by array reference is the point. The size N is deduced at each call site, so forwarding to snprintf is safe. And if any call site had been passing a pointer instead of an array, it would fail to compile rather than silently truncating.

Two places where "do nothing" was the specification

The nastiest part of a port like this is code that depends on incidental behaviour of the original implementation. I hit two.

dbSprite() must not change visibility. The obvious implementation makes a sprite visible — you are drawing it, after all. That broke the destructible hatches: the game hides a hatch with its own hide call after it is destroyed, then calls dbSprite() to keep updating its position. Making that call visible again resurrects hatches you already blew up. The game always calls the show function explicitly when it wants something visible — I checked all 193 call sites.

dbCloneSprite() can invalidate its own source. Written the obvious way, explosions stopped rendering entirely:

gSprites[iDestination] = *src;   // dangling

Inserting a new key can rehash the unordered_map, which moves the element src points at, and the assignment then reads freed memory. The game clones an explosion sprite to a fresh ID every time one goes off, so this fires constantly. Copy to a value first, then insert.

Touch controls, without touching the game

TouchPad.cpp is 1,614 lines and is entirely new — the original is a keyboard game and there is no keyboard on a phone.

What it does is narrow: draw buttons, and turn touches into key states.

int dbKeyState(int iKey) {
    PumpForInput();
    if (ScriptHolding(iKey)) return 1;
    /* Virtual keypad. The game cannot tell this from a real keyboard. */
    if (TouchPad_IsKeyDown(iKey)) return 1;
    ...
}

The game asks for dbKeyState(44) — the Z key — and has no idea whether a keyboard or a thumb answered. That is why touch controls landed without a line of game code changing.

Layout lives in a text file and switches by context (title, in-game, paused, and so on), so the button set changes with the screen without the game knowing.

The part that had nothing to do with code

App Review rejected the first build with ITMS-90683:

Missing purpose string: NSCameraUsageDescription
Missing purpose string: NSBluetoothAlwaysUsageDescription

The game uses neither. The references came from statically linked SDL. Chasing symbols in the binary found them:

Apple flags the reference, not the use. Writing purpose strings for capabilities the app does not want seemed worse than not shipping them at all, so I built SDL with SDL_CAMERA=OFF and SDL_HIDAPI=OFF.

Gamepad support survives this. On iOS, controllers come through GameController.framework, on a path independent of HIDAPI. Measured across the two builds: AVCapture references 12 → 0, Bluetooth references 1 → 0, GCController references 5 → 5, binary 2.99 MB → 2.70 MB.

What the port turned up

An unplanned benefit: building the macOS version under AddressSanitizer surfaced a buffer overrun that had been in the game for sixteen years.

global-buffer-overflow  Jiki.cpp  Jiki::BackGroundInit()

An initialisation loop ran 2,001 iterations over arrays sized 1,301 — a counter constant copied from a different subsystem, writing 700 elements past the end. It never surfaced on Windows and I never noticed.

Nothing to do with porting as such, but a decent argument for it: moving code to another environment shows you things the original one hid.

The numbers

Lines
Original game code (2010)30,716
Compatibility layer DarkGDK.cpp / .h1,900
Touch controls TouchPad.cpp / .h1,700
Total written3,600

3,600 lines bought me 30,716 lines that never had to change. It holds a steady 60fps on device.

To be precise about "unchanged": the accurate claim is that porting required no changes to the game logic. Since the port I have changed plenty in the game itself — enemy patterns, effects, hitbox fixes — but those are gameplay decisions, not porting work.

When this is the right trade

Writing a compatibility layer is not automatically the answer. It worked here because of some specific conditions:

If the game had been deeply tied to a wide engine API, this would not have been worth it. But replacing the floor is sometimes both faster and more certain than rebuilding the house, and I think that option gets considered less often than it deserves. In my case it cost 3,600 lines.