Added Linux Support.
This commit is contained in:
@@ -2,17 +2,29 @@
|
||||
|
||||
Native PC versions of the PlayStation 2 Ratchet & Clank games, built on your own PC from your own disc.
|
||||
|
||||
Project Solana is a launcher and a set of static recompilations. Each game's code is translated ahead of time from the game's executable into C, with the decompiled source from OpenRAC where it exists, and compiled into a native Windows program that reads everything else (models, textures, sound, movies) from your disc image at run time.
|
||||
Project Solana is a launcher and a set of static recompilations. Each game's code is translated ahead of time from the game's executable into C, with the decompiled source from OpenRAC where it exists, and compiled into a native Windows or Linux program that reads everything else (models, textures, sound, movies) from your disc image at run time.
|
||||
|
||||
**Nothing from the games is distributed here.** The launcher downloads only tools and source code; the game is built on your machine from a disc image you provide.
|
||||
|
||||
## Playing
|
||||
## Playing on Windows
|
||||
|
||||
1. Download `Project Solana.exe` from the [latest launcher release](../../releases) and put it in a folder of its own (it keeps its files next to itself).
|
||||
2. Run it and add your disc image: **Add disc image**, **Scan a folder**, or drag an `.iso` onto the window.
|
||||
3. Select the game and press **Build**. The first build downloads about 95 MB (Python and the zig compiler, then the game's build kit) and builds the game from your disc: about 15 minutes on a recent 6 to 8 core CPU, an hour or more on an older 4 core one. It only happens once per game version.
|
||||
4. Press **Play**.
|
||||
|
||||
## Playing on Linux and Steam Deck
|
||||
|
||||
The game runs natively on Linux (no Wine or Proton). The Linux launcher is a single script, `launcher/linux/project-solana` (download it from the [latest launcher release](../../releases), then `chmod +x project-solana`):
|
||||
|
||||
```
|
||||
./project-solana build ~/Games/ratchet.iso # once: about 95 MB of downloads, then the same build as on Windows
|
||||
./project-solana play # --upscaled / --native, --fullscreen / --windowed, --vsync (remembered)
|
||||
./project-solana desktop # adds it to the applications menu; from there, Steam > Add a Non-Steam Game
|
||||
```
|
||||
|
||||
It needs `python3` and the **32-bit** SDL2 and OpenGL libraries. Steam installs them, so SteamOS and any PC with Steam already has them; otherwise `./project-solana status` says what to install for your distribution. Everything is kept in `~/.local/share/project-solana`. On a Steam Deck, run the build once in Desktop Mode (Konsole), then add the game to Steam with `desktop`; it starts full screen there, with the Deck's controls as the controller.
|
||||
|
||||
### Supported games
|
||||
|
||||
| Game | Region | Disc executable | Status |
|
||||
@@ -23,10 +35,10 @@ Project Solana is a launcher and a set of static recompilations. Each game's cod
|
||||
|
||||
### Requirements
|
||||
|
||||
- Windows 10 or 11, 64-bit
|
||||
- Windows 10 or 11, 64-bit, or Linux x86-64 (glibc 2.17 or newer, X11 or Wayland) with the 32-bit SDL2 and OpenGL libraries
|
||||
- A CPU with SSE4.2 (Intel since 2008, AMD since 2011) and 8 GB of RAM
|
||||
- A GPU with OpenGL 3.3 (tested: RTX 3060, Upscaled at 3x)
|
||||
- A controller: Xbox, DualShock 4 or DualSense, over USB or Bluetooth
|
||||
- A controller: Xbox, DualShock 4 or DualSense, over USB or Bluetooth (on Linux, anything SDL2 knows, including the Steam Deck's controls)
|
||||
- About 2.5 GB free while building, 600 MB after (the game plus the build tools, kept for rebuilds)
|
||||
|
||||
### Settings
|
||||
@@ -43,7 +55,8 @@ Resolution (Native, or Upscaled to a whole multiple of the PS2's 448 lines that
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `launcher/` | `Project Solana.exe`: Win32 + GDI, no dependencies. `python launcher/build_launcher.py` builds it (needs zig: `pip install ziglang`) |
|
||||
| `games/rac1/` | Ratchet & Clank's build kit: `build.py`, the recompilers (`tools/`), the host engine (`host/`: GS in OpenGL and software, DMA, VU, SPU2 and 989snd, disc, pad, memory card, movies), the wrapper (`launcher/`) |
|
||||
| `launcher/linux/project-solana` | the Linux launcher (Python 3, no dependencies) |
|
||||
| `games/rac1/` | Ratchet & Clank's build kit: `build.py`, the recompilers (`tools/`), the host engine (`host/`: GS in OpenGL and software, DMA, VU, SPU2 and 989snd, disc, pad, memory card, movies; Win32 and Linux/SDL2 platform layers), the wrapper (`launcher/`) |
|
||||
| `games/rac1/dev/` | developer tools (GS capture replay, VU reference check); not part of the kit |
|
||||
| `release/make_release.py` | makes a game's release files |
|
||||
| `THIRD_PARTY_NOTICES.md` | pl_mpeg (MIT), jak-project's 989snd tables (ISC) |
|
||||
@@ -56,6 +69,7 @@ The launcher fetches from the release named after the game (`releases/download/r
|
||||
|---|---|
|
||||
| `rac1.json` | version, file names, sizes and SHA-256s, the levels to build |
|
||||
| `toolchain-win64.zip` | Python 3.12 (python-build-standalone), rabbitizer, zig 0.16; shared by all games, kept in `toolchain\` and fetched again only when its version changes |
|
||||
| `toolchain-linux64.zip` | the same for Linux x86-64 (the Linux launcher's) |
|
||||
| `rac1-build.zip` | `games/rac1` plus the decomp's source as `decomp/` (no `baserom/`) |
|
||||
|
||||
It checks both zips against their SHA-256s, unpacks them next to itself (`toolchain\`, `games\rac1\`), runs `build.py --platform windows --iso <your disc>`, and then deletes the intermediate files (about 1.3 GB) and the bytes it read from the disc, keeping `games\rac1\build\windows` (the game, its saves and logs).
|
||||
@@ -65,13 +79,14 @@ It checks both zips against their SHA-256s, unpacks them next to itself (`toolch
|
||||
## Making a release
|
||||
|
||||
```
|
||||
python release/make_release.py rac1 --decomp ../rac1-decomp --version 2026.10.13
|
||||
gh release create rac1 dist/rac1/rac1.json dist/rac1/rac1-build.zip dist/rac1/toolchain-win64.zip
|
||||
python release/make_release.py rac1 --decomp ../rac1-decomp --version 2026.10.14.2 \
|
||||
--toolchain-zip dist/rac1/toolchain-win64.zip --toolchain-linux-zip dist/rac1/toolchain-linux64.zip # (reuse, or omit to assemble them)
|
||||
gh release create rac1 dist/rac1/rac1.json dist/rac1/rac1-build.zip dist/rac1/toolchain-win64.zip dist/rac1/toolchain-linux64.zip
|
||||
```
|
||||
|
||||
To update an existing game release, upload the new files over the old ones (`gh release upload rac1 dist/rac1/* --clobber`). Players get them with **Rebuild the game**.
|
||||
|
||||
The launcher itself goes in a release of its own (for example `launcher-v1`) with `Project Solana.exe`.
|
||||
The launchers go in a release of their own (for example `launcher-v1`): `Project Solana.exe` and `project-solana` (Linux).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+8
-2
@@ -28,7 +28,9 @@ ap.add_argument("--levels", default="00")
|
||||
a = ap.parse_args()
|
||||
|
||||
zig = [shutil.which("zig")] if shutil.which("zig") else [sys.executable, "-m", "ziglang"]
|
||||
target = {"linux": "x86-linux-musl", "windows": "x86-windows-gnu"}[a.platform]
|
||||
# Linux: 32-bit glibc 2.17 or newer (any distribution since 2013), linked dynamically so it can load
|
||||
# the system's SDL2 and OpenGL driver (32-bit: i386 libraries)
|
||||
target = {"linux": "x86-linux-gnu.2.17", "windows": "x86-windows-gnu"}[a.platform]
|
||||
out = os.path.join(HERE, "build", a.platform)
|
||||
gen = os.path.join(out, "gen")
|
||||
stage = os.path.join(out, "gen.new")
|
||||
@@ -145,6 +147,7 @@ sync(stage, gen)
|
||||
|
||||
common = ["-target", target, "-O" + a.O, "-g", "-march=nehalem", "-mfpmath=sse", "-malign-double", "-fno-strict-aliasing", "-fwrapv",
|
||||
"-fno-PIE", "-fno-stack-protector", "-I", os.path.join(gen, "include"), "-I", os.path.join(HERE, "host", "include")]
|
||||
if a.platform == "linux": common += ["-D_FILE_OFFSET_BITS=64"] # (32-bit glibc: disc images are over 2 GB)
|
||||
# frontbin code: keep it permissive, it was written for gcc 2.95
|
||||
menu_flags = ["-w", "-Wno-error=incompatible-pointer-types", "-Wno-error=int-conversion",
|
||||
"-Wno-error=implicit-function-declaration", "-Wno-error=implicit-int",
|
||||
@@ -228,7 +231,10 @@ if errs:
|
||||
sys.exit("%d files failed to compile" % len(errs))
|
||||
if a.platform == "linux":
|
||||
exe = os.path.join(out, "rac1_wrapper")
|
||||
link = zig + ["cc", "-target", target, "-o", exe, objs_rsp("link")] + ["-static", "-no-pie", "-Wl,--image-base=0x40000000"]
|
||||
# at 0x40000000 like the Windows DLL (lower addresses are the PS2's); its symbols exported
|
||||
# (-rdynamic) so the crash handler can name functions
|
||||
link = zig + ["cc", "-target", target, "-o", exe, objs_rsp("link")] + ["-no-pie", "-rdynamic", "-Wl,--image-base=0x40000000",
|
||||
"-ldl", "-lpthread", "-lm"]
|
||||
r = subprocess.run(link, capture_output=True, text=True)
|
||||
if r.returncode:
|
||||
print(r.stderr[-6000:]); sys.exit("link failed")
|
||||
|
||||
@@ -2095,7 +2095,7 @@ static void uya_gs_gif_(const u32 *d, u32 qwc)
|
||||
|
||||
/* ---- init and display ---- */
|
||||
/* UYA_RENDERER=gl|soft (or renderer= in rac1_wrapper.cfg): the GPU draws
|
||||
* (default where there is GL: Windows) or the software rasterizer; GL falls
|
||||
* (default) or the software rasterizer; GL falls
|
||||
* back to software if it cannot start */
|
||||
const char *uya_renderer_cfg;
|
||||
void uya_gs_init(void)
|
||||
@@ -2107,11 +2107,7 @@ void uya_gs_init(void)
|
||||
{
|
||||
const char *r = getenv("UYA_RENDERER");
|
||||
if (!r) r = uya_renderer_cfg;
|
||||
#ifdef _WIN32
|
||||
if (!r) r = "gl";
|
||||
#else
|
||||
if (!r) r = "soft";
|
||||
#endif
|
||||
if (!r) r = "gl"; /* (with no window, as headless on Linux, GL does not start: software) */
|
||||
if (!strcmp(r, "gl")) {
|
||||
if (plat_gl_init() && gsgl_init(vram)) { plat_gl_active = 1; uya_log("GS: drawing with OpenGL"); }
|
||||
else uya_log("GS: OpenGL not available, drawing in software");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Linux (i386) platform layer: fixed mappings and the int3 trap for calls
|
||||
* into PS2 code the host does not implement. */
|
||||
* into PS2 code the host does not implement; the window, OpenGL, gamepads and
|
||||
* sound through SDL2 (platform_linux_sdl.h), or headless for testing. */
|
||||
#ifdef __linux__
|
||||
#define _GNU_SOURCE
|
||||
#include "uya_runtime.h"
|
||||
@@ -81,10 +82,41 @@ static void on_trap(int sig, siginfo_t *si, void *ctxv)
|
||||
g[REG_ESP] = esp + 4;
|
||||
}
|
||||
|
||||
#include <dlfcn.h>
|
||||
static void *dladdr_base(void) /* the game executable's own load address */
|
||||
{
|
||||
static void *b; Dl_info di;
|
||||
if (!b && dladdr((void *)&dladdr_base, &di)) b = di.dli_fbase;
|
||||
return b;
|
||||
}
|
||||
/* "name+0xoff" for an address in the game (its symbols are exported: -rdynamic) */
|
||||
static const char *sym_name(uint32_t a, char *buf, int cap)
|
||||
{
|
||||
Dl_info di;
|
||||
if (dladdr((void *)(uintptr_t)a, &di) && di.dli_sname) snprintf(buf, cap, "%s+0x%X", di.dli_sname, a - (uint32_t)(uintptr_t)di.dli_saddr);
|
||||
else snprintf(buf, cap, "?");
|
||||
return buf;
|
||||
}
|
||||
static void on_fault(int sig, siginfo_t *si, void *ctxv)
|
||||
{
|
||||
static volatile int in_crash;
|
||||
if (__atomic_exchange_n(&in_crash, 1, __ATOMIC_SEQ_CST)) _exit(1); /* (a crash while reporting one) */
|
||||
ucontext_t *uc = ctxv;
|
||||
uya_log("crash: signal %d, fault address %p, eip=0x%08X", sig, si->si_addr, (uint32_t)uc->uc_mcontext.gregs[REG_EIP]);
|
||||
greg_t *g = uc->uc_mcontext.gregs;
|
||||
char nm[256];
|
||||
uint32_t eip = (uint32_t)g[REG_EIP];
|
||||
uya_log("crash: signal %d, fault address %p, eip=0x%08X", sig, si->si_addr, eip);
|
||||
uya_log("crash: in %s", sym_name(eip, nm, sizeof nm));
|
||||
{ /* the return addresses on the stack, named: the call chain */
|
||||
const uint32_t *sp = (const uint32_t *)(uintptr_t)(uint32_t)g[REG_ESP];
|
||||
Dl_info di;
|
||||
for (int i = 0, shown = 0; i < 256 && shown < 12; i++) {
|
||||
uint32_t v = sp[i];
|
||||
if (v >= 0x40000000u && v < 0x80000000u && dladdr((void *)(uintptr_t)v, &di) && di.dli_sname && di.dli_fbase == dladdr_base()) {
|
||||
uya_log("crash: called from %s", sym_name(v, nm, sizeof nm)); shown++;
|
||||
}
|
||||
}
|
||||
}
|
||||
uya_report();
|
||||
_exit(1);
|
||||
}
|
||||
@@ -153,10 +185,10 @@ int plat_install_trap_handler(void)
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
/* No window on Linux (yet): with UYA_DUMP=dir every 15th presented frame is
|
||||
* written there as a PPM, so output can be checked. */
|
||||
/* Headless (no display, no SDL2, or UYA_HEADLESS / UYA_DUMP set): with UYA_DUMP=dir every
|
||||
* 15th presented frame is written there as a PPM, so output can be checked. */
|
||||
#include <stdlib.h>
|
||||
int plat_present(const uint32_t *px, int w, int h)
|
||||
static int hl_present(const uint32_t *px, int w, int h)
|
||||
{
|
||||
static unsigned long n;
|
||||
const char *dir = getenv("UYA_DUMP");
|
||||
@@ -189,8 +221,7 @@ static const struct { const char *name; unsigned bit; } pad_names[] = {
|
||||
{"lup", 0x10000}, {"ldown", 0x20000}, {"lleft", 0x40000}, {"lright", 0x80000},
|
||||
{"rup", 0x100000}, {"rdown", 0x200000}, {"rleft", 0x400000}, {"rright", 0x800000},
|
||||
};
|
||||
int plat_pad_sticks(uint8_t st[4]) { (void)st; return 0; }
|
||||
unsigned plat_pad_buttons(void)
|
||||
static unsigned hl_buttons(void)
|
||||
{
|
||||
const char *s = getenv("UYA_KEYS");
|
||||
unsigned now = uya_frame_count(), bits = 0;
|
||||
@@ -211,18 +242,9 @@ unsigned plat_pad_buttons(void)
|
||||
return bits;
|
||||
}
|
||||
/* UYA_SKIPMOVIES=n skips the first n movies (plat_movie_started counts them) */
|
||||
static int movies_started;
|
||||
void plat_movie_started(void) { movies_started++; }
|
||||
int plat_key_skip(void)
|
||||
{
|
||||
const char *e = getenv("UYA_SKIPMOVIES");
|
||||
if (e && movies_started <= atoi(e)) return 1;
|
||||
return (plat_pad_buttons() & 0x4008) != 0;
|
||||
}
|
||||
void plat_poll(void) {}
|
||||
|
||||
/* Sound on Linux: with UYA_DUMP set, each sound is saved there as a WAV file. */
|
||||
void plat_audio_play(const int16_t *lr, size_t frames, int rate, const char *tag)
|
||||
static void hl_audio_play(const int16_t *lr, size_t frames, int rate, const char *tag)
|
||||
{
|
||||
const char *dir = getenv("UYA_DUMP");
|
||||
if (!dir) return;
|
||||
@@ -241,7 +263,6 @@ void plat_audio_play(const int16_t *lr, size_t frames, int rate, const char *tag
|
||||
fwrite(lr, 4, frames, f);
|
||||
fclose(f);
|
||||
}
|
||||
void plat_audio_stop(void) {}
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
@@ -250,7 +271,7 @@ void plat_audio_stop(void) {}
|
||||
static uint64_t stream_written, stream_t0;
|
||||
uint64_t uya_fixed_clock(void);
|
||||
static uint64_t stream_now(void) { uint64_t v = uya_fixed_clock(); return v ? v : plat_time_us(); } /* UYA_FIXEDTIME: the game's clock */
|
||||
void plat_stream_write(const int16_t *lr, int frames)
|
||||
static void hl_stream_write(const int16_t *lr, int frames)
|
||||
{
|
||||
static FILE *f;
|
||||
static int tried;
|
||||
@@ -263,18 +284,46 @@ void plat_stream_write(const int16_t *lr, int frames)
|
||||
if (f) fwrite(lr, 4, (size_t)frames, f);
|
||||
stream_written += (uint64_t)frames;
|
||||
}
|
||||
int plat_stream_queued(void)
|
||||
static int hl_stream_queued(void)
|
||||
{
|
||||
if (!stream_t0) return 0;
|
||||
uint64_t played = (stream_now() - stream_t0) * 48000 / 1000000;
|
||||
return played >= stream_written ? 0 : (int)(stream_written - played);
|
||||
}
|
||||
/* OpenGL: this build is 32-bit static (musl) and cannot load a GL driver, so
|
||||
* the GS draws in software (the GL renderer is tested with tools/gs_replay.c) */
|
||||
int plat_gl_active;
|
||||
int plat_gl_init(void) { return 0; }
|
||||
void plat_gl_current(int on) { (void)on; }
|
||||
void plat_gl_swap(void) {}
|
||||
void plat_gl_size(int *w, int *h) { *w = 640; *h = 480; }
|
||||
void *plat_gl_proc(const char *name) { (void)name; return 0; }
|
||||
#include "platform_linux_sdl.h"
|
||||
|
||||
/* ---- what the game calls: SDL when there is a display, else headless (testing) ---- */
|
||||
int plat_present(const uint32_t *px, int w, int h) { return sdl_on() ? sdl_present(px, w, h) : hl_present(px, w, h); }
|
||||
int plat_pad_sticks(uint8_t st[4]) { return sdl_on() ? sdl_sticks(st) : 0; }
|
||||
unsigned plat_pad_buttons(void) { return hl_buttons() | (sdl_on() ? sdl_buttons() : 0); } /* (UYA_KEYS: scripted presses) */
|
||||
/* movies: a fresh press of Cross or Start skips (buttons already held when the movie starts do not);
|
||||
* UYA_SKIPMOVIES=n skips the first n movies (testing) */
|
||||
static int movies_started;
|
||||
static unsigned skip_held;
|
||||
void plat_movie_started(void) { movies_started++; pump(); skip_held = plat_pad_buttons() & 0x4008; }
|
||||
int plat_key_skip(void)
|
||||
{
|
||||
const char *e = getenv("UYA_SKIPMOVIES");
|
||||
if (e && movies_started <= atoi(e)) return 1;
|
||||
pump();
|
||||
unsigned b = plat_pad_buttons() & 0x4008, fresh = b & ~skip_held;
|
||||
skip_held = b;
|
||||
return fresh != 0;
|
||||
}
|
||||
void plat_poll(void)
|
||||
{
|
||||
if (!win) return;
|
||||
pump();
|
||||
if (win_closed) { uya_log("window closed"); exit(0); }
|
||||
}
|
||||
void plat_audio_play(const int16_t *lr, size_t frames, int rate, const char *tag) { if (sdl_on()) sdl_audio_play(lr, frames, rate); else hl_audio_play(lr, frames, rate, tag); }
|
||||
void plat_audio_stop(void) { if (sdl_on()) sdl_audio_stop(); }
|
||||
void plat_stream_write(const int16_t *lr, int frames) { if (sdl_on()) sdl_stream_write(lr, frames); else hl_stream_write(lr, frames); }
|
||||
int plat_stream_queued(void) { return sdl_on() ? sdl_stream_queued() : hl_stream_queued(); }
|
||||
/* OpenGL through SDL (none headless: the GS draws in software) */
|
||||
int plat_gl_init(void) { return sdl_gl_init(); }
|
||||
void plat_gl_current(int on) { sdl_gl_current(on); }
|
||||
void plat_gl_swap(void) { sdl_gl_swap(); }
|
||||
void plat_gl_size(int *w, int *h) { sdl_gl_size(w, h); }
|
||||
void *plat_gl_proc(const char *name) { return sdl_gl_proc(name); }
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/* platform_linux_sdl.h: the window, OpenGL, gamepads and sound on Linux, through SDL2
|
||||
* (included once by platform_linux.c).
|
||||
*
|
||||
* libSDL2-2.0.so.0 is loaded at run time (dlopen), so the game builds without SDL's
|
||||
* headers and still runs headless (testing) where there is no SDL or no display. Only
|
||||
* the few SDL2 declarations used are written out here (SDL2's ABI is stable).
|
||||
*
|
||||
* window one resizable window, 640x480 to start; F11 or UYA_FULLSCREEN=1 for full screen
|
||||
* OpenGL a 3.3 core context for the GS renderer (gs_gl.c); vsync with UYA_VSYNC=1
|
||||
* gamepads SDL's game controller API: Xbox, PlayStation, Switch and Steam Deck controls
|
||||
* by position (south = Cross, east = Circle, west = Square, north = Triangle)
|
||||
* sound two queued audio devices: one-shot sounds (plat_audio_play), and the 48 kHz
|
||||
* effects stream (plat_stream_write) */
|
||||
#include <dlfcn.h>
|
||||
|
||||
/* ---- SDL2 declarations (SDL.h, 2.0.x) ---- */
|
||||
typedef struct SDL_Window SDL_Window;
|
||||
typedef struct SDL_GameController SDL_GameController;
|
||||
typedef void *SDL_GLContext;
|
||||
typedef uint32_t SDL_AudioDeviceID;
|
||||
typedef struct { int freq; uint16_t format; uint8_t channels, silence; uint16_t samples, padding; uint32_t size; void *callback, *userdata; } SDL_AudioSpec;
|
||||
typedef struct { int x, y, w, h; } SDL_Rect;
|
||||
typedef struct { uint32_t flags; void *format; int w, h, pitch; void *pixels; } SDL_Surface_head; /* (the start of SDL_Surface) */
|
||||
#define SDL_INIT_AUDIO 0x10u
|
||||
#define SDL_INIT_VIDEO 0x20u
|
||||
#define SDL_INIT_GAMECONTROLLER 0x2000u
|
||||
#define SDL_WINDOWPOS_CENTERED 0x2FFF0000
|
||||
#define SDL_WINDOW_FULLSCREEN_DESKTOP 0x1001u
|
||||
#define SDL_WINDOW_OPENGL 0x2u
|
||||
#define SDL_WINDOW_SHOWN 0x4u
|
||||
#define SDL_WINDOW_RESIZABLE 0x20u
|
||||
#define SDL_WINDOW_ALLOW_HIGHDPI 0x2000u
|
||||
#define SDL_QUIT_EV 0x100u
|
||||
#define SDL_WINDOWEVENT_EV 0x200u
|
||||
#define SDL_KEYDOWN_EV 0x300u
|
||||
#define SDL_WINDOWEVENT_CLOSE 14
|
||||
#define SDL_SCANCODE_F9 66
|
||||
#define SDL_SCANCODE_F11 68
|
||||
#define SDL_GL_DOUBLEBUFFER 5
|
||||
#define SDL_GL_CONTEXT_MAJOR_VERSION 17
|
||||
#define SDL_GL_CONTEXT_MINOR_VERSION 18
|
||||
#define SDL_GL_CONTEXT_PROFILE_MASK 21
|
||||
#define SDL_GL_CONTEXT_PROFILE_CORE 1
|
||||
#define AUDIO_S16LSB 0x8010
|
||||
#define SDL_PIXELFORMAT_RGB888 0x16161804u /* (XRGB8888: the frames' BGRX bytes) */
|
||||
|
||||
static struct {
|
||||
int (*Init)(uint32_t);
|
||||
const char *(*GetError)(void);
|
||||
int (*SetHint)(const char *, const char *);
|
||||
SDL_Window *(*CreateWindow)(const char *, int, int, int, int, uint32_t);
|
||||
void (*SetWindowTitle)(SDL_Window *, const char *);
|
||||
int (*SetWindowFullscreen)(SDL_Window *, uint32_t);
|
||||
uint32_t (*GetWindowFlags)(SDL_Window *);
|
||||
void (*GetWindowSize)(SDL_Window *, int *, int *);
|
||||
int (*PollEvent)(void *);
|
||||
void (*PumpEvents)(void);
|
||||
int (*ShowCursor)(int);
|
||||
int (*GL_SetAttribute)(int, int);
|
||||
SDL_GLContext (*GL_CreateContext)(SDL_Window *);
|
||||
int (*GL_MakeCurrent)(SDL_Window *, SDL_GLContext);
|
||||
void (*GL_SwapWindow)(SDL_Window *);
|
||||
void *(*GL_GetProcAddress)(const char *);
|
||||
int (*GL_SetSwapInterval)(int);
|
||||
void (*GL_GetDrawableSize)(SDL_Window *, int *, int *);
|
||||
void *(*GetWindowSurface)(SDL_Window *);
|
||||
int (*UpdateWindowSurface)(SDL_Window *);
|
||||
void *(*CreateRGBSurfaceWithFormatFrom)(void *, int, int, int, int, uint32_t);
|
||||
void (*FreeSurface)(void *);
|
||||
int (*FillRect)(void *, const SDL_Rect *, uint32_t);
|
||||
int (*UpperBlitScaled)(void *, const SDL_Rect *, void *, SDL_Rect *);
|
||||
int (*NumJoysticks)(void);
|
||||
int (*IsGameController)(int);
|
||||
SDL_GameController *(*GameControllerOpen)(int);
|
||||
void (*GameControllerClose)(SDL_GameController *);
|
||||
int (*GameControllerGetAttached)(SDL_GameController *);
|
||||
const char *(*GameControllerName)(SDL_GameController *);
|
||||
uint8_t (*GameControllerGetButton)(SDL_GameController *, int);
|
||||
int16_t (*GameControllerGetAxis)(SDL_GameController *, int);
|
||||
SDL_AudioDeviceID (*OpenAudioDevice)(const char *, int, const SDL_AudioSpec *, SDL_AudioSpec *, int);
|
||||
void (*CloseAudioDevice)(SDL_AudioDeviceID);
|
||||
void (*PauseAudioDevice)(SDL_AudioDeviceID, int);
|
||||
int (*QueueAudio)(SDL_AudioDeviceID, const void *, uint32_t);
|
||||
uint32_t (*GetQueuedAudioSize)(SDL_AudioDeviceID);
|
||||
void (*ClearQueuedAudio)(SDL_AudioDeviceID);
|
||||
} sdl;
|
||||
|
||||
/* 1: SDL is there and a display is (the game draws in a window); 0: headless */
|
||||
static int sdl_state = -1;
|
||||
static int sdl_on(void)
|
||||
{
|
||||
if (sdl_state >= 0) return sdl_state;
|
||||
sdl_state = 0;
|
||||
if (getenv("UYA_HEADLESS") || getenv("UYA_DUMP")) return 0; /* (testing: frames and sound to files) */
|
||||
if (!getenv("DISPLAY") && !getenv("WAYLAND_DISPLAY")) { uya_log("no display: running without a window"); return 0; }
|
||||
void *h = dlopen("libSDL2-2.0.so.0", RTLD_NOW | RTLD_GLOBAL);
|
||||
if (!h) h = dlopen("libSDL2.so", RTLD_NOW | RTLD_GLOBAL);
|
||||
if (!h) { uya_log("SDL2 not found (%s): install the 32-bit SDL2 library (libSDL2-2.0.so.0, i386)", dlerror()); return 0; }
|
||||
static const char *const names[] = {
|
||||
"SDL_Init", "SDL_GetError", "SDL_SetHint", "SDL_CreateWindow", "SDL_SetWindowTitle", "SDL_SetWindowFullscreen",
|
||||
"SDL_GetWindowFlags", "SDL_GetWindowSize", "SDL_PollEvent", "SDL_PumpEvents", "SDL_ShowCursor",
|
||||
"SDL_GL_SetAttribute", "SDL_GL_CreateContext", "SDL_GL_MakeCurrent", "SDL_GL_SwapWindow", "SDL_GL_GetProcAddress",
|
||||
"SDL_GL_SetSwapInterval", "SDL_GL_GetDrawableSize", "SDL_GetWindowSurface", "SDL_UpdateWindowSurface",
|
||||
"SDL_CreateRGBSurfaceWithFormatFrom", "SDL_FreeSurface", "SDL_FillRect", "SDL_UpperBlitScaled",
|
||||
"SDL_NumJoysticks", "SDL_IsGameController", "SDL_GameControllerOpen", "SDL_GameControllerClose",
|
||||
"SDL_GameControllerGetAttached", "SDL_GameControllerName", "SDL_GameControllerGetButton", "SDL_GameControllerGetAxis",
|
||||
"SDL_OpenAudioDevice", "SDL_CloseAudioDevice", "SDL_PauseAudioDevice", "SDL_QueueAudio", "SDL_GetQueuedAudioSize",
|
||||
"SDL_ClearQueuedAudio",
|
||||
};
|
||||
void **fp = (void **)&sdl;
|
||||
for (unsigned i = 0; i < sizeof names / sizeof names[0]; i++)
|
||||
if (!(fp[i] = dlsym(h, names[i]))) { uya_log("SDL2: %s missing (SDL 2.0.10 or newer needed)", names[i]); return 0; }
|
||||
sdl.SetHint("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", "1");
|
||||
sdl.SetHint("SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR", "0");
|
||||
if (sdl.Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER | SDL_INIT_AUDIO) != 0 && sdl.Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
|
||||
uya_log("SDL2: cannot start (%s)", sdl.GetError());
|
||||
return 0;
|
||||
}
|
||||
sdl_state = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- the window ---- */
|
||||
static SDL_Window *win;
|
||||
static SDL_GLContext glc;
|
||||
int plat_gl_active;
|
||||
static void win_create(uint32_t extra)
|
||||
{
|
||||
if (win || !sdl_on()) return;
|
||||
uint32_t fl = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI | extra;
|
||||
const char *fs = getenv("UYA_FULLSCREEN");
|
||||
if (fs && atoi(fs)) fl |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
win = sdl.CreateWindow("Project Solana", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 640, 480, fl);
|
||||
if (!win) uya_log("SDL2: no window (%s)", sdl.GetError());
|
||||
else if (fl & SDL_WINDOW_FULLSCREEN_DESKTOP) sdl.ShowCursor(0);
|
||||
}
|
||||
|
||||
static void toggle_fullscreen(void)
|
||||
{
|
||||
if (!win) return;
|
||||
int fs = (sdl.GetWindowFlags(win) & SDL_WINDOW_FULLSCREEN_DESKTOP) == SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
sdl.SetWindowFullscreen(win, fs ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP);
|
||||
sdl.ShowCursor(fs);
|
||||
}
|
||||
|
||||
static volatile int win_closed;
|
||||
static void pump(void)
|
||||
{
|
||||
if (!win) return;
|
||||
union { uint32_t type; uint8_t b[64]; struct { uint32_t type, ts, wid; uint8_t state, repeat, p2, p3; int32_t scancode, sym; } key;
|
||||
struct { uint32_t type, ts, wid; uint8_t event; } w; uint8_t pad[128]; } ev;
|
||||
while (sdl.PollEvent(&ev)) {
|
||||
if (ev.type == SDL_QUIT_EV) win_closed = 1;
|
||||
else if (ev.type == SDL_WINDOWEVENT_EV && ev.w.event == SDL_WINDOWEVENT_CLOSE) win_closed = 1;
|
||||
else if (ev.type == SDL_KEYDOWN_EV && !ev.key.repeat) {
|
||||
if (ev.key.scancode == SDL_SCANCODE_F9) { void uya_gs_rec_hotkey(void); uya_gs_rec_hotkey(); } /* testing: record the GS input */
|
||||
if (ev.key.scancode == SDL_SCANCODE_F11) toggle_fullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- OpenGL ---- */
|
||||
void *sdl_gl_proc(const char *name) { return sdl.GL_GetProcAddress ? sdl.GL_GetProcAddress(name) : 0; }
|
||||
static int sdl_gl_init(void)
|
||||
{
|
||||
if (!sdl_on()) return 0;
|
||||
sdl.GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
||||
sdl.GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
|
||||
sdl.GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||
sdl.GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
win_create(SDL_WINDOW_OPENGL);
|
||||
if (!win) return 0;
|
||||
glc = sdl.GL_CreateContext(win);
|
||||
if (!glc) { uya_log("GL: no OpenGL 3.3 core context (%s)", sdl.GetError()); return 0; }
|
||||
{ /* UYA_VSYNC=1: swaps wait for the monitor (the game paces itself anyway) */
|
||||
const char *e = getenv("UYA_VSYNC");
|
||||
sdl.GL_SetSwapInterval(e ? atoi(e) : 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static void sdl_gl_current(int on) { if (glc) sdl.GL_MakeCurrent(win, on ? glc : 0); }
|
||||
static void sdl_gl_swap(void) { if (glc) sdl.GL_SwapWindow(win); }
|
||||
static void sdl_gl_size(int *w, int *h)
|
||||
{
|
||||
*w = 640; *h = 480;
|
||||
if (win && glc) sdl.GL_GetDrawableSize(win, w, h); /* pixels (HiDPI) */
|
||||
else if (win) sdl.GetWindowSize(win, w, h);
|
||||
}
|
||||
|
||||
/* the frame rate in the title, as on Windows */
|
||||
static void title_fps(void)
|
||||
{
|
||||
extern unsigned long long nsends;
|
||||
static uint64_t t0; static unsigned long long s0;
|
||||
uint64_t t = plat_time_us();
|
||||
if (!t0) { t0 = t; s0 = nsends; return; }
|
||||
if (t - t0 < 1000000) return;
|
||||
char title[64];
|
||||
snprintf(title, sizeof title, "Project Solana - %.0f FPS", (double)(nsends - s0) / ((double)(t - t0) / 1e6));
|
||||
sdl.SetWindowTitle(win, title);
|
||||
t0 = t; s0 = nsends;
|
||||
}
|
||||
|
||||
/* the software renderer's frames (when there is no OpenGL): scaled into the window, 4:3 */
|
||||
static int sdl_present(const uint32_t *px, int w, int h)
|
||||
{
|
||||
win_create(0);
|
||||
pump();
|
||||
if (win_closed) return 0;
|
||||
if (!win) return 1;
|
||||
title_fps();
|
||||
if (plat_gl_active || !px) return 1; /* (GL: the GS thread draws the window) */
|
||||
void *dst = sdl.GetWindowSurface(win);
|
||||
void *src = sdl.CreateRGBSurfaceWithFormatFrom((void *)px, w, h, 32, w * 4, SDL_PIXELFORMAT_RGB888);
|
||||
if (dst && src) {
|
||||
const SDL_Surface_head *d = dst;
|
||||
int vw = d->w, vh = d->w * 3 / 4;
|
||||
if (vh > d->h) { vh = d->h; vw = d->h * 4 / 3; }
|
||||
SDL_Rect r = { (d->w - vw) / 2, (d->h - vh) / 2, vw, vh };
|
||||
sdl.FillRect(dst, 0, 0);
|
||||
sdl.UpperBlitScaled(src, 0, dst, &r);
|
||||
sdl.UpdateWindowSurface(win);
|
||||
}
|
||||
if (src) sdl.FreeSurface(src);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- gamepads ---- */
|
||||
static SDL_GameController *pad;
|
||||
static uint64_t pad_next;
|
||||
static SDL_GameController *pad_get(void)
|
||||
{
|
||||
if (pad && !sdl.GameControllerGetAttached(pad)) { uya_log("gamepad: disconnected"); sdl.GameControllerClose(pad); pad = 0; }
|
||||
if (pad) return pad;
|
||||
uint64_t t = plat_time_us();
|
||||
if (t < pad_next) return 0;
|
||||
pad_next = t + 1000000; /* looked for every second */
|
||||
int n = sdl.NumJoysticks();
|
||||
for (int i = 0; i < n && !pad; i++)
|
||||
if (sdl.IsGameController(i) && (pad = sdl.GameControllerOpen(i))) uya_log("gamepad: %s", sdl.GameControllerName(pad));
|
||||
return pad;
|
||||
}
|
||||
static unsigned sdl_buttons(void)
|
||||
{
|
||||
if (!sdl_on()) return 0;
|
||||
if (!win) sdl.PumpEvents();
|
||||
SDL_GameController *g = pad_get();
|
||||
if (!g) return 0;
|
||||
/* SDL_GameControllerButton: A B X Y Back Guide Start LS RS LB RB Up Down Left Right */
|
||||
static const unsigned map[15] = { 0x4000, 0x2000, 0x8000, 0x1000, 0x0001, 0, 0x0008, 0x0002, 0x0004, 0x0400, 0x0800, 0x0010, 0x0040, 0x0080, 0x0020 };
|
||||
unsigned b = 0;
|
||||
for (int i = 0; i < 15; i++) if (map[i] && sdl.GameControllerGetButton(g, i)) b |= map[i];
|
||||
if (sdl.GameControllerGetAxis(g, 4) > 8000) b |= 0x0100; /* L2 */
|
||||
if (sdl.GameControllerGetAxis(g, 5) > 8000) b |= 0x0200; /* R2 */
|
||||
return b;
|
||||
}
|
||||
static uint8_t sdl_axis(int16_t v) /* -32768..32767 -> 0..255, 0x80 centre, with a dead zone; down is + (as the PS2) */
|
||||
{
|
||||
int x = v / 256;
|
||||
if (x > -24 && x < 24) return 0x7F;
|
||||
x += 0x80;
|
||||
return (uint8_t)(x < 0 ? 0 : x > 255 ? 255 : x);
|
||||
}
|
||||
static int sdl_sticks(uint8_t st[4])
|
||||
{
|
||||
if (!sdl_on()) return 0;
|
||||
SDL_GameController *g = pad_get();
|
||||
if (!g) return 0;
|
||||
for (int i = 0; i < 4; i++) st[i] = sdl_axis(sdl.GameControllerGetAxis(g, i));
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- sound ---- */
|
||||
static SDL_AudioDeviceID one_dev, stream_dev;
|
||||
static int stream_failed;
|
||||
static SDL_AudioDeviceID audio_open(int rate)
|
||||
{
|
||||
SDL_AudioSpec want, have;
|
||||
memset(&want, 0, sizeof want);
|
||||
want.freq = rate; want.format = AUDIO_S16LSB; want.channels = 2; want.samples = 1024;
|
||||
SDL_AudioDeviceID d = sdl.OpenAudioDevice(0, 0, &want, &have, 0); /* (SDL converts to what the device takes) */
|
||||
if (!d) { uya_log("sound: no output device (%s)", sdl.GetError()); return 0; }
|
||||
sdl.PauseAudioDevice(d, 0);
|
||||
return d;
|
||||
}
|
||||
static void sdl_audio_stop(void)
|
||||
{
|
||||
if (!one_dev) return;
|
||||
sdl.ClearQueuedAudio(one_dev);
|
||||
sdl.CloseAudioDevice(one_dev);
|
||||
one_dev = 0;
|
||||
}
|
||||
static void sdl_audio_play(const int16_t *lr, size_t frames, int rate)
|
||||
{
|
||||
sdl_audio_stop();
|
||||
if (!(one_dev = audio_open(rate))) return;
|
||||
sdl.QueueAudio(one_dev, lr, (uint32_t)(frames * 4));
|
||||
}
|
||||
#define STREAM_MAX_FRAMES (32 * 1024) /* as the Windows ring: beyond it, writes are dropped */
|
||||
static int sdl_stream_queued(void)
|
||||
{
|
||||
if (!stream_dev) return stream_failed ? 1 << 20 : 0;
|
||||
return (int)(sdl.GetQueuedAudioSize(stream_dev) / 4);
|
||||
}
|
||||
static void sdl_stream_write(const int16_t *lr, int frames)
|
||||
{
|
||||
if (!stream_dev && !stream_failed && !(stream_dev = audio_open(48000))) stream_failed = 1;
|
||||
if (!stream_dev || sdl_stream_queued() + frames > STREAM_MAX_FRAMES) return;
|
||||
sdl.QueueAudio(stream_dev, lr, (uint32_t)frames * 4);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project Solana for Linux: builds the game from your own disc and plays it.
|
||||
|
||||
project-solana build DISC.iso identify the disc, fetch the build files, build the game (once)
|
||||
project-solana play [options] play it (--upscaled / --native, --fullscreen / --windowed, --vsync)
|
||||
project-solana status what is installed, and whether the 32-bit libraries it needs are there
|
||||
project-solana desktop add Project Solana to your applications menu (and so to Steam)
|
||||
project-solana log print the last game log (rac1_log.txt)
|
||||
|
||||
Like the Windows launcher: nothing from the game is downloaded. The build files (the
|
||||
recompiler, the runtime, the decomp's source) come from the project's GitHub release, the
|
||||
game itself from your disc image. Everything lives in ~/.local/share/project-solana
|
||||
(or $XDG_DATA_HOME/project-solana); `play` remembers your last options.
|
||||
|
||||
Needs: python3 (3.6 or newer), an x86-64 CPU with SSE4.2, and the 32-bit (i386) SDL2 and
|
||||
OpenGL libraries, which Steam installs (SteamOS and any PC with Steam have them)."""
|
||||
import argparse, configparser, hashlib, json, os, shutil, struct, subprocess, sys, time, urllib.request, zipfile
|
||||
|
||||
RELEASE_URL = "https://github.com/OpenRAC/project-solana/releases/download/"
|
||||
GAMES = { # serial (BOOT2 in SYSTEM.CNF): what this version is
|
||||
"SCES_509.16": {"title": "Ratchet & Clank (PAL)", "kit": "rac1", "md5": "d86429e2eca4e1da7d7f1cc656af2574", "exe": "rac1_wrapper"},
|
||||
}
|
||||
OTHER = {"SCUS_971.99": "Ratchet & Clank (NTSC-U)", "SCPS_150.37": "Ratchet & Clank (NTSC-J)",
|
||||
"SCUS_972.68": "Ratchet & Clank: Going Commando (NTSC-U)", "SCES_516.07": "Ratchet & Clank 2 (PAL)",
|
||||
"SCUS_973.53": "Ratchet & Clank: Up Your Arsenal (NTSC-U)", "SCES_524.56": "Ratchet & Clank 3 (PAL)",
|
||||
"SCUS_974.65": "Ratchet: Deadlocked (NTSC-U)", "SCES_532.85": "Ratchet: Gladiator (PAL)"}
|
||||
|
||||
HOME = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "project-solana")
|
||||
INI = os.path.join(HOME, "solana.ini")
|
||||
|
||||
|
||||
def say(*a): print(*a, flush=True)
|
||||
def die(msg): sys.exit("project-solana: " + msg)
|
||||
|
||||
|
||||
def settings():
|
||||
c = configparser.ConfigParser()
|
||||
c.read(INI)
|
||||
for s in ("disc", "play", "build"):
|
||||
if not c.has_section(s): c.add_section(s)
|
||||
return c
|
||||
|
||||
|
||||
def save(c):
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
with open(INI, "w") as f: c.write(f)
|
||||
|
||||
|
||||
# ---- the disc: ISO 9660, SYSTEM.CNF's BOOT2, and that executable's MD5 (as the Windows launcher) ----
|
||||
def iso_root_find(f, root, name):
|
||||
o = 0
|
||||
while o < len(root):
|
||||
ln = root[o]
|
||||
if not ln: o = (o // 2048 + 1) * 2048; continue
|
||||
fl = root[o + 32]
|
||||
fn = root[o + 33:o + 33 + fl].decode("latin-1")
|
||||
if fn.split(";")[0].upper() == name.upper():
|
||||
return struct.unpack_from("<I", root, o + 2)[0], struct.unpack_from("<I", root, o + 10)[0]
|
||||
o += ln
|
||||
return None
|
||||
|
||||
|
||||
def identify(path):
|
||||
"""(serial, md5 of its executable) or raises"""
|
||||
with open(path, "rb") as f:
|
||||
f.seek(16 * 2048); pvd = f.read(2048)
|
||||
if pvd[1:6] != b"CD001": raise ValueError("not a disc image (no ISO 9660 volume)")
|
||||
rlba, rsize = struct.unpack_from("<I", pvd, 158)[0], min(struct.unpack_from("<I", pvd, 166)[0], 1 << 20)
|
||||
f.seek(rlba * 2048); root = f.read(rsize)
|
||||
cnf = iso_root_find(f, root, "SYSTEM.CNF")
|
||||
if not cnf: raise ValueError("not a PlayStation 2 disc (no SYSTEM.CNF)")
|
||||
f.seek(cnf[0] * 2048); text = f.read(cnf[1]).decode("latin-1")
|
||||
boot = [l for l in text.splitlines() if l.strip().upper().startswith("BOOT2")]
|
||||
if not boot or "\\" not in boot[0]: raise ValueError("SYSTEM.CNF names no BOOT2 executable")
|
||||
serial = boot[0].split("\\")[-1].split(";")[0].strip()
|
||||
exe = iso_root_find(f, root, serial)
|
||||
md5 = ""
|
||||
if exe and exe[1] < (64 << 20):
|
||||
f.seek(exe[0] * 2048); md5 = hashlib.md5(f.read(exe[1])).hexdigest()
|
||||
return serial, md5
|
||||
|
||||
|
||||
# ---- downloads ----
|
||||
def fetch(src, dest=None, what="", size=0):
|
||||
"""src: a URL, or a file in a local folder ([build] release_url=/path for offline installs)"""
|
||||
if not src.startswith(("http://", "https://")):
|
||||
if dest: shutil.copyfile(src, dest); return None
|
||||
return open(src, "rb").read()
|
||||
req = urllib.request.Request(src, headers={"User-Agent": "ProjectSolana-Linux/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
total = int(r.headers.get("Content-Length") or size or 0)
|
||||
out = open(dest + ".part", "wb") if dest else None
|
||||
buf, got, last = [], 0, 0
|
||||
while True:
|
||||
b = r.read(1 << 16)
|
||||
if not b: break
|
||||
got += len(b)
|
||||
if out: out.write(b)
|
||||
else: buf.append(b)
|
||||
if what and time.time() - last > 0.5:
|
||||
last = time.time()
|
||||
print("\r %s: %.0f of %.0f MB " % (what, got / 1048576, total / 1048576) if total else "\r %s: %.0f MB " % (what, got / 1048576), end="", flush=True)
|
||||
if what: print("\r %s: done%s" % (what, " " * 30), flush=True)
|
||||
if out: out.close(); os.replace(dest + ".part", dest); return None
|
||||
return b"".join(buf)
|
||||
|
||||
|
||||
def sha256(p):
|
||||
h = hashlib.sha256()
|
||||
with open(p, "rb") as f:
|
||||
for b in iter(lambda: f.read(1 << 20), b""): h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def unzip(zp, dest, what):
|
||||
with zipfile.ZipFile(zp) as z:
|
||||
n = len(z.infolist())
|
||||
for i, info in enumerate(z.infolist()):
|
||||
z.extract(info, dest)
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir(): os.chmod(os.path.join(dest, info.filename), mode)
|
||||
if i % 200 == 0: print("\r unpacking %s: %d of %d files" % (what, i + 1, n), end="", flush=True)
|
||||
print("\r unpacking %s: done%s" % (what, " " * 20), flush=True)
|
||||
|
||||
|
||||
def rmtree(p):
|
||||
if os.path.isdir(p): shutil.rmtree(p, ignore_errors=True)
|
||||
elif os.path.exists(p): os.remove(p)
|
||||
|
||||
|
||||
# ---- the 32-bit libraries the game loads ----
|
||||
LIBDIRS = ["/usr/lib32", "/usr/lib/i386-linux-gnu", "/lib/i386-linux-gnu", "/usr/lib/i686-linux-gnu", "/lib32"]
|
||||
|
||||
|
||||
def have_lib(name):
|
||||
if any(os.path.exists(os.path.join(d, name)) for d in LIBDIRS): return True
|
||||
try: # (Fedora and others keep 32-bit libraries in /usr/lib: ask the linker cache)
|
||||
out = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True).stdout
|
||||
return any(name in l and "x86-64" not in l and "64bit" not in l for l in out.splitlines())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_libs(quiet=False):
|
||||
missing = [n for n in ("libSDL2-2.0.so.0", "libGL.so.1") if not have_lib(n)]
|
||||
if missing and not quiet:
|
||||
say("\nThe game is a 32-bit program and needs these 32-bit libraries: %s" % ", ".join(missing))
|
||||
say("Installing Steam brings them. Otherwise:")
|
||||
say(" Debian, Ubuntu, Mint, Pop!_OS: sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install libsdl2-2.0-0:i386 libgl1:i386 libgl1-mesa-dri:i386")
|
||||
say(" Fedora: sudo dnf install SDL2.i686 mesa-libGL.i686 mesa-dri-drivers.i686")
|
||||
say(" Arch, Manjaro, EndeavourOS: enable [multilib], then sudo pacman -S lib32-sdl2 lib32-mesa")
|
||||
say(" openSUSE: sudo zypper install libSDL2-2_0-0-32bit Mesa-libGL1-32bit")
|
||||
say(" (NVIDIA's own driver: also its 32-bit OpenGL library, e.g. lib32-nvidia-utils on Arch)")
|
||||
return not missing
|
||||
|
||||
|
||||
# ---- build ----
|
||||
def progress_line(line):
|
||||
p = line.split()
|
||||
if len(p) >= 4 and p[0] == "[progress]":
|
||||
ph, i, n = p[1], int(p[2]), max(int(p[3]), 1)
|
||||
label = {"prepare": "preparing", "levels": "translating the levels", "compile": "compiling", "link": "linking", "done": "done"}.get(ph, ph)
|
||||
print("\r %s (%d of %d)%s" % (label, i + (ph == "levels"), n, " " * 10) if ph in ("levels", "compile") else "\r %s%s" % (label, " " * 30), end="", flush=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build(args):
|
||||
iso = os.path.abspath(os.path.expanduser(args.disc))
|
||||
if not os.path.isfile(iso): die("no such file: %s" % iso)
|
||||
say("Disc: %s" % iso)
|
||||
try: serial, md5 = identify(iso)
|
||||
except (OSError, ValueError) as e: die("%s: %s" % (iso, e))
|
||||
g = GAMES.get(serial)
|
||||
if not g: die("%s (%s) is not supported yet." % (OTHER.get(serial, "this game"), serial) if serial in OTHER else "not a game Project Solana knows (%s)" % serial)
|
||||
if g["md5"] and md5 != g["md5"]:
|
||||
die("this is %s, but another version of it (executable MD5 %s): the build is made for %s" % (g["title"], md5, g["md5"]))
|
||||
say("Game: %s" % g["title"])
|
||||
check_libs()
|
||||
c = settings()
|
||||
rel = (args.release_url or c.get("build", "release_url", fallback="") or RELEASE_URL).rstrip("/") + "/"
|
||||
kit = g["kit"]
|
||||
src = (lambda f: rel + kit + "/" + f) if rel.startswith("http") else (lambda f: os.path.join(rel, f))
|
||||
|
||||
say("Checking for the latest build files")
|
||||
man = json.loads(fetch(src(kit + ".json")).decode())
|
||||
tc_dir = os.path.join(HOME, "toolchain")
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
have = open(os.path.join(tc_dir, "version.txt")).read().strip() if os.path.exists(os.path.join(tc_dir, "version.txt")) else ""
|
||||
if have != man["toolchain_version"]:
|
||||
say("Getting the build tools (Python and the zig compiler, %d MB, kept for later builds)" % (int(man["toolchain_linux_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["toolchain_linux_file"])
|
||||
fetch(src(man["toolchain_linux_file"]), zp, "build tools", int(man["toolchain_linux_size"]))
|
||||
if sha256(zp) != man["toolchain_linux_sha256"]: os.remove(zp); die("the build tools did not download correctly (checksum): try again")
|
||||
rmtree(tc_dir); unzip(zp, tc_dir, "build tools"); os.remove(zp)
|
||||
open(os.path.join(tc_dir, "version.txt"), "w").write(man["toolchain_version"])
|
||||
|
||||
gdir = os.path.join(HOME, "games", kit)
|
||||
kv = os.path.join(gdir, "kit_version.txt")
|
||||
old = open(kv).read().strip() if os.path.exists(kv) else ""
|
||||
if old != man["version"] or args.clean:
|
||||
say("Getting the build kit %s (%d MB)" % (man["version"], int(man["kit_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["kit_file"])
|
||||
fetch(src(man["kit_file"]), zp, "build kit", int(man["kit_size"]))
|
||||
if sha256(zp) != man["kit_sha256"]: os.remove(zp); die("the build kit did not download correctly (checksum): try again")
|
||||
if os.path.isdir(gdir): # everything but the build output (and its saves); a new kit version builds from scratch
|
||||
for e in os.listdir(gdir):
|
||||
if e != "build": rmtree(os.path.join(gdir, e))
|
||||
rmtree(os.path.join(gdir, "build", "linux", "gen")); rmtree(os.path.join(gdir, "build", "linux", "obj"))
|
||||
unzip(zp, gdir, "build kit"); os.remove(zp)
|
||||
|
||||
levels = c.get("build", "levels", fallback="") or man.get("levels") or ",".join(str(i) for i in range(19))
|
||||
cpus = os.cpu_count() or 2
|
||||
try: ram_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1 << 30)
|
||||
except (ValueError, OSError): ram_gb = 8
|
||||
jobs = args.jobs or max(1, min(cpus, int(ram_gb / 1.5)))
|
||||
say("Building the game from your disc (%d jobs): about 15 minutes on a recent 6 to 8 core CPU, longer on older ones" % jobs)
|
||||
env = dict(os.environ, PATH=os.path.join(tc_dir, "zig") + os.pathsep + os.environ.get("PATH", ""), PYTHONUTF8="1",
|
||||
ZIG_GLOBAL_CACHE_DIR=os.path.join(gdir, "zig-cache"), ZIG_LOCAL_CACHE_DIR=os.path.join(gdir, "zig-cache"))
|
||||
py = os.path.join(tc_dir, "python", "bin", "python3")
|
||||
cmd = [py, "-u", "build.py", "--platform", "linux", "--levels", levels, "--iso", iso, "--repo", "decomp", "-j", str(jobs)]
|
||||
log = open(os.path.join(gdir, "build_log.txt"), "w")
|
||||
p = subprocess.Popen(cmd, cwd=gdir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
for line in p.stdout:
|
||||
log.write(line)
|
||||
if not progress_line(line) and ("error" in line.lower() or "failed" in line.lower()): print("\n " + line.rstrip())
|
||||
p.wait(); log.close(); print()
|
||||
if p.returncode: die("the build stopped (see %s)" % os.path.join(gdir, "build_log.txt"))
|
||||
for j in ("build/linux/gen", "build/linux/gen.new", "build/linux/obj", "build/levels", "build/disc", "zig-cache"): rmtree(os.path.join(gdir, j))
|
||||
open(kv, "w").write(man["version"])
|
||||
c.set("disc", kit, iso); c.set("build", "built_" + kit, man["version"]); save(c)
|
||||
say("Built. Ready to play: project-solana play")
|
||||
|
||||
|
||||
# ---- play ----
|
||||
def play(args):
|
||||
c = settings()
|
||||
kit = "rac1"
|
||||
iso = c.get("disc", kit, fallback="")
|
||||
exe = os.path.join(HOME, "games", kit, "build", "linux", GAMES["SCES_509.16"]["exe"])
|
||||
if not os.path.exists(exe) or not iso: die("not built yet: run project-solana build /path/to/your/disc.iso")
|
||||
if not os.path.exists(iso): die("the disc image has moved (%s): build again with its new path" % iso)
|
||||
pc = c["play"]
|
||||
for k, v in (("resolution", args.resolution), ("fullscreen", args.fullscreen), ("vsync", args.vsync)):
|
||||
if v is not None: pc[k] = str(v)
|
||||
save(c)
|
||||
env = dict(os.environ)
|
||||
deck = os.environ.get("SteamDeck") == "1"
|
||||
if pc.get("resolution", "upscaled") == "upscaled":
|
||||
try: # (as the Windows launcher: render at about the screen's height)
|
||||
out = subprocess.run(["xrandr", "--current"], capture_output=True, text=True, timeout=5).stdout
|
||||
h = max(int(l.split("current ")[1].split(" x ")[1].split(",")[0]) for l in out.splitlines() if "current " in l)
|
||||
except Exception:
|
||||
h = 800 if deck else 1080
|
||||
env["UYA_SCALE"] = str(min(4, max(2, -(-h // 448))))
|
||||
full = pc.get("fullscreen", "1" if deck else "0") in ("1", "True", "true")
|
||||
if full: env["UYA_FULLSCREEN"] = "1"
|
||||
if pc.get("vsync", "0") in ("1", "True", "true"): env["UYA_VSYNC"] = "1"
|
||||
if not check_libs(quiet=True): check_libs(); die("install the 32-bit libraries above, then play again")
|
||||
os.chdir(os.path.dirname(exe))
|
||||
os.execve(exe, [exe, "-", iso], env)
|
||||
|
||||
|
||||
def status(args):
|
||||
c = settings()
|
||||
say("Data folder: %s" % HOME)
|
||||
tv = os.path.join(HOME, "toolchain", "version.txt")
|
||||
say("Build tools: %s" % (open(tv).read().strip() if os.path.exists(tv) else "not downloaded"))
|
||||
for kit in ("rac1",):
|
||||
b = c.get("build", "built_" + kit, fallback="")
|
||||
say("%s: %s, disc %s" % (kit, ("built from kit " + b) if b else "not built", c.get("disc", kit, fallback="(none)")))
|
||||
say("32-bit SDL2 and OpenGL: %s" % ("found" if check_libs(quiet=True) else "missing"))
|
||||
if not check_libs(quiet=True): check_libs()
|
||||
|
||||
|
||||
def desktop(args):
|
||||
me = os.path.abspath(sys.argv[0])
|
||||
d = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "applications")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
p = os.path.join(d, "project-solana-rac1.desktop")
|
||||
with open(p, "w") as f:
|
||||
f.write("[Desktop Entry]\nType=Application\nName=Ratchet & Clank (Project Solana)\nComment=Built from your own disc\n"
|
||||
"Exec=\"%s\" play\nTerminal=false\nCategories=Game;\n" % me)
|
||||
say("Added %s" % p)
|
||||
say("Steam: Games > Add a Non-Steam Game to My Library, and pick \"Ratchet & Clank (Project Solana)\".")
|
||||
|
||||
|
||||
def show_log(args):
|
||||
p = os.path.join(HOME, "games", "rac1", "build", "linux", "rac1_log.txt")
|
||||
if not os.path.exists(p): die("no log yet: it is written when the game runs")
|
||||
sys.stdout.write(open(p, errors="replace").read())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(prog="project-solana", description="Build Ratchet & Clank from your own disc, then play it.")
|
||||
sp = ap.add_subparsers(dest="cmd")
|
||||
b = sp.add_parser("build", help="build the game from your disc image")
|
||||
b.add_argument("disc"); b.add_argument("-j", "--jobs", type=int, default=0)
|
||||
b.add_argument("--clean", action="store_true", help="fetch the build kit again and build from scratch")
|
||||
b.add_argument("--release-url", default="", help=argparse.SUPPRESS)
|
||||
p = sp.add_parser("play", help="play (options are remembered)")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--upscaled", dest="resolution", action="store_const", const="upscaled", help="render at about your screen's resolution (default)")
|
||||
g.add_argument("--native", dest="resolution", action="store_const", const="native", help="the PS2's own resolution")
|
||||
f = p.add_mutually_exclusive_group()
|
||||
f.add_argument("--fullscreen", dest="fullscreen", action="store_const", const=1, help="full screen (F11 switches; default on Steam Deck)")
|
||||
f.add_argument("--windowed", dest="fullscreen", action="store_const", const=0)
|
||||
v = p.add_mutually_exclusive_group()
|
||||
v.add_argument("--vsync", dest="vsync", action="store_const", const=1)
|
||||
v.add_argument("--no-vsync", dest="vsync", action="store_const", const=0)
|
||||
sp.add_parser("status", help="what is installed"); sp.add_parser("desktop", help="add it to the applications menu")
|
||||
sp.add_parser("log", help="print the last game log")
|
||||
a = ap.parse_args()
|
||||
if sys.platform != "linux": die("this is the Linux launcher: on Windows, use Project Solana.exe")
|
||||
{"build": build, "play": play, "status": status, "desktop": desktop, "log": show_log}.get(a.cmd, lambda _: ap.print_help())(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project Solana for Linux: builds the game from your own disc and plays it.
|
||||
|
||||
project-solana build DISC.iso identify the disc, fetch the build files, build the game (once)
|
||||
project-solana play [options] play it (--upscaled / --native, --fullscreen / --windowed, --vsync)
|
||||
project-solana status what is installed, and whether the 32-bit libraries it needs are there
|
||||
project-solana desktop add Project Solana to your applications menu (and so to Steam)
|
||||
project-solana log print the last game log (rac1_log.txt)
|
||||
|
||||
Like the Windows launcher: nothing from the game is downloaded. The build files (the
|
||||
recompiler, the runtime, the decomp's source) come from the project's GitHub release, the
|
||||
game itself from your disc image. Everything lives in ~/.local/share/project-solana
|
||||
(or $XDG_DATA_HOME/project-solana); `play` remembers your last options.
|
||||
|
||||
Needs: python3 (3.6 or newer), an x86-64 CPU with SSE4.2, and the 32-bit (i386) SDL2 and
|
||||
OpenGL libraries, which Steam installs (SteamOS and any PC with Steam have them)."""
|
||||
import argparse, configparser, hashlib, json, os, shutil, struct, subprocess, sys, time, urllib.request, zipfile
|
||||
|
||||
RELEASE_URL = "https://github.com/OpenRAC/project-solana/releases/download/"
|
||||
GAMES = { # serial (BOOT2 in SYSTEM.CNF): what this version is
|
||||
"SCES_509.16": {"title": "Ratchet & Clank (PAL)", "kit": "rac1", "md5": "d86429e2eca4e1da7d7f1cc656af2574", "exe": "rac1_wrapper"},
|
||||
}
|
||||
OTHER = {"SCUS_971.99": "Ratchet & Clank (NTSC-U)", "SCPS_150.37": "Ratchet & Clank (NTSC-J)",
|
||||
"SCUS_972.68": "Ratchet & Clank: Going Commando (NTSC-U)", "SCES_516.07": "Ratchet & Clank 2 (PAL)",
|
||||
"SCUS_973.53": "Ratchet & Clank: Up Your Arsenal (NTSC-U)", "SCES_524.56": "Ratchet & Clank 3 (PAL)",
|
||||
"SCUS_974.65": "Ratchet: Deadlocked (NTSC-U)", "SCES_532.85": "Ratchet: Gladiator (PAL)"}
|
||||
|
||||
HOME = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "project-solana")
|
||||
INI = os.path.join(HOME, "solana.ini")
|
||||
|
||||
|
||||
def say(*a): print(*a, flush=True)
|
||||
def die(msg): sys.exit("project-solana: " + msg)
|
||||
|
||||
|
||||
def settings():
|
||||
c = configparser.ConfigParser()
|
||||
c.read(INI)
|
||||
for s in ("disc", "play", "build"):
|
||||
if not c.has_section(s): c.add_section(s)
|
||||
return c
|
||||
|
||||
|
||||
def save(c):
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
with open(INI, "w") as f: c.write(f)
|
||||
|
||||
|
||||
# ---- the disc: ISO 9660, SYSTEM.CNF's BOOT2, and that executable's MD5 (as the Windows launcher) ----
|
||||
def iso_root_find(f, root, name):
|
||||
o = 0
|
||||
while o < len(root):
|
||||
ln = root[o]
|
||||
if not ln: o = (o // 2048 + 1) * 2048; continue
|
||||
fl = root[o + 32]
|
||||
fn = root[o + 33:o + 33 + fl].decode("latin-1")
|
||||
if fn.split(";")[0].upper() == name.upper():
|
||||
return struct.unpack_from("<I", root, o + 2)[0], struct.unpack_from("<I", root, o + 10)[0]
|
||||
o += ln
|
||||
return None
|
||||
|
||||
|
||||
def identify(path):
|
||||
"""(serial, md5 of its executable) or raises"""
|
||||
with open(path, "rb") as f:
|
||||
f.seek(16 * 2048); pvd = f.read(2048)
|
||||
if pvd[1:6] != b"CD001": raise ValueError("not a disc image (no ISO 9660 volume)")
|
||||
rlba, rsize = struct.unpack_from("<I", pvd, 158)[0], min(struct.unpack_from("<I", pvd, 166)[0], 1 << 20)
|
||||
f.seek(rlba * 2048); root = f.read(rsize)
|
||||
cnf = iso_root_find(f, root, "SYSTEM.CNF")
|
||||
if not cnf: raise ValueError("not a PlayStation 2 disc (no SYSTEM.CNF)")
|
||||
f.seek(cnf[0] * 2048); text = f.read(cnf[1]).decode("latin-1")
|
||||
boot = [l for l in text.splitlines() if l.strip().upper().startswith("BOOT2")]
|
||||
if not boot or "\\" not in boot[0]: raise ValueError("SYSTEM.CNF names no BOOT2 executable")
|
||||
serial = boot[0].split("\\")[-1].split(";")[0].strip()
|
||||
exe = iso_root_find(f, root, serial)
|
||||
md5 = ""
|
||||
if exe and exe[1] < (64 << 20):
|
||||
f.seek(exe[0] * 2048); md5 = hashlib.md5(f.read(exe[1])).hexdigest()
|
||||
return serial, md5
|
||||
|
||||
|
||||
# ---- downloads ----
|
||||
def fetch(src, dest=None, what="", size=0):
|
||||
"""src: a URL, or a file in a local folder ([build] release_url=/path for offline installs)"""
|
||||
if not src.startswith(("http://", "https://")):
|
||||
if dest: shutil.copyfile(src, dest); return None
|
||||
return open(src, "rb").read()
|
||||
req = urllib.request.Request(src, headers={"User-Agent": "ProjectSolana-Linux/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
total = int(r.headers.get("Content-Length") or size or 0)
|
||||
out = open(dest + ".part", "wb") if dest else None
|
||||
buf, got, last = [], 0, 0
|
||||
while True:
|
||||
b = r.read(1 << 16)
|
||||
if not b: break
|
||||
got += len(b)
|
||||
if out: out.write(b)
|
||||
else: buf.append(b)
|
||||
if what and time.time() - last > 0.5:
|
||||
last = time.time()
|
||||
print("\r %s: %.0f of %.0f MB " % (what, got / 1048576, total / 1048576) if total else "\r %s: %.0f MB " % (what, got / 1048576), end="", flush=True)
|
||||
if what: print("\r %s: done%s" % (what, " " * 30), flush=True)
|
||||
if out: out.close(); os.replace(dest + ".part", dest); return None
|
||||
return b"".join(buf)
|
||||
|
||||
|
||||
def sha256(p):
|
||||
h = hashlib.sha256()
|
||||
with open(p, "rb") as f:
|
||||
for b in iter(lambda: f.read(1 << 20), b""): h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def unzip(zp, dest, what):
|
||||
with zipfile.ZipFile(zp) as z:
|
||||
n = len(z.infolist())
|
||||
for i, info in enumerate(z.infolist()):
|
||||
z.extract(info, dest)
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir(): os.chmod(os.path.join(dest, info.filename), mode)
|
||||
if i % 200 == 0: print("\r unpacking %s: %d of %d files" % (what, i + 1, n), end="", flush=True)
|
||||
print("\r unpacking %s: done%s" % (what, " " * 20), flush=True)
|
||||
|
||||
|
||||
def rmtree(p):
|
||||
if os.path.isdir(p): shutil.rmtree(p, ignore_errors=True)
|
||||
elif os.path.exists(p): os.remove(p)
|
||||
|
||||
|
||||
# ---- the 32-bit libraries the game loads ----
|
||||
LIBDIRS = ["/usr/lib32", "/usr/lib/i386-linux-gnu", "/lib/i386-linux-gnu", "/usr/lib/i686-linux-gnu", "/lib32"]
|
||||
|
||||
|
||||
def have_lib(name):
|
||||
if any(os.path.exists(os.path.join(d, name)) for d in LIBDIRS): return True
|
||||
try: # (Fedora and others keep 32-bit libraries in /usr/lib: ask the linker cache)
|
||||
out = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True).stdout
|
||||
return any(name in l and "x86-64" not in l and "64bit" not in l for l in out.splitlines())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_libs(quiet=False):
|
||||
missing = [n for n in ("libSDL2-2.0.so.0", "libGL.so.1") if not have_lib(n)]
|
||||
if missing and not quiet:
|
||||
say("\nThe game is a 32-bit program and needs these 32-bit libraries: %s" % ", ".join(missing))
|
||||
say("Installing Steam brings them. Otherwise:")
|
||||
say(" Debian, Ubuntu, Mint, Pop!_OS: sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install libsdl2-2.0-0:i386 libgl1:i386 libgl1-mesa-dri:i386")
|
||||
say(" Fedora: sudo dnf install SDL2.i686 mesa-libGL.i686 mesa-dri-drivers.i686")
|
||||
say(" Arch, Manjaro, EndeavourOS: enable [multilib], then sudo pacman -S lib32-sdl2 lib32-mesa")
|
||||
say(" openSUSE: sudo zypper install libSDL2-2_0-0-32bit Mesa-libGL1-32bit")
|
||||
say(" (NVIDIA's own driver: also its 32-bit OpenGL library, e.g. lib32-nvidia-utils on Arch)")
|
||||
return not missing
|
||||
|
||||
|
||||
# ---- build ----
|
||||
def progress_line(line):
|
||||
p = line.split()
|
||||
if len(p) >= 4 and p[0] == "[progress]":
|
||||
ph, i, n = p[1], int(p[2]), max(int(p[3]), 1)
|
||||
label = {"prepare": "preparing", "levels": "translating the levels", "compile": "compiling", "link": "linking", "done": "done"}.get(ph, ph)
|
||||
print("\r %s (%d of %d)%s" % (label, i + (ph == "levels"), n, " " * 10) if ph in ("levels", "compile") else "\r %s%s" % (label, " " * 30), end="", flush=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build(args):
|
||||
iso = os.path.abspath(os.path.expanduser(args.disc))
|
||||
if not os.path.isfile(iso): die("no such file: %s" % iso)
|
||||
say("Disc: %s" % iso)
|
||||
try: serial, md5 = identify(iso)
|
||||
except (OSError, ValueError) as e: die("%s: %s" % (iso, e))
|
||||
g = GAMES.get(serial)
|
||||
if not g: die("%s (%s) is not supported yet." % (OTHER.get(serial, "this game"), serial) if serial in OTHER else "not a game Project Solana knows (%s)" % serial)
|
||||
if g["md5"] and md5 != g["md5"]:
|
||||
die("this is %s, but another version of it (executable MD5 %s): the build is made for %s" % (g["title"], md5, g["md5"]))
|
||||
say("Game: %s" % g["title"])
|
||||
check_libs()
|
||||
c = settings()
|
||||
rel = (args.release_url or c.get("build", "release_url", fallback="") or RELEASE_URL).rstrip("/") + "/"
|
||||
kit = g["kit"]
|
||||
src = (lambda f: rel + kit + "/" + f) if rel.startswith("http") else (lambda f: os.path.join(rel, f))
|
||||
|
||||
say("Checking for the latest build files")
|
||||
man = json.loads(fetch(src(kit + ".json")).decode())
|
||||
tc_dir = os.path.join(HOME, "toolchain")
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
have = open(os.path.join(tc_dir, "version.txt")).read().strip() if os.path.exists(os.path.join(tc_dir, "version.txt")) else ""
|
||||
if have != man["toolchain_version"]:
|
||||
say("Getting the build tools (Python and the zig compiler, %d MB, kept for later builds)" % (int(man["toolchain_linux_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["toolchain_linux_file"])
|
||||
fetch(src(man["toolchain_linux_file"]), zp, "build tools", int(man["toolchain_linux_size"]))
|
||||
if sha256(zp) != man["toolchain_linux_sha256"]: os.remove(zp); die("the build tools did not download correctly (checksum): try again")
|
||||
rmtree(tc_dir); unzip(zp, tc_dir, "build tools"); os.remove(zp)
|
||||
open(os.path.join(tc_dir, "version.txt"), "w").write(man["toolchain_version"])
|
||||
|
||||
gdir = os.path.join(HOME, "games", kit)
|
||||
kv = os.path.join(gdir, "kit_version.txt")
|
||||
old = open(kv).read().strip() if os.path.exists(kv) else ""
|
||||
if old != man["version"] or args.clean:
|
||||
say("Getting the build kit %s (%d MB)" % (man["version"], int(man["kit_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["kit_file"])
|
||||
fetch(src(man["kit_file"]), zp, "build kit", int(man["kit_size"]))
|
||||
if sha256(zp) != man["kit_sha256"]: os.remove(zp); die("the build kit did not download correctly (checksum): try again")
|
||||
if os.path.isdir(gdir): # everything but the build output (and its saves); a new kit version builds from scratch
|
||||
for e in os.listdir(gdir):
|
||||
if e != "build": rmtree(os.path.join(gdir, e))
|
||||
rmtree(os.path.join(gdir, "build", "linux", "gen")); rmtree(os.path.join(gdir, "build", "linux", "obj"))
|
||||
unzip(zp, gdir, "build kit"); os.remove(zp)
|
||||
|
||||
levels = c.get("build", "levels", fallback="") or man.get("levels") or ",".join(str(i) for i in range(19))
|
||||
cpus = os.cpu_count() or 2
|
||||
try: ram_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1 << 30)
|
||||
except (ValueError, OSError): ram_gb = 8
|
||||
jobs = args.jobs or max(1, min(cpus, int(ram_gb / 1.5)))
|
||||
say("Building the game from your disc (%d jobs): about 15 minutes on a recent 6 to 8 core CPU, longer on older ones" % jobs)
|
||||
env = dict(os.environ, PATH=os.path.join(tc_dir, "zig") + os.pathsep + os.environ.get("PATH", ""), PYTHONUTF8="1",
|
||||
ZIG_GLOBAL_CACHE_DIR=os.path.join(gdir, "zig-cache"), ZIG_LOCAL_CACHE_DIR=os.path.join(gdir, "zig-cache"))
|
||||
py = os.path.join(tc_dir, "python", "bin", "python3")
|
||||
cmd = [py, "-u", "build.py", "--platform", "linux", "--levels", levels, "--iso", iso, "--repo", "decomp", "-j", str(jobs)]
|
||||
log = open(os.path.join(gdir, "build_log.txt"), "w")
|
||||
p = subprocess.Popen(cmd, cwd=gdir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
for line in p.stdout:
|
||||
log.write(line)
|
||||
if not progress_line(line) and ("error" in line.lower() or "failed" in line.lower()): print("\n " + line.rstrip())
|
||||
p.wait(); log.close(); print()
|
||||
if p.returncode: die("the build stopped (see %s)" % os.path.join(gdir, "build_log.txt"))
|
||||
for j in ("build/linux/gen", "build/linux/gen.new", "build/linux/obj", "build/levels", "build/disc", "zig-cache"): rmtree(os.path.join(gdir, j))
|
||||
open(kv, "w").write(man["version"])
|
||||
c.set("disc", kit, iso); c.set("build", "built_" + kit, man["version"]); save(c)
|
||||
say("Built. Ready to play: project-solana play")
|
||||
|
||||
|
||||
# ---- play ----
|
||||
def play(args):
|
||||
c = settings()
|
||||
kit = "rac1"
|
||||
iso = c.get("disc", kit, fallback="")
|
||||
exe = os.path.join(HOME, "games", kit, "build", "linux", GAMES["SCES_509.16"]["exe"])
|
||||
if not os.path.exists(exe) or not iso: die("not built yet: run project-solana build /path/to/your/disc.iso")
|
||||
if not os.path.exists(iso): die("the disc image has moved (%s): build again with its new path" % iso)
|
||||
pc = c["play"]
|
||||
for k, v in (("resolution", args.resolution), ("fullscreen", args.fullscreen), ("vsync", args.vsync)):
|
||||
if v is not None: pc[k] = str(v)
|
||||
save(c)
|
||||
env = dict(os.environ)
|
||||
deck = os.environ.get("SteamDeck") == "1"
|
||||
if pc.get("resolution", "upscaled") == "upscaled":
|
||||
try: # (as the Windows launcher: render at about the screen's height)
|
||||
out = subprocess.run(["xrandr", "--current"], capture_output=True, text=True, timeout=5).stdout
|
||||
h = max(int(l.split("current ")[1].split(" x ")[1].split(",")[0]) for l in out.splitlines() if "current " in l)
|
||||
except Exception:
|
||||
h = 800 if deck else 1080
|
||||
env["UYA_SCALE"] = str(min(4, max(2, -(-h // 448))))
|
||||
full = pc.get("fullscreen", "1" if deck else "0") in ("1", "True", "true")
|
||||
if full: env["UYA_FULLSCREEN"] = "1"
|
||||
if pc.get("vsync", "0") in ("1", "True", "true"): env["UYA_VSYNC"] = "1"
|
||||
if not check_libs(quiet=True): check_libs(); die("install the 32-bit libraries above, then play again")
|
||||
os.chdir(os.path.dirname(exe))
|
||||
os.execve(exe, [exe, "-", iso], env)
|
||||
|
||||
|
||||
def status(args):
|
||||
c = settings()
|
||||
say("Data folder: %s" % HOME)
|
||||
tv = os.path.join(HOME, "toolchain", "version.txt")
|
||||
say("Build tools: %s" % (open(tv).read().strip() if os.path.exists(tv) else "not downloaded"))
|
||||
for kit in ("rac1",):
|
||||
b = c.get("build", "built_" + kit, fallback="")
|
||||
say("%s: %s, disc %s" % (kit, ("built from kit " + b) if b else "not built", c.get("disc", kit, fallback="(none)")))
|
||||
say("32-bit SDL2 and OpenGL: %s" % ("found" if check_libs(quiet=True) else "missing"))
|
||||
if not check_libs(quiet=True): check_libs()
|
||||
|
||||
|
||||
def desktop(args):
|
||||
me = os.path.abspath(sys.argv[0])
|
||||
d = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "applications")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
p = os.path.join(d, "project-solana-rac1.desktop")
|
||||
with open(p, "w") as f:
|
||||
f.write("[Desktop Entry]\nType=Application\nName=Ratchet & Clank (Project Solana)\nComment=Built from your own disc\n"
|
||||
"Exec=\"%s\" play\nTerminal=false\nCategories=Game;\n" % me)
|
||||
say("Added %s" % p)
|
||||
say("Steam: Games > Add a Non-Steam Game to My Library, and pick \"Ratchet & Clank (Project Solana)\".")
|
||||
|
||||
|
||||
def show_log(args):
|
||||
p = os.path.join(HOME, "games", "rac1", "build", "linux", "rac1_log.txt")
|
||||
if not os.path.exists(p): die("no log yet: it is written when the game runs")
|
||||
sys.stdout.write(open(p, errors="replace").read())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(prog="project-solana", description="Build Ratchet & Clank from your own disc, then play it.")
|
||||
sp = ap.add_subparsers(dest="cmd")
|
||||
b = sp.add_parser("build", help="build the game from your disc image")
|
||||
b.add_argument("disc"); b.add_argument("-j", "--jobs", type=int, default=0)
|
||||
b.add_argument("--clean", action="store_true", help="fetch the build kit again and build from scratch")
|
||||
b.add_argument("--release-url", default="", help=argparse.SUPPRESS)
|
||||
p = sp.add_parser("play", help="play (options are remembered)")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--upscaled", dest="resolution", action="store_const", const="upscaled", help="render at about your screen's resolution (default)")
|
||||
g.add_argument("--native", dest="resolution", action="store_const", const="native", help="the PS2's own resolution")
|
||||
f = p.add_mutually_exclusive_group()
|
||||
f.add_argument("--fullscreen", dest="fullscreen", action="store_const", const=1, help="full screen (F11 switches; default on Steam Deck)")
|
||||
f.add_argument("--windowed", dest="fullscreen", action="store_const", const=0)
|
||||
v = p.add_mutually_exclusive_group()
|
||||
v.add_argument("--vsync", dest="vsync", action="store_const", const=1)
|
||||
v.add_argument("--no-vsync", dest="vsync", action="store_const", const=0)
|
||||
sp.add_parser("status", help="what is installed"); sp.add_parser("desktop", help="add it to the applications menu")
|
||||
sp.add_parser("log", help="print the last game log")
|
||||
a = ap.parse_args()
|
||||
if sys.platform != "linux": die("this is the Linux launcher: on Windows, use Project Solana.exe")
|
||||
{"build": build, "play": play, "status": status, "desktop": desktop, "log": show_log}.get(a.cmd, lambda _: ap.print_help())(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"game": "rac1",
|
||||
"version": "2026.10.14.1",
|
||||
"levels": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18",
|
||||
"kit_file": "rac1-build.zip",
|
||||
"kit_size": 5125429,
|
||||
"kit_sha256": "b6919dd6d31d1ae8fac65c2859eaa6c084e98b97af36e3a305b4253da40ba5c5",
|
||||
"toolchain_file": "toolchain-win64.zip",
|
||||
"toolchain_size": 84006129,
|
||||
"toolchain_sha256": "2d56894833ca4066faf5ac008d3fa06aa40e2bb1d1fcb8f5bd26f28a70382b63",
|
||||
"toolchain_version": "py3.12.7-zig0.16.0-rabbitizer1.16.2"
|
||||
}
|
||||
+52
-23
@@ -2,7 +2,7 @@
|
||||
"""make_release.py: makes the files of a game's GitHub release (the tag is the game, e.g. "rac1").
|
||||
|
||||
usage: python release/make_release.py rac1 --decomp PATH_TO_RAC1_DECOMP [--version V]
|
||||
[--toolchain-zip toolchain-win64.zip]
|
||||
[--toolchain-zip toolchain-win64.zip] [--toolchain-linux-zip toolchain-linux64.zip]
|
||||
|
||||
Writes dist/rac1/:
|
||||
rac1.json what the launcher's Build button fetches (version, files, SHA-256s, levels)
|
||||
@@ -10,8 +10,9 @@ Writes dist/rac1/:
|
||||
source as decomp/ (everything but baserom/ and .git: no game files)
|
||||
toolchain-win64.zip Python, rabbitizer and zig for Windows x64 (release/toolchain.json); reused
|
||||
from --toolchain-zip when given, else fetched (PyPI and GitHub) and assembled
|
||||
toolchain-linux64.zip the same for Linux x86-64 (--toolchain-linux-zip to reuse one)
|
||||
|
||||
Upload the three files to the release named after the game (gh release create rac1 dist/rac1/*).
|
||||
Upload the four files to the release named after the game (gh release create rac1 dist/rac1/*).
|
||||
Players never download anything from the game itself: the build reads their own disc image."""
|
||||
import argparse, datetime, glob, hashlib, io, json, os, shutil, subprocess, sys, tarfile, tempfile, zipfile
|
||||
|
||||
@@ -21,7 +22,8 @@ ap = argparse.ArgumentParser()
|
||||
ap.add_argument("game")
|
||||
ap.add_argument("--decomp", required=True, help="the game's decomp repo (only its source is packed; baserom/ is left out)")
|
||||
ap.add_argument("--version", default=datetime.date.today().strftime("%Y.%m.%d"))
|
||||
ap.add_argument("--toolchain-zip", default=None)
|
||||
ap.add_argument("--toolchain-zip", default=None, help="reuse this toolchain-win64.zip")
|
||||
ap.add_argument("--toolchain-linux-zip", default=None, help="reuse this toolchain-linux64.zip")
|
||||
ap.add_argument("--levels", default=",".join(str(i) for i in range(19)))
|
||||
a = ap.parse_args()
|
||||
|
||||
@@ -35,7 +37,7 @@ SKIP_DIRS = {"__pycache__", ".git", "build", "dev", "decomp", "zig-cache"}
|
||||
DECOMP_SKIP = {"baserom", ".git", ".github"}
|
||||
|
||||
|
||||
def add_tree(z, top, arc, skip):
|
||||
def add_tree(z, top, arc, skip, modes=False):
|
||||
for root, dirs, files in os.walk(top):
|
||||
dirs[:] = sorted(d for d in dirs if d not in skip)
|
||||
for f in sorted(files):
|
||||
@@ -45,7 +47,7 @@ def add_tree(z, top, arc, skip):
|
||||
# newer, so a retry of the same kit reuses them (a new kit version starts clean)
|
||||
zi = zipfile.ZipInfo(os.path.join(arc, os.path.relpath(p, top)).replace(os.sep, "/"), date_time=(2020, 1, 1, 0, 0, 0))
|
||||
zi.compress_type = zipfile.ZIP_DEFLATED
|
||||
zi.external_attr = 0o644 << 16
|
||||
zi.external_attr = ((os.stat(p).st_mode & 0o777) if modes else 0o644) << 16 # (Linux: executables stay executable)
|
||||
with open(p, "rb") as fh: z.writestr(zi, fh.read(), compresslevel=9)
|
||||
|
||||
|
||||
@@ -67,44 +69,70 @@ with zipfile.ZipFile(kit, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
|
||||
if n.startswith("decomp/baserom/") or n.endswith(("SCES_509.16", ".iso")): sys.exit("refusing to pack %s" % n)
|
||||
print("kit:", kit, os.path.getsize(kit) // 1024, "KB")
|
||||
|
||||
# the toolchain
|
||||
# the toolchains: Python, rabbitizer and zig, for building on Windows (x64) and on Linux (x86-64)
|
||||
tcj = json.load(open(os.path.join(HERE, "toolchain.json")))
|
||||
tcz = os.path.join(out, "toolchain-win64.zip")
|
||||
if a.toolchain_zip:
|
||||
if os.path.abspath(a.toolchain_zip) != os.path.abspath(tcz): shutil.copyfile(a.toolchain_zip, tcz)
|
||||
else:
|
||||
|
||||
|
||||
def make_toolchain(plat, out_zip):
|
||||
import urllib.request
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
print("fetching", tcj["python"])
|
||||
data = urllib.request.urlopen(tcj["python"]).read()
|
||||
print("fetching", tcj["python"][plat])
|
||||
data = urllib.request.urlopen(tcj["python"][plat]).read()
|
||||
tarfile.open(fileobj=io.BytesIO(data)).extractall(t)
|
||||
py = os.path.join(t, "python")
|
||||
for pat in ("*.pdb", "**/*.pdb"):
|
||||
for p in glob.glob(os.path.join(py, pat), recursive=True): os.remove(p)
|
||||
for d in ("tcl", "Lib/test", "Lib/idlelib", "Lib/tkinter", "Lib/turtledemo", "Lib/ensurepip", "include", "libs", "Scripts"):
|
||||
site = os.path.join(py, "Lib", "site-packages") if plat == "windows" else glob.glob(os.path.join(py, "lib", "python3.*", "site-packages"))[0]
|
||||
stdlib = os.path.join(py, "Lib") if plat == "windows" else os.path.dirname(site)
|
||||
for d in ("test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "config-3.12-x86_64-linux-gnu"):
|
||||
shutil.rmtree(os.path.join(stdlib, d), ignore_errors=True)
|
||||
for d in ("tcl", "include", "libs", "Scripts", "share"):
|
||||
shutil.rmtree(os.path.join(py, d), ignore_errors=True)
|
||||
for p in glob.glob(os.path.join(py, "lib", "libtcl*")) + glob.glob(os.path.join(py, "lib", "libtk*")) + glob.glob(os.path.join(py, "lib", "tcl*")) + glob.glob(os.path.join(py, "lib", "tk*")):
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
w = os.path.join(t, "wheels")
|
||||
subprocess.run([sys.executable, "-m", "pip", "download", "--no-deps", "--platform", "win_amd64", "--only-binary=:all:", "-d", w,
|
||||
tags = ["--platform", "win_amd64"] if plat == "windows" else ["--platform", "manylinux_2_17_x86_64", "--platform", "manylinux2014_x86_64", "--platform", "manylinux_2_12_x86_64"]
|
||||
subprocess.run([sys.executable, "-m", "pip", "download", "--no-deps", *tags, "--python-version", "3.12", "--only-binary=:all:", "-d", w,
|
||||
tcj["ziglang_wheel"], tcj["rabbitizer_wheel"]], check=True)
|
||||
for whl in glob.glob(os.path.join(w, "rabbitizer-*.whl")): zipfile.ZipFile(whl).extractall(os.path.join(py, "Lib", "site-packages"))
|
||||
for whl in glob.glob(os.path.join(w, "rabbitizer-*.whl")): zipfile.ZipFile(whl).extractall(site)
|
||||
for whl in glob.glob(os.path.join(w, "ziglang-*.whl")): zipfile.ZipFile(whl).extractall(os.path.join(t, "zw"))
|
||||
zg = os.path.join(t, "zig")
|
||||
shutil.move(os.path.join(t, "zw", "ziglang"), zg)
|
||||
for f in ("__init__.py", "__main__.py"):
|
||||
if os.path.exists(os.path.join(zg, f)): os.remove(os.path.join(zg, f))
|
||||
# the C library sources and headers of other systems are not needed for x86-windows-gnu
|
||||
if plat == "linux":
|
||||
os.chmod(os.path.join(zg, "zig"), 0o755)
|
||||
# symlinks would be stored as second copies, and the binaries carry debug symbols (hundreds of MB)
|
||||
for root, dirs, files in os.walk(py):
|
||||
for f in files:
|
||||
q = os.path.join(root, f)
|
||||
if os.path.islink(q) and f.startswith("libpython"): os.remove(q) # (libpython3.12.so: for linking against it)
|
||||
for q in glob.glob(os.path.join(py, "lib", "libpython3*.so*")) + [os.path.join(zg, "zig")]:
|
||||
subprocess.run(["strip", "--strip-unneeded", q], check=True)
|
||||
# the C library sources and headers of other systems: x86-windows-gnu needs mingw, x86-linux-gnu glibc
|
||||
keep_libc = ("mingw", "include") if plat == "windows" else ("glibc", "include", "include-linux")
|
||||
lc = os.path.join(zg, "lib", "libc")
|
||||
for d in os.listdir(lc):
|
||||
if d in ("glibc", "musl", "darwin", "wasi", "freebsd", "netbsd", "openbsd"): shutil.rmtree(os.path.join(lc, d))
|
||||
if d in ("glibc", "musl", "darwin", "wasi", "freebsd", "netbsd", "openbsd", "mingw") and d not in keep_libc: shutil.rmtree(os.path.join(lc, d))
|
||||
inc = os.path.join(lc, "include")
|
||||
for d in os.listdir(inc):
|
||||
if "windows" not in d: shutil.rmtree(os.path.join(inc, d))
|
||||
want = "windows" in d if plat == "windows" else (d.endswith(("linux-gnu", "linux-any", "-glibc")) or d in ("generic-glibc", "any-linux-any", "x86-linux-any", "x86-linux-gnu"))
|
||||
if not want: shutil.rmtree(os.path.join(inc, d))
|
||||
for d in ("libcxx", "libcxxabi", "libunwind", "libtsan", "docs", "build-web", "init"):
|
||||
shutil.rmtree(os.path.join(zg, "lib", d), ignore_errors=True)
|
||||
with zipfile.ZipFile(tcz, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
|
||||
add_tree(z, py, "python", set())
|
||||
add_tree(z, zg, "zig", set())
|
||||
print("toolchain:", tcz, os.path.getsize(tcz) // (1 << 20), "MB")
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
|
||||
add_tree(z, py, "python", set(), modes=plat == "linux")
|
||||
add_tree(z, zg, "zig", set(), modes=plat == "linux")
|
||||
|
||||
|
||||
tc = {}
|
||||
for plat, name, reuse in (("windows", "toolchain-win64.zip", a.toolchain_zip), ("linux", "toolchain-linux64.zip", a.toolchain_linux_zip)):
|
||||
tc[plat] = os.path.join(out, name)
|
||||
if reuse:
|
||||
if os.path.abspath(reuse) != os.path.abspath(tc[plat]): shutil.copyfile(reuse, tc[plat])
|
||||
else:
|
||||
make_toolchain(plat, tc[plat])
|
||||
print("toolchain (%s):" % plat, tc[plat], os.path.getsize(tc[plat]) // (1 << 20), "MB")
|
||||
|
||||
# what the launcher reads (a flat object: it is read without a JSON library)
|
||||
man = {
|
||||
@@ -112,7 +140,8 @@ man = {
|
||||
"version": a.version,
|
||||
"levels": a.levels,
|
||||
"kit_file": os.path.basename(kit), "kit_size": os.path.getsize(kit), "kit_sha256": sha256(kit),
|
||||
"toolchain_file": os.path.basename(tcz), "toolchain_size": os.path.getsize(tcz), "toolchain_sha256": sha256(tcz),
|
||||
"toolchain_file": os.path.basename(tc["windows"]), "toolchain_size": os.path.getsize(tc["windows"]), "toolchain_sha256": sha256(tc["windows"]),
|
||||
"toolchain_linux_file": os.path.basename(tc["linux"]), "toolchain_linux_size": os.path.getsize(tc["linux"]), "toolchain_linux_sha256": sha256(tc["linux"]),
|
||||
"toolchain_version": tcj["version"],
|
||||
}
|
||||
with open(os.path.join(out, "%s.json" % game), "w") as f: json.dump(man, f, indent=1)
|
||||
|
||||
Binary file not shown.
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"game": "rac1",
|
||||
"version": "2026.10.14.1",
|
||||
"version": "2026.10.14.2",
|
||||
"levels": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18",
|
||||
"kit_file": "rac1-build.zip",
|
||||
"kit_size": 5125429,
|
||||
"kit_sha256": "b6919dd6d31d1ae8fac65c2859eaa6c084e98b97af36e3a305b4253da40ba5c5",
|
||||
"kit_size": 5131215,
|
||||
"kit_sha256": "440ee0db6288b4f10ac13806ca24a352b7331cebf06e91335f5c7e469dd26ba8",
|
||||
"toolchain_file": "toolchain-win64.zip",
|
||||
"toolchain_size": 84006129,
|
||||
"toolchain_sha256": "2d56894833ca4066faf5ac008d3fa06aa40e2bb1d1fcb8f5bd26f28a70382b63",
|
||||
"toolchain_linux_file": "toolchain-linux64.zip",
|
||||
"toolchain_linux_size": 83952276,
|
||||
"toolchain_linux_sha256": "b6502c5c606b28cfda4587b6818084e8f8304a52f7d906408b70f664602e50bf",
|
||||
"toolchain_version": "py3.12.7-zig0.16.0-rabbitizer1.16.2"
|
||||
}
|
||||
Binary file not shown.
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"comment": "What toolchain-win64.zip is made of (release/make_release.py fetches these when it has no toolchain zip to reuse). All are redistributable: CPython (PSF licence), zig (MIT), rabbitizer (MIT).",
|
||||
"comment": "What toolchain-win64.zip and toolchain-linux64.zip are made of (release/make_release.py fetches these when it has no toolchain zip to reuse). All are redistributable: CPython (PSF licence), zig (MIT), rabbitizer (MIT).",
|
||||
"version": "py3.12.7-zig0.16.0-rabbitizer1.16.2",
|
||||
"python": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-pc-windows-msvc-install_only.tar.gz",
|
||||
"python": {
|
||||
"windows": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-pc-windows-msvc-install_only.tar.gz",
|
||||
"linux": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz"
|
||||
},
|
||||
"ziglang_wheel": "ziglang==0.16.0",
|
||||
"rabbitizer_wheel": "rabbitizer==1.16.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user