mirror of
https://github.com/MrLowPr/overdrive.git
synced 2026-10-19 00:01:19 +02:00
862 lines
41 KiB
C++
862 lines
41 KiB
C++
// Copyright (C) 2026 half144 and PS5 React contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Additional attribution term: see LICENSE-ATTRIBUTION.
|
|
#include "network.hpp"
|
|
#include "image_loader.hpp"
|
|
#include "archives.hpp"
|
|
#include "packages.hpp"
|
|
#include "removals.hpp"
|
|
#include "browser_capture.hpp"
|
|
#include "lan.hpp"
|
|
#include "app_config.hpp"
|
|
#define SDL_MAIN_HANDLED
|
|
#include <SDL2/SDL.h>
|
|
#ifdef __APPLE__
|
|
#include <OpenGL/gl3.h>
|
|
#else
|
|
#define GL_GLEXT_PROTOTYPES
|
|
#include <SDL2/SDL_opengl.h>
|
|
#endif
|
|
|
|
extern "C" {
|
|
#include "er_runtime.h"
|
|
#include "er_scene.h"
|
|
#include "native_renderer.h"
|
|
#include "software_backend.h"
|
|
void er_register_assets(void);
|
|
}
|
|
#include "baked_sounds.h"
|
|
#include "damage_tracker.hpp"
|
|
#include "frame_stats.hpp"
|
|
#include "gl_presenter.hpp"
|
|
#include "host_api.hpp"
|
|
#include "host_platform.hpp"
|
|
#include "actions.hpp"
|
|
#include "control.hpp"
|
|
#include "input_script.hpp"
|
|
#include "js_heap.hpp"
|
|
#include "js_profiler.hpp"
|
|
#include "render_workers.hpp"
|
|
#include "screenshot.hpp"
|
|
#include "text_shaper.hpp"
|
|
#include "video_api.hpp"
|
|
#include "storage_stats.hpp"
|
|
#include "directory_records.hpp"
|
|
#include <algorithm>
|
|
#include <cerrno>
|
|
#include <climits>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <iterator>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
namespace {
|
|
// Render at twice the logical resolution so rounded edges remain smooth on Retina.
|
|
constexpr int width = PS5_REACT_WIDTH, height = PS5_REACT_HEIGHT;
|
|
|
|
std::int64_t now_us() {
|
|
static const Uint64 frequency = SDL_GetPerformanceFrequency();
|
|
const Uint64 ticks = SDL_GetPerformanceCounter();
|
|
return static_cast<std::int64_t>(ticks / frequency * 1000000 + ticks % frequency * 1000000 / frequency);
|
|
}
|
|
|
|
std::uint32_t perf_clock() { return static_cast<std::uint32_t>(now_us()); }
|
|
|
|
// Keyboard and controller bindings; triggers are axes, read separately. See docs/NAVIGATION.md.
|
|
constexpr std::pair<SDL_Keycode, HostAction> kKeyActions[] = {
|
|
{SDLK_UP, HostAction::up}, {SDLK_DOWN, HostAction::down}, {SDLK_LEFT, HostAction::left},
|
|
{SDLK_RIGHT, HostAction::right}, {SDLK_RETURN, HostAction::confirm}, {SDLK_SPACE, HostAction::confirm},
|
|
{SDLK_BACKSPACE, HostAction::back}, {SDLK_q, HostAction::l1}, {SDLK_e, HostAction::r1},
|
|
{SDLK_t, HostAction::triangle}, {SDLK_f, HostAction::square}, {SDLK_o, HostAction::options},
|
|
};
|
|
// The keys' GamepadState buttons (kButtonNames indexes). Z and C are L2 and R2: full travel, or a light
|
|
// press (kLightTravel, short of the half-travel press) while Shift is down. O is Options, P the touchpad.
|
|
constexpr std::pair<SDL_Scancode, int> kKeyButtons[] = {
|
|
{SDL_SCANCODE_UP, 0}, {SDL_SCANCODE_DOWN, 1}, {SDL_SCANCODE_LEFT, 2}, {SDL_SCANCODE_RIGHT, 3},
|
|
{SDL_SCANCODE_RETURN, 4}, {SDL_SCANCODE_SPACE, 4}, {SDL_SCANCODE_BACKSPACE, 5}, {SDL_SCANCODE_T, 6},
|
|
{SDL_SCANCODE_F, 7}, {SDL_SCANCODE_Q, 8}, {SDL_SCANCODE_E, 9}, {SDL_SCANCODE_O, 14}, {SDL_SCANCODE_P, 15},
|
|
};
|
|
constexpr float kLightTravel = 0.4f;
|
|
|
|
float key_travel(SDL_Scancode key) {
|
|
if (!SDL_GetKeyboardState(nullptr)[key]) return 0;
|
|
return SDL_GetModState() & KMOD_SHIFT ? kLightTravel : 1;
|
|
}
|
|
|
|
// Triggers are analog: an action on crossing half travel, re-armed below a quarter.
|
|
bool crossed(bool& down, float travel) {
|
|
if (!down && travel > 0.5f) return down = true;
|
|
if (down && travel < 0.25f) down = false;
|
|
return false;
|
|
}
|
|
constexpr std::pair<SDL_GameControllerButton, HostAction> kButtonActions[] = {
|
|
{SDL_CONTROLLER_BUTTON_DPAD_UP, HostAction::up}, {SDL_CONTROLLER_BUTTON_DPAD_DOWN, HostAction::down},
|
|
{SDL_CONTROLLER_BUTTON_DPAD_LEFT, HostAction::left}, {SDL_CONTROLLER_BUTTON_DPAD_RIGHT, HostAction::right},
|
|
{SDL_CONTROLLER_BUTTON_A, HostAction::confirm}, {SDL_CONTROLLER_BUTTON_B, HostAction::back},
|
|
{SDL_CONTROLLER_BUTTON_LEFTSHOULDER, HostAction::l1}, {SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, HostAction::r1},
|
|
{SDL_CONTROLLER_BUTTON_Y, HostAction::triangle}, {SDL_CONTROLLER_BUTTON_X, HostAction::square},
|
|
{SDL_CONTROLLER_BUTTON_START, HostAction::options},
|
|
};
|
|
|
|
float stick(SDL_GameController* controller, SDL_GameControllerAxis axis) {
|
|
constexpr float deadzone = 0.16f;
|
|
const float value = std::clamp(SDL_GameControllerGetAxis(controller, axis) / 32767.0f, -1.0f, 1.0f);
|
|
if (std::abs(value) < deadzone) return 0;
|
|
return std::copysign((std::abs(value) - deadzone) / (1 - deadzone), value);
|
|
}
|
|
|
|
GamepadState read_gamepad(SDL_GameController* controller) {
|
|
GamepadState state;
|
|
// Indexes follow kButtonNames; l2/r2 come from the triggers below.
|
|
constexpr int buttons[] = {
|
|
SDL_CONTROLLER_BUTTON_DPAD_UP, SDL_CONTROLLER_BUTTON_DPAD_DOWN, SDL_CONTROLLER_BUTTON_DPAD_LEFT,
|
|
SDL_CONTROLLER_BUTTON_DPAD_RIGHT, SDL_CONTROLLER_BUTTON_A, SDL_CONTROLLER_BUTTON_B, SDL_CONTROLLER_BUTTON_Y,
|
|
SDL_CONTROLLER_BUTTON_X, SDL_CONTROLLER_BUTTON_LEFTSHOULDER, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, -1, -1,
|
|
SDL_CONTROLLER_BUTTON_LEFTSTICK, SDL_CONTROLLER_BUTTON_RIGHTSTICK, SDL_CONTROLLER_BUTTON_START,
|
|
SDL_CONTROLLER_BUTTON_TOUCHPAD,
|
|
};
|
|
if (controller && SDL_GameControllerGetAttached(controller)) {
|
|
state.connected = true;
|
|
state.left_x = stick(controller, SDL_CONTROLLER_AXIS_LEFTX);
|
|
state.left_y = stick(controller, SDL_CONTROLLER_AXIS_LEFTY);
|
|
state.right_x = stick(controller, SDL_CONTROLLER_AXIS_RIGHTX);
|
|
state.right_y = stick(controller, SDL_CONTROLLER_AXIS_RIGHTY);
|
|
state.l2 = std::max(0.0f, SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) / 32767.0f);
|
|
state.r2 = std::max(0.0f, SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) / 32767.0f);
|
|
for (std::uint32_t i = 0; i < std::size(buttons); ++i)
|
|
if (buttons[i] >= 0 && SDL_GameControllerGetButton(controller, static_cast<SDL_GameControllerButton>(buttons[i])))
|
|
state.buttons |= 1u << i;
|
|
if (state.l2 > 0.5f) state.buttons |= 1u << 10;
|
|
if (state.r2 > 0.5f) state.buttons |= 1u << 11;
|
|
}
|
|
const Uint8* keys = SDL_GetKeyboardState(nullptr);
|
|
for (const auto& [key, button] : kKeyButtons)
|
|
if (keys[key]) state.buttons |= 1u << button;
|
|
state.l2 = std::max(state.l2, key_travel(SDL_SCANCODE_Z));
|
|
state.r2 = std::max(state.r2, key_travel(SDL_SCANCODE_C));
|
|
if (state.l2 > 0.5f) state.buttons |= 1u << 10;
|
|
if (state.r2 > 0.5f) state.buttons |= 1u << 11;
|
|
return state;
|
|
}
|
|
|
|
struct Host {
|
|
SDL_Window* window = nullptr;
|
|
SDL_GLContext context = nullptr;
|
|
SDL_GameController* controller = nullptr;
|
|
GlPresenter presenter;
|
|
FrameStats stats;
|
|
bool log_frames = true, running = true, runtime_started = false, backend_started = false;
|
|
// The interactive loop leaves a frame that changed nothing unpresented, as the PS5 host does; the
|
|
// self-tests read back every frame. exposed forces the next frame out after the window reappears.
|
|
bool skip_still = false, exposed = false;
|
|
int render_worker_count = 1;
|
|
Uint32 previous_tick = 0, next_repeat = 0, next_image_stats = 0;
|
|
// When the last frame callback ran: the next one advances by the whole frame period, swap wait included.
|
|
std::int64_t previous_step_us = 0;
|
|
// The monitor's frame interval, for picking video frames by display frame.
|
|
std::int64_t display_frame_us = 16667;
|
|
std::string image_stats;
|
|
const char* held_action = nullptr;
|
|
InputScript script;
|
|
Control control;
|
|
bool controlled = false;
|
|
struct Wait { int client; Uint32 until; };
|
|
std::vector<Wait> waits;
|
|
Uint32 slow_frame_us = 33000;
|
|
SDL_Scancode held_key = SDL_SCANCODE_UNKNOWN;
|
|
SDL_GameControllerButton held_button = SDL_CONTROLLER_BUTTON_INVALID;
|
|
GcScheduler gc_scheduler{now_us};
|
|
JsProfiler profiler{now_us};
|
|
bool l2_down = false, r2_down = false, key_l2_down = false, key_r2_down = false;
|
|
VirtualPad virtual_pad;
|
|
|
|
~Host() {
|
|
ps5_react_stop_images();
|
|
archives::stop();
|
|
browser_capture::stop();
|
|
lan::stop();
|
|
packages::stop();
|
|
removals::stop();
|
|
network::stop();
|
|
video_layers::stop();
|
|
if (runtime_started) er_runtime_shutdown();
|
|
text_shaper::shutdown();
|
|
ps5_react_stop_sound();
|
|
render_workers::stop();
|
|
if (backend_started) er_software_backend_destroy();
|
|
presenter.release(); // GL objects must be deleted before their context.
|
|
desktop_set_controller(nullptr);
|
|
if (controller) SDL_GameControllerClose(controller);
|
|
if (context) SDL_GL_DeleteContext(context);
|
|
if (window) SDL_DestroyWindow(window);
|
|
SDL_Quit();
|
|
}
|
|
|
|
bool start() {
|
|
SDL_SetMainReady();
|
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) return false;
|
|
if (SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4) ||
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1) ||
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE) ||
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG) ||
|
|
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1)) return false;
|
|
window = SDL_CreateWindow(PS5_REACT_NAME,
|
|
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width/2, height/2,
|
|
SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
|
|
if (!window || !(context = SDL_GL_CreateContext(window))) return false;
|
|
SDL_GL_SetSwapInterval(1);
|
|
if (SDL_DisplayMode mode; SDL_GetWindowDisplayMode(window, &mode) == 0 && mode.refresh_rate > 0)
|
|
display_frame_us = 1000000 / mode.refresh_rate;
|
|
std::printf("GL %s / %s\n", glGetString(GL_VERSION), glGetString(GL_RENDERER));
|
|
for (int i = 0; i < SDL_NumJoysticks(); ++i)
|
|
if (SDL_IsGameController(i) && (controller = SDL_GameControllerOpen(i))) break;
|
|
std::printf("Physical controller: %s\n", controller ? SDL_GameControllerName(controller) : "not connected");
|
|
desktop_set_controller(controller);
|
|
previous_tick = SDL_GetTicks();
|
|
previous_step_us = now_us();
|
|
er_perf_set_clock(perf_clock);
|
|
return presenter.init(width, height);
|
|
}
|
|
|
|
bool boot(const char* path) {
|
|
backend_started = er_software_backend_init(width, height);
|
|
if (!backend_started || !damage_tracker_install(width, height)) return false;
|
|
// The presenter draws qualifying <Image layer> images itself; PS5_REACT_LAYERS=0 rasterizes them all.
|
|
const char* layers = std::getenv("PS5_REACT_LAYERS");
|
|
er_set_layers_enabled((!layers || std::atoi(layers) != 0) && presenter.layers_supported());
|
|
// PS5_REACT_RENDER_WORKERS=1 renders single-core, for comparisons.
|
|
const char* workers = std::getenv("PS5_REACT_RENDER_WORKERS");
|
|
render_worker_count = render_workers::start(workers && std::atoi(workers) > 0 ? std::atoi(workers)
|
|
: render_workers::available_cpus());
|
|
std::printf("[PS5-REACT] render workers=%d of %d, cpus=%d\n", render_worker_count, render_workers::max_workers(),
|
|
render_workers::available_cpus());
|
|
if (network::start()) ps5_react_start_images();
|
|
ps5_react_start_sound();
|
|
ErRuntimeConfig cfg = {};
|
|
cfg.screen_width = width; cfg.screen_height = height; cfg.screen_scale = 2;
|
|
cfg.memory_limit = kJsMemoryLimit;
|
|
cfg.malloc_functions = js_heap_functions();
|
|
cfg.max_stack_size = 1024 * 1024;
|
|
cfg.install_host_globals = ps5_react_install_host_api;
|
|
runtime_started = er_runtime_init(&cfg);
|
|
if (!runtime_started) return false;
|
|
// PS5_REACT_PROFILE=<file> samples JavaScript; the folded stacks are written there on exit.
|
|
if (std::getenv("PS5_REACT_PROFILE")) profiler.start(JS_GetRuntime(er_runtime_context()), 1000);
|
|
if (controlled) {
|
|
// Read when the bundle loads: Text reports its content to the inspector only then.
|
|
JSContext* ctx = er_runtime_context();
|
|
JSValue global = JS_GetGlobalObject(ctx);
|
|
JS_SetPropertyStr(ctx, global, "__ps5ReactInspecting", JS_TRUE);
|
|
JS_FreeValue(ctx, global);
|
|
}
|
|
er_register_assets();
|
|
text_shaper::install(PS5_REACT_FONT_DIR, [](const char* line) { std::printf("[PS5-REACT] %s\n", line); });
|
|
FILE* file = std::fopen(path, "rb");
|
|
if (!file) return false;
|
|
bool ok = std::fseek(file, 0, SEEK_END) == 0;
|
|
const long size = ok ? std::ftell(file) : -1;
|
|
ok = size >= 0 && std::fseek(file, 0, SEEK_SET) == 0;
|
|
const std::size_t length = ok ? static_cast<std::size_t>(size) : 0;
|
|
// QuickJS's source parser requires a trailing NUL beyond the supplied length.
|
|
std::vector<char> source(length + 1, '\0');
|
|
if (ok) ok = std::fread(source.data(), 1, length, file) == length;
|
|
std::fclose(file);
|
|
return ok && er_runtime_load_source(source.data(), length, path);
|
|
}
|
|
|
|
const char* hold(const char* action, SDL_Scancode key,
|
|
SDL_GameControllerButton button = SDL_CONTROLLER_BUTTON_INVALID) {
|
|
held_action = action; held_key = key; held_button = button;
|
|
next_repeat = SDL_GetTicks() + 350;
|
|
return action;
|
|
}
|
|
|
|
// Calls the runtime's input entry `name` with `argc` arguments, which it frees.
|
|
bool call_input(const char* name, int argc, JSValue* argv) {
|
|
gc_scheduler.input(now_us());
|
|
er_perf_phase_begin(ER_PERF_PHASE_JS);
|
|
JSContext* ctx = er_runtime_context();
|
|
JSValue global = JS_GetGlobalObject(ctx);
|
|
JSValue fn = JS_GetPropertyStr(ctx, global, name);
|
|
JSValue result = JS_Call(ctx, fn, global, argc, argv);
|
|
const bool ok = !JS_IsException(result);
|
|
if (!ok) {
|
|
JSValue error = JS_GetException(ctx);
|
|
const char* message = JS_ToCString(ctx, error);
|
|
std::fprintf(stderr, "Input failed: %s\n", message ? message : "JS exception");
|
|
if (message) JS_FreeCString(ctx, message);
|
|
JS_FreeValue(ctx, error);
|
|
}
|
|
JS_FreeValue(ctx, result);
|
|
for (int i = 0; i < argc; ++i) JS_FreeValue(ctx, argv[i]);
|
|
JS_FreeValue(ctx, fn); JS_FreeValue(ctx, global);
|
|
er_perf_phase_end(ER_PERF_PHASE_JS);
|
|
return ok;
|
|
}
|
|
|
|
bool dispatch(const char* action) {
|
|
JSValue arg = JS_NewString(er_runtime_context(), action);
|
|
return call_input("__ps5ReactDispatch", 1, &arg);
|
|
}
|
|
|
|
// A controller touchpad's first finger at (x, y) 0..1, or lifted.
|
|
bool touch(bool down, float x, float y) {
|
|
JSContext* ctx = er_runtime_context();
|
|
JSValue args[2] = {down ? JS_NewFloat64(ctx, x) : JS_NULL, down ? JS_NewFloat64(ctx, y) : JS_NULL};
|
|
return call_input("__ps5ReactTouch", 2, args);
|
|
}
|
|
|
|
// A development hook's result as text: strings as they are, anything else through JSON.
|
|
std::string call_js(const char* name, const char* arg) {
|
|
JSContext* ctx = er_runtime_context();
|
|
JSValue global = JS_GetGlobalObject(ctx);
|
|
JSValue fn = JS_GetPropertyStr(ctx, global, name);
|
|
JSValue argument = arg ? JS_NewString(ctx, arg) : JS_UNDEFINED;
|
|
JSValue result = JS_Call(ctx, fn, global, arg ? 1 : 0, &argument);
|
|
std::string text;
|
|
if (JS_IsException(result)) {
|
|
JSValue error = JS_GetException(ctx);
|
|
const char* message = JS_ToCString(ctx, error);
|
|
text = std::string("error: ") + (message ? message : "JS exception");
|
|
if (message) JS_FreeCString(ctx, message);
|
|
JS_FreeValue(ctx, error);
|
|
} else {
|
|
JSValue json = JS_IsString(result) ? JS_DupValue(ctx, result) : JS_JSONStringify(ctx, result, JS_UNDEFINED, JS_UNDEFINED);
|
|
if (const char* value = JS_ToCString(ctx, json)) { text = value; JS_FreeCString(ctx, value); }
|
|
JS_FreeValue(ctx, json);
|
|
}
|
|
JS_FreeValue(ctx, result); JS_FreeValue(ctx, argument);
|
|
JS_FreeValue(ctx, fn); JS_FreeValue(ctx, global);
|
|
return text;
|
|
}
|
|
|
|
// Commands from tools/ps5_drive.py, a few a frame so a busy client cannot stall rendering.
|
|
bool serve_control() {
|
|
const Uint32 ticks = SDL_GetTicks();
|
|
std::erase_if(waits, [&](const Wait& wait) {
|
|
if (!SDL_TICKS_PASSED(ticks, wait.until)) return false;
|
|
Control::reply(wait.client, "ok");
|
|
return true;
|
|
});
|
|
for (int served = 0; served < 4; ++served) {
|
|
int client = -1;
|
|
const std::string line = control.next(client);
|
|
if (client < 0) return true;
|
|
const std::size_t space = line.find(' ');
|
|
const std::string verb = line.substr(0, space), arg = space == std::string::npos ? "" : line.substr(space + 1);
|
|
if (verb == "press") {
|
|
const bool known = std::any_of(std::begin(kActionNames), std::end(kActionNames),
|
|
[&](const char* name) { return arg == name; });
|
|
if (known && !dispatch(arg.c_str())) return false;
|
|
Control::reply(client, known ? "ok" : "unknown action: " + arg);
|
|
} else if (verb == "hold" || verb == "release" || verb == "trigger") {
|
|
std::string step = line;
|
|
std::replace(step.begin(), step.end(), ' ', ':');
|
|
char error[160];
|
|
Control::reply(client, virtual_pad.apply(step, SDL_GetTicks64(), error, sizeof error) ? "ok" : std::string(error));
|
|
} else if (verb == "shot") {
|
|
// `shot <path> full` keeps the framebuffer's full resolution, for images meant to be shown.
|
|
const bool full = arg.size() > 5 && arg.ends_with(" full");
|
|
const std::string path = full ? arg.substr(0, arg.size() - 5) : arg;
|
|
Control::reply(client, save_screenshot(path.c_str(), full ? 1 : 2) ? path : std::string("error: ") + std::strerror(errno));
|
|
} else if (verb == "snapshot") {
|
|
Control::reply(client, call_js("__ps5ReactInspect", nullptr));
|
|
} else if (verb == "focus") {
|
|
Control::reply(client, call_js("__ps5ReactFocus", arg.c_str()) == "true" ? "ok" : "not found: " + arg);
|
|
} else if (verb == "script") {
|
|
// Runs input-script steps from now on, e.g. a touchpad swipe.
|
|
char error[512] = {};
|
|
Control::reply(client, script.parse(arg.c_str(), error, sizeof error) ? "ok" : std::string("error: ") + error);
|
|
} else if (verb == "logs") {
|
|
Control::reply(client, control.take_logs());
|
|
} else if (verb == "wait") {
|
|
waits.push_back({client, ticks + static_cast<Uint32>(std::max(0, std::atoi(arg.c_str())))});
|
|
} else if (verb == "quit") {
|
|
running = false;
|
|
Control::reply(client, "ok");
|
|
} else {
|
|
Control::reply(client, "unknown command: " + verb);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Runs the scripted action that is due; it goes through the same dispatch as keys.
|
|
bool play_script() {
|
|
const char* action = script.next(SDL_GetTicks64());
|
|
if (!action) return true;
|
|
if (log_frames) std::printf("[PS5-REACT] script: %s\n", action);
|
|
if (!std::strcmp(action, "quit")) running = false;
|
|
else if (VirtualPad::is_step(action)) {
|
|
char error[160];
|
|
if (!virtual_pad.apply(action, SDL_GetTicks64(), error, sizeof error)) std::fprintf(stderr, "script: %s\n", error);
|
|
} else if (!std::strncmp(action, "shot:", 5)) {
|
|
// The preview keeps shots in the sandbox's temporary folder rather than the app's sources.
|
|
char path[PATH_MAX];
|
|
const std::string name = std::string("/temp0/") + (action + 5) + ".bmp";
|
|
const bool saved = host::resolve_path(name.c_str(), path, sizeof path) && save_screenshot(path);
|
|
std::printf("[PS5-REACT] shot %s: %s\n", path, saved ? "saved" : std::strerror(errno));
|
|
} else if (!std::strncmp(action, "touch:", 6)) {
|
|
float x = 0, y = 0;
|
|
return touch(std::sscanf(action + 6, "%f,%f", &x, &y) == 2, x, y);
|
|
} else return dispatch(action);
|
|
return true;
|
|
}
|
|
|
|
bool frame(bool swap = true) {
|
|
if (const char* line = stats.start_frame(now_us()); line && log_frames) {
|
|
std::printf("[PS5-REACT] %s\n", line);
|
|
std::fflush(stdout); // The dev watcher reads a pipe, where stdout is fully buffered.
|
|
}
|
|
// Input and JavaScript commit layout only; the commit after them paints the frame once.
|
|
er_set_raster_deferred(true);
|
|
SDL_Event event;
|
|
while (SDL_PollEvent(&event)) {
|
|
const char* action = nullptr;
|
|
if (event.type == SDL_QUIT) running = false;
|
|
if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_EXPOSED) exposed = true;
|
|
// The host repeats held directions itself, so OS key repeats are ignored.
|
|
if (event.type == SDL_KEYDOWN && !event.key.repeat) {
|
|
const SDL_Scancode key = event.key.keysym.scancode;
|
|
// Escape stands for the PS button: it leaves. O is Options.
|
|
if (event.key.keysym.sym == SDLK_ESCAPE) running = false;
|
|
for (const auto& [code, mapped] : kKeyActions) {
|
|
if (code == event.key.keysym.sym) action = repeats(mapped) ? hold(action_name(mapped), key) : action_name(mapped);
|
|
}
|
|
} else if (event.type == SDL_CONTROLLERBUTTONDOWN) {
|
|
const auto button = static_cast<SDL_GameControllerButton>(event.cbutton.button);
|
|
for (const auto& [code, mapped] : kButtonActions) {
|
|
if (code == button)
|
|
action = repeats(mapped) ? hold(action_name(mapped), SDL_SCANCODE_UNKNOWN, button) : action_name(mapped);
|
|
}
|
|
} else if ((event.type == SDL_CONTROLLERTOUCHPADDOWN || event.type == SDL_CONTROLLERTOUCHPADMOTION ||
|
|
event.type == SDL_CONTROLLERTOUCHPADUP) && event.ctouchpad.touchpad == 0 && event.ctouchpad.finger == 0) {
|
|
if (!touch(event.type != SDL_CONTROLLERTOUCHPADUP, event.ctouchpad.x, event.ctouchpad.y)) return false;
|
|
} else if (event.type == SDL_CONTROLLERAXISMOTION) {
|
|
const auto trigger = [&](bool& down, const char* name) {
|
|
if (crossed(down, event.caxis.value / 32767.0f)) action = name;
|
|
};
|
|
if (event.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERLEFT) trigger(l2_down, action_name(HostAction::l2));
|
|
else if (event.caxis.axis == SDL_CONTROLLER_AXIS_TRIGGERRIGHT) trigger(r2_down, action_name(HostAction::r2));
|
|
}
|
|
if (action && !dispatch(action)) return false;
|
|
}
|
|
if (!play_script()) return false;
|
|
if (controlled && !serve_control()) return false;
|
|
if (held_action) {
|
|
// Polled state, not key-up events: synthetic self-test presses never stay held.
|
|
const bool down = held_key != SDL_SCANCODE_UNKNOWN ? SDL_GetKeyboardState(nullptr)[held_key]
|
|
: controller && SDL_GameControllerGetButton(controller, held_button);
|
|
const Uint32 ticks = SDL_GetTicks();
|
|
if (!down) held_action = nullptr;
|
|
else if (SDL_TICKS_PASSED(ticks, next_repeat)) {
|
|
next_repeat = ticks + 110;
|
|
if (!dispatch(held_action)) return false;
|
|
}
|
|
}
|
|
if (!running) return true;
|
|
if (crossed(key_l2_down, key_travel(SDL_SCANCODE_Z)) && !dispatch(action_name(HostAction::l2))) return false;
|
|
if (crossed(key_r2_down, key_travel(SDL_SCANCODE_C)) && !dispatch(action_name(HostAction::r2))) return false;
|
|
GamepadState state = read_gamepad(controller);
|
|
// What a tool holds presses like the controller: the buttons' actions, once (directions do not repeat).
|
|
for (std::uint32_t pressed = virtual_pad.merge(state, SDL_GetTicks64()), i = 0; pressed; pressed >>= 1, ++i) {
|
|
const char* held = pressed & 1 ? VirtualPad::action(i) : nullptr;
|
|
if (held && !dispatch(held)) return false;
|
|
}
|
|
ps5_react_set_gamepad(state);
|
|
stats.lap(FrameStats::input, now_us());
|
|
er_perf_phase_begin(ER_PERF_PHASE_JS);
|
|
er_runtime_pump();
|
|
const std::int64_t step_us = now_us();
|
|
const bool stepped = ps5_react_frame(er_runtime_context(), std::min(50.0, (step_us - previous_step_us) / 1000.0));
|
|
previous_step_us = step_us;
|
|
er_perf_phase_end(ER_PERF_PHASE_JS);
|
|
if (!stepped) {
|
|
std::fprintf(stderr, "Frame callback failed\n");
|
|
return false;
|
|
}
|
|
er_set_raster_deferred(false);
|
|
er_commit();
|
|
stats.lap(FrameStats::update, now_us());
|
|
if (ps5_react_exit_requested()) {
|
|
running = false;
|
|
return true;
|
|
}
|
|
int sw = 0, sh = 0;
|
|
SDL_GL_GetDrawableSize(window, &sw, &sh);
|
|
const std::size_t repainted = damage_tracker_area();
|
|
const bool still = skip_still && !exposed && sw > 0 && sh > 0 &&
|
|
presenter.unchanged(damage_tracker_rects(), damage_tracker_moves(), sw, sh);
|
|
exposed = false;
|
|
if (sw > 0 && sh > 0 && !still) {
|
|
er_perf_phase_begin(ER_PERF_PHASE_PRESENT);
|
|
int layer_count = 0;
|
|
const ERLayer* layers = er_get_layers(&layer_count);
|
|
presenter.set_layers({layers, static_cast<std::size_t>(layer_count)});
|
|
video_layers::update(now_us(), display_frame_us);
|
|
if (!presenter.draw(er_software_framebuffer(), damage_tracker_rects(), damage_tracker_moves(), sw, sh)) return false;
|
|
er_perf_phase_end(ER_PERF_PHASE_PRESENT);
|
|
{
|
|
const GlPresenter::Timing& t = presenter.last_timing();
|
|
stats.note_present(t.upload_us, t.draw_us, t.upload_bytes);
|
|
}
|
|
}
|
|
if (sw > 0 && sh > 0) damage_tracker_clear();
|
|
stats.lap(FrameStats::present, now_us());
|
|
if (still) {
|
|
SDL_DisplayMode mode{};
|
|
SDL_Delay(1000 / (SDL_GetWindowDisplayMode(window, &mode) == 0 && mode.refresh_rate > 0 ? mode.refresh_rate : 60));
|
|
} else if (swap) SDL_GL_SwapWindow(window);
|
|
stats.lap(FrameStats::swap, now_us());
|
|
if (const char* line = gc_scheduler.frame(JS_GetRuntime(er_runtime_context()), now_us(), repainted,
|
|
static_cast<std::size_t>(width) * height);
|
|
line && log_frames)
|
|
std::printf("[PS5-REACT] %s\n", line);
|
|
const Uint32 now = SDL_GetTicks();
|
|
embedded_renderer_tick(std::min<Uint32>(now - previous_tick, 50));
|
|
previous_tick = now;
|
|
if (const char* line = stats.end_frame(slow_frame_us); line && log_frames) {
|
|
std::printf("[PS5-REACT] %s\n", line);
|
|
std::fflush(stdout);
|
|
}
|
|
if (const int hz = ps5_react_take_refresh_request()) {
|
|
// The preview keeps the monitor's rate, but rebuilds the presenter as the PS5 host must, so that
|
|
// path runs here too, and reports the requested rate as the one the display accepted.
|
|
presenter.release();
|
|
if (!presenter.init(width, height)) return false;
|
|
ps5_react_set_refresh_rate(hz);
|
|
std::printf("[PS5-REACT] refresh switch to %d Hz (the preview keeps the monitor's rate)\n", hz);
|
|
std::fflush(stdout);
|
|
}
|
|
// The PS5 host prints this with its memory line; here every two seconds while it changes.
|
|
if (SDL_TICKS_PASSED(now, next_image_stats)) {
|
|
next_image_stats = now + 2000;
|
|
if (std::string line = images::stats(); line != image_stats) {
|
|
std::printf("[PS5-REACT] images: %s\n", line.c_str());
|
|
std::fflush(stdout);
|
|
image_stats = std::move(line);
|
|
}
|
|
}
|
|
return !*er_runtime_last_error();
|
|
}
|
|
|
|
bool snapshot(const char* path) {
|
|
if (!frame(false)) return false;
|
|
int sw = 0, sh = 0;
|
|
SDL_GL_GetDrawableSize(window, &sw, &sh);
|
|
if (sw <= 0 || sh <= 0) return false;
|
|
std::vector<unsigned char> rgb(static_cast<std::size_t>(sw) * sh * 3);
|
|
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
|
glReadBuffer(GL_BACK);
|
|
glReadPixels(0, 0, sw, sh, GL_RGB, GL_UNSIGNED_BYTE, rgb.data());
|
|
if (glGetError() != GL_NO_ERROR) return false;
|
|
FILE* file = std::fopen(path, "wb");
|
|
if (!file) return false;
|
|
bool ok = std::fprintf(file, "P6\n%d %d\n255\n", sw, sh) > 0;
|
|
for (int y = sh - 1; y >= 0 && ok; --y)
|
|
ok = std::fwrite(rgb.data() + static_cast<std::size_t>(y) * sw * 3, 3, sw, file) == static_cast<std::size_t>(sw);
|
|
if (std::fclose(file) != 0) ok = false;
|
|
SDL_GL_SwapWindow(window);
|
|
return ok;
|
|
}
|
|
};
|
|
|
|
bool expect(int focus, int count, int detail) {
|
|
JSContext* ctx = er_runtime_context();
|
|
JSValue global = JS_GetGlobalObject(ctx);
|
|
JSValue state = JS_GetPropertyStr(ctx, global, "__ps5ReactTestState");
|
|
const char* fields[] = {"focus", "count", "detail"};
|
|
const int expected[] = {focus, count, detail};
|
|
bool ok = true;
|
|
for (int i = 0; i < 3; ++i) {
|
|
JSValue value = JS_GetPropertyStr(ctx, state, fields[i]);
|
|
int32_t actual = -1;
|
|
if (JS_ToInt32(ctx, &actual, value) || actual != expected[i]) {
|
|
std::fprintf(stderr, "%s: expected %d, got %d\n", fields[i], expected[i], actual);
|
|
ok = false;
|
|
}
|
|
JS_FreeValue(ctx, value);
|
|
}
|
|
JS_FreeValue(ctx, state); JS_FreeValue(ctx, global);
|
|
return ok;
|
|
}
|
|
|
|
bool color_test(Host& host) {
|
|
const std::uint32_t colors[] = {0xffff0000, 0xff00ff00, 0xff0000ff, 0xffffffff};
|
|
std::vector<std::uint32_t> pattern(width * height);
|
|
for (int y = 0; y < height; ++y)
|
|
for (int x = 0; x < width; ++x)
|
|
pattern[y*width+x] = colors[(y >= height/2 ? 2 : 0) + (x >= width/2 ? 1 : 0)];
|
|
int sw = 0, sh = 0;
|
|
SDL_GL_GetDrawableSize(host.window, &sw, &sh);
|
|
if (!host.presenter.draw(pattern.data(), sw, sh)) return false;
|
|
// Samples in the centre of each quadrant test channel order and vertical orientation.
|
|
for (int i = 0; i < 4; ++i) {
|
|
unsigned char pixel[4] = {};
|
|
glReadPixels((i%2 ? 3 : 1)*sw/4, (i/2 ? 1 : 3)*sh/4, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
|
|
const auto c = colors[i];
|
|
if (pixel[0] != ((c>>16)&255) || pixel[1] != ((c>>8)&255) ||
|
|
pixel[2] != (c&255) || pixel[3] != 255) return false;
|
|
}
|
|
return glGetError() == GL_NO_ERROR;
|
|
}
|
|
|
|
bool press(Host& host, SDL_Keycode key, int focus, int count, int detail) {
|
|
SDL_Event event = {};
|
|
event.type = SDL_KEYDOWN; event.key.keysym.sym = key;
|
|
if (SDL_PushEvent(&event) != 1) return false;
|
|
for (int i = 0; i < 4; ++i) if (!host.frame()) return false;
|
|
return expect(focus, count, detail);
|
|
}
|
|
|
|
bool storage_test() {
|
|
double total = 0, free = 0;
|
|
if (!storage::disk_bytes(4096, 100, 25, total, free) || total != 409600 || free != 102400 ||
|
|
!storage::disk_bytes(4096, 100, -2, total, free) || total != 409600 || free != 0 ||
|
|
!storage::disk_bytes(4096, 100, 120, total, free) || free != total ||
|
|
storage::disk_bytes(0, 100, 50, total, free) ||
|
|
!storage::disk_bytes(4096, UINT64_MAX, 1, total, free) ||
|
|
total <= static_cast<double>(UINT64_MAX) || free != 4096) return false;
|
|
host::MountEntry mounts[2]{};
|
|
int count = 0;
|
|
host::MountEntry mount{};
|
|
std::strcpy(mount.path, "/app0");
|
|
std::strcpy(mount.device, "/dev/test");
|
|
std::strcpy(mount.type, "nullfs");
|
|
storage::append_unique(mounts, 2, count, mount);
|
|
storage::append_unique(mounts, 2, count, mount);
|
|
if (count != 1) return false;
|
|
std::strcpy(mount.path, "/download0");
|
|
storage::append_unique(mounts, 2, count, mount);
|
|
std::strcpy(mount.path, "/temp0");
|
|
storage::append_unique(mounts, 2, count, mount);
|
|
if (count != 2 || std::strcmp(mounts[1].path, "/download0")) return false;
|
|
|
|
// Packed PS5 records can be unaligned and must never read past their bounds.
|
|
char bytes[25]{};
|
|
const auto record = [&](int offset, const char* name) {
|
|
const std::uint32_t inode = 1;
|
|
const std::uint16_t length = 12;
|
|
std::memcpy(bytes + offset, &inode, sizeof inode);
|
|
std::memcpy(bytes + offset + 4, &length, sizeof length);
|
|
bytes[offset + 7] = static_cast<char>(std::strlen(name));
|
|
std::strcpy(bytes + offset + 8, name);
|
|
};
|
|
record(1, "."); record(13, "abc");
|
|
std::vector<std::string> names;
|
|
const auto collect = [](const char* name, void* user) {
|
|
static_cast<std::vector<std::string>*>(user)->emplace_back(name);
|
|
};
|
|
if (!host::visit_directory_records(bytes + 1, 24, collect, &names) ||
|
|
names.size() != 1 || names[0] != "abc") return false;
|
|
names.clear();
|
|
if (host::visit_directory_records(bytes + 13, 7, collect, &names) ||
|
|
host::visit_directory_records(bytes + 13, 11, collect, &names)) return false;
|
|
bytes[24] = 'x'; // Missing NUL.
|
|
if (host::visit_directory_records(bytes + 13, 12, collect, &names)) return false;
|
|
bytes[24] = '\0'; bytes[17] = 0; bytes[18] = 0; // Zero record length.
|
|
if (host::visit_directory_records(bytes + 13, 12, collect, &names) || !names.empty()) return false;
|
|
// Exercise actual host bindings, sandbox path resolution, and error propagation.
|
|
constexpr char script[] = R"JS((() => {
|
|
const network = globalThis.__ps5ReactNative.network;
|
|
if (!network || !network.version().includes("libcurl") ||
|
|
!Array.isArray(network.poll())) return false;
|
|
for (const invalid of [
|
|
() => network.request("file:///tmp/example", {}),
|
|
() => network.request("https://example.com", {maxBytes: 0}),
|
|
() => network.request("https://example.com", {headers: {Host: "other.test"}}),
|
|
() => network.download("https://example.com", "/download0/a", {headers: {Range: "bytes=0-1"}}),
|
|
() => network.download("https://example.com", "/download0/a", {connections: 65}),
|
|
() => network.download("https://example.com", "/download0/a", {recoverCompleted: 'yes'}),
|
|
() => network.cancel(),
|
|
]) {
|
|
try { invalid(); return false; }
|
|
catch (error) { if (!error.message.includes("network.")) return false; }
|
|
}
|
|
const rangeProbe = network.request("http://127.0.0.1:1", {headers: {Range: "bytes=0-0"}, maxBytes: 8192});
|
|
if (!Number.isInteger(rangeProbe) || rangeProbe <= 0) return false;
|
|
network.cancel(rangeProbe);
|
|
// With any sound baked, a second play while the first still sounds is skipped.
|
|
const sound = globalThis.__ps5ReactNative.sound;
|
|
const baked = globalThis.__ps5ReactTestSound;
|
|
if (baked && (sound.play(baked, 0, 0) !== true || sound.play(baked, 0, 0) !== false)) return false;
|
|
// Baked names drop the extension, so no import is named this.
|
|
try { sound.play('not-baked.wav', 1, 0); return false; }
|
|
catch (error) { if (!error.message.includes('sound.play not-baked.wav')) return false; }
|
|
const fs = globalThis.__ps5ReactNative.fs;
|
|
const testName = `listing-test-${Date.now()}.txt`;
|
|
const testPath = `/download0/${testName}`;
|
|
fs.writeFile(testPath, 'listing test', false);
|
|
try {
|
|
const listing = fs.readDir('/download0');
|
|
const item = listing.find(entry => entry.name === testName);
|
|
if (!item || !item.isFile || item.isDirectory || item.size !== 12 ||
|
|
!Number.isFinite(item.modified)) return false;
|
|
const info = fs.stat(testPath);
|
|
if (!/^\d+$/.test(info.device) || !/^\d+$/.test(info.inode) ||
|
|
typeof info.device !== 'string' || typeof info.inode !== 'string' ||
|
|
info.device !== item.device || info.inode !== item.inode) return false;
|
|
try { fs.readDir(testPath); return false; }
|
|
catch (error) { if (!error.message.includes('fs.readDir')) return false; }
|
|
// readFile sizes its buffer from the file: exact for small, empty and multi-megabyte files.
|
|
if (fs.readFile(testPath) !== 'listing test') return false;
|
|
fs.writeFile(testPath, '', false);
|
|
if (fs.readFile(testPath) !== '') return false;
|
|
const large = 'x'.repeat(3 * 1024 * 1024 + 7);
|
|
fs.writeFile(testPath, large, false);
|
|
if (fs.readFile(testPath) !== large) return false;
|
|
fs.writeFile(testPath, 'synced', false, true);
|
|
if (fs.readFile(testPath) !== 'synced') return false;
|
|
} finally { fs.remove(testPath); }
|
|
// removeTree deletes the tree but only unlinks the symbolic link set up below, keeping its target.
|
|
fs.removeTree('/download0/remove-tree-test');
|
|
if (fs.stat('/download0/remove-tree-test') || !fs.stat('/download0/remove-tree-target/keep.txt')) return false;
|
|
fs.removeTree('/download0/remove-tree-target');
|
|
try { fs.removeTree('/download0/remove-tree-test'); return false; }
|
|
catch (error) { if (!error.message.includes('fs.removeTree')) return false; }
|
|
const usage = fs.diskUsage('/download0');
|
|
if (!Number.isFinite(usage.total) || usage.total <= 0 ||
|
|
usage.free < 0 || usage.free > usage.total) return false;
|
|
const mounts = fs.mounts();
|
|
if (!Array.isArray(mounts) || !mounts.length || mounts.length > 64 ||
|
|
!mounts.every(m => typeof m.path === 'string' && typeof m.device === 'string' &&
|
|
typeof m.type === 'string')) return false;
|
|
try { fs.diskUsage('/missing-storage-test-path'); return false; }
|
|
catch (error) {
|
|
return error.message.includes('fs.diskUsage') &&
|
|
error.message.includes('/missing-storage-test-path');
|
|
}
|
|
})())JS";
|
|
char tree[PATH_MAX], target[PATH_MAX];
|
|
if (!host::resolve_path("/download0/remove-tree-test", tree, sizeof tree) ||
|
|
!host::resolve_path("/download0/remove-tree-target", target, sizeof target)) return false;
|
|
const std::string tree_path = tree, target_path = target;
|
|
// A previous run that stopped early may have left the fixture behind.
|
|
const auto directory = [](const std::string& path) { return mkdir(path.c_str(), 0755) == 0 || errno == EEXIST; };
|
|
unlink((tree_path + "/nested/link").c_str());
|
|
std::FILE* kept = nullptr;
|
|
if (!directory(target_path) || !(kept = std::fopen((target_path + "/keep.txt").c_str(), "w")) ||
|
|
!directory(tree_path) || !directory(tree_path + "/nested") ||
|
|
symlink(target, (tree_path + "/nested/link").c_str()) != 0) {
|
|
if (kept) std::fclose(kept);
|
|
return false;
|
|
}
|
|
std::fclose(kept);
|
|
if (std::FILE* file = std::fopen((tree_path + "/nested/file.txt").c_str(), "w")) std::fclose(file);
|
|
JSContext* ctx = er_runtime_context();
|
|
// The first sound the app baked, whatever it is named; none leaves only the unknown-name check.
|
|
JSValue global = JS_GetGlobalObject(ctx);
|
|
JS_SetPropertyStr(ctx, global, "__ps5ReactTestSound",
|
|
ps5_react_sound_count ? JS_NewString(ctx, ps5_react_sounds[0].name) : JS_NULL);
|
|
JS_FreeValue(ctx, global);
|
|
JSValue result = JS_Eval(ctx, script, sizeof script - 1, "storage-test.js", JS_EVAL_TYPE_GLOBAL);
|
|
const bool ok = !JS_IsException(result) && JS_ToBool(ctx, result) == 1;
|
|
if (JS_IsException(result)) {
|
|
JSValue error = JS_GetException(ctx);
|
|
const char* message = JS_ToCString(ctx, error);
|
|
std::fprintf(stderr, "Storage test: %s\n", message ? message : "unknown exception");
|
|
JS_FreeCString(ctx, message);
|
|
JS_FreeValue(ctx, error);
|
|
}
|
|
JS_FreeValue(ctx, result);
|
|
if (ok) std::puts("PASS: bounded PS5 directory records, mount deduplication, storage bytes, sounds and desktop native bindings.");
|
|
return ok;
|
|
}
|
|
|
|
bool self_test(Host& host) {
|
|
if (!storage_test()) return false;
|
|
for (int i = 0; i < 4; ++i) if (!host.frame()) return false;
|
|
if (!expect(0, 0, 0) || !host.snapshot("texture-initial.ppm")) return false;
|
|
if (!press(host, SDLK_RETURN, 0, 1, 1) || !host.snapshot("texture-increment.ppm") ||
|
|
!press(host, SDLK_BACKSPACE, 0, 1, 0) || !press(host, SDLK_RIGHT, 1, 1, 0) ||
|
|
!press(host, SDLK_RETURN, 1, 11, 1) || !press(host, SDLK_BACKSPACE, 1, 11, 0) ||
|
|
!press(host, SDLK_RIGHT, 2, 11, 0) || !press(host, SDLK_RETURN, 2, 0, 1) ||
|
|
!press(host, SDLK_BACKSPACE, 2, 0, 0)) return false;
|
|
SDL_Event button = {};
|
|
button.type = SDL_CONTROLLERBUTTONDOWN;
|
|
button.cbutton.button = SDL_CONTROLLER_BUTTON_DPAD_RIGHT;
|
|
if (SDL_PushEvent(&button) != 1) return false;
|
|
button.cbutton.button = SDL_CONTROLLER_BUTTON_A;
|
|
if (SDL_PushEvent(&button) != 1 || !host.frame() || !expect(0, 1, 1)) return false;
|
|
if (SDL_SetWindowFullscreen(host.window, SDL_WINDOW_FULLSCREEN_DESKTOP) != 0) return false;
|
|
for (int i = 0; i < 4; ++i) if (!host.frame()) return false;
|
|
if (!(SDL_GetWindowFlags(host.window) & SDL_WINDOW_FULLSCREEN) ||
|
|
!expect(0, 1, 1) || !host.snapshot("texture-fullscreen.ppm")) return false;
|
|
std::puts("PASS: software framebuffer → OpenGL texture, channel order, orientation, React state/focus, simulated controller and fullscreen.");
|
|
// The snapshots are compared against a single-core run, which proves nothing if no commit forked.
|
|
if (host.render_worker_count > 1 && render_workers::forked_commits() == 0) {
|
|
std::fprintf(stderr, "Render workers: %d installed, but no commit rendered in parallel\n", host.render_worker_count);
|
|
return false;
|
|
}
|
|
if (host.render_worker_count > 1)
|
|
std::printf("PASS: %u commits rendered across %d workers.\n", render_workers::forked_commits(), host.render_worker_count);
|
|
return true;
|
|
}
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
if (argc < 2) {
|
|
std::fprintf(stderr, "Usage: %s bundle.js [--self-test|--fullscreen]\n", argv[0]);
|
|
return 1;
|
|
}
|
|
const bool testing = argc > 2 && !std::strcmp(argv[2], "--self-test");
|
|
if (testing) SDL_setenv("SDL_AUDIODRIVER", "dummy", 1);
|
|
Host host;
|
|
host.log_frames = !testing;
|
|
bool ok = host.start();
|
|
if (ok && testing) ok = color_test(host);
|
|
if (ok && !testing) {
|
|
if (const char* path = std::getenv("PS5_REACT_CONTROL")) {
|
|
host.controlled = host.control.start(path);
|
|
if (!host.controlled) std::fprintf(stderr, "PS5_REACT_CONTROL: cannot listen on %s: %s\n", path, std::strerror(errno));
|
|
else std::printf("[PS5-REACT] control: %s\n", path);
|
|
}
|
|
}
|
|
if (ok) ok = host.boot(argv[1]);
|
|
if (ok && testing) ok = self_test(host);
|
|
else if (ok) {
|
|
if (const char* ms = std::getenv("PS5_REACT_SLOW_FRAME_MS")) {
|
|
char* rest = nullptr;
|
|
const long value = std::strtol(ms, &rest, 10);
|
|
if (*ms && !*rest && value > 0 && value <= 60000) host.slow_frame_us = static_cast<Uint32>(value) * 1000;
|
|
else std::fprintf(stderr, "PS5_REACT_SLOW_FRAME_MS: expected milliseconds (1-60000), got '%s'\n", ms);
|
|
}
|
|
if (const char* text = std::getenv("PS5_REACT_INPUT_SCRIPT")) {
|
|
char error[160];
|
|
ok = host.script.parse(text, error, sizeof error);
|
|
if (!ok) std::fprintf(stderr, "PS5_REACT_INPUT_SCRIPT: %s\n", error);
|
|
}
|
|
if (ok && argc > 2 && !std::strcmp(argv[2], "--fullscreen")) {
|
|
ok = SDL_SetWindowFullscreen(host.window, SDL_WINDOW_FULLSCREEN_DESKTOP) == 0;
|
|
SDL_ShowCursor(SDL_DISABLE);
|
|
}
|
|
host.skip_still = true;
|
|
while (ok && host.running) ok = host.frame();
|
|
if (host.profiler.running()) {
|
|
host.profiler.stop();
|
|
const char* path = std::getenv("PS5_REACT_PROFILE");
|
|
std::printf("profile: %llu samples, %s %s\n", static_cast<unsigned long long>(host.profiler.samples()),
|
|
host.profiler.write(path) ? "saved to" : "could not write", path);
|
|
}
|
|
}
|
|
if (!ok) std::fprintf(stderr, "Texture proof failed: %s / %s\n", SDL_GetError(), er_runtime_last_error());
|
|
if (ok && desktop_relaunch_requested()) {
|
|
SDL_Quit();
|
|
execv(argv[0], argv);
|
|
std::fprintf(stderr, "relaunch: %s\n", std::strerror(errno));
|
|
}
|
|
return ok ? 0 : 1;
|
|
}
|
|
|
|
void archives::trace(const char*, const char*) {}
|
|
std::size_t archives::pipeline_bytes() { return 32 * 1024 * 1024; }
|
|
bool archives::hold_memory(bool hold) { images::hold_memory(hold); return true; }
|
|
bool archives::downloading() { return network::downloading(); }
|
|
bool archives::list_directory(const char* path, void (*visit)(const char*, void*), void* user) { return host::read_dir(path, visit, user); }
|