G2.0: StateStore (JSON state file + atomic write)

This commit is contained in:
2026-08-16 07:08:37 +07:00
parent af9a285974
commit 1fe2d261fd
4 changed files with 371 additions and 0 deletions
+1
View File
@@ -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
+65
View File
@@ -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 <cassert>
#include <cstdio>
#include <string>
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;
}
+44
View File
@@ -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 <cstdint>
#include <string>
#include <vector>
// 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<InstrumentStateEntry> 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
+261
View File
@@ -0,0 +1,261 @@
// native_bridge/src/StateStore.cpp
#include "StateStore.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#ifdef _WIN32
#include <windows.h>
#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<char>(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
}