PSU Clementine for Mac 1.0.0 is out
Version 1.0.0 of the Mac client is out. It is a 46 MB disk image for Apple Silicon Macs on macOS 15 or later. It logs in to Clementine, reaches the lobby and plays online, and a PlayStation 5 controller works through macOS. The Clementine staff approved the public release. I am not affiliated with SEGA or the Clementine team.
You need your own Clementine client and a free account. The DMG holds only our tools and runtime, and the build audit fails if a game file gets in.
Install it
Get the DMG from the Download page, drag the app to Applications, and open it. The app is ad hoc signed and not notarized, because notarizing costs $99 a year and I have not paid it yet. So macOS blocks a downloaded copy the first time. Click Done, open System Settings, Privacy & Security, and press Open Anyway next to "PSU Clementine". You do it once.
To check the file, run shasum -a 256 on it and compare with the hash on the Download page. The how-to has every screen.
Everything below is how the launcher got to this point.
The window
The launcher is still the same program as before, so the command line still works. When Finder starts it with no arguments, it opens a fixed 560 by 380 point window instead of printing usage. It is plain AppKit through the Rust bindings, with no nib files and no second language.
The screens follow the order a first-time player needs. A welcome paragraph says the app makes its own copy of your Clementine folder and builds the game for your Mac, and that your folder is never changed. Then it looks for the folder, checks it, builds, and lands on a Home screen where Return starts the game.
If you open the app straight from the DMG, it offers to copy itself to Applications first. The build screen is a real progress bar. The lifter now prints a line for each of the 563 compile units, and the launcher reads those lines as they arrive. On my machine a cold prepare takes 22.1 s. The clone of the install is only 0.08 s of that, since both folders sit on the same disk. A second launch with nothing changed takes 0.13 s. If the game's exe changed, it takes about 4.7 s. The lifter is not incremental, but 562 of the 563 units come out of the cache.
Cancel is a real cancel. I sent SIGTERM nine seconds into a cold build, with 173 units done. The launcher exited with code 130, left no build record behind, and the next prepare started at unit 173 and finished in 18.7 s.
Finding the folder without upsetting macOS
macOS asks permission before an app may read Downloads, Desktop or Documents. Most people unzip into Downloads, so a launcher that searches there on its own would pop a permission dialog before saying a word of explanation.
So the first search stays out of those three folders. It looks in ~/Games, ~/Applications, /Applications, the home folder and its direct subfolders, and CrossOver bottles for people who used a Wine route before. It goes three folders deep and gives up after 2 s, and it reports at most 12 hits. A folder counts when it holds PSUC.exe and DATA in any letter case. Hidden folders and app bundles are skipped, and so are symlinks.
Downloads, Desktop and Documents are behind a button that says macOS will ask first. The other way in is Choose Folder, which opens the standard folder picker starting in Downloads. A folder picked there needs no permission prompt at all, which is the cleanest route. The search code never opens a file for writing. A test makes the folders read-only and compares the tree before and after.
One consequence I only found by reading the docs. With ad hoc signing, every new build looks like a new app to macOS, so the Downloads prompt can come back after each update. A real Developer ID signature would keep the grant. I have not bought one yet (it comes up again at the end).
Our own compiler
The game's code is turned into about 2 million lines of C (2,029,364 lines, 27,758 functions, 563 units), and a C compiler has to build it on the player's Mac. Until now that meant Apple's Command Line Tools. Those can only come from Apple, under Apple's licence, so I can't put them in the DMG, and I would rather not tell every player to install a developer package first.
LLVM is different. It is Apache 2.0 with an exception that says code you compile with it carries no obligation, so I can ship clang and lld with the licence text. I built LLVM 22.1.8 myself, arm64 only, as a single multicall binary:
scripts/build-toolchain.sh
That took 7 min 54 s (3,288 build steps on 14 cores while other sessions were running). The result is 98.2 MB on disk, mostly one 87 MB binary, and 39.3 MB as a disk image. The finished DMG is 46.4 MB. My last post called the DMG about 5 MB.
Shipping a compiler does not help if the generated C still wants Apple's headers. It did. The headers included <math.h> and <string.h>, and the link step wanted libSystem from the SDK. So the generated C is now freestanding. It includes only clang's own <stddef.h> and <stdint.h>. Memory copies with a fixed size are loads and stores. The sine, cosine, tangent, arctangent, fmod and similar functions the x87 code needs go through a function table that the host fills in, using the same libm entry points the old code called, so the results come out as the same bits. I link with ld64.lld against a small stub I wrote that lists four symbol names. Run llvm nm -u on the finished library and nothing is undefined.
Two flags cost me time. Without -target arm64-apple-macos13.0, objects were tagged with the host OS version (26.0) as their minimum and lld complained. Without -fno-stack-protector, the driver added two more libSystem symbols I had just gotten rid of.
Same results, a bit slower
I trust the results more than the speed numbers. The golden traces still pass with the bundled compiler. The lifted and interpreted runs make the same 685 Windows calls up to WinMain, and the same Direct3D 9 call sequence over 300 presents. The mass test against the interpreter covered 27,758 functions and 111,032 tasks. 85,973 matched and 0 mismatched, and the harness skipped 25,059 for its usual reasons. A 300-present run makes 216 trips into the interpreter and 5,816 steps with either compiler. The two libraries are not the same file (35,341,792 bytes against 34,969,808), and I would not expect them to be.
The other check was to make Apple's tools unavailable. I ran a cold build under sandbox-exec with the Command Line Tools, Xcode, /usr/bin/clang, ld, xcrun and a few more denied, with an empty DEVELOPER_DIR. It finished in 28.4 s and produced a library of the same size. That is a stand-in for a Mac without Xcode, not the real thing.
On speed, the bundled clang is slower. My shared machine had a load average between 8 and 60, so the runs jump around. In the two quietest pairs it compiled in 25.2 s against Apple's 20.8 s, and 25.9 s against 22.8 s. That is 10 to 19% slower. It is a one-time cost, and the launcher already says "one time" on that screen.
One test failed at the time. In a release build, fsincos on a negative NaN gave a different sign in the interpreter than in the C. I traced it to LLVM fusing the interpreter's sine and cosine into one call, while the C calls cosine alone. It failed the same way with Apple's compiler, so the bundled one did not cause it. It is fixed now.
Signing and a missing entitlement
The app now builds with the hardened runtime, signed ad hoc. The game library the player's Mac builds is signed by the linker, with no team ID of mine on it. So psu-run needs one entitlement, disable-library-validation, or dlopen fails with "mapping process and mapped file (non-platform) have different Team IDs". I checked by signing a copy of the app without it, and it failed that way. Nothing else in the app has any entitlement.
I wrote the notarization script too (packaging/notarize.sh) and it has never been run. It refuses an ad hoc signed app, by design.
The game's own settings
I wanted a Settings window that changes things the game actually reads. The game keeps its options in PsuIlluminus.ini, normally edited by SEGA's option.exe. I do not run option.exe, so I had to find out what the game wants.
First I traced it. A new PSU_TRACE_INI=1 flag prints every GetPrivateProfile* call the game makes, with section, key, default and value, and blanks anything that looks like a credential. The game makes 96 of those calls, all in one burst at startup, and never reads the file again. It does not even import the function to write it. So the file only changes when something else changes it, which is now our launcher.
Then I read option.exe as a file, without running it. It is a 772,032 byte Borland program from 2008, and it gave me the meaning and allowed values for most keys.
The Game tab writes only a short list. Resolution is an index from 0 to 5, which means 640x480, 800x600, 1024x768, 1280x960, 1280x1024 and 1280x720. Music and sound effects are on or off switches, because the file has no volume levels (volume stays in our own audio.json). The 31 keyboard bindings are written as DirectInput key names. Each write changes only that one value. Every other line, comment, line ending and byte stays the same, and a round trip of the file is byte for byte identical.
Two keys are never written and never shown. VSYNCWAIT and a setting labelled Frame skip look like frame pacing, and the Clementine rules say not to uncap the game. A test checks that no frame rate key can ever be written.
The 1280 x 1024 bug
Picking 1280x1024 gave a squeezed picture. The game creates a 1280x1024 back buffer, which is 5:4, and then moves its own window to a client area of 1280 by 959, which is about 4:3. My window code took the client size as the picture size, so the 5:4 image was squashed by about 6% vertically. On a 3840 x 2160 display it showed up as a 4:3 picture 2,884 pixels wide.
The fix compares the two shapes. If the back buffer's aspect differs from the client's by more than half a percent, the picture follows the back buffer. Otherwise nothing changes, so the other five sizes behave as before. Full screen on that display is now 2,700 x 2,160 with bars on the sides. The mouse stayed tricky. Windows stretches the back buffer over the client area, and mouse messages carry client pixels, so the bottom row of the picture arrives as y = 958, not 1023. I scale the position into the client rectangle to match.
A controller, through a DLL I did not need
The game ships a file called di.dll, and my first fear was that it was an input wrapper my runtime would have to copy. I opened it (read only again). It is 45,056 bytes of SEGA's own helper from 2006, with four exports, used only by option.exe to detect which button you pressed. The game never loads it. The game talks to DirectInput 8 directly, enumerates controllers once at startup, takes up to two, and reads a DIJOYSTATE2.
So the runtime now answers those calls. The host side reads GCController through GameController.framework and presents one pad as a generic 12 button USB pad, because the game's built-in button numbers were written for that layout. A, B, X and Y are buttons 1 to 4, shoulders 5 and 6, triggers 7 and 8, stick clicks 9 and 10, and Start is 12. With that, the defaults line up and the launcher only has to add three ini lines, one for Select and two for the right stick.
Two details nearly got me. The D-pad is a hat switch, and in DirectInput a value of 0 means up, so a state filled with zeros holds up forever. Centred has to be 0xFFFFFFFF. And Y on the stick is inverted between the two systems, so I flip it once and add no dead zone of mine, since the game applies 0.25 itself.
At first I had no controller connected, so I tested with a script. psu-run --pad-script "menu@800+8,down@1300" feeds fake presses. The title screen answered to Menu (Start) and went to the main menu. D-pad down and the left stick pushed down both moved the cursor from Network Mode to End Game, so the hat and the inversion are right. B opened the Network Mode login screen, and A and X did nothing there, so B is the game's confirm button. I saw this in the window, not just in the log.
What is still rough
Most of my testing was on my own Mac, which has Xcode on it. Japanese input and non-US keyboard layouts are not handled. Because the signature is ad hoc, macOS can ask about Downloads access again after an update. The 1280 x 1024 fix and the fsincos sign bug from earlier are both closed. If something breaks, the app offers Open Logs Folder, and crash reports stay on your Mac.
Notarization is next, so the Open Anyway step can go away. Bug reports are welcome in the community channels.