Star Wars: Force Commander

September 23, 2026 · View on GitHub

Static recompilation of Star Wars: Force Commander (LucasArts, 2000) from its shipping Win32 binary to native C.

Built on the pcrecomp toolchain, and next to xwa — same publisher, same year, same studio's tooling.

Project Status: It is in game. The campaign's briefing room, in 3D.

build/focom.exe game/Focom.exe --run \
    --nocond 25 3 83 --varat 182 40 12 1 \
    --mousescale 1.25 --clickat 120000 --clickgap 15000 \
    --click 227 145   # Single Player
    --click 577 427   # forward  -> SINGLE PLAYER page
    --click 227 145   # Campaign
    --click 577 427   # forward  -> the Imperial hangar, in 3D

The last click does not open another page. Every UI rectangle disappears except the cursor and one arrow, and the frame becomes a fully 3D interior: curved hangar walls, a ramp, and a holographic briefing table with a blue ring and a green tactical display on its console. 1,331,273 primitives rasterised by present #24150, 306,510 of 307,200 pixels non-black, holding and fading in over hundreds of frames. That is 0007 - EmpireHangar / stateEmpireHangar out of the exe's own 63-entry state table at 0x008595E0 -- where a Force Commander campaign begins.

Two flags in that command are cheats, and both say so in their own comments. --nocond steps over a disc check for a disc that is present; --varat answers a name check for a name the front end will not accept typed input for. They stand in for two bugs that are located to the point of proof and written up in docs/STARTUP.md: the font sheets are stored upside down, and a keystroke reaches the window procedure but not the name field.

No screenshot of it here: a rendered frame of the game's own artwork is retail content, and this repository does not carry any.

Before that: the splash screen, drawn by lifted code.

The first thing it ever drew. 640×480, pixel-exact, and every pixel is produced by sub_00401770 — Force Commander's own splash dialog procedure, lifted to C — running on a host that owns nothing but the window. The title text is the game's too: CreateFontA sized from the window rect, then TextOutA twice, black then yellow one pixel up and left, for the drop shadow. (A capture of it used to live here; it is the game's own splash art, so it went.)

$ build/focom game/Focom.exe --splash
  import bridges:               307
  IAT slots self-patched:       307
  mapped game/Focom.exe: 0x00400000 + 5632000 bytes
  WM_INITDIALOG -> lifted 0x00401770
  [shim] LoadBitmapA(102): 640x480 8bpp, 256 colors, 308264 bytes
  [shim] StretchBlt dst=0,0 640x480 src=0,0 640x480 rop=00CC0020 -> 1
  real shims installed:         71

Two functions lifted to get there (the dialog proc and __chkstk), 71 of 307 imports with real bodies. Getting in game was a different order of work — see The road to in-game.

recovered function starts34,674
byte coverage of .text98.8%
classes recovered from RTTI567
vtables / virtual methods1,121 / 5,436
methods attributed to one class4,832
functions attributed to a source file738
game-specific classes6
lifted, whole binary39,038 functions, 10.7M lines of C, 0 errors
real import bodiesall 307 bridged; DirectDraw 7, Direct3D 7, DirectInput, DirectSound, DirectPlay, SMUSH as COM/DLL shims

Read docs/RECON.md first. It is the primary P1 document: the RE3D/Ronin/GEAPI/DX7 namespace map, the renderer's interface seam, and the decoded .rpk, .znm/SMUSH and .M3D formats. This README covers P0, P2 and what was added afterwards; it does not repeat RECON.md.


P0: what the binaries are

BinarySize.textBuiltRole
Focom.exe4,943,8723,936,8822000-03-03the game
Force.exe106,496—2000-02-29launcher
Smush.dll147,456—1999-02-27LucasArts SMUSH video (4 exports)
mss32.dll328,704—1999-01-04Miles Sound System
FocomSetup.dll843,776—2000-02-29installer

MSVC 6.0, MSVCP60.dll with 82 imports — STL-heavy C++, and both the CRT and the standard library are dynamic, which is a smaller classification problem than a statically linked CRT (cf. Monster Truck Madness).

No DRM, no .reloc, fixed base 0x00400000. 3.94 MB of .text makes this the second-largest target in the collection; only Rise of Legends (13.25 MB) is bigger, and that one is explicitly a stress test rather than a project. This is roughly 1.5× X-Wing Alliance and 4× Crimson Skies.

The import table understates the runtime surface

307 imports across 11 DLLs, and not one of them is DirectX:

KERNEL32 63   MSVCP60 82   MSVCRT 67   USER32 44   mss32 22
WINMM 8   GDI32 11   ADVAPI32 5   ole32 3   SHELL32 1   DINPUT 1

DDRAW.DLL, SMUSH.DLL, FEELIT.DLL and DINPUT.DLL are LoadLibrary'd; DirectPlay, DirectMusic and the video path arrive through CoCreateInstance. RECON.md makes this point from the GUIDs in .rdata; the import table confirms it from the other side.

The consequence for bring-up, as written at P0: runtime/compat/win32_compat.h sorts 275 imported Win32 APIs into keep/shim/SDL2/stub, and that machinery does not reach an interface pointer called through a vtable. Budget for interface shims. That is what happened: src/runtime/ddraw_shims.c builds the DirectDraw 7, Direct3D 7 (all 49 device methods), DirectInput, DirectSound and DirectPlay objects inside the target's address space, and raster.c is the software rasteriser behind them.


P2: the classification is free, because RTTI was left on

567 classes out of one tools/cpp/rtti.py run. For scale: Black & White has 569 types and they were recovered by hand — that project is what all of tools/cpp/ was built for. Same class count here, with names, vtables and inheritance chains, in seconds, because LucasArts shipped a release build with RTTI enabled.

RECON.md maps the four engine namespaces. What it left as "plus Subsystem, GamePPMultiplayer, GamePPVisBase and friends" is 237 of the 567 classes, and they sort cleanly by name prefix:

FamilyClassesvtable slotsWhat it is
GamePPGlobal*1454,274Platform/game layer — resources, events, managers, and the script AST
GamePPSys*671,650Framework core: Object, Process, Event, Message, Resource, Library, Variable, State, Undo
GamePPVis*251,053A visual editor. Shipped in the retail binary.
GamePPMultiplayer*20655DirectPlay
Focom*6161The entire game-specific surface
GamePPProd* / App / Log3247Startup and logging

82% of all 567 classes have a recovered base class. (Namespace counts here are by outer scope after stripping template arguments; they run a little higher than RECON.md's for RE3D and Ronin, which is a counting-method difference rather than a disagreement — RECON.md's table is the one to quote.)

Six classes are game-specific. Six, out of 567.

FocomStartup                       FocomEmitterObjectCallback
FocomPassCallback                  GamePPFocomSystemManager
GamePPFocomLandscapeResourceData   GamePPFocomLandscapeResourceType

Plus a handful of global-scope render-pass callbacks — SkyObjectCallback, ShadowObjectCallback, RadarObjectCallback, CFOWOverlayCallback (fog of war), GlobalWeatherObjectCallback, CSmushMemRenderCallback.

This is the Gunman Chronicles shape — 78% of the binary is the SDK, and only 499 of 3,990 functions need real work — except Gunman needed a four-pass classifier to prove which functions were SDK, and that classifier is all of tools/classify/. Here the answer is in the type names, and classify/ does not need to run at all.

So this is a thin game on a large unreleased in-house engine, and the recompilation is mostly an engine recompilation. The payoff is bigger than one title: anything else built on Ronin/GamePP gets cheap afterwards.

Mission logic is a visual script, not native code

38 GamePPGlobal* classes are AST nodes with their own vtables:

If  Else  ElseIf  For  EndFor  While  EndWhile  Switch  Case  CaseOr  CaseRange
CaseOrRange  DefaultCase  Do  Loop  LoopForever  LoopVar  EndLoop  EndIf  And
Or  Assert  Comment  NOOP  Stop  CallParent  EnumDef  ArrayTypeDef  ConstTypeDef
MessageTypeDef  ResourceTypeDef  EventFunctionTypeDef  Argument{Expr,VarDec,…}

With GamePPSysCodeBlock, GamePPSysCodeContainer, GamePPSysLibraryProc, GamePPVisCodeBlock, GamePPVisCodeClipboard, the info.pro / code.bin filenames in .rdata, and Subsystems as the first asset category in the .rpk string table, that is a complete in-house visual scripting language and its editor, compiled into the shipped game.

Force Commander therefore lands on the same shelf as Encarta 97, the Magic School Bus and Prodigy: the interpreter is the code and the behaviour is data. Recompiling the binary gets the VM; code.bin and the Subsystems members of the .rpk are the other half of the project.

Audio has two managers, GamePPGlobalSysMilesSoundManager and GamePPGlobalSysiMuseSoundManager — iMuse, LucasArts' interactive music system, alongside Miles. And GamePPGlobalSysFEELitMouseManager goes with the FEELIT.DLL string: Immersion force-feedback. Genuinely obscure.


Correction: RTTI is a symbol source, not a recovery pass

This was asserted the wrong way round here and is worth recording properly.

tools/cpp/rtti.py's docstring says the method addresses are proof of function entry points, which is why this is worth running before disassembly rather than after, and the obvious inference is that seeding a 3.94 MB C++ binary with 1,121 vtables should find functions a branch scan cannot. Measured, it finds essentially none:

RTTI virtual methods5,436
already in the disassembler's catalog5,427
not found by disasm329
…of those, new functions rather than alternate entry points0

All nine land inside a body the sweep had already decoded, against 34,674 recovered starts at 98.8% byte coverage. vtable_scan.py measured against RTTI as truth found 91.6% of its methods and proposed 688 more, of which 109 were absent from the catalog — so the more generous of the two passes contributes at most 109 addresses out of 34,674.

That is now measured on two binaries that differ in nearly everything that should matter (Trespasser: 7.8 MB, has a linker map; this: 3.9 MB, none) with the same answer both times. E9 seeding plus the fixpoint is what finds the functions. See pcrecomp docs/CONSOLIDATION.md items 5 and 6 for the full write-up.

What RTTI is worth here is names, and on a stripped retail binary with no symbols of any kind that is the whole point: 4,832 methods attributed to one of 567 classes plus 738 functions attributed to a source file means 5,570 of 34,674 functions (16%) stop being sub_004A1C30. That is what makes lifted code readable and a crash stack worth reading.

analysis/rtti_seeds.json, analysis/vtable_seeds.json and their union in analysis/seeds_union.json (6,124 addresses) are kept as a names/methods index, not as a seeding input.


The road to in-game

The splash screen was reachable because it is a closed loop: one dialog procedure, two GDI calls, a resource already inside the exe. Nothing else in this binary is like that. The inventory below was written when only that loop worked; the status column is what happened since.

#WorkStatus
1The function catalog. Needed before any closure can be computed.done — 38,908 entries
2Lift the startup closure and walk the failures.done, and then some — the whole binary lifts, 10.7M lines of C, 0 errors
358 __thiscall MSVCP60 shims — "the wall".done — real basic_string with the MSVC 6 COW layout. What is left of MSVCP60 is data symbols (vtables, npos, _Nullstr), not code
4DirectDraw 7 over the DIB.done — COM object model in the target address space, 640×480 16bpp primary surface, device enumeration, GetCaps
5The .rpk reader. 274 MB, and nothing loads without it.done — tools/rpk.py; 9,554 members tile the archive with no gap and no overlap
6Stubs that must not lie: DirectInput, Miles (22), SMUSH, DirectPlay.partly — Miles and WINMM are still stubs; nothing has needed them yet
7Whatever the lifter gets wrong across 34,674 functions.two found, two fixed — see below

Item 3 was called the real gate and item 7 the real risk. That was the wrong way round. The STL came out in a day; the lifter bugs were the expensive part, and both of them truncated function bodies silently, so the generated C compiled, ran, and quietly did less than the original:

  • a fixed 512-byte window in the recursive descent, which cut every body with a longer straight run — WinMain lost everything past 0x004012AE and fell off its own end into the CRT, which then stored the result through a clobbered ebp;
  • the tail-call guard being given the catalog's 4,234 alias entries, so a jmp to a second entry point of the same function ended the descent, and the arm reachable only through it became an unresolved ITAIL at runtime.

A third was chased and turned out not to exist. 4,025 bodies appear to end on a lea, which cannot end a function, and that looked like the catalog's end being too small — but an indirect tail call (jmp [eax+0x18], which is every vtable thunk in this binary) emits as an ITAIL dispatch block, so the last instruction comment in the body is the one before the jump. Measured properly, 14 of 38,908 bodies never reach a terminator, all one-block fragments at false starts, and raising the bound for them changes the generated C by nothing. The catalog's end is trustworthy. run_lift.py now reports the number and leaves it alone.

The other finding is that not one of the sixteen blockers between the splash screen and here was a defect in the lifted code. Every one was an infidelity in a hand-written Windows or CRT reimplementation. docs/STL-GATE.md argues from that to a 32-bit host, and the argument has only got stronger.

Where it actually stops

The game runs its own startup, driven by its own script -- see docs/STARTUP.md, which is the other thing that had to be recovered: Focom.ini is not a settings file but a directive list, and the disc ships it as zero bytes because the installer writes it.

CheckAppMutex FORCE       -> CreateMutexA
CheckCD <installdir>      -> GetVolumeInformationA, label FOCOM_1
LoadAppFileName ...       -> reads Resource/appname.ini
ShowLoadingPanel          -> CreateDialogParamA(101), dialog procedure
                             0x00401770 -- lifted -- and it paints
InitBase                  -> builds GamePPProdBase, creates its registry key
RPKDir / Workspace        -> opens forcecommand.rpk, 288,585 reads,
                             compiles 1,713 object templates
Movies / Music / GameFiles / Players
Run 2 1 6                 -> starts the "Trasse - Day" section as a process
                             on a Ronin worker thread
HideLoadingPanel

CreateThread is honoured -- one thread runs lifted code at a time, with the switch points at the blocking shims -- and the section's script really runs.

Then RE3D comes up: a 640x480 16bpp mode, a flipping primary with a back buffer, an attached Z buffer, a Direct3D 7 device, texture formats enumerated, textures uploaded, render state and materials set. All 49 IDirect3DDevice7 methods are implemented, with the purge count of each taken from the headers. Nothing is rasterised yet -- Clear really clears the render target and the DrawPrimitive family accepts and counts its vertices.

Getting that far took one discovery and four corrections, all of the same shape, and docs/STARTUP.md has them: the game's screen gate reads dwDeviceRenderBitDepth at a hardcoded device + 0x454, which is the HAL slot of the four device descriptions it keeps inline, so offering only the RGB software device made it skip the entire screen and renderer setup and run its front end for 61 frames with nothing to draw on.

And then it draws. Every reading of the stall pointed at the boot script -- one context on Wait Forever, one on a Wait If that never cleared -- and the script was fine. Its thread was being shot, by one shim:

u32_MsgWaitForMultipleObjects passed nCount = 0 with no handle array, and read its timeout from ARG(2), which is bWaitAll. The return value of that function is a position in the handle array, so with nCount = 0 "a message arrived" comes back as WAIT_OBJECT_0 + 0 -- byte-for-byte what "handle 0 signalled" looks like. Handle 0, for every Ronin thread, is its stop event, and a boot process lives exactly as long as its boot thread. The first stray mouse message ended the game's first section; the frame count that varied run to run (61, 68, 164) was just how long it took one message to arrive.

With the handles passed through, BeginScene, SetTransform, SetViewport, Clear, EndScene and Flip all run, CDD7FSScreen::Present is reached, and the window shows frames:

[present] #1 surface 0x10D62090 640x480: 307200 of 307200 pixels non-black
[present] #3 surface 0x10D62090 640x480: 0 of 307200 pixels non-black
[present] #4 surface 0x10D62090 640x480: 307200 of 307200 pixels non-black

And then it renders. src/runtime/raster.c is a software rasteriser for the DrawPrimitive family -- half-space edge functions, gouraud diffuse, one modulated texture stage, alpha blending, the alpha test, a 16-bit depth test -- and with it, plus two things that had to be right first, the game boots into its front end and draws its title screen, presenting around 3,000 frames a run.

The two: 130 vtable slots were never lifted, because MSVC's virtual-inheritance adjustor thunks are eight bytes that nothing calls and nothing falls into, so RECOMP_ICALL answered the slot with eax = 0 and a renderer took that null for a render stage. And IDirect3DDevice7::Load was a no-op, which is how every texture stayed black: the game locks a system-memory surface, writes the image, and calls Load to move it into the texture it draws with.

Two more were found by looking at the frames rather than the code. A bit depth is not a pixel format -- the font atlas is ARGB1555 and decoding it as 565 painted a solid red panel over the game's own title -- and Clear ignored D3DCLEAR_ZBUFFER.

And it is a menu. One value explains why it was not: the front end draws its menu with a vertex diffuse of 0x00FC0000 -- a real red, and an alpha of zero -- against ALPHAOP = MODULATE, ALPHAARG1 = TEXTURE, ALPHAARG2 = CURRENT. Reading that literally multiplies every glyph by zero, and with the alpha test the game also sets, 6 of 500 UI draws painted anything. Taking alpha from the texture alone puts Single Player / Multiplayer / View Installation / Show Credits on the screen.

And it draws the scene behind the menu. The frame used to be a menu panel on black, and the black had a name: BeginStateBlock/EndStateBlock/ ApplyStateBlock were no-ops. Direct3D RECORDS state between Begin and End rather than applying it, so with recording unimplemented every state in every block was applied the instant it was recorded and never applied again -- and the live state at every draw was whatever the last block to be BUILT had wanted. The 3D backdrop was therefore drawn with the 2D front end's state: blending on, depth writes off, and an alpha test it never asked for. With state blocks implemented, the AT-ATs walk, the stormtroopers advance and the explosions burn behind the menu.

And it responds to a click. That took finding out that there were two windows -- visibly two, a fullscreen white one with "Force Commander" in yellow at the bottom (the game's own, with its GDI loading panel) and a smaller one beside it with the frames. The game's is the one Windows delivers input to. There is one now: host_present blits into the game's window and the host's is never shown when the game is going to make its own. An earlier note blamed cross-thread GDI for making that too slow to use, at 39 presents a run; the real culprit was ShowWindow, which SENDS its message to a thread that never pumps. Presenting into the game's window measures 4,400 presents in 130 s.

--uimap prints the hot rectangles so click coordinates are read off the draws instead of guessed:

140,130  175x 30  centre 227,145    Single Player
538,388   78x 78  centre 577,427    forward

The mouse turned out to be DirectInput only -- the game's own window procedure dispatches messages 7..0x100, 0x101 and 0x102..0x112 and throws everything else, so WM_MOUSEMOVE and WM_LBUTTONDOWN reach it and are discarded -- and the cursor is driven by relative deltas divided by a sensitivity, which --uimap calibrated by reading the cursor's own 51x51 quad back: --mousescale 1.25 puts an aim at 227,145 on the pixel.

With that, Show Credits works: sixteen script blocks that had never run start and the panel fills with a scrolling credits roll. View Introduction works, and so does exit, which brings up a confirmation page with a button each side.

Single Player and Multiplayer do not. The menu is a 217-line script block -- a Switch on the row with a Case per item -- and Single Player's Case runs its eleven set-up calls to completion: it sets a Bool, an Int and an Enum and posts a Message. The front end's 309-line page controller then reacts, running ten lines it had never run. And the page does not change: click Single Player, then the exit arrow, and the quit confirmation comes up instead of a return to the menu, which is what settles it. docs/STARTUP.md has the trace and the five things this is not -- including one earlier reading of the same trace that --switchtrace proved wrong.

What it waits on is now located to a line. --scripttracefrom MS --scripttracefor MS bounds the script trace to the seconds around a click, and tools/stepdecode.py collapses 30,000 [step] lines into the sequence each block ran with a class name per line. A 25-line block that had been parked on a Wait If for the whole run wakes up on the click and enters a While ... Wait ... EndWhile that never exits. --nodedump read that While's condition straight out of the bytecode -- GamePPGlobalSysWhile::Execute keeps the operand count in [args], the jump target in [args+8] and the operands at args+0xC -- and it is one operand, variable slot 83.

And then everything got its name. --threadlist scans the heap for thread records and finds 4,011 script threads, 197 of them in the front end's own container, so the whole front end is a map: Opening Screen 111 lines running, Cursor, Click, Wait, Monitor running, every other page a state marker with an empty body and an Enable X event function behind it -- and the thread that spins is For CD. tools/gtxvars.py reads the script's declaration table out of Trasse - Night/Opening.gtx and names slot 83 "Min CD Number". Resource/appname.ini, all 108 bytes, is three strings, and the third is "Please, insert the Force Commander CD to proceed".

So it was never a player profile and never a page that fails to draw. The front end reaches its disc check.

And the check can be stepped over. GamePPGlobalSysWhile::Execute initialises its result to zero before evaluating, so writing 3 into the six-bit operand-count field of any control-flow line forces its condition false -- no operands touched, and no need for the operand grammar that had been in the way. --nocond 25 3 83 does it to that one line, identified by its block's line count, its line number and the variable its first operand reads, because a script line's address is different every run and several 25-line blocks exist.

With it, the front end navigates: SELECT PLAYER NAME, then ENTER PLAYER NAME, then the player page's forward arrow -- which fails a name check and draws "No Name Selected". That check's result is variable slot 12, assigned by a 7-line block called Check That Name Exists and read at line 40 of the 182-line handler, so --varat 182 40 12 1 answers it at that one line and nowhere else. (--varpoke 12 1, which pins the slot everywhere, segfaults the game in sixty-three writes: the whole front end shares it.)

Then SINGLE PLAYER, then Campaign, and the next click is not a page at all. Every UI rectangle disappears and the frame becomes a fully 3D interior: curved hangar walls, a ramp, a holographic briefing table with a blue ring and a green tactical display. 1,331,273 primitives a frame, 306,510 of 307,200 pixels lit, holding and fading in over hundreds of frames. 0007 - EmpireHangar out of the exe's own state table -- where a Force Commander campaign begins.

Two things were missing along the way and neither was the cause. --nolib proves no line the click takes names an unregistered subsystem. And the install was short 260 MB: Resource\Music and Resource\Movies had never been copied off the disc, and the music file names turn out to be the exact state names in the exe's own 63-entry state table -- 1202 - MasterScreen.imu is stateSinglePlayerScreen. tools/iso_extract.py copies them out with their real names, which means reading the image's Joliet tree rather than its truncated 8.3 one. timeSetEvent delivers its callback now too, on a host thread with its own target stack and machine state, because iMUSE runs on that timer.

Three other things had to be right for that, and docs/STARTUP.md has them: IDirect3DVertexBuffer7 (the game locks one on its first rendered frame), host_present blitting straight to the window rather than through UpdateWindow (which blocks on another thread's message pump, and hung the process on the first Flip it ever issued), and a fourth lifter bug -- a body can continue past an int3 when something jumps over it, and four dropped leaders came out as tail transfers to VAs nobody lifted.

SMUSH.DLL is shimmed too, so the intro movie is skipped rather than silently disabling the module: the exe asks GetProcAddress for four exports and the loader answers one NULL by FreeLibrary-ing the whole thing.

Where it goes next

In order, and the first two are the ones that matter:

  1. Walk the hangar to a battle. The campaign's briefing room renders; the rest is the same click-at-a---uimap-rectangle work the front end took.
  2. The font sheets, stored upside down -- which is what makes that work slow, because the screen cannot be read. --vtxdump proves the glyph quads are right (x-span/u-span is 3.75 on every glyph, matching y-span/v-span), so it is the texture content, and a sheet's alpha channel dumps as ASCII running bottom to top with every glyph inverted. The font files are 8-bit BMPs and the model textures 24-bit through the same fill function, sub_00774640.
  3. A keystroke that reaches the name field. The messages arrive -- DispatchMessageA shows WM_KEYDOWN, both WM_CHARs and WM_KEYUP with the right scan code on the game's own pumping window -- and ten typed characters still leave the field empty, so the break is inside the game's key-to-script plumbing. Fixing it retires --varat.
  4. The alpha, underneath that. --drawprobe settled what the forced opaque diffuse costs: the panel is FVF 0x112, which has no diffuse at all, so it looks the same either way, and the TEXT is FVF 0x142 with a vertex diffuse alpha of zero in every batch sampled across a run. Both readings are wrong; the honest fix is a real texture-stage evaluator.
  5. Miles (21 entries). Still stubs, and the install now has its music, so there is something for them to play.
  6. Stub GamePPVis* entirely. 25 classes and 1,053 vtable slots of editor that a first playable does not need. Free.
  7. A 32-bit host. docs/STL-GATE.md makes the case for loading the real 32-bit DLLs instead of reimplementing them, since every blocker so far was a reimplementation infidelity.
  8. DirectPlay is a dead service. Whatever replaces it is a design decision, not a recompilation one.
  9. Disc 2 has not been unpacked.

Smush.dll needs no reverse engineering — RECON.md has the container decoded and ScummVM has implemented SMUSH for twenty years.


Building and running it

You need your own copy of the game: both discs, or an installed copy. Nothing of it is in this repository. pcrecomp must be checked out next to this one as ../tools -- the lifter, the disassembler and the 32-bit runtime (runtime/recomp32/) all come from there. Python needs capstone; the host builds with MinGW-w64 GCC and Ninja.

# 1. Assemble an install under game/ (see docs/STARTUP.md for the layout):
#    Focom.exe and its DLLs from your disc or installed copy, then Resource.
#    Resource\Music and Resource\Movies are ~260 MB and easy to miss.
py -3 tools/iso_extract.py original/fc1.iso RESOURCE game/Resource
py -3 tools/make_focom_ini.py <absolute path to game> > game/Focom.ini

# 2. Catalog, import bridge, lift (the lift is ~10.7M lines; it is not committed)
py -3 ../tools/tools/disasm/disasm32.py game/Focom.exe -o analysis/functions.json
py -3 gen_imports.py
py -3 run_lift.py --all

# 3. Build, and run from the project root -- not from game/
cmake -B build -G Ninja && cmake --build build
build/focom.exe game/Focom.exe --run --watchdog 180

The command at the top of this page is the one that reaches the hangar. docs/STARTUP.md ("Running it") has the diagnostic flags and the two ways the build fails silently on a mixed MSYS2/Windows PATH.

Layout

forcecommander/
  run_lift.py        lift driver over pcrecomp's tools/lift/generate.py
  gen_imports.py     generates src/runtime/imports_gen.c (the 307-import bridge)
  src/runtime/       the host: image mapping, threads, shims, COM objects,
                     the software rasteriser -- all hand-written
  src/recomp/gen/    lifted C, generated and gitignored
  tools/             rpk.py, iso_extract.py, make_focom_ini.py, gtxvars.py,
                     stepdecode.py, scriptmap.py, whoslot.py, com_purge_check.py
  analysis/          catalog, sections, imports, rtti.json, vtables.json, seeds
  original/, game/   your discs and your install (gitignored)
  docs/
    RECON.md         what is inside Focom.exe: namespaces, renderer seam, formats
    STARTUP.md       startup, Focom.ini, the install layout, every bring-up fix,
                     the front end's script, and how to run it
    STL-GATE.md      the MSVCP60 gate, and the case for a 32-bit host

Credits

Star Wars: Force Commander © 2000 LucasArts Entertainment Company. This project neither contains nor distributes any part of it. The generated C is a derivative of the game's binary and is produced from your own copy, never committed; see LICENSE.