Case Study · Software Preservation · Reverse Engineering
«Դառնալ Նախագահ», Smart Systems LLC, 2007
How I took a dead Windows game — a Flash movie welded to a .NET brain, its assets AES-encrypted, its Armenian text trapped in a font from another era — and rebuilt it as a static, plugin-free web app that runs in any modern browser.
▶ Play it — davidmcarati.info/president
Chapter 0 · The Artifact
Become President is a turn-based election-strategy game published by Smart Systems LLC in 2007, written for an Armenian audience. You run a presidential campaign against three AI opponents: courting ten voter blocs with slogans, promises and media buys, drafting a national budget, cutting coalition deals — and, if you neglect your poll watchers, losing the election to outright falsification. It shipped on CD, installed like any Windows program of its day, and today it does not start on anything.
The first clue was sitting right next to the executable, in trace.log:
Fail: Class not registered (HRESULT 0x80040154 REGDB_E_CLASSNOTREG)
at System.Windows.Forms.AxHost.CreateInstance()
at Become_President.Form_Main.Form_Main_Shown(...)
That error told me most of what I needed to know about the architecture before I had
decompiled a single byte. This is not a Flash game, and not a .NET game — it
is both, stapled together. A ~256 KB VB.NET WinForms executable holds all the game
rules, data loading and cryptography, and it embeds the bundled
Flash 9 ActiveX control (Flash9c.ocx) to render the
actual game. DirectShow (quartz.dll) plays the intro and election-night
videos. The OCX was never registered on this machine, so the Flash control can't be
instantiated, and the main form dies the moment it's shown.
I started calling the design "two brains and a bridge":
FSCommand events; .NET talks back via the legacy ActiveX API (SetVariable, GetVariable, CallFunction).
You could fix the crash with regsvr32 on a period-correct Windows box. But
resurrecting a dead plugin on a dying platform isn't preservation — it's life support.
The goal became a real port: the original artwork and animation, running in a modern
browser, forever, with nothing to install.
Chapter 1 · The Vault
All of the game's content lives in 25 files under Data\ with the extension
.dat — 16 MB of opaque ciphertext. No SWF headers, no XML, nothing
file could identify. The content had to come from the brain, so I decompiled
the brain: ilspycmd recovered the full, unobfuscated C# source of the
executable — 36 files, 8,138 lines, with original class names intact.
Two files answered everything. PESymetric.cs implements the crypto:
AES-256-CBC with PKCS7 padding, key and IV derived via PBKDF2
(SHA-1, salt "This is my sa1t" — note the deliberate digit — and a distinctly
2007-era iteration count of ten). And Globals.Gides() holds the
password. Rather than store it as a string literal where any hex editor would find it,
the developers stored a list of integers and reconstructed the password at runtime by
alternately subtracting and adding one:
| gides[] | 54 | 106 | 107 | 106 | 116 | 121 | 113 | 105 | 50 | 47 |
| index parity | −1 | +1 | −1 | +1 | −1 | +1 | −1 | +1 | −1 | +1 |
| password | 5 | k | j | k | s | z | p | j | 1 | 0 |
Globals.Gides(): even indices −1, odd
indices +1, yielding the AES password 5kjkszpj10. Obfuscation, not
cryptography — but nineteen years ago, against casual copying, it worked.
It took me a few hours of staring at kjkszpj before it clicked:
that is the GTA: San Andreas weapons cheat code. Some developer in
2007 needed a password, typed the one string his fingers already knew by heart, and
wrapped it in 5…10. Easily the most fun discovery of
the whole project — and a reminder that the human sitting behind a cipher is usually
the most interesting part of it.
I wrote a standalone PowerShell decryptor (_tools/decrypt.ps1) that rebuilds
the key the same way, decrypts every .dat, and names the output by sniffing
magic bytes — CWS means SWF, < means XML,
30 26 B2 75 means Windows Media. All 25 files decrypted
cleanly on the first run, and sorted themselves into three groups:
| Group | Files | What came out |
|---|---|---|
| Flash | 2 | scn.swf (3.4 MB — the entire game UI) and msc.swf (763 KB — a music player). Both SWF v8, ActionScript 2. |
| Video | 2 | int.wmv and elc.wmv — the intro and election-night clips, WMV3/WMA2, 800×600. |
| Game data | 21 | Plain, human-readable XML: 3,891 rows of slogans (571), promises (1,225), quiz questions (683), offers (496), negotiation clauses (342), plus difficulty tuning, budget coefficients, media pricing… |
Both SWFs are Flash Player 8 / AVM1 — the older ActionScript 2
virtual machine, which Ruffle (a Flash Player emulator
compiled to WebAssembly) supports essentially completely. The moment the SWF version
byte read 8, the port went from "maybe" to "planned."
One decrypted SWF also settled the rendering question for good: parsing
scn.swf's tag table revealed zero bitmap tags. All of the
2007 art — the game board, seasons, characters, buttons — is pure vector: 690 shape
definitions, ten embedded fonts, 28 embedded sounds, and not a single JPEG. That's a
big part of why the game renders faithfully under emulation, and why it scales
razor-sharp to a 4K display that didn't exist when it was drawn.
Chapter 2 · The Contract
With assets in hand, the port needed a precise inventory of how the two brains spoke to
each other. The Flash movie is unusable without the .NET host — every button ultimately
fires an FSCommand and waits for the host to push new state back. So I went
through the 81 KB of decompiled Form_Main.cs line by line and catalogued
the entire protocol:
| Direction | Mechanism | Call sites | Purpose |
|---|---|---|---|
| Flash → host | FSCommand(cmd, args) | 40 commands | Every player action: clicks, menu choices, save/load, exit |
| Host → Flash | SetVariable(name, value) | 134 | Push state into timeline variables the SWF reads |
| Host → Flash | GetVariable(name) | 37 | Read player inputs back (name, options, budget sliders…) |
| Host → Flash | CallFunction("<invoke…>") | 27 | Invoke a named AS2 function — always zero-argument |
| Host → Flash | TSetProperty(path, 7, v) | 2 | Toggle _visible on one movie clip (property index 7) |
The protocol has a distinctive shape: the host never passes arguments to functions.
Instead it pushes dozens of variables — PlayerMoney,
SloganText1..5, YouthPers — and then calls a no-argument
function like OpenSlogans that reads them. That "write everything, then
ring the bell" style turned out to matter enormously in the next chapter.
The same pass documented the save system (ten slots, the whole Game object
XML-serialized and AES-encrypted with the same key), the options file, and the
high-score table — everything the browser port would need to re-home in
localStorage.
| FSCommand | Args | Host action |
|---|---|---|
PlayerLevelNumber | level 0–2 | Start a new game at Beginner / Advanced / Expert |
ChooseSlogan | index 0–5 | Commit the round's slogan choice |
ChoosePromises | csv of 5 flags | Commit up to five campaign promises |
TryYourSkillsAnswerClick | answer 1–5 | Answer a quiz question for sector sympathy |
DraftBudgetDone | — | Validate the 12-line budget (must sum to 85–115) |
StartNegotations | opponent | Open coalition talks with a rival |
SaveGame / LoadGame | slot 1–10 | Persist / restore, with overwrite confirmation |
NextLevelClick | — | End the round once all ten steps are complete |
ExitSystem | — | Close the app (a no-op on the web) |
Chapter 3 · The Pivot
Here the plan hit its one architecture-defining obstacle. Ruffle deliberately does not
implement the legacy Flash plugin/ActiveX API — SetVariable,
GetVariable, CallFunction, TSetProperty are all
unimplemented (ruffle#7868).
That is every single host→Flash channel in the table above. The inbound
direction was fine — Ruffle's addFSCommandHandler covers all 40 FSCommands —
but the host had no way to talk back. For a day, the fallback plan (boot an entire
Windows image with real Flash inside the v86 x86 emulator — heavy, but
guaranteed) looked uncomfortably plausible.
The way out came from a detail of how Flash itself was built:
the ActiveX CallFunction mechanism is the same transport that
ActionScript's ExternalInterface rides on. For the original host's calls to
have ever worked, the SWF must already register those function names via
ExternalInterface.addCallback — and Ruffle supports
ExternalInterface for AS2 movies completely. So I decompiled
scn.swf with JPEXS into 1,046 ActionScript files and grepped. Every one of
the 27 function names the host calls — PlayGameBoard,
UpdateBoard, OpenSlogans, ShowElectionResult… —
was already registered, sitting there waiting for a caller that no longer existed.
Function channel: works unmodified via Ruffle's
callExternalInterface. FSCommand channel: works unmodified.
The only thing genuinely missing was the humble variable channel — so the entire
bridge problem reduced to injecting three tiny callbacks into the SWF's
first frame.
Using JPEXS in script-import mode, I appended a shim to frame 1 — carefully
preserving the original frame's two existing lines — that registers
bp_setVar, bp_getVar and bp_setVis, then reports
its own health back over the one channel already known to work:
// injected into scn.swf frame 1 (after the original two lines)
var __ei = "no";
try {
if (flash.external.ExternalInterface.available) {
ExternalInterface.addCallback("bp_setVar", _root, function(n, v) { _root[n] = v; });
ExternalInterface.addCallback("bp_getVar", _root, function(n) { return _root[n]; });
ExternalInterface.addCallback("bp_setVis", _root, function(p, vis) {
var t = eval("_root" + p.split("/").join(".")); // "/game_board/info/old_but"
if (t) { t._visible = vis != 0; } // → _root.game_board.info.old_but
});
__ei = "yes";
}
} catch (e) { __ei = "err"; }
fscommand("bp_ready", __ei);
A small favorite: bp_setVis translates the .NET-era slash path
(/game_board/info/old_but) into an AS2 dot path with
split("/").join(".") — thirty years of Flash path syntax bridged in one
expression. The injected SWF is exactly 1,277 bytes larger than the
original, and the repo keeps two full re-decompilations of the patched movie
(_tools/verify_scn, verify_scn2) so the injection itself is
auditable — you can diff the two iterations of the shim design.
Chapter 4 · The Rewrite
With the bridge solved, the remaining work was a disciplined translation: 8,138 lines of decompiled C# (plus 9,144 lines of decompiled ActionScript as the reference for what the SWF expects) distilled into 3,435 lines of TypeScript. No framework, no canvas code — the TypeScript is purely the brain:
| Module | Lines | Role |
|---|---|---|
game/model.ts | 1,551 | Domain model: Game, Player, sectors, slogans/offers/promises, serialization |
game/engine.ts | 689 | Per-turn actions, opponent AI, the election itself (72 methods) |
host.ts | 557 | 1:1 port of Form_Main — the FSCommand switch and all state pushes |
main.ts + bridge.ts | 202 | Boot, Ruffle wiring, the interop layer |
storage.ts / video.ts / types.ts | 308 | localStorage persistence, video overlay, 13 enums ported value-for-value |
game/rng.ts + game/armenian.ts | 100 | VB6 randomness and legacy text encoding (below) |
A simulation game is its arithmetic. To keep the ported game feeling identical,
the TypeScript reimplements Visual Basic's actual numeric behavior rather than
approximating it: VB6's 24-bit linear congruential Rnd generator
(seed 0x50000, multiplier 0x43FD43FD), VB's
Int() floor-toward-negative-infinity, and .NET's banker's rounding
(MidpointRounding.ToEven) everywhere the original rounded money to two
decimals.
Fidelity also meant porting the bugs. The original's loop that deals five slogans from the pool increments its index even after removing an element — so it can skip one — and its "does this action have any positive influence" test contains a self-referential accumulator that almost certainly wasn't what the 2007 author intended. Both quirks are replicated, with comments explaining why:
"NB: the original increments the index on every iteration even after a RemoveAt (so an element can be skipped). Replicated for fidelity."
— web/src/game/model.ts
The alternative — "fixing" them — would silently change the deck-dealing distribution and the AI's spending decisions, and the port would no longer be the same game.
The 21 XML tables (converted to JSON at build time) are the whole political economy:
ten voter sectors with money coefficients from 8 (Businessmen) down to 0
(Youth, Unemployed, Housekeepers bring votes but no cash); three difficulty tiers where
Expert stretches to nine rounds while cutting your starting purse and the opponent
handicap; 14 media formats priced per sector across 140 tuning rows. And one rule I
can't resist quoting from the decompiled source: if you finish below your trustee quota,
GetElectionsFalsificatedOrNot() rolls Int(10 * Rnd() + 1) > 3
— a flat 70% chance the election is stolen. Fail to staff the polling
stations and no amount of popularity saves you. In a 2007 game about Armenian
presidential politics, that mechanic is its own commentary.
Chapter 5 · Integration
A ported brain and a bridged SWF still had to be made to behave. Every fix in this chapter came from a symptom that made no sense until it did.
Ruffle invokes the FSCommand handler synchronously, from inside its own execution.
Call back into the player from that stack frame — even a harmless
getVar — and the call is silently dropped. The fix is one line with a long
comment: every incoming command is deferred with setTimeout(0), which moves
the handling outside Ruffle's call stack while preserving FIFO command order. The
original COM host never had this problem; single-threaded ActiveX re-entrancy just
worked. Emulator boundaries have their own physics.
Early on, the player's money never changed on screen — though saves proved the
engine was computing it correctly. The decompiled SWF explained it: the board's text
fields don't watch _root variables; they re-read them only inside
UpdateBoard, and only where a prev_* shadow copy differs.
Setting variables moves no pixels. The host now calls UpdateBoard after
every push, exactly as Form_Main.UpdateBoardVariables always had.
The board's button tooltips read variables pushed once at boot. Click "New game" fast enough and the board opened before the push landed — blank buttons, but only on the first visit, since by the second visit boot had long finished. Classic misleading symptom; the fix is to re-push the tooltips on every board entry.
The original host's readiness strategy was brute force: try CallFunction,
catch the failure, Thread.Sleep(100), repeat up to fifty times. The port
replaces the sleep loop with the injected shim's fscommand("bp_ready", __ei)
hello, then runs a round-trip self-test at boot — write bp_test = "123"
through the bridge, read it back, log the verdict — so a broken bridge announces itself
in the console instead of as mysterious downstream silence.
window.confirm in the middle of a 2007 vector world is jarring, so the port
drives the SWF's own modal instead: set _root.MessageText, call
MessageBoxOK or MessageBoxYesNo (the choice comes from the same
msg data table the original used), and await the answer as an FSCommand —
wrapped in a promise so overwrite-confirmations sequence correctly.
One inconsistency was left in place deliberately: the SWF's quiz music emits a lowercase
fscommand("stopMusic") that no host — the 2007 one included — has ever had a
case for. It hits the port's default: branch and logs itself. Some bugs are
heritage.
Chapter 6 · The Alphabet
The strangest problem in the project wasn't binary at all. The decompiled C# was full
of string constants like "ÀÝïñÇñ ϳñ·³Ëáë" — Armenian text, but not in
Unicode. The game predates practical Unicode-Armenian workflows: its text is
single-byte ANSI whose codepoints map to glyphs in legacy Armenian
fonts embedded in the SWF (ArTarumianHelvetica ExtraBold,
Arial Armenian, ArmTitle — 350 KB of vector glyphs
across ten fonts).
Matching the mojibake against phrases the game had to contain cracked the scheme.
"ÀÝïñÇñ ϳñ·³Ëáë" had to be «Ընտրիր կարգախոս» — choose a slogan.
Lining the bytes against the letters revealed an interleaved alphabet:
uppercase and lowercase Armenian letters alternate byte positions —
| Byte | 0xB2 | 0xB3 | 0xB4 | 0xB5 | 0xB6 | 0xB7 | … |
|---|---|---|---|---|---|---|---|
| Glyph | Ա | ա | Բ | բ | Գ | գ | … |
| Rule | uppercase letter i = 0xB2 + 2i · lowercase letter i = 0xB3 + 2i | ||||||
Which produced the project's most user-visible problem: a modern Armenian keyboard types real Unicode (U+0531…U+0586), the embedded font has no glyphs there, and the name-entry field renders rectangles. The fix runs on both sides of the bridge:
onChanged handler that live-rewrites typed Unicode Armenian into the
legacy bytes, preserving the caret position via Selection.getCaretIndex().
Latin, digits and punctuation pass through untouched, so English names still work.
toLegacyArmenian() re-encodes anything that
slipped through, idempotently, as a safety net before the name reaches the board,
saves, and high scores.
A final wrinkle worthy of the era: the injected placeholder text itself had to be
written as String.fromCharCode(178, 221, 225, 245, 221, 185), because the
decompiler's script-import would mangle raw high bytes in source. Encoding problems all
the way down — solved by refusing to let any tool interpret the bytes at all.
Chapter 7 · Shipping
The two DirectShow clips decrypt to WMV, which no browser will touch, so they were
transcoded once with ffmpeg to H.264+AAC with +faststart — and came out
smaller than the originals (the intro dropped from 4.2 MB to 2.3 MB).
They play in a full-screen HTML5 overlay above the Ruffle stage: the intro fires
without await so the SWFs load behind it; the election clip is awaited,
with both the success and error paths routing to the results screen, matching the
original's catch fallback.
The best web-specific war story is autoplay. Chromium was lenient at cold boot, but
Brave blocks autoplay-with-sound outright — and the failure mode compounded: the
rejected video.play() shows a ▶ Play button, but the overlay's
click-to-skip handler would then treat the very click meant to press Play as a
skip, dismissing the intro before it started. The fix is choreography: skip is only
armed once the playing event actually fires, and the Play/Skip buttons
stopPropagation(). As a deliberate web-era mercy, the election clip is
skippable here; the 2007 one made you watch.
Music kept its original architecture, which I find charming: msc.swf is an
entire Flash movie whose stage is 2×2 pixels and whose only cargo is one 768 KB MP3.
Rather than re-implement it in Web Audio, the port runs it exactly as the original host
did — as a second, hidden Ruffle player driven by PlayMusic /
StopMusic calls, with volume flowing through the same
_root.MusicVolume variable into AS2's Sound.setVolume().
The build output is a fully static dist/ — HTML, JS, two SWFs, two MP4s,
and Ruffle's two ~13–14 MB WASM cores. No server-side anything; Node exists only at
build time. It deploys to bare Apache, S3, or GitHub Pages, works from a subdirectory
(base: "./"), and the only operational notes that matter are a
application/wasm MIME type and gzip on the cores (a ~4× transfer cut).
Saves live in localStorage as plain JSON — ten slots, options, and the
top-ten high-score table, seeded on first run from the decrypted 2007 defaults.
Epilogue · The Scorecard
| Concern | Original (2007) | Port (2026) |
|---|---|---|
| Rendering | Flash 9 ActiveX control in WinForms | The same SWF, emulated by Ruffle (WASM) |
| Game logic | VB.NET, ~8,100 lines decompiled | 3,435 lines of TypeScript |
| Host → Flash | SetVariable / CallFunction (ActiveX) | ExternalInterface + 3 injected shims (+1,277 bytes) |
| Asset protection | AES-256 with a key hidden as ±1 integers | None — the point is that it's readable |
| Videos | DirectShow playing decrypted WMV temp files | HTML5 <video>, transcoded MP4 |
| Saves | XML → AES → Saved Games\*.sav | Plain JSON in localStorage, same 10 slots |
| Blocking the right-click menu | A Win32 window-proc subclass swallowing WM_RBUTTONDOWN | contextMenu: "off" — one config flag |
| Waiting for Flash to load | Up to 50 × 100 ms blocking sleeps | One fscommand("bp_ready") event + a boot self-test |
| Armenian text | Legacy single-byte font encoding, ANSI everywhere | Same bytes preserved, plus a live Unicode→legacy shim for input |
| Distribution | CD, installer, COM registration | A folder of static files on any web host |
The number I keep coming back to: roughly 17,300 lines of recovered legacy source, studied and distilled into 3,435 lines of TypeScript plus about thirty lines of injected ActionScript — while the original 2007 movie, byte-for-byte except those thirty lines, still draws every frame. That's the shape of preservation I was after. Not a remake, not a video of the game, but the game — its vector art, its quiz-show music stings, its 70%-rigged elections — running natively in the one runtime that seems likely to outlive us all: the browser.