From 1fe2d261fdde3ea3ffea7f24bf5ea2320d7bffb0 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sun, 16 Aug 2026 07:08:37 +0700 Subject: [PATCH] G2.0: StateStore (JSON state file + atomic write) --- native_bridge/CMakeLists.txt | 1 + native_bridge/debug/test_state_store.cpp | 65 ++++++ native_bridge/include/StateStore.h | 44 ++++ native_bridge/src/StateStore.cpp | 261 +++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 native_bridge/debug/test_state_store.cpp create mode 100644 native_bridge/include/StateStore.h create mode 100644 native_bridge/src/StateStore.cpp diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index 5ece1dc..e49d2ca 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -58,6 +58,7 @@ add_executable(daw_vst_bridge src/NativeInstrumentEngine.cpp src/SharedMemoryIPC.cpp src/Vst3Instrument.cpp + src/StateStore.cpp ) # VST3 SDK hosting: sdk_hosting lacks the platform module loader — module_win32.cpp diff --git a/native_bridge/debug/test_state_store.cpp b/native_bridge/debug/test_state_store.cpp new file mode 100644 index 0000000..dab62c2 --- /dev/null +++ b/native_bridge/debug/test_state_store.cpp @@ -0,0 +1,65 @@ +// native_bridge/debug/test_state_store.cpp +// G2.0 self-check: StateStore save/load/clear round-trip (not part of bridge). +// Build: cl /nologo /EHsc /std:c++17 /I native_bridge/include \ +// debug/test_state_store.cpp native_bridge/src/StateStore.cpp +#include "StateStore.h" + +#include +#include +#include + +int main() { + const char* path = "state_roundtrip_test.json"; + + BridgeStateSnapshot snap; + snap.playing = true; + snap.playhead = 123.456789; + + InstrumentStateEntry a; + a.channel = 0; a.type = 0; // VST3 + a.path = "C:\\Program Files\\Common Files\\VST3\\Nexus.vst3"; + a.presetBase64 = "AB+//w=="; // base64 chars incl + / = + a.bank = 0; a.program = 2; + + InstrumentStateEntry b; + b.channel = 3; b.type = 2; // SF2/SF3 + b.path = "C:\\sounds\\my bank.sf2"; + b.presetBase64 = "line1\nline2\t\"quoted\"\\tail"; + b.bank = 1; b.program = 5; + + snap.instruments.push_back(a); + snap.instruments.push_back(b); + + assert(StateStore::save(snap, path)); + + // Atomic write: no .tmp leftover after save. + FILE* tmp = std::fopen((std::string(path) + ".tmp").c_str(), "rb"); + assert(tmp == nullptr); + if (tmp) std::fclose(tmp); + + BridgeStateSnapshot out; + assert(StateStore::load(out, path)); + assert(out.playing == true); + assert(out.playhead == 123.456789); + assert(out.instruments.size() == 2); + assert(out.instruments[0].channel == 0); + assert(out.instruments[0].type == 0); + assert(out.instruments[0].path == "C:\\Program Files\\Common Files\\VST3\\Nexus.vst3"); + assert(out.instruments[0].presetBase64 == "AB+//w=="); + assert(out.instruments[0].bank == 0 && out.instruments[0].program == 2); + assert(out.instruments[1].channel == 3); + assert(out.instruments[1].type == 2); + assert(out.instruments[1].path == "C:\\sounds\\my bank.sf2"); + assert(out.instruments[1].presetBase64 == "line1\nline2\t\"quoted\"\\tail"); + assert(out.instruments[1].bank == 1 && out.instruments[1].program == 5); + + // Missing file -> load false, no crash. + assert(!StateStore::load(out, "no_such_file.json")); + + // clear removes the file. + assert(StateStore::clear(path)); + assert(!StateStore::load(out, path)); + + std::printf("state_store round-trip PASS\n"); + return 0; +} diff --git a/native_bridge/include/StateStore.h b/native_bridge/include/StateStore.h new file mode 100644 index 0000000..2ccd929 --- /dev/null +++ b/native_bridge/include/StateStore.h @@ -0,0 +1,44 @@ +// native_bridge/include/StateStore.h +// G2: persist bridge instrument state to a JSON file so a bridge respawn +// (crash, kill, restart) can reload instruments and continue playing. +#ifndef STATE_STORE_H +#define STATE_STORE_H + +#include +#include +#include + +// One instrument slot persisted to disk. +struct InstrumentStateEntry { + uint32_t channel = 0; + int type = 0; // InstrumentType as int (0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ) + std::string path; // absolute plugin/soundfont path + std::string presetBase64; // VST3 serialized preset (empty for non-VST) + uint32_t bank = 0; // last bank select (SF2/SF3) + uint32_t program = 0; // last program change +}; + +// Whole-bridge snapshot written to disk. +struct BridgeStateSnapshot { + std::vector instruments; + bool playing = false; + double playhead = 0.0; +}; + +// Hand-rolled JSON persistence (no external JSON dependency). +// File format: {"version":1,"playing":bool,"playhead":num,"instruments":[...]} +// Writes are atomic: temp file + rename (crash mid-write never corrupts state). +class StateStore { +public: + // %APPDATA%/SonicForgeDAW/state/bridge_state.json (UTF-8). + static std::string defaultPath(); + + // Serialize snapshot to `path` (atomic). Returns false on IO error. + static bool save(const BridgeStateSnapshot& snap, const std::string& path); + // Read `path` into `out`; returns false if missing or unparsable. + static bool load(BridgeStateSnapshot& out, const std::string& path); + // Remove the state file (and any stale .tmp). Returns false if missing. + static bool clear(const std::string& path); +}; + +#endif // STATE_STORE_H diff --git a/native_bridge/src/StateStore.cpp b/native_bridge/src/StateStore.cpp new file mode 100644 index 0000000..2f04295 --- /dev/null +++ b/native_bridge/src/StateStore.cpp @@ -0,0 +1,261 @@ +// native_bridge/src/StateStore.cpp +#include "StateStore.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +namespace { + +// Escape a string for JSON output (keeps UTF-8 bytes >= 0x20 as-is). +std::string jsonEscape(const std::string& s) { + std::string out; + out.reserve(s.size() + 16); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out += static_cast(c); + } + } + } + return out; +} + +// Skip whitespace; returns pointer at first non-ws char. +const char* skipWs(const char* p) { + while (p && *p && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) ++p; + return p; +} + +// Find the value after `"key":` starting at `from`. Returns pointer at the +// first value char (after ws), or nullptr. +const char* findValue(const std::string& s, const char* key, size_t from = 0) { + size_t keyLen = std::strlen(key); + size_t pos = s.find(key, from); + while (pos != std::string::npos) { + if (pos > 0 && s[pos - 1] == '"') { + size_t colon = s.find(':', pos + keyLen); + if (colon != std::string::npos) + return skipWs(s.c_str() + colon + 1); + } + pos = s.find(key, pos + 1); + } + return nullptr; +} + +// Parse a quoted JSON string starting at *pp (which must point at '"'). +// Advances *pp past the closing quote. Returns false on malformed input. +bool parseString(const char** pp, std::string& out) { + const char* p = *pp; + if (!p || *p != '"') return false; + ++p; + out.clear(); + while (*p && *p != '"') { + if (*p == '\\') { + ++p; + switch (*p) { + case '"': out += '"'; ++p; break; + case '\\': out += '\\'; ++p; break; + case '/': out += '/'; ++p; break; + case 'n': out += '\n'; ++p; break; + case 'r': out += '\r'; ++p; break; + case 't': out += '\t'; ++p; break; + case 'u': { + unsigned v = 0; + for (int i = 0; i < 4; ++i) { + ++p; + if (!*p) return false; + char c = *p; + v <<= 4; + if (c >= '0' && c <= '9') v |= (unsigned)(c - '0'); + else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10); + else return false; + } + out += (char)(v & 0xFF); + break; + } + default: return false; + } + } else { + out += *p; + ++p; + } + } + if (*p != '"') return false; + *pp = p + 1; + return true; +} + +// Parse an integer until delimiter , } ] ws. Returns false on empty. +bool parseIntAt(const char** pp, long& out) { + const char* p = skipWs(*pp); + if (!p || !*p) return false; + long sign = 1; + if (*p == '-') { sign = -1; ++p; } + if (*p < '0' || *p > '9') return false; + long v = 0; + while (*p >= '0' && *p <= '9') { v = v * 10 + (*p - '0'); ++p; } + out = sign * v; + *pp = p; + return true; +} + +// Parse a double until delimiter , } ] ws. +bool parseDoubleAt(const char** pp, double& out) { + const char* p = skipWs(*pp); + const char* start = p; + while (*p && *p != ',' && *p != '}' && *p != ']' && + *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') ++p; + if (p == start) return false; + out = std::atof(std::string(start, p - start).c_str()); + *pp = p; + return true; +} + +} // namespace + +std::string StateStore::defaultPath() { +#ifdef _WIN32 + const char* appData = std::getenv("APPDATA"); + std::string base = appData && *appData ? appData : "."; + return base + "\\SonicForgeDAW\\state\\bridge_state.json"; +#else + const char* home = std::getenv("HOME"); + std::string base = home && *home ? home : "."; + return base + "/.sonicforge/state/bridge_state.json"; +#endif +} + +bool StateStore::save(const BridgeStateSnapshot& snap, const std::string& path) { + std::string json; + json.reserve(256 + snap.instruments.size() * 192); + json += "{\n"; + json += " \"version\": 1,\n"; + json += " \"playing\": "; + json += snap.playing ? "true" : "false"; + json += ",\n"; + json += " \"playhead\": "; + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.6f", snap.playhead); + json += buf; + json += ",\n"; + json += " \"instruments\": [\n"; + for (size_t i = 0; i < snap.instruments.size(); ++i) { + const InstrumentStateEntry& e = snap.instruments[i]; + json += " {\n"; + json += " \"channel\": " + std::to_string(e.channel) + ",\n"; + json += " \"type\": " + std::to_string(e.type) + ",\n"; + json += " \"path\": \"" + jsonEscape(e.path) + "\",\n"; + json += " \"presetBase64\": \"" + jsonEscape(e.presetBase64) + "\",\n"; + json += " \"bank\": " + std::to_string(e.bank) + ",\n"; + json += " \"program\": " + std::to_string(e.program) + "\n"; + json += " }"; + if (i + 1 < snap.instruments.size()) json += ","; + json += "\n"; + } + json += " ]\n"; + json += "}\n"; + + // Atomic write: temp file in same dir, then rename over the target. + const std::string tmp = path + ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(json.data(), (std::streamsize)json.size()); + f.flush(); + if (!f) return false; + f.close(); + } +#ifdef _WIN32 + if (!MoveFileExA(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { + // Fallback: direct write if rename failed (e.g. AV lock on target). + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(json.data(), (std::streamsize)json.size()); + return (bool)f; + } +#else + if (std::rename(tmp.c_str(), path.c_str()) != 0) return false; +#endif + return true; +} + +bool StateStore::load(BridgeStateSnapshot& out, const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + std::ostringstream ss; + ss << f.rdbuf(); + const std::string json = ss.str(); + if (json.empty()) return false; + + BridgeStateSnapshot snap; + const char* p; + + if ((p = findValue(json, "playing")) != nullptr) { + p = skipWs(p); + if (std::strncmp(p, "true", 4) == 0) snap.playing = true; + else if (std::strncmp(p, "false", 5) == 0) snap.playing = false; + else return false; + } + if ((p = findValue(json, "playhead")) != nullptr) { + double v = 0.0; + if (!parseDoubleAt(&p, v)) return false; + snap.playhead = v; + } + + // Parse the instruments array object by object. + const char* arr = findValue(json, "instruments"); + if (arr && *arr == '[') { + const char* q = arr + 1; + while (*q && *q != ']') { + q = skipWs(q); + if (*q == ',') { ++q; continue; } + if (*q != '{') { if (*q == ']') break; return false; } + // Our writer never nests braces inside an entry. + const char* objEnd = std::strchr(q, '}'); + if (!objEnd) return false; + std::string obj(q, objEnd - q + 1); + q = objEnd + 1; + + InstrumentStateEntry e; + if ((p = findValue(obj, "channel"))) { long v; if (parseIntAt(&p, v)) e.channel = (uint32_t)v; } + if ((p = findValue(obj, "type"))) { long v; if (parseIntAt(&p, v)) e.type = (int)v; } + if ((p = findValue(obj, "path"))) { std::string s; if (parseString(&p, s)) e.path = s; } + if ((p = findValue(obj, "presetBase64"))){ std::string s; if (parseString(&p, s)) e.presetBase64 = s; } + if ((p = findValue(obj, "bank"))) { long v; if (parseIntAt(&p, v)) e.bank = (uint32_t)v; } + if ((p = findValue(obj, "program"))) { long v; if (parseIntAt(&p, v)) e.program = (uint32_t)v; } + snap.instruments.push_back(e); + } + } + out = snap; + return true; +} + +bool StateStore::clear(const std::string& path) { +#ifdef _WIN32 + bool ok = DeleteFileA(path.c_str()) != 0; + DeleteFileA((path + ".tmp").c_str()); + return ok; +#else + bool ok = std::remove(path.c_str()) == 0; + std::remove((path + ".tmp").c_str()); + return ok; +#endif +}