Skip to content

Commit da9c23a

Browse files
committed
NA version support
1 parent 1add308 commit da9c23a

24 files changed

Lines changed: 1109 additions & 280 deletions

‎README.md‎

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,9 @@ The game boots, reaches the menus, and races are playable with sound.
4040

4141
### Known limitations
4242

43-
- **Exactly one build of `SPEED2.EXE` is supported**, the European v1.2 one
44-
(see [Game versions](#game-versions)).
45-
Game addresses are hard-coded in the runtime, and the pipeline relies on a
46-
property of that particular file (below).
43+
- **Two builds of `SPEED2.EXE` are supported**, the European and the North
44+
American v1.2 ones (see [Game versions](#game-versions)); each has its own
45+
engine. Other builds would need their own lifting pass and address map.
4746
- Videos are skipped: there is no VP6 decoder.
4847
- Keyboard and mouse only; no gamepad or wheel support. No network play.
4948
- Structured exception handling is not implemented (`RtlUnwind` /
@@ -154,27 +153,27 @@ HD build draws about 18 W.
154153
- Xcode Command Line Tools (`clang`; `swiftc` for the launcher window).
155154
- Python 3 with `capstone` 5.x and `pefile` (`requirements.txt`).
156155
- Rosetta 2, only for `tools/fuzzisa.py`.
157-
- Your own installation of the game, with this exact executable:
156+
- Your own installation of the game, with one of these executables (both v1.2
157+
without the CD check, built Feb 9 2005):
158158

159-
| | |
160-
|---|---|
161-
| File | `SPEED2.EXE`, 4,788,224 bytes |
162-
| Build | **European** (PAL) v1.2 executable without the CD check, built Feb 9 2005 |
163-
| SHA-256 | `8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297` |
159+
| Build | `SPEED2.EXE` size | SHA-256 |
160+
|---|---|---|
161+
| **European** (PAL) v1.2 | 4,788,224 bytes | `8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297` |
162+
| **North American** v1.2 | 4,800,512 bytes | `f9dd86c054878ce6276beb07c1fd61874f7a1e4bf1f241b084c65b73e24168a7` |
164163

165-
The app checks the file at startup and says so instead of crashing;
166-
`NFSU2_ANY_EXE=1` skips the check.
164+
The app checks the file at startup, picks the matching engine and says so
165+
instead of crashing on any other file; `NFSU2_ANY_EXE=1` skips the check.
167166

168167
### Game versions
169168

170-
The recompiled code and the addresses in the runtime belong to one executable,
171-
so every other build of `SPEED2.EXE` needs its own lifting pass and address
172-
table. The game *data* is a separate matter (see the note under the table).
169+
The recompiled code belongs to one executable, so every build of `SPEED2.EXE`
170+
gets its own lifting pass and engine. The game *data* is a separate matter (see
171+
the note under the table).
173172

174173
| `SPEED2.EXE` | Size | SHA-256 | Status |
175174
|---|---|---|---|
176-
| European v1.2, no CD check | 4,788,224 | `8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297` | **Supported** |
177-
| North American v1.2, no CD check (the build most PC mods target) | 4,800,512 | `f9dd86c054878ce6276beb07c1fd61874f7a1e4bf1f241b084c65b73e24168a7` | Not supported yet. Same Feb 9 2005 build; the data addresses match ours, the code is shifted by small amounts. Planned. |
175+
| European v1.2, no CD check | 4,788,224 | `8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297` | **Supported** (the reference build) |
176+
| North American v1.2, no CD check (the build most PC mods target) | 4,800,512 | `f9dd86c054878ce6276beb07c1fd61874f7a1e4bf1f241b084c65b73e24168a7` | **Supported** (engines `nfsu2-engine-na`, `-na-hd`). Tested on US disc data from the menus into a Quick Race, original and HD. |
178177
| US v1.0 retail disc | 5,987,981 | `2cbd698fee8338a13d1891bc17d27e6641b4b9dd40580de818594b9140222899` | **Cannot be supported.** Protected by SafeDisc: the code section is encrypted on disc and only decrypted at run time, so there is nothing to recompile. |
179178

180179
The retail disc executable carries the disc's copy protection, and the official
@@ -210,10 +209,36 @@ python3 tools/install_from_discs.py ~/Games/NFSU2 "Disc 1.cue" "Disc 2.cue"
210209
```
211210

212211
This writes a normal game folder (about 1.7 GB) that the launcher can point at.
213-
It copies the disc's `SPEED2.EXE` too, but see the table above: the launcher
214-
accepts only the supported executable. With that executable in the folder, the
215-
US disc data plays in the US region (see "US data works with the supported
216-
executable" above).
212+
It copies the disc's `SPEED2.EXE` too, but see the table above: the disc
213+
executable cannot run, so put a supported one in its place. The North American
214+
v1.2 executable is the natural match for US disc data; the European one also
215+
works with it (see "US data works with the supported executable" above).
216+
217+
### How the North American build is made
218+
219+
The European executable is the reference: the runtime was written against it.
220+
`tools/addrmap.py` matches the two executables function by function (a byte
221+
signature with the parts that move between builds masked, aligned in order) and
222+
collects data-address pairs from the matched code: 23,276 of 23,277 functions
223+
match, most code is 16 bytes away from the European build, most data is at the
224+
same address. Every game address in the runtime is written as `GA(0x…)` in
225+
European terms; for the North American engine `runtime/build_na.h` (generated
226+
from the map) turns each one, and each `sub_XXXXXXXX` name the runtime uses, into
227+
the North American one. An address the map cannot place stops the build.
228+
229+
Two things differ at run time:
230+
231+
- The North American no-CD executable still looks for a CD-ROM drive (its disc
232+
contents check is patched out, the drive check is not) and otherwise asks for
233+
"Disc 2" forever. Its engine reports `D:` as an empty CD-ROM drive; paths on
234+
`D:` resolve to the game folder like those on `C:`.
235+
- Its region variable is in `.bss` (0, the US) rather than a constant, see
236+
"US data works with the supported executable".
237+
238+
Function discovery missed eight call targets in this executable (its linear scan
239+
stops on bytes an earlier, misaligned scan covered), which became traps;
240+
`LIFT_MISSING=1` (set by `tools/gen_v2.sh` for this build) lifts such targets in
241+
extra shards.
217242

218243
## Build and run
219244

@@ -243,6 +268,22 @@ tools/gen_v2.sh "$EXE"
243268
./pack.sh --embed # out/personal/NFSU2.app with your game inside (do not share)
244269
```
245270

271+
For the North American v1.2 executable the same steps build a second pair of
272+
engines; `tools/gen_v2.sh` recognises the file by its SHA-256 and writes `gen/na`,
273+
`run.sh` picks the engine by the executable, and `pack.sh` adds the engines to the
274+
app when they are built:
275+
276+
```bash
277+
tools/gen_v2.sh "/path/to/NA/SPEED2.EXE" # -> gen/na
278+
BUILD=na ./build.sh && BUILD=na ./link.sh # -> out/nfsu2_na_boot, out/nfsu2_na_hd_boot
279+
```
280+
281+
`runtime/build_na.h` is committed. `link.sh` regenerates it from
282+
`out/na/addrmap.json` when that file exists, which takes both executables:
283+
`tools/addrmap.py build <European SPEED2.EXE> <North American SPEED2.EXE> out/na/addrmap.json`.
284+
Do this after adding a `GA(...)` address or a `sub_` name to the runtime; game
285+
addresses written without `GA(...)` would be wrong in the North American engine.
286+
246287
The app opens a small launcher window first (`launcher/`, built by `pack.sh`
247288
with `swiftc`): it finds and checks `SPEED2.EXE`, offers Original or HD graphics
248289
and full screen, opens the saves and logs folders, and **Make Copy…** builds a
@@ -297,10 +338,10 @@ game files inside (about 1.4 GB). It runs on its own; saves stay in
297338
so keep it for yourself.
298339

299340
**Which version of the game does it need?**
300-
The Windows PC version, with the European v1.2 `SPEED2.EXE` listed under
301-
[Requirements](#requirements). The North American v1.2 executable is not
302-
supported yet; the US v1.0 disc executable cannot be (it is copy-protected).
303-
Console versions are not supported. See [Game versions](#game-versions).
341+
The Windows PC version, with the European or the North American v1.2
342+
`SPEED2.EXE` listed under [Requirements](#requirements). The US v1.0 disc
343+
executable cannot be supported (it is copy-protected). Console versions are not
344+
supported. See [Game versions](#game-versions).
304345

305346
**I only have the PC discs. Can I install them on a Mac?**
306347
Yes, without Windows: `tools/install_from_discs.py` unpacks the disc images

‎build.sh‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ MINOS="${MINOS:-11.0}"
1919
# without the flag clang's GVN can miscompile it. See LIFTER_EFFICIENCY.md section 4.
2020
OPT="${OPT:--O3 -mcpu=$MCPU -mmacosx-version-min=$MINOS -fno-strict-aliasing}"
2121
J="${J:-$(sysctl -n hw.ncpu)}"
22-
mkdir -p "$ROOT/out/obj"
23-
cd "$ROOT/gen/all"
22+
# BUILD=eu (default): gen/all -> out/obj BUILD=na: gen/na -> out/obj_na
23+
# (North American v1.2 executable; see README "Game versions", tools/gen_v2.sh)
24+
case "${BUILD:-eu}" in
25+
eu) GEN=gen/all; OBJ=out/obj ;;
26+
na) GEN=gen/na; OBJ=out/obj_na ;;
27+
*) echo "BUILD must be eu or na"; exit 1 ;;
28+
esac
29+
mkdir -p "$ROOT/$OBJ"
30+
cd "$ROOT/$GEN"
2431
# DON'T EMBED $OPT in the xargs command line: the length of a line built with
2532
# -I{} is limited ("command line cannot be assembled, too long"). Pass it in
2633
# from the environment and let the inner shell expand it.
@@ -31,11 +38,11 @@ cd "$ROOT/gen/all"
3138
# returned 0 -> the link broke with "symbol(s) not found". The output is first
3239
# captured in a variable, then truncated; clang's exit code is kept (if any shard
3340
# fails to compile, xargs and therefore build.sh return an error).
34-
export OPT ROOT
41+
export OPT ROOT OBJ
3542
ls shard_*.c fntab.c stubs.c 2>/dev/null | \
3643
xargs -P "$J" -n 1 sh -c '
3744
o=$(clang $OPT -c -I"$ROOT/runtime" -I. -Wno-unused-label -Wno-parentheses-equality \
38-
-o "$ROOT/out/obj/$(basename "$1" .c).o" "$1" 2>&1); r=$?
45+
-o "$ROOT/$OBJ/$(basename "$1" .c).o" "$1" 2>&1); r=$?
3946
[ -z "$o" ] || printf "%s\n" "$o" | head -5
4047
[ $r -eq 0 ] || echo "COMPILE FAILED: $1" >&2
4148
exit $r' sh

‎launcher/Game.swift‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,28 @@
77
import Foundation
88
import CryptoKit
99

10+
/// A build of SPEED2.EXE the port has an engine for (README "Game versions").
11+
struct ExeBuild: Equatable {
12+
let name: String
13+
let size: UInt64
14+
let sha256: String
15+
let engineSuffix: String // Contents/MacOS/nfsu2-engine<suffix>[-hd]
16+
}
17+
1018
enum SupportedExe {
11-
static let size: UInt64 = 4_788_224
12-
static let sha256 = "8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297"
19+
static let builds = [
20+
ExeBuild(name: "European v1.2", size: 4_788_224,
21+
sha256: "8f6a9ae2c4cfa01181d2fd755d99c6f4b7103f60273908ea1de1e719dfe2a297", engineSuffix: ""),
22+
ExeBuild(name: "North American v1.2", size: 4_800_512,
23+
sha256: "f9dd86c054878ce6276beb07c1fd61874f7a1e4bf1f241b084c65b73e24168a7", engineSuffix: "-na"),
24+
]
1325
static let names = ["SPEED2.EXE", "speed2.exe", "Speed2.exe"]
1426
}
1527

1628
struct GameLocation: Equatable {
1729
let exe: URL
1830
let embedded: Bool
31+
var build: ExeBuild? = nil // set by Locator.check
1932
var folder: URL { exe.deletingLastPathComponent() }
2033
}
2134

@@ -35,6 +48,16 @@ enum Paths {
3548
static var saves: URL { home.appendingPathComponent("Library/Application Support/NFSU2 Saves") }
3649
static var logs: URL { home.appendingPathComponent("Library/Logs/NFSU2") }
3750
static var registry: URL { home.appendingPathComponent(".nfsu2_registry") }
51+
52+
/// The engine binary for a build of SPEED2.EXE (nil: the European one).
53+
static func engine(_ build: ExeBuild?, hd: Bool) -> URL {
54+
macOS.appendingPathComponent("nfsu2-engine" + (build?.engineSuffix ?? "") + (hd ? "-hd" : ""))
55+
}
56+
static func hasEngine(_ build: ExeBuild?) -> Bool {
57+
let fm = FileManager.default
58+
return fm.isExecutableFile(atPath: engine(build, hd: false).path)
59+
|| fm.isExecutableFile(atPath: engine(build, hd: true).path)
60+
}
3861
}
3962

4063
enum Prefs {
@@ -102,17 +125,24 @@ enum Locator {
102125
((try? FileManager.default.attributesOfItem(atPath: u.path))?[.size] as? NSNumber)?.uint64Value ?? 0
103126
}
104127

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 }
128+
/// Which supported build this SPEED2.EXE is, if any.
129+
static func identify(_ exe: URL) -> ExeBuild? {
130+
let size = fileSize(exe)
131+
guard SupportedExe.builds.contains(where: { $0.size == size }),
132+
let data = try? Data(contentsOf: exe, options: .mappedIfSafe) else { return nil }
110133
let hex = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
111-
return hex == SupportedExe.sha256
134+
return SupportedExe.builds.first { $0.sha256 == hex }
112135
}
113136

137+
/// Ready when the file is a supported build and this app has its engine.
114138
static func check(_ loc: GameLocation) -> GameState {
115-
isSupported(loc.exe) ? .ready(loc) : .unsupported(loc, fileSize(loc.exe))
139+
var l = loc
140+
l.build = identify(loc.exe)
141+
if l.build == nil && ProcessInfo.processInfo.environment["NFSU2_ANY_EXE"] == "1" {
142+
l.build = SupportedExe.builds[0]
143+
}
144+
guard let b = l.build, Paths.hasEngine(b) else { return .unsupported(l, fileSize(loc.exe)) }
145+
return .ready(l)
116146
}
117147
}
118148

‎launcher/Model.swift‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,16 @@ final class LauncherModel: ObservableObject {
2929
var isCopying: Bool { builder != nil }
3030
private var quitWhenCopyStops = false
3131

32-
let hasOriginal: Bool
33-
let hasHD: Bool
34-
var canChooseEngine: Bool { hasOriginal && hasHD }
35-
36-
static func engineURL(_ e: Engine) -> URL {
37-
Paths.macOS.appendingPathComponent(e == .hd ? "nfsu2-engine-hd" : "nfsu2-engine")
32+
/// Engines for the found SPEED2.EXE (European ones until it is known).
33+
func hasEngine(_ e: Engine) -> Bool {
34+
FileManager.default.isExecutableFile(atPath: Paths.engine(location?.build, hd: e == .hd).path)
3835
}
36+
var canChooseEngine: Bool { hasEngine(.original) && hasEngine(.hd) }
3937

4038
init() {
4139
let fm = FileManager.default
42-
hasOriginal = fm.isExecutableFile(atPath: Self.engineURL(.original).path)
43-
hasHD = fm.isExecutableFile(atPath: Self.engineURL(.hd).path)
40+
let hasOriginal = fm.isExecutableFile(atPath: Paths.engine(nil, hd: false).path)
41+
let hasHD = fm.isExecutableFile(atPath: Paths.engine(nil, hd: true).path)
4442
defaults.register(defaults: [Prefs.showAtStartup: true])
4543
var e = Engine(rawValue: defaults.string(forKey: Prefs.engine) ?? "") ?? .original
4644
if let env = ProcessInfo.processInfo.environment["NFSU2_ENGINE"], let f = Engine(rawValue: env) { e = f }
@@ -111,7 +109,9 @@ final class LauncherModel: ObservableObject {
111109
if !engineForced { defaults.set(engine.rawValue, forKey: Prefs.engine) }
112110
defaults.synchronize()
113111
if !fromEnv { setenv("NFSU2_DATA", loc.exe.path, 1) }
114-
let path = Self.engineURL(engine).path
112+
var e = engine
113+
if !hasEngine(e) { e = (e == .hd) ? .original : .hd }
114+
let path = Paths.engine(loc.build, hd: e == .hd).path
115115
// A child process, not exec(): macOS attributes the child's file access to
116116
// this launcher (the "responsible" process), so folder permissions the
117117
// player granted here (Downloads, Documents...) are not asked for again.

‎launcher/Views.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,15 @@ struct GameCard: View {
7474
return (Theme.warn, "Game files not found",
7575
"Choose the folder where Need for Speed Underground 2 is installed (the folder with SPEED2.EXE in it). Your own copy of the PC game is needed.")
7676
case .ready(let l):
77-
return (Theme.accent, l.embedded ? "Ready · the game is inside this app" : "Ready · supported version",
77+
return (Theme.accent, l.embedded ? "Ready · the game is inside this app" : "Ready · \(l.build?.name ?? "supported version")",
7878
l.embedded ? "This is a portable copy: it needs no separate game folder." : l.folder.path)
7979
case .unsupported(let l, let size):
80+
if let b = l.build {
81+
return (Theme.bad, "No engine for this version in this app",
82+
"\(l.folder.path) has the \(b.name) SPEED2.EXE, but this copy of the app has no engine for it. Use a release that includes it.")
83+
}
8084
return (Theme.bad, "This SPEED2.EXE is a different version",
81-
"Found \(formatBytes(Int64(size))) at \(l.folder.path). The port runs only the European v1.2 SPEED2.EXE without the CD check (4,788,224 bytes); other versions, such as the US one, would crash.")
85+
"Found \(formatBytes(Int64(size))) at \(l.folder.path). The port runs the European v1.2 (4,788,224 bytes) and North American v1.2 (4,800,512 bytes) SPEED2.EXE without the CD check; other versions, such as the US disc one, would crash.")
8286
}
8387
}
8488

0 commit comments

Comments
 (0)