Skip to content

Commit 295c599

Browse files
committed
GUI update
1 parent e468a9a commit 295c599

10 files changed

Lines changed: 1143 additions & 57 deletions

File tree

‎PROJECT.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2108,3 +2108,56 @@ stall → 1.8 s muted instead of a replay; a normal race mutes ~85 ms.
21082108
`SPEED2.EXE` is checked by SHA-256 at startup; any other build (the US retail
21092109
executable, for one) gets a clear message instead of a crash.
21102110
`NFSU2_ANY_EXE=1` skips the check.
2111+
2112+
## 2026-10-26: launcher window and portable copy
2113+
2114+
Reddit asked for a GUI and for a single app with the game inside, made without a
2115+
terminal. `launcher/*.swift` (Swift 5 mode, AppKit + SwiftUI, macOS 11 target)
2116+
is now the bundle's executable; `pack.sh` builds it with `swiftc` and puts both
2117+
engines next to it:
2118+
2119+
Contents/MacOS/NFSU2 launcher
2120+
Contents/MacOS/nfsu2-engine out/nfsu2_boot
2121+
Contents/MacOS/nfsu2-engine-hd out/nfsu2_hd_boot
2122+
2123+
One download (~67 MB) instead of two apps. `./pack.sh --hd` still makes the old
2124+
HD-only "NFSU2 HD.app"; `--original-only` leaves the HD engine out; without
2125+
`swiftc` the engine becomes the executable as before (no window).
2126+
2127+
- **Game files:** same search order as `app_find_game`; the exe is checked
2128+
(size, SHA-256) before Play; the engine gets the checked path in `NFSU2_DATA`.
2129+
States: found / not found / different version / inside this app.
2130+
- **Graphics:** Original or HD (`LauncherEngine`), full screen (`NFSUFullScreen`,
2131+
the key the engine already uses).
2132+
- **Make Copy…:** builds "NFSU2 Portable.app" (save panel, /Applications by
2133+
default): copies this bundle plus the game folder into
2134+
`Contents/Resources/Game` (MOVIES and the Windows plugins left out, as in
2135+
`pack.sh --embed`), `copyfile(COPYFILE_CLONE)` per file (instant on the same
2136+
APFS disk: measured 6 s for 1.5 GB), removes the quarantine tag, signs ad hoc
2137+
again, then renames from a hidden `.partial` name. The running app is never
2138+
written to (signature; App Translocation makes it read-only anyway). A copy
2139+
being replaced goes to the Trash.
2140+
- **Tools:** Saves, Logs, Reset Graphics Settings (drops `g_*` and
2141+
`NotFirstTime` from `~/.nfsu2_registry`: the game detects again), Report a
2142+
Problem (GitHub issues).
2143+
- **Show this window when the app opens:** off = straight into the game; hold
2144+
Option to see the window.
2145+
2146+
**The engine is a child process, not exec().** A game folder under Downloads /
2147+
Documents / Desktop triggers macOS's folder permission prompt. A child process
2148+
inherits the launcher as its "responsible" process, so the permission granted to
2149+
the launcher covers the engine; after exec() the ad-hoc signed engine would be a
2150+
different code identity and could be asked again. The launcher hides its window,
2151+
drops its Dock icon (accessory) and exits with the engine's exit status, so
2152+
`open -W` still waits for the game.
2153+
2154+
Test hooks (no visible window): `NFSU2_LAUNCHER_SNAPSHOT=<png>` draws the window
2155+
(`NFSU2_LAUNCHER_STATE=notfound|unsupported|embedded|copying|done`),
2156+
`NFSU2_LAUNCHER_TEST=play` presses Play off screen,
2157+
`NFSU2_LAUNCHER_TEST=portable:<game dir>:<dest.app>` builds a portable copy.
2158+
In automated runs keep the game outside Downloads/Documents/Desktop (or pass
2159+
`NFSU2_DATA`): the permission prompt otherwise waits for a click.
2160+
Verified: Original and HD started through `open` + launcher, 1200–1500 frames,
2161+
clean exit, 0 traps; portable copy signed (`codesign --verify --strict`), no
2162+
quarantine, runs from its embedded data; user prefs and registry unchanged.
2163+
`tools/session.sh` opens NFSU2.app with `NFSU2_SKIP_LAUNCHER=1 NFSU2_ENGINE=…`.

‎README.md‎

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ time. On an M1 Pro, races run at **119 fps** (the display's limit), against
1717

1818
> **This repository contains no game code and no game assets.** It contains the
1919
> recompiler and the runtime only. You need your own copy of the game. The C
20-
> code generated from its executable is never committed. The prebuilt apps on
21-
> the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest) contain the recompiled program but no game files:
22-
> on first launch you point them at your own installation.
20+
> code generated from its executable is never committed. The prebuilt app on
21+
> the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest) contains the recompiled program but no game files:
22+
> on first launch you point it at your own installation.
2323
2424
![Race, native ARM64 build](screenshots/selected/27_20260924_current-race.jpg)
2525

@@ -35,7 +35,7 @@ The game boots, reaches the menus, and races are playable with sound.
3535
| DirectSound → CoreAudio, DirectInput (keyboard, mouse) | working |
3636
| Windowed, fullscreen, Retina-native and 1080p widescreen | working |
3737
| Career mode: profile creation (name entry), save and load | working |
38-
| Double-clickable `.app` (`./pack.sh`) | working |
38+
| Double-clickable `.app` with a launcher window (`./pack.sh`) | working |
3939
| Optional "HD" build: MSAA (2× at 1440 lines and above, 4× below), SSR, ray-traced reflections | working, see `GRAPHICS_RESEARCH.md` |
4040

4141
### Known limitations
@@ -107,6 +107,7 @@ out/nfsu2_boot ~35 MB arm64 Mach-O
107107
| `runtime/overrides.c`, `d3dx_hle.c` | hand-written replacements for specific game functions |
108108
| `runtime/enhance.c` | HD build only (`-DNFS_ENHANCED=1`) |
109109
| `runtime/bundle.m`, `pack.sh` | `.app` behaviour and packaging |
110+
| `launcher/*.swift` | the app's launcher window (Swift/SwiftUI): finds and checks the game, Original/HD, portable copy |
110111
| `tests/boot.c` | program entry point (runs the game from its PE entry point) |
111112

112113
## Verification
@@ -149,7 +150,7 @@ HD build draws about 18 W.
149150
## Requirements
150151

151152
- Apple Silicon Mac, macOS 11 or later. Developed on an M1 Pro, macOS 27.
152-
- Xcode Command Line Tools (`clang`).
153+
- Xcode Command Line Tools (`clang`; `swiftc` for the launcher window).
153154
- Python 3 with `capstone` 5.x and `pefile` (`requirements.txt`).
154155
- Rosetta 2, only for `tools/fuzzisa.py`.
155156
- Your own installation of the game, with this exact executable:
@@ -187,10 +188,22 @@ tools/gen_v2.sh "$EXE"
187188
./run.sh # windowed
188189
./run.sh 1080p # 1920x1080, widescreen-corrected
189190
./run.sh hd # HD build
190-
./pack.sh # out/NFSU2.app
191-
./pack.sh --hd # out/NFSU2 HD.app
191+
./pack.sh # out/NFSU2.app: launcher + both engines (Original and HD)
192+
./pack.sh --zip # also out/NFSU2.zip for a release
193+
./pack.sh --embed # out/personal/NFSU2.app with your game inside (do not share)
192194
```
193195

196+
The app opens a small launcher window first (`launcher/`, built by `pack.sh`
197+
with `swiftc`): it finds and checks `SPEED2.EXE`, offers Original or HD graphics
198+
and full screen, opens the saves and logs folders, and **Make Copy…** builds a
199+
portable app with the game files inside (APFS clones, seconds on the same disk),
200+
so nobody needs `pack.sh --embed` or a terminal for that. Untick "Show this
201+
window when the app opens" to go straight into the game (hold Option to get the
202+
window back). `NFSU2_SKIP_LAUNCHER=1` / `NFSU2_ENGINE=original|hd` do the same
203+
from scripts.
204+
205+
![Launcher window](screenshots/selected/launcher.png)
206+
194207
`run.sh` documents the environment switches; most are diagnostics.
195208

196209
While playing, **Cmd+Shift+D** writes the next frame as a PNG plus the full
@@ -204,8 +217,9 @@ a report.
204217
**Can I play Need for Speed: Underground 2 on a Mac?**
205218
Yes, on an Apple Silicon Mac (M1 or later) with your own copy of the PC game.
206219
Download `NFSU2.zip` from the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest), or build the port as
207-
described above (`./pack.sh` produces a double-clickable `NFSU2.app`). Menus,
208-
races, career saves and sound work.
220+
described above (`./pack.sh` produces a double-clickable `NFSU2.app`). Open it,
221+
choose the game folder once, click Play. Menus, races, career saves and sound
222+
work.
209223

210224
**Do I need Wine, CrossOver, Whisky, Parallels or a virtual machine?**
211225
No. The game's code is recompiled to native ARM64 and runs directly on macOS.
@@ -217,12 +231,21 @@ against 10–15 fps under Wine 11.18 with Rosetta 2, and 119 fps against 45 fps
217231
in the 3D menus. Method and raw numbers are in `WINE_COMPARISON.md`.
218232

219233
**Is there a ready-made download?**
220-
Yes: the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest) has `NFSU2.zip` (standard build) and
221-
`NFSU2.HD.zip` (MSAA, ray-traced reflections). Neither contains game files; on
222-
first launch the app asks for the folder that holds your own `SPEED2.EXE`. The
223-
app is not notarized, so after moving it to Applications run once:
234+
Yes: the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest) has `NFSU2.zip`. The app holds both
235+
graphics modes, Original and HD (MSAA, ray-traced reflections), chosen in its
236+
window; older releases had a separate `NFSU2.HD.zip`. It contains no game files:
237+
the window asks for the folder that holds your own `SPEED2.EXE`.
238+
The app is not notarized, so macOS blocks the first start: open System Settings ›
239+
Privacy & Security and click **Open Anyway** (on macOS 14 and older: right-click
240+
the app › Open). If macOS says the app is damaged, run once in Terminal:
224241
`xattr -dr com.apple.quarantine /Applications/NFSU2.app`
225242

243+
**Can I carry the game to another Mac in one piece?**
244+
Yes. In the app's window, **Make Copy…** creates "NFSU2 Portable.app" with the
245+
game files inside (about 1.4 GB). It runs on its own; saves stay in
246+
`~/Library/Application Support/NFSU2 Saves` on each Mac. It contains the game,
247+
so keep it for yourself.
248+
226249
**Which version of the game does it need?**
227250
The Windows PC version, with the exact `SPEED2.EXE` listed under
228251
[Requirements](#requirements). Other builds, including the console versions,

‎launcher/Game.swift‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Finding and checking the game files, and the small settings the launcher owns.
2+
//
3+
// The search order is the same as the engine's (runtime/bundle.m, app_find_game):
4+
// data inside the bundle, the folder chosen before, next to the app, its parent
5+
// folder, a few usual places in the home folder. The engine gets the exact
6+
// SPEED2.EXE the launcher checked through NFSU2_DATA.
7+
import Foundation
8+
import CryptoKit
9+
10+
enum SupportedExe {
11+
static let size: UInt64 = 4_788_224
12+
static let sha256 = "8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297"
13+
static let names = ["SPEED2.EXE", "speed2.exe", "Speed2.exe"]
14+
}
15+
16+
struct GameLocation: Equatable {
17+
let exe: URL
18+
let embedded: Bool
19+
var folder: URL { exe.deletingLastPathComponent() }
20+
}
21+
22+
enum GameState: Equatable {
23+
case searching
24+
case notFound
25+
case ready(GameLocation)
26+
case unsupported(GameLocation, UInt64) // exe size in bytes
27+
}
28+
29+
enum Paths {
30+
static var home: URL { FileManager.default.homeDirectoryForCurrentUser }
31+
static var bundle: URL { Bundle.main.bundleURL }
32+
static var resources: URL { bundle.appendingPathComponent("Contents/Resources") }
33+
static var embeddedGame: URL { resources.appendingPathComponent("Game") }
34+
static var macOS: URL { bundle.appendingPathComponent("Contents/MacOS") }
35+
static var saves: URL { home.appendingPathComponent("Library/Application Support/NFSU2 Saves") }
36+
static var logs: URL { home.appendingPathComponent("Library/Logs/NFSU2") }
37+
static var registry: URL { home.appendingPathComponent(".nfsu2_registry") }
38+
}
39+
40+
enum Prefs {
41+
// Shared with the engine (same bundle id, same defaults domain).
42+
static let gameFolder = "OyunDizini" // name kept for existing installs
43+
static let fullscreen = "NFSUFullScreen"
44+
// Launcher only.
45+
static let engine = "LauncherEngine" // "original" | "hd"
46+
static let showAtStartup = "LauncherShowAtStartup"
47+
}
48+
49+
enum Locator {
50+
static func exeIn(_ dir: URL) -> URL? {
51+
for n in SupportedExe.names {
52+
let u = dir.appendingPathComponent(n)
53+
var isDir: ObjCBool = false
54+
if FileManager.default.fileExists(atPath: u.path, isDirectory: &isDir), !isDir.boolValue { return u }
55+
}
56+
return nil
57+
}
58+
59+
/// The folder itself or up to two levels below it ("...Clean Edition/Need for Speed Underground 2").
60+
static func find(under dir: URL, depth: Int = 2) -> URL? {
61+
if let e = exeIn(dir) { return e }
62+
guard depth > 0,
63+
let kids = try? FileManager.default.contentsOfDirectory(
64+
at: dir, includingPropertiesForKeys: [.isDirectoryKey, .isPackageKey],
65+
options: [.skipsHiddenFiles]) else { return nil }
66+
for k in kids.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
67+
let v = try? k.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey])
68+
guard v?.isDirectory == true, v?.isPackage != true else { continue }
69+
if let e = find(under: k, depth: depth - 1) { return e }
70+
}
71+
return nil
72+
}
73+
74+
static func locate() -> GameLocation? {
75+
if let env = ProcessInfo.processInfo.environment["NFSU2_DATA"], !env.isEmpty {
76+
let u = URL(fileURLWithPath: env)
77+
var isDir: ObjCBool = false
78+
if FileManager.default.fileExists(atPath: u.path, isDirectory: &isDir) {
79+
if !isDir.boolValue { return GameLocation(exe: u, embedded: false) }
80+
if let e = find(under: u) { return GameLocation(exe: e, embedded: false) }
81+
}
82+
}
83+
if let e = find(under: Paths.embeddedGame) { return GameLocation(exe: e, embedded: true) }
84+
if let saved = UserDefaults.standard.string(forKey: Prefs.gameFolder),
85+
let e = find(under: URL(fileURLWithPath: saved)) { return GameLocation(exe: e, embedded: false) }
86+
let besideApp = Paths.bundle.deletingLastPathComponent()
87+
for d in [besideApp, besideApp.deletingLastPathComponent()] {
88+
if let e = find(under: d) { return GameLocation(exe: e, embedded: false) }
89+
}
90+
for rel in ["Library/Application Support/NFSU2",
91+
"Documents/Need for Speed Underground 2",
92+
"Downloads/Need for Speed Underground 2",
93+
"Desktop/Need for Speed Underground 2"] {
94+
if let e = find(under: Paths.home.appendingPathComponent(rel)) {
95+
return GameLocation(exe: e, embedded: false)
96+
}
97+
}
98+
return nil
99+
}
100+
101+
static func fileSize(_ u: URL) -> UInt64 {
102+
((try? FileManager.default.attributesOfItem(atPath: u.path))?[.size] as? NSNumber)?.uint64Value ?? 0
103+
}
104+
105+
/// true when this is the one SPEED2.EXE the port was built from.
106+
static func isSupported(_ exe: URL) -> Bool {
107+
if ProcessInfo.processInfo.environment["NFSU2_ANY_EXE"] == "1" { return true }
108+
guard fileSize(exe) == SupportedExe.size,
109+
let data = try? Data(contentsOf: exe, options: .mappedIfSafe) else { return false }
110+
let hex = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
111+
return hex == SupportedExe.sha256
112+
}
113+
114+
static func check(_ loc: GameLocation) -> GameState {
115+
isSupported(loc.exe) ? .ready(loc) : .unsupported(loc, fileSize(loc.exe))
116+
}
117+
}
118+
119+
enum Registry {
120+
/// Forget the saved graphics settings so the game picks settings for this Mac
121+
/// again on its next start (its own first-run detection). Saves untouched.
122+
static func resetGraphics() throws {
123+
let u = Paths.registry
124+
guard let text = try? String(contentsOf: u, encoding: .utf8) else { return }
125+
let kept = text.split(separator: "\n", omittingEmptySubsequences: true).filter { line in
126+
!(line.hasPrefix("d g_") || line.hasPrefix("s g_") || line.hasPrefix("d NotFirstTime="))
127+
}
128+
try (kept.joined(separator: "\n") + "\n").write(to: u, atomically: true, encoding: .utf8)
129+
}
130+
}
131+
132+
func formatBytes(_ n: Int64) -> String {
133+
let f = ByteCountFormatter()
134+
f.countStyle = .file
135+
return f.string(fromByteCount: n)
136+
}

0 commit comments

Comments
 (0)