diff --git a/docs/lua_api.rst b/docs/lua_api.rst index f76a78c72e..feac864f77 100644 --- a/docs/lua_api.rst +++ b/docs/lua_api.rst @@ -1411,6 +1411,50 @@ Represents a collection of resources that can be loaded in group. **has_loaded** (package) : bool Returns whether the *package* has been loaded. +SaveGame +======== + +Singleton to save and load savegames. + +**save** (filename, data) : Id + Starts saving *data* to *filename* asynchronously and returns its request id. + The function returns before the file has been written. Poll for completion + with ``SaveGame.status()``. *filename* must be a filename, not a path. *data* + must be a table with string keys. Values can be bool, number, string, table, + Vector3, Vector3Box, Matrix4x4 or Matrix4x4Box. + +**load** (filename) : Id + Starts loading *filename* asynchronously and returns its request id. The + function returns before the file has been read. Poll for completion with + ``SaveGame.status()``. *filename* must be a filename, not a path. When the + request is done, the loaded table is returned in the ``data`` field of + ``SaveGame.status()``. + +**status** (request) : table + Returns a table with the current status of *request*. + + The returned table has the following fields: + + * ``done``: Whether the request has finished. + * ``progress``: Completion progress in the [0; 1] range. + * ``data``: The loaded table. This field is present only for successful load requests. + * ``error``: One of `SaveError`_, or ``nil`` if the request is done and no error has occurred. + +**free** (request) + Frees the resources held by *request*. Call this after the request is done. + After this call, *request* is invalid. + +SaveError +--------- + +* ``INVALID_REQUEST``: The request id is invalid. +* ``SAVE_DIR_UNSET``: ``save_dir`` is not configured. +* ``MISSING``: The save file does not exist. +* ``INVALID_FILENAME``: The filename is invalid. +* ``IO_ERROR``: File read/write failed. +* ``CORRUPTED``: Save data could not be parsed. +* ``UNKNOWN``: The request failed for an unknown reason. + SceneGraph ========== diff --git a/docs/reference/boot_config.rst b/docs/reference/boot_config.rst index 322f019d76..c827d7a89f 100644 --- a/docs/reference/boot_config.rst +++ b/docs/reference/boot_config.rst @@ -1,6 +1,24 @@ boot.config reference ===================== +Path templates +-------------- + +Some settings accept path templates. A path template is a path that may contain +variables in the form ``$VARNAME``. + +Supported variables: + +* ``$DATE``: The local date in ``YYYY-MM-DD`` format. +* ``$UTC_DATE``: The UTC date in ``YYYY-MM-DD`` format. +* ``$TIME``: The local time in ``HH-MM-SS`` format. +* ``$UTC_TIME``: The UTC time in ``HH-MM-SS`` format. +* ``$USER_DATA``: The user data directory. On Android, this expands to the + activity internal data path. +* ``$TMP``: The temporary directory. +* ``$RANDOM``: An 8-character random string. +* ``$OBB_PATH``: Android only. The activity OBB path. + Generic configurations ---------------------- @@ -32,7 +50,6 @@ All configurations for a given *platform* are placed under a key named *platform } } - Renderer configurations ~~~~~~~~~~~~~~~~~~~~~~~ @@ -65,3 +82,9 @@ Physics configurations A value of 4 at 60 Hz means the physics simulation is allowed to simulate up to ~0.067 seconds (4/60) worth of physics per frame. If one frame takes longer than ``max_substeps/step_frequency`` then physics will appear slowed down. +Other settings +~~~~~~~~~~~~~~ + +``save_dir = $USER_DATA/mygame`` + Sets the directory where save files will be stored. Setting the save + directory is mandatory if you plan to use the SaveGame system. diff --git a/scripts/crown-launcher.lua b/scripts/crown-launcher.lua index d20bf59f0d..99d6495ede 100644 --- a/scripts/crown-launcher.lua +++ b/scripts/crown-launcher.lua @@ -39,7 +39,9 @@ project ("crown-launcher") configuration {} files { + CROWN_DIR .. "src/core/date.cpp", CROWN_DIR .. "src/core/debug/**.cpp", + CROWN_DIR .. "src/core/environment.cpp", CROWN_DIR .. "src/core/error/**.cpp", CROWN_DIR .. "src/core/filesystem/path.cpp", CROWN_DIR .. "src/core/guid.cpp", @@ -50,6 +52,7 @@ project ("crown-launcher") CROWN_DIR .. "src/core/strings/dynamic_string.cpp", CROWN_DIR .. "src/core/strings/string_id.cpp", CROWN_DIR .. "src/core/thread/mutex.cpp", + CROWN_DIR .. "src/core/time.cpp", CROWN_DIR .. "src/device/log.cpp", CROWN_DIR .. "tools/launcher/launcher.cpp", } diff --git a/scripts/crown.lua b/scripts/crown.lua index e3299046fe..2cd0bfe3a5 100644 --- a/scripts/crown.lua +++ b/scripts/crown.lua @@ -116,6 +116,7 @@ function crown_project(_name, _kind, _defines) targetextension ".js" linkoptions { "-pthread", -- https://emscripten.org/docs/porting/pthreads.html#compiling-with-pthreads-enabled + "-lidbfs.js", "-lopenal", "-s ABORTING_MALLOC=0", "-s PTHREAD_POOL_SIZE=8", diff --git a/src/core/date.cpp b/src/core/date.cpp index ba6c0cf269..8428a3025c 100644 --- a/src/core/date.cpp +++ b/src/core/date.cpp @@ -4,6 +4,7 @@ */ #include "core/date.h" +#include #if CROWN_PLATFORM_WINDOWS #ifndef WIN32_LEAN_AND_MEAN @@ -18,82 +19,94 @@ namespace crown { namespace date { - void date(s32 &year, s32 &month, s32 &day) + void date(Date &date) { #if CROWN_PLATFORM_WINDOWS SYSTEMTIME now; GetLocalTime(&now); - year = now.wYear; - month = now.wMonth; - day = now.wDay; + date.year = now.wYear; + date.month = now.wMonth; + date.day = now.wDay; #else time_t t; struct tm now; ::time(&t); localtime_r(&t, &now); - year = now.tm_year + 1900; - month = now.tm_mon + 1; - day = now.tm_mday; + date.year = now.tm_year + 1900; + date.month = now.tm_mon + 1; + date.day = now.tm_mday; #endif } - void utc_date(s32 &year, s32 &month, s32 &day) + void utc_date(Date &date) { #if CROWN_PLATFORM_WINDOWS SYSTEMTIME now; GetSystemTime(&now); - year = now.wYear; - month = now.wMonth; - day = now.wDay; + date.year = now.wYear; + date.month = now.wMonth; + date.day = now.wDay; #else time_t t; struct tm now; ::time(&t); gmtime_r(&t, &now); - year = now.tm_year + 1900; - month = now.tm_mon + 1; - day = now.tm_mday; + date.year = now.tm_year + 1900; + date.month = now.tm_mon + 1; + date.day = now.tm_mday; #endif } - void time(s32 &hour, s32 &minutes, s32 &seconds) + void time(Time &time) { #if CROWN_PLATFORM_WINDOWS SYSTEMTIME now; GetLocalTime(&now); - hour = now.wHour; - minutes = now.wMinute; - seconds = now.wSecond; + time.hour = now.wHour; + time.minutes = now.wMinute; + time.seconds = now.wSecond; #else time_t t; struct tm now; ::time(&t); localtime_r(&t, &now); - hour = now.tm_hour; - minutes = now.tm_min; - seconds = now.tm_sec; + time.hour = now.tm_hour; + time.minutes = now.tm_min; + time.seconds = now.tm_sec; #endif } - void utc_time(s32 &hour, s32 &minutes, s32 &seconds) + void utc_time(Time &time) { #if CROWN_PLATFORM_WINDOWS SYSTEMTIME now; GetSystemTime(&now); - hour = now.wHour; - minutes = now.wMinute; - seconds = now.wSecond; + time.hour = now.wHour; + time.minutes = now.wMinute; + time.seconds = now.wSecond; #else time_t t; struct tm now; ::time(&t); gmtime_r(&t, &now); - hour = now.tm_hour; - minutes = now.tm_min; - seconds = now.tm_sec; + time.hour = now.tm_hour; + time.minutes = now.tm_min; + time.seconds = now.tm_sec; #endif } + const char *to_string(char *buf, u32 len, const Date &date) + { + stbsp_snprintf(buf, len, "%04d-%02d-%02d", date.year, date.month, date.day); + return buf; + } + + const char *to_string(char *buf, u32 len, const Time &time) + { + stbsp_snprintf(buf, len, "%02d-%02d-%02d", time.hour, time.minutes, time.seconds); + return buf; + } + } // namespace date } // namespace crown diff --git a/src/core/date.h b/src/core/date.h index 226200cfd9..871cc1910a 100644 --- a/src/core/date.h +++ b/src/core/date.h @@ -11,17 +11,37 @@ namespace crown { namespace date { + struct Date + { + s32 year; + s32 month; + s32 day; + }; + + struct Time + { + s32 hour; + s32 minutes; + s32 seconds; + }; + /// - void date(s32 &year, s32 &month, s32 &day); + void date(Date &date); /// - void utc_date(s32 &year, s32 &month, s32 &day); + void utc_date(Date &date); /// - void time(s32 &hour, s32 &minutes, s32 &seconds); + void time(Time &time); /// - void utc_time(s32 &hour, s32 &minutes, s32 &seconds); + void utc_time(Time &time); + + /// Formats @a date as YYYY-MM-DD. + const char *to_string(char *buf, u32 len, const Date &date); + + /// Formats @a time as HH-MM-SS. + const char *to_string(char *buf, u32 len, const Time &time); } // namespace date diff --git a/src/core/filesystem/path.cpp b/src/core/filesystem/path.cpp index c7e6116d09..61626d8db4 100644 --- a/src/core/filesystem/path.cpp +++ b/src/core/filesystem/path.cpp @@ -4,15 +4,22 @@ */ #include "core/containers/array.inl" +#include "core/date.h" +#include "core/environment.h" #include "core/filesystem/path.h" +#include "core/math/random.inl" +#include "core/memory/temp_allocator.inl" #include "core/platform.h" #include "core/strings/dynamic_string.inl" #include "core/strings/string_view.inl" -#include // isalpha -#include // strrchr +#include "device/types.h" +#include // isalpha, isalnum +#include // strncmp, strrchr namespace crown { +extern PlatformData g_platform_data; + #if CROWN_PLATFORM_WINDOWS const char PATH_SEPARATOR = '\\'; #else @@ -115,6 +122,96 @@ namespace path array::pop_back(clean._data); } + bool expand(DynamicString &expanded, const char *path_template) + { + const char *ch = path_template; + CE_ENSURE(ch != NULL); + + expanded = ""; + char buf[16]; + + while (*ch) { + if (*ch != '$') { + expanded += *ch; + ++ch; + continue; + } + + ++ch; + if (strncmp(ch, "USER_DATA", 9) == 0 && !isalnum((unsigned char)ch[9])) { + ch += 9; + if (CROWN_PLATFORM_ANDROID) { + const char *path = (const char *)g_platform_data._android_internal_data_path; + if (path == NULL || path[0] == '\0') + return false; + expanded += path; + } else { + TempAllocator256 ta; + DynamicString path(ta); + environment::user_data_dir(path); + if (path.empty()) + return false; + + expanded += path; + } + } else if (CROWN_PLATFORM_ANDROID + && strncmp(ch, "OBB_PATH", 8) == 0 + && !isalnum((unsigned char)ch[8]) + ) { + ch += 8; + const char *path = (const char *)g_platform_data._android_obb_path; + if (path == NULL || path[0] == '\0') + return false; + expanded += path; + } else if (strncmp(ch, "UTC_DATE", 8) == 0 && !isalnum((unsigned char)ch[8])) { + ch += 8; + date::Date d; + date::utc_date(d); + date::to_string(buf, sizeof(buf), d); + expanded += buf; + } else if (strncmp(ch, "UTC_TIME", 8) == 0 && !isalnum((unsigned char)ch[8])) { + ch += 8; + date::Time t; + date::utc_time(t); + date::to_string(buf, sizeof(buf), t); + expanded += buf; + } else if (strncmp(ch, "RANDOM", 6) == 0 && !isalnum((unsigned char)ch[6])) { + ch += 6; + Random random; + const char alphabet[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + for (u32 ii = 0; ii < 8; ++ii) + buf[ii] = alphabet[random.integer(s32(sizeof(alphabet) - 1))]; + buf[8] = '\0'; + expanded += buf; + } else if (strncmp(ch, "DATE", 4) == 0 && !isalnum((unsigned char)ch[4])) { + ch += 4; + date::Date d; + date::date(d); + date::to_string(buf, sizeof(buf), d); + expanded += buf; + } else if (strncmp(ch, "TIME", 4) == 0 && !isalnum((unsigned char)ch[4])) { + ch += 4; + date::Time t; + date::time(t); + date::to_string(buf, sizeof(buf), t); + expanded += buf; + } else if (strncmp(ch, "TMP", 3) == 0 && !isalnum((unsigned char)ch[3])) { + ch += 3; + TempAllocator256 ta; + DynamicString path(ta); + environment::tmp_dir(path); + if (path.empty()) + return false; + + expanded += path; + } else { + return false; + } + } + + return true; + } + StringView parent_dir(const char *path) { const char *ls = strrchr(path, PATH_SEPARATOR); @@ -133,6 +230,21 @@ namespace path return { path, u32(ls - path) }; } + bool is_valid_basename(const char *basename) + { + CE_ENSURE(basename != NULL); + + if (basename[0] == '\0') + return false; + + if (strcmp(basename, ".") == 0 || strcmp(basename, "..") == 0) + return false; + + return strchr(basename, '/') == NULL + && strchr(basename, '\\') == NULL + ; + } + } // namespace path } // namespace crown diff --git a/src/core/filesystem/path.h b/src/core/filesystem/path.h index 61f3d6fb3b..8dc77f5e1d 100644 --- a/src/core/filesystem/path.h +++ b/src/core/filesystem/path.h @@ -49,9 +49,17 @@ namespace path /// Removes unnecessary dots and separators from @a path. void reduce(DynamicString &clean, const char *path); + /// Expands variables in @a path_template into @a expanded. + /// Returns false if @a path_template contains an unknown variable or a + /// variable cannot be expanded. + bool expand(DynamicString &expanded, const char *path_template); + /// Returns the parent directory of @a path. StringView parent_dir(const char *path); + /// Returns whether the @a basename is valid. + bool is_valid_basename(const char *basename); + } // namespace path } // namespace crown diff --git a/src/core/math/random.h b/src/core/math/random.h index 1fbd0b3d82..eb42bc77e3 100644 --- a/src/core/math/random.h +++ b/src/core/math/random.h @@ -16,6 +16,9 @@ struct Random { u32 _seed; + /// Initializes the generator with the current time. + Random(); + /// Initializes the generator with the given @a seed. explicit Random(s32 seed); diff --git a/src/core/math/random.inl b/src/core/math/random.inl index 549b6c0b18..f90ee94f25 100644 --- a/src/core/math/random.inl +++ b/src/core/math/random.inl @@ -4,9 +4,15 @@ */ #include "core/math/random.h" +#include "core/time.h" namespace crown { +inline Random::Random() + : _seed((u32)time::now()) +{ +} + inline Random::Random(s32 seed) : _seed((u32)seed) { diff --git a/src/core/unit_tests.cpp b/src/core/unit_tests.cpp index 77065d5d79..ecadde910a 100644 --- a/src/core/unit_tests.cpp +++ b/src/core/unit_tests.cpp @@ -1779,6 +1779,24 @@ static void test_path() ENSURE(path == "foo/bar"); } #endif + { + TempAllocator128 ta; + DynamicString expanded(ta); + ENSURE(path::expand(expanded, "$DATE_$RANDOM")); + ENSURE(expanded.length() == 19); + } + { + TempAllocator128 ta; + DynamicString expanded(ta); + ENSURE(!path::expand(expanded, "$DATEFOO")); + } + { + ENSURE(path::is_valid_basename("foo")); + ENSURE(path::is_valid_basename("foo.txt")); + ENSURE(!path::is_valid_basename("foo/bar")); + ENSURE(!path::is_valid_basename("foo\\bar")); + ENSURE(!path::is_valid_basename("")); + } } static void test_command_line() @@ -2337,7 +2355,7 @@ static void test_unit_id() static void test_random() { { - Random rnd((s32)time::now()); + Random rnd; for (u32 i = 0; i < 1000; ++i) { s32 a = rnd.integer(); diff --git a/src/device/boot_config.cpp b/src/device/boot_config.cpp index 02666411a7..89e56f1a7d 100644 --- a/src/device/boot_config.cpp +++ b/src/device/boot_config.cpp @@ -22,6 +22,7 @@ BootConfig::BootConfig(Allocator &a) , boot_package_name(u64(0)) , render_config_name(u64(0)) , window_title(a) + , save_dir(a) , window_w(CROWN_DEFAULT_WINDOW_WIDTH) , window_h(CROWN_DEFAULT_WINDOW_HEIGHT) , device_id(0) @@ -80,6 +81,9 @@ bool BootConfig::parse(const char *json) JsonObject platform(ta); sjson::parse(platform, cfg[CROWN_PLATFORM_NAME]); + if (json_object::has(platform, "save_dir")) + sjson::parse_string(save_dir, platform["save_dir"]); + if (json_object::has(platform, "renderer")) { JsonObject renderer(ta); sjson::parse(renderer, platform["renderer"]); diff --git a/src/device/boot_config.h b/src/device/boot_config.h index 2ca19c5a3f..68f0eb478a 100644 --- a/src/device/boot_config.h +++ b/src/device/boot_config.h @@ -21,6 +21,7 @@ struct BootConfig StringId64 boot_package_name; StringId64 render_config_name; DynamicString window_title; + DynamicString save_dir; u16 window_w; u16 window_h; u16 device_id; diff --git a/src/device/device.cpp b/src/device/device.cpp index c1b0f3c356..174a367f6f 100644 --- a/src/device/device.cpp +++ b/src/device/device.cpp @@ -10,6 +10,7 @@ #include "core/filesystem/filesystem.h" #include "core/filesystem/filesystem_apk.h" #include "core/filesystem/filesystem_disk.h" +#include "core/filesystem/path.h" #include "core/json/json_object.inl" #include "core/json/sjson.h" #include "core/list.inl" @@ -37,6 +38,7 @@ #include "device/input_manager.h" #include "device/log.h" #include "device/pipeline.h" +#include "device/save_game.h" #include "lua/lua_environment.h" #include "lua/lua_stack.inl" #include "resource/resource_id.inl" @@ -67,6 +69,8 @@ LOG_SYSTEM(DEVICE, "device") namespace crown { +PlatformData g_platform_data; + extern bool next_event(OsEvent &ev); #define RESOURCE_TYPE(type_name) \ @@ -548,7 +552,7 @@ int Device::main_loop() bool is_bundle = true; #if CROWN_PLATFORM_ANDROID - _data_filesystem = CE_NEW(_allocator, FilesystemApk)(default_allocator(), const_cast((AAssetManager *)_options._asset_manager)); + _data_filesystem = CE_NEW(_allocator, FilesystemApk)(default_allocator(), (AAssetManager *)g_platform_data._android_asset_manager); #else _data_filesystem = CE_NEW(_allocator, FilesystemDisk)(default_allocator()); { @@ -712,6 +716,15 @@ int Device::main_loop() if (!_options._hidden) _window->show(); + { + DynamicString save_dir(default_allocator()); + if (!_boot_config.save_dir.empty() + && !path::expand(save_dir, _boot_config.save_dir.c_str()) + ) + loge(DEVICE, "Unable to expand save_dir: '%s'", _boot_config.save_dir.c_str()); + save_game_globals::init(default_allocator(), save_dir.c_str()); + } + _input_manager = CE_NEW(_allocator, InputManager)(default_allocator()); _unit_manager = CE_NEW(_allocator, UnitManager)(default_allocator()); _lua_environment = CE_NEW(_allocator, LuaEnvironment)(); @@ -771,6 +784,7 @@ int Device::main_loop() _pipeline->destroy(); CE_DELETE(_allocator, _pipeline); CE_DELETE(_allocator, _lua_environment); + save_game_globals::shutdown(); CE_DELETE(_allocator, _unit_manager); CE_DELETE(_allocator, _input_manager); CE_DELETE(_allocator, _resource_manager); diff --git a/src/device/device_options.cpp b/src/device/device_options.cpp index cce3268843..6ac66d7916 100644 --- a/src/device/device_options.cpp +++ b/src/device/device_options.cpp @@ -83,9 +83,6 @@ DeviceOptions::DeviceOptions(Allocator &a, int argc, const char **argv) , _window_y(0) , _window_width(CROWN_DEFAULT_WINDOW_WIDTH) , _window_height(CROWN_DEFAULT_WINDOW_HEIGHT) -#if CROWN_PLATFORM_ANDROID - , _asset_manager(NULL) -#endif { } diff --git a/src/device/device_options.h b/src/device/device_options.h index 82767d6855..4c842b104b 100644 --- a/src/device/device_options.h +++ b/src/device/device_options.h @@ -6,7 +6,6 @@ #pragma once #include "core/option.h" -#include "core/platform.h" #include "core/strings/dynamic_string.h" #include "core/types.h" @@ -40,9 +39,6 @@ struct DeviceOptions u16 _window_y; Option _window_width; Option _window_height; -#if CROWN_PLATFORM_ANDROID - void *_asset_manager; -#endif /// DeviceOptions(Allocator &a, int argc, const char **argv); diff --git a/src/device/main_android.cpp b/src/device/main_android.cpp index dc1e88ab51..83846572d1 100644 --- a/src/device/main_android.cpp +++ b/src/device/main_android.cpp @@ -30,6 +30,8 @@ extern "C" namespace crown { +extern PlatformData g_platform_data; + static KeyboardButton::Enum android_translate_key(s32 keycode) { #ifndef AKEYCODE_SCROLL_LOCK @@ -1072,7 +1074,9 @@ void android_main(struct android_app *app) guid_globals::init(); DeviceOptions opts(default_allocator(), 0, NULL); - opts._asset_manager = app->activity->assetManager; + g_platform_data._android_asset_manager = app->activity->assetManager; + g_platform_data._android_internal_data_path = (void *)app->activity->internalDataPath; + g_platform_data._android_obb_path = (void *)app->activity->obbPath; s_android_device = CE_NEW(default_allocator(), AndroidDevice)(default_allocator()); s_android_device->run(app, opts); diff --git a/src/device/save_game.cpp b/src/device/save_game.cpp new file mode 100644 index 0000000000..4f88480c02 --- /dev/null +++ b/src/device/save_game.cpp @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2012-2026 Daniele Bartolini et al. + * SPDX-License-Identifier: MIT + */ + +#include "core/error/error.inl" +#include "core/filesystem/file.h" +#include "core/filesystem/filesystem_disk.h" +#include "core/filesystem/path.h" +#include "core/memory/globals.h" +#include "core/memory/memory.inl" +#include "core/os.h" +#include "core/platform.h" +#include "core/strings/dynamic_string.inl" +#include "core/thread/condition_variable.h" +#include "core/thread/mutex.h" +#include "core/thread/spsc_queue.inl" +#include "core/thread/thread.h" +#include "device/log.h" +#include "device/save_game.h" +#include +#include +#if CROWN_PLATFORM_EMSCRIPTEN + #include +#endif + +#define CROWN_SAVE_GAME_SIMULATE_SLOW_IO 0 +#define CROWN_SAVE_GAME_SIMULATE_SLOW_IO_MS 150 +#define CROWN_SAVE_GAME_IO_CHUNK_SIZE (64*1024) + +LOG_SYSTEM(SAVE_GAME, "save_game") + +namespace crown +{ +static SaveGame *s_save_game; + +#if CROWN_PLATFORM_EMSCRIPTEN +static void html5_save_game_init(const char *save_dir) +{ + // code-format off + MAIN_THREAD_EM_ASM({ + var saveDir = UTF8ToString($0); + if (saveDir.charAt(0) != "/") + saveDir = "/" + saveDir; + + Module.CrownSaveGame = Module.CrownSaveGame || {}; + var state = Module.CrownSaveGame; + + if (state.mountDir == saveDir && state.ready) + return; + + state.mountDir = saveDir; + state.ready = 0; + state.error = 0; + state.syncing = 0; + state.syncError = 0; + + function mkdirTree(dir) + { + var path = ""; + var parts = dir.split("/"); + for (var ii = 0; ii < parts.length; ++ii) { + if (!parts[ii]) + continue; + path += "/" + parts[ii]; + try { + FS.mkdir(path); + } catch (err) { + } + } + } + + try { + mkdirTree(saveDir); + FS.mount(IDBFS, {}, saveDir); + FS.syncfs(true, function(err) + { + state.error = err ? 1 : 0; + state.ready = 1; + }); + } catch (err) { + console.error(err); + state.error = 1; + state.ready = 1; + } + } + , save_dir + ); + // code-format on +} + +static bool html5_save_game_ready() +{ + // code-format off + return MAIN_THREAD_EM_ASM_INT({ + return Module.CrownSaveGame && Module.CrownSaveGame.ready ? 1 : 0; + }) != 0; + // code-format on +} + +static bool html5_save_game_ok() +{ + // code-format off + return MAIN_THREAD_EM_ASM_INT({ + return Module.CrownSaveGame && !Module.CrownSaveGame.error ? 1 : 0; + }) != 0; + // code-format on +} + +static void html5_save_game_flush() +{ + // code-format off + MAIN_THREAD_EM_ASM({ + var state = Module.CrownSaveGame || (Module.CrownSaveGame = {}); + state.syncing = 1; + state.syncError = 0; + + try { + FS.syncfs(false, function(err) + { + state.syncError = err ? 1 : 0; + state.syncing = 0; + }); + } catch (err) { + console.error(err); + state.syncError = 1; + state.syncing = 0; + } + }); + // code-format on +} + +static bool html5_save_game_syncing() +{ + // code-format off + return MAIN_THREAD_EM_ASM_INT({ + return Module.CrownSaveGame && Module.CrownSaveGame.syncing ? 1 : 0; + }) != 0; + // code-format on +} + +static bool html5_save_game_flush_ok() +{ + // code-format off + return MAIN_THREAD_EM_ASM_INT({ + var s = Module.CrownSaveGame; + return s && !s.error && !s.syncError ? 1 : 0; + }) != 0; + // code-format on +} +#endif // if CROWN_PLATFORM_EMSCRIPTEN + +/// Asynchronous save game service. +/// +/// @ingroup Device +struct SaveGame +{ + struct Request + { + enum Type + { + LOAD, + SAVE + }; + + std::atomic_bool done; + std::atomic_int error; + Type type; + u32 token; + void *data; + u32 size; + std::atomic progress; + char basename[256]; + + Request() + : done(true) + , error(SaveError::SUCCESS) + , type(LOAD) + , token(0) + , data(NULL) + , size(0) + , progress(0) + { + basename[0] = '\0'; + } + + void set_progress(u32 processed, u32 total) + { + if (total == 0) { + progress.store(1.0f); + return; + } + + progress.store(f32(processed) / f32(total)); + } + }; + + Allocator *_allocator; + FilesystemDisk _filesystem; + SPSCQueue _requests_queue; + Thread _thread; + Mutex _mutex; + ConditionVariable _requests_condition; + Request _requests[64]; + u32 _next_token; + std::atomic_bool _exit; + bool _has_save_dir; + + /// + SaveGame(Allocator &a, const char *save_dir) + : _allocator(&a) + , _filesystem(a) + , _requests_queue(a) + , _next_token(0) + , _exit(false) + , _has_save_dir(save_dir != NULL && save_dir[0] != '\0') + { + CE_ENSURE(s_save_game == NULL); + + if (_has_save_dir) { + _filesystem.set_prefix(save_dir); + CreateResult cr = _filesystem.create_directory(""); + if (cr.error == CreateResult::UNKNOWN) + logw(SAVE_GAME, "Unable to create save directory '%s'", save_dir); + } + +#if CROWN_PLATFORM_EMSCRIPTEN + if (_has_save_dir) + html5_save_game_init(save_dir); +#endif + + _thread.start([](void *thiz) { return ((SaveGame *)thiz)->run(); }, this); + } + + /// + ~SaveGame() + { + CE_ENSURE(s_save_game == this); + + _mutex.lock(); + _exit.store(true); + _requests_condition.signal(); + _mutex.unlock(); + _thread.stop(); + + for (u32 ii = 0; ii < countof(_requests); ++ii) { + Request &rr = _requests[ii]; + _allocator->deallocate(rr.data); + rr.data = NULL; + rr.size = 0; + } + } + + /// + SaveGame(const SaveGame &) = delete; + + /// + SaveGame &operator=(const SaveGame &) = delete; + + SaveGame::Request *find_request(u32 save_request) + { + if (save_request == 0) + return NULL; + + for (u32 ii = 0; ii < countof(_requests); ++ii) { + if (_requests[ii].token == save_request) + return &_requests[ii]; + } + + return NULL; + } + + u32 queue_request(SaveGame::Request::Type type + , const char *filename + , const void *data + , u32 size + ) + { + CE_ENSURE(type == SaveGame::Request::LOAD || data != NULL || size == 0); + + for (u32 ii = 0; ii < countof(_requests); ++ii) { + SaveGame::Request &rr = _requests[ii]; + if (rr.token == 0) { + rr.type = type; + rr.token = ++_next_token; + if (rr.token == 0) + rr.token = ++_next_token; + rr.done.store(true); + rr.error.store(SaveError::SUCCESS); + rr.progress.store(0.0f); + rr.basename[0] = '\0'; + _allocator->deallocate(rr.data); + rr.data = NULL; + rr.size = 0; + + if (filename == NULL + || !path::is_valid_basename(filename) + || strlen32(filename) >= sizeof(SaveGame::Request::basename) + ) { + loge(SAVE_GAME, "Invalid savegame filename: '%s'", filename ? filename : ""); + rr.progress.store(1.0f); + rr.error.store(SaveError::INVALID_FILENAME); + return rr.token; + } + + strcpy(rr.basename, filename); + + if (!_has_save_dir) { + rr.progress.store(1.0f); + rr.error.store(SaveError::SAVE_DIR_UNSET); + return rr.token; + } + + if (type == SaveGame::Request::SAVE && data == NULL && size > 0) { + loge(SAVE_GAME, "Invalid savegame data"); + rr.progress.store(1.0f); + rr.error.store(SaveError::UNKNOWN); + return rr.token; + } + + if (size > 0) { + rr.data = _allocator->allocate(size); + memcpy(rr.data, data, size); + rr.size = size; + } + + rr.done.store(false); + + _mutex.lock(); + const bool queued = _requests_queue.push(ii); + if (queued) + _requests_condition.signal(); + _mutex.unlock(); + + if (!queued) { + rr.progress.store(1.0f); + rr.error.store(SaveError::UNKNOWN); + rr.done.store(true); + return rr.token; + } + + return rr.token; + } + } + + return 0; + } + + enum IODir { IO_READ, IO_WRITE }; + + s32 do_chunked_io(File &file, void *buf, u32 size, IODir dir, Request &rr) + { + u32 bytes_done = 0; + const u32 chunk = u32(CROWN_SAVE_GAME_IO_CHUNK_SIZE); + while (bytes_done < size) { + const u32 remaining = size - bytes_done; + const u32 n_req = remaining < chunk ? remaining : chunk; +#if CROWN_SAVE_GAME_SIMULATE_SLOW_IO + os::sleep(CROWN_SAVE_GAME_SIMULATE_SLOW_IO_MS); +#endif + const u32 n = dir == IO_READ + ? file.read((char *)buf + bytes_done, n_req) + : file.write((const char *)buf + bytes_done, n_req); + CE_ENSURE(n <= n_req); + bytes_done += n; + rr.set_progress(bytes_done, size); + if (n != n_req) + break; + } + rr.set_progress(bytes_done, size); + return bytes_done == size ? SaveError::SUCCESS : SaveError::IO_ERROR; + } + + s32 read_request(SaveGame::Request &rr) + { + _allocator->deallocate(rr.data); + rr.data = NULL; + rr.size = 0; + + const Stat st = _filesystem.stat(rr.basename); + if (st.file_type == Stat::NO_ENTRY) + return SaveError::MISSING; + if (st.file_type != Stat::REGULAR) + return SaveError::IO_ERROR; + + File *file = _filesystem.open(rr.basename, FileOpenMode::READ); + CE_ENSURE(file != NULL); + if (!file->is_open()) { + _filesystem.close(*file); + return SaveError::IO_ERROR; + } + + const u32 size = file->size(); + rr.data = _allocator->allocate(size + 1); + rr.size = size; + + const s32 io_result = do_chunked_io(*file, rr.data, size, IO_READ, rr); + _filesystem.close(*file); + if (io_result != SaveError::SUCCESS) + return io_result; + ((char *)rr.data)[rr.size] = '\0'; + + return SaveError::SUCCESS; + } + + s32 write_request(SaveGame::Request &rr) + { + DynamicString temp_filename(default_allocator()); + File *file = _filesystem.open_temporary(temp_filename); + CE_ENSURE(file != NULL); + if (!file->is_open()) { + _filesystem.close(*file); + return SaveError::IO_ERROR; + } + + const u32 size = rr.size; + + const s32 io_result = do_chunked_io(*file, rr.data, size, IO_WRITE, rr); + _filesystem.close(*file); + + if (io_result != SaveError::SUCCESS) { + os::delete_file(temp_filename.c_str()); + return io_result; + } + + DynamicString filename(default_allocator()); + _filesystem.absolute_path(filename, rr.basename); + + const RenameResult res = os::rename(temp_filename.c_str(), filename.c_str()); + if (res.error != RenameResult::SUCCESS) { + os::delete_file(temp_filename.c_str()); + return SaveError::IO_ERROR; + } + + return SaveError::SUCCESS; + } + +#if CROWN_PLATFORM_EMSCRIPTEN + /// Spins until @a pred returns true or exit is requested. + /// Returns true if the predicate was satisfied, false if exit was requested. + bool html5_spin_until(bool (*pred)()) + { + while (!_exit.load() && !pred()) + os::sleep(1); + return !_exit.load(); + } +#endif + + /// + void process_request(Request &rr) + { + CE_ENSURE(!rr.done.load()); + rr.progress.store(0.0f); + rr.error.store(SaveError::SUCCESS); + +#if CROWN_PLATFORM_EMSCRIPTEN + if (!html5_spin_until(html5_save_game_ready)) + return; + + if (!html5_save_game_ok()) { + rr.progress.store(1.0f); + rr.error.store(SaveError::IO_ERROR); + rr.done.store(true); + return; + } +#endif + + s32 result = rr.type == SaveGame::Request::LOAD + ? read_request(rr) + : write_request(rr) + ; + +#if CROWN_PLATFORM_EMSCRIPTEN + if (result == SaveError::SUCCESS && rr.type == SaveGame::Request::SAVE) { + html5_save_game_flush(); + if (!html5_spin_until([]{ return !html5_save_game_syncing(); })) + return; + + if (!html5_save_game_flush_ok()) + result = SaveError::IO_ERROR; + } +#endif + + rr.progress.store(1.0f); + rr.error.store(result); + rr.done.store(true); + } + + /// + s32 run() + { + while (!_exit.load()) { + _mutex.lock(); + while (!_exit.load() && _requests_queue.empty()) + _requests_condition.wait(_mutex); + _mutex.unlock(); + + u32 idx; + while (!_exit.load() && _requests_queue.pop(idx)) { + CE_ENSURE(idx < countof(_requests)); + process_request(_requests[idx]); + } + } + + return 0; + } +}; + +namespace save_game +{ + u32 load(const char *filename) + { + CE_ASSERT(s_save_game != NULL, "SaveGame not initialized!"); + return s_save_game->queue_request(SaveGame::Request::LOAD, filename, NULL, 0); + } + + u32 save(const char *filename, const void *data, u32 size) + { + CE_ASSERT(s_save_game != NULL, "SaveGame not initialized!"); + return s_save_game->queue_request(SaveGame::Request::SAVE, filename, data, size); + } + + SaveStatus status(u32 save_request) + { + CE_ASSERT(s_save_game != NULL, "SaveGame not initialized!"); + SaveStatus st; + st.done = true; + st.progress = 0.0f; + st.data = NULL; + st.size = 0; + st.error = SaveError::INVALID_REQUEST; + + SaveGame::Request *rr = s_save_game->find_request(save_request); + if (rr == NULL) + return st; + + const bool done = rr->done.load(); + const s32 error = done ? rr->error.load() : SaveError::SUCCESS; + const bool load_ok = done && error == SaveError::SUCCESS && rr->type == SaveGame::Request::LOAD; + st.done = done; + st.progress = rr->progress.load(); + st.data = load_ok ? rr->data : NULL; + st.size = load_ok ? rr->size : 0; + st.error = error; + return st; + } + + void free(u32 save_request) + { + CE_ASSERT(s_save_game != NULL, "SaveGame not initialized!"); + SaveGame::Request *rr = s_save_game->find_request(save_request); + if (rr == NULL) + return; + + CE_ASSERT(rr->done.load(), "Cannot free pending SaveGame request"); + + rr->token = 0; + s_save_game->_allocator->deallocate(rr->data); + rr->data = NULL; + rr->size = 0; + rr->progress.store(0.0f); + rr->basename[0] = '\0'; + rr->error.store(SaveError::SUCCESS); + } + +} // namespace save_game + +namespace save_game_globals +{ + void init(Allocator &a, const char *save_dir) + { + CE_ASSERT(s_save_game == NULL, "SaveGame already initialized"); + s_save_game = CE_NEW(a, SaveGame)(a, save_dir); + } + + void shutdown() + { + if (s_save_game == NULL) + return; + + Allocator &a = *s_save_game->_allocator; + CE_DELETE(a, s_save_game); + s_save_game = NULL; + } + + SaveGame *save_game() + { + return s_save_game; + } + +} // namespace save_game_globals + +} // namespace crown diff --git a/src/device/save_game.h b/src/device/save_game.h new file mode 100644 index 0000000000..849f2fdcad --- /dev/null +++ b/src/device/save_game.h @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2012-2026 Daniele Bartolini et al. + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "core/memory/types.h" +#include "core/types.h" + +namespace crown +{ +/// Save request error codes. +/// +/// @ingroup Device +struct SaveError +{ + enum Enum + { + SUCCESS = 0, ///< Operation completed successfully. + INVALID_REQUEST, ///< Token does not identify a live request. + SAVE_DIR_UNSET, ///< save_dir is not configured. + MISSING, ///< Save file does not exist. + INVALID_FILENAME, ///< Filename is invalid. + IO_ERROR, ///< File read/write failed. + CORRUPTED, ///< Save data could not be parsed. + UNKNOWN ///< Request failed for an unknown reason. + }; +}; + +/// Save request status. +/// +/// @ingroup Device +struct SaveStatus +{ + bool done; ///< Request has finished. + f32 progress; ///< Completion progress in [0; 1] range. + void *data; ///< Loaded data, or NULL unless this is a successful load request. + u32 size; ///< Loaded data size in bytes, excluding any internal terminator. + s32 error; ///< SaveError code, SaveError::SUCCESS while pending or when no error occurred. +}; + +struct SaveGame; + +namespace save_game +{ + /// Starts loading @a filename asynchronously and returns its request id. + /// + /// @a filename must be a valid filename, not a path. The function returns + /// before the file has been read. Call status() with the returned id to poll + /// for completion. On success, SaveStatus::data points to the loaded bytes + /// and SaveStatus::size contains their size. + u32 load(const char *filename); + + /// Starts saving @a data to @a filename asynchronously and returns its request id. + /// + /// @a filename must be a valid filename, not a path. @a data is copied before + /// this function returns and may be NULL only when @a size is 0. The function + /// returns before the file has been written. Call status() with the returned + /// id to poll for completion. + u32 save(const char *filename, const void *data, u32 size); + + /// Returns the status of @a save_request. + /// + /// While the request is pending, SaveStatus::error is SaveError::SUCCESS. + /// SaveStatus::data is owned by the request and remains valid until free() + /// is called for @a save_request. + SaveStatus status(u32 save_request); + + /// Frees resources allocated for @a save_request. + /// + /// The request must be done. After this call, @a save_request is invalid. + void free(u32 save_request); + +} // namespace save_game + +namespace save_game_globals +{ + /// Initializes the SaveGame singleton. + void init(Allocator &a, const char *save_dir); + + /// Shuts down the SaveGame singleton. + void shutdown(); + + /// Returns the SaveGame singleton. + SaveGame *save_game(); + +} // namespace save_game_globals + +} // namespace crown diff --git a/src/device/types.h b/src/device/types.h index 83070ea855..c5d51c6d7b 100644 --- a/src/device/types.h +++ b/src/device/types.h @@ -14,6 +14,13 @@ struct Device; struct Pipeline; struct Profiler; +struct PlatformData +{ + void *_android_asset_manager; ///< Android asset manager. + void *_android_internal_data_path; ///< Android internal data path. + void *_android_obb_path; ///< Android OBB path. +}; + struct OsEventType { enum Enum diff --git a/src/lua/lua_api.cpp b/src/lua/lua_api.cpp index 2b64faa4ee..0d5ec617be 100644 --- a/src/lua/lua_api.cpp +++ b/src/lua/lua_api.cpp @@ -4,7 +4,10 @@ */ #include "core/containers/hash_set.inl" +#include "core/containers/array.inl" #include "core/guid.h" +#include "core/json/json_object.inl" +#include "core/json/sjson.h" #include "core/math/color4.inl" #include "core/math/constants.h" #include "core/math/frustum.inl" @@ -17,6 +20,7 @@ #include "core/math/types.h" #include "core/math/vector2.inl" #include "core/math/vector3.inl" +#include "core/memory/memory.inl" #include "core/memory/temp_allocator.inl" #include "core/profiler.h" #include "core/strings/dynamic_string.inl" @@ -26,6 +30,7 @@ #include "device/device.h" #include "device/input_device.h" #include "device/input_manager.h" +#include "device/save_game.h" #include "lua/lua_environment.h" #include "lua/lua_stack.inl" #include "resource/resource_id.inl" @@ -506,6 +511,423 @@ static void lua_dump_table(lua_State *L, int i, StringStream &json) json << "}"; } +static int lua_abs_index(lua_State *L, int i) +{ + return i > 0 || i <= LUA_REGISTRYINDEX + ? i + : lua_gettop(L) + i + 1 + ; +} + +static void lua_save_data_write_indent(StringStream &sjson, u32 depth) +{ + for (u32 ii = 0; ii < depth; ++ii) + sjson << "\t"; +} + +static bool lua_save_data_is_reserved_type(const char *type) +{ + return strcmp(type, "table") == 0 + || strcmp(type, "vector3") == 0 + || strcmp(type, "vector3box") == 0 + || strcmp(type, "matrix4x4") == 0 + || strcmp(type, "matrix4x4box") == 0 + ; +} + +static void lua_save_data_write_string(StringStream &sjson, const char *str, u32 len) +{ + CE_ENSURE(str != NULL); + + sjson << "\""; + for (u32 ii = 0; ii < len; ++ii) { + const char ch = str[ii]; + switch (ch) { + case '"': sjson << "\\\""; break; + case '\\': sjson << "\\\\"; break; + case '\b': sjson << "\\b"; break; + case '\f': sjson << "\\f"; break; + case '\n': sjson << "\\n"; break; + case '\r': sjson << "\\r"; break; + case '\t': sjson << "\\t"; break; + default: + sjson << ch; + break; + } + } + sjson << "\""; +} + +static void lua_save_data_write_string(StringStream &sjson, lua_State *L, int i) +{ + size_t len = 0; + const char *str = lua_tolstring(L, i, &len); + lua_save_data_write_string(sjson, str, (u32)len); +} + +static void lua_save_data_write_key(StringStream &sjson, lua_State *L, int i) +{ + if (lua_type(L, i) != LUA_TSTRING) + luaL_error(L, "Unsupported save data key type"); + + size_t len = 0; + const char *str = lua_tolstring(L, i, &len); + CE_ENSURE(str != NULL); + lua_save_data_write_string(sjson, str, (u32)len); +} + +static void lua_save_data_write_vector3(StringStream &sjson, const char *type, const Vector3 &v) +{ + sjson << "{ type = \"" << type << "\" data = [ "; + sjson << v.x << " " << v.y << " " << v.z; + sjson << " ] }"; +} + +static void lua_save_data_write_matrix4x4(StringStream &sjson, const char *type, const Matrix4x4 &m) +{ + sjson << "{ type = \"" << type << "\" data = [ "; + sjson << m.x.x << " " << m.x.y << " " << m.x.z << " " << m.x.w << " "; + sjson << m.y.x << " " << m.y.y << " " << m.y.z << " " << m.y.w << " "; + sjson << m.z.x << " " << m.z.y << " " << m.z.z << " " << m.z.w << " "; + sjson << m.t.x << " " << m.t.y << " " << m.t.z << " " << m.t.w; + sjson << " ] }"; +} + +static bool lua_save_data_table_collides_with_typed_object(lua_State *L, int i) +{ + i = lua_abs_index(L, i); + + u32 count = 0; + bool has_type = false; + bool has_data = false; + + lua_pushnil(L); + while (lua_next(L, i) != 0) { + ++count; + if (lua_type(L, -2) == LUA_TSTRING) { + const char *key = lua_tostring(L, -2); + has_type = has_type || strcmp(key, "type") == 0; + has_data = has_data || strcmp(key, "data") == 0; + } + lua_pop(L, 1); + } + + if (count != 2 || !has_type || !has_data) + return false; + + lua_getfield(L, i, "type"); + const bool collides = lua_type(L, -1) == LUA_TSTRING + && lua_save_data_is_reserved_type(lua_tostring(L, -1)) + ; + lua_pop(L, 1); + return collides; +} + +static void lua_save_data_write_value(lua_State *L, int i, StringStream &sjson, u32 depth); + +static void lua_save_data_write_table(lua_State *L, int i, StringStream &sjson, u32 depth, bool root, bool force_regular) +{ + i = lua_abs_index(L, i); + const u32 child_depth = root ? depth : depth + 1; + + if (!force_regular && lua_save_data_table_collides_with_typed_object(L, i)) { + if (!root) + sjson << "{\n"; + + lua_save_data_write_indent(sjson, child_depth); + sjson << "type = \"table\"\n"; + lua_save_data_write_indent(sjson, child_depth); + sjson << "data = "; + lua_save_data_write_table(L, i, sjson, child_depth, false, true); + + sjson << "\n"; + if (!root) { + lua_save_data_write_indent(sjson, depth); + sjson << "}"; + } + return; + } + + if (!root) + sjson << "{\n"; + + lua_pushnil(L); + while (lua_next(L, i) != 0) { + lua_save_data_write_indent(sjson, child_depth); + lua_save_data_write_key(sjson, L, -2); + sjson << " = "; + lua_save_data_write_value(L, -1, sjson, child_depth); + sjson << "\n"; + lua_pop(L, 1); + } + + if (!root) { + lua_save_data_write_indent(sjson, depth); + sjson << "}"; + } +} + +static void lua_save_data_write_value(lua_State *L, int i, StringStream &sjson, u32 depth) +{ + LuaStack stack(L); + const int type = lua_type(L, i); + + if (type == LUA_TBOOLEAN) { + sjson << (stack.get_bool(i) ? "true" : "false"); + } else if (type == LUA_TNUMBER) { + sjson << (f64)lua_tonumber(L, i); + } else if (type == LUA_TSTRING) { + lua_save_data_write_string(sjson, L, i); + } else if (type == LUA_TLIGHTUSERDATA && stack.is_vector3(i)) { + lua_save_data_write_vector3(sjson, "vector3", stack.get_vector3(i)); + } else if (type == LUA_TLIGHTUSERDATA && stack.is_matrix4x4(i)) { + lua_save_data_write_matrix4x4(sjson, "matrix4x4", stack.get_matrix4x4(i)); + } else if (stack.has_metatable(i, "Vector3Box")) { + lua_save_data_write_vector3(sjson, "vector3box", stack.get_vector3box(i)); + } else if (stack.has_metatable(i, "Matrix4x4Box")) { + lua_save_data_write_matrix4x4(sjson, "matrix4x4box", stack.get_matrix4x4box(i)); + } else if (type == LUA_TTABLE) { + lua_save_data_write_table(L, i, sjson, depth, false, false); + } else { + luaL_error(L, "Unsupported save data value type"); + } +} + +static void lua_save_data_write_root_table(lua_State *L, int i, StringStream &sjson) +{ + lua_save_data_write_table(L, i, sjson, 0, true, false); +} + +static bool lua_save_data_push_sjson_value(lua_State *L, const char *value, bool &error); +static bool lua_save_data_push_sjson_object(lua_State *L, const char *value, bool &error, bool force_regular); + +static bool lua_save_data_push_sjson_array(lua_State *L, const char *value, bool &error) +{ + const int top = lua_gettop(L); + LuaStack stack(L, INT_MAX); + TempAllocator1024 ta; + JsonArray arr(ta); + sjson::parse_array(arr, value); + if (error) { + lua_settop(L, top); + return false; + } + + stack.push_table(array::size(arr)); + for (u32 ii = 0; ii < array::size(arr); ++ii) { + stack.push_key_begin(ii + 1); + if (!lua_save_data_push_sjson_value(L, arr[ii], error)) { + lua_settop(L, top); + return false; + } + stack.push_key_end(); + } + + return true; +} + +static bool lua_save_data_parse_typed_object(DynamicString &type, const JsonObject &obj, bool &error) +{ + if (json_object::size(obj) != 2 + || !json_object::has(obj, "type") + || !json_object::has(obj, "data") + || sjson::type(obj["type"]) != JsonValueType::STRING + ) { + return false; + } + + sjson::parse_string(type, obj["type"]); + if (error) + return false; + + return lua_save_data_is_reserved_type(type.c_str()); +} + +static bool lua_save_data_push_sjson_object(lua_State *L, const char *value, bool &error, bool force_regular) +{ + const int top = lua_gettop(L); + LuaStack stack(L, INT_MAX); + TempAllocator4096 ta; + JsonObject obj(ta); + sjson::parse(obj, value); + if (error) { + lua_settop(L, top); + return false; + } + + if (!force_regular) { + DynamicString type(ta); + if (lua_save_data_parse_typed_object(type, obj, error)) { + if (type == "table") { + if (!lua_save_data_push_sjson_object(L, obj["data"], error, true)) { + lua_settop(L, top); + return false; + } + return true; + } + + JsonArray arr(ta); + sjson::parse_array(arr, obj["data"]); + if (error) { + lua_settop(L, top); + return false; + } + + if (type == "vector3" || type == "vector3box") { + if (array::size(arr) != 3) { + error = true; + lua_settop(L, top); + return false; + } + + Vector3 v = sjson::parse_vector3(obj["data"]); + if (error) { + lua_settop(L, top); + return false; + } + + if (type == "vector3") + stack.push_vector3(v); + else + stack.push_vector3box(v); + return true; + } else if (type == "matrix4x4" || type == "matrix4x4box") { + if (array::size(arr) != 16) { + error = true; + lua_settop(L, top); + return false; + } + + Matrix4x4 m = sjson::parse_matrix4x4(obj["data"]); + if (error) { + lua_settop(L, top); + return false; + } + + if (type == "matrix4x4") + stack.push_matrix4x4(m); + else + stack.push_matrix4x4box(m); + return true; + } + } + if (error) { + lua_settop(L, top); + return false; + } + } + + stack.push_table(0, json_object::size(obj)); + auto cur = json_object::begin(obj); + auto end = json_object::end(obj); + for (; cur != end; ++cur) { + JSON_OBJECT_SKIP_HOLE(obj, cur); + + lua_pushlstring(L, cur->first.data(), cur->first.length()); + if (!lua_save_data_push_sjson_value(L, cur->second, error)) { + lua_settop(L, top); + return false; + } + lua_settable(L, -3); + } + + return true; +} + +static bool lua_save_data_push_sjson_value(lua_State *L, const char *value, bool &error) +{ + const int top = lua_gettop(L); + LuaStack stack(L, INT_MAX); + + switch (sjson::type(value)) { + case JsonValueType::NIL: + stack.push_nil(); + break; + + case JsonValueType::BOOL: { + const bool val = sjson::parse_bool(value); + if (error) { + lua_settop(L, top); + return false; + } + stack.push_bool(val); + return true; + } + + case JsonValueType::NUMBER: { + const f32 val = sjson::parse_float(value); + if (error) { + lua_settop(L, top); + return false; + } + stack.push_float(val); + return true; + } + + case JsonValueType::STRING: { + TempAllocator1024 ta; + DynamicString str(ta); + sjson::parse_string(str, value); + if (error) { + lua_settop(L, top); + return false; + } + stack.push_lstring(str.c_str(), str.length()); + break; + } + + case JsonValueType::ARRAY: + if (!lua_save_data_push_sjson_array(L, value, error)) { + lua_settop(L, top); + return false; + } + return true; + + case JsonValueType::OBJECT: + if (!lua_save_data_push_sjson_object(L, value, error, false)) { + lua_settop(L, top); + return false; + } + return true; + + default: + stack.push_nil(); + break; + } + + if (error) { + lua_settop(L, top); + return false; + } + return true; +} + +static bool lua_save_data_push_table(lua_State *L, const void *data) +{ + const int top = lua_gettop(L); + CE_ENSURE(data != NULL); + if (data == NULL) + return false; + + bool parse_error = false; + sjson::set_error_callback([](const char *msg, void *user_data) { + CE_UNUSED(msg); + *(bool *)user_data = true; + } + , &parse_error + ); + + const bool parsed = lua_save_data_push_sjson_object(L, (const char *)data, parse_error, false); + sjson::set_error_callback(NULL, NULL); + + if (!parsed || parse_error) { + lua_settop(L, top); + return false; + } + + return true; +} + void load_api(LuaEnvironment &env) { // code-format off @@ -3069,6 +3491,67 @@ void load_api(LuaEnvironment &env) return 0; }); + env.add_module_function("SaveGame", "save", [](lua_State *L) { + LuaStack stack(L, +1); + LUA_ASSERT(stack.is_table(2), stack, "Table expected"); + + StringStream sjson(default_allocator()); + lua_save_data_write_root_table(L, 2, sjson); + + stack.push_id(save_game::save(stack.get_string(1), array::begin(sjson), array::size(sjson))); + return 1; + }); + env.add_module_function("SaveGame", "load", [](lua_State *L) { + LuaStack stack(L, +1); + stack.push_id(save_game::load(stack.get_string(1))); + return 1; + }); + env.add_module_function("SaveGame", "status", [](lua_State *L) { + LuaStack stack(L, +1); + const u32 request = stack.get_id(1); + const SaveStatus st = save_game::status(request); + s32 error = st.done ? st.error : SaveError::SUCCESS; + + stack.push_table(0, 4); + const int status_table = lua_gettop(L); + + stack.push_key_begin("done"); + stack.push_bool(st.done); + stack.push_key_end(); + + stack.push_key_begin("progress"); + stack.push_float(st.progress); + stack.push_key_end(); + + if (st.done && error == SaveError::SUCCESS && st.data != NULL) { + stack.push_key_begin("data"); + if (lua_save_data_push_table(L, st.data)) + stack.push_key_end(); + else { + lua_settop(L, status_table); + error = SaveError::CORRUPTED; + } + } + + if (st.done && error != SaveError::SUCCESS) { + stack.push_key_begin("error"); + stack.push_int(error); + stack.push_key_end(); + } + + return 1; + }); + env.add_module_function("SaveGame", "free", [](lua_State *L) { + LuaStack stack(L); + const u32 request = stack.get_id(1); + const SaveStatus st = save_game::status(request); + if (!st.done) + return 0; + + save_game::free(request); + return 0; + }); + env.add_module_function("Profiler", "enter_scope", [](lua_State *L) { LuaStack stack(L); profiler::enter_profile_scope(stack.get_string(1)); @@ -3523,6 +4006,13 @@ void load_api(LuaEnvironment &env) env.set_module_number("InputEventType", "BUTTON_PRESSED", InputEventType::BUTTON_PRESSED); env.set_module_number("InputEventType", "BUTTON_RELEASED", InputEventType::BUTTON_RELEASED); env.set_module_number("InputEventType", "AXIS_CHANGED", InputEventType::AXIS_CHANGED); + env.set_module_number("SaveError", "INVALID_REQUEST", SaveError::INVALID_REQUEST); + env.set_module_number("SaveError", "SAVE_DIR_UNSET", SaveError::SAVE_DIR_UNSET); + env.set_module_number("SaveError", "MISSING", SaveError::MISSING); + env.set_module_number("SaveError", "INVALID_FILENAME", SaveError::INVALID_FILENAME); + env.set_module_number("SaveError", "IO_ERROR", SaveError::IO_ERROR); + env.set_module_number("SaveError", "CORRUPTED", SaveError::CORRUPTED); + env.set_module_number("SaveError", "UNKNOWN", SaveError::UNKNOWN); // code-format on } diff --git a/src/lua/lua_stack.h b/src/lua/lua_stack.h index 09e91308d3..1a90f51f32 100644 --- a/src/lua/lua_stack.h +++ b/src/lua/lua_stack.h @@ -133,6 +133,9 @@ struct LuaStack /// bool is_table(int i); + /// + bool has_metatable(int i, const char *metatable); + /// bool is_vector3(int i); diff --git a/src/lua/lua_stack.inl b/src/lua/lua_stack.inl index e4e296d138..29c3a61f3f 100644 --- a/src/lua/lua_stack.inl +++ b/src/lua/lua_stack.inl @@ -113,6 +113,17 @@ inline bool LuaStack::is_table(int i) return lua_istable(L, i) == 1; } +inline bool LuaStack::has_metatable(int i, const char *metatable) +{ + if (lua_getmetatable(L, i) == 0) + return false; + + luaL_getmetatable(L, metatable); + const bool equal = lua_rawequal(L, -1, -2) != 0; + lua_pop(L, 2); + return equal; +} + inline int LuaStack::value_type(int i) { return lua_type(L, i);