Skip to content

Half-Life 2 (experimental) and one checkout/installer for both games - #22

Merged
kanapitsas merged 78 commits into
mainfrom
feat/multi-game
Nov 8, 2026
Merged

kanapitsas merged 78 commits into
mainfrom
feat/multi-game

Conversation

@kanapitsas

Copy link
Copy Markdown
Owner

The same static recompiler now builds Half-Life 2 (Steam app 220, the 20th anniversary build, mod hl2_complete, with Episode One, Episode Two and Lost Coast) next to Portal 2, on Mac and iPhone.

  • Game profiles (tools/games.py): program, modules, indirect call roots (config/hl2-roots.json), Steam app and depot, app name, data folder, build paths. Portal 2's paths are unchanged (build/game, build/portal2-native, build/ios); Half-Life 2 builds in build/hl2.
  • One checkout, both games: tools take --game portal2|hl2 (GAME= in the Makefile); other games' settings live under "games" in config/local.json, existing Portal 2 configs work unchanged. ./setup.sh builds every game it finds (--game portal2|hl2|all, --detect to preview).
  • Installer: "Natif Installer" detects both games and builds the ticked ones; same build folder as v0.2.0. uninstall.sh handles both.
  • Lifter: SSSE3/SSE3, jecxz/jcxz, CPUID-gated AES-NI paths, more GCC PIC switch forms.
  • Runtime: imports HL2 adds (glibc, network, termios, SDL, Fontconfig), Steam bridge for SDK 1.60/1.61 interfaces, IDirect3D9Ex for shaderapivk.so, event queries reported reached once their frame is presented (Source's frame sync no longer serializes CPU and GPU: 83 → ~118 fps), d3d9Ex user memory for SYSTEMMEM textures (fixed HL2's sky on Metal).
  • iPhone: per-game app (HalfLife2.app, Info.plist P2Game), touch layout for HL2 (fire/aim on the right thumb, toggle sprint/crouch, weapon switch, flashlight, quick save), iOS Steam stub for SDK 1.60, fonts. Runs on an iPhone 15 Pro Max (first short test).
  • Fixes found on the way: Portal 2's Steam app id/library could be read from the wrong game's keys; HL2 language; a switch table reached by a grenade + quick save; uninstall never offers another game's saves; the Brightness slider now works in windowed mode (Direct3D 9 gamma ramp forwarded to the window, the display's own ramp restored); iOS signing picks the certificate the profile allows (two Apple accounts in Xcode).
  • Dark interiors: by design (the maps' lightmaps are near zero there and the maps cap exposure at 2×); documented in docs/hl2.md, the flashlight lights them on Metal.

Tested: make check (165 Python tests, 241 C checks); fresh clone ./setup.sh building HL2 end to end (9 min) and the app starting on Metal; both games built from one checkout and launched hidden; HL2 main game played a few hours (Mac); Episode One/Two and Lost Coast first maps load and render; HL2 in the iOS Simulator (menu, moving, firing, flashlight, quick save). HL2 started and played briefly on an iPhone 15 Pro Max. Not tested: setup.sh --ios end to end, the installer's Build button end to end, machines other than an M3 Pro.

🤖 Generated with Claude Code

kanapitsas and others added 30 commits November 7, 2026 19:31
The Linux depot layout (module folders, program name, excluded modules,
indirect call roots) now comes from tools/games.py, picked by `game` in
config/local.json. Portal 2 stays the default, and its prepared layout is
byte-identical. elfprep also accepts relocations without a symbol, which
the Half-Life 2 modules use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 20th anniversary build is vectorised with SSSE3: 400 functions used
pshufb, palignr, movddup, pmaddubsw and a few SSE2 forms the lifter did
not know (psadbw, pextrw/pinsrw, movntq). The remaining 36 untranslated
functions are CPU-dispatched (AES-NI, PCLMUL, SSE4.1) and stay off since
CPUID does not report those extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Half-Life 2 asks for SDK 1.60 versions (SteamUser023, UGC020...) and for
ISteamTimeline V004 of SDK 1.61. The generator now merges several
Steamworks.NET packages into one table keyed by interface version, and
the generated methods call the host object's vtable slot directly: the
dylib's flat functions are nothing more than that tail call, but they
follow the layout of the dylib's own SDK, which would be wrong for a
version from another SDK. Portal 2's 772 entries are unchanged.

Also adds SteamInternal_SteamAPI_Init, SteamInternal_GameServer_Init_V2
(SDK 1.59+) and a no-op SteamAPI_SetBreakpadAppID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS Steam stub defines the flat functions by name and has no vtables,
so calling slots for every version broke Portal 2 on iPhone (caught by
test_steam_stub_generated_is_up_to_date). Only versions from another SDK
than the loaded dylib's go through the vtable slot now; Portal 2's
generated bodies are byte-identical to before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 20th anniversary build is compiled with _FORTIFY_SOURCE and a newer
glibc: __printf_chk, __vfprintf_chk, __fdelt_chk, __longjmp_chk,
__sigsetjmp/siglongjmp (with the signal mask), __rawmemchr, __strtok_r and
the XSI __xpg_basename/__xpg_strerror_r. Also geteuid/getgid/getegid,
getlogin_r, rand_r (glibc's algorithm), fcntl with Linux commands and
flags, signal() limited to SIG_DFL/SIG_IGN like sigaction, valloc,
dladdr and a one-frame backtrace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getaddrinfo/freeaddrinfo and getifaddrs/freeifaddrs build their lists in
guest memory in the i386 Linux layout, with Linux flags, families and
EAI_* codes. Also getpeername, getsockopt (sharing setsockopt's option
table), shutdown and inet_pton/inet_ntop/inet_ntoa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/proc/self/exe and the default run arguments assumed portal2_linux and
-game portal2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Portal 2 ships mssmp3.asi and binkawin.asi in bin/linux32 of the game
copy; Half-Life 2 has them only in the Linux depot. The build and the
merged file view now look in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The static pass only lists imports reached by known call sites, and the
first launch trapped on memalign right away. All imports of the prepared
modules now have a shim: the memalign family, sqrt/exp2f/fmaxf/fminf,
fscanf/swscanf, wcstoll, the fortified wide and stpcpy/fread variants,
nanosleep/sleep/times, raise, pthread_setname_np, isatty, ctermid,
setbuf, freopen64, shm_open, __assert_fail and tcgetattr/tcsetattr for
non-terminals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Message boxes are logged instead of shown, SDL_GetWindowWMInfo reports
that there is no X11 window, and Direct3DCreate9Ex answers
D3DERR_NOTAVAILABLE so the engine uses Direct3DCreate9. Fontconfig
configurations keep one reference per handle across FcConfigSetCurrent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fcntl translates POSIX record locks (struct flock and flock64 in the
  i386 layout): the launcher's single-instance lock used F_SETLK.
- CPUID-gated instructions (AES-NI, PCLMUL, SSE4.x, AVX, RDRAND, PadLock)
  now trap where they stand instead of rejecting the whole function:
  Crypto++ keeps AES-NI and portable paths in one function and picks one
  from CPUID, which does not report AES.
- A guest SIGILL handler may be registered (Crypto++ probes SSE2 and
  CPUID under signal + setjmp); it is never delivered.
- discover_game.py writes the roots of the configured game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Used by the crypto code of Half-Life 2's filesystem_stdio.so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Translate mssmixer.mix, the Miles mixer plugin Half-Life 2 loads, and
  hide untranslated .mix files like .asi ones. Its segment save/restore
  (push/pop ds, es) is lifted for a flat model.
- vaudio_miles sets a SIGSEGV handler around AIL_startup: registered,
  never delivered, like the Crypto++ SIGILL probes.
- The launcher leaves SteamAppId set but empty; treat it as missing so
  SteamAPI_InitEx gets the configured app id. Its failure log now lists
  the app id and the interface versions.
- More indirect call roots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The size parameter of StartPlaytimeTracking, StopPlaytimeTracking and
CreateQueryUGCDetailsRequest is named unNumPublishedFileIDs, which the
generator did not recognise; Half-Life 2 calls StartPlaytimeTracking at
start-up. The arrays are read by Steam. Portal 2 gains the same three
methods of UGC016; the iOS stub is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Half-Life 2's ServerBrowser asks IsRefreshing(NULL) at start-up. Server
list requests are not bridged, so the guest can only hold the null
handle; any other value traps. Also more indirect call roots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2's engine reads and sets terminal attributes for its text console when
stdin is a terminal, so a visible launch from a shell trapped. The shim maps
flags, c_cc indices and speed codes both ways and still traps on a Linux bit
that Darwin cannot represent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2's GCC interleaves unrelated moves between the table load, the GOT add
and the jump, and sometimes folds the load into the add itself. The lifter
missed these switches, so every case surfaced as a runtime trap. 336 more
switches now become local gotos; the real target is still checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2 engine.so schedules an SSE conversion (cvttss2si into an unrelated
register) between the table load and the GOT add, which crashed the game
right after a save loaded. Instructions with implicit register writes
(xchg, div, string ops, ...) still stop the recognition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS's legacy 2.1 context has no GL_ARB_sampler_objects: glGenSamplers
resolves but fails with GL_INVALID_OPERATION. HL2's togl enables sampler
objects anyway, so every texture lost its mipmap and anisotropic filtering
(noisy, aliased surfaces). The runtime now adds -gl_disablesamplerobjects
on the OpenGL path unless the player chose either option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2's shaderapivk creates Direct3D through Direct3DCreate9Ex and has no
fallback to Direct3DCreate9, so the Metal path stopped at 'Failed to
create D3D9!'. The generator now describes IDirect3D9Ex,
IDirect3DDevice9Ex and IDirect3DSwapChain9Ex, and the runtime calls the
Direct3DCreate9Ex that libd3d9metal already exports. CheckResourceResidency
and GetPresentStats stay logged stubs (the stats struct differs on i386).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2's shaderapivk.so loads switch entries with [B + I - D], I being the
index already multiplied by 4 in another block. The bound is not tracked
there, so entries are read while they land on code, as for unbounded
absolute tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very

run_game now sets P2_D3D9METAL from config d3d9metal_dylib (written by
setup.sh) unless already set, so dev launches pick Metal like the app does.
discover_game forwards arguments after -- to the game, to discover roots
past a +load. Also adds the shaderapivk roots found on the Metal path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2 calls fminf/fmaxf tens of thousands of times a second, each through the
name chain of the math provider. The checks also cover the direct pthread
routes (TLS keys, pthread_self) that HL2's tier0 calls ~430 000 times a
second; those routes came with the November 6 audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hidden Metal run executing `jpeg` trapped at 0x148af37c
(materialsystem.so+0xaf37c, target not recompiled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el samples

The game is CPU-bound and single-threaded; translated code is ~5-6 ARM
instructions per x86 instruction and spends ~38% of its time in the call
protocol, which bounds what lifter work can still gain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanapitsas and others added 29 commits November 7, 2026 23:29
build_game, gameprep, make_app, run_game, discover_game and bench_game take
--game (GAME= in the Makefile) and read and write the game's own paths, so
building Half-Life 2 no longer overwrites Portal 2's build. The HL2 binary is
hl2-native, also inside its app. run_game passes the game's SteamAppId and
libsteam_api.dylib, which the runtime would otherwise take from Portal 2's
top-level keys. make_app snapshots the game's merged config, so the app's
language detection looks at the right game folder. iPhone tools stay Portal 2
only and say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without --game, setup.sh builds each game whose data and Linux binaries it
finds, so a Portal 2 user who adds Half-Life 2 just re-runs it. A game
installed in Steam without its depot is skipped with the download_depot hint
instead of stopping the other. The refusal to switch games is gone: the games
no longer share build/. --detect prints the plan without changing anything
(used by the tests and handy for support).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each game's app, log and saves get their own questions, and the installer's
shared build folder its own yes. Another game's data folder stays protected
from a user_dir that overlaps it. The running check covers hl2-native, and is
skipped against the tests' fake home so a real game does not skip the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The installer now finds Portal 2 and Half-Life 2 the way setup.sh does, shows
one checkbox per game (ticked when its data and Linux binaries are there) and
runs setup.sh --game once per ticked game, the progress bar split between
them. It becomes "Natif Installer" (zip Natif-Installer-<version>.zip), named
in one place in build_installer.sh; the bundle identifier and the build
folder in Application Support stay the first release's, so an update
rebuilds incrementally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_ios.py took Portal 2's roots, program, name and icon for granted, so
an HL2 checkout could not lift or link. It now reads them from the game
profile and writes the game into the Info.plist (P2Game), from which
runtime/ios/game_ios.c picks the data folder (hl2-userdata), the Steam
app id of the iOS stub, the default for back taps and the ICvar layout
(VEngineCvar004, FindCommandBase at slot 10, checked in depot 224).
setup.sh --ios accepts HL2 with its own bundle identifier, so it does
not replace Portal 2 on the phone; ios_device.sh copies the HL2 folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The overlay's buttons were Portal 2's, hard-coded. They now come from a
table per game (touch_layout.c); Portal 2's is unchanged, checked against
the old formulas. HL2 gets fire (dragging the finger also aims, since the
same thumb fires and looks), alt-fire, reload, use, jump, crouch and
sprint as toggles (a thumb cannot hold them while moving or looking; the
sprint stops when the move stick is released), and small buttons in the
top bar for previous/next weapon (mouse wheel, under the game's weapon
list), flashlight, quick save and pause. Back taps and the firing
vibration stay Portal 2 only, and the HL2 settings page drops the Portals
group. Checks cover the layouts on three screen sizes and the profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Install instructions start with the Natif Installer, then ./setup.sh, for
either game or both; the one-checkout-per-game advice and the separate HL2
clone are gone. usage.md describes the shared config/local.json with its
games section, game selection and per-game build paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
language.py only looked at portal2/resource and appmanifest_620.acf, so the
Half-Life 2 app always started in English. It now follows the "game" and
steam_appid of the config it is given: the app's snapshot and run_game's
merged config are the game's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save_sync.py only knows Portal 2's folders: say so instead of failing on
a missing portal2-userdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two games, a yes for one game's saves could move the other's custom
user_dir when both overlap, and the data and installer build folders were
offered even if a game or depot had been put inside them. Every folder
offered is now checked against the games, the depots, this checkout and the
other game's saves. Found by Codex's review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime took the first "steam_appid" or "steam_api_dylib" anywhere in
the file, so a games.hl2 section written before Portal 2's keys (HL2 set up
first, or a hand-edited config) gave Portal 2 HL2's app id and Steamworks
library. The lookup now tracks JSON depth and only matches top-level keys,
which are Portal 2's and the shared ones; other games get theirs from
run_game.py or their app's launcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2's vgui loads a fresh configuration and adds resource/*.ttf (HUD
icons, GorDIN, DejaVu) with FcConfigAppFontAddFile; runtime/fonts.c
already bridges these on the Mac, the iOS build did not link. One
configuration, game fonts indexed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HL2 starts Steam with SteamInternal_SteamAPI_Init and calls its newer
interfaces (SteamUser023, SteamClient021, UGC 020...) by vtable slot,
not through flat functions: the stub had neither, so the game trapped,
then jumped past the 64-entry zero table. The generator now emits a
vtable per slot-called version with the same answers as the flat
functions (logged on, Steam ID, auth ticket), and SteamInput's Init
answers true so HL2 stops re-initialising Steam every few seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the HUD

Without -w/-h HL2 started at 640x480 stretched over the screen; it now
always gets the render size (Portal 2 still only with the Resolution
setting). In game the camera stick's resting ring sat under alt-fire and
use, and the camera-mode button over the health readout: the ring moves
toward the centre and the button to the top bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steam on a Mac can keep steamapps/common/Portal 2 with only an empty
portal2/ folder. With depot 661 present, setup.sh took it for the game and
stopped with "does not look like Portal 2" before building Half-Life 2;
the installer would have ticked it. Both now require portal2/pak01_dir.vpk
(hl2_complete/gameinfo.txt for Half-Life 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck saving

Throwing a grenade then quick saving in d1_canals_01 trapped on client.so
0x5a0940 (target not recompiled): a case of a switch dispatched with
'movl %ebx,%eax; addl table(%ebx,%edx,4),%eax; jmp *%eax', which the
lifter and table_roots do not follow. All 21 distinct targets of that
27-entry table are added. Found in the iOS simulator; the Mac build
lifts the same code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How to build and install it, its touch layout and why sprint and crouch
toggle, why back taps are off, what the simulator showed and what is
untested on a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FcConfigAppFontAddFile grows the index with realloc while another thread
may be matching a font (Codex review). Additions, matches and listings
now hold one mutex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iPhone build of Half-Life 2 now follows the one-checkout layout: its
sources and objects live under build/hl2 and its app is
build/hl2/ios/<target>/HalfLife2.app (Portal 2 keeps
build/ios/<target>/Portal2.app). build_ios.py, run_ios_sim.sh and
ios_device.sh take --game and read the game's merged config
(localconfig.game_config, through the new tools/ios_game.py for the shell
scripts) instead of the top-level keys, which are Portal 2's: game_root,
linux_root, language and ios_bundle_id would otherwise have been Portal 2's
for HL2.

setup.sh --ios builds, signs and installs the iPhone app of every selected
game; each game gets its own ios_bundle_id (local.hl2recomp.<team> in
games.hl2), so the HL2 app does not replace Portal 2's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
P2_BUNDLE_ID (or the same ios_bundle_id for both games) would make the
second install replace the first app, and its data marker skip the second
game's data copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The probe identifier is the game's plus .probe; building it for one game and
launching it for another used two identifiers. Also fill @GAMe@ of the
shared Info.plist template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s limit

The README title and status now cover Half-Life 2 and its episodes. Episode
One, Episode Two and Lost Coast start from the menu (SwitchExpansion) and
their first maps render on Metal. On 8 Nov. every build held 120 fps on the
120 Hz display; PGO saves ~5 % CPU per frame, not worth shipping a profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run from a dev checkout, uninstall.sh only knew that checkout's config, so
the installer's build folder could be offered for the Trash while the
installer's config put a game, a depot or saves inside it. Its paths now
join the protected list (found by Codex's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Portal 2 depot with engine.so but not server.so (interrupted download)
was found, then check_game stopped the whole run, so a complete Half-Life 2
was never built. Detection now requires the same files as check_game; the
incomplete game is skipped with its download_depot hint (Codex's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source's Brightness option (mat_monitorgamma) sets a D3D9 gamma ramp.
Direct3D 9, and libd3d9metal with it, applies a ramp only to a fullscreen
device, and on the Mac the game runs windowed or in desktop fullscreen:
the slider did nothing in Half-Life 2, so dark interiors could not be
brightened. For a windowed device the ramp now also goes to its SDL
window (SDL_SetWindowGammaRamp), which is what Portal 2's OpenGL path
already does. The default ramp is the identity and leaves the display
alone, except to undo a ramp set earlier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Going back to the default brightness sent a linear ramp, which would
replace the display's calibration ramp while the game has focus. The
display's ramp is now read once before the first change and restored
when the game returns to the identity. The state follows the device's
window, so a recreated window starts clean. Review by Codex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two Apple accounts in Xcode (a free team and a paid team), sign.py
took the first "Apple Development" identity of the keychain, from the
other team: the iPhone refused the app ("A valid provisioning profile
for this executable was not found"). The identity is now the one whose
certificate is in the profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kanapitsas
kanapitsas merged commit ee51caa into main Nov 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant