← All posts

Rebuilding my 2014 game from the only copy left

Schwizzle is a puzzle game I released in 2014 for iOS, Android and OS X. A picture is cut into a grid of rotating rings, and lens-shaped pieces sit where neighbouring rings overlap. Turning a ring carries the four pieces around it, so pieces migrate from ring to ring, and the goal is to put the picture back together as fast as you can.

Grab It Magazine's video of the original, on an iPad, shows how it plays:

I no longer had the source. It went somewhere between laptops, and there was no backup to restore. What I did have was a copy of the shipped Mac app, which made that app the only complete record of how the game worked.

Then it stopped opening. macOS showed this:

"Schwizzle" does not support the latest receipt validation requirements. Please contact the developer for support.

I was the developer.

This post is the whole route back, written so you can follow it with your own lost project: what to read out of the bundle, how to get structure back from a stripped C++ binary, how to check a rewritten function against the original machine code, how to run original code you do not want to translate, and how the result became an Awaken project. Every command and output below was run against the Schwizzle binary.

flowchart TD
  A["Shipped .app"] --> B["Read the bundle
file, otool, plutil, codesign"] B --> C["Resources by magic bytes
names from the game's own lookup"] B --> D["Structure from RTTI and vtables"] D --> E["Ghidra, headless
names table, one file per class"] E --> F["Readable C++ and a rebuild spec"] F --> G["Check rewrites against the original bytes"] F --> H["Run original code under emulation
meshes, camera"] G --> I["Awaken project"] H --> I C --> I

Getting the old app to open

A receipt check in an App Store app of that era is ordinary code inside the app, and Objective-C keeps its method names in the compiled binary. Schwizzle's check was a method called verifyReceiptWithRefresh, and the call site never looks at what it returns. In a separate copy of the app, I replaced the method's first instruction with a return:

verifyReceiptWithRefresh, first byte
  original   55    push rbp    (the start of a normal function)
  recovery   c3    ret         (return before doing anything)

That skips the check in my local copy. It does not validate anything, and it says nothing about which part of the old validation the new macOS rejected; I never needed to find out.

Changing one byte breaks the code signature, so the copy has to be signed again, and a 2014 bundle fights that. Signing it as it was failed twice. The first failure was Finder metadata:

Recovered.app: resource fork, Finder information, or similar detritus not allowed

With the extended attributes cleared, the next was the frameworks bundled inside SDL2_mixer.framework and SDL2_ttf.framework:

Recovered.app: unsealed contents present in the root directory of an embedded framework
In subcomponent: .../SDL2_mixer.framework/Versions/Current/Frameworks/Vorbis.framework

A framework's root may hold only its symbolic links and Versions. These had licence files sitting at the root, and symbolic links whose targets end in a slash, which the signing tools also treat as wrong:

Vorbis.framework
  Headers   -> Versions/Current/Headers/
  LICENSE.ogg-vorbis.txt
  Resources -> Versions/Current/Resources/
  Versions/Current -> A/

The fix is to move the licence files into Resources, re-point every link without the slash, and sign from the inside out: nested frameworks first, then the frameworks that contain them, then the app.

# 1. Licence files into Resources; no symlink target ending in "/".
find Recovered.app/Contents/Frameworks -name '*.framework' -type d | while read -r fw; do
  for f in "$fw"/LICENSE*; do [ -f "$f" ] && mv "$f" "$fw/Versions/A/Resources/"; done
done
find Recovered.app/Contents/Frameworks -type l | while read -r l; do
  t=$(readlink "$l"); case "$t" in */) ln -sfn "${t%/}" "$l";; esac
done
xattr -cr Recovered.app
# 2. Sign deepest first, then the app, with an ad hoc identity ("-").
find Recovered.app/Contents/Frameworks -name '*.framework' -type d |
  awk '{ print gsub("/", "/"), $0 }' | sort -rn | cut -d' ' -f2- |
  while read -r fw; do codesign --force --sign - "$fw"; done
codesign --force --sign - Recovered.app
# 3. Check.
codesign --verify --deep --strict --verbose=2 Recovered.app
Recovered.app: valid on disk
Recovered.app: satisfies its Designated Requirement

I zipped the result, unzipped it again and verified the unzipped copy, because zipping can change symbolic links. It launched, made its OpenGL context and loaded the game's graphics, and I could play it again.

This is a local recovery copy of my own app. It is not notarised and is not for anyone else, and the original app and the archive it came from were left untouched.

Step 1: read the bundle before the binary

A .app is a folder, and most of what you need to know about the binary is written down around it. Five commands answer the first questions: what the executable is, what built it, what it links against, and who signed it.

file Schwizzle.app/Contents/MacOS/Schwizzle
Schwizzle.app/Contents/MacOS/Schwizzle: Mach-O 64-bit executable x86_64

Intel only, so on an Apple Silicon Mac it runs under Rosetta, and anything I lift out of it has to as well.

otool -L Schwizzle.app/Contents/MacOS/Schwizzle
@rpath/SDL2.framework/Versions/A/SDL2 (compatibility version 1.0.0, current version 3.1.0)
/System/Library/Frameworks/StoreKit.framework/Versions/A/StoreKit (...)
@rpath/SDL2_image.framework/Versions/A/SDL2_image (...)
@rpath/SDL2_mixer.framework/Versions/A/SDL2_mixer (...)
@rpath/SDL2_ttf.framework/Versions/A/SDL2_ttf (...)
/System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa (...)
/usr/lib/libobjc.A.dylib (...)
/usr/lib/libc++.1.dylib (...)
/System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL (...)
...

That list is the architecture: C++ (libc++), a thin layer of Objective-C (libobjc, Cocoa, StoreKit), and SDL with OpenGL underneath. The bundled frameworks' own Info.plist files give their versions: SDL 2.0.3, SDL_image 2.0.0, SDL_mixer 2.0.0, SDL_ttf 2.0.12.

otool -l Schwizzle.app/Contents/MacOS/Schwizzle | grep -A3 LC_VERSION_MIN
plutil -p Schwizzle.app/Contents/Info.plist
      cmd LC_VERSION_MIN_MACOSX
  cmdsize 16
  version 10.7
      sdk 10.9

"CFBundleIdentifier" => "com.raiblin.osx.schwizzle"
"CFBundleShortVersionString" => "1.2"
"DTSDKName" => "macosx10.9"
"DTXcode" => "0511"
"LSMinimumSystemVersion" => "10.7"
...

DTXcode 0511 is Xcode 5.1.1. Knowing the toolchain tells you what the compiler did: this one is Apple clang of early 2014, so the C++ follows the Itanium ABI, which matters in step 3.

codesign -dv --verbose=2 Schwizzle.app
Identifier=com.raiblin.osx.schwizzle
Format=app bundle with Mach-O thin (x86_64)
Authority=Apple Mac OS Application Signing
Authority=Apple Worldwide Developer Relations Certification Authority
Authority=Apple Root CA
Sealed Resources version=2 rules=12 files=68
...

"Apple Mac OS Application Signing" means the App Store re-signed it, so the copy I had was the store build, not a development build with extra symbols.

Last, the segment table. otool -l prints every load command; the two that matter later are where __TEXT (the code) and __DATA sit in memory and in the file:

  segname __TEXT
   vmaddr 0x0000000100000000
  fileoff 0
 filesize 2760704
...
  segname __DATA
   vmaddr 0x00000001002a2000
  fileoff 2760704
 filesize 172032

Step 2: resources named by UUID

The app's Resources folder held 64 files with names like 0CB5861F-2988-487E-929D-EB1CC3C81D09 and no extensions. The first bytes say what each one is:

for f in 0CB5861F-... 0B83C62B-... 377DD024-... C55D927E-...; do echo "$f"; xxd -l 16 "$f"; done
0CB5861F-2988-487E-929D-EB1CC3C81D09
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
0B83C62B-568D-4C23-9F01-F6CC018A5620
00000000: 4f67 6753 0002 0000 0000 0000 0000 751a  OggS..........u.
377DD024-497C-48EB-B356-AC225F92E789
00000000: ab4b 5458 2031 31bb 0d0a 1a0a 0102 0304  .KTX 11.........
C55D927E-30DC-4EEA-99A6-7DC82E724B96
00000000: 0001 0000 0010 0040 0004 00c0 4f53 2f32  .......@....OS/2

A PNG, an Ogg Vorbis stream, a KTX texture and a TrueType font (00 01 00 00, with its OS/2 table already in sight).

The shaders are plain text, and Decade Engine, my engine of the time, kept both stages in one file between GL_VERTEX_SHADER and GL_FRAGMENT_SHADER markers. A short script sorts the folder, copies each file out under a usable extension, and records its size and SHA-256 so nothing can change unnoticed later:

b = p.read_bytes()
if   b.startswith(b'\x89PNG'):         kind, ext = 'images', '.png'
elif b.startswith(b'OggS'):            kind, ext = 'audio', '.ogg'
elif b.startswith(b'\xabKTX'):         kind, ext = 'textures', '.ktx'
elif b.startswith(b'\x00\x01\x00\x00'): kind, ext = 'fonts', '.ttf'
elif b.startswith(b'icns'):            kind, ext = 'icons', '.icns'
elif b'GL_VERTEX_SHADER' in b:
    kind = 'shaders'
    m = re.search(rb'//\s+(\S+\.glsl)', b)   # the file names itself in its header comment
row = {'original_id': p.name, 'category': kind, 'bytes': len(b),
       'sha256': hashlib.sha256(b).hexdigest()}
Kind Files
PNG images 23
Ogg Vorbis audio 26
KTX compressed textures 3
GLSL shaders 7
Font, icon, other 5

All 64 copies match their originals' hashes. The three KTX files are ETC2, a GPU format most image tools do not open. Their 64-byte header gives the format and size, and the texture2ddecoder Python package turns level 0 into pixels: format 0x9274 is GL_COMPRESSED_RGB8_ETC2 and 0x9278 the RGBA version.

Part of the contact sheet of the recovered images: the five puzzle pictures at full size and as thumbnails, the settings gear, the tutorial pointer, frames and borders, and the wordmark, each labelled with the UUID it shipped under and its size.

The seven shaders are the one piece of real source that survived, because they shipped as text. They still have my header on them:

//  quadTexturedBlurred.glsl
//  Schwizzle
//
//  Created by Ciaran McCormack on 9/04/13.

    vec4 pixel = texture(diffuse, _uv0.xy)               * 4.0 +
                 texture(diffuse, _uv0.xy - offx)        * 2.0 +
                 texture(diffuse, _uv0.xy + offx)        * 2.0 +
                 texture(diffuse, _uv0.xy - offy)        * 2.0 +
                 texture(diffuse, _uv0.xy + offy)        * 2.0 +
                 texture(diffuse, _uv0.xy - offx - offy) * 1.0 +
                 ...
    color += (pixel / 16.0);

That one is the pause screen's blur: a 9-tap tent, weights 4, 2 and 1 over 16.

Getting the real names back

A UUID tells you nothing about which picture is which. The binary knows, because the game asks for assets by name. Ghidra lists every string in the binary with the code that references it, and following the image and shader UUIDs to their code lands in one 2,741-byte function. Decompiled, it is a chain of string compares:

iVar2 = std::basic_string<...>::compare(param_2, "quadColored.glsl");
if (iVar2 == 0) {
  pcVar3 = "984E5DAE-A8C6-4F10-8139-3F73063A0C38";
}
else {
  iVar2 = std::basic_string<...>::compare(param_2, "quadTextured.glsl");
  if (iVar2 == 0) {
    pcVar3 = "D1E88268-E49B-407A-B885-CE9F4B96D9A4";
    goto LAB_100077176;
  }
  ...

That is a lookup table compiled into code. Rather than read 2,741 bytes of it, I wrote a script that walks the function's disassembly (otool -tV prints string literals beside the instructions that load them), remembers the last filename literal, and at each compare follows the "equal" branch to the UUID it loads:

original_name,resource_uuid,in_bundle
quadTexturedBlurred.glsl,73512DB7-71A5-4FDA-A036-7C282550D5FC,yes
schwizzle.png,9DE8A98F-22F7-4FB0-A8B8-3368BA62BD69,yes
HD_Pause.png,A08500C4-2061-4761-9DAA-D2BA143D8452,yes
SD_TN_1.ktx,78FE0221-A170-4E44-9663-77220E9D1112,no
...

It maps 75 names. Only 45 of those files shipped: the SD_ low-resolution set was never bundled for the Mac. An earlier guess, pairing filenames with UUIDs that sat next to them in the string table, had produced 30 pairings; the table from the code replaced it. The audio names were never in the binary at all, so the sound files kept descriptive names.

Step 3: structure from a stripped C++ binary

A release build has no symbol table worth the name:

nm Schwizzle.app/Contents/MacOS/Schwizzle | grep ' T '
0000000100000000 T __mh_execute_header

One defined symbol out of 410; the rest are imports. But C++ keeps class names anyway, for dynamic_cast and exceptions. Under the Itanium ABI each polymorphic class has a typeinfo record, and the record points at a mangled name: the length of the name, then the name.

strings -a Schwizzle.app/Contents/MacOS/Schwizzle | grep -E '^[0-9]+(Twister|Board|Gem|PuzzleScreen)$'
echo _ZTS7Twister _ZTSN2de6ObjectE | c++filt
7Twister
5Board
12PuzzleScreen
3Gem
typeinfo name for Twister typeinfo name for de::Object

N2de6ObjectE is de::Object, a class in the de namespace: Decade Engine. Finding the name string is the start. The useful part is the record around it. The script scans the data segment for any 8-byte word equal to the name string's address; each hit is the second word of a typeinfo record. Twister's record is three words:

word 0   the typeinfo class's vtable (filled in by the loader)
word 1   -> "7Twister"
word 2   -> StaticModel's typeinfo      (one base class at offset 0)

A record with three words is __si_class_type_info, single inheritance. A class with several bases uses __vmi_class_type_info, which also gives each base's offset inside the object. That is how the class hierarchy came back, 254 typeinfo records in all, including:

"Twister":      { "bases": [{ "name": "StaticModel", "offset": 0 }], "kind": "single" }
"PuzzleScreen": { "bases": [{ "name": "Screen", "offset": 0 },
                            { "name": "ITwister", "offset": 152 },
                            { "name": "IControlCallback", "offset": 160 }], "kind": "multiple" }

PuzzleScreen implements ITwister, the callback a ring uses to say it was grabbed or released, and the 152 says where in a PuzzleScreen that interface's vtable pointer lives.

Vtables come back the same way, one step further on. Under the same ABI the word just before a vtable's first slot points at the class's typeinfo, so searching the data segment for each typeinfo address, then reading the run of code pointers that follows, gave 206 vtables. Twister's primary one has nine slots, and the last is the only one Twister adds itself:

vtable @1002a2c80:
  [ 0] Twister::bindShader  (0x100005538)
  [ 1] de::Model<de::VERTEX_POSITION_NORMAL_COLOR_UV0>::vslot01  (0x10015d038)
  ...
  [ 8] Twister::matchesSolvedGems  (0x1000060fc)

A vtable slot is a name you can carry: once slot 8 of Twister is known, every indirect call through vtable + 0x40 on a ring is a call to it. Objective-C is easier still. Its runtime metadata keeps every class and method name in readable form, which otool -ov prints; that is how the receipt method was findable by name at all.

Step 4: Ghidra, headless

Ghidra's GUI is good for reading one function. For 1,943 of them I ran it headless from scripts, against a project where the binary was imported and analysed once:

"$GHIDRA/support/analyzeHeadless" work/ghidra-project Schwizzle \
  -process Schwizzle -noanalysis -readOnly \
  -scriptPath tools -postScript DecompileJsonl.java work/export-addrs.txt work/decomp.jsonl

The pipeline is five steps, and each writes a file the next one reads:

  1. DumpMeta.java writes every function's size, callers, callees and the strings and data it references as JSON lines, so naming can happen outside Ghidra.
  2. names.py derives names from structure and records why: a vtable slot, a constructor (a function that installs a class's vtable), an __assert_rtn string, a "Creating X" log line, or a hand-written entry.
  3. ApplyNames.java writes those names back into the Ghidra project, with the class as a namespace and __thiscall for methods.
  4. units.py assigns every remaining function to a class: unnamed functions inherit the class of the nearest named function before them, because each object file is laid out contiguously.
  5. DecompileJsonl.java decompiles everything, and assemble.py writes one file per class.

The names table is four tab-separated columns: address, class, method, and the reason.

100005ba8	Twister	setGrabbed	manual
1000060fc	Twister	matchesSolvedGems	manual
10000687a	Board	update	manual
100007c60	Board	isSolved	manual

The hand-written entries live in their own files, one per area, each with the evidence that justified the name:

# addr	class	method	# evidence
10000540e	Twister	updateSlotPositions	# 4x getModelMatrix*(0,0,-R),(0,0,+R),(+R,0,0),(-R,0,0) with R=0x3f8
10000599c	Twister	rotateTo	# (xmm0 duration, target): no-op if rotation==target; duration==0 -> applyRotation

The decompile script itself is short. The core loop:

for (String a : Files.readAllLines(Paths.get(args[0]))) {
    Function f = getFunctionAt(toAddr(Long.parseUnsignedLong(a.trim(), 16)));
    if (f == null) continue;
    DecompileResults r = di.decompileFunction(f, 90, monitor);
    String c = r.decompileCompleted() ? r.getDecompiledFunction().getC()
                                      : "/* decompile failed: " + r.getErrorMessage() + " */";
    w.println("{\"addr\":" + esc(f.getEntryPoint().toString()) + ",\"name\":" + esc(f.getName())
        + ",\"ns\":" + esc(f.getParentNamespace().getName(true)) + ",\"c\":" + esc(c) + "}");
}

All 1,943 functions decompiled with no failures: 673 belong to the game, 680 to Decade Engine, 524 to support code and 66 to the platform layer. Of the game's 673, 510 have meaningful names; the rest are small helpers. Of the whole binary, Schwizzle's own C++ is 7.3% of the code and Decade Engine 5.7%. Bullet physics, Recast/Detour and OpenSSL make up 80%, and the game never calls Bullet or Recast.

Running the steps again after each round of hand naming is what made this workable: every new name shows up in every caller's pseudocode on the next pass. The output reads like this:

// ---- 0x1000060fc  Twister::matchesSolvedGems  [40 bytes]
//      attributed: named; name from manual
undefined8 __thiscall Twister::matchesSolvedGems(Twister *this)
{
  long lVar1;
  long lVar2;

  lVar2 = 0;
  do {
    lVar1 = lVar2 * 8;
    if (*(long *)(this + lVar1 + 0x3a8) != *(long *)(this + lVar2 * 8 + 0x358)) {
      return 0;
    }
    lVar2 = lVar2 + 1;
  } while ((int)lVar2 < 4);
  return CONCAT71((int7)((ulong)*(long *)(this + lVar1 + 0x3a8) >> 8),1);
}

From there I wrote a rebuild specification, every rule, screen, timing, sound and save format on one page, each fact tagged [V] verified against the machine code, [L] likely or [G] guess, and about 10,000 lines of readable C++17 in five areas (the puzzle, the game shell, the play screens, the menus and the widgets), each file compiling on its own against small stand-in interfaces.

Step 5: one function, three times

The release snap shows what pseudocode hides and why the readable version has to be checked. When a player lets go of a ring, it settles on a quarter turn. Ghidra's version:

void __thiscall Twister::setGrabbed(Twister *this, Twister param_2)
{
  ...
  this[0x330] = param_2;
  if (param_2 == (Twister)0x0) {
    pfVar4 = (float *)(**(code **)(*(long *)(this + 8) + 0x28))(this + 8);
    iVar2 = -1;
    iVar1 = (int)(*pfVar4 * 57.29578);
    do {
      iVar6 = iVar1;
      iVar2 = iVar2 + 1;
      iVar1 = iVar6 + 1;
    } while (iVar6 != (iVar6 / 0x5a - iVar6 / -0x4c000000) * 0x5a);
    iVar7 = -1;
    iVar1 = (int)(*pfVar4 * 57.29578);
    do {
      iVar5 = iVar1;
      iVar7 = iVar7 + 1;
      iVar1 = iVar5 + -1;
    } while (iVar5 != (iVar5 / 0x5a - iVar5 / -0x4c000000) * 0x5a);
    if (iVar2 < iVar7) {
      local_30[0] = (float)iVar6 * 0.017453292;
      ...
    }
    else {
      local_40[0] = (float)iVar5 * 0.017453292;
      ...
    }
    rotateTo(this, pfVar4);
  }
  ...

Reading it:

  • (**(code **)(*(long *)(this + 8) + 0x28))(this + 8) is a virtual call through the vtable of the de::Object part of the ring, at this + 8: slot 5 (0x28 / 8), which is de::Object::getRotation.
  • 57.29578 and 0.017453292 are 180/π and π/180.
  • (int)(x * 57.29578) is a cvttss2si instruction: it truncates toward zero. It does not round.
  • (i / 0x5a - i / -0x4c000000) * 0x5a is how Ghidra prints a multiple-of-90 test after the compiler has turned the division into a multiply and shifts. The second term is zero for any angle, so the condition reads i % 90 != 0.
  • The two loops count steps up and down to the nearest multiple of 90. Up wins only if it is strictly closer, so a tie goes down.

So the rule is "truncate to whole degrees, then pick the nearest multiple of 90, ties toward negative infinity". The reconstruction replaces the loops with a closed form:

// Release snap (Twister::setGrabbed(false), 0x100005ba8):
//   d = (int)trunc(yaw in degrees); target = nearest multiple of 90, ties
//   toward -infinity.  Closed form verified against the loop version for
//   d in [-2000, 2000].
inline int snapDegrees(int truncatedDegrees) {
    const double v = std::floor((static_cast<double>(truncatedDegrees) + 44.0) / 90.0);
    return static_cast<int>(v) * 90;
}

The + 44 is the tie rule: at 45 degrees the floor still lands on 0. The same function in the rebuild's TypeScript, and its test:

/** Where a released disc settles: the nearest quarter, ties going down. */
export function snapDegrees(deg: number): number {
  return 90 * Math.floor((Math.trunc(deg) + 44) / 90);
}

expect([44, 45, 46, -44, -45, -46, 134, 135, 136, 359.9].map(snapDegrees))
  .toEqual([0, 0, 90, 0, -90, -90, 90, 90, 180, 360]);

-45 goes to -90 and 359.9 goes to 360, both exactly as the 2014 loops do. A plain Math.round(deg / 90) * 90 gets both 45 and -45 wrong, and that is the kind of difference a player feels as a ring that sometimes settles the other way.

The solved check is the opposite lesson. Board::isSolved converts each ring's angle with roundf, not a truncation, and its second loop does more than read:

cVar6 = (*(code *)**(undefined8 **)this)(this, uVar5);        // select this ring
if (cVar6 != '\0') {
  cVar6 = (**(code **)(**(long **)(this + 8) + 0x40))();      // ring vtable slot 8
  (*(code *)**(undefined8 **)this)(this, 0);                  // deselect

It selects each ring, calls slot 8 (0x40 / 8, matchesSolvedGems), and deselects again, so checking for a win fires the ring's grab and release callbacks. The readable version keeps that and says so:

// Not a pure query: it selects/deselects each disc (firing onTwisterGrabbed)
// and leaves no disc active.
bool Board::isSolved() {
    for (const auto& t : twisters_) {
        const int degrees = static_cast<int>(std::round(radToDeg(t->rotation().x)));
        if (degrees % 360 != 0) return false;
    }
    for (const auto& t : twisters_) {
        if (!selectTwister(t.get())) continue;
        const bool ok = active_->matchesSolvedGems();
        selectTwister(nullptr);
        if (!ok) return false;
    }
    return true;
}

Step 6: test a rewrite against the original bytes

A rewrite that looks right is still a guess. For small functions you can do better than reading: run the original instructions and the rewrite on the same inputs and compare. matchesSolvedGems is a good candidate, and the reasons are the checklist for any function you want to lift this way. It calls nothing, it reads no global data through a RIP-relative address, and its jumps are relative to itself, so its bytes work wherever they are placed:

1000060fc:	55	pushq	%rbp
1000060fd:	48 89 e5	movq	%rsp, %rbp
100006100:	31 c9	xorl	%ecx, %ecx
100006102:	48 8b 84 cf a8 03 00 00	movq	0x3a8(%rdi,%rcx,8), %rax
10000610a:	48 3b 84 cf 58 03 00 00	cmpq	0x358(%rdi,%rcx,8), %rax
100006112:	75 0c	jne	0x100006120
100006114:	48 ff c1	incq	%rcx
100006117:	83 f9 03	cmpl	$0x3, %ecx
10000611a:	b0 01	movb	$0x1, %al
10000611c:	7e e4	jle	0x100006102
10000611e:	eb 02	jmp	0x100006122
100006120:	31 c0	xorl	%eax, %eax
100006122:	5d	popq	%rbp
100006123:	c3	retq

this arrives in rdi. The loop compares four 64-bit values at this + 0x3a8 with four at this + 0x358: the four pieces a ring holds now against the four it held when the board captured its reference arrangement.

To get the bytes out of the file, turn the address into a file offset. An address in a segment is vmaddr + (offset - fileoff), so the offset is address - vmaddr + fileoff. The function is in __TEXT, whose vmaddr is 0x100000000 and fileoff is 0 (from step 1):

0x1000060fc - 0x100000000 + 0 = 0x60fc
xxd -s 0x60fc -l 40 Schwizzle.app/Contents/MacOS/Schwizzle
000060fc: 5548 89e5 31c9 488b 84cf a803 0000 483b  UH..1.H.......H;
0000610c: 84cf 5803 0000 750c 48ff c183 f903 b001  ..X...u.H.......
0000611c: 7ee4 eb02 31c0 5dc3                      ~...1.].

Those 40 bytes go straight into the test program's own code section with an assembler .byte directive, under a symbol C++ can call. A zeroed 0x450-byte buffer stands in for the ring, with the two arrays written at the offsets the instructions read:

#include "../reconstructed/TwisterState.hpp"
extern "C" bool original_check(const void*);
__asm__(".text\n.globl _original_check\n_original_check:\n"
        ".byte 85,72,137,229,49,201,72,139,132,207,168,3,0,0,72,59,132,207,88,3,0,0,"
        "117,12,72,255,193,131,249,3,176,1,126,228,235,2,49,192,93,195\n");

int main() {
  std::mt19937_64 gen(1234); unsigned cases = 0;
  for (unsigned n = 0; n < 1000; n++) {
    schwizzle::recovered::TwisterState s;
    for (auto& v : s.current) v = gen();
    s.target = s.current;
    for (int mismatch = -1; mismatch < 4; mismatch++) {     // all equal, then each slot wrong
      auto t = s; if (mismatch >= 0) t.target[mismatch] ^= 1;
      alignas(16) unsigned char object[0x450] = {};
      std::memcpy(object + 0x358, t.current.data(), 32);
      std::memcpy(object + 0x3a8, t.target.data(), 32);
      if (original_check(object) != t.matchesTarget()) return 1;
      cases++;
    }
  }
  std::printf("PASS: %u comparisons against original x86_64 machine code\n", cases);
}

matchesTarget() is the readable version, a loop over two std::array<std::uint64_t, 4>. The program has to be built for Intel, because the bytes are Intel code; on Apple Silicon, Rosetta runs it:

clang++ -std=c++17 -arch x86_64 compare_original.cpp -o compare_original
./compare_original
PASS: 5000 comparisons against original x86_64 machine code

A thousand random arrangements, each tried with all four slots equal and with a mismatch in each slot in turn. It proves this one check and nothing more, and that is the right size of claim: one function, exactly.

Step 7: run the original code instead of translating it

Some answers are not worth translating. What exact shape are the rings and lenses? Which way does a +90° turn go on screen? The code that knows is a mesh generator full of trigonometry and a camera built from Decade Engine's matrix code. Rather than port either, I ran them, instruction by instruction, in the Unicorn CPU emulator from Python.

The emulator script maps every segment of the binary at its own address, using the same otool -l numbers as step 1:

for line in subprocess.run(['otool', '-l', binary], capture_output=True, text=True).stdout.splitlines():
    ...
    if p[0] == 'filesize' and cur['segname'] != '__PAGEZERO':
        uc.mem_map(cur['vmaddr'], (cur['vmsize'] + 0xfff) & ~0xfff, UC_PROT_ALL)
        uc.mem_write(cur['vmaddr'], data[cur['fileoff']:cur['fileoff'] + cur['filesize']])
for base, size in ((HEAP, HEAP_SIZE), (STACK, STACK_SIZE), (SCRATCH, 0x10000), (SENTINEL, 0x1000)):
    uc.mem_map(base, size, UC_PROT_ALL)
uc.mem_write(SENTINEL, b'\xf4')              # hlt: where every call returns to

The game's own code runs unchanged. What cannot run is anything imported from a library, because no library is loaded. Every call to an import goes through a small stub in __stubs, so a hook on that address range catches them all, does the work in Python, and returns:

def _on_code(self, uc, addr, size, _):
    if addr in self.stubs:
        self._stub(self.stubs[addr])                       # e.g. '_sinf', '__Znwm', '_glGenBuffers'
        rsp = uc.reg_read(UC_X86_REG_RSP)
        uc.reg_write(UC_X86_REG_RIP, self.u64(rsp))        # pop the return address
        uc.reg_write(UC_X86_REG_RSP, rsp + 8)

The replacements are small. operator new bumps a pointer in the heap region. OpenGL calls do nothing, apart from handing out buffer ids and reporting a 1024 x 768 viewport. The maths functions call Python's, and the single-precision ones round back to float32 so the results match what the 2014 sinf would have given:

elif name in ('_sinf', '_cosf', '_tanf', '_acosf', '_atanf', '_sqrtf'):
    x = struct.unpack('<f', uc.reg_read(UC_X86_REG_XMM0).to_bytes(16, 'little')[:4])[0]
    r = struct.pack('<f', getattr(math, name[1:-1])(x))   # rounded to float32 like libm
    uc.reg_write(UC_X86_REG_XMM0, int.from_bytes(r + b'\0' * 12, 'little'))

A call follows the x86-64 System V convention: integer and pointer arguments in rdi, rsi, rdx, rcx, floats in xmm0 upward, and the sentinel's address pushed as the return address, so the emulator stops when the function returns. The board's constructor calls the ring generator with an inner radius of 0.5 and an outer of 1.0, so the script makes the same call and reads the mesh's vertex array back out of emulated memory, 0x30 bytes a vertex: position, normal, colour, UV.

emu.call(RING, ints=(SCRATCH + 0x200, SCRATCH + 0x100), floats=(0.5, 1.0))
ring = read_mesh(SCRATCH + 0x200)
ring 6336 vertices; bbox ['-0.7431', '0.0000', '-0.7431', '0.7431', '0.0000', '0.7431']
lens 900 vertices; bbox ['-0.2596', '0.0000', '-0.6765', '0.2596', '0.0000', '0.6765']

6,336 vertices is 2,112 triangles for a ring and 900 is 300 for a lens, exact, with nothing to approximate.

The meshes as the 2014 generators made them, recorded under emulation: one ring with its four notches, one lens, and a 3 by 2 board assembled from them, 17 lenses in all, orange on the X sites and blue on the Z sites.

The camera question went the same way: build a real de::Camera the way the board fits its camera, project world points through the original projection code, then apply a +90° yaw to an object and see where its "up" slot goes.

engine: fov 37.5 near 0.01 far 50; camera pitch -1.5707964 rad
screen (origin top-left, y down): world origin ['512.0', '384.0', '1.0']
  world +X -> ['+188.5', '+0.0']  world +Z -> ['+0.0', '+188.5']
yaw +90: up slot (0,0,-0.75) -> ['-0.750', '+0.000', '+0.000']

On screen +X is right and +Z is down, and a +90° turn takes the top slot to the left: counter-clockwise. That is one line of the rules, and getting it backwards would mirror every move in the game.

The rules, as recovered

With the pieces above, the rules came out verified against the code:

  • A board is cols x rows rings 1.5 apart, with a piece on every site between neighbours, so 2 * cols * rows + cols + rows pieces. The ten sizes run from 2x1 (7 pieces) to 10x8 (178).
  • Pieces belong to no ring. A ring picks up whatever four pieces lie on its slots when it is grabbed, and that lookup by position is the whole mechanic.
  • The shuffle is cols * rows * 100 random quarter turns, at most 100 a frame so the scramble is visible, with 500 more if it happens to land solved.
  • Solved means every ring at 0 mod 360° as well as every piece home. The rings carry the picture too, so all the pieces can be home with a ring still turned.
  • A released ring snaps by the rule in step 5.

My first pass had a few things wrong, and the full decompile corrected them. The shuffle and the solved test belong to Board, not Twister. Board sizes unlock by solving the previous size, not by purchase. The save file keeps best minutes and seconds and never stores moves; moves exist only to pace the action replay. The spec also lists the original's bugs so the rebuild would not copy them, such as Restart adding a second pause button on top of the first.

Rebuilding it in Awaken

The rebuild is a project made in the Awaken editor: one scene, six prefabs (Disc, Gem, Gem Across, Board, PictureCard, LevelTile), shader graphs for the pieces and the backdrop, and the recorded mesh vertices imported unchanged. The 2014 game drew a piece on a vertical site by turning it and swapping its UV axes in the shader. The rebuild uses a second lens mesh, lens-across, already turned, so every piece shares one material.

That material is the Picture graph. In 2014 each ring and lens had uvMin and uvMax uniforms that chose the patch of the picture it shows, and the graph does the same thing with two per-object parameters of the same names. A texture cannot be a per-object parameter in Awaken (per-object parameters are floats in a buffer), so the five pictures share one 3072 x 2048 atlas, three cells by two with one spare, and each piece's rectangle points into its picture's cell.

The Picture shader graph as first built: UV, then Tiling And Offset driven by the uvMin and uvMax properties, then a sample of the pictures atlas into an unlit surface. On the right, uvMin and uvMax are vec2 parameters with Per object scope.

The rules live in a script library, Puzzle, ported from the reconstructed C++. The board numbers its sites in the order the original created its pieces, so piece g's home is site g, and a move is a table lookup:

/** Where a +1 quarter turn moves the gem in each slot: up -> left, down -> right, right -> up, left -> down. */
const PLUS_QUARTER = [LEFT, RIGHT, UP, DOWN];
const MINUS_QUARTER = [RIGHT, LEFT, DOWN, UP];

/** One quarter turn of `disc`, dir +1 (counter-clockwise on screen) or -1. */
rotate(disc: number, dir: number): void {
  const before = this.gemsOn(disc);
  const to = dir > 0 ? PLUS_QUARTER : MINUS_QUARTER;
  for (let s = 0; s < 4; s++) {
    this.gemAt[this.siteOf(disc, to[s])] = before[s];
    this.gemTurns[before[s]] += dir;
  }
  this.turns[disc] += dir;
}

Sixteen tests check the rules and the saves against the spec's tables. Two of them:

it("has 2 cols rows + cols + rows gems at every size, as the original's table says", () => {
  const gems = [7, 10, 12, 17, 24, 31, 49, 71, 127, 178];
  SIZES.forEach(({ cols, rows }, i) => expect(new Puzzle(cols, rows).gemCount).toBe(gems[i]));
});

it("numbers its sites in the original's creation order", () => {
  const p = new Puzzle(2, 1);
  expect([UP, DOWN, RIGHT, LEFT].map((s) => p.siteOf(0, s))).toEqual([0, 1, 2, 3]);
  expect([UP, DOWN, RIGHT, LEFT].map((s) => p.siteOf(1, s))).toEqual([4, 5, 6, 2]);
});

A second library, Boards, holds BoardView, a board fitted into any rectangle of the screen, which is how one class draws the game board, the live boards on the home cards and the action replay; post 23 covers how api.ui.rect does the fitting. Its Turner keeps the 2014 feel: a press grabs the nearest ring within 1.41 ring radii, the ring follows the angle the pointer sweeps, and it ratchets as the original did, a click once 20 degrees have built up and 15 taken off after each one, with one of 15 click samples chosen by the ring's angle.

Six behaviours and four libraries come to about 1,500 lines of TypeScript. The engine needed one addition, api.setSoundVolume, so the music slider could change a track that is already playing.

Amulet on the 5x4 board, mid-game, with one ring partly turned. Twenty rings and 49 pieces, each showing its own patch of the picture through the Picture graph.

Two checks run the real game. One exports it and plays it in headless Chrome: home cards, a level, a turn, pause and resume, failing on any console error. The other plays it in the editor's Play mode through pause, resume and a solve, and the solve is found by breadth-first search over the puzzle's states (pieces on sites, rings mod four). Each move of the solution is then made as a quarter turn of the board on screen, so the game sees it come to rest exactly as it would a player's, reaches its Solved screen and plays the replay.

Two passes at the screens

The first rebuild copied the 2014 screens as closely as it could: a 4:3 layout designed at 1024 x 768, the Hobo typeface, the curved wordmark, a strip of live tiles, and the original pause screen. That screen draws the board into a texture and shows it blurred, so in the rebuild a second camera rendered only the board's layer into a render texture, and a shader graph applied the 9-tap tent from the shader above.

Then I redesigned the screens. Each one is now designed at 420 x 560 and scaled by the smaller of width over 420 and height over 560, with elements pinned to edges and corners, so one layout serves a desktop window, a phone held sideways and a phone held upright. Hobo is a commercial typeface, so the redesign uses an open-licence font instead. The pause screen now hides the board under its card, so the board cannot be studied while the clock is stopped, and the render-texture camera went away with it.

The same rebuild before and after the first redesign. Top: the 2014 picture strip with its tutorial tile and live Electra board, then the redesigned home cards. Bottom: the 2014 pause screen with its blurred thumbnail of the board, then the redesigned pause card.

Still open

  • Portrait boards. On a phone held upright the board is still 4:3 across the width, with room to spare above and below.
  • Rights. The pictures and the music were licensed for the 2014 app, and each licence needs checking for a new release. Four of the five pictures are by Tim Henderson, and the credits screen still says so.
  • Left out on purpose: the "Schwizzle Pro" purchase, the own-photo tile that came with it, and the Twitter and Facebook buttons.
← All posts